Compare commits

...

28 Commits
v7.4 ... v7.4.2

Author SHA1 Message Date
Koitharu
809e7d8701 Ability to check proxy connection 2024-08-12 18:22:05 +03:00
Koitharu
0015c5704a Fix ignoring external sources in global search 2024-08-12 17:30:21 +03:00
Koitharu
a7ff1610eb Fix crashes 2024-08-12 17:17:02 +03:00
Koitharu
22c402fc5e Update parers 2024-08-12 16:51:03 +03:00
Koitharu
6e92d46a63 Update parsers 2024-08-03 15:02:31 +03:00
Koitharu
66ed926ea8 Merge remote-tracking branch 'weblate/devel' into devel 2024-08-03 13:36:12 +03:00
Koitharu
b7741ce2af Allow to use biometric unlock manually (closes #999) 2024-08-03 13:35:28 +03:00
vianh
1a17324d26 Fix reader state not being restored 2024-08-03 12:47:57 +03:00
gekka
4044936481 Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (664 of 664 strings)

Co-authored-by: gekka <1778962971@qq.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2024-08-03 12:47:46 +03:00
Oğuz Ersen
1efe86421a Translated using Weblate (Turkish)
Currently translated at 100.0% (664 of 664 strings)

Co-authored-by: Oğuz Ersen <oguz@ersen.moe>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2024-08-03 12:47:46 +03:00
gallegonovato
34dd080f6c Translated using Weblate (Spanish)
Currently translated at 100.0% (664 of 664 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2024-08-03 12:47:46 +03:00
Scrambled777
f4838afab0 Translated using Weblate (Hindi)
Currently translated at 100.0% (664 of 664 strings)

Translated using Weblate (Hindi)

Currently translated at 100.0% (663 of 663 strings)

Co-authored-by: Scrambled777 <weblate.scrambled777@simplelogin.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/hi/
Translation: Kotatsu/Strings
2024-08-03 12:47:46 +03:00
Anon
b207eebe56 Translated using Weblate (Serbian)
Currently translated at 100.0% (663 of 663 strings)

Co-authored-by: Anon <anonymousprivate76@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/sr/
Translation: Kotatsu/Strings
2024-08-03 12:47:46 +03:00
Draken
4f454ab438 Translated using Weblate (Vietnamese)
Currently translated at 100.0% (663 of 663 strings)

Co-authored-by: Draken <premieregirl26@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/vi/
Translation: Kotatsu/Strings
2024-08-03 12:47:46 +03:00
weedyy
1ecf416113 Translated using Weblate (Arabic)
Currently translated at 99.8% (662 of 663 strings)

Co-authored-by: weedyy <huzskywalker@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ar/
Translation: Kotatsu/Strings
2024-08-03 12:47:46 +03:00
TheOneWhoCares
94670a03ff Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (663 of 663 strings)

Co-authored-by: TheOneWhoCares <266nre4gw@mozmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt_BR/
Translation: Kotatsu/Strings
2024-08-03 12:47:46 +03:00
Макар Разин
e92f165677 Translated using Weblate (Russian)
Currently translated at 100.0% (663 of 663 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (663 of 663 strings)

Co-authored-by: Макар Разин <makarrazin14@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/be/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translation: Kotatsu/Strings
2024-08-03 12:47:46 +03:00
gekka
4a03137a25 Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (664 of 664 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (664 of 664 strings)

Co-authored-by: gekka <1778962971@qq.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2024-08-03 11:47:41 +02:00
Oğuz Ersen
7e6e1fb6de Translated using Weblate (Turkish)
Currently translated at 100.0% (664 of 664 strings)

Co-authored-by: Oğuz Ersen <oguz@ersen.moe>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2024-08-03 11:47:41 +02:00
gallegonovato
f477797823 Translated using Weblate (Spanish)
Currently translated at 100.0% (664 of 664 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2024-08-03 11:47:41 +02:00
Scrambled777
125b6740a6 Translated using Weblate (Hindi)
Currently translated at 100.0% (664 of 664 strings)

Translated using Weblate (Hindi)

Currently translated at 100.0% (663 of 663 strings)

Co-authored-by: Scrambled777 <weblate.scrambled777@simplelogin.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/hi/
Translation: Kotatsu/Strings
2024-08-03 11:47:41 +02:00
Anon
1618a11955 Translated using Weblate (Serbian)
Currently translated at 100.0% (663 of 663 strings)

Co-authored-by: Anon <anonymousprivate76@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/sr/
Translation: Kotatsu/Strings
2024-08-03 11:47:41 +02:00
Draken
966d6e2383 Translated using Weblate (Vietnamese)
Currently translated at 100.0% (663 of 663 strings)

Co-authored-by: Draken <premieregirl26@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/vi/
Translation: Kotatsu/Strings
2024-08-03 11:47:41 +02:00
weedyy
2f33a135fc Translated using Weblate (Arabic)
Currently translated at 99.8% (662 of 663 strings)

Co-authored-by: weedyy <huzskywalker@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ar/
Translation: Kotatsu/Strings
2024-08-03 11:47:41 +02:00
TheOneWhoCares
207ea492d5 Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (663 of 663 strings)

Co-authored-by: TheOneWhoCares <266nre4gw@mozmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt_BR/
Translation: Kotatsu/Strings
2024-08-03 11:47:41 +02:00
Макар Разин
250d5432a0 Translated using Weblate (Russian)
Currently translated at 100.0% (663 of 663 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (663 of 663 strings)

Co-authored-by: Макар Разин <makarrazin14@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/be/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translation: Kotatsu/Strings
2024-08-03 11:47:41 +02:00
Koitharu
9768758ecc Optimize external plugin cursor 2024-08-02 12:27:42 +03:00
Koitharu
20852dbd12 Fix query plugin source capabilities 2024-08-01 21:01:40 +03:00
28 changed files with 589 additions and 263 deletions

View File

@@ -16,8 +16,8 @@ android {
applicationId 'org.koitharu.kotatsu'
minSdk = 21
targetSdk = 35
versionCode = 657
versionName = '7.4'
versionCode = 659
versionName = '7.4.2'
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:ca212ca692') {
exclude group: 'org.json', module: 'json'
}
@@ -95,7 +95,7 @@ dependencies {
implementation 'androidx.activity:activity-ktx:1.9.1'
implementation 'androidx.fragment:fragment-ktx:1.8.2'
implementation 'androidx.transition:transition-ktx:1.5.1'
implementation 'androidx.collection:collection-ktx:1.4.2'
implementation 'androidx.collection:collection-ktx:1.4.3'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.4'
implementation 'androidx.lifecycle:lifecycle-service:2.8.4'
implementation 'androidx.lifecycle:lifecycle-process:2.8.4'
@@ -109,7 +109,7 @@ dependencies {
implementation 'androidx.lifecycle:lifecycle-common-java8:2.8.4'
implementation 'androidx.webkit:webkit:1.11.0'
implementation 'androidx.work:work-runtime:2.9.0'
implementation 'androidx.work:work-runtime:2.9.1'
//noinspection GradleDependency
implementation('com.google.guava:guava:32.0.1-android') {
exclude group: 'com.google.guava', module: 'failureaccess'

View File

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

View File

@@ -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,
)
}

View 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"
}
}

View 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)
}
}
}

View File

@@ -704,6 +704,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_LOGS_SHARE = "logs_share"
const val KEY_APP_UPDATE = "app_update"
const val KEY_APP_TRANSLATION = "about_app_translation"
const val PROXY_TEST = "proxy_test"
// old keys are for migration only
private const val KEY_IMAGES_PROXY_OLD = "images_proxy"

View File

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

View File

@@ -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)

