Merge remote-tracking branch 'origin/devel' into devel
This commit is contained in:
@@ -16,8 +16,8 @@ android {
|
||||
applicationId 'org.koitharu.kotatsu'
|
||||
minSdk = 21
|
||||
targetSdk = 35
|
||||
versionCode = 657
|
||||
versionName = '7.4'
|
||||
versionCode = 658
|
||||
versionName = '7.4.1'
|
||||
generatedDensities = []
|
||||
testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner'
|
||||
ksp {
|
||||
@@ -82,7 +82,7 @@ afterEvaluate {
|
||||
}
|
||||
dependencies {
|
||||
//noinspection GradleDependency
|
||||
implementation('com.github.KotatsuApp:kotatsu-parsers:a9fc534ea7') {
|
||||
implementation('com.github.KotatsuApp:kotatsu-parsers:3b5a018f8c') {
|
||||
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
|
||||
|
||||
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.async
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import org.koitharu.kotatsu.core.cache.MemoryContentCache
|
||||
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.ContentType
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||
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.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
|
||||
|
||||
@@ -33,232 +23,58 @@ class ExternalMangaRepository(
|
||||
cache: MemoryContentCache,
|
||||
) : 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>
|
||||
get() = capabilities?.availableSortOrders ?: EnumSet.of(SortOrder.ALPHABETICAL)
|
||||
|
||||
override val states: Set<MangaState>
|
||||
get() = capabilities?.availableStates.orEmpty()
|
||||
|
||||
override val contentRatings: Set<ContentRating>
|
||||
get() = capabilities?.availableContentRating.orEmpty()
|
||||
|
||||
override var defaultSortOrder: SortOrder
|
||||
get() = capabilities?.defaultSortOrder ?: SortOrder.ALPHABETICAL
|
||||
set(value) = Unit
|
||||
|
||||
override val isMultipleTagsSupported: Boolean
|
||||
get() = capabilities?.isMultipleTagsSupported ?: true
|
||||
|
||||
override val isTagsExclusionSupported: Boolean
|
||||
get() = capabilities?.isTagsExclusionSupported ?: false
|
||||
|
||||
override val isSearchSupported: Boolean
|
||||
get() = capabilities?.isSearchSupported ?: true
|
||||
|
||||
override suspend fun getList(offset: Int, filter: MangaListFilter?): List<Manga> =
|
||||
runInterruptible(Dispatchers.Default) {
|
||||
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)?.use { cursor ->
|
||||
val result = ArrayList<Manga>(cursor.count)
|
||||
if (cursor.moveToFirst()) {
|
||||
do {
|
||||
result += cursor.getManga()
|
||||
} while (cursor.moveToNext())
|
||||
}
|
||||
result
|
||||
}.orEmpty()
|
||||
runInterruptible(Dispatchers.IO) {
|
||||
contentSource.getList(offset, filter)
|
||||
}
|
||||
|
||||
override suspend fun getDetailsImpl(manga: Manga): Manga = coroutineScope {
|
||||
val chapters = async { 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.await(),
|
||||
source = source,
|
||||
)
|
||||
override suspend fun getDetailsImpl(manga: Manga): Manga = runInterruptible(Dispatchers.IO) {
|
||||
contentSource.getDetails(manga)
|
||||
}
|
||||
|
||||
override suspend fun getPagesImpl(chapter: MangaChapter): List<MangaPage> = runInterruptible(Dispatchers.Default) {
|
||||
val uri = "content://${source.authority}/chapters".toUri()
|
||||
.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 getPagesImpl(chapter: MangaChapter): List<MangaPage> = runInterruptible(Dispatchers.IO) {
|
||||
contentSource.getPages(chapter)
|
||||
}
|
||||
|
||||
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) {
|
||||
val uri = "content://${source.authority}/tags".toUri()
|
||||
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 getTags(): Set<MangaTag> = runInterruptible(Dispatchers.IO) {
|
||||
contentSource.getTags()
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
||||
291
app/src/main/kotlin/org/koitharu/kotatsu/core/parser/external/ExternalPluginContentSource.kt
vendored
Normal file
291
app/src/main/kotlin/org/koitharu/kotatsu/core/parser/external/ExternalPluginContentSource.kt
vendored
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
73
app/src/main/kotlin/org/koitharu/kotatsu/core/parser/external/SafeCursor.kt
vendored
Normal file
73
app/src/main/kotlin/org/koitharu/kotatsu/core/parser/external/SafeCursor.kt
vendored
Normal file
@@ -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 androidx.annotation.ColorRes
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.core.view.children
|
||||
import com.google.android.material.chip.Chip
|
||||
import com.google.android.material.chip.ChipDrawable
|
||||
@@ -92,7 +93,11 @@ class ChipsView @JvmOverloads constructor(
|
||||
}
|
||||
|
||||
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.isCheckable = model.isCheckable
|
||||
if (model.icon == 0) {
|
||||
@@ -139,7 +144,8 @@ class ChipsView @JvmOverloads constructor(
|
||||
}
|
||||
|
||||
data class ChipModel(
|
||||
val title: CharSequence,
|
||||
val title: CharSequence? = null,
|
||||
@StringRes val titleResId: Int = 0,
|
||||
@DrawableRes val icon: Int = 0,
|
||||
val isCheckable: Boolean = false,
|
||||
@ColorRes val tint: Int = 0,
|
||||
|
||||
@@ -37,3 +37,5 @@ fun JSONObject.toContentValues(): ContentValues {
|
||||
}
|
||||
|
||||
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.CloudFlareProtectedException
|
||||
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.SyncApiException
|
||||
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)
|
||||
|
||||
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 NotFoundException -> resources.getString(R.string.not_found_404)
|
||||
is UnsupportedSourceException -> resources.getString(R.string.unsupported_source)
|
||||
|
||||
@@ -10,6 +10,7 @@ import androidx.sqlite.db.SimpleSQLiteQuery
|
||||
import androidx.sqlite.db.SupportSQLiteQuery
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import org.koitharu.kotatsu.core.db.entity.TagEntity
|
||||
import org.koitharu.kotatsu.list.domain.ListFilterOption
|
||||
import org.koitharu.kotatsu.list.domain.ListSortOrder
|
||||
|
||||
@Dao
|
||||
@@ -27,7 +28,11 @@ abstract class HistoryDao {
|
||||
@Query("SELECT * FROM history WHERE deleted_at = 0 ORDER BY updated_at DESC LIMIT :limit")
|
||||
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) {
|
||||
ListSortOrder.LAST_READ -> "history.updated_at DESC"
|
||||
ListSortOrder.LONG_AGO_READ -> "history.updated_at ASC"
|
||||
@@ -44,8 +49,13 @@ abstract class HistoryDao {
|
||||
val query = buildString {
|
||||
append(
|
||||
"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)
|
||||
if (limit > 0) {
|
||||
append(" LIMIT ")
|
||||
@@ -147,4 +157,11 @@ abstract class HistoryDao {
|
||||
@Transaction
|
||||
@RawQuery(observedEntities = [HistoryEntity::class])
|
||||
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.util.ext.mapItems
|
||||
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.ReadingProgress
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
@@ -76,8 +77,12 @@ class HistoryRepository @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun observeAllWithHistory(order: ListSortOrder, limit: Int): Flow<List<MangaWithHistory>> {
|
||||
return db.getHistoryDao().observeAll(order, limit).mapItems {
|
||||
fun observeAllWithHistory(
|
||||
order: ListSortOrder,
|
||||
filterOptions: Set<ListFilterOption>,
|
||||
limit: Int
|
||||
): Flow<List<MangaWithHistory>> {
|
||||
return db.getHistoryDao().observeAll(order, filterOptions, limit).mapItems {
|
||||
MangaWithHistory(
|
||||
it.manga.toManga(it.tags.toMangaTags()),
|
||||
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.observe
|
||||
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.size.DynamicItemSizeResolver
|
||||
|
||||
@@ -34,6 +35,10 @@ class HistoryListFragment : MangaListFragment() {
|
||||
|
||||
override fun onScrolledToEnd() = viewModel.requestMoreItems()
|
||||
|
||||
override fun onFilterOptionClick(option: ListFilterOption) {
|
||||
viewModel.onFilterOptionClick(option)
|
||||
}
|
||||
|
||||
override fun onEmptyActionClick() {
|
||||
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.observeAsStateFlow
|
||||
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.call
|
||||
import org.koitharu.kotatsu.core.util.ext.combine
|
||||
import org.koitharu.kotatsu.core.util.ext.onFirst
|
||||
import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
|
||||
import org.koitharu.kotatsu.history.data.HistoryRepository
|
||||
import org.koitharu.kotatsu.history.domain.MarkAsReadUseCase
|
||||
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.MangaListMapper
|
||||
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.ListModel
|
||||
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.toErrorState
|
||||
import org.koitharu.kotatsu.local.data.LocalMangaRepository
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import java.time.Instant
|
||||
import java.util.EnumSet
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -63,6 +68,8 @@ class HistoryListViewModel @Inject constructor(
|
||||
valueProducer = { historySortOrder },
|
||||
)
|
||||
|
||||
private val filterOptions = MutableStateFlow<Set<ListFilterOption>>(EnumSet.noneOf(ListFilterOption::class.java))
|
||||
|
||||
override val listMode = settings.observeAsStateFlow(
|
||||
scope = viewModelScope + Dispatchers.Default,
|
||||
key = AppSettings.KEY_LIST_MODE_HISTORY,
|
||||
@@ -86,25 +93,25 @@ class HistoryListViewModel @Inject constructor(
|
||||
)
|
||||
|
||||
override val content = combine(
|
||||
filterOptions,
|
||||
observeHistory(),
|
||||
isGroupingEnabled,
|
||||
observeListModeWithTriggers(),
|
||||
networkState,
|
||||
settings.observeAsFlow(AppSettings.KEY_INCOGNITO_MODE) { isIncognitoModeEnabled },
|
||||
) { list, grouped, mode, online, incognito ->
|
||||
) { filters, list, grouped, mode, online, incognito ->
|
||||
when {
|
||||
list.isEmpty() -> listOf(
|
||||
EmptyState(
|
||||
icon = R.drawable.ic_empty_history,
|
||||
textPrimary = R.string.text_history_holder_primary,
|
||||
textSecondary = R.string.text_history_holder_secondary,
|
||||
actionStringRes = 0,
|
||||
),
|
||||
)
|
||||
list.isEmpty() -> {
|
||||
if (filters.isEmpty()) {
|
||||
listOf(getEmptyState(hasFilters = false))
|
||||
} else {
|
||||
listOf(filterItem(filters), getEmptyState(hasFilters = true))
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
isReady.set(true)
|
||||
mapList(list, grouped, mode, online, incognito)
|
||||
mapList(filters, list, grouped, mode, online, incognito)
|
||||
}
|
||||
}
|
||||
}.onStart {
|
||||
@@ -154,17 +161,29 @@ class HistoryListViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private fun observeHistory() = combine(sortOrder, limit, ::Pair)
|
||||
.flatMapLatest { repository.observeAllWithHistory(it.first, it.second) }
|
||||
fun onFilterOptionClick(option: ListFilterOption) {
|
||||
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(
|
||||
filters: Set<ListFilterOption>,
|
||||
list: List<MangaWithHistory>,
|
||||
grouped: Boolean,
|
||||
mode: ListMode,
|
||||
isOnline: Boolean,
|
||||
isIncognito: Boolean,
|
||||
): 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) {
|
||||
result += TipModel(
|
||||
key = AppSettings.KEY_INCOGNITO_MODE,
|
||||
@@ -185,12 +204,14 @@ class HistoryListViewModel @Inject constructor(
|
||||
actionStringRes = R.string.manage,
|
||||
)
|
||||
}
|
||||
var isEmpty = true
|
||||
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
|
||||
} else {
|
||||
m
|
||||
}
|
||||
isEmpty = false
|
||||
if (grouped) {
|
||||
val header = history.header(order)
|
||||
if (header != prevHeader) {
|
||||
@@ -202,6 +223,9 @@ class HistoryListViewModel @Inject constructor(
|
||||
}
|
||||
result += mangaListMapper.toListModel(manga, mode)
|
||||
}
|
||||
if (filters.isNotEmpty() && isEmpty) {
|
||||
result += getEmptyState(hasFilters = true)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -229,4 +253,32 @@ class HistoryListViewModel @Inject constructor(
|
||||
ListSortOrder.UPDATED,
|
||||
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
|
||||
}
|
||||
|
||||
fun isComplete() = percent >= 1f - Math.ulp(percent)
|
||||
|
||||
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.download.ui.worker.DownloadStartedObserver
|
||||
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.MangaListAdapter
|
||||
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 onEmptyActionClick() = Unit
|
||||
|
||||
@@ -2,6 +2,7 @@ package org.koitharu.kotatsu.list.ui.adapter
|
||||
|
||||
enum class ListItemType {
|
||||
|
||||
FILTER_HEADER,
|
||||
FILTER_SORT,
|
||||
FILTER_TAG,
|
||||
FILTER_TAG_MULTI,
|
||||
|
||||
@@ -24,6 +24,7 @@ open class MangaListAdapter(
|
||||
addDelegate(ListItemType.STATE_EMPTY, emptyStateListAD(coil, lifecycleOwner, listener))
|
||||
addDelegate(ListItemType.HINT_EMPTY, emptyHintAD(coil, lifecycleOwner, listener))
|
||||
addDelegate(ListItemType.HEADER, listHeaderAD(listener))
|
||||
addDelegate(ListItemType.FILTER_HEADER, quickFilterAD(listener))
|
||||
addDelegate(ListItemType.TIP, tipAD(listener))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ fun mangaListDetailedItemAD(
|
||||
source(item.source)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import org.koitharu.kotatsu.core.ui.widgets.TipView
|
||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||
|
||||
interface MangaListListener : MangaDetailsClickListener, ListStateHolderListener, ListHeaderClickListener,
|
||||
TipView.OnButtonClickListener {
|
||||
TipView.OnButtonClickListener, QuickFilterClickListener {
|
||||
|
||||
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_STATE,
|
||||
ListItemType.FILTER_LANGUAGE,
|
||||
ListItemType.FILTER_HEADER,
|
||||
-> outRect.set(0)
|
||||
|
||||
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.AuthenticationCallback
|
||||
import androidx.core.graphics.Insets
|
||||
import com.google.android.material.textfield.TextInputLayout
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.koitharu.kotatsu.R
|
||||
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.observeEvent
|
||||
import org.koitharu.kotatsu.databinding.ActivityProtectBinding
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
@AndroidEntryPoint
|
||||
class ProtectActivity :
|
||||
@@ -34,6 +36,7 @@ class ProtectActivity :
|
||||
View.OnClickListener {
|
||||
|
||||
private val viewModel by viewModels<ProtectViewModel>()
|
||||
private var canUseBiometric = false
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
@@ -61,7 +64,9 @@ class ProtectActivity :
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
if (!useFingerprint()) {
|
||||
canUseBiometric = useFingerprint()
|
||||
updateEndIcon()
|
||||
if (!canUseBiometric) {
|
||||
viewBinding.editPassword.requestFocus()
|
||||
}
|
||||
}
|
||||
@@ -80,6 +85,7 @@ class ProtectActivity :
|
||||
when (v.id) {
|
||||
R.id.button_next -> viewModel.tryUnlock(viewBinding.editPassword.text?.toString().orEmpty())
|
||||
R.id.button_cancel -> finish()
|
||||
materialR.id.text_input_end_icon -> useFingerprint()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,6 +105,7 @@ class ProtectActivity :
|
||||
override fun afterTextChanged(s: Editable?) {
|
||||
viewBinding.layoutPassword.error = null
|
||||
viewBinding.buttonNext.isEnabled = !s.isNullOrEmpty()
|
||||
updateEndIcon()
|
||||
}
|
||||
|
||||
private fun onError(e: Throwable) {
|
||||
@@ -127,6 +134,24 @@ class ProtectActivity :
|
||||
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() {
|
||||
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
|
||||
super.onAuthenticationSucceeded(result)
|
||||
|
||||
@@ -73,7 +73,7 @@ private const val PREFETCH_LIMIT = 10
|
||||
class ReaderViewModel
|
||||
@Inject
|
||||
constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
private val savedStateHandle: SavedStateHandle,
|
||||
private val dataRepository: MangaDataRepository,
|
||||
private val historyRepository: HistoryRepository,
|
||||
private val bookmarksRepository: BookmarksRepository,
|
||||
@@ -223,6 +223,7 @@ constructor(
|
||||
fun saveCurrentState(state: ReaderState? = null) {
|
||||
if (state != null) {
|
||||
currentState.value = state
|
||||
savedStateHandle[ReaderActivity.EXTRA_STATE] = state
|
||||
}
|
||||
if (incognitoMode.value) {
|
||||
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.ReaderViewModel
|
||||
|
||||
private const val KEY_STATE = "state"
|
||||
|
||||
abstract class BaseReaderFragment<B : ViewBinding> : BaseFragment<B>(), ZoomControl.ZoomControlListener {
|
||||
|
||||
protected val viewModel by activityViewModels<ReaderViewModel>()
|
||||
private var stateToSave: ReaderState? = null
|
||||
|
||||
protected var readerAdapter: BaseReaderAdapter<*>? = null
|
||||
private set
|
||||
|
||||
override fun onViewBindingCreated(binding: B, savedInstanceState: Bundle?) {
|
||||
super.onViewBindingCreated(binding, savedInstanceState)
|
||||
var restoredState = savedInstanceState?.getParcelableCompat<ReaderState>(KEY_STATE)
|
||||
readerAdapter = onCreateAdapter()
|
||||
|
||||
viewModel.content.observe(viewLifecycleOwner) {
|
||||
var pendingState = restoredState ?: it.state
|
||||
if (pendingState == null && it.pages.isNotEmpty() && readerAdapter?.hasItems != true) {
|
||||
pendingState = viewModel.getCurrentState()
|
||||
if (it.state == null && it.pages.isNotEmpty() && readerAdapter?.hasItems != true) {
|
||||
onPagesChanged(it.pages, 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() {
|
||||
stateToSave = getCurrentState()
|
||||
viewModel.saveCurrentState(getCurrentState())
|
||||
readerAdapter = null
|
||||
super.onDestroyView()
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
super.onSaveInstanceState(outState)
|
||||
getCurrentState()?.let {
|
||||
stateToSave = it
|
||||
}
|
||||
outState.putParcelable(KEY_STATE, stateToSave)
|
||||
}
|
||||
|
||||
protected fun requireAdapter() = checkNotNull(readerAdapter) {
|
||||
"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.download.ui.worker.DownloadStartedObserver
|
||||
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.adapter.MangaListListener
|
||||
import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
|
||||
@@ -133,6 +134,8 @@ class MultiSearchActivity :
|
||||
viewModel.retry()
|
||||
}
|
||||
|
||||
override fun onFilterOptionClick(option: ListFilterOption) = Unit
|
||||
|
||||
override fun onUpdateFilter(tags: Set<MangaTag>) = 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.databinding.FragmentFeedBinding
|
||||
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.TypedListSpacingDecoration
|
||||
import org.koitharu.kotatsu.list.ui.model.ListHeader
|
||||
@@ -94,6 +95,8 @@ class FeedFragment :
|
||||
viewModel.update()
|
||||
}
|
||||
|
||||
override fun onFilterOptionClick(option: ListFilterOption) = Unit
|
||||
|
||||
override fun onRetryClick(error: Throwable) = Unit
|
||||
|
||||
override fun onUpdateFilter(tags: Set<MangaTag>) = Unit
|
||||
|
||||
24
app/src/main/res/layout/item_quick_filter.xml
Normal file
24
app/src/main/res/layout/item_quick_filter.xml
Normal file
@@ -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="paused">متوقف مؤقتاً</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="suggestions_enable_prompt">هل ترغب في تلقي اقتراحات المانجا الشخصية؟</string>
|
||||
<string name="images_proxy_title">وكيل تحسين الصور</string>
|
||||
@@ -515,7 +515,7 @@
|
||||
<string name="speed_value">س%.1f</string>
|
||||
<string name="skip">تخطى</string>
|
||||
<string name="grayscale">تدرج الرمادي</string>
|
||||
<string name="globally">عالماً</string>
|
||||
<string name="globally">عالمياً</string>
|
||||
<string name="this_manga">هذه المانجا</string>
|
||||
<string name="color_correction_apply_text">يمكن تطبيق هذه الإعدادات عالمياً أو على المانجا الحالية فقط. إذا تم تطبيقه عالمياً، فلن يتم تجاوز الإعدادات الفردية.</string>
|
||||
<string name="apply">طَبِق</string>
|
||||
@@ -602,7 +602,7 @@
|
||||
<string name="show_updated">عرض التحديثات</string>
|
||||
<string name="rating_suggestive">موحية</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="preferred_download_format">تنسيق التحميل المُفضل</string>
|
||||
<string name="automatic">تلقائي</string>
|
||||
|
||||
@@ -653,4 +653,9 @@
|
||||
<string name="recent_sources">Нядаўнія крыніцы</string>
|
||||
<string name="sources_pinned">Крыніцы замацаваны</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>
|
||||
@@ -658,4 +658,5 @@
|
||||
<string name="chapters_read">Capítulos leídos</string>
|
||||
<string name="chapters_left">Capítulos restantes</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>
|
||||
@@ -653,4 +653,10 @@
|
||||
<string name="source_pinned">स्रोत पिन किया गया</string>
|
||||
<string name="source_unpinned">स्रोत अनपिन किया गया</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>
|
||||
@@ -644,4 +644,18 @@
|
||||
<string name="crop_pages">Cortar páginas</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="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>
|
||||
@@ -657,4 +657,5 @@
|
||||
<string name="chapters_read">Глав прочитано</string>
|
||||
<string name="percent_left">Процент оставшегося</string>
|
||||
<string name="chapters_left">Глав осталось</string>
|
||||
<string name="external_source">Внешний/плагин</string>
|
||||
</resources>
|
||||
@@ -2,7 +2,7 @@
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<string name="local_storage">Локално складиште</string>
|
||||
<string name="error_occurred">Грешка се појавила</string>
|
||||
<string name="favourites">Омиљено</string>
|
||||
<string name="favourites">Омиљене</string>
|
||||
<string name="history">Историја</string>
|
||||
<string name="network_error">Грешка на мрежи</string>
|
||||
<string name="details">Детаљи</string>
|
||||
@@ -117,7 +117,7 @@
|
||||
<string name="show_on_shelf">Прикажи на полици</string>
|
||||
<string name="explore">Истражи</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="light_indicator">Показатељ ЛЕД светла</string>
|
||||
<string name="favourites_categories">Омиљене категорије</string>
|
||||
@@ -151,7 +151,7 @@
|
||||
<string name="cancel_all">Откажи све</string>
|
||||
<string name="sync_host_description">Можеш да користиш послуживач за синхронизацију који се самостално хостује или подразумевани. Не мењај ово ако ниси сигуран шта радиш.</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="pick_custom_directory">Изабери прилагођени директоријум</string>
|
||||
<string name="no_chapters">Нема поглавља</string>
|
||||
@@ -189,7 +189,7 @@
|
||||
<string name="clear_all_history">Избриши сву историју</string>
|
||||
<string name="data_deletion">Брисање података</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="import_completed">Увоз је завршен</string>
|
||||
<string name="show_reading_indicators">Прикажи показивач током читања</string>
|
||||
@@ -252,7 +252,7 @@
|
||||
<string name="data_not_restored">Подаци нису враћени</string>
|
||||
<string name="manage_sources">Управљај изворима</string>
|
||||
<string name="directories">Директоријуми</string>
|
||||
<string name="local_manga_directories">Локални директорији манги</string>
|
||||
<string name="local_manga_directories">Локални директоријуми Манги</string>
|
||||
<string name="manage_categories">Управљај категоријама</string>
|
||||
<string name="update">Ажурирај</string>
|
||||
<string name="scrobbling_empty_hint">Да бисте пратили напредак читања, изаберите Изборник → Прати на екрану са детаљима манге.</string>
|
||||
@@ -308,7 +308,7 @@
|
||||
<string name="dns_over_https">DNS преко HTTPS-а</string>
|
||||
<string name="show_suspicious_content">Прикажи сумњив садржај</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="downloads_paused">Преузимања су заустављена</string>
|
||||
<string name="too_many_requests_message">Превише захтева. Покушај поново касније</string>
|
||||
@@ -400,7 +400,7 @@
|
||||
<string name="mark_as_current">Означи као тренутно</string>
|
||||
<string name="protect_application_summary">Затражи лозинку при покретању Котатсу-а</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="mirror_switching">Аутоматски изабери послуживач</string>
|
||||
<string name="use_fingerprint">Користи отисак прста ако је доступан</string>
|
||||
@@ -495,7 +495,7 @@
|
||||
<string name="content_type_other">Остало</string>
|
||||
<string name="suggestion_manga">Предлог: %s</string>
|
||||
<string name="color_black">Црна</string>
|
||||
<string name="removed_from_favourites">Уклоњено из омиљених</string>
|
||||
<string name="removed_from_favourites">Уклоњено из Омиљених</string>
|
||||
<string name="bookmarks">Обележивачи</string>
|
||||
<string name="show_all">Покажи све</string>
|
||||
<string name="this_month">Овог месеца</string>
|
||||
@@ -657,4 +657,5 @@
|
||||
<string name="recent_sources">Недавни извори</string>
|
||||
<string name="image_server">Жељени послуживач слика</string>
|
||||
<string name="crop_pages">Изрежи странице</string>
|
||||
<string name="external_source">Спољни/додатак</string>
|
||||
</resources>
|
||||
@@ -658,4 +658,5 @@
|
||||
<string name="chapters_read">Okunan bölüm</string>
|
||||
<string name="chapters_left">Kalan bölüm</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>
|
||||
@@ -657,4 +657,5 @@
|
||||
<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_left">Chương còn lại</string>
|
||||
<string name="external_source">Nguồn / Plugin bên ngoài</string>
|
||||
</resources>
|
||||
@@ -617,7 +617,7 @@
|
||||
<string name="hours_minutes_short">%1$d 时 %2$d 分</string>
|
||||
<string name="fix">修复</string>
|
||||
<string name="missing_storage_permission">无访问外部存储漫画权限</string>
|
||||
<string name="last_used">最近使用</string>
|
||||
<string name="last_used">上次使用</string>
|
||||
<string name="show_updated">显示更新</string>
|
||||
<string name="webtoon_gaps_summary">在条漫模式下添加页与页之间的横向缝隙</string>
|
||||
<string name="webtoon_gaps">缝隙条漫模式</string>
|
||||
@@ -658,4 +658,5 @@
|
||||
<string name="chapters_read">已读章节数</string>
|
||||
<string name="chapters_left">剩余章节数</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_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_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_secondary">Save something from an online catalog or import it from a file.</string>
|
||||
<string name="manga_shelf">Shelf</string>
|
||||
@@ -267,7 +268,7 @@
|
||||
<string name="status_on_hold">On hold</string>
|
||||
<string name="status_dropped">Dropped</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_recent_description">Your recently read manga</string>
|
||||
<string name="report">Report</string>
|
||||
@@ -669,4 +670,5 @@
|
||||
<string name="chapters_read">Chapters read</string>
|
||||
<string name="chapters_left">Chapters left</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>
|
||||
|
||||
Reference in New Issue
Block a user