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

master
Mac135135 2 years ago
commit f86ee7d5c2

@ -16,8 +16,8 @@ android {
applicationId 'org.koitharu.kotatsu' applicationId 'org.koitharu.kotatsu'
minSdk = 21 minSdk = 21
targetSdk = 35 targetSdk = 35
versionCode = 657 versionCode = 658
versionName = '7.4' versionName = '7.4.1'
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:a9fc534ea7') { implementation('com.github.KotatsuApp:kotatsu-parsers:3b5a018f8c') {
exclude group: 'org.json', module: 'json' exclude group: 'org.json', module: 'json'
} }

@ -0,0 +1,6 @@
package org.koitharu.kotatsu.core.exceptions
class IncompatiblePluginException(
val name: String?,
cause: Throwable?,
) : RuntimeException(cause)

@ -1,19 +1,12 @@
package org.koitharu.kotatsu.core.parser.external package org.koitharu.kotatsu.core.parser.external
import android.content.ContentResolver import android.content.ContentResolver
import android.database.Cursor
import androidx.collection.ArraySet
import androidx.core.database.getStringOrNull
import androidx.core.net.toUri
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.runInterruptible
import org.koitharu.kotatsu.core.cache.MemoryContentCache import org.koitharu.kotatsu.core.cache.MemoryContentCache
import org.koitharu.kotatsu.core.parser.CachingMangaRepository import org.koitharu.kotatsu.core.parser.CachingMangaRepository
import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.parsers.model.ContentRating import org.koitharu.kotatsu.parsers.model.ContentRating
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.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaListFilter import org.koitharu.kotatsu.parsers.model.MangaListFilter
@ -21,9 +14,6 @@ import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaState 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.find
import org.koitharu.kotatsu.parsers.util.mapNotNullToSet
import org.koitharu.kotatsu.parsers.util.splitTwoParts
import java.util.EnumSet import java.util.EnumSet
import java.util.Locale import java.util.Locale
@ -33,232 +23,58 @@ class ExternalMangaRepository(
cache: MemoryContentCache, cache: MemoryContentCache,
) : CachingMangaRepository(cache) { ) : CachingMangaRepository(cache) {
private val capabilities by lazy { queryCapabilities() } private val contentSource = ExternalPluginContentSource(contentResolver, source)
private val capabilities by lazy {
runCatching {
contentSource.getCapabilities()
}.onFailure {
it.printStackTraceDebug()
}.getOrNull()
}
override val sortOrders: Set<SortOrder> override val sortOrders: Set<SortOrder>
get() = capabilities?.availableSortOrders ?: EnumSet.of(SortOrder.ALPHABETICAL) get() = capabilities?.availableSortOrders ?: EnumSet.of(SortOrder.ALPHABETICAL)
override val states: Set<MangaState> override val states: Set<MangaState>
get() = capabilities?.availableStates.orEmpty() get() = capabilities?.availableStates.orEmpty()
override val contentRatings: Set<ContentRating> override val contentRatings: Set<ContentRating>
get() = capabilities?.availableContentRating.orEmpty() get() = capabilities?.availableContentRating.orEmpty()
override var defaultSortOrder: SortOrder override var defaultSortOrder: SortOrder
get() = capabilities?.defaultSortOrder ?: SortOrder.ALPHABETICAL get() = capabilities?.defaultSortOrder ?: SortOrder.ALPHABETICAL
set(value) = Unit set(value) = Unit
override val isMultipleTagsSupported: Boolean override val isMultipleTagsSupported: Boolean
get() = capabilities?.isMultipleTagsSupported ?: true get() = capabilities?.isMultipleTagsSupported ?: true
override val isTagsExclusionSupported: Boolean override val isTagsExclusionSupported: Boolean
get() = capabilities?.isTagsExclusionSupported ?: false get() = capabilities?.isTagsExclusionSupported ?: false
override val isSearchSupported: Boolean override val isSearchSupported: Boolean
get() = capabilities?.isSearchSupported ?: true get() = capabilities?.isSearchSupported ?: true
override suspend fun getList(offset: Int, filter: MangaListFilter?): List<Manga> = override suspend fun getList(offset: Int, filter: MangaListFilter?): List<Manga> =
runInterruptible(Dispatchers.Default) { runInterruptible(Dispatchers.IO) {
val uri = "content://${source.authority}/manga".toUri().buildUpon() contentSource.getList(offset, filter)
uri.appendQueryParameter("offset", offset.toString())
when (filter) {
is MangaListFilter.Advanced -> {
filter.tags.forEach { uri.appendQueryParameter("tag_include", it.key) }
filter.tagsExclude.forEach { uri.appendQueryParameter("tag_exclude", it.key) }
filter.states.forEach { uri.appendQueryParameter("state", it.name) }
filter.locale?.let { uri.appendQueryParameter("locale", it.language) }
filter.contentRating.forEach { uri.appendQueryParameter("content_rating", it.name) }
}
is MangaListFilter.Search -> {
uri.appendQueryParameter("query", filter.query)
}
null -> Unit
}
contentResolver.query(uri.build(), null, null, null, filter?.sortOrder?.name)?.use { cursor ->
val result = ArrayList<Manga>(cursor.count)
if (cursor.moveToFirst()) {
do {
result += cursor.getManga()
} while (cursor.moveToNext())
}
result
}.orEmpty()
} }
override suspend fun getDetailsImpl(manga: Manga): Manga = coroutineScope { override suspend fun getDetailsImpl(manga: Manga): Manga = runInterruptible(Dispatchers.IO) {
val chapters = async { queryChapters(manga.url) } contentSource.getDetails(manga)
val details = queryDetails(manga.url)
Manga(
id = manga.id,
title = details.title.ifBlank { manga.title },
altTitle = details.altTitle.ifNullOrEmpty { manga.altTitle },
url = details.url.ifEmpty { manga.url },
publicUrl = details.publicUrl.ifEmpty { manga.publicUrl },
rating = maxOf(details.rating, manga.rating),
isNsfw = details.isNsfw,
coverUrl = details.coverUrl.ifEmpty { manga.coverUrl },
tags = details.tags + manga.tags,
state = details.state ?: manga.state,
author = details.author.ifNullOrEmpty { manga.author },
largeCoverUrl = details.largeCoverUrl.ifNullOrEmpty { manga.largeCoverUrl },
description = details.description.ifNullOrEmpty { manga.description },
chapters = chapters.await(),
source = source,
)
} }
override suspend fun getPagesImpl(chapter: MangaChapter): List<MangaPage> = runInterruptible(Dispatchers.Default) { override suspend fun getPagesImpl(chapter: MangaChapter): List<MangaPage> = runInterruptible(Dispatchers.IO) {
val uri = "content://${source.authority}/chapters".toUri() contentSource.getPages(chapter)
.buildUpon()
.appendPath(chapter.url)
.build()
contentResolver.query(uri, null, null, null, null)?.use { cursor ->
val result = ArrayList<MangaPage>(cursor.count)
if (cursor.moveToFirst()) {
do {
result += MangaPage(
id = cursor.getLong(0),
url = cursor.getString(1),
preview = cursor.getStringOrNull(2),
source = source,
)
} while (cursor.moveToNext())
}
result
}.orEmpty()
} }
override suspend fun getPageUrl(page: MangaPage): String = page.url override suspend fun getPageUrl(page: MangaPage): String = page.url // TODO
override suspend fun getTags(): Set<MangaTag> = runInterruptible(Dispatchers.Default) { override suspend fun getTags(): Set<MangaTag> = runInterruptible(Dispatchers.IO) {
val uri = "content://${source.authority}/tags".toUri() contentSource.getTags()
contentResolver.query(uri, null, null, null, null)?.use { cursor ->
val result = ArraySet<MangaTag>(cursor.count)
if (cursor.moveToFirst()) {
do {
result += MangaTag(
key = cursor.getString(0),
title = cursor.getString(1),
source = source,
)
} while (cursor.moveToNext())
}
result
}.orEmpty()
} }
override suspend fun getLocales(): Set<Locale> = emptySet() override suspend fun getLocales(): Set<Locale> = emptySet() // TODO
override suspend fun getRelatedMangaImpl(seed: Manga): List<Manga> = emptyList() // TODO override suspend fun getRelatedMangaImpl(seed: Manga): List<Manga> = emptyList() // TODO
private suspend fun queryDetails(url: String): Manga = runInterruptible(Dispatchers.Default) {
val uri = "content://${source.authority}/manga".toUri()
.buildUpon()
.appendPath(url)
.build()
checkNotNull(
contentResolver.query(uri, null, null, null, null)?.use { cursor ->
cursor.moveToFirst()
cursor.getManga()
},
)
}
private suspend fun queryChapters(url: String): List<MangaChapter>? = runInterruptible(Dispatchers.Default) {
val uri = "content://${source.authority}/manga/chapters".toUri()
.buildUpon()
.appendPath(url)
.build()
contentResolver.query(uri, null, null, null, null)?.use { cursor ->
val result = ArrayList<MangaChapter>(cursor.count)
if (cursor.moveToFirst()) {
do {
result += MangaChapter(
id = cursor.getLong(0),
name = cursor.getString(1),
number = cursor.getFloat(2),
volume = cursor.getInt(3),
url = cursor.getString(4),
scanlator = cursor.getStringOrNull(5),
uploadDate = cursor.getLong(6),
branch = cursor.getStringOrNull(7),
source = source,
)
} while (cursor.moveToNext())
}
result
}
}
private fun Cursor.getManga() = Manga(
id = getLong(0),
title = getString(1),
altTitle = getStringOrNull(2),
url = getString(3),
publicUrl = getString(4),
rating = getFloat(5),
isNsfw = getInt(6) > 1,
coverUrl = getString(7),
tags = getStringOrNull(8)?.split(':')?.mapNotNullToSet {
val parts = it.splitTwoParts('=') ?: return@mapNotNullToSet null
MangaTag(key = parts.first, title = parts.second, source = source)
}.orEmpty(),
state = getStringOrNull(9)?.let { MangaState.entries.find(it) },
author = optString(10),
largeCoverUrl = optString(11),
description = optString(12),
chapters = emptyList(),
source = source,
)
private fun Cursor.optString(columnIndex: Int): String? {
return if (isNull(columnIndex)) {
null
} else {
getString(columnIndex)
}
}
private fun queryCapabilities(): MangaSourceCapabilities? {
val uri = "content://${source.authority}/capabilities".toUri()
return contentResolver.query(uri, null, null, null, null)?.use { cursor ->
if (cursor.moveToFirst()) {
MangaSourceCapabilities(
availableSortOrders = cursor.getStringOrNull(0)
?.split(',')
?.mapNotNullTo(EnumSet.noneOf(SortOrder::class.java)) {
SortOrder.entries.find(it)
}.orEmpty(),
availableStates = cursor.getStringOrNull(1)
?.split(',')
?.mapNotNullTo(EnumSet.noneOf(MangaState::class.java)) {
MangaState.entries.find(it)
}.orEmpty(),
availableContentRating = cursor.getStringOrNull(2)
?.split(',')
?.mapNotNullTo(EnumSet.noneOf(ContentRating::class.java)) {
ContentRating.entries.find(it)
}.orEmpty(),
isMultipleTagsSupported = cursor.getInt(3) > 1,
isTagsExclusionSupported = cursor.getInt(4) > 1,
isSearchSupported = cursor.getInt(5) > 1,
contentType = ContentType.entries.find(cursor.getString(6)) ?: ContentType.OTHER,
defaultSortOrder = cursor.getStringOrNull(7)?.let {
SortOrder.entries.find(it)
} ?: SortOrder.ALPHABETICAL,
sourceLocale = cursor.getStringOrNull(8)?.let { Locale(it) } ?: Locale.ROOT,
)
} else {
null
}
}
}
private class MangaSourceCapabilities(
val availableSortOrders: Set<SortOrder>,
val availableStates: Set<MangaState>,
val availableContentRating: Set<ContentRating>,
val isMultipleTagsSupported: Boolean,
val isTagsExclusionSupported: Boolean,
val isSearchSupported: Boolean,
val contentType: ContentType,
val defaultSortOrder: SortOrder,
val sourceLocale: Locale,
)
} }