View File

@@ -61,7 +61,13 @@ class MangaSourcesRepository @Inject constructor(
suspend fun getEnabledSources(): List<MangaSource> {
assimilateNewSources()
val order = settings.sourcesSortOrder
return dao.findAllEnabled(order).toSources(settings.isNsfwContentDisabled, order)
return dao.findAllEnabled(order).toSources(settings.isNsfwContentDisabled, order).let { enabled ->
val external = getExternalSources()
val list = ArrayList<MangaSourceInfo>(enabled.size + external.size)
external.mapTo(list) { MangaSourceInfo(it, isEnabled = true, isPinned = true) }
list.addAll(enabled)
list
}
}
suspend fun getPinnedSources(): Set<MangaSource> {
@@ -308,8 +314,6 @@ class MangaSourcesRepository @Inject constructor(
}
private fun observeExternalSources(): Flow<List<ExternalMangaSource>> {
val intent = Intent("app.kotatsu.parser.PROVIDE_MANGA")
val pm = context.packageManager
return callbackFlow {
val receiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
@@ -333,15 +337,19 @@ class MangaSourcesRepository @Inject constructor(
}.onStart {
emit(null)
}.map {
pm.queryIntentContentProviders(intent, 0).map { resolveInfo ->
ExternalMangaSource(
packageName = resolveInfo.providerInfo.packageName,
authority = resolveInfo.providerInfo.authority,
)
}
getExternalSources()
}.distinctUntilChanged()
}
private fun getExternalSources() = context.packageManager.queryIntentContentProviders(
Intent("app.kotatsu.parser.PROVIDE_MANGA"), 0,
).map { resolveInfo ->
ExternalMangaSource(
packageName = resolveInfo.providerInfo.packageName,
authority = resolveInfo.providerInfo.authority,
)
}
private fun List<MangaSourceEntity>.toSources(
skipNsfwSources: Boolean,
sortOrder: SourcesSortOrder?,

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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

View File

@@ -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"
}

View File

@@ -176,8 +176,10 @@ class WebtoonScalingFrame @JvmOverloads constructor(
val targetChild = findTargetChild()
adjustBounds()
targetChild.run {
scaleX = scale
scaleY = scale
if (!scale.isNaN()) {
scaleX = scale
scaleY = scale
}
translationX = transX
translationY = transY
if (pendingScroll != 0) {
@@ -298,7 +300,7 @@ class WebtoonScalingFrame @JvmOverloads constructor(
distanceX: Float,
distanceY: Float,
): Boolean {
if (scale <= 1f) return false
if (scale <= 1f || scale.isNaN()) return false
transformMatrix.postTranslate(-distanceX, -distanceY)
invalidateTarget()
return true
@@ -323,7 +325,7 @@ class WebtoonScalingFrame @JvmOverloads constructor(
velocityX: Float,
velocityY: Float,
): Boolean {
if (scale <= 1) return false
if (scale <= 1 || scale.isNaN()) return false
prevPos.set(transX.toInt(), transY.toInt())
overScroller.fling(

View File

@@ -7,20 +7,40 @@ import android.view.inputmethod.EditorInfo
import androidx.preference.EditTextPreference
import androidx.preference.Preference
import androidx.preference.PreferenceCategory
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.network.BaseHttpClient
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.BasePreferenceFragment
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.viewLifecycleScope
import org.koitharu.kotatsu.parsers.util.await
import org.koitharu.kotatsu.settings.utils.EditTextBindListener
import org.koitharu.kotatsu.settings.utils.PasswordSummaryProvider
import org.koitharu.kotatsu.settings.utils.validation.DomainValidator
import org.koitharu.kotatsu.settings.utils.validation.PortNumberValidator
import java.net.Proxy
import javax.inject.Inject
import kotlin.coroutines.cancellation.CancellationException
@AndroidEntryPoint
class ProxySettingsFragment : BasePreferenceFragment(R.string.proxy),
SharedPreferences.OnSharedPreferenceChangeListener {
private var testJob: Job? = null
@Inject
@BaseHttpClient
lateinit var okHttpClient: OkHttpClient
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
addPreferencesFromResource(R.xml.pref_proxy)
findPreference<EditTextPreference>(AppSettings.KEY_PROXY_ADDRESS)?.setOnBindEditTextListener(
@@ -60,6 +80,15 @@ class ProxySettingsFragment : BasePreferenceFragment(R.string.proxy),
super.onDestroyView()
}
override fun onPreferenceTreeClick(preference: Preference): Boolean = when (preference.key) {
AppSettings.PROXY_TEST -> {
testConnection()
true
}
else -> super.onPreferenceTreeClick(preference)
}
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) {
when (key) {
AppSettings.KEY_PROXY_TYPE -> updateDependencies()
@@ -73,5 +102,47 @@ class ProxySettingsFragment : BasePreferenceFragment(R.string.proxy),
findPreference<PreferenceCategory>(AppSettings.KEY_PROXY_AUTH)?.isEnabled = isProxyEnabled
findPreference<Preference>(AppSettings.KEY_PROXY_LOGIN)?.isEnabled = isProxyEnabled
findPreference<Preference>(AppSettings.KEY_PROXY_PASSWORD)?.isEnabled = isProxyEnabled
findPreference<Preference>(AppSettings.PROXY_TEST)?.isEnabled = isProxyEnabled && testJob?.isActive != true
}
private fun testConnection() {
testJob?.cancel()
testJob = viewLifecycleScope.launch {
val pref = findPreference<Preference>(AppSettings.PROXY_TEST)
pref?.run {
setSummary(R.string.loading_)
isEnabled = false
}
try {
withContext(Dispatchers.Default) {
val request = Request.Builder()
.get()
.url("http://neverssl.com")
.build()
val response = okHttpClient.newCall(request).await()
check(response.isSuccessful) { response.message }
}
showTestResult(null)
} catch (e: CancellationException) {
throw e
} catch (e: Throwable) {
e.printStackTraceDebug()
showTestResult(e)
} finally {
pref?.run {
isEnabled = true
summary = null
}
}
}
}
private fun showTestResult(error: Throwable?) {
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.proxy)
.setMessage(error?.getDisplayMessage(resources) ?: getString(R.string.connection_ok))
.setPositiveButton(android.R.string.ok, null)
.setCancelable(true)
.show()
}
}

View File

@@ -26,6 +26,7 @@ class UpdatesFragment : MangaListFragment() {
return when (item.itemId) {
R.id.action_remove -> {
viewModel.remove(controller.snapshot())
mode.finish()
true
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -267,7 +267,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 +669,6 @@
<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>
<string name="connection_ok">Connection is OK</string>
</resources>

View File

@@ -36,4 +36,10 @@
</PreferenceCategory>
<Preference
android:key="proxy_test"
android:persistent="false"
android:title="Test connection"
app:allowDividerAbove="true" />
</PreferenceScreen>