diff --git a/app/build.gradle b/app/build.gradle
index 7bf81f718..cc32b7d0a 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -16,8 +16,8 @@ android {
applicationId 'org.koitharu.kotatsu'
minSdk = 21
targetSdk = 34
- versionCode = 650
- versionName = '7.2.1'
+ versionCode = 651
+ versionName = '7.3'
generatedDensities = []
testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner'
ksp {
@@ -82,7 +82,7 @@ afterEvaluate {
}
dependencies {
//noinspection GradleDependency
- implementation('com.github.KotatsuApp:kotatsu-parsers:f923acc5a7') {
+ implementation('com.github.KotatsuApp:kotatsu-parsers:b06288e7eb') {
exclude group: 'org.json', module: 'json'
}
@@ -93,12 +93,12 @@ dependencies {
implementation 'androidx.appcompat:appcompat:1.7.0'
implementation 'androidx.core:core-ktx:1.13.1'
implementation 'androidx.activity:activity-ktx:1.9.0'
- implementation 'androidx.fragment:fragment-ktx:1.8.0'
+ implementation 'androidx.fragment:fragment-ktx:1.8.1'
implementation 'androidx.transition:transition-ktx:1.5.0'
implementation 'androidx.collection:collection-ktx:1.4.0'
- implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.2'
- implementation 'androidx.lifecycle:lifecycle-service:2.8.2'
- implementation 'androidx.lifecycle:lifecycle-process:2.8.2'
+ implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.3'
+ implementation 'androidx.lifecycle:lifecycle-service:2.8.3'
+ implementation 'androidx.lifecycle:lifecycle-process:2.8.3'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
implementation 'androidx.recyclerview:recyclerview:1.3.2'
@@ -106,7 +106,7 @@ dependencies {
implementation 'androidx.preference:preference-ktx:1.2.1'
implementation 'androidx.biometric:biometric-ktx:1.2.0-alpha05'
implementation 'com.google.android.material:material:1.12.0'
- implementation 'androidx.lifecycle:lifecycle-common-java8:2.8.2'
+ implementation 'androidx.lifecycle:lifecycle-common-java8:2.8.3'
implementation 'androidx.webkit:webkit:1.11.0'
implementation 'androidx.work:work-runtime:2.9.0'
@@ -136,7 +136,7 @@ dependencies {
implementation 'io.coil-kt:coil-base:2.6.0'
implementation 'io.coil-kt:coil-svg:2.6.0'
- implementation 'com.github.KotatsuApp:subsampling-scale-image-view:8cafac256e'
+ implementation 'com.github.KotatsuApp:subsampling-scale-image-view:882bc0620c'
implementation 'com.github.solkin:disk-lru-cache:1.4'
implementation 'io.noties.markwon:core:4.6.2'
@@ -154,10 +154,10 @@ dependencies {
testImplementation 'org.json:json:20240303'
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1'
- androidTestImplementation 'androidx.test:runner:1.5.2'
- androidTestImplementation 'androidx.test:rules:1.5.0'
- androidTestImplementation 'androidx.test:core-ktx:1.5.0'
- androidTestImplementation 'androidx.test.ext:junit-ktx:1.1.5'
+ androidTestImplementation 'androidx.test:runner:1.6.1'
+ androidTestImplementation 'androidx.test:rules:1.6.1'
+ androidTestImplementation 'androidx.test:core-ktx:1.6.1'
+ androidTestImplementation 'androidx.test.ext:junit-ktx:1.2.1'
androidTestImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1'
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/JsonDeserializer.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/JsonDeserializer.kt
index 02229b7de..cc042d7fe 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/JsonDeserializer.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/JsonDeserializer.kt
@@ -12,6 +12,7 @@ import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.util.json.getBooleanOrDefault
import org.koitharu.kotatsu.parsers.util.json.getFloatOrDefault
import org.koitharu.kotatsu.parsers.util.json.getIntOrDefault
+import org.koitharu.kotatsu.parsers.util.json.getLongOrDefault
import org.koitharu.kotatsu.parsers.util.json.getStringOrNull
class JsonDeserializer(private val json: JSONObject) {
@@ -85,6 +86,8 @@ class JsonDeserializer(private val json: JSONObject) {
isEnabled = json.getBoolean("enabled"),
sortKey = json.getInt("sort_key"),
addedIn = json.getIntOrDefault("added_in", 0),
+ lastUsedAt = json.getLongOrDefault("used_at", 0L),
+ isPinned = json.getBooleanOrDefault("pinned", false),
)
fun toMap(): Map {
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/JsonSerializer.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/JsonSerializer.kt
index cfe7451d0..28bf270da 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/JsonSerializer.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/JsonSerializer.kt
@@ -89,6 +89,9 @@ class JsonSerializer private constructor(private val json: JSONObject) {
put("source", e.source)
put("enabled", e.isEnabled)
put("sort_key", e.sortKey)
+ put("added_in", e.addedIn)
+ put("used_at", e.lastUsedAt)
+ put("pinned", e.isPinned)
},
)
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/MangaDatabase.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/db/MangaDatabase.kt
index bda4584aa..1bec0c9b8 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/MangaDatabase.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/db/MangaDatabase.kt
@@ -34,6 +34,7 @@ import org.koitharu.kotatsu.core.db.migrations.Migration18To19
import org.koitharu.kotatsu.core.db.migrations.Migration19To20
import org.koitharu.kotatsu.core.db.migrations.Migration1To2
import org.koitharu.kotatsu.core.db.migrations.Migration20To21
+import org.koitharu.kotatsu.core.db.migrations.Migration21To22
import org.koitharu.kotatsu.core.db.migrations.Migration2To3
import org.koitharu.kotatsu.core.db.migrations.Migration3To4
import org.koitharu.kotatsu.core.db.migrations.Migration4To5
@@ -59,7 +60,7 @@ import org.koitharu.kotatsu.tracker.data.TrackEntity
import org.koitharu.kotatsu.tracker.data.TrackLogEntity
import org.koitharu.kotatsu.tracker.data.TracksDao
-const val DATABASE_VERSION = 21
+const val DATABASE_VERSION = 22
@Database(
entities = [
@@ -120,6 +121,7 @@ fun getDatabaseMigrations(context: Context): Array = arrayOf(
Migration18To19(),
Migration19To20(),
Migration20To21(),
+ Migration21To22(),
)
fun MangaDatabase(context: Context): MangaDatabase = Room
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/dao/MangaSourcesDao.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/db/dao/MangaSourcesDao.kt
index de66fd655..ab1b77150 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/dao/MangaSourcesDao.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/db/dao/MangaSourcesDao.kt
@@ -18,7 +18,7 @@ import org.koitharu.kotatsu.explore.data.SourcesSortOrder
@Dao
abstract class MangaSourcesDao {
- @Query("SELECT * FROM sources ORDER BY sort_key")
+ @Query("SELECT * FROM sources ORDER BY pinned DESC, sort_key")
abstract suspend fun findAll(): List
@Query("SELECT source FROM sources WHERE enabled = 1")
@@ -27,7 +27,10 @@ abstract class MangaSourcesDao {
@Query("SELECT * FROM sources WHERE added_in >= :version")
abstract suspend fun findAllFromVersion(version: Int): List
- @Query("SELECT * FROM sources ORDER BY sort_key")
+ @Query("SELECT * FROM sources ORDER BY used_at DESC LIMIT :limit")
+ abstract suspend fun findLastUsed(limit: Int): List
+
+ @Query("SELECT * FROM sources ORDER BY pinned DESC, sort_key")
abstract fun observeAll(): Flow>
@Query("SELECT enabled FROM sources WHERE source = :source")
@@ -42,6 +45,12 @@ abstract class MangaSourcesDao {
@Query("UPDATE sources SET sort_key = :sortKey WHERE source = :source")
abstract suspend fun setSortKey(source: String, sortKey: Int)
+ @Query("UPDATE sources SET used_at = :value WHERE source = :source")
+ abstract suspend fun setLastUsed(source: String, value: Long)
+
+ @Query("UPDATE sources SET pinned = :isPinned WHERE source = :source")
+ abstract suspend fun setPinned(source: String, isPinned: Boolean)
+
@Insert(onConflict = OnConflictStrategy.IGNORE)
@Transaction
abstract suspend fun insertIfAbsent(entries: Collection)
@@ -49,11 +58,14 @@ abstract class MangaSourcesDao {
@Upsert
abstract suspend fun upsert(entry: MangaSourceEntity)
+ @Query("SELECT * FROM sources WHERE pinned = 1")
+ abstract suspend fun findAllPinned(): List
+
fun observeEnabled(order: SourcesSortOrder): Flow> {
val orderBy = getOrderBy(order)
@Language("RoomSql")
- val query = SimpleSQLiteQuery("SELECT * FROM sources WHERE enabled = 1 ORDER BY $orderBy")
+ val query = SimpleSQLiteQuery("SELECT * FROM sources WHERE enabled = 1 ORDER BY pinned DESC, $orderBy")
return observeImpl(query)
}
@@ -61,7 +73,7 @@ abstract class MangaSourcesDao {
val orderBy = getOrderBy(order)
@Language("RoomSql")
- val query = SimpleSQLiteQuery("SELECT * FROM sources WHERE enabled = 1 ORDER BY $orderBy")
+ val query = SimpleSQLiteQuery("SELECT * FROM sources WHERE enabled = 1 ORDER BY pinned DESC, $orderBy")
return findAllImpl(query)
}
@@ -73,6 +85,8 @@ abstract class MangaSourcesDao {
isEnabled = isEnabled,
sortKey = getMaxSortKey() + 1,
addedIn = BuildConfig.VERSION_CODE,
+ lastUsedAt = 0,
+ isPinned = false,
)
upsert(entity)
}
@@ -91,5 +105,6 @@ abstract class MangaSourcesDao {
SourcesSortOrder.ALPHABETIC -> "source ASC"
SourcesSortOrder.POPULARITY -> "(SELECT COUNT(*) FROM manga WHERE source = sources.source) DESC"
SourcesSortOrder.MANUAL -> "sort_key ASC"
+ SourcesSortOrder.LAST_USED -> "used_at DESC"
}
}
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/entity/MangaSourceEntity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/db/entity/MangaSourceEntity.kt
index 8c8784a46..849cfcd61 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/entity/MangaSourceEntity.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/db/entity/MangaSourceEntity.kt
@@ -15,4 +15,6 @@ data class MangaSourceEntity(
@ColumnInfo(name = "enabled") val isEnabled: Boolean,
@ColumnInfo(name = "sort_key", index = true) val sortKey: Int,
@ColumnInfo(name = "added_in") val addedIn: Int,
+ @ColumnInfo(name = "used_at") val lastUsedAt: Long,
+ @ColumnInfo(name = "pinned") val isPinned: Boolean,
)
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration21To22.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration21To22.kt
new file mode 100644
index 000000000..c45503038
--- /dev/null
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration21To22.kt
@@ -0,0 +1,12 @@
+package org.koitharu.kotatsu.core.db.migrations
+
+import androidx.room.migration.Migration
+import androidx.sqlite.db.SupportSQLiteDatabase
+
+class Migration21To22 : Migration(21, 22) {
+
+ override fun migrate(db: SupportSQLiteDatabase) {
+ db.execSQL("ALTER TABLE sources ADD COLUMN `used_at` INTEGER NOT NULL DEFAULT 0")
+ db.execSQL("ALTER TABLE sources ADD COLUMN `pinned` INTEGER NOT NULL DEFAULT 0")
+ }
+}
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/AppSettings.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/AppSettings.kt
index 4f652cfe6..fdbd623d8 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/AppSettings.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/AppSettings.kt
@@ -33,7 +33,6 @@ import org.koitharu.kotatsu.reader.domain.ReaderColorFilter
import java.io.File
import java.net.Proxy
import java.util.EnumSet
-import java.util.Locale
import javax.inject.Inject
import javax.inject.Singleton
@@ -485,6 +484,15 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val isAutoLocalChaptersCleanupEnabled: Boolean
get() = prefs.getBoolean(KEY_CHAPTERS_CLEAR_AUTO, false)
+ fun isPagesCropEnabled(mode: ReaderMode): Boolean {
+ val rawValue = prefs.getStringSet(KEY_READER_CROP, emptySet())
+ if (rawValue.isNullOrEmpty()) {
+ return false
+ }
+ val needle = if (mode == ReaderMode.WEBTOON) READER_CROP_WEBTOON else READER_CROP_PAGED
+ return needle.toString() in rawValue
+ }
+
fun isTipEnabled(tip: String): Boolean {
return prefs.getStringSet(KEY_TIPS_CLOSED, emptySet())?.contains(tip) != true
}
@@ -597,6 +605,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_READER_ANIMATION = "reader_animation2"
const val KEY_READER_MODE = "reader_mode"
const val KEY_READER_MODE_DETECT = "reader_mode_detect"
+ const val KEY_READER_CROP = "reader_crop"
const val KEY_APP_PASSWORD = "app_password"
const val KEY_APP_PASSWORD_NUMERIC = "app_password_num"
const val KEY_PROTECT_APP = "protect_app"
@@ -698,5 +707,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
// old keys are for migration only
private const val KEY_IMAGES_PROXY_OLD = "images_proxy"
+
+ // values
+ private const val READER_CROP_PAGED = 1
+ private const val READER_CROP_WEBTOON = 2
}
}
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/SearchSuggestionType.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/SearchSuggestionType.kt
index ab3cb3c49..f25ef6f99 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/SearchSuggestionType.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/SearchSuggestionType.kt
@@ -12,5 +12,6 @@ enum class SearchSuggestionType(
QUERIES_SUGGEST(R.string.suggested_queries),
MANGA(R.string.content_type_manga),
SOURCES(R.string.remote_sources),
+ RECENT_SOURCES(R.string.recent_sources),
AUTHORS(R.string.authors),
}
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/SourceSettings.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/SourceSettings.kt
index 8b87e8169..8b38fb341 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/SourceSettings.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/SourceSettings.kt
@@ -38,6 +38,7 @@ class SourceSettings(context: Context, source: MangaSource) : MangaSourceConfig
is ConfigKey.ShowSuspiciousContent -> prefs.getBoolean(key.key, key.defaultValue)
is ConfigKey.SplitByTranslations -> prefs.getBoolean(key.key, key.defaultValue)
+ is ConfigKey.PreferredImageServer -> prefs.getString(key.key, key.defaultValue)?.takeUnless(String::isEmpty)
} as T
}
@@ -47,6 +48,7 @@ class SourceSettings(context: Context, source: MangaSource) : MangaSourceConfig
is ConfigKey.ShowSuspiciousContent -> putBoolean(key.key, value as Boolean)
is ConfigKey.UserAgent -> putString(key.key, (value as String?)?.sanitizeHeaderValue())
is ConfigKey.SplitByTranslations -> putBoolean(key.key, value as Boolean)
+ is ConfigKey.PreferredImageServer -> putString(key.key, value as String? ?: "")
}
}
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/image/TrimTransformation.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/image/TrimTransformation.kt
index 88dda77b5..15695ff11 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/image/TrimTransformation.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/image/TrimTransformation.kt
@@ -1,15 +1,10 @@
package org.koitharu.kotatsu.core.ui.image
import android.graphics.Bitmap
-import androidx.annotation.ColorInt
-import androidx.core.graphics.alpha
-import androidx.core.graphics.blue
import androidx.core.graphics.get
-import androidx.core.graphics.green
-import androidx.core.graphics.red
import coil.size.Size
import coil.transform.Transformation
-import kotlin.math.abs
+import org.koitharu.kotatsu.reader.domain.EdgeDetector.Companion.isColorTheSame
class TrimTransformation(
private val tolerance: Int = 20,
@@ -28,7 +23,7 @@ class TrimTransformation(
var isColBlank = true
val prevColor = input[x, 0]
for (y in 1 until input.height) {
- if (!isColorTheSame(input[x, y], prevColor)) {
+ if (!isColorTheSame(input[x, y], prevColor, tolerance)) {
isColBlank = false
break
}
@@ -47,7 +42,7 @@ class TrimTransformation(
var isColBlank = true
val prevColor = input[x, 0]
for (y in 1 until input.height) {
- if (!isColorTheSame(input[x, y], prevColor)) {
+ if (!isColorTheSame(input[x, y], prevColor, tolerance)) {
isColBlank = false
break
}
@@ -63,7 +58,7 @@ class TrimTransformation(
var isRowBlank = true
val prevColor = input[0, y]
for (x in 1 until input.width) {
- if (!isColorTheSame(input[x, y], prevColor)) {
+ if (!isColorTheSame(input[x, y], prevColor, tolerance)) {
isRowBlank = false
break
}
@@ -79,7 +74,7 @@ class TrimTransformation(
var isRowBlank = true
val prevColor = input[0, y]
for (x in 1 until input.width) {
- if (!isColorTheSame(input[x, y], prevColor)) {
+ if (!isColorTheSame(input[x, y], prevColor, tolerance)) {
isRowBlank = false
break
}
@@ -98,13 +93,6 @@ class TrimTransformation(
}
}
- private fun isColorTheSame(@ColorInt a: Int, @ColorInt b: Int): Boolean {
- return abs(a.red - b.red) <= tolerance &&
- abs(a.green - b.green) <= tolerance &&
- abs(a.blue - b.blue) <= tolerance &&
- abs(a.alpha - b.alpha) <= tolerance
- }
-
override fun equals(other: Any?): Boolean {
return this === other || (other is TrimTransformation && other.tolerance == tolerance)
}
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/SelectableTextView.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/SelectableTextView.kt
index 1f80fb57b..41e0f2fc8 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/SelectableTextView.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/SelectableTextView.kt
@@ -23,11 +23,16 @@ class SelectableTextView @JvmOverloads constructor(
private fun fixSelectionRange() {
if (selectionStart < 0 || selectionEnd < 0) {
val spannableText = text as? Spannable ?: return
- Selection.setSelection(spannableText, text.length)
+ Selection.setSelection(spannableText, spannableText.length)
}
}
override fun scrollTo(x: Int, y: Int) {
super.scrollTo(0, 0)
}
+
+ fun selectAll() {
+ val spannableText = text as? Spannable ?: return
+ Selection.selectAll(spannableText)
+ }
}
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Collections.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Collections.kt
index fb41830ae..11400295c 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Collections.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Collections.kt
@@ -69,4 +69,11 @@ fun Iterable.sortedWithSafe(comparator: Comparator): List = try
}
}
-fun Collection<*>?.sizeOrZero() = if (this == null) 0 else size
+fun Collection<*>?.sizeOrZero() = this?.size ?: 0
+
+@Suppress("UNCHECKED_CAST")
+inline fun Collection.mapToArray(transform: (T) -> R): Array {
+ val result = arrayOfNulls(size)
+ forEachIndexed { index, t -> result[index] = transform(t) }
+ return result as Array
+}
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Coroutines.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Coroutines.kt
index c2ccef138..ab7f26c38 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Coroutines.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Coroutines.kt
@@ -12,12 +12,16 @@ import kotlinx.coroutines.CancellableContinuation
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.joinAll
import kotlinx.coroutines.plus
import kotlinx.coroutines.suspendCancellableCoroutine
import org.koitharu.kotatsu.core.util.AcraCoroutineErrorHandler
import org.koitharu.kotatsu.core.util.RetainedLifecycleCoroutineScope
+import org.koitharu.kotatsu.parsers.util.cancelAll
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import kotlin.coroutines.EmptyCoroutineContext
+import kotlin.coroutines.cancellation.CancellationException
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
@@ -90,3 +94,10 @@ fun Deferred.peek(): T? = if (isCompleted) {
} else {
null
}
+
+@Suppress("SuspendFunctionOnCoroutineScope")
+suspend fun CoroutineScope.cancelChildrenAndJoin(cause: CancellationException? = null) {
+ val jobs = coroutineContext[Job]?.children?.toList() ?: return
+ jobs.cancelAll(cause)
+ jobs.joinAll()
+}
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Graphics.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Graphics.kt
index 2e59b582f..2a9f0b81c 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Graphics.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Graphics.kt
@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.core.util.ext
+import android.graphics.Bitmap
import android.graphics.Rect
import kotlin.math.roundToInt
@@ -11,3 +12,9 @@ fun Rect.scale(factor: Double) {
(height() - newHeight) / 2,
)
}
+
+inline fun Bitmap.use(block: (Bitmap) -> R) = try {
+ block(this)
+} finally {
+ recycle()
+}
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsActivity.kt
index af11b86e3..2d9987de9 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsActivity.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsActivity.kt
@@ -125,7 +125,6 @@ class DetailsActivity :
lateinit var tagHighlighter: ListExtraProvider
private val viewModel: DetailsViewModel by viewModels()
-
private lateinit var menuProvider: DetailsMenuProvider
override fun onCreate(savedInstanceState: Bundle?) {
@@ -159,6 +158,7 @@ class DetailsActivity :
viewBinding.containerBottomSheet?.let { sheet ->
onBackPressedDispatcher.addCallback(BottomSheetCollapseCallback(sheet))
}
+ TitleExpandListener(viewBinding.textViewTitle).attach()
viewModel.details.filterNotNull().observe(this, ::onMangaUpdated)
viewModel.onMangaRemoved.observeEvent(this, ::onMangaRemoved)
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/TitleExpandListener.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/TitleExpandListener.kt
new file mode 100644
index 000000000..5d5765100
--- /dev/null
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/TitleExpandListener.kt
@@ -0,0 +1,45 @@
+package org.koitharu.kotatsu.details.ui
+
+import android.annotation.SuppressLint
+import android.transition.TransitionManager
+import android.view.GestureDetector
+import android.view.MotionEvent
+import android.view.View
+import android.view.View.OnTouchListener
+import android.view.ViewGroup
+import org.koitharu.kotatsu.R
+import org.koitharu.kotatsu.core.ui.widgets.SelectableTextView
+import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled
+
+@SuppressLint("ClickableViewAccessibility")
+class TitleExpandListener(
+ private val textView: SelectableTextView,
+) : GestureDetector.SimpleOnGestureListener(), OnTouchListener {
+
+ private val gestureDetector = GestureDetector(textView.context, this)
+ private val linesExpanded = textView.resources.getInteger(R.integer.details_description_lines)
+ private val linesCollapsed = textView.resources.getInteger(R.integer.details_title_lines)
+
+ override fun onTouch(v: View?, event: MotionEvent) = gestureDetector.onTouchEvent(event)
+
+ override fun onSingleTapConfirmed(e: MotionEvent): Boolean {
+ if (textView.context.isAnimationsEnabled) {
+ TransitionManager.beginDelayedTransition(textView.parent as ViewGroup)
+ }
+ if (textView.maxLines in 1 until Integer.MAX_VALUE) {
+ textView.maxLines = Integer.MAX_VALUE
+ } else {
+ textView.maxLines = linesCollapsed
+ }
+ return true
+ }
+
+ override fun onLongPress(e: MotionEvent) {
+ textView.maxLines = Integer.MAX_VALUE
+ textView.selectAll()
+ }
+
+ fun attach() {
+ textView.setOnTouchListener(this)
+ }
+}
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/model/HistoryInfo.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/model/HistoryInfo.kt
index e59f7f712..a87a32b97 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/model/HistoryInfo.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/model/HistoryInfo.kt
@@ -31,7 +31,11 @@ fun HistoryInfo(
history: MangaHistory?,
isIncognitoMode: Boolean
): HistoryInfo {
- val chapters = manga?.chapters?.get(branch)
+ val chapters = if (manga?.chapters?.isEmpty() == true) {
+ emptyList()
+ } else {
+ manga?.chapters?.get(branch)
+ }
val currentChapter = if (history != null && !chapters.isNullOrEmpty()) {
chapters.indexOfFirst { it.id == history.chapterId }
} else {
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/pages/MangaPageFetcher.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/pages/MangaPageFetcher.kt
index 3bfe70c52..e954b630e 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/pages/MangaPageFetcher.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/pages/MangaPageFetcher.kt
@@ -92,7 +92,7 @@ class MangaPageFetcher(
}
else -> {
- val request = PageLoader.createPageRequest(page, pageUrl)
+ val request = PageLoader.createPageRequest(pageUrl, page.source)
imageProxyInterceptor.interceptPageRequest(request, okHttpClient).use { response ->
if (!response.isSuccessful) {
throw HttpException(response)
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadWorker.kt b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadWorker.kt
index f89ba563e..eeb507b59 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadWorker.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadWorker.kt
@@ -35,17 +35,18 @@ import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
-import okhttp3.Request
import okhttp3.internal.closeQuietly
import okio.IOException
import okio.buffer
import okio.sink
+import okio.use
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.TooManyRequestExceptions
import org.koitharu.kotatsu.core.model.ids
import org.koitharu.kotatsu.core.model.isLocal
import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.core.network.MangaHttpClient
+import org.koitharu.kotatsu.core.network.imageproxy.ImageProxyInterceptor
import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings
@@ -56,6 +57,7 @@ import org.koitharu.kotatsu.core.util.ext.awaitWorkInfosByTag
import org.koitharu.kotatsu.core.util.ext.deleteAwait
import org.koitharu.kotatsu.core.util.ext.deleteWork
import org.koitharu.kotatsu.core.util.ext.deleteWorks
+import org.koitharu.kotatsu.core.util.ext.ensureSuccess
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
import org.koitharu.kotatsu.core.util.ext.getWorkInputData
import org.koitharu.kotatsu.core.util.ext.getWorkSpec
@@ -74,9 +76,9 @@ import org.koitharu.kotatsu.local.domain.model.LocalManga
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaSource
-import org.koitharu.kotatsu.parsers.util.await
import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
+import org.koitharu.kotatsu.reader.domain.PageLoader
import java.io.File
import java.util.UUID
import java.util.concurrent.TimeUnit
@@ -94,6 +96,7 @@ class DownloadWorker @AssistedInject constructor(
private val mangaRepositoryFactory: MangaRepository.Factory,
private val settings: AppSettings,
@LocalStorageChanges private val localStorageChanges: MutableSharedFlow,
+ private val imageProxyInterceptor: ImageProxyInterceptor,
notificationFactoryFactory: DownloadNotificationFactory.Factory,
) : CoroutineWorker(appContext, params) {
@@ -328,28 +331,24 @@ class DownloadWorker @AssistedInject constructor(
destination: File,
source: MangaSource,
): File {
- val request = Request.Builder()
- .url(url)
- .tag(MangaSource::class.java, source)
- .header(CommonHeaders.ACCEPT, "image/webp,image/png;q=0.9,image/jpeg,*/*;q=0.8")
- .cacheControl(CommonHeaders.CACHE_CONTROL_NO_STORE)
- .get()
- .build()
+ val request = PageLoader.createPageRequest(url, source)
slowdownDispatcher.delay(source)
- val call = okHttp.newCall(request)
- val file = File(destination, UUID.randomUUID().toString() + ".tmp")
- try {
- val response = call.clone().await()
- checkNotNull(response.body).use { body ->
- file.sink(append = false).buffer().use {
- it.writeAllCancellable(body.source())
+ return imageProxyInterceptor.interceptPageRequest(request, okHttp)
+ .ensureSuccess()
+ .use { response ->
+ val file = File(destination, UUID.randomUUID().toString() + ".tmp")
+ try {
+ checkNotNull(response.body).use { body ->
+ file.sink(append = false).buffer().use {
+ it.writeAllCancellable(body.source())
+ }
+ }
+ } catch (e: CancellationException) {
+ file.delete()
+ throw e
}
+ file
}
- } catch (e: CancellationException) {
- file.delete()
- throw e
- }
- return file
}
private suspend fun publishState(state: DownloadState) {
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/explore/data/MangaSourcesRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/explore/data/MangaSourcesRepository.kt
index b8c2732e0..31ecdc995 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/explore/data/MangaSourcesRepository.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/explore/data/MangaSourcesRepository.kt
@@ -1,7 +1,9 @@
package org.koitharu.kotatsu.explore.data
+import android.content.Context
import androidx.room.withTransaction
import dagger.Reusable
+import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
@@ -12,6 +14,7 @@ import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.dao.MangaSourcesDao
import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity
+import org.koitharu.kotatsu.core.model.getTitle
import org.koitharu.kotatsu.core.model.isNsfw
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.observeAsFlow
@@ -19,6 +22,7 @@ import org.koitharu.kotatsu.core.ui.util.ReversibleHandle
import org.koitharu.kotatsu.parsers.model.ContentType
import org.koitharu.kotatsu.parsers.model.MangaParserSource
import org.koitharu.kotatsu.parsers.model.MangaSource
+import org.koitharu.kotatsu.parsers.util.mapNotNullToSet
import org.koitharu.kotatsu.parsers.util.mapToSet
import java.util.Collections
import java.util.EnumSet
@@ -27,6 +31,7 @@ import javax.inject.Inject
@Reusable
class MangaSourcesRepository @Inject constructor(
+ @ApplicationContext private val context: Context,
private val db: MangaDatabase,
private val settings: AppSettings,
) {
@@ -50,6 +55,19 @@ class MangaSourcesRepository @Inject constructor(
return dao.findAllEnabled(order).toSources(settings.isNsfwContentDisabled, order)
}
+ suspend fun getPinnedSources(): Set {
+ assimilateNewSources()
+ val skipNsfw = settings.isNsfwContentDisabled
+ return dao.findAllPinned().mapNotNullToSet {
+ it.source.toMangaSourceOrNull()?.takeUnless { x -> skipNsfw && x.isNsfw() }
+ }
+ }
+
+ suspend fun getTopSources(limit: Int): List {
+ assimilateNewSources()
+ return dao.findLastUsed(limit).toSources(settings.isNsfwContentDisabled, null)
+ }
+
suspend fun getDisabledSources(): Set {
assimilateNewSources()
val result = EnumSet.copyOf(remoteSources)
@@ -69,7 +87,7 @@ class MangaSourcesRepository @Inject constructor(
query: String?,
locale: String?,
sortOrder: SourcesSortOrder?,
- ): List {
+ ): List {
assimilateNewSources()
val entities = dao.findAll().toMutableList()
if (isDisabledOnly) {
@@ -83,17 +101,17 @@ class MangaSourcesRepository @Inject constructor(
sortOrder = sortOrder,
)
if (locale != null) {
- sources.retainAll { it.locale == locale }
+ sources.retainAll { it is MangaParserSource && it.locale == locale }
}
if (excludeBroken) {
- sources.removeAll { it.isBroken }
+ sources.removeAll { it is MangaParserSource && it.isBroken }
}
if (types.isNotEmpty()) {
- sources.retainAll { it.contentType in types }
+ sources.retainAll { it is MangaParserSource && it.contentType in types }
}
if (!query.isNullOrEmpty()) {
sources.retainAll {
- it.title.contains(query, ignoreCase = true) || it.name.contains(query, ignoreCase = true)
+ it.getTitle(context).contains(query, ignoreCase = true) || it.name.contains(query, ignoreCase = true)
}
}
return sources
@@ -213,6 +231,8 @@ class MangaSourcesRepository @Inject constructor(
isEnabled = false,
sortKey = ++maxSortKey,
addedIn = BuildConfig.VERSION_CODE,
+ lastUsedAt = 0,
+ isPinned = false,
)
}
dao.insertIfAbsent(entities)
@@ -223,6 +243,19 @@ class MangaSourcesRepository @Inject constructor(
return settings.sourcesVersion == 0 && dao.findAllEnabledNames().isEmpty()
}
+ suspend fun setIsPinned(sources: Collection, isPinned: Boolean): ReversibleHandle {
+ setSourcesPinnedImpl(sources, isPinned)
+ return ReversibleHandle {
+ setSourcesEnabledImpl(sources, !isPinned)
+ }
+ }
+
+ suspend fun trackUsage(source: MangaSource) {
+ if (!settings.isIncognitoModeEnabled && !(settings.isHistoryExcludeNsfw && source.isNsfw())) {
+ dao.setLastUsed(source.name, System.currentTimeMillis())
+ }
+ }
+
private suspend fun setSourcesEnabledImpl(sources: Collection, isEnabled: Boolean) {
if (sources.size == 1) { // fast path
dao.setEnabled(sources.first().name, isEnabled)
@@ -244,11 +277,25 @@ class MangaSourcesRepository @Inject constructor(
return result
}
+ private suspend fun setSourcesPinnedImpl(sources: Collection, isPinned: Boolean) {
+ if (sources.size == 1) { // fast path
+ dao.setPinned(sources.first().name, isPinned)
+ return
+ }
+ db.withTransaction {
+ for (source in sources) {
+ dao.setPinned(source.name, isPinned)
+ }
+ }
+ }
+
+
private fun List.toSources(
skipNsfwSources: Boolean,
sortOrder: SourcesSortOrder?,
- ): MutableList {
- val result = ArrayList(size)
+ ): MutableList {
+ val result = ArrayList(size)
+ val pinned = HashSet()
for (entity in this) {
val source = entity.source.toMangaSourceOrNull() ?: continue
if (skipNsfwSources && source.isNsfw()) {
@@ -256,10 +303,13 @@ class MangaSourcesRepository @Inject constructor(
}
if (source in remoteSources) {
result.add(source)
+ if (entity.isPinned) {
+ pinned.add(source)
+ }
}
}
if (sortOrder == SourcesSortOrder.ALPHABETIC) {
- result.sortBy { it.title }
+ result.sortWith(compareBy { it in pinned }.thenBy { it.getTitle(context) })
}
return result
}
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/explore/data/SourcesSortOrder.kt b/app/src/main/kotlin/org/koitharu/kotatsu/explore/data/SourcesSortOrder.kt
index 9c42be758..041fbe678 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/explore/data/SourcesSortOrder.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/explore/data/SourcesSortOrder.kt
@@ -9,4 +9,5 @@ enum class SourcesSortOrder(
ALPHABETIC(R.string.by_name),
POPULARITY(R.string.popular),
MANUAL(R.string.manual),
+ LAST_USED(R.string.last_used),
}
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/ExploreFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/ExploreFragment.kt
index 28f165740..84e60f38a 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/ExploreFragment.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/ExploreFragment.kt
@@ -197,6 +197,16 @@ class ExploreFragment :
mode.finish()
}
+ R.id.action_pin -> {
+ viewModel.setSourcesPinned(selectedSources, isPinned = true)
+ mode.finish()
+ }
+
+ R.id.action_unpin -> {
+ viewModel.setSourcesPinned(selectedSources, isPinned = false)
+ mode.finish()
+ }
+
else -> return false
}
return true
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/ExploreViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/ExploreViewModel.kt
index 30eba8c14..fad516b34 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/ExploreViewModel.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/ExploreViewModel.kt
@@ -108,6 +108,18 @@ class ExploreViewModel @Inject constructor(
}
}
+ fun setSourcesPinned(sources: Set, isPinned: Boolean) {
+ launchJob(Dispatchers.Default) {
+ sourcesRepository.setIsPinned(sources, isPinned)
+ val message = if (sources.size == 1) {
+ if (isPinned) R.string.source_pinned else R.string.source_unpinned
+ } else {
+ if (isPinned) R.string.sources_pinned else R.string.sources_unpinned
+ }
+ onActionDone.call(ReversibleAction(message, null))
+ }
+ }
+
fun respondSuggestionTip(isAccepted: Boolean) {
settings.isSuggestionsEnabled = isAccepted
settings.closeTip(TIP_SUGGESTIONS)
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/PagesCache.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/PagesCache.kt
index a8c3763f7..a66b60956 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/PagesCache.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/PagesCache.kt
@@ -82,6 +82,13 @@ class PagesCache @Inject constructor(@ApplicationContext context: Context) {
}
}
+ suspend fun clear() {
+ val cache = lruCache.get()
+ runInterruptible(Dispatchers.IO) {
+ cache.clearCache()
+ }
+ }
+
private suspend fun getAvailableSize(): Long = runCatchingCancellable {
val statFs = StatFs(cacheDir.get().absolutePath)
statFs.availableBytes
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt
index c38aa1de3..4eed4c626 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt
@@ -13,6 +13,7 @@ import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.core.util.ext.toFileOrNull
import org.koitharu.kotatsu.core.util.ext.toUriOrNull
import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
+import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
import org.koitharu.kotatsu.explore.domain.ExploreRepository
import org.koitharu.kotatsu.filter.ui.FilterCoordinator
import org.koitharu.kotatsu.list.domain.ListExtraProvider
@@ -39,6 +40,7 @@ class LocalListViewModel @Inject constructor(
exploreRepository: ExploreRepository,
@LocalStorageChanges private val localStorageChanges: SharedFlow,
private val localStorageManager: LocalStorageManager,
+ sourcesRepository: MangaSourcesRepository,
) : RemoteListViewModel(
savedStateHandle,
mangaRepositoryFactory,
@@ -47,6 +49,7 @@ class LocalListViewModel @Inject constructor(
listExtraProvider,
downloadScheduler,
exploreRepository,
+ sourcesRepository,
), SharedPreferences.OnSharedPreferenceChangeListener {
val onMangaRemoved = MutableEventFlow()
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/DetectReaderModeUseCase.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/DetectReaderModeUseCase.kt
index cb7ec0917..21c8cea06 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/DetectReaderModeUseCase.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/DetectReaderModeUseCase.kt
@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.reader.domain
import android.graphics.BitmapFactory
import android.net.Uri
import android.util.Size
+import androidx.core.net.toFile
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
import okhttp3.OkHttpClient
@@ -14,6 +15,8 @@ import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.ReaderMode
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
+import org.koitharu.kotatsu.local.data.isFileUri
+import org.koitharu.kotatsu.local.data.isZipUri
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
@@ -61,19 +64,28 @@ class DetectReaderModeUseCase @Inject constructor(
val page = requireNotNull(pages.getOrNull(pageIndex)) { "No pages" }
val url = repository.getPageUrl(page)
val uri = Uri.parse(url)
- val size = if (uri.scheme == "cbz") {
- runInterruptible(Dispatchers.IO) {
+
+ val size = when {
+ uri.isZipUri() -> runInterruptible(Dispatchers.IO) {
val zip = ZipFile(uri.schemeSpecificPart)
val entry = zip.getEntry(uri.fragment)
zip.getInputStream(entry).use {
getBitmapSize(it)
}
}
- } else {
- val request = PageLoader.createPageRequest(page, url)
- imageProxyInterceptor.interceptPageRequest(request, okHttpClient).use {
- runInterruptible(Dispatchers.IO) {
- getBitmapSize(it.body?.byteStream())
+
+ uri.isFileUri() -> runInterruptible(Dispatchers.IO) {
+ uri.toFile().inputStream().use {
+ getBitmapSize(it)
+ }
+ }
+
+ else -> {
+ val request = PageLoader.createPageRequest(url, page.source)
+ imageProxyInterceptor.interceptPageRequest(request, okHttpClient).use {
+ runInterruptible(Dispatchers.IO) {
+ getBitmapSize(it.body?.byteStream())
+ }
}
}
}
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/EdgeDetector.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/EdgeDetector.kt
new file mode 100644
index 000000000..c21e83300
--- /dev/null
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/EdgeDetector.kt
@@ -0,0 +1,150 @@
+package org.koitharu.kotatsu.reader.domain
+
+import android.content.Context
+import android.graphics.Bitmap
+import android.graphics.Color
+import android.graphics.Point
+import android.graphics.Rect
+import androidx.annotation.ColorInt
+import androidx.core.graphics.alpha
+import androidx.core.graphics.blue
+import androidx.core.graphics.get
+import androidx.core.graphics.green
+import androidx.core.graphics.red
+import com.davemorrissey.labs.subscaleview.ImageSource
+import com.davemorrissey.labs.subscaleview.decoder.ImageRegionDecoder
+import com.davemorrissey.labs.subscaleview.decoder.SkiaPooledImageRegionDecoder
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.async
+import kotlinx.coroutines.awaitAll
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.runInterruptible
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import kotlinx.coroutines.withContext
+import org.koitharu.kotatsu.core.util.ext.use
+import kotlin.math.abs
+
+class EdgeDetector(private val context: Context) {
+
+ private val mutex = Mutex()
+
+ suspend fun getBounds(imageSource: ImageSource): Rect? = mutex.withLock {
+ withContext(Dispatchers.IO) {
+ val decoder = SkiaPooledImageRegionDecoder(Bitmap.Config.RGB_565)
+ try {
+ val size = runInterruptible {
+ decoder.init(context, imageSource)
+ }
+ val edges = coroutineScope {
+ listOf(
+ async { detectLeftRightEdge(decoder, size, isLeft = true) },
+ async { detectTopBottomEdge(decoder, size, isTop = true) },
+ async { detectLeftRightEdge(decoder, size, isLeft = false) },
+ async { detectTopBottomEdge(decoder, size, isTop = false) },
+ ).awaitAll()
+ }
+ var hasEdges = false
+ for (edge in edges) {
+ if (edge > 0) {
+ hasEdges = true
+ } else if (edge < 0) {
+ return@withContext null
+ }
+ }
+ if (hasEdges) {
+ Rect(edges[0], edges[1], size.x - edges[2], size.y - edges[3])
+ } else {
+ null
+ }
+ } finally {
+ decoder.recycle()
+ }
+ }
+ }
+
+ private fun detectLeftRightEdge(decoder: ImageRegionDecoder, size: Point, isLeft: Boolean): Int {
+ var width = size.x
+ val rectCount = size.x / BLOCK_SIZE
+ val maxRect = rectCount / 3
+ for (i in 0 until rectCount) {
+ if (i > maxRect) {
+ return -1
+ }
+ var dd = BLOCK_SIZE
+ for (j in 0 until size.y / BLOCK_SIZE) {
+ val regionX = if (isLeft) i * BLOCK_SIZE else size.x - (i + 1) * BLOCK_SIZE
+ decoder.decodeRegion(region(regionX, j * BLOCK_SIZE), 1).use { bitmap ->
+ for (ii in 0 until minOf(BLOCK_SIZE, dd)) {
+ for (jj in 0 until BLOCK_SIZE) {
+ val bi = if (isLeft) ii else BLOCK_SIZE - ii - 1
+ if (bitmap[bi, jj].isNotWhite()) {
+ width = minOf(width, BLOCK_SIZE * i + ii)
+ dd--
+ break
+ }
+ }
+ }
+ }
+ if (dd == 0) {
+ break
+ }
+ }
+ if (dd < BLOCK_SIZE) {
+ break // We have already found vertical field or it is not exist
+ }
+ }
+ return width
+ }
+
+ private fun detectTopBottomEdge(decoder: ImageRegionDecoder, size: Point, isTop: Boolean): Int {
+ var height = size.y
+ val rectCount = size.y / BLOCK_SIZE
+ val maxRect = rectCount / 3
+ for (j in 0 until rectCount) {
+ if (j > maxRect) {
+ return -1
+ }
+ var dd = BLOCK_SIZE
+ for (i in 0 until size.x / BLOCK_SIZE) {
+ val regionY = if (isTop) j * BLOCK_SIZE else size.y - (j + 1) * BLOCK_SIZE
+ decoder.decodeRegion(region(i * BLOCK_SIZE, regionY), 1).use { bitmap ->
+ for (jj in 0 until minOf(BLOCK_SIZE, dd)) {
+ for (ii in 0 until BLOCK_SIZE) {
+ val bj = if (isTop) jj else BLOCK_SIZE - jj - 1
+ if (bitmap[ii, bj].isNotWhite()) {
+ height = minOf(height, BLOCK_SIZE * j + jj)
+ dd--
+ break
+ }
+ }
+ }
+ }
+ if (dd == 0) {
+ break
+ }
+ }
+ if (dd < BLOCK_SIZE) {
+ break // We have already found vertical field or it is not exist
+ }
+ }
+ return height
+ }
+
+ companion object {
+
+ private const val BLOCK_SIZE = 100
+ private const val COLOR_TOLERANCE = 16
+
+ fun isColorTheSame(@ColorInt a: Int, @ColorInt b: Int, tolerance: Int): Boolean {
+ return abs(a.red - b.red) <= tolerance &&
+ abs(a.green - b.green) <= tolerance &&
+ abs(a.blue - b.blue) <= tolerance &&
+ abs(a.alpha - b.alpha) <= tolerance
+ }
+
+ private fun Int.isNotWhite() = !isColorTheSame(this, Color.WHITE, COLOR_TOLERANCE)
+
+ private fun region(x: Int, y: Int) = Rect(x, y, x + BLOCK_SIZE, y + BLOCK_SIZE)
+ }
+}
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/PageLoader.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/PageLoader.kt
index ec6e41640..68d4adb55 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/PageLoader.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/PageLoader.kt
@@ -2,12 +2,14 @@ package org.koitharu.kotatsu.reader.domain
import android.content.Context
import android.graphics.BitmapFactory
+import android.graphics.Rect
import android.net.Uri
import androidx.annotation.AnyThread
import androidx.collection.LongSparseArray
import androidx.collection.set
import androidx.core.net.toFile
import androidx.core.net.toUri
+import com.davemorrissey.labs.subscaleview.ImageSource
import dagger.hilt.android.ActivityRetainedLifecycle
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.scopes.ActivityRetainedScoped
@@ -35,6 +37,7 @@ import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.FileSize
import org.koitharu.kotatsu.core.util.RetainedLifecycleCoroutineScope
import org.koitharu.kotatsu.core.util.ext.URI_SCHEME_ZIP
+import org.koitharu.kotatsu.core.util.ext.cancelChildrenAndJoin
import org.koitharu.kotatsu.core.util.ext.compressToPNG
import org.koitharu.kotatsu.core.util.ext.ensureRamAtLeast
import org.koitharu.kotatsu.core.util.ext.ensureSuccess
@@ -44,6 +47,7 @@ import org.koitharu.kotatsu.core.util.ext.isPowerSaveMode
import org.koitharu.kotatsu.core.util.ext.isTargetNotEmpty
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.ramAvailable
+import org.koitharu.kotatsu.core.util.ext.use
import org.koitharu.kotatsu.core.util.ext.withProgress
import org.koitharu.kotatsu.core.util.progress.ProgressDeferred
import org.koitharu.kotatsu.local.data.PagesCache
@@ -51,6 +55,7 @@ import org.koitharu.kotatsu.local.data.isFileUri
import org.koitharu.kotatsu.local.data.isZipUri
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaSource
+import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
import java.util.LinkedList
import java.util.concurrent.atomic.AtomicInteger
@@ -83,6 +88,7 @@ class PageLoader @Inject constructor(
private val prefetchQueue = LinkedList()
private val counter = AtomicInteger(0)
private var prefetchQueueLimit = PREFETCH_LIMIT_DEFAULT // TODO adaptive
+ private val edgeDetector = EdgeDetector(context)
fun isPrefetchApplicable(): Boolean {
return repository is RemoteMangaRepository
@@ -142,22 +148,33 @@ class PageLoader @Inject constructor(
} else {
val file = uri.toFile()
context.ensureRamAtLeast(file.length() * 2)
- val image = runInterruptible(Dispatchers.IO) {
+ runInterruptible(Dispatchers.IO) {
BitmapFactory.decodeFile(file.absolutePath)
- }
- try {
+ }.use { image ->
image.compressToPNG(file)
- } finally {
- image.recycle()
}
uri
}
}
+ suspend fun getTrimmedBounds(uri: Uri): Rect? = runCatchingCancellable {
+ edgeDetector.getBounds(ImageSource.Uri(uri))
+ }.onFailure { error ->
+ error.printStackTraceDebug()
+ }.getOrNull()
+
suspend fun getPageUrl(page: MangaPage): String {
return getRepository(page.source).getPageUrl(page)
}
+ suspend fun invalidate(clearCache: Boolean) {
+ tasks.clear()
+ loaderScope.cancelChildrenAndJoin()
+ if (clearCache) {
+ cache.clear()
+ }
+ }
+
private fun onIdle() = loaderScope.launch {
prefetchLock.withLock {
while (prefetchQueue.isNotEmpty()) {
@@ -213,7 +230,7 @@ class PageLoader @Inject constructor(
uri.isFileUri() -> uri
else -> {
- val request = createPageRequest(page, pageUrl)
+ val request = createPageRequest(pageUrl, page.source)
imageProxyInterceptor.interceptPageRequest(request, okHttp).ensureSuccess().use { response ->
val body = checkNotNull(response.body) { "Null response body" }
body.withProgress(progress).use {
@@ -248,12 +265,12 @@ class PageLoader @Inject constructor(
private const val PREFETCH_LIMIT_DEFAULT = 6
private const val PREFETCH_MIN_RAM_MB = 80L
- fun createPageRequest(page: MangaPage, pageUrl: String) = Request.Builder()
+ fun createPageRequest(pageUrl: String, mangaSource: MangaSource) = Request.Builder()
.url(pageUrl)
.get()
.header(CommonHeaders.ACCEPT, "image/webp,image/png;q=0.9,image/jpeg,*/*;q=0.8")
.cacheControl(CommonHeaders.CACHE_CONTROL_NO_STORE)
- .tag(MangaSource::class.java, page.source)
+ .tag(MangaSource::class.java, mangaSource)
.build()
}
}
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt
index 5a145b563..38ccf2da1 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt
@@ -283,7 +283,9 @@ constructor(
prevJob?.cancelAndJoin()
content.value = ReaderContent(emptyList(), null)
chaptersLoader.loadSingleChapter(id)
- content.value = ReaderContent(chaptersLoader.snapshot(), ReaderState(id, page, 0))
+ val newState = ReaderState(id, page, 0)
+ content.value = ReaderContent(chaptersLoader.snapshot(), newState)
+ saveCurrentState(newState)
}
}
@@ -291,17 +293,27 @@ constructor(
val prevJob = loadingJob
loadingJob = launchLoadingJob(Dispatchers.Default) {
prevJob?.cancelAndJoin()
- val currentChapterId = currentState.requireValue().chapterId
- val allChapters = checkNotNull(manga).allChapters
- var index = allChapters.indexOfFirst { x -> x.id == currentChapterId }
- if (index < 0) {
- return@launchLoadingJob
+ val prevState = currentState.requireValue()
+ val newChapterId = if (delta != 0) {
+ val allChapters = checkNotNull(manga).allChapters
+ var index = allChapters.indexOfFirst { x -> x.id == prevState.chapterId }
+ if (index < 0) {
+ return@launchLoadingJob
+ }
+ index += delta
+ (allChapters.getOrNull(index) ?: return@launchLoadingJob).id
+ } else {
+ prevState.chapterId
}
- index += delta
- val newChapterId = (allChapters.getOrNull(index) ?: return@launchLoadingJob).id
content.value = ReaderContent(emptyList(), null)
chaptersLoader.loadSingleChapter(newChapterId)
- content.value = ReaderContent(chaptersLoader.snapshot(), ReaderState(newChapterId, 0, 0))
+ val newState = ReaderState(
+ chapterId = newChapterId,
+ page = if (delta == 0) prevState.page else 0,
+ scroll = if (delta == 0) prevState.scroll else 0,
+ )
+ content.value = ReaderContent(chaptersLoader.snapshot(), newState)
+ saveCurrentState(newState)
}
}
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/config/ImageServerDelegate.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/config/ImageServerDelegate.kt
new file mode 100644
index 000000000..8090ead79
--- /dev/null
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/config/ImageServerDelegate.kt
@@ -0,0 +1,85 @@
+package org.koitharu.kotatsu.reader.ui.config
+
+import android.content.Context
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.suspendCancellableCoroutine
+import kotlinx.coroutines.withContext
+import org.koitharu.kotatsu.R
+import org.koitharu.kotatsu.core.parser.MangaRepository
+import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
+import org.koitharu.kotatsu.core.util.ext.mapToArray
+import org.koitharu.kotatsu.parsers.config.ConfigKey
+import org.koitharu.kotatsu.parsers.model.MangaSource
+import org.koitharu.kotatsu.parsers.util.SuspendLazy
+import kotlin.coroutines.resume
+
+class ImageServerDelegate(
+ private val mangaRepositoryFactory: MangaRepository.Factory,
+ private val mangaSource: MangaSource?,
+) {
+
+ private val repositoryLazy = SuspendLazy {
+ mangaRepositoryFactory.create(checkNotNull(mangaSource)) as RemoteMangaRepository
+ }
+
+ suspend fun isAvailable() = withContext(Dispatchers.Default) {
+ repositoryLazy.tryGet().map { repository ->
+ repository.getConfigKeys().any { it is ConfigKey.PreferredImageServer }
+ }.getOrDefault(false)
+ }
+
+ suspend fun getValue(): String? = withContext(Dispatchers.Default) {
+ repositoryLazy.tryGet().map { repository ->
+ val key = repository.getConfigKeys().firstNotNullOfOrNull { it as? ConfigKey.PreferredImageServer }
+ if (key != null) {
+ key.presetValues[repository.getConfig()[key]]
+ } else {
+ null
+ }
+ }.getOrNull()
+ }
+
+ suspend fun showDialog(context: Context): Boolean {
+ val repository = withContext(Dispatchers.Default) {
+ repositoryLazy.tryGet().getOrNull()
+ } ?: return false
+ val key = repository.getConfigKeys().firstNotNullOfOrNull {
+ it as? ConfigKey.PreferredImageServer
+ } ?: return false
+ val entries = key.presetValues.values.mapToArray {
+ it ?: context.getString(R.string.automatic)
+ }
+ val entryValues = key.presetValues.keys.toTypedArray()
+ val config = repository.getConfig()
+ val initialValue = config[key]
+ var currentValue = initialValue
+ val changed = suspendCancellableCoroutine { cont ->
+ val dialog = MaterialAlertDialogBuilder(context)
+ .setTitle(R.string.image_server)
+ .setCancelable(true)
+ .setSingleChoiceItems(entries, entryValues.indexOf(initialValue)) { _, i ->
+ currentValue = entryValues[i]
+ }.setNegativeButton(android.R.string.cancel) { dialog, _ ->
+ dialog.cancel()
+ }.setPositiveButton(android.R.string.ok) { _, _ ->
+ if (currentValue != initialValue) {
+ config[key] = currentValue
+ cont.resume(true)
+ } else {
+ cont.resume(false)
+ }
+ }.setOnCancelListener {
+ cont.resume(false)
+ }.create()
+ dialog.show()
+ cont.invokeOnCancellation {
+ dialog.cancel()
+ }
+ }
+ if (changed) {
+ repository.invalidateCache()
+ }
+ return changed
+ }
+}
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/config/ReaderConfigSheet.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/config/ReaderConfigSheet.kt
index 93c03a049..cfb59eb12 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/config/ReaderConfigSheet.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/config/ReaderConfigSheet.kt
@@ -16,8 +16,10 @@ import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.launch
import kotlinx.coroutines.plus
import org.koitharu.kotatsu.R
+import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.ReaderMode
import org.koitharu.kotatsu.core.prefs.observeAsStateFlow
@@ -29,6 +31,7 @@ import org.koitharu.kotatsu.core.util.ext.showDistinct
import org.koitharu.kotatsu.core.util.ext.viewLifecycleScope
import org.koitharu.kotatsu.core.util.ext.withArgs
import org.koitharu.kotatsu.databinding.SheetReaderConfigBinding
+import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.reader.ui.ReaderViewModel
import org.koitharu.kotatsu.reader.ui.colorfilter.ColorFilterConfigActivity
import org.koitharu.kotatsu.settings.SettingsActivity
@@ -47,7 +50,14 @@ class ReaderConfigSheet :
@Inject
lateinit var orientationHelper: ScreenOrientationHelper
+ @Inject
+ lateinit var mangaRepositoryFactory: MangaRepository.Factory
+
+ @Inject
+ lateinit var pageLoader: PageLoader
+
private lateinit var mode: ReaderMode
+ private lateinit var imageServerDelegate: ImageServerDelegate
@Inject
lateinit var settings: AppSettings
@@ -57,6 +67,10 @@ class ReaderConfigSheet :
mode = arguments?.getInt(ARG_MODE)
?.let { ReaderMode.valueOf(it) }
?: ReaderMode.STANDARD
+ imageServerDelegate = ImageServerDelegate(
+ mangaRepositoryFactory = mangaRepositoryFactory,
+ mangaSource = viewModel.manga?.toManga()?.source,
+ )
}
override fun onCreateViewBinding(
@@ -83,11 +97,20 @@ class ReaderConfigSheet :
binding.buttonSavePage.setOnClickListener(this)
binding.buttonScreenRotate.setOnClickListener(this)
binding.buttonSettings.setOnClickListener(this)
+ binding.buttonImageServer.setOnClickListener(this)
binding.buttonColorFilter.setOnClickListener(this)
binding.sliderTimer.addOnChangeListener(this)
binding.switchScrollTimer.setOnCheckedChangeListener(this)
binding.switchDoubleReader.setOnCheckedChangeListener(this)
+ viewLifecycleScope.launch {
+ val isAvailable = imageServerDelegate.isAvailable()
+ if (isAvailable) {
+ bindImageServerTitle()
+ }
+ binding.buttonImageServer.isVisible = isAvailable
+ }
+
settings.observeAsStateFlow(
scope = lifecycleScope + Dispatchers.Default,
key = AppSettings.KEY_READER_AUTOSCROLL_SPEED,
@@ -124,6 +147,14 @@ class ReaderConfigSheet :
val manga = viewModel.manga?.toManga() ?: return
startActivity(ColorFilterConfigActivity.newIntent(v.context, manga, page))
}
+
+ R.id.button_image_server -> viewLifecycleScope.launch {
+ if (imageServerDelegate.showDialog(v.context)) {
+ bindImageServerTitle()
+ pageLoader.invalidate(clearCache = true)
+ viewModel.switchChapterBy(0)
+ }
+ }
}
}
@@ -194,6 +225,14 @@ class ReaderConfigSheet :
switch.setOnCheckedChangeListener(this)
}
+ private suspend fun bindImageServerTitle() {
+ viewBinding?.buttonImageServer?.text = getString(
+ R.string.inline_preference_pattern,
+ getString(R.string.image_server),
+ imageServerDelegate.getValue() ?: getString(R.string.automatic),
+ )
+ }
+
interface Callback {
var isAutoScrollEnabled: Boolean
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/config/ReaderSettings.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/config/ReaderSettings.kt
index 60708cfcc..f93822540 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/config/ReaderSettings.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/config/ReaderSettings.kt
@@ -18,6 +18,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.core.model.ZoomMode
import org.koitharu.kotatsu.core.prefs.AppSettings
+import org.koitharu.kotatsu.core.prefs.ReaderMode
import org.koitharu.kotatsu.core.util.ext.isLowRamDevice
import org.koitharu.kotatsu.reader.domain.ReaderColorFilter
@@ -54,6 +55,10 @@ class ReaderSettings(
view.background = bg.resolve(view.context)
}
+ fun isPagesCropEnabled(isWebtoon: Boolean) = settings.isPagesCropEnabled(
+ if (isWebtoon) ReaderMode.WEBTOON else ReaderMode.STANDARD,
+ )
+
@CheckResult
fun applyBitmapConfig(ssiv: SubsamplingScaleImageView): Boolean {
val config = bitmapConfig
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/BasePageHolder.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/BasePageHolder.kt
index 001cfdf0d..a85d98616 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/BasePageHolder.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/BasePageHolder.kt
@@ -13,6 +13,7 @@ import org.koitharu.kotatsu.databinding.LayoutPageInfoBinding
import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.reader.ui.config.ReaderSettings
import org.koitharu.kotatsu.reader.ui.pager.PageHolderDelegate.State
+import org.koitharu.kotatsu.reader.ui.pager.webtoon.WebtoonHolder
abstract class BasePageHolder(
protected val binding: B,
@@ -24,7 +25,14 @@ abstract class BasePageHolder(
) : LifecycleAwareViewHolder(binding.root, lifecycleOwner), PageHolderDelegate.Callback {
@Suppress("LeakingThis")
- protected val delegate = PageHolderDelegate(loader, settings, this, networkState, exceptionResolver)
+ protected val delegate = PageHolderDelegate(
+ loader = loader,
+ readerSettings = settings,
+ callback = this,
+ networkState = networkState,
+ exceptionResolver = exceptionResolver,
+ isWebtoon = this is WebtoonHolder,
+ )
protected val bindingInfo = LayoutPageInfoBinding.bind(binding.root)
val context: Context
@@ -70,7 +78,7 @@ abstract class BasePageHolder(
delegate.onRecycle()
}
- protected fun SubsamplingScaleImageView.applyDownsampling(isForeground: Boolean) {
+ protected fun SubsamplingScaleImageView.applyDownSampling(isForeground: Boolean) {
downSampling = when {
isForeground || !settings.isReaderOptimizationEnabled -> 1
context.isLowRamDevice() -> 8
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/PageHolderDelegate.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/PageHolderDelegate.kt
index 0b58a3c31..3cfcea351 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/PageHolderDelegate.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/PageHolderDelegate.kt
@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.reader.ui.pager
+import android.graphics.Rect
import android.net.Uri
import androidx.lifecycle.Observer
import com.davemorrissey.labs.subscaleview.DefaultOnImageEventListener
@@ -32,6 +33,7 @@ class PageHolderDelegate(
private val callback: Callback,
private val networkState: NetworkState,
private val exceptionResolver: ExceptionResolver,
+ private val isWebtoon: Boolean,
) : DefaultOnImageEventListener, Observer {
private val scope = loader.loaderScope + Dispatchers.Main.immediate
@@ -39,6 +41,7 @@ class PageHolderDelegate(
private set
private var job: Job? = null
private var uri: Uri? = null
+ private var cachedBounds: Rect? = null
private var error: Throwable? = null
init {
@@ -88,6 +91,7 @@ class PageHolderDelegate(
fun onRecycle() {
state = State.EMPTY
uri = null
+ cachedBounds = null
error = null
job?.cancel()
}
@@ -95,7 +99,7 @@ class PageHolderDelegate(
fun reload() {
if (state == State.SHOWN) {
uri?.let {
- callback.onImageReady(it)
+ callback.onImageReady(it, cachedBounds)
}
}
}
@@ -138,8 +142,13 @@ class PageHolderDelegate(
state = State.CONVERTING
try {
val newUri = loader.convertBimap(uri)
+ cachedBounds = if (readerSettings.isPagesCropEnabled(isWebtoon)) {
+ loader.getTrimmedBounds(newUri)
+ } else {
+ null
+ }
state = State.CONVERTED
- callback.onImageReady(newUri)
+ callback.onImageReady(newUri, cachedBounds)
} catch (ce: CancellationException) {
throw ce
} catch (e2: Throwable) {
@@ -166,7 +175,12 @@ class PageHolderDelegate(
file
}
state = State.LOADED
- callback.onImageReady(checkNotNull(uri))
+ cachedBounds = if (readerSettings.isPagesCropEnabled(isWebtoon)) {
+ loader.getTrimmedBounds(checkNotNull(uri))
+ } else {
+ null
+ }
+ callback.onImageReady(checkNotNull(uri), cachedBounds)
} catch (e: CancellationException) {
throw e
} catch (e: Throwable) {
@@ -196,7 +210,7 @@ class PageHolderDelegate(
fun onError(e: Throwable)
- fun onImageReady(uri: Uri)
+ fun onImageReady(uri: Uri, bounds: Rect?)
fun onImageShowing(settings: ReaderSettings)
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/standard/PageHolder.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/standard/PageHolder.kt
index c4b9d6b80..e724884b4 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/standard/PageHolder.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/standard/PageHolder.kt
@@ -2,6 +2,7 @@ package org.koitharu.kotatsu.reader.ui.pager.standard
import android.annotation.SuppressLint
import android.graphics.PointF
+import android.graphics.Rect
import android.net.Uri
import android.view.View
import android.view.animation.DecelerateInterpolator
@@ -46,12 +47,12 @@ open class PageHolder(
override fun onResume() {
super.onResume()
- binding.ssiv.applyDownsampling(isForeground = true)
+ binding.ssiv.applyDownSampling(isForeground = true)
}
override fun onPause() {
super.onPause()
- binding.ssiv.applyDownsampling(isForeground = false)
+ binding.ssiv.applyDownSampling(isForeground = false)
}
override fun onConfigChanged() {
@@ -59,7 +60,7 @@ open class PageHolder(
if (settings.applyBitmapConfig(binding.ssiv)) {
delegate.reload()
}
- binding.ssiv.applyDownsampling(isResumed())
+ binding.ssiv.applyDownSampling(isResumed())
binding.textViewNumber.isVisible = settings.isPagesNumbersEnabled
}
@@ -89,8 +90,12 @@ open class PageHolder(
}
}
- override fun onImageReady(uri: Uri) {
- binding.ssiv.setImage(ImageSource.Uri(uri))
+ override fun onImageReady(uri: Uri, bounds: Rect?) {
+ val source = ImageSource.Uri(uri)
+ if (bounds != null) {
+ source.region(bounds)
+ }
+ binding.ssiv.setImage(source)
}
override fun onImageShowing(settings: ReaderSettings) {
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonHolder.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonHolder.kt
index 48111be7b..745a57971 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonHolder.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonHolder.kt
@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.reader.ui.pager.webtoon
+import android.graphics.Rect
import android.net.Uri
import android.view.View
import androidx.core.view.isVisible
@@ -39,12 +40,12 @@ class WebtoonHolder(
override fun onResume() {
super.onResume()
- binding.ssiv.applyDownsampling(isForeground = true)
+ binding.ssiv.applyDownSampling(isForeground = true)
}
override fun onPause() {
super.onPause()
- binding.ssiv.applyDownsampling(isForeground = false)
+ binding.ssiv.applyDownSampling(isForeground = false)
}
override fun onConfigChanged() {
@@ -52,7 +53,7 @@ class WebtoonHolder(
if (settings.applyBitmapConfig(binding.ssiv)) {
delegate.reload()
}
- binding.ssiv.applyDownsampling(isResumed())
+ binding.ssiv.applyDownSampling(isResumed())
}
override fun onBind(data: ReaderPage) {
@@ -89,8 +90,12 @@ class WebtoonHolder(
}
}
- override fun onImageReady(uri: Uri) {
- binding.ssiv.setImage(ImageSource.Uri(uri))
+ override fun onImageReady(uri: Uri, bounds: Rect?) {
+ val source = ImageSource.Uri(uri)
+ if (bounds != null) {
+ source.region(bounds)
+ }
+ binding.ssiv.setImage(source)
}
override fun onImageShowing(settings: ReaderSettings) {
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/remotelist/ui/RemoteListViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/remotelist/ui/RemoteListViewModel.kt
index e1923e1ca..05653b7fc 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/remotelist/ui/RemoteListViewModel.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/remotelist/ui/RemoteListViewModel.kt
@@ -28,6 +28,7 @@ import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.require
import org.koitharu.kotatsu.core.util.ext.sizeOrZero
import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
+import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
import org.koitharu.kotatsu.explore.domain.ExploreRepository
import org.koitharu.kotatsu.filter.ui.FilterCoordinator
import org.koitharu.kotatsu.filter.ui.MangaFilter
@@ -45,7 +46,6 @@ import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaTag
-import org.koitharu.kotatsu.parsers.util.concatUrl
import javax.inject.Inject
private const val FILTER_MIN_INTERVAL = 250L
@@ -59,6 +59,7 @@ open class RemoteListViewModel @Inject constructor(
listExtraProvider: ListExtraProvider,
downloadScheduler: DownloadWorker.Scheduler,
private val exploreRepository: ExploreRepository,
+ sourcesRepository: MangaSourcesRepository,
) : MangaListViewModel(settings, downloadScheduler), MangaFilter by filter {
val source = savedStateHandle.require(RemoteListFragment.ARG_SOURCE)
@@ -117,6 +118,10 @@ open class RemoteListViewModel @Inject constructor(
}.catch { error ->
listError.value = error
}.launchIn(viewModelScope)
+
+ launchJob(Dispatchers.Default) {
+ sourcesRepository.trackUsage(source)
+ }
}
override fun onRefresh() {
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/domain/MangaSearchRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/domain/MangaSearchRepository.kt
index 3adc10d2a..e3df45981 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/search/domain/MangaSearchRepository.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/domain/MangaSearchRepository.kt
@@ -125,6 +125,8 @@ class MangaSearchRepository @Inject constructor(
return db.getTagsDao().findRareTags(source.name, limit).toMangaTagsList()
}
+ suspend fun getSourcesSuggestion(limit: Int): List = sourcesRepository.getTopSources(limit)
+
fun getSourcesSuggestion(query: String, limit: Int): List {
if (query.length < 3) {
return emptyList()
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionViewModel.kt
index 9e8e7ccea..c604a2c11 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionViewModel.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionViewModel.kt
@@ -36,6 +36,7 @@ private const val MAX_HINTS_ITEMS = 3
private const val MAX_AUTHORS_ITEMS = 2
private const val MAX_TAGS_ITEMS = 8
private const val MAX_SOURCES_ITEMS = 6
+private const val MAX_SOURCES_TIPS_ITEMS = 2
@HiltViewModel
class SearchSuggestionViewModel @Inject constructor(
@@ -148,12 +149,18 @@ class SearchSuggestionViewModel @Inject constructor(
} else {
null
}
+ val sourcesTipsDeferred = if (searchQuery.isEmpty() && SearchSuggestionType.RECENT_SOURCES in types) {
+ async { repository.getSourcesSuggestion(MAX_SOURCES_TIPS_ITEMS) }
+ } else {
+ null
+ }
val tags = tagsDeferred?.await()
val mangaList = mangaDeferred?.await()
val queries = queriesDeferred?.await()
val hints = hintsDeferred?.await()
val authors = authorsDeferred?.await()
+ val sourcesTips = sourcesTipsDeferred?.await()
buildList(queries.sizeOrZero() + sources.sizeOrZero() + authors.sizeOrZero() + hints.sizeOrZero() + 2) {
if (!tags.isNullOrEmpty()) {
@@ -166,6 +173,7 @@ class SearchSuggestionViewModel @Inject constructor(
queries?.mapTo(this) { SearchSuggestionItem.RecentQuery(it) }
authors?.mapTo(this) { SearchSuggestionItem.Author(it) }
hints?.mapTo(this) { SearchSuggestionItem.Hint(it) }
+ sourcesTips?.mapTo(this) { SearchSuggestionItem.SourceTip(it) }
}
}
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionAdapter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionAdapter.kt
index 3152927f0..9b923e571 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionAdapter.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionAdapter.kt
@@ -18,6 +18,7 @@ class SearchSuggestionAdapter(
delegatesManager
.addDelegate(SEARCH_SUGGESTION_ITEM_TYPE_QUERY, searchSuggestionQueryAD(listener))
.addDelegate(searchSuggestionSourceAD(coil, lifecycleOwner, listener))
+ .addDelegate(searchSuggestionSourceTipAD(coil, lifecycleOwner, listener))
.addDelegate(searchSuggestionTagsAD(listener))
.addDelegate(searchSuggestionMangaListAD(coil, lifecycleOwner, listener))
.addDelegate(searchSuggestionQueryHintAD(listener))
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionSourceTipAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionSourceTipAD.kt
new file mode 100644
index 000000000..55a71a06d
--- /dev/null
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionSourceTipAD.kt
@@ -0,0 +1,43 @@
+package org.koitharu.kotatsu.search.ui.suggestion.adapter
+
+import androidx.lifecycle.LifecycleOwner
+import coil.ImageLoader
+import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
+import org.koitharu.kotatsu.R
+import org.koitharu.kotatsu.core.model.getSummary
+import org.koitharu.kotatsu.core.model.getTitle
+import org.koitharu.kotatsu.core.parser.favicon.faviconUri
+import org.koitharu.kotatsu.core.ui.image.FaviconDrawable
+import org.koitharu.kotatsu.core.util.ext.enqueueWith
+import org.koitharu.kotatsu.core.util.ext.newImageRequest
+import org.koitharu.kotatsu.core.util.ext.source
+import org.koitharu.kotatsu.databinding.ItemSearchSuggestionSourceTipBinding
+import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionListener
+import org.koitharu.kotatsu.search.ui.suggestion.model.SearchSuggestionItem
+
+fun searchSuggestionSourceTipAD(
+ coil: ImageLoader,
+ lifecycleOwner: LifecycleOwner,
+ listener: SearchSuggestionListener,
+) =
+ adapterDelegateViewBinding(
+ { inflater, parent -> ItemSearchSuggestionSourceTipBinding.inflate(inflater, parent, false) },
+ ) {
+
+ binding.root.setOnClickListener {
+ listener.onSourceClick(item.source)
+ }
+
+ bind {
+ binding.textViewTitle.text = item.source.getTitle(context)
+ binding.textViewSubtitle.text = item.source.getSummary(context)
+ val fallbackIcon = FaviconDrawable(context, R.style.FaviconDrawable_Small, item.source.name)
+ binding.imageViewCover.newImageRequest(lifecycleOwner, item.source.faviconUri())?.run {
+ fallback(fallbackIcon)
+ placeholder(fallbackIcon)
+ error(fallbackIcon)
+ source(item.source)
+ enqueueWith(coil)
+ }
+ }
+ }
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/model/SearchSuggestionItem.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/model/SearchSuggestionItem.kt
index 263153f30..27f877460 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/model/SearchSuggestionItem.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/model/SearchSuggestionItem.kt
@@ -69,6 +69,18 @@ sealed interface SearchSuggestionItem : ListModel {
}
}
+ data class SourceTip(
+ val source: MangaSource,
+ ) : SearchSuggestionItem {
+
+ val isNsfw: Boolean
+ get() = source.isNsfw()
+
+ override fun areItemsTheSame(other: ListModel): Boolean {
+ return other is Source && other.source == source
+ }
+ }
+
data class Tags(
val tags: List,
) : SearchSuggestionItem {
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/ReaderSettingsFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/ReaderSettingsFragment.kt
index be4e8be6c..d46843db9 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/ReaderSettingsFragment.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/ReaderSettingsFragment.kt
@@ -5,6 +5,7 @@ import android.content.SharedPreferences
import android.os.Bundle
import android.view.View
import androidx.preference.ListPreference
+import androidx.preference.MultiSelectListPreference
import androidx.preference.Preference
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
@@ -17,6 +18,7 @@ import org.koitharu.kotatsu.core.ui.BasePreferenceFragment
import org.koitharu.kotatsu.core.util.ext.setDefaultValueCompat
import org.koitharu.kotatsu.parsers.util.names
import org.koitharu.kotatsu.settings.reader.ReaderTapGridConfigActivity
+import org.koitharu.kotatsu.settings.utils.MultiSummaryProvider
import org.koitharu.kotatsu.settings.utils.PercentSummaryProvider
import org.koitharu.kotatsu.settings.utils.SliderPreference
@@ -48,6 +50,9 @@ class ReaderSettingsFragment :
entryValues = ZoomMode.entries.names()
setDefaultValueCompat(ZoomMode.FIT_CENTER.name)
}
+ findPreference(AppSettings.KEY_READER_CROP)?.run {
+ summaryProvider = MultiSummaryProvider(R.string.disabled)
+ }
findPreference(AppSettings.KEY_WEBTOON_ZOOM_OUT)?.summaryProvider = PercentSummaryProvider()
updateReaderModeDependency()
}
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/SourceSettingsExt.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/SourceSettingsExt.kt
index 064697808..ef58ffc15 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/SourceSettingsExt.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/SourceSettingsExt.kt
@@ -2,11 +2,13 @@ package org.koitharu.kotatsu.settings.sources
import android.view.inputmethod.EditorInfo
import androidx.preference.EditTextPreference
+import androidx.preference.ListPreference
import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat
import androidx.preference.SwitchPreferenceCompat
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
+import org.koitharu.kotatsu.core.util.ext.mapToArray
import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.network.UserAgents
import org.koitharu.kotatsu.settings.utils.AutoCompleteTextViewPreference
@@ -23,9 +25,9 @@ fun PreferenceFragmentCompat.addPreferencesFromRepository(repository: RemoteMang
is ConfigKey.Domain -> {
val presetValues = key.presetValues
if (presetValues.size <= 1) {
- EditTextPreference(requireContext())
+ EditTextPreference(screen.context)
} else {
- AutoCompleteTextViewPreference(requireContext()).apply {
+ AutoCompleteTextViewPreference(screen.context).apply {
entries = presetValues.toStringArray()
}
}.apply {
@@ -43,7 +45,7 @@ fun PreferenceFragmentCompat.addPreferencesFromRepository(repository: RemoteMang
}
is ConfigKey.UserAgent -> {
- AutoCompleteTextViewPreference(requireContext()).apply {
+ AutoCompleteTextViewPreference(screen.context).apply {
entries = arrayOf(
UserAgents.FIREFOX_MOBILE,
UserAgents.CHROME_MOBILE,
@@ -64,19 +66,32 @@ fun PreferenceFragmentCompat.addPreferencesFromRepository(repository: RemoteMang
}
is ConfigKey.ShowSuspiciousContent -> {
- SwitchPreferenceCompat(requireContext()).apply {
+ SwitchPreferenceCompat(screen.context).apply {
setDefaultValue(key.defaultValue)
setTitle(R.string.show_suspicious_content)
}
}
is ConfigKey.SplitByTranslations -> {
- SwitchPreferenceCompat(requireContext()).apply {
+ SwitchPreferenceCompat(screen.context).apply {
setDefaultValue(key.defaultValue)
setTitle(R.string.split_by_translations)
setSummary(R.string.split_by_translations_summary)
}
}
+
+ is ConfigKey.PreferredImageServer -> {
+ ListPreference(screen.context).apply {
+ entries = key.presetValues.values.mapToArray {
+ it ?: context.getString(R.string.automatic)
+ }
+ entryValues = key.presetValues.keys.mapToArray { it.orEmpty() }
+ setDefaultValue(key.defaultValue.orEmpty())
+ setTitle(R.string.image_server)
+ setDialogTitle(R.string.image_server)
+ summaryProvider = ListPreference.SimpleSummaryProvider.getInstance()
+ }
+ }
}
preference.isIconSpaceReserved = false
preference.key = key.key
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigAdapterDelegates.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigAdapterDelegates.kt
index e8c41887d..3470c28de 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigAdapterDelegates.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigAdapterDelegates.kt
@@ -2,6 +2,7 @@ package org.koitharu.kotatsu.settings.sources.adapter
import android.view.View
import androidx.appcompat.widget.PopupMenu
+import androidx.core.content.ContextCompat
import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.view.isGone
import androidx.core.view.isVisible
@@ -16,49 +17,14 @@ import org.koitharu.kotatsu.core.parser.favicon.faviconUri
import org.koitharu.kotatsu.core.ui.image.FaviconDrawable
import org.koitharu.kotatsu.core.ui.list.OnTipCloseListener
import org.koitharu.kotatsu.core.util.ext.crossfade
+import org.koitharu.kotatsu.core.util.ext.drawableStart
import org.koitharu.kotatsu.core.util.ext.enqueueWith
import org.koitharu.kotatsu.core.util.ext.newImageRequest
import org.koitharu.kotatsu.core.util.ext.source
import org.koitharu.kotatsu.databinding.ItemSourceConfigBinding
-import org.koitharu.kotatsu.databinding.ItemSourceConfigCheckableBinding
import org.koitharu.kotatsu.databinding.ItemTipBinding
import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem
-fun sourceConfigItemCheckableDelegate(
- listener: SourceConfigListener,
- coil: ImageLoader,
- lifecycleOwner: LifecycleOwner,
-) = adapterDelegateViewBinding(
- { layoutInflater, parent ->
- ItemSourceConfigCheckableBinding.inflate(
- layoutInflater,
- parent,
- false,
- )
- },
-) {
-
- binding.switchToggle.setOnCheckedChangeListener { _, isChecked ->
- listener.onItemEnabledChanged(item, isChecked)
- }
-
- bind {
- binding.textViewTitle.text = item.source.getTitle(context)
- binding.switchToggle.isChecked = item.isEnabled
- binding.switchToggle.isEnabled = item.isAvailable
- binding.textViewDescription.text = item.source.getSummary(context)
- val fallbackIcon = FaviconDrawable(context, R.style.FaviconDrawable_Small, item.source.name)
- binding.imageViewIcon.newImageRequest(lifecycleOwner, item.source.faviconUri())?.run {
- crossfade(context)
- error(fallbackIcon)
- placeholder(fallbackIcon)
- fallback(fallbackIcon)
- source(item.source)
- enqueueWith(coil)
- }
- }
-}
-
fun sourceConfigItemDelegate2(
listener: SourceConfigListener,
coil: ImageLoader,
@@ -73,6 +39,7 @@ fun sourceConfigItemDelegate2(
},
) {
+ val iconPinned = ContextCompat.getDrawable(context, R.drawable.ic_pin_small)
val eventListener = View.OnClickListener { v ->
when (v.id) {
R.id.imageView_add -> listener.onItemEnabledChanged(item, true)
@@ -89,6 +56,7 @@ fun sourceConfigItemDelegate2(
binding.imageViewAdd.isGone = item.isEnabled || !item.isAvailable
binding.imageViewRemove.isVisible = item.isEnabled
binding.imageViewMenu.isVisible = item.isEnabled
+ binding.textViewTitle.drawableStart = if (item.isPinned) iconPinned else null
binding.textViewDescription.text = item.source.getSummary(context)
val fallbackIcon = FaviconDrawable(context, R.style.FaviconDrawable_Small, item.source.name)
binding.imageViewIcon.newImageRequest(lifecycleOwner, item.source.faviconUri())?.run {
@@ -132,12 +100,15 @@ private fun showSourceMenu(
menu.inflate(R.menu.popup_source_config)
menu.menu.findItem(R.id.action_shortcut)
?.isVisible = ShortcutManagerCompat.isRequestPinShortcutSupported(anchor.context)
+ menu.menu.findItem(R.id.action_pin)?.isVisible = item.isEnabled
+ menu.menu.findItem(R.id.action_pin)?.isChecked = item.isPinned
menu.menu.findItem(R.id.action_lift)?.isVisible = item.isDraggable
menu.setOnMenuItemClickListener {
when (it.itemId) {
R.id.action_settings -> listener.onItemSettingsClick(item)
R.id.action_lift -> listener.onItemLiftClick(item)
R.id.action_shortcut -> listener.onItemShortcutClick(item)
+ R.id.action_pin -> listener.onItemPinClick(item)
}
true
}
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigListener.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigListener.kt
index 2a09d6516..82fa5212f 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigListener.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigListener.kt
@@ -11,5 +11,7 @@ interface SourceConfigListener : OnTipCloseListener {
fun onItemShortcutClick(item: SourceConfigItem.SourceItem)
+ fun onItemPinClick(item: SourceConfigItem.SourceItem)
+
fun onItemEnabledChanged(item: SourceConfigItem.SourceItem, isEnabled: Boolean)
}
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/manage/SourcesListProducer.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/manage/SourcesListProducer.kt
index 411d68f06..85b06587b 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/manage/SourcesListProducer.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/manage/SourcesListProducer.kt
@@ -64,6 +64,7 @@ class SourcesListProducer @Inject constructor(
private suspend fun buildList(): List {
val enabledSources = repository.getEnabledSources()
+ val pinned = repository.getPinnedSources()
val isNsfwDisabled = settings.isNsfwContentDisabled
val isReorderAvailable = settings.sourcesSortOrder == SourcesSortOrder.MANUAL
val withTip = isReorderAvailable && settings.isTipEnabled(TIP_REORDER)
@@ -78,6 +79,7 @@ class SourcesListProducer @Inject constructor(
isEnabled = it in enabledSet,
isDraggable = false,
isAvailable = !isNsfwDisabled || !it.isNsfw(),
+ isPinned = it in pinned,
)
}.ifEmpty {
listOf(SourceConfigItem.EmptySearchResult)
@@ -98,6 +100,7 @@ class SourcesListProducer @Inject constructor(
isEnabled = true,
isDraggable = isReorderAvailable,
isAvailable = false,
+ isPinned = it in pinned,
)
}
}
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/manage/SourcesManageFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/manage/SourcesManageFragment.kt
index f5a7e051a..b970465cc 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/manage/SourcesManageFragment.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/manage/SourcesManageFragment.kt
@@ -120,6 +120,10 @@ class SourcesManageFragment :
}
}
+ override fun onItemPinClick(item: SourceConfigItem.SourceItem) {
+ viewModel.setPinned(item.source, !item.isPinned)
+ }
+
override fun onItemEnabledChanged(item: SourceConfigItem.SourceItem, isEnabled: Boolean) {
viewModel.setEnabled(item.source, isEnabled)
}
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/manage/SourcesManageViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/manage/SourcesManageViewModel.kt
index 8175142e4..d39187b00 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/manage/SourcesManageViewModel.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/manage/SourcesManageViewModel.kt
@@ -58,8 +58,9 @@ class SourcesManageViewModel @Inject constructor(
fun canReorder(oldPos: Int, newPos: Int): Boolean {
val snapshot = content.value
- if ((snapshot[oldPos] as? SourceConfigItem.SourceItem)?.isEnabled != true) return false
- return (snapshot[newPos] as? SourceConfigItem.SourceItem)?.isEnabled == true
+ val oldPosItem = snapshot.getOrNull(oldPos) as? SourceConfigItem.SourceItem ?: return false
+ val newPosItem = snapshot.getOrNull(newPos) as? SourceConfigItem.SourceItem ?: return false
+ return oldPosItem.isEnabled && newPosItem.isEnabled && oldPosItem.isPinned == newPosItem.isPinned
}
fun setEnabled(source: MangaSource, isEnabled: Boolean) {
@@ -71,6 +72,14 @@ class SourcesManageViewModel @Inject constructor(
}
}
+ fun setPinned(source: MangaSource, isPinned: Boolean) {
+ launchJob(Dispatchers.Default) {
+ val rollback = repository.setIsPinned(setOf(source), isPinned)
+ val message = if (isPinned) R.string.source_pinned else R.string.source_unpinned
+ onActionDone.call(ReversibleAction(message, rollback))
+ }
+ }
+
fun bringToTop(source: MangaSource) {
val snapshot = content.value
launchJob(Dispatchers.Default) {
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/model/SourceConfigItem.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/model/SourceConfigItem.kt
index fcc1f4410..321a54334 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/model/SourceConfigItem.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/model/SourceConfigItem.kt
@@ -13,6 +13,7 @@ sealed interface SourceConfigItem : ListModel {
val isEnabled: Boolean,
val isDraggable: Boolean,
val isAvailable: Boolean,
+ val isPinned: Boolean,
) : SourceConfigItem {
val isNsfw: Boolean
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/debug/TrackDebugAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/debug/TrackDebugAD.kt
index 9590267eb..e1a62459a 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/debug/TrackDebugAD.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/debug/TrackDebugAD.kt
@@ -58,7 +58,7 @@ fun trackDebugAD(
append(" - ")
bold {
color(context.getThemeColor(materialR.attr.colorError, Color.RED)) {
- append(getString(R.string.error))
+ append(item.lastError ?: getString(R.string.error))
}
}
}
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/debug/TrackDebugItem.kt b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/debug/TrackDebugItem.kt
index 0e59c15ef..851c39427 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/debug/TrackDebugItem.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/debug/TrackDebugItem.kt
@@ -11,6 +11,7 @@ data class TrackDebugItem(
val lastCheckTime: Instant?,
val lastChapterDate: Instant?,
val lastResult: Int,
+ val lastError: String?,
) : ListModel {
override fun areItemsTheSame(other: ListModel): Boolean {
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/debug/TrackerDebugViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/debug/TrackerDebugViewModel.kt
index 942741882..cf591c685 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/debug/TrackerDebugViewModel.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/debug/TrackerDebugViewModel.kt
@@ -31,6 +31,7 @@ class TrackerDebugViewModel @Inject constructor(
lastCheckTime = it.track.lastCheckTime.toInstantOrNull(),
lastChapterDate = it.track.lastChapterDate.toInstantOrNull(),
lastResult = it.track.lastResult,
+ lastError = it.track.lastError,
)
}
}
diff --git a/app/src/main/res/drawable/ic_images.xml b/app/src/main/res/drawable/ic_images.xml
new file mode 100644
index 000000000..df36798a3
--- /dev/null
+++ b/app/src/main/res/drawable/ic_images.xml
@@ -0,0 +1,12 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_pin_small.xml b/app/src/main/res/drawable/ic_pin_small.xml
new file mode 100644
index 000000000..383c74b9f
--- /dev/null
+++ b/app/src/main/res/drawable/ic_pin_small.xml
@@ -0,0 +1,12 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_settings.xml b/app/src/main/res/drawable/ic_settings.xml
index 2eef40149..e6e61e610 100644
--- a/app/src/main/res/drawable/ic_settings.xml
+++ b/app/src/main/res/drawable/ic_settings.xml
@@ -6,6 +6,6 @@
android:viewportWidth="24"
android:viewportHeight="24">
diff --git a/app/src/main/res/drawable/ic_shortcut.xml b/app/src/main/res/drawable/ic_shortcut.xml
new file mode 100644
index 000000000..fe64806f2
--- /dev/null
+++ b/app/src/main/res/drawable/ic_shortcut.xml
@@ -0,0 +1,12 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_unpin.xml b/app/src/main/res/drawable/ic_unpin.xml
new file mode 100644
index 000000000..a3c89da86
--- /dev/null
+++ b/app/src/main/res/drawable/ic_unpin.xml
@@ -0,0 +1,12 @@
+
+
+
+
diff --git a/app/src/main/res/layout/activity_details.xml b/app/src/main/res/layout/activity_details.xml
index 51a35a540..9645983aa 100644
--- a/app/src/main/res/layout/activity_details.xml
+++ b/app/src/main/res/layout/activity_details.xml
@@ -84,7 +84,7 @@
android:layout_marginTop="16dp"
android:layout_marginEnd="16dp"
android:ellipsize="end"
- android:maxLines="5"
+ android:maxLines="@integer/details_title_lines"
android:textAppearance="?attr/textAppearanceHeadlineSmall"
android:textIsSelectable="true"
app:layout_constraintEnd_toEndOf="parent"
diff --git a/app/src/main/res/layout/item_search_suggestion_source_tip.xml b/app/src/main/res/layout/item_search_suggestion_source_tip.xml
new file mode 100644
index 000000000..f3a9da423
--- /dev/null
+++ b/app/src/main/res/layout/item_search_suggestion_source_tip.xml
@@ -0,0 +1,52 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/item_source_config.xml b/app/src/main/res/layout/item_source_config.xml
index b1c524539..4d958f260 100644
--- a/app/src/main/res/layout/item_source_config.xml
+++ b/app/src/main/res/layout/item_source_config.xml
@@ -35,9 +35,11 @@
android:id="@+id/textView_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
+ android:drawablePadding="4dp"
android:ellipsize="end"
android:singleLine="true"
android:textAppearance="?attr/textAppearanceTitleSmall"
+ tools:drawableStart="@drawable/ic_pin_small"
tools:text="@tools:sample/lorem[15]" />
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/values-ab/plurals.xml b/app/src/main/res/values-ab/plurals.xml
new file mode 100644
index 000000000..a6b3daec9
--- /dev/null
+++ b/app/src/main/res/values-ab/plurals.xml
@@ -0,0 +1,2 @@
+
+
\ No newline at end of file
diff --git a/app/src/main/res/values-ar/plurals.xml b/app/src/main/res/values-ar/plurals.xml
index 670c3d980..2fd1eee47 100644
--- a/app/src/main/res/values-ar/plurals.xml
+++ b/app/src/main/res/values-ar/plurals.xml
@@ -64,4 +64,12 @@
- إحدى عشر دقيقة
- مئة دقيقة
+
+ - العربية
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml
index be2998822..892de7473 100644
--- a/app/src/main/res/values-ar/strings.xml
+++ b/app/src/main/res/values-ar/strings.xml
@@ -383,4 +383,6 @@
متوقف مؤقتاً
التحميل عبر شبكة الوايفاي فقط
إظهار الإشعارات أحيانًا بالمانغا المقترحة
+ اللغة العربية
+
\ No newline at end of file
diff --git a/app/src/main/res/values-be/strings.xml b/app/src/main/res/values-be/strings.xml
index 93fbfac62..a9a5121bc 100644
--- a/app/src/main/res/values-be/strings.xml
+++ b/app/src/main/res/values-be/strings.xml
@@ -22,7 +22,7 @@
Гісторыя пустая
Чытаць
Дадайце цікавую для вас мангу ў абранае, каб не страціць яе
- Дадаць ў абраныя
+ Дадаць у абраныя
Стварыць катэгорыю
Дадаць
Захаваць
@@ -45,7 +45,7 @@
Тэма
Светлая
Цёмная
- Як ў сістэме
+ Як у сістэме
Старонкi
Ачысціць
Выдаліць
@@ -78,7 +78,7 @@
Знешняе сховішча
Дамен
Даступна абнаўленне праграмы
- Адкрыць ў браўзеры
+ Адкрыць у браўзеры
Паведамленні
Ўключана %1$d з %2$d
Новыя раздзелы
@@ -125,7 +125,7 @@
Справа налева
Стварыць катэгорыю
Маштабаванне
- Ўмясціць ў экран
+ Умясціць у экран
Падагнаць па вышыні
Падагнаць па шырыні
Зыходны памер
@@ -137,7 +137,7 @@
Данныя адноўлены
Падрыхтоўка…
Файл не знойдзены
- Ўсе данныя паспяхова адноўлены
+ Усе данныя паспяхова адноўлены
Данныя адноўлены, але ўзніклі некаторыя памылкі
Вы можаце стварыць рэзервовую копію абранага і гісторыі і потым аднавіць іх
Толькі што
@@ -153,7 +153,7 @@
Ачысціць кукi
Ўсе кукi выдалены
Ачысціць стужку
- Ўся гісторыя абнаўленняў будзе ачышчана і яе нельга будзе вярнуць. Вы ўпэўненыя?
+ Уся гісторыя абнаўленняў будзе ачышчана і яе нельга будзе вярнуць. Вы ўпэўненыя?
Праверка новых глаў
Ў адваротным парадку
Ўвайсці
@@ -163,14 +163,14 @@
Калі ласка, увядзіце пароль, які спатрэбіцца пры запуску праграмы
Пацвердзіць
Пароль павінен змяшчаць не менш за 4 сімвалы
- Вы сапраўды хочаце выдаліць ўсе апошнія пошукавыя запыты?
+ Вы сапраўды хочаце выдаліць усе апошнія пошукавыя запыты?
Падрабязна
Некаторыя вытворцы могуць змяняць паводзіны сістэмы, што можа парушаць выкананне фонавых задач.
Рэзервовая копія паспяхова захавана
Вітаю
Захавайце што-небудзь з інтэрнэт-каталога або імпартуйце гэта з файла.
Спачатку захавайце што-небудзь
- Знайдзіце, што пачытаць, ў раздзеле «Агляд»
+ Знайдзіце, што пачытаць, у раздзеле «Агляд»
Тут будзе паказана манга, якую вы чытаеце
Паспрабуйце перафармуляваць запыт.
Неяк тут пуста…
@@ -178,7 +178,7 @@
Ў чарзе
Дапамагчы з перакладам праграмы
Пераклад
- Вы выйдзеце з усіх крыніц, ў якіх вы аўтарызаваны
+ Вы выйдзеце з усіх крыніц, у якіх вы аўтарызаваны
Аўтарызацыя на %s не падтрымліваецца
Аўтарызацыя выканана
Жанры
@@ -196,7 +196,7 @@
Ўключаны
Ня прапаноўваць NSFW мангу
Пачніце чытаць мангу, і вы атрымаеце персаналізаваныя прапановы
- Ўсе даныя аналізуюцца толькі лакальна на гэтай прыладзе і нікуды не адпраўляюцца.
+ Усе даныя аналізуюцца толькі лакальна на гэтай прыладзе і нікуды не адпраўляюцца.
Прапануеце мангу, заснаваную на вашых перавагах
Ўключыць прапановы
Прапанова
@@ -211,10 +211,10 @@
Розныя мовы
Знайсці главу
%1$s%%
- Ў гэтай манге няма раздзелаў
+ У гэтай манге няма раздзелаў
Схаваць
Знешні выгляд
- Выключыць ўсё
+ Адключыць усе
Выкарыстоўваць адбітак пальца, калі даступна
Манга з абраных
Манга, якую вы нядаўна чыталі
@@ -235,7 +235,7 @@
Даступныя новыя крыніцы мангі
Запавольванне спампоўкі
Выключыць жанры
- Укажыце жанры, якія вы не хочаце бачыць ў рэкамендацыях
+ Укажыце жанры, якія вы не хочаце бачыць у рэкамендацыях
Выдаліць выбраныя элементы з прылады назаўжды\?
Выдаленне завершана
Дапамагае пазбегнуць блакіроўкі па IP-адрасе
@@ -263,20 +263,20 @@
Выйсці
Паказваць працэнт прачытанага ў гісторыі і абраных
Выдаленне даных
- Паказаць ўсе
+ Паказаць усе
Манга, пазначаная як NSFW, не будзе дададзеная ў гісторыю і ваш прагрэс не будзе захаваны
- Можа дапамагчы з некаторымі праблемам. Ўсе аўтарызацыі будуць ануляваныя
+ Можа дапамагчы з некаторымі праблемам. Усе аўтарызацыі будуць ануляваныя
Змесціва не знойдзена ці выдалена
Перачытваю
Выберыце дыяпазон
Тут нічога няма
Службы
Канадзе
- Ачысціць ўсю гісторыю
+ Ачысціць усю гісторыю
Гісторыя ачышчана
Рэжым інкогніта
Вы ўпэўнены, што хочаце выдаліць выбраныя абраныя катэгорыі?
-\nЎся манга ў ім будзе страчана, і гэта нельга будзе адрабіць.
+\nУся манга ў ім будзе страчана, і гэта нельга будзе адрабіць.
Вы можаце стварыць закладку падчас чытання мангі
Захаваная манга
Мамімі
@@ -288,7 +288,7 @@
Пазначыць як бягучы
На прыладзе не засталося месца
Сетка недаступная
- Каб чытаць мангу онлайн, ўключыце Wi-Fi або мабільную сетку
+ Каб чытаць мангу онлайн, уключыце Wi-Fi або мабільную сетку
Маштабаванне ў рэжыме манхвы
Дынамічны
Каляровая гама
@@ -304,7 +304,7 @@
Паказаць апошнія ярлыкі мангі
Зрабіце нядаўнюю мангу даступнай, доўга націскаючы на значок праграмы
Націск на правы край або націск правай клавішы заўсёды перамыкае на наступную старонку.
- Эрганамічны упраўленне чытаннем
+ Эрганамічнае кіраванне рэжымам чытання
Карэкцыя колеру
Яркасць
Кантраст
@@ -321,7 +321,7 @@
Стужка
Паказаць паўзунок пераключэння старонак
Крыніца адключана
- Паказаць ў выглядзе сеткі
+ Паказаць у выглядзе сеткі
Міку
Аска
Міён
@@ -330,7 +330,7 @@
Няма раздзелаў
Аўтаматычная пракрутка
Разд. %1$d/%2$d Стар. %3$d/%4$d
- Паказаць інфармацыйную панэль ў праграме чытання
+ Паказаць інфармацыйную панэль у праграме чытання
Архіў коміксаў
Тэчка з малюнкамі
Імпарт мангі
@@ -339,7 +339,7 @@
Закладак пакуль няма
Закладкі выдалены
Няма крыніц мангі
- Каб чытаць мангу онлайн, ўключыце крыніцы мангі
+ Каб чытаць мангу онлайн, уключыце крыніцы мангі
Выпадковы
Змяніць парадак
Пуста
@@ -373,7 +373,7 @@
Ігнараваць памылкі SSL
Аднавіць
Прыпынена
- Адмяніць ўсё
+ Адмяніць усе
Спампаваць толькі праз Wi-Fi
Вы можаце выкарыстоўваць уласны сервер сінхранізацыі або сервер па змаўчанні. Не змяняйце гэта, калі вы не ўпэўненыя, што робіце.
Паўза
@@ -382,7 +382,7 @@
Часам паказваць апавяшчэнні з прапанаванай мангай
Больш
Ўключыць
- Ўсе актыўныя спампоўкі будуць адменены, часткова спампаваныя даныя будуць страчаны
+ Усе актыўныя спампоўкі будуць адменены, часткова спампаваныя даныя будуць страчаны
Хочаце атрымліваць персаналізаваныя прапановы мангі\?
Прапанова: %s
Не, дзякуй
@@ -407,13 +407,13 @@
Выкарыстоўвайце службу wsrv.nl, каб паменшыць выкарыстанне трафіку і паскорыць загрузку малюнкаў, калі гэта магчыма
Пароль
Інвертаваць колеры
- Паказаць нумары старонак ў ніжнім куце
+ Паказаць нумары старонак у ніжнім куце
Сетка
Дадзеныя і канфідэнцыяльнасць
Дазволіць жэсты маштабавання ў рэжыме манхвы
Аднавіць раней створаную рэзервовую копію
Паказаць бягучы час і ход чытання ў верхняй частцы экрана
- Выдаліць файлы cookie толькі для вызначанага дамена. Ў большасці выпадкаў гэта робіць аўтарызацыю несапраўднай
+ Выдаліць файлы cookie толькі для вызначанага дамена. У большасці выпадкаў гэта робіць аўтарызацыю несапраўднай
Манга цалкам
Лакальныя каталогі мангі
Ўсе раздзелы з перакладам %s
@@ -459,10 +459,10 @@
Раздзелы галоўнага экрана
Ўверх
Павялічыць
- Ці паказваць кнопкі кіравання маштабаваннем ў правым ніжнім куце
+ Ці паказваць кнопкі кіравання маштабаваннем у правым ніжнім куце
Паказаць кнопкі маштабавання
Зменшыць
- Трымаць экран ўключаным
+ Трымаць экран уключаным
Ня выключаць экран падчас чытання мангі
Кінута
Катэгорыі
@@ -480,7 +480,7 @@
Раз на тыдзень
Перыядычнае рэзервовае капіраванне
Два разы на месяц
- Адзін раз ў месяц
+ Адзін раз у месяц
Апошняе паспяховае рэзервовае капіраванне: %s
Вывадны каталог рэзервовых копій
x%.1f
@@ -496,7 +496,7 @@
Ўручную
Крыніца ўключана
Адключыць крыніцы NSFW і схавайць мангу для дарослых са спісу, калі гэта магчыма
- Ў гэтым раздзеле няма даступных крыніц, ці ўсе яны маглі быць ўжо дададзены.
+ У гэтым раздзеле няма даступных крыніц, ці ўсе яны маглі быць ужо дададзены.
\nСачыце за абнаўленнямі
Даступна: %1$d
Іншае
@@ -517,18 +517,18 @@
Прапусціць
Гэтыя налады могуць прымяняцца глабальна або толькі да бягучай мангі. Пры глабальным прымяненні індывідуальныя налады не будуць перавызначаны.
Адценні шэрага
- Можа дапамагчы з пачаткам загрузкі, калі у вас ўзнікаюць з ёй праблемы
+ Можа дапамагчы з пачаткам загрузкі, калі у вас узнікаюць з ёй праблемы
Выберыце, якія крыніцы змесціва вы хочаце ўключыць. Гэта таксама можна наладзіць пазней ў наладах
Аднавіць
Дата стварэння рэзервовай копіі: %s
- Ўвайдзіце, каб сінхранізаваць ўліковы запіс
+ Увайсці ў акаўнт сінхранізацыі
Імя (зваротнае)
Рэйтынг кантэнту
Выключыць жанры
Бяспечны
З падказкамі
Дарослы
- Ўкладка па змаўчанні
+ Укладка па змаўчанні
Чакаецца
Том %d
Невядомы том
@@ -554,7 +554,7 @@
\nУвага: бягучы ход чытання будзе страчаны.
Налада дзеянняў для сэнсарных абласцей экрана
%1$s %2$s
- Каб працягнуць, ўвядзіце адрас электроннай пошты і пароль
+ Каб працягнуць, увядзіце адрас электроннай пошты і пароль
Скінуць налады да значэнняў па змаўчанні? Гэта дзеянне нельга адмяніць.
Выкарыстоўвайце двухстаронкавы макет у альбомнай арыентацыі (бэта)
Аддаленне ў рэжыме манхвы
@@ -589,7 +589,7 @@
Адзін файл CBZ
Некалькі файлаў CBZ
Альтэрнатывы
- Манга «%1$s» з «%2$s» будзе заменена на «%3$s» з «%4$s» ў вашай гісторыі і ў абраных (калі ёсць)
+ Манга «%1$s» з «%2$s» будзе заменена на «%3$s» з «%4$s» у вашай гісторыі і ў абраных (калі ёсць)
Перанос завершаны
Перанос мангі
Перанесці
@@ -609,7 +609,7 @@
Ўключыць крыніцу
Гэтая крыніца мангі не падтрымліваецца
Паказаць мініяцюры старонак
- Ўключыце ўкладку «Старонкі» на экране звестак
+ Уключыце ўкладку «Старонкі» на экране звестак
Ніякія дадзеныя не былі атрыманы з сервера
Абярыце правільны файл рэзервовай копіі Kotatsu
Апошні раз выкарыстоўваўся
@@ -642,4 +642,6 @@
Адкл.
Крыніцы адключаны
Новае
+ Усе мовы
+ Блакіраваць у рэжыме інкогніта
\ No newline at end of file
diff --git a/app/src/main/res/values-bn/strings.xml b/app/src/main/res/values-bn/strings.xml
index ff06c4710..d4245aceb 100644
--- a/app/src/main/res/values-bn/strings.xml
+++ b/app/src/main/res/values-bn/strings.xml
@@ -11,7 +11,7 @@
ডোমেইন
অ্যাপের নতুন ভার্সন পাওয়া গেছে
ব্রাউজারে খুলুন
- কিছু একটা গন্ডগোল হয়েছে
+ কিছু একটা সমস্যা হয়েছে
খুঁটিনাটি
পর্ব সমূহ
তালিকা
@@ -102,7 +102,7 @@
ডাউনলোডের জন্য ফোল্ডার
কখনও কখনও প্রস্তাবিত মাঙ্গা সহ বিজ্ঞপ্তিগুলি দেখান৷
সাফ করা হয়েছে
- তালিকা মোড
+ তালিকার ধরন
ডাউনলোড করা হয়েছে
হালনাগাদ
ফিড আপডেট শীঘ্রই শুরু হবে
@@ -151,4 +151,4 @@
পরামর্শ: %s
এখানে খালি…
সম্পন্ন
-
+
\ No newline at end of file
diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml
index fe0651fe3..950531d70 100644
--- a/app/src/main/res/values-es/strings.xml
+++ b/app/src/main/res/values-es/strings.xml
@@ -644,4 +644,6 @@
Nuevos
Todos los idiomas
Bloquear en modo incógnito
+ Servidor de imágenes preferido
+ Páginas de recortes
\ No newline at end of file
diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml
index c92721ea0..8576f23ce 100644
--- a/app/src/main/res/values-hi/strings.xml
+++ b/app/src/main/res/values-hi/strings.xml
@@ -644,4 +644,6 @@
नया
सभी भाषाएं
गुप्त मोड में ब्लॉक करें
+ पसंदीदा छवि सर्वर
+ पृष्ठ काटें
\ No newline at end of file
diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml
index 71287cdb7..c18822f71 100644
--- a/app/src/main/res/values-in/strings.xml
+++ b/app/src/main/res/values-in/strings.xml
@@ -641,4 +641,6 @@
Anda diblokir oleh server. Coba gunakan koneksi jaringan yang berbeda (VPN, Proxy, dll.)
Lebih jarang
Jangan sembunyikan bilah navigasi dan tampilan pencarian saat menggulir
+ Dihapus, dibersihkan
+ Pertanyaan yang disarankan
\ No newline at end of file
diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml
index 432cbd0f3..685cc35e8 100644
--- a/app/src/main/res/values-it/strings.xml
+++ b/app/src/main/res/values-it/strings.xml
@@ -593,4 +593,20 @@
Più frequente
Frequenza di controllo
%1$s: %2$d
+ Nuovi
+ Automatico
+ Tutte le lingue
+ Cancella capitoli letti
+ Migrazione manga
+ Migrazione completata
+ Nessun capitolo è stato cancellato
+ Giorno
+ Tre mesi
+ Non ci sono statistiche per il periodo selezionato
+ Chiedi per la cartella di destinazione ogni volta
+ Salva pagine
+ Formato download preferito
+ File CBZ singolo
+ Molti file CBZ
+ Lettura statistiche
\ No newline at end of file
diff --git a/app/src/main/res/values-kk/strings.xml b/app/src/main/res/values-kk/strings.xml
index 39d6be40a..f88a5102d 100644
--- a/app/src/main/res/values-kk/strings.xml
+++ b/app/src/main/res/values-kk/strings.xml
@@ -17,8 +17,8 @@
Тарау
Егжей-тегжейлі тізім
Тізім түрі
- Маңга қайнары
- Жүктеу…
+ Маңга дереккөзі
+ Жүктеліп жатыр…
Есептеу…
Таңдаулы
Желі қатесі
@@ -34,7 +34,7 @@
Таңбаша жасау…
%s бөлісу
Іздеу
- Жүктеу…
+ Жүктеліп жатыр…
Үдерісте…
Жүктелді
Атауы
@@ -107,7 +107,7 @@
Әзірге тарих жоқ
Маңга іздеу
Таңдаулыға
- Жүктелгендер
+ Жүктелген
Қолжетімсіз амал
Жою
CBZ не ZIP пішімде таңдаңыз.
@@ -272,7 +272,7 @@
Ешқашан
Қуат оңтайлығын өшіру
Жоспарланған
- Бетбелгілер
+ Бетбелгі
Бәрін көрсету
Таңдаулы санатыңыз жоқ
Қате дәмейін
@@ -303,7 +303,7 @@
Құпиясөз
Маңганы толықтай
Өзгерту іске қосылуы үшін қолданбаны өшіріп қосыңыз
- Дереккөз сөніп жатыр
+ Дереккөз өшіп жатыр
Сақтық көшірмесінің жиілігі
Дерек пен құпиялық
Қате болса көмектесе алады. Түгел тіркелгінің күші жойылады
@@ -381,7 +381,7 @@
Жүктеп алу тоқтап қалды
Тым көп сұрату. Біраздан соң қайталап көріңіз
Wi-Fi арқылы ғана жүктеу
- Белсенді жүктеудің бәрі жойылып, жартылай жүктелгендер жоғалып кетеді
+ Жүктеліп жатқанның бәрі жойылып, жартылай жүктелгендер жоғалып кетеді
Өзектілігі
Ұқсас маңга
Сақтамау
@@ -471,7 +471,7 @@
Шығып кету үшін «Кері» батырмасын екі рет басыңыз
Бетбелгілер жойылды
Мион
- Дереккөз дәмейіннің қатесі пайда болып, айнасы қолжетімді болса, өздігінен соған ауыстыру
+ Дереккөз дәмейнінің қатесі пайда болып, айнасы қолжетімді болса, өздігінен соған ауыстыру
Бетбелгі жоқ
Жоқ, рақмет
Қолданбаның жаңа нұсқасында пайда болған дереккөзді ұсыну
@@ -514,4 +514,28 @@
Бұл маңга
Бұл дереккөзде жанр бойынша және локал файлдар бойынша сүзуге болмайды
Бұл дереккөзде бірнеше жанр бойынша сүзуге болмайды
+ Жанр алып тастау
+ Қауіпсіз
+ Кесте түрі
+ Бұл дереккөзде жанр мен күні бойынша сүзу жоқ
+ Жанр атауын тере бастаңыз
+ Жүктеу дұрыс басталмай жүрсе көмектесе алады
+ Қалпына келтіру
+ Сақтық көшірме күні: %s
+ Жақында
+ Теріс ат
+ Контент рейтиңі
+ Алдыңғы тарау
+ Келесі тарау
+ Алдыңғы бет
+ Дыбыс батырмасын қосу
+ Дыбыс батырмасы арқылы парақтау
+ Басқандағы әрекет
+ Басып тұрғандағы әрекет
+ Түк
+ Қандай дереккөз қосқыңыз келетінін таңдаңыз. Кейін баптап алуға болады
+ Келесі бет
+ Оқымадағы әрекет
+ Синхрондау тіркелгісіне кіру
+ Әдепкі баптауға қайтайық па? Әрекетті қайтаруға болмайды.
\ No newline at end of file
diff --git a/app/src/main/res/values-ml/strings.xml b/app/src/main/res/values-ml/strings.xml
index fb7acd25c..baf8a8e35 100644
--- a/app/src/main/res/values-ml/strings.xml
+++ b/app/src/main/res/values-ml/strings.xml
@@ -3,4 +3,6 @@
ചരിത്രം
അദ്ധ്യായങ്ങൾ
ക്രമീകരണങ്ങൾ
+ പ്രിയപ്പെട്ടവ
+ വിശദാംശങ്ങൾ
\ No newline at end of file
diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml
index b09a90c14..2e3849546 100644
--- a/app/src/main/res/values-ru/strings.xml
+++ b/app/src/main/res/values-ru/strings.xml
@@ -642,4 +642,15 @@
Откл.
Источники отключены
Новое
+ Все языки
+ Блокировать в режиме инкогнито
+ Источник закреплён
+ Источники откреплены
+ Источники закреплены
+ Недавние источники
+ Обрезать страницы
+ Закрепить
+ Открепить
+ Источник откреплён
+ Сервер изображений
\ No newline at end of file
diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml
index 2a7ebd848..e14bee103 100644
--- a/app/src/main/res/values-uk/strings.xml
+++ b/app/src/main/res/values-uk/strings.xml
@@ -642,4 +642,15 @@
Вимкнути
Джерела вимкнено
Нове
+ Всі мови
+ Блокувати в режимі інкогніто
+ Сервер зображень
+ Відкріпити
+ Джерело закріплено
+ Джерела закріплені
+ Нещодавні джерела
+ Обрізати сторінки
+ Джерела відкріплені
+ Закріпити
+ Джерело відкріплено
\ No newline at end of file
diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml
index 06552452b..2107a6fe3 100644
--- a/app/src/main/res/values/arrays.xml
+++ b/app/src/main/res/values/arrays.xml
@@ -97,4 +97,8 @@
- @string/system_default
- @string/more_frequently
+
+ - @string/pages
+ - @string/webtoon
+
diff --git a/app/src/main/res/values/constants.xml b/app/src/main/res/values/constants.xml
index 8c22f632a..5fbd6afaa 100644
--- a/app/src/main/res/values/constants.xml
+++ b/app/src/main/res/values/constants.xml
@@ -68,4 +68,8 @@
- 1
- 2
+
+ - 1
+ - 2
+
diff --git a/app/src/main/res/values/integers.xml b/app/src/main/res/values/integers.xml
index e405948e9..5c1164864 100644
--- a/app/src/main/res/values/integers.xml
+++ b/app/src/main/res/values/integers.xml
@@ -8,6 +8,7 @@
3
2
4
+ 4
450
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index a2e0983cd..08495a58b 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -654,4 +654,14 @@
New
All languages
Block when incognito mode
+ Preferred image server
+ %1$s: %2$s
+ Crop pages
+ Pin
+ Unpin
+ Source pinned
+ Source unpinned
+ Sources unpinned
+ Sources pinned
+ Recent sources
diff --git a/app/src/main/res/xml/pref_reader.xml b/app/src/main/res/xml/pref_reader.xml
index e13444949..6183b186a 100644
--- a/app/src/main/res/xml/pref_reader.xml
+++ b/app/src/main/res/xml/pref_reader.xml
@@ -88,6 +88,12 @@
android:summary="@string/reader_optimize_summary"
android:title="@string/reader_optimize" />
+
+
+ android:title="@string/clear_cookies"
+ app:allowDividerAbove="true" />
+ android:title="@string/download_slowdown" />
diff --git a/build.gradle b/build.gradle
index 72ac4332e..daa9263a6 100644
--- a/build.gradle
+++ b/build.gradle
@@ -4,7 +4,7 @@ buildscript {
mavenCentral()
}
dependencies {
- classpath 'com.android.tools.build:gradle:8.4.1'
+ classpath 'com.android.tools.build:gradle:8.5.1'
classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.24'
classpath 'com.google.dagger:hilt-android-gradle-plugin:2.51.1'
classpath 'com.google.devtools.ksp:symbol-processing-gradle-plugin:1.9.24-1.0.20'