@ -0,0 +1,291 @@
package org.koitharu.kotatsu.core.parser.external
import android.content.ContentResolver
import android.database.Cursor
import androidx.annotation.WorkerThread
import androidx.collection.ArraySet
import androidx.core.net.toUri
import org.jetbrains.annotations.Blocking
import org.koitharu.kotatsu.core.exceptions.IncompatiblePluginException
import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty
import org.koitharu.kotatsu.core.util.ext.toLocale
import org.koitharu.kotatsu.parsers.model.ContentRating
import org.koitharu.kotatsu.parsers.model.ContentType
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.util.find
import org.koitharu.kotatsu.parsers.util.mapNotNullToSet
import org.koitharu.kotatsu.parsers.util.splitTwoParts
import java.util.EnumSet
import java.util.Locale
class ExternalPluginContentSource(
private val contentResolver: ContentResolver,
private val source: ExternalMangaSource,
) {
@Blocking
@WorkerThread
fun getList(offset: Int, filter: MangaListFilter?): List<Manga> = runCatchingCompatibility {
val uri = "content://${source.authority}/manga".toUri().buildUpon()
uri.appendQueryParameter("offset", offset.toString())
when (filter) {
is MangaListFilter.Advanced -> {
filter.tags.forEach { uri.appendQueryParameter("tag_include", it.key) }
filter.tagsExclude.forEach { uri.appendQueryParameter("tag_exclude", it.key) }
filter.states.forEach { uri.appendQueryParameter("state", it.name) }
filter.locale?.let { uri.appendQueryParameter("locale", it.language) }
filter.contentRating.forEach { uri.appendQueryParameter("content_rating", it.name) }
}
is MangaListFilter.Search -> {
uri.appendQueryParameter("query", filter.query)
}
null -> Unit
}
contentResolver.query(uri.build(), null, null, null, filter?.sortOrder?.name)
.safe()
.use { cursor ->
val result = ArrayList<Manga>(cursor.count)
if (cursor.moveToFirst()) {
do {
result += cursor.getManga()
} while (cursor.moveToNext())
}
result
}
}
@Blocking
@WorkerThread
fun getDetails(manga: Manga) = runCatchingCompatibility {
val chapters = queryChapters(manga.url)
val details = queryDetails(manga.url)
Manga(
id = manga.id,
title = details.title.ifBlank { manga.title },
altTitle = details.altTitle.ifNullOrEmpty { manga.altTitle },
url = details.url.ifEmpty { manga.url },
publicUrl = details.publicUrl.ifEmpty { manga.publicUrl },
rating = maxOf(details.rating, manga.rating),
isNsfw = details.isNsfw,
coverUrl = details.coverUrl.ifEmpty { manga.coverUrl },
tags = details.tags + manga.tags,
state = details.state ?: manga.state,
author = details.author.ifNullOrEmpty { manga.author },
largeCoverUrl = details.largeCoverUrl.ifNullOrEmpty { manga.largeCoverUrl },
description = details.description.ifNullOrEmpty { manga.description },
chapters = chapters,
source = source,
)
}
@Blocking
@WorkerThread
fun getPages(chapter: MangaChapter): List<MangaPage> = runCatchingCompatibility {
val uri = "content://${source.authority}/chapters".toUri()
.buildUpon()
.appendPath(chapter.url)
.build()
contentResolver.query(uri, null, null, null, null)
.safe()
.use { cursor ->
val result = ArrayList<MangaPage>(cursor.count)
if (cursor.moveToFirst()) {
do {
result += MangaPage(
id = cursor.getLong(COLUMN_ID),
url = cursor.getString(COLUMN_URL),
preview = cursor.getStringOrNull(COLUMN_PREVIEW),
source = source,
)
} while (cursor.moveToNext())
}
result
}
}
@Blocking
@WorkerThread
fun getTags(): Set<MangaTag> = runCatchingCompatibility {
val uri = "content://${source.authority}/tags".toUri()
contentResolver.query(uri, null, null, null, null)
.safe()
.use { cursor ->
val result = ArraySet<MangaTag>(cursor.count)
if (cursor.moveToFirst()) {
do {
result += MangaTag(
key = cursor.getString(COLUMN_KEY),
title = cursor.getString(COLUMN_TITLE),
source = source,
)
} while (cursor.moveToNext())
}
result
}
}
fun getCapabilities(): MangaSourceCapabilities? {
val uri = "content://${source.authority}/capabilities".toUri()
return contentResolver.query(uri, null, null, null, null)
.safe()
.use { cursor ->
if (cursor.moveToFirst()) {
MangaSourceCapabilities(
availableSortOrders = cursor.getStringOrNull(COLUMN_SORT_ORDERS)
?.split(',')
?.mapNotNullTo(EnumSet.noneOf(SortOrder::class.java)) {
SortOrder.entries.find(it)
}.orEmpty(),
availableStates = cursor.getStringOrNull(COLUMN_STATES)
?.split(',')
?.mapNotNullTo(EnumSet.noneOf(MangaState::class.java)) {
MangaState.entries.find(it)
}.orEmpty(),
availableContentRating = cursor.getStringOrNull(COLUMN_CONTENT_RATING)
?.split(',')
?.mapNotNullTo(EnumSet.noneOf(ContentRating::class.java)) {
ContentRating.entries.find(it)
}.orEmpty(),
isMultipleTagsSupported = cursor.getBooleanOrDefault(COLUMN_MULTIPLE_TAGS_SUPPORTED, true),
isTagsExclusionSupported = cursor.getBooleanOrDefault(COLUMN_TAGS_EXCLUSION_SUPPORTED, false),
isSearchSupported = cursor.getBooleanOrDefault(COLUMN_SEARCH_SUPPORTED, true),
contentType = cursor.getStringOrNull(COLUMN_CONTENT_TYPE)?.let {
ContentType.entries.find(it)
} ?: ContentType.OTHER,
defaultSortOrder = cursor.getStringOrNull(COLUMN_DEFAULT_SORT_ORDER)?.let {
SortOrder.entries.find(it)
} ?: SortOrder.ALPHABETICAL,
sourceLocale = cursor.getStringOrNull(COLUMN_LOCALE)?.toLocale() ?: Locale.ROOT,
)
} else {
null
}
}
}
private fun queryDetails(url: String): Manga {
val uri = "content://${source.authority}/manga".toUri()
.buildUpon()
.appendPath(url)
.build()
return contentResolver.query(uri, null, null, null, null)
.safe()
.use { cursor ->
cursor.moveToFirst()
cursor.getManga()
}
}
private fun queryChapters(url: String): List<MangaChapter> {
val uri = "content://${source.authority}/manga/chapters".toUri()
.buildUpon()
.appendPath(url)
.build()
return contentResolver.query(uri, null, null, null, null)
.safe()
.use { cursor ->
val result = ArrayList<MangaChapter>(cursor.count)
if (cursor.moveToFirst()) {
do {
result += MangaChapter(
id = cursor.getLong(COLUMN_ID),
name = cursor.getString(COLUMN_NAME),
number = cursor.getFloatOrDefault(COLUMN_NUMBER, 0f),
volume = cursor.getIntOrDefault(COLUMN_VOLUME, 0),
url = cursor.getString(COLUMN_URL),
scanlator = cursor.getStringOrNull(COLUMN_SCANLATOR),
uploadDate = cursor.getLongOrDefault(COLUMN_UPLOAD_DATE, 0L),
branch = cursor.getStringOrNull(COLUMN_BRANCH),
source = source,
)
} while (cursor.moveToNext())
}
result
}
}
private fun SafeCursor.getManga() = Manga(
id = getLong(COLUMN_ID),
title = getString(COLUMN_TITLE),
altTitle = getStringOrNull(COLUMN_ALT_TITLE),
url = getString(COLUMN_URL),
publicUrl = getString(COLUMN_PUBLIC_URL),
rating = getFloat(COLUMN_RATING),
isNsfw = getBooleanOrDefault(COLUMN_IS_NSFW, false),
coverUrl = getString(COLUMN_COVER_URL),
tags = getStringOrNull(COLUMN_TAGS)?.split(':')?.mapNotNullToSet {
val parts = it.splitTwoParts('=') ?: return@mapNotNullToSet null
MangaTag(key = parts.first, title = parts.second, source = source)
}.orEmpty(),
state = getStringOrNull(COLUMN_STATE)?.let { MangaState.entries.find(it) },
author = getStringOrNull(COLUMN_AUTHOR),
largeCoverUrl = getStringOrNull(COLUMN_LARGE_COVER_URL),
description = getStringOrNull(COLUMN_DESCRIPTION),
chapters = emptyList(),
source = source,
)
private inline fun <R> runCatchingCompatibility(block: () -> R): R = try {
block()
} catch (e: NoSuchElementException) { // unknown column name
throw IncompatiblePluginException(source.name, e)
} catch (e: IllegalArgumentException) {
throw IncompatiblePluginException(source.name, e)
}
private fun Cursor?.safe() = SafeCursor(this ?: throw IncompatiblePluginException(source.name, null))
class MangaSourceCapabilities(
val availableSortOrders: Set<SortOrder>,
val availableStates: Set<MangaState>,
val availableContentRating: Set<ContentRating>,
val isMultipleTagsSupported: Boolean,
val isTagsExclusionSupported: Boolean,
val isSearchSupported: Boolean,
val contentType: ContentType,
val defaultSortOrder: SortOrder,
val sourceLocale: Locale,
)
private companion object {
const val COLUMN_SORT_ORDERS = "sort_orders"
const val COLUMN_STATES = "states"
const val COLUMN_CONTENT_RATING = "content_rating"
const val COLUMN_MULTIPLE_TAGS_SUPPORTED = "multiple_tags_supported"
const val COLUMN_TAGS_EXCLUSION_SUPPORTED = "tags_exclusion_supported"
const val COLUMN_SEARCH_SUPPORTED = "search_supported"
const val COLUMN_CONTENT_TYPE = "content_type"
const val COLUMN_DEFAULT_SORT_ORDER = "default_sort_order"
const val COLUMN_LOCALE = "locale"
const val COLUMN_ID = "id"
const val COLUMN_NAME = "name"
const val COLUMN_NUMBER = "number"
const val COLUMN_VOLUME = "volume"
const val COLUMN_URL = "url"
const val COLUMN_SCANLATOR = "scanlator"
const val COLUMN_UPLOAD_DATE = "upload_date"
const val COLUMN_BRANCH = "branch"
const val COLUMN_TITLE = "title"
const val COLUMN_ALT_TITLE = "alt_title"
const val COLUMN_PUBLIC_URL = "public_url"
const val COLUMN_RATING = "rating"
const val COLUMN_IS_NSFW = "is_nsfw"
const val COLUMN_COVER_URL = "cover_url"
const val COLUMN_TAGS = "tags"
const val COLUMN_STATE = "state"
const val COLUMN_AUTHOR = "author"
const val COLUMN_LARGE_COVER_URL = "large_cover_url"
const val COLUMN_DESCRIPTION = "description"
const val COLUMN_PREVIEW = "preview"
const val COLUMN_KEY = "key"
}
}

@ -0,0 +1,73 @@
package org.koitharu.kotatsu.core.parser.external
import android.database.Cursor
import android.database.CursorWrapper
import org.koitharu.kotatsu.core.util.ext.getBoolean
class SafeCursor(cursor: Cursor) : CursorWrapper(cursor) {
fun getString(columnName: String): String {
return getString(getColumnIndexOrThrow(columnName))
}
fun getStringOrNull(columnName: String): String? {
val columnIndex = getColumnIndex(columnName)
return when {
columnIndex < 0 -> null
isNull(columnIndex) -> null
else -> getString(columnIndex)
}
}
fun getBoolean(columnName: String): Boolean {
return getBoolean(getColumnIndexOrThrow(columnName))
}
fun getBooleanOrDefault(columnName: String, defaultValue: Boolean): Boolean {
val columnIndex = getColumnIndex(columnName)
return when {
columnIndex < 0 -> defaultValue
isNull(columnIndex) -> defaultValue
else -> getBoolean(columnIndex)
}
}
fun getInt(columnName: String): Int {
return getInt(getColumnIndexOrThrow(columnName))
}
fun getIntOrDefault(columnName: String, defaultValue: Int): Int {
val columnIndex = getColumnIndex(columnName)
return when {
columnIndex < 0 -> defaultValue
isNull(columnIndex) -> defaultValue
else -> getInt(columnIndex)
}
}
fun getLong(columnName: String): Long {
return getLong(getColumnIndexOrThrow(columnName))
}
fun getLongOrDefault(columnName: String, defaultValue: Long): Long {
val columnIndex = getColumnIndex(columnName)
return when {
columnIndex < 0 -> defaultValue
isNull(columnIndex) -> defaultValue
else -> getLong(columnIndex)
}
}
fun getFloat(columnName: String): Float {
return getFloat(getColumnIndexOrThrow(columnName))
}
fun getFloatOrDefault(columnName: String, defaultValue: Float): Float {
val columnIndex = getColumnIndex(columnName)
return when {
columnIndex < 0 -> defaultValue
isNull(columnIndex) -> defaultValue
else -> getFloat(columnIndex)
}
}
}

@ -5,6 +5,7 @@ import android.util.AttributeSet
import android.view.View.OnClickListener import android.view.View.OnClickListener
import androidx.annotation.ColorRes import androidx.annotation.ColorRes
import androidx.annotation.DrawableRes import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.core.view.children import androidx.core.view.children
import com.google.android.material.chip.Chip import com.google.android.material.chip.Chip
import com.google.android.material.chip.ChipDrawable import com.google.android.material.chip.ChipDrawable
@ -92,7 +93,11 @@ class ChipsView @JvmOverloads constructor(
} }
private fun bindChip(chip: Chip, model: ChipModel) { private fun bindChip(chip: Chip, model: ChipModel) {
chip.text = model.title if (model.titleResId == 0) {
chip.text = model.title
} else {
chip.setText(model.titleResId)
}
chip.isClickable = onChipClickListener != null || model.isCheckable chip.isClickable = onChipClickListener != null || model.isCheckable
chip.isCheckable = model.isCheckable chip.isCheckable = model.isCheckable
if (model.icon == 0) { if (model.icon == 0) {
@ -139,7 +144,8 @@ class ChipsView @JvmOverloads constructor(
} }
data class ChipModel( data class ChipModel(
val title: CharSequence, val title: CharSequence? = null,
@StringRes val titleResId: Int = 0,
@DrawableRes val icon: Int = 0, @DrawableRes val icon: Int = 0,
val isCheckable: Boolean = false, val isCheckable: Boolean = false,
@ColorRes val tint: Int = 0, @ColorRes val tint: Int = 0,

@ -37,3 +37,5 @@ fun JSONObject.toContentValues(): ContentValues {
} }
private fun String.escapeName() = "`$this`" private fun String.escapeName() = "`$this`"
fun Cursor.getBoolean(columnIndex: Int) = getInt(columnIndex) > 0

@ -15,6 +15,7 @@ import org.koitharu.kotatsu.core.exceptions.CaughtException
import org.koitharu.kotatsu.core.exceptions.CloudFlareBlockedException import org.koitharu.kotatsu.core.exceptions.CloudFlareBlockedException
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
import org.koitharu.kotatsu.core.exceptions.EmptyHistoryException import org.koitharu.kotatsu.core.exceptions.EmptyHistoryException
import org.koitharu.kotatsu.core.exceptions.IncompatiblePluginException
import org.koitharu.kotatsu.core.exceptions.NoDataReceivedException import org.koitharu.kotatsu.core.exceptions.NoDataReceivedException
import org.koitharu.kotatsu.core.exceptions.SyncApiException import org.koitharu.kotatsu.core.exceptions.SyncApiException
import org.koitharu.kotatsu.core.exceptions.TooManyRequestExceptions import org.koitharu.kotatsu.core.exceptions.TooManyRequestExceptions
@ -60,7 +61,7 @@ fun Throwable.getDisplayMessage(resources: Resources): String = when (this) {
-> resources.getString(R.string.network_error) -> resources.getString(R.string.network_error)
is NoDataReceivedException -> resources.getString(R.string.error_no_data_received) is NoDataReceivedException -> resources.getString(R.string.error_no_data_received)
is IncompatiblePluginException -> resources.getString(R.string.plugin_incompatible)
is WrongPasswordException -> resources.getString(R.string.wrong_password) is WrongPasswordException -> resources.getString(R.string.wrong_password)
is NotFoundException -> resources.getString(R.string.not_found_404) is NotFoundException -> resources.getString(R.string.not_found_404)
is UnsupportedSourceException -> resources.getString(R.string.unsupported_source) is UnsupportedSourceException -> resources.getString(R.string.unsupported_source)

@ -10,6 +10,7 @@ import androidx.sqlite.db.SimpleSQLiteQuery
import androidx.sqlite.db.SupportSQLiteQuery import androidx.sqlite.db.SupportSQLiteQuery
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import org.koitharu.kotatsu.core.db.entity.TagEntity import org.koitharu.kotatsu.core.db.entity.TagEntity
import org.koitharu.kotatsu.list.domain.ListFilterOption
import org.koitharu.kotatsu.list.domain.ListSortOrder import org.koitharu.kotatsu.list.domain.ListSortOrder
@Dao @Dao
@ -27,7 +28,11 @@ abstract class HistoryDao {
@Query("SELECT * FROM history WHERE deleted_at = 0 ORDER BY updated_at DESC LIMIT :limit") @Query("SELECT * FROM history WHERE deleted_at = 0 ORDER BY updated_at DESC LIMIT :limit")
abstract fun observeAll(limit: Int): Flow<List<HistoryWithManga>> abstract fun observeAll(limit: Int): Flow<List<HistoryWithManga>>
fun observeAll(order: ListSortOrder, limit: Int): Flow<List<HistoryWithManga>> { fun observeAll(
order: ListSortOrder,
filterOptions: Set<ListFilterOption>,
limit: Int
): Flow<List<HistoryWithManga>> {
val orderBy = when (order) { val orderBy = when (order) {
ListSortOrder.LAST_READ -> "history.updated_at DESC" ListSortOrder.LAST_READ -> "history.updated_at DESC"
ListSortOrder.LONG_AGO_READ -> "history.updated_at ASC" ListSortOrder.LONG_AGO_READ -> "history.updated_at ASC"
@ -44,8 +49,13 @@ abstract class HistoryDao {
val query = buildString { val query = buildString {
append( append(
"SELECT * FROM history LEFT JOIN manga ON history.manga_id = manga.manga_id " + "SELECT * FROM history LEFT JOIN manga ON history.manga_id = manga.manga_id " +
"WHERE history.deleted_at = 0 GROUP BY history.manga_id ORDER BY ", "WHERE history.deleted_at = 0",
) )
for (option in filterOptions) {
append(" AND ")
append(option.getCondition())
}
append(" GROUP BY history.manga_id ORDER BY ")
append(orderBy) append(orderBy)
if (limit > 0) { if (limit > 0) {
append(" LIMIT ") append(" LIMIT ")
@ -147,4 +157,11 @@ abstract class HistoryDao {
@Transaction @Transaction
@RawQuery(observedEntities = [HistoryEntity::class]) @RawQuery(observedEntities = [HistoryEntity::class])
protected abstract fun observeAllImpl(query: SupportSQLiteQuery): Flow<List<HistoryWithManga>> protected abstract fun observeAllImpl(query: SupportSQLiteQuery): Flow<List<HistoryWithManga>>
private fun ListFilterOption.getCondition(): String = when (this) {
ListFilterOption.NEW_CHAPTERS -> "(SELECT chapters_new FROM tracks WHERE tracks.manga_id = history.manga_id) > 0"
ListFilterOption.FAVORITE -> "EXISTS(SELECT * FROM favourites WHERE history.manga_id = favourites.manga_id)"
ListFilterOption.COMPLETED -> "percent >= 0.9999"
ListFilterOption.DOWNLOADED -> throw IllegalArgumentException("Unsupported option $this")
}
} }

@ -23,6 +23,7 @@ import org.koitharu.kotatsu.core.prefs.ProgressIndicatorMode
import org.koitharu.kotatsu.core.ui.util.ReversibleHandle import org.koitharu.kotatsu.core.ui.util.ReversibleHandle
import org.koitharu.kotatsu.core.util.ext.mapItems import org.koitharu.kotatsu.core.util.ext.mapItems
import org.koitharu.kotatsu.history.domain.model.MangaWithHistory import org.koitharu.kotatsu.history.domain.model.MangaWithHistory
import org.koitharu.kotatsu.list.domain.ListFilterOption
import org.koitharu.kotatsu.list.domain.ListSortOrder import org.koitharu.kotatsu.list.domain.ListSortOrder
import org.koitharu.kotatsu.list.domain.ReadingProgress import org.koitharu.kotatsu.list.domain.ReadingProgress
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
@ -76,8 +77,12 @@ class HistoryRepository @Inject constructor(
} }
} }
fun observeAllWithHistory(order: ListSortOrder, limit: Int): Flow<List<MangaWithHistory>> { fun observeAllWithHistory(
return db.getHistoryDao().observeAll(order, limit).mapItems { order: ListSortOrder,
filterOptions: Set<ListFilterOption>,
limit: Int
): Flow<List<MangaWithHistory>> {
return db.getHistoryDao().observeAll(order, filterOptions, limit).mapItems {
MangaWithHistory( MangaWithHistory(
it.manga.toManga(it.tags.toMangaTags()), it.manga.toManga(it.tags.toMangaTags()),
it.history.toMangaHistory(), it.history.toMangaHistory(),

@ -16,6 +16,7 @@ import org.koitharu.kotatsu.core.ui.util.MenuInvalidator
import org.koitharu.kotatsu.core.util.ext.addMenuProvider import org.koitharu.kotatsu.core.util.ext.addMenuProvider
import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.databinding.FragmentListBinding import org.koitharu.kotatsu.databinding.FragmentListBinding
import org.koitharu.kotatsu.list.domain.ListFilterOption
import org.koitharu.kotatsu.list.ui.MangaListFragment import org.koitharu.kotatsu.list.ui.MangaListFragment
import org.koitharu.kotatsu.list.ui.size.DynamicItemSizeResolver import org.koitharu.kotatsu.list.ui.size.DynamicItemSizeResolver
@ -34,6 +35,10 @@ class HistoryListFragment : MangaListFragment() {
override fun onScrolledToEnd() = viewModel.requestMoreItems() override fun onScrolledToEnd() = viewModel.requestMoreItems()
override fun onFilterOptionClick(option: ListFilterOption) {
viewModel.onFilterOptionClick(option)
}
override fun onEmptyActionClick() { override fun onEmptyActionClick() {
startActivity(NetworkManageIntent()) startActivity(NetworkManageIntent())
} }

@ -21,13 +21,16 @@ import org.koitharu.kotatsu.core.prefs.ListMode
import org.koitharu.kotatsu.core.prefs.observeAsFlow import org.koitharu.kotatsu.core.prefs.observeAsFlow
import org.koitharu.kotatsu.core.prefs.observeAsStateFlow import org.koitharu.kotatsu.core.prefs.observeAsStateFlow
import org.koitharu.kotatsu.core.ui.util.ReversibleAction import org.koitharu.kotatsu.core.ui.util.ReversibleAction
import org.koitharu.kotatsu.core.ui.widgets.ChipsView
import org.koitharu.kotatsu.core.util.ext.calculateTimeAgo import org.koitharu.kotatsu.core.util.ext.calculateTimeAgo
import org.koitharu.kotatsu.core.util.ext.call import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.core.util.ext.combine
import org.koitharu.kotatsu.core.util.ext.onFirst import org.koitharu.kotatsu.core.util.ext.onFirst
import org.koitharu.kotatsu.download.ui.worker.DownloadWorker import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
import org.koitharu.kotatsu.history.data.HistoryRepository import org.koitharu.kotatsu.history.data.HistoryRepository
import org.koitharu.kotatsu.history.domain.MarkAsReadUseCase import org.koitharu.kotatsu.history.domain.MarkAsReadUseCase
import org.koitharu.kotatsu.history.domain.model.MangaWithHistory import org.koitharu.kotatsu.history.domain.model.MangaWithHistory
import org.koitharu.kotatsu.list.domain.ListFilterOption
import org.koitharu.kotatsu.list.domain.ListSortOrder import org.koitharu.kotatsu.list.domain.ListSortOrder
import org.koitharu.kotatsu.list.domain.MangaListMapper import org.koitharu.kotatsu.list.domain.MangaListMapper
import org.koitharu.kotatsu.list.ui.MangaListViewModel import org.koitharu.kotatsu.list.ui.MangaListViewModel
@ -36,11 +39,13 @@ import org.koitharu.kotatsu.list.ui.model.EmptyState
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.LoadingState import org.koitharu.kotatsu.list.ui.model.LoadingState
import org.koitharu.kotatsu.list.ui.model.QuickFilter
import org.koitharu.kotatsu.list.ui.model.TipModel import org.koitharu.kotatsu.list.ui.model.TipModel
import org.koitharu.kotatsu.list.ui.model.toErrorState import org.koitharu.kotatsu.list.ui.model.toErrorState
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 java.time.Instant import java.time.Instant
import java.util.EnumSet
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
import javax.inject.Inject import javax.inject.Inject
@ -63,6 +68,8 @@ class HistoryListViewModel @Inject constructor(
valueProducer = { historySortOrder }, valueProducer = { historySortOrder },
) )
private val filterOptions = MutableStateFlow<Set<ListFilterOption>>(EnumSet.noneOf(ListFilterOption::class.java))
override val listMode = settings.observeAsStateFlow( override val listMode = settings.observeAsStateFlow(
scope = viewModelScope + Dispatchers.Default, scope = viewModelScope + Dispatchers.Default,
key = AppSettings.KEY_LIST_MODE_HISTORY, key = AppSettings.KEY_LIST_MODE_HISTORY,
@ -86,25 +93,25 @@ class HistoryListViewModel @Inject constructor(
) )
override val content = combine( override val content = combine(
filterOptions,
observeHistory(), observeHistory(),
isGroupingEnabled, isGroupingEnabled,
observeListModeWithTriggers(), observeListModeWithTriggers(),
networkState, networkState,
settings.observeAsFlow(AppSettings.KEY_INCOGNITO_MODE) { isIncognitoModeEnabled }, settings.observeAsFlow(AppSettings.KEY_INCOGNITO_MODE) { isIncognitoModeEnabled },
) { list, grouped, mode, online, incognito -> ) { filters, list, grouped, mode, online, incognito ->
when { when {
list.isEmpty() -> listOf( list.isEmpty() -> {
EmptyState( if (filters.isEmpty()) {
icon = R.drawable.ic_empty_history, listOf(getEmptyState(hasFilters = false))
textPrimary = R.string.text_history_holder_primary, } else {
textSecondary = R.string.text_history_holder_secondary, listOf(filterItem(filters), getEmptyState(hasFilters = true))
actionStringRes = 0, }
), }
)
else -> { else -> {
isReady.set(true) isReady.set(true)
mapList(list, grouped, mode, online, incognito) mapList(filters, list, grouped, mode, online, incognito)
} }
} }
}.onStart { }.onStart {
@ -154,17 +161,29 @@ class HistoryListViewModel @Inject constructor(
} }
} }
private fun observeHistory() = combine(sortOrder, limit, ::Pair) fun onFilterOptionClick(option: ListFilterOption) {
.flatMapLatest { repository.observeAllWithHistory(it.first, it.second) } filterOptions.value = EnumSet.copyOf(filterOptions.value).also {
if (option in it) {
it.remove(option)
} else {
it.add(option)
}
}
}
private fun observeHistory() = combine(sortOrder, filterOptions, limit, ::Triple)
.flatMapLatest { repository.observeAllWithHistory(it.first, it.second - ListFilterOption.DOWNLOADED, it.third) }
private suspend fun mapList( private suspend fun mapList(
filters: Set<ListFilterOption>,
list: List<MangaWithHistory>, list: List<MangaWithHistory>,
grouped: Boolean, grouped: Boolean,
mode: ListMode, mode: ListMode,
isOnline: Boolean, isOnline: Boolean,
isIncognito: Boolean, isIncognito: Boolean,
): List<ListModel> { ): List<ListModel> {
val result = ArrayList<ListModel>(if (grouped) (list.size * 1.4).toInt() else list.size + 2) val result = ArrayList<ListModel>((if (grouped) (list.size * 1.4).toInt() else list.size) + 3)
result += filterItem(filters)
if (isIncognito) { if (isIncognito) {
result += TipModel( result += TipModel(
key = AppSettings.KEY_INCOGNITO_MODE, key = AppSettings.KEY_INCOGNITO_MODE,
@ -185,12 +204,14 @@ class HistoryListViewModel @Inject constructor(
actionStringRes = R.string.manage, actionStringRes = R.string.manage,
) )
} }
var isEmpty = true
for ((m, history) in list) { for ((m, history) in list) {
val manga = if (!isOnline && !m.isLocal) { val manga = if ((!isOnline && !m.isLocal) || ListFilterOption.DOWNLOADED in filters) {
localMangaRepository.findSavedManga(m)?.manga ?: continue localMangaRepository.findSavedManga(m)?.manga ?: continue
} else { } else {
m m
} }
isEmpty = false
if (grouped) { if (grouped) {
val header = history.header(order) val header = history.header(order)
if (header != prevHeader) { if (header != prevHeader) {
@ -202,6 +223,9 @@ class HistoryListViewModel @Inject constructor(
} }
result += mangaListMapper.toListModel(manga, mode) result += mangaListMapper.toListModel(manga, mode)
} }
if (filters.isNotEmpty() && isEmpty) {
result += getEmptyState(hasFilters = true)
}
return result return result
} }
@ -229,4 +253,32 @@ class HistoryListViewModel @Inject constructor(
ListSortOrder.UPDATED, ListSortOrder.UPDATED,
ListSortOrder.RATING -> null ListSortOrder.RATING -> null
} }
private fun filterItem(selected: Set<ListFilterOption>) = QuickFilter(
items = ListFilterOption.HISTORY.map { option ->
ChipsView.ChipModel(
titleResId = option.titleResId,
icon = option.iconResId,
isCheckable = true,
isChecked = option in selected,
data = option,
)
},
)
private fun getEmptyState(hasFilters: Boolean) = if (hasFilters) {
EmptyState(
icon = R.drawable.ic_empty_history,
textPrimary = R.string.nothing_found,
textSecondary = R.string.text_history_holder_secondary_filtered,
actionStringRes = 0,
)
} else {
EmptyState(
icon = R.drawable.ic_empty_history,
textPrimary = R.string.text_history_holder_primary,
textSecondary = R.string.text_history_holder_secondary,
actionStringRes = 0,
)
}
} }

@ -0,0 +1,28 @@
package org.koitharu.kotatsu.list.domain
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import org.koitharu.kotatsu.R
import java.util.EnumSet
enum class ListFilterOption(
@StringRes val titleResId: Int,
@DrawableRes val iconResId: Int,
) {
DOWNLOADED(R.string.on_device, R.drawable.ic_storage),
COMPLETED(R.string.status_completed, R.drawable.ic_state_finished),
NEW_CHAPTERS(R.string.new_chapters, R.drawable.ic_updated),
FAVORITE(R.string.favourites, R.drawable.ic_heart_outline),
;
companion object {
val HISTORY: Set<ListFilterOption> = EnumSet.of(
DOWNLOADED,
NEW_CHAPTERS,
FAVORITE,
COMPLETED,
)
}
}

@ -31,5 +31,7 @@ data class ReadingProgress(
CHAPTERS_LEFT -> totalChapters > 0 && percent in 0f..1f CHAPTERS_LEFT -> totalChapters > 0 && percent in 0f..1f
} }
fun isComplete() = percent >= 1f - Math.ulp(percent)
fun isReversed() = mode == PERCENT_LEFT || mode == CHAPTERS_LEFT fun isReversed() = mode == PERCENT_LEFT || mode == CHAPTERS_LEFT
} }

@ -44,6 +44,7 @@ import org.koitharu.kotatsu.databinding.FragmentListBinding
import org.koitharu.kotatsu.details.ui.DetailsActivity import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.download.ui.worker.DownloadStartedObserver import org.koitharu.kotatsu.download.ui.worker.DownloadStartedObserver
import org.koitharu.kotatsu.favourites.ui.categories.select.FavoriteSheet import org.koitharu.kotatsu.favourites.ui.categories.select.FavoriteSheet
import org.koitharu.kotatsu.list.domain.ListFilterOption
import org.koitharu.kotatsu.list.ui.adapter.ListItemType import org.koitharu.kotatsu.list.ui.adapter.ListItemType
import org.koitharu.kotatsu.list.ui.adapter.MangaListAdapter import org.koitharu.kotatsu.list.ui.adapter.MangaListAdapter
import org.koitharu.kotatsu.list.ui.adapter.MangaListListener import org.koitharu.kotatsu.list.ui.adapter.MangaListListener
@ -226,6 +227,8 @@ abstract class MangaListFragment :
} }
} }
override fun onFilterOptionClick(option: ListFilterOption) = Unit
override fun onFilterClick(view: View?) = Unit override fun onFilterClick(view: View?) = Unit
override fun onEmptyActionClick() = Unit override fun onEmptyActionClick() = Unit

@ -2,6 +2,7 @@ package org.koitharu.kotatsu.list.ui.adapter
enum class ListItemType { enum class ListItemType {
FILTER_HEADER,
FILTER_SORT, FILTER_SORT,
FILTER_TAG, FILTER_TAG,
FILTER_TAG_MULTI, FILTER_TAG_MULTI,

@ -24,6 +24,7 @@ open class MangaListAdapter(
addDelegate(ListItemType.STATE_EMPTY, emptyStateListAD(coil, lifecycleOwner, listener)) addDelegate(ListItemType.STATE_EMPTY, emptyStateListAD(coil, lifecycleOwner, listener))
addDelegate(ListItemType.HINT_EMPTY, emptyHintAD(coil, lifecycleOwner, listener)) addDelegate(ListItemType.HINT_EMPTY, emptyHintAD(coil, lifecycleOwner, listener))
addDelegate(ListItemType.HEADER, listHeaderAD(listener)) addDelegate(ListItemType.HEADER, listHeaderAD(listener))
addDelegate(ListItemType.FILTER_HEADER, quickFilterAD(listener))
addDelegate(ListItemType.TIP, tipAD(listener)) addDelegate(ListItemType.TIP, tipAD(listener))
} }
} }

@ -52,7 +52,7 @@ fun mangaListDetailedItemAD(
source(item.source) source(item.source)
enqueueWith(coil) enqueueWith(coil)
} }
binding.textViewTags.text = item.tags.joinToString(separator = ", ") { it.title } binding.textViewTags.text = item.tags.joinToString(separator = ", ") { it.title ?: "" }
badge = itemView.bindBadge(badge, item.counter) badge = itemView.bindBadge(badge, item.counter)
} }
} }

@ -5,7 +5,7 @@ import org.koitharu.kotatsu.core.ui.widgets.TipView
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
interface MangaListListener : MangaDetailsClickListener, ListStateHolderListener, ListHeaderClickListener, interface MangaListListener : MangaDetailsClickListener, ListStateHolderListener, ListHeaderClickListener,
TipView.OnButtonClickListener { TipView.OnButtonClickListener, QuickFilterClickListener {
fun onUpdateFilter(tags: Set<MangaTag>) fun onUpdateFilter(tags: Set<MangaTag>)

@ -0,0 +1,25 @@
package org.koitharu.kotatsu.list.ui.adapter
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.core.ui.widgets.ChipsView
import org.koitharu.kotatsu.databinding.ItemQuickFilterBinding
import org.koitharu.kotatsu.list.domain.ListFilterOption
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.QuickFilter
fun quickFilterAD(
listener: QuickFilterClickListener,
) = adapterDelegateViewBinding<QuickFilter, ListModel, ItemQuickFilterBinding>(
{ layoutInflater, parent -> ItemQuickFilterBinding.inflate(layoutInflater, parent, false) }
) {
binding.chipsTags.onChipClickListener = ChipsView.OnChipClickListener { chip, data ->
if (data is ListFilterOption) {
listener.onFilterOptionClick(data)
}
}
bind {
binding.chipsTags.setChips(item.items)
}
}

@ -0,0 +1,8 @@
package org.koitharu.kotatsu.list.ui.adapter
import org.koitharu.kotatsu.list.domain.ListFilterOption
interface QuickFilterClickListener {
fun onFilterOptionClick(option: ListFilterOption)
}

@ -32,6 +32,7 @@ class TypedListSpacingDecoration(
ListItemType.FILTER_TAG_MULTI, ListItemType.FILTER_TAG_MULTI,
ListItemType.FILTER_STATE, ListItemType.FILTER_STATE,
ListItemType.FILTER_LANGUAGE, ListItemType.FILTER_LANGUAGE,
ListItemType.FILTER_HEADER,
-> outRect.set(0) -> outRect.set(0)
ListItemType.HEADER, ListItemType.HEADER,

@ -0,0 +1,13 @@
package org.koitharu.kotatsu.list.ui.model
import org.koitharu.kotatsu.core.ui.widgets.ChipsView
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
data class QuickFilter(
val items: List<ChipsView.ChipModel>,
) : ListModel {
override fun areItemsTheSame(other: ListModel): Boolean = other is QuickFilter
override fun getChangePayload(previousState: ListModel) = ListModelDiffCallback.PAYLOAD_NESTED_LIST_CHANGED
}

@ -17,6 +17,7 @@ import androidx.biometric.BiometricManager.BIOMETRIC_SUCCESS
import androidx.biometric.BiometricPrompt import androidx.biometric.BiometricPrompt
import androidx.biometric.BiometricPrompt.AuthenticationCallback import androidx.biometric.BiometricPrompt.AuthenticationCallback
import androidx.core.graphics.Insets import androidx.core.graphics.Insets
import com.google.android.material.textfield.TextInputLayout
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.BaseActivity import org.koitharu.kotatsu.core.ui.BaseActivity
@ -25,6 +26,7 @@ import org.koitharu.kotatsu.core.util.ext.getParcelableExtraCompat
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.ActivityProtectBinding import org.koitharu.kotatsu.databinding.ActivityProtectBinding
import com.google.android.material.R as materialR
@AndroidEntryPoint @AndroidEntryPoint
class ProtectActivity : class ProtectActivity :
@ -34,6 +36,7 @@ class ProtectActivity :
View.OnClickListener { View.OnClickListener {
private val viewModel by viewModels<ProtectViewModel>() private val viewModel by viewModels<ProtectViewModel>()
private var canUseBiometric = false
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -61,7 +64,9 @@ class ProtectActivity :
override fun onStart() { override fun onStart() {
super.onStart() super.onStart()
if (!useFingerprint()) { canUseBiometric = useFingerprint()
updateEndIcon()
if (!canUseBiometric) {
viewBinding.editPassword.requestFocus() viewBinding.editPassword.requestFocus()
} }
} }
@ -80,6 +85,7 @@ class ProtectActivity :
when (v.id) { when (v.id) {
R.id.button_next -> viewModel.tryUnlock(viewBinding.editPassword.text?.toString().orEmpty()) R.id.button_next -> viewModel.tryUnlock(viewBinding.editPassword.text?.toString().orEmpty())
R.id.button_cancel -> finish() R.id.button_cancel -> finish()
materialR.id.text_input_end_icon -> useFingerprint()
} }
} }
@ -99,6 +105,7 @@ class ProtectActivity :
override fun afterTextChanged(s: Editable?) { override fun afterTextChanged(s: Editable?) {
viewBinding.layoutPassword.error = null viewBinding.layoutPassword.error = null
viewBinding.buttonNext.isEnabled = !s.isNullOrEmpty() viewBinding.buttonNext.isEnabled = !s.isNullOrEmpty()
updateEndIcon()
} }
private fun onError(e: Throwable) { private fun onError(e: Throwable) {
@ -127,6 +134,24 @@ class ProtectActivity :
return true return true
} }
private fun updateEndIcon() = with(viewBinding.layoutPassword) {
val isFingerprintIcon = canUseBiometric && viewBinding.editPassword.text.isNullOrEmpty()
if (isFingerprintIcon == (endIconMode == TextInputLayout.END_ICON_CUSTOM)) {
return@with
}
if (isFingerprintIcon) {
endIconMode = TextInputLayout.END_ICON_CUSTOM
setEndIconDrawable(androidx.biometric.R.drawable.fingerprint_dialog_fp_icon)
endIconContentDescription = getString(androidx.biometric.R.string.use_biometric_label)
setEndIconOnClickListener(this@ProtectActivity)
} else {
setEndIconOnClickListener(null)
setEndIconDrawable(0)
endIconContentDescription = null
endIconMode = TextInputLayout.END_ICON_PASSWORD_TOGGLE
}
}
private inner class BiometricCallback : AuthenticationCallback() { private inner class BiometricCallback : AuthenticationCallback() {
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
super.onAuthenticationSucceeded(result) super.onAuthenticationSucceeded(result)

@ -73,7 +73,7 @@ private const val PREFETCH_LIMIT = 10
class ReaderViewModel class ReaderViewModel
@Inject @Inject
constructor( constructor(
savedStateHandle: SavedStateHandle, private val savedStateHandle: SavedStateHandle,
private val dataRepository: MangaDataRepository, private val dataRepository: MangaDataRepository,
private val historyRepository: HistoryRepository, private val historyRepository: HistoryRepository,
private val bookmarksRepository: BookmarksRepository, private val bookmarksRepository: BookmarksRepository,
@ -223,6 +223,7 @@ constructor(
fun saveCurrentState(state: ReaderState? = null) { fun saveCurrentState(state: ReaderState? = null) {
if (state != null) { if (state != null) {
currentState.value = state currentState.value = state
savedStateHandle[ReaderActivity.EXTRA_STATE] = state
} }
if (incognitoMode.value) { if (incognitoMode.value) {
return return

@ -13,28 +13,23 @@ import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.reader.ui.ReaderState import org.koitharu.kotatsu.reader.ui.ReaderState
import org.koitharu.kotatsu.reader.ui.ReaderViewModel import org.koitharu.kotatsu.reader.ui.ReaderViewModel
private const val KEY_STATE = "state"
abstract class BaseReaderFragment<B : ViewBinding> : BaseFragment<B>(), ZoomControl.ZoomControlListener { abstract class BaseReaderFragment<B : ViewBinding> : BaseFragment<B>(), ZoomControl.ZoomControlListener {
protected val viewModel by activityViewModels<ReaderViewModel>() protected val viewModel by activityViewModels<ReaderViewModel>()
private var stateToSave: ReaderState? = null
protected var readerAdapter: BaseReaderAdapter<*>? = null protected var readerAdapter: BaseReaderAdapter<*>? = null
private set private set
override fun onViewBindingCreated(binding: B, savedInstanceState: Bundle?) { override fun onViewBindingCreated(binding: B, savedInstanceState: Bundle?) {
super.onViewBindingCreated(binding, savedInstanceState) super.onViewBindingCreated(binding, savedInstanceState)
var restoredState = savedInstanceState?.getParcelableCompat<ReaderState>(KEY_STATE)
readerAdapter = onCreateAdapter() readerAdapter = onCreateAdapter()
viewModel.content.observe(viewLifecycleOwner) { viewModel.content.observe(viewLifecycleOwner) {
var pendingState = restoredState ?: it.state if (it.state == null && it.pages.isNotEmpty() && readerAdapter?.hasItems != true) {
if (pendingState == null && it.pages.isNotEmpty() && readerAdapter?.hasItems != true) { onPagesChanged(it.pages, viewModel.getCurrentState())
pendingState = viewModel.getCurrentState() } else {
onPagesChanged(it.pages, it.state)
} }
onPagesChanged(it.pages, pendingState)
restoredState = null
} }
} }
@ -44,19 +39,11 @@ abstract class BaseReaderFragment<B : ViewBinding> : BaseFragment<B>(), ZoomCont
} }
override fun onDestroyView() { override fun onDestroyView() {
stateToSave = getCurrentState() viewModel.saveCurrentState(getCurrentState())
readerAdapter = null readerAdapter = null
super.onDestroyView() super.onDestroyView()
} }
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
getCurrentState()?.let {
stateToSave = it
}
outState.putParcelable(KEY_STATE, stateToSave)
}
protected fun requireAdapter() = checkNotNull(readerAdapter) { protected fun requireAdapter() = checkNotNull(readerAdapter) {
"Adapter was not created or already destroyed" "Adapter was not created or already destroyed"
} }

@ -27,6 +27,7 @@ import org.koitharu.kotatsu.databinding.ActivitySearchMultiBinding
import org.koitharu.kotatsu.details.ui.DetailsActivity import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.download.ui.worker.DownloadStartedObserver import org.koitharu.kotatsu.download.ui.worker.DownloadStartedObserver
import org.koitharu.kotatsu.favourites.ui.categories.select.FavoriteSheet import org.koitharu.kotatsu.favourites.ui.categories.select.FavoriteSheet
import org.koitharu.kotatsu.list.domain.ListFilterOption
import org.koitharu.kotatsu.list.ui.MangaSelectionDecoration import org.koitharu.kotatsu.list.ui.MangaSelectionDecoration
import org.koitharu.kotatsu.list.ui.adapter.MangaListListener import org.koitharu.kotatsu.list.ui.adapter.MangaListListener
import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
@ -133,6 +134,8 @@ class MultiSearchActivity :
viewModel.retry() viewModel.retry()
} }
override fun onFilterOptionClick(option: ListFilterOption) = Unit
override fun onUpdateFilter(tags: Set<MangaTag>) = Unit override fun onUpdateFilter(tags: Set<MangaTag>) = Unit
override fun onFilterClick(view: View?) = Unit override fun onFilterClick(view: View?) = Unit

@ -24,6 +24,7 @@ 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.FragmentFeedBinding import org.koitharu.kotatsu.databinding.FragmentFeedBinding
import org.koitharu.kotatsu.details.ui.DetailsActivity import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.list.domain.ListFilterOption
import org.koitharu.kotatsu.list.ui.adapter.MangaListListener import org.koitharu.kotatsu.list.ui.adapter.MangaListListener
import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
import org.koitharu.kotatsu.list.ui.model.ListHeader import org.koitharu.kotatsu.list.ui.model.ListHeader
@ -94,6 +95,8 @@ class FeedFragment :
viewModel.update() viewModel.update()
} }
override fun onFilterOptionClick(option: ListFilterOption) = Unit
override fun onRetryClick(error: Throwable) = Unit override fun onRetryClick(error: Throwable) = Unit
override fun onUpdateFilter(tags: Set<MangaTag>) = Unit override fun onUpdateFilter(tags: Set<MangaTag>) = Unit

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<HorizontalScrollView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/scrollView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:clipToPadding="false"
android:paddingHorizontal="@dimen/list_spacing"
android:scrollbars="none">
<org.koitharu.kotatsu.core.ui.widgets.ChipsView
android:id="@+id/chips_tags"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:clipChildren="false"
android:clipToPadding="false"
android:paddingVertical="2dp"
app:chipStyle="@style/Widget.Kotatsu.Chip.Filter"
app:selectionRequired="false"
app:singleLine="true"
app:singleSelection="false" />
</HorizontalScrollView>

@ -383,7 +383,7 @@
<string name="sync_auth_hint">يمكنك تسجيل الدخول إلى حساب موجود أصلا أو إنشاء حساب جديد</string> <string name="sync_auth_hint">يمكنك تسجيل الدخول إلى حساب موجود أصلا أو إنشاء حساب جديد</string>
<string name="paused">متوقف مؤقتاً</string> <string name="paused">متوقف مؤقتاً</string>
<string name="downloads_wifi_only">التحميل عبر شبكة الوايفاي فقط</string> <string name="downloads_wifi_only">التحميل عبر شبكة الوايفاي فقط</string>
<string name="suggestions_notifications_summary">إظهار الإشعارات أحيانًا بالمانغا المقترحة</string> <string name="suggestions_notifications_summary">إظهار الإشعارات أحيانًا بالمانجا المقترحة</string>
<string name="mirror_switching_summary">اللغة العربية</string> <string name="mirror_switching_summary">اللغة العربية</string>
<string name="suggestions_enable_prompt">‌‌‍‎‎‍هل ترغب في تلقي اقتراحات المانجا الشخصية؟</string> <string name="suggestions_enable_prompt">‌‌‍‎‎‍هل ترغب في تلقي اقتراحات المانجا الشخصية؟</string>
<string name="images_proxy_title">وكيل تحسين الصور</string> <string name="images_proxy_title">وكيل تحسين الصور</string>
@ -515,7 +515,7 @@
<string name="speed_value">س%.1f</string> <string name="speed_value">س%.1f</string>
<string name="skip">تخطى</string> <string name="skip">تخطى</string>
<string name="grayscale">تدرج الرمادي</string> <string name="grayscale">تدرج الرمادي</string>
<string name="globally">عالماً</string> <string name="globally">عالمياً</string>
<string name="this_manga">هذه المانجا</string> <string name="this_manga">هذه المانجا</string>
<string name="color_correction_apply_text">يمكن تطبيق هذه الإعدادات عالمياً أو على المانجا الحالية فقط. إذا تم تطبيقه عالمياً، فلن يتم تجاوز الإعدادات الفردية.</string> <string name="color_correction_apply_text">يمكن تطبيق هذه الإعدادات عالمياً أو على المانجا الحالية فقط. إذا تم تطبيقه عالمياً، فلن يتم تجاوز الإعدادات الفردية.</string>
<string name="apply">طَبِق</string> <string name="apply">طَبِق</string>
@ -602,7 +602,7 @@
<string name="show_updated">عرض التحديثات</string> <string name="show_updated">عرض التحديثات</string>
<string name="rating_suggestive">موحية</string> <string name="rating_suggestive">موحية</string>
<string name="reader_actions_summary">تهيئة الإجراءات لمناطق الشاشة القابلة للنقر عليها</string> <string name="reader_actions_summary">تهيئة الإجراءات لمناطق الشاشة القابلة للنقر عليها</string>
<string name="ask_for_dest_dir_every_time">اطلب دليل الوجهة في كل مرة</string> <string name="ask_for_dest_dir_every_time">اطلب وجهة المجلد في كل مرة</string>
<string name="default_page_save_dir">مجلد حفظ الصفحة الافتراضية</string> <string name="default_page_save_dir">مجلد حفظ الصفحة الافتراضية</string>
<string name="preferred_download_format">تنسيق التحميل المُفضل</string> <string name="preferred_download_format">تنسيق التحميل المُفضل</string>
<string name="automatic">تلقائي</string> <string name="automatic">تلقائي</string>

@ -653,4 +653,9 @@
<string name="recent_sources">Нядаўнія крыніцы</string> <string name="recent_sources">Нядаўнія крыніцы</string>
<string name="sources_pinned">Крыніцы замацаваны</string> <string name="sources_pinned">Крыніцы замацаваны</string>
<string name="crop_pages">Абрэзаць старонкі</string> <string name="crop_pages">Абрэзаць старонкі</string>
<string name="percent_read">Працэнт прачытанага</string>
<string name="percent_left">Астатні працэнт</string>
<string name="chapters_read">Прачытаныя раздзелы</string>
<string name="chapters_left">Астатнія раздзелы</string>
<string name="external_source">Знешні/плагін</string>
</resources> </resources>

@ -658,4 +658,5 @@
<string name="chapters_read">Capítulos leídos</string> <string name="chapters_read">Capítulos leídos</string>
<string name="chapters_left">Capítulos restantes</string> <string name="chapters_left">Capítulos restantes</string>
<string name="external_source">Externo/plugin</string> <string name="external_source">Externo/plugin</string>
<string name="plugin_incompatible">Complemento incompatible o error interno. Asegúrate de estar usando la última versión del complemento y de Kotatsu</string>
</resources> </resources>

@ -653,4 +653,10 @@
<string name="source_pinned">स्रोत पिन किया गया</string> <string name="source_pinned">स्रोत पिन किया गया</string>
<string name="source_unpinned">स्रोत अनपिन किया गया</string> <string name="source_unpinned">स्रोत अनपिन किया गया</string>
<string name="recent_sources">हालिया स्रोत</string> <string name="recent_sources">हालिया स्रोत</string>
<string name="percent_read">प्रतिशत पढ़ा</string>
<string name="percent_left">प्रतिशत शेष</string>
<string name="chapters_read">अध्याय पढ़ा</string>
<string name="external_source">बाहरी/प्लगइन</string>
<string name="chapters_left">अध्याय शेष</string>
<string name="plugin_incompatible">असंगत प्लगइन या आंतरिक त्रुटि। सुनिश्चित करें कि आप प्लगइन और कोटात्सु के नवीनतम संस्करण का उपयोग कर रहे हैं</string>
</resources> </resources>

@ -644,4 +644,18 @@
<string name="crop_pages">Cortar páginas</string> <string name="crop_pages">Cortar páginas</string>
<string name="disable_nsfw_notifications">Desativar notificações NSFW</string> <string name="disable_nsfw_notifications">Desativar notificações NSFW</string>
<string name="disable_nsfw_notifications_summary">Não mostrar notificações sobre atualizações de mangás NSFW</string> <string name="disable_nsfw_notifications_summary">Não mostrar notificações sobre atualizações de mangás NSFW</string>
<string name="percent_read">Porcentagem lido</string>
<string name="percent_left">Porcentagem restante</string>
<string name="chapters_read">Capítulos lidos</string>
<string name="chapters_left">Capítulos restantes</string>
<string name="pin">Pin</string>
<string name="unpin">Unpin</string>
<string name="source_pinned">Fonte destacada</string>
<string name="source_unpinned">Fonte não destacada</string>
<string name="sources_pinned">Fontes destacadas</string>
<string name="recent_sources">Fontes recentes</string>
<string name="external_source">Plugin/Externo</string>
<string name="sources_unpinned">Fontes não destacadas</string>
<string name="tracker_debug_info">Checando por novos logs de capítulos</string>
<string name="tracker_debug_info_summary">Informações de Debug sobre a checagem de fundo para novos capítulos</string>
</resources> </resources>

@ -657,4 +657,5 @@
<string name="chapters_read">Глав прочитано</string> <string name="chapters_read">Глав прочитано</string>
<string name="percent_left">Процент оставшегося</string> <string name="percent_left">Процент оставшегося</string>
<string name="chapters_left">Глав осталось</string> <string name="chapters_left">Глав осталось</string>
<string name="external_source">Внешний/плагин</string>
</resources> </resources>

@ -2,7 +2,7 @@
<resources xmlns:tools="http://schemas.android.com/tools"> <resources xmlns:tools="http://schemas.android.com/tools">
<string name="local_storage">Локално складиште</string> <string name="local_storage">Локално складиште</string>
<string name="error_occurred">Грешка се појавила</string> <string name="error_occurred">Грешка се појавила</string>
<string name="favourites">Омиљено</string> <string name="favourites">Омиљене</string>
<string name="history">Историја</string> <string name="history">Историја</string>
<string name="network_error">Грешка на мрежи</string> <string name="network_error">Грешка на мрежи</string>
<string name="details">Детаљи</string> <string name="details">Детаљи</string>
@ -117,7 +117,7 @@
<string name="show_on_shelf">Прикажи на полици</string> <string name="show_on_shelf">Прикажи на полици</string>
<string name="explore">Истражи</string> <string name="explore">Истражи</string>
<string name="options">Опције</string> <string name="options">Опције</string>
<string name="add_to_favourites">Додај у омиљене</string> <string name="add_to_favourites">Додај у Омиљене</string>
<string name="text_history_holder_secondary">Пронађи ствари за читање у одељку „Истражи“</string> <string name="text_history_holder_secondary">Пронађи ствари за читање у одељку „Истражи“</string>
<string name="light_indicator">Показатељ ЛЕД светла</string> <string name="light_indicator">Показатељ ЛЕД светла</string>
<string name="favourites_categories">Омиљене категорије</string> <string name="favourites_categories">Омиљене категорије</string>
@ -151,7 +151,7 @@
<string name="cancel_all">Откажи све</string> <string name="cancel_all">Откажи све</string>
<string name="sync_host_description">Можеш да користиш послуживач за синхронизацију који се самостално хостује или подразумевани. Не мењај ово ако ниси сигуран шта радиш.</string> <string name="sync_host_description">Можеш да користиш послуживач за синхронизацију који се самостално хостује или подразумевани. Не мењај ово ако ниси сигуран шта радиш.</string>
<string name="error_corrupted_file">Враћени су неважећи подаци или је датотека оштећена</string> <string name="error_corrupted_file">Враћени су неважећи подаци или је датотека оштећена</string>
<string name="all_favourites">Сви омиљени</string> <string name="all_favourites">Све омиљене</string>
<string name="email_enter_hint">Унесите своју адресу е-поште да бисте наставили</string> <string name="email_enter_hint">Унесите своју адресу е-поште да бисте наставили</string>
<string name="pick_custom_directory">Изабери прилагођени директоријум</string> <string name="pick_custom_directory">Изабери прилагођени директоријум</string>
<string name="no_chapters">Нема поглавља</string> <string name="no_chapters">Нема поглавља</string>
@ -189,7 +189,7 @@
<string name="clear_all_history">Избриши сву историју</string> <string name="clear_all_history">Избриши сву историју</string>
<string name="data_deletion">Брисање података</string> <string name="data_deletion">Брисање података</string>
<string name="history_shortcuts">Прикажи недавне пречице за мангу</string> <string name="history_shortcuts">Прикажи недавне пречице за мангу</string>
<string name="downloads_wifi_only_summary">Заустави преузимање када пређеш на мобилну мрежу</string> <string name="downloads_wifi_only_summary">Зауставља преузимање када пређеш на мобилну мрежу</string>
<string name="suggest_new_sources">Предложи нове изворе након ажурирања апликације</string> <string name="suggest_new_sources">Предложи нове изворе након ажурирања апликације</string>
<string name="import_completed">Увоз је завршен</string> <string name="import_completed">Увоз је завршен</string>
<string name="show_reading_indicators">Прикажи показивач током читања</string> <string name="show_reading_indicators">Прикажи показивач током читања</string>
@ -252,7 +252,7 @@
<string name="data_not_restored">Подаци нису враћени</string> <string name="data_not_restored">Подаци нису враћени</string>
<string name="manage_sources">Управљај изворима</string> <string name="manage_sources">Управљај изворима</string>
<string name="directories">Директоријуми</string> <string name="directories">Директоријуми</string>
<string name="local_manga_directories">Локални директорији манги</string> <string name="local_manga_directories">Локални директоријуми Манги</string>
<string name="manage_categories">Управљај категоријама</string> <string name="manage_categories">Управљај категоријама</string>
<string name="update">Ажурирај</string> <string name="update">Ажурирај</string>
<string name="scrobbling_empty_hint">Да бисте пратили напредак читања, изаберите Изборник → Прати на екрану са детаљима манге.</string> <string name="scrobbling_empty_hint">Да бисте пратили напредак читања, изаберите Изборник → Прати на екрану са детаљима манге.</string>
@ -308,7 +308,7 @@
<string name="dns_over_https">DNS преко HTTPS-а</string> <string name="dns_over_https">DNS преко HTTPS-а</string>
<string name="show_suspicious_content">Прикажи сумњив садржај</string> <string name="show_suspicious_content">Прикажи сумњив садржај</string>
<string name="sync_title">Синхронизујте своје податке</string> <string name="sync_title">Синхронизујте своје податке</string>
<string name="appwidget_shelf_description">Манга из ваших омиљених</string> <string name="appwidget_shelf_description">Манга из ваших Омиљених</string>
<string name="comics_archive_import_description">Можеш да изабереш једну или више .cbz или .zip датотека, свака датотека ће бити препозната као засебна манга.</string> <string name="comics_archive_import_description">Можеш да изабереш једну или више .cbz или .zip датотека, свака датотека ће бити препозната као засебна манга.</string>
<string name="downloads_paused">Преузимања су заустављена</string> <string name="downloads_paused">Преузимања су заустављена</string>
<string name="too_many_requests_message">Превише захтева. Покушај поново касније</string> <string name="too_many_requests_message">Превише захтева. Покушај поново касније</string>
@ -400,7 +400,7 @@
<string name="mark_as_current">Означи као тренутно</string> <string name="mark_as_current">Означи као тренутно</string>
<string name="protect_application_summary">Затражи лозинку при покретању Котатсу-а</string> <string name="protect_application_summary">Затражи лозинку при покретању Котатсу-а</string>
<string name="right_to_left">Са десна на лево</string> <string name="right_to_left">Са десна на лево</string>
<string name="show_reading_indicators_summary">Прикажи проценат читања у историји и омиљеним</string> <string name="show_reading_indicators_summary">Прикажи проценат читања у Историји и Омиљеним</string>
<string name="random">Насумично</string> <string name="random">Насумично</string>
<string name="mirror_switching">Аутоматски изабери послуживач</string> <string name="mirror_switching">Аутоматски изабери послуживач</string>
<string name="use_fingerprint">Користи отисак прста ако је доступан</string> <string name="use_fingerprint">Користи отисак прста ако је доступан</string>
@ -495,7 +495,7 @@
<string name="content_type_other">Остало</string> <string name="content_type_other">Остало</string>
<string name="suggestion_manga">Предлог: %s</string> <string name="suggestion_manga">Предлог: %s</string>
<string name="color_black">Црна</string> <string name="color_black">Црна</string>
<string name="removed_from_favourites">Уклоњено из омиљених</string> <string name="removed_from_favourites">Уклоњено из Омиљених</string>
<string name="bookmarks">Обележивачи</string> <string name="bookmarks">Обележивачи</string>
<string name="show_all">Покажи све</string> <string name="show_all">Покажи све</string>
<string name="this_month">Овог месеца</string> <string name="this_month">Овог месеца</string>
@ -657,4 +657,5 @@
<string name="recent_sources">Недавни извори</string> <string name="recent_sources">Недавни извори</string>
<string name="image_server">Жељени послуживач слика</string> <string name="image_server">Жељени послуживач слика</string>
<string name="crop_pages">Изрежи странице</string> <string name="crop_pages">Изрежи странице</string>
<string name="external_source">Спољни/додатак</string>
</resources> </resources>

@ -658,4 +658,5 @@
<string name="chapters_read">Okunan bölüm</string> <string name="chapters_read">Okunan bölüm</string>
<string name="chapters_left">Kalan bölüm</string> <string name="chapters_left">Kalan bölüm</string>
<string name="external_source">Harici/eklenti</string> <string name="external_source">Harici/eklenti</string>
<string name="plugin_incompatible">Uyumsuz eklenti veya dahili hata. Eklentinin ve Kotatsu\'nun en son sürümünü kullandığınızdan emin olun</string>
</resources> </resources>

@ -657,4 +657,5 @@
<string name="percent_left">Tiến trình đọc còn lại</string> <string name="percent_left">Tiến trình đọc còn lại</string>
<string name="chapters_read">Chương đã đọc</string> <string name="chapters_read">Chương đã đọc</string>
<string name="chapters_left">Chương còn lại</string> <string name="chapters_left">Chương còn lại</string>
<string name="external_source">Nguồn / Plugin bên ngoài</string>
</resources> </resources>

@ -617,7 +617,7 @@
<string name="hours_minutes_short">%1$d 时 %2$d 分</string> <string name="hours_minutes_short">%1$d 时 %2$d 分</string>
<string name="fix">修复</string> <string name="fix">修复</string>
<string name="missing_storage_permission">无访问外部存储漫画权限</string> <string name="missing_storage_permission">无访问外部存储漫画权限</string>
<string name="last_used">最近使用</string> <string name="last_used">上次使用</string>
<string name="show_updated">显示更新</string> <string name="show_updated">显示更新</string>
<string name="webtoon_gaps_summary">在条漫模式下添加页与页之间的横向缝隙</string> <string name="webtoon_gaps_summary">在条漫模式下添加页与页之间的横向缝隙</string>
<string name="webtoon_gaps">缝隙条漫模式</string> <string name="webtoon_gaps">缝隙条漫模式</string>
@ -658,4 +658,5 @@
<string name="chapters_read">已读章节数</string> <string name="chapters_read">已读章节数</string>
<string name="chapters_left">剩余章节数</string> <string name="chapters_left">剩余章节数</string>
<string name="external_source">外部插件</string> <string name="external_source">外部插件</string>
</resources> <string name="plugin_incompatible">插件不兼容或出现了外部错误,请确保你已经将 Kotatsu 以及插件更新至最新版本</string>
</resources>

@ -96,6 +96,7 @@
<string name="text_search_holder_secondary">Try to reformulate the query.</string> <string name="text_search_holder_secondary">Try to reformulate the query.</string>
<string name="text_history_holder_primary">What you read will be displayed here</string> <string name="text_history_holder_primary">What you read will be displayed here</string>
<string name="text_history_holder_secondary">Find what to read in the «Explore» section</string> <string name="text_history_holder_secondary">Find what to read in the «Explore» section</string>
<string name="text_history_holder_secondary_filtered">There are no manga matching the filters you selected</string>
<string name="text_local_holder_primary">Save something first</string> <string name="text_local_holder_primary">Save something first</string>
<string name="text_local_holder_secondary">Save something from an online catalog or import it from a file.</string> <string name="text_local_holder_secondary">Save something from an online catalog or import it from a file.</string>
<string name="manga_shelf">Shelf</string> <string name="manga_shelf">Shelf</string>
@ -267,7 +268,7 @@
<string name="status_on_hold">On hold</string> <string name="status_on_hold">On hold</string>
<string name="status_dropped">Dropped</string> <string name="status_dropped">Dropped</string>
<string name="disable_all">Disable all</string> <string name="disable_all">Disable all</string>
<string name="use_fingerprint">Use fingerprint if available</string> <string name="use_fingerprint">Use biometric if available</string>
<string name="appwidget_shelf_description">Manga from your favourites</string> <string name="appwidget_shelf_description">Manga from your favourites</string>
<string name="appwidget_recent_description">Your recently read manga</string> <string name="appwidget_recent_description">Your recently read manga</string>
<string name="report">Report</string> <string name="report">Report</string>
@ -669,4 +670,5 @@
<string name="chapters_read">Chapters read</string> <string name="chapters_read">Chapters read</string>
<string name="chapters_left">Chapters left</string> <string name="chapters_left">Chapters left</string>
<string name="external_source">External/plugin</string> <string name="external_source">External/plugin</string>
<string name="plugin_incompatible">Incompatible plugin or internal error. Make sure you are using the latest version of the plugin and Kotatsu</string>
</resources> </resources>

Loading…
Cancel
Save