Compare commits

..

63 Commits
v6.2.3 ... v6.3

Author SHA1 Message Date
Koitharu
55851fb22f Avoid replacing online manga wthin local in database 2023-11-18 16:03:01 +02:00
Koitharu
7801456d17 Enable desugaring to fit Jsoup requirements #553 2023-11-18 15:12:18 +02:00
Koitharu
38a1fafa26 Load local manga if not connection when possible #547 2023-11-18 13:35:12 +02:00
Koitharu
aa02233883 Update parsers 2023-11-18 13:35:12 +02:00
Abay Emes
5405fdb85a Translated using Weblate (Kazakh)
Currently translated at 100.0% (524 of 524 strings)

Co-authored-by: Abay Emes <abayemes@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/kk/
Translation: Kotatsu/Strings
2023-11-18 13:35:03 +02:00
InfinityDouki56
38ad7e1fd4 Translated using Weblate (Filipino)
Currently translated at 88.3% (463 of 524 strings)

Co-authored-by: InfinityDouki56 <ced.paltep10@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fil/
Translation: Kotatsu/Strings
2023-11-18 12:31:21 +02:00
gallegonovato
06372083fd Translated using Weblate (Spanish)
Currently translated at 100.0% (524 of 524 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2023-11-18 12:31:21 +02:00
Isira Seneviratne
d5d3154074 Avoid accidental link clicks 2023-11-18 12:30:39 +02:00
Koitharu
1a279966d9 Update parsers 2023-11-14 07:54:03 +02:00
Koitharu
3222c2128e Translated using Weblate (Russian)
Currently translated at 99.8% (523 of 524 strings)

Co-authored-by: Koitharu <nvasya95@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translation: Kotatsu/Strings
2023-11-14 07:45:12 +02:00
gallegonovato
872c859efe Translated using Weblate (Spanish)
Currently translated at 100.0% (521 of 521 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (519 of 519 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (510 of 510 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2023-11-14 07:45:12 +02:00
Макар Разин
b79c00f8df Translated using Weblate (Ukrainian)
Currently translated at 100.0% (510 of 510 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (510 of 510 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (510 of 510 strings)

Co-authored-by: Макар Разин <makarrazin14@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/be/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/uk/
Translation: Kotatsu/Strings
2023-11-14 07:45:12 +02:00
Koitharu
e7d3d9811d Fix reader zoom buttons 2023-11-12 17:45:37 +02:00
Koitharu
4fdfc75833 Try fix strange crashes 2023-11-12 16:57:05 +02:00
Koitharu
9754ebf1bb Reduce main menu while search opened 2023-11-12 16:48:18 +02:00
Koitharu
fee35cceab Sources settings screen 2023-11-12 16:30:11 +02:00
Koitharu
b928c4123c Update explore navigation 2023-11-12 13:16:42 +02:00
Koitharu
b093a885c9 Sources catalog 2023-11-12 12:59:12 +02:00
Koitharu
dd898579c9 Option to lock reader screen rotation 2023-11-11 15:01:08 +02:00
Koitharu
73143d2f94 Rework favourite sheet 2023-11-11 14:40:30 +02:00
Koitharu
563752f6a4 Upgrade gradle 2023-11-11 12:59:16 +02:00
Koitharu
7135902100 Update parsers 2023-11-10 14:55:28 +02:00
Nayuki
969947ef71 Translated using Weblate (Thai)
Currently translated at 73.2% (373 of 509 strings)

Co-authored-by: Nayuki <me@nayuki.cyou>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/th/
Translation: Kotatsu/Strings
2023-11-10 14:48:38 +02:00
GpixeL
806e4eade6 Translated using Weblate (Indonesian)
Currently translated at 99.4% (506 of 509 strings)

Co-authored-by: GpixeL <gamesfire313@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/id/
Translation: Kotatsu/Strings
2023-11-10 14:48:38 +02:00
Abay Emes
063cfbe6b9 Translated using Weblate (Kazakh)
Currently translated at 100.0% (7 of 7 strings)

Translated using Weblate (Kazakh)

Currently translated at 100.0% (509 of 509 strings)

Co-authored-by: Abay Emes <abayemes@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/kk/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/kk/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2023-11-10 14:48:38 +02:00
InfinityDouki56
7cb94a3baa Translated using Weblate (Filipino)
Currently translated at 88.8% (452 of 509 strings)

Co-authored-by: InfinityDouki56 <ced.paltep10@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fil/
Translation: Kotatsu/Strings
2023-11-10 14:48:38 +02:00
Oğuz Ersen
894c584c78 Translated using Weblate (Turkish)
Currently translated at 100.0% (509 of 509 strings)

Co-authored-by: Oğuz Ersen <oguz@ersen.moe>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2023-11-10 14:48:38 +02:00
gallegonovato
2f65e7776a Translated using Weblate (Spanish)
Currently translated at 100.0% (509 of 509 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2023-11-10 14:48:38 +02:00
Макар Разин
76c56c9119 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (509 of 509 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (509 of 509 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (509 of 509 strings)

Co-authored-by: Макар Разин <makarrazin14@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/be/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/uk/
Translation: Kotatsu/Strings
2023-11-10 14:48:38 +02:00
InfinityDouki56
e0a803399c Translated using Weblate (Filipino)
Currently translated at 88.9% (452 of 508 strings)

Co-authored-by: InfinityDouki56 <ced.paltep10@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fil/
Translation: Kotatsu/Strings
2023-11-07 18:49:34 +02:00
kenewjr
7803f42486 Translated using Weblate (Indonesian)
Currently translated at 96.4% (490 of 508 strings)

Co-authored-by: kenewjr <kenelewatan@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/id/
Translation: Kotatsu/Strings
2023-11-07 18:49:34 +02:00
Макар Разин
39713b3cf6 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (508 of 508 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (508 of 508 strings)

Co-authored-by: Макар Разин <makarrazin14@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/be/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/uk/
Translation: Kotatsu/Strings
2023-11-07 18:49:34 +02:00
Nayuki
8ebf5cea62 Translated using Weblate (Thai)
Currently translated at 68.7% (349 of 508 strings)

Co-authored-by: Nayuki <me@nayuki.cyou>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/th/
Translation: Kotatsu/Strings
2023-11-07 18:49:34 +02:00
Abay Emes
663dabe218 Added translation using Weblate (Kazakh)
Translated using Weblate (Kazakh)

Currently translated at 57.4% (292 of 508 strings)

Co-authored-by: Abay Emes <abayemes@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/kk/
Translation: Kotatsu/Strings
2023-11-07 18:49:34 +02:00
Tommy12pl
3a5d0120bf Translated using Weblate (Chinese (Simplified))
Currently translated at 99.4% (505 of 508 strings)

Co-authored-by: Tommy12pl <tommy12pl@qq.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2023-11-07 18:49:34 +02:00
gallegonovato
a773f932d4 Translated using Weblate (Spanish)
Currently translated at 100.0% (508 of 508 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2023-11-07 18:49:34 +02:00
Koitharu
2a5812735f Cubic reader scroll speed 2023-11-05 08:54:07 +02:00
Koitharu
06ec145802 Update parsers 2023-11-02 08:56:43 +02:00
Koitharu
6624778f7f Fix periodical backups 2023-11-02 08:50:51 +02:00
Koitharu
1af1f071ad Fix crashes 2023-11-01 17:25:55 +02:00
Koitharu
f87db4e6d3 Update dependencies 2023-11-01 16:38:10 +02:00
Crono
07bd66fb39 Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (508 of 508 strings)

Co-authored-by: Crono <cronoreader@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt_BR/
Translation: Kotatsu/Strings
2023-11-01 16:32:38 +02:00
Koitharu
4bb0d52217 Fix downloading 2023-10-28 16:39:43 +03:00
Koitharu
66de4bd49e Translated using Weblate (Russian)
Currently translated at 100.0% (508 of 508 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (507 of 507 strings)

Co-authored-by: Koitharu <nvasya95@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translation: Kotatsu/Strings
2023-10-28 16:16:39 +03:00
Bai
ff12d63696 Translated using Weblate (Turkish)
Currently translated at 100.0% (507 of 507 strings)

Co-authored-by: Bai <batuhanakkurt000@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2023-10-28 16:16:39 +03:00
InfinityDouki56
c168a841f3 Translated using Weblate (Filipino)
Currently translated at 88.9% (451 of 507 strings)

Co-authored-by: InfinityDouki56 <ced.paltep10@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fil/
Translation: Kotatsu/Strings
2023-10-28 16:16:39 +03:00
pro maxime
8bfb676e6a Translated using Weblate (Arabic)
Currently translated at 36.0% (183 of 507 strings)

Translated using Weblate (Arabic)

Currently translated at 100.0% (7 of 7 strings)

Co-authored-by: pro maxime <promaxime45@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/ar/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ar/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2023-10-28 16:16:39 +03:00
gallegonovato
d5c0ce280e Translated using Weblate (Spanish)
Currently translated at 100.0% (507 of 507 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2023-10-28 16:16:39 +03:00
Vinícius Saturnino
b34627c361 Translated using Weblate (Portuguese)
Currently translated at 100.0% (498 of 498 strings)

Co-authored-by: Vinícius Saturnino <saturninodepaulavinicius62@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt/
Translation: Kotatsu/Strings
2023-10-28 16:16:39 +03:00
Paulo Oliveira
cbc3be056a Translated using Weblate (Portuguese)
Currently translated at 100.0% (498 of 498 strings)

Co-authored-by: Paulo Oliveira <junior.literasas@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt/
Translation: Kotatsu/Strings
2023-10-28 16:16:39 +03:00
Koitharu
d9acc4ec18 Fix periodical backups to external directory 2023-10-28 16:14:47 +03:00
Koitharu
577cc848ee Scroll lists to top atomatically 2023-10-28 15:26:22 +03:00
Koitharu
8a64c88a07 (Temporary) remove chapters list from downloads 2023-10-28 14:44:58 +03:00
Koitharu
1cd7745e38 Update parsers 2023-10-28 13:26:02 +03:00
Koitharu
395b3f7200 Fix proguard rules 2023-10-27 17:27:40 +03:00
Koitharu
b8db4c81d8 Handle up navigation from reader 2023-10-27 16:44:40 +03:00
Koitharu
98bd42f3ae Remove deletions from sync process 2023-10-27 15:02:10 +03:00
Koitharu
db8835a7b8 Fix history restoring 2023-10-27 14:18:14 +03:00
Koitharu
afe50a9ed6 Fixes 2023-10-27 13:58:04 +03:00
Koitharu
beba818f57 Periodic backups 2023-10-26 17:24:11 +03:00
Koitharu
beb17ef442 Pause autoscroll while touch down 2023-10-26 16:13:30 +03:00
Koitharu
24f1546019 Fix pagination 2023-10-26 12:45:32 +03:00
ngocanhtve
1b0fed5c56 Translated using Weblate (Vietnamese)
Currently translated at 84.1% (419 of 498 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (7 of 7 strings)

Co-authored-by: ngocanhtve <ngocanh.tve@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/vi/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/vi/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2023-10-26 12:29:29 +03:00
137 changed files with 2714 additions and 877 deletions

View File

@@ -16,8 +16,8 @@ android {
applicationId 'org.koitharu.kotatsu'
minSdk = 21
targetSdk = 34
versionCode = 590
versionName = '6.2.3'
versionCode = 597
versionName = '6.3.0'
generatedDensities = []
testInstrumentationRunner "org.koitharu.kotatsu.HiltTestRunner"
ksp {
@@ -33,7 +33,6 @@ android {
applicationIdSuffix = '.debug'
}
release {
multiDexEnabled false
minifyEnabled true
shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
@@ -48,11 +47,12 @@ android {
main.java.srcDirs += 'src/main/kotlin/'
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
coreLibraryDesugaringEnabled true
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_17.toString()
jvmTarget = JavaVersion.VERSION_1_8.toString()
freeCompilerArgs += [
'-opt-in=kotlin.ExperimentalStdlibApi',
'-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi',
@@ -82,17 +82,18 @@ afterEvaluate {
}
dependencies {
//noinspection GradleDependency
implementation('com.github.KotatsuApp:kotatsu-parsers:6bf0ae92e4') {
implementation('com.github.KotatsuApp:kotatsu-parsers:41eea1c420') {
exclude group: 'org.json', module: 'json'
}
implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.9.10'
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.4'
implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.9.20'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3'
implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'androidx.core:core-ktx:1.12.0'
implementation 'androidx.activity:activity-ktx:1.8.0'
implementation 'androidx.fragment:fragment-ktx:1.6.1'
implementation 'androidx.activity:activity-ktx:1.8.1'
implementation 'androidx.fragment:fragment-ktx:1.6.2'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.2'
implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.6.2'
implementation 'androidx.lifecycle:lifecycle-service:2.6.2'
@@ -119,8 +120,8 @@ dependencies {
implementation 'androidx.room:room-ktx:2.6.0'
ksp 'androidx.room:room-compiler:2.6.0'
implementation 'com.squareup.okhttp3:okhttp:4.11.0'
implementation 'com.squareup.okhttp3:okhttp-dnsoverhttps:4.11.0'
implementation 'com.squareup.okhttp3:okhttp:4.12.0'
implementation 'com.squareup.okhttp3:okhttp-dnsoverhttps:4.12.0'
implementation 'com.squareup.okio:okio:3.6.0'
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.2'
@@ -128,22 +129,22 @@ dependencies {
implementation 'com.google.dagger:hilt-android:2.48.1'
kapt 'com.google.dagger:hilt-compiler:2.48.1'
implementation 'androidx.hilt:hilt-work:1.0.0'
kapt 'androidx.hilt:hilt-compiler:1.0.0'
implementation 'androidx.hilt:hilt-work:1.1.0'
kapt 'androidx.hilt:hilt-compiler:1.1.0'
implementation 'io.coil-kt:coil-base:2.4.0'
implementation 'io.coil-kt:coil-svg:2.4.0'
implementation 'io.coil-kt:coil-base:2.5.0'
implementation 'io.coil-kt:coil-svg:2.5.0'
implementation 'com.github.KotatsuApp:subsampling-scale-image-view:cf089a264d'
implementation 'com.github.solkin:disk-lru-cache:1.4'
implementation 'io.noties.markwon:core:4.6.2'
implementation 'ch.acra:acra-http:5.11.2'
implementation 'ch.acra:acra-dialog:5.11.2'
implementation 'ch.acra:acra-http:5.11.3'
implementation 'ch.acra:acra-dialog:5.11.3'
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.12'
testImplementation 'junit:junit:4.13.2'
testImplementation 'org.json:json:20230618'
testImplementation 'org.json:json:20231013'
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3'
androidTestImplementation 'androidx.test:runner:1.5.2'

View File

@@ -18,3 +18,6 @@
-keep class org.koitharu.kotatsu.core.exceptions.* { *; }
-keep class org.koitharu.kotatsu.settings.NotificationSettingsLegacyFragment
-keep class org.koitharu.kotatsu.core.prefs.ScreenshotsPolicy { *; }
-keep class org.koitharu.kotatsu.settings.backup.PeriodicalBackupSettingsFragment { *; }
-keep class org.jsoup.parser.Tag
-keep class org.jsoup.internal.StringUtil

View File

@@ -221,6 +221,9 @@
</intent-filter>
</activity>
<activity
android:name="org.koitharu.kotatsu.settings.sources.catalog.SourcesCatalogActivity"
android:label="@string/sources_catalog" />
<service
android:name="androidx.work.impl.foreground.SystemForegroundService"

View File

@@ -48,8 +48,8 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
if (!catchingWebViewUnavailability {
setContentView(
ActivityBrowserBinding.inflate(
layoutInflater
)
layoutInflater,
),
)
}) {
return
@@ -82,9 +82,11 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
}
override fun onDestroy() {
viewBinding.webView.run {
stopLoading()
destroy()
runCatching {
viewBinding.webView
}.onSuccess {
it.stopLoading()
it.destroy()
}
super.onDestroy()
}

View File

@@ -29,7 +29,7 @@ class BackupZipOutput(val file: File) : Closeable {
}
}
private const val DIR_BACKUPS = "backups"
const val DIR_BACKUPS = "backups"
suspend fun BackupZipOutput(context: Context): BackupZipOutput = runInterruptible(Dispatchers.IO) {
val dir = context.run {

View File

@@ -4,10 +4,15 @@ import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.RawQuery
import androidx.room.Transaction
import androidx.room.Upsert
import androidx.sqlite.db.SimpleSQLiteQuery
import androidx.sqlite.db.SupportSQLiteQuery
import kotlinx.coroutines.flow.Flow
import org.intellij.lang.annotations.Language
import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity
import org.koitharu.kotatsu.explore.data.SourcesSortOrder
@Dao
abstract class MangaSourcesDao {
@@ -15,11 +20,11 @@ abstract class MangaSourcesDao {
@Query("SELECT * FROM sources ORDER BY sort_key")
abstract suspend fun findAll(): List<MangaSourceEntity>
@Query("SELECT * FROM sources WHERE enabled = 1 ORDER BY sort_key")
abstract suspend fun findAllEnabled(): List<MangaSourceEntity>
@Query("SELECT * FROM sources WHERE enabled = 0 ORDER BY sort_key")
abstract suspend fun findAllDisabled(): List<MangaSourceEntity>
@Query("SELECT * FROM sources WHERE enabled = 1 ORDER BY sort_key")
abstract fun observeEnabled(): Flow<List<MangaSourceEntity>>
@Query("SELECT * FROM sources WHERE enabled = 0")
abstract fun observeDisabled(): Flow<List<MangaSourceEntity>>
@Query("SELECT * FROM sources ORDER BY sort_key")
abstract fun observeAll(): Flow<List<MangaSourceEntity>>
@@ -40,6 +45,22 @@ abstract class MangaSourcesDao {
@Upsert
abstract suspend fun upsert(entry: MangaSourceEntity)
fun observeEnabled(order: SourcesSortOrder): Flow<List<MangaSourceEntity>> {
val orderBy = getOrderBy(order)
@Language("RoomSql")
val query = SimpleSQLiteQuery("SELECT * FROM sources WHERE enabled = 1 ORDER BY $orderBy")
return observeImpl(query)
}
suspend fun findAllEnabled(order: SourcesSortOrder): List<MangaSourceEntity> {
val orderBy = getOrderBy(order)
@Language("RoomSql")
val query = SimpleSQLiteQuery("SELECT * FROM sources WHERE enabled = 1 ORDER BY $orderBy")
return findAllImpl(query)
}
@Transaction
open suspend fun setEnabled(source: String, isEnabled: Boolean) {
if (updateIsEnabled(source, isEnabled) == 0) {
@@ -54,4 +75,16 @@ abstract class MangaSourcesDao {
@Query("UPDATE sources SET enabled = :isEnabled WHERE source = :source")
protected abstract suspend fun updateIsEnabled(source: String, isEnabled: Boolean): Int
@RawQuery(observedEntities = [MangaSourceEntity::class])
protected abstract fun observeImpl(query: SupportSQLiteQuery): Flow<List<MangaSourceEntity>>
@RawQuery
protected abstract suspend fun findAllImpl(query: SupportSQLiteQuery): List<MangaSourceEntity>
private fun getOrderBy(order: SourcesSortOrder) = when (order) {
SourcesSortOrder.ALPHABETIC -> "source ASC"
SourcesSortOrder.POPULARITY -> "(SELECT COUNT(*) FROM manga WHERE source = sources.source) DESC"
SourcesSortOrder.MANUAL -> "sort_key ASC"
}
}

View File

@@ -1,5 +1,8 @@
package org.koitharu.kotatsu.core.model
import android.content.Context
import androidx.annotation.StringRes
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.parsers.model.ContentType
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.toTitleCase
@@ -18,3 +21,18 @@ fun MangaSource(name: String): MangaSource {
}
fun MangaSource.isNsfw() = contentType == ContentType.HENTAI
@get:StringRes
val ContentType.titleResId
get() = when (this) {
ContentType.MANGA -> R.string.content_type_manga
ContentType.HENTAI -> R.string.content_type_hentai
ContentType.COMICS -> R.string.content_type_comics
ContentType.OTHER -> R.string.content_type_other
}
fun MangaSource.getSummary(context: Context): String {
val type = context.getString(contentType.titleResId)
val locale = getLocaleTitle() ?: context.getString(R.string.various_languages)
return context.getString(R.string.source_summary_pattern, type, locale)
}

View File

@@ -14,8 +14,8 @@ class CloudFlareInterceptor : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val response = chain.proceed(chain.request())
if (response.code == HTTP_FORBIDDEN || response.code == HTTP_UNAVAILABLE) {
val content = response.body?.source()?.peek()?.use {
Jsoup.parse(it.inputStream(), Charsets.UTF_8.name(), response.request.url.toString())
val content = response.body?.let { response.peekBody(Long.MAX_VALUE) }?.byteStream()?.use {
Jsoup.parse(it, Charsets.UTF_8.name(), response.request.url.toString())
} ?: return response
if (content.getElementById("challenge-error-title") != null) {
val request = response.request

View File

@@ -11,6 +11,7 @@ import org.koitharu.kotatsu.core.db.entity.toEntities
import org.koitharu.kotatsu.core.db.entity.toEntity
import org.koitharu.kotatsu.core.db.entity.toManga
import org.koitharu.kotatsu.core.db.entity.toMangaTags
import org.koitharu.kotatsu.core.model.isLocal
import org.koitharu.kotatsu.core.prefs.ReaderMode
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
@@ -77,10 +78,18 @@ class MangaDataRepository @Inject constructor(
}
suspend fun storeManga(manga: Manga) {
val tags = manga.tags.toEntities()
db.withTransaction {
db.getTagsDao().upsert(tags)
db.getMangaDao().upsert(manga.toEntity(), tags)
// avoid storing local manga if remote one is already stored
val existing = if (manga.isLocal) {
db.getMangaDao().find(manga.id)?.manga
} else {
null
}
if (existing == null || existing.source == manga.source.name) {
val tags = manga.tags.toEntities()
db.getTagsDao().upsert(tags)
db.getMangaDao().upsert(manga.toEntity(), tags)
}
}
}

View File

@@ -22,6 +22,7 @@ import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.putEnumValue
import org.koitharu.kotatsu.core.util.ext.takeIfReadable
import org.koitharu.kotatsu.core.util.ext.toUriOrNull
import org.koitharu.kotatsu.explore.data.SourcesSortOrder
import org.koitharu.kotatsu.list.domain.ListSortOrder
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.util.find
@@ -209,6 +210,10 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
return policy.isNetworkAllowed(connectivityManager)
}
var sourcesSortOrder: SourcesSortOrder
get() = prefs.getEnumValue(KEY_SOURCES_ORDER, SourcesSortOrder.MANUAL)
set(value) = prefs.edit { putEnumValue(KEY_SOURCES_ORDER, value) }
var isSourcesGridMode: Boolean
get() = prefs.getBoolean(KEY_SOURCES_GRID, false)
set(value) = prefs.edit { putBoolean(KEY_SOURCES_GRID, value) }
@@ -354,6 +359,16 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val is32BitColorsEnabled: Boolean
get() = prefs.getBoolean(KEY_32BIT_COLOR, false)
val isPeriodicalBackupEnabled: Boolean
get() = prefs.getBoolean(KEY_BACKUP_PERIODICAL_ENABLED, false)
val periodicalBackupFrequency: Long
get() = prefs.getString(KEY_BACKUP_PERIODICAL_FREQUENCY, null)?.toLongOrNull() ?: 7L
var periodicalBackupOutput: Uri?
get() = prefs.getString(KEY_BACKUP_PERIODICAL_OUTPUT, null)?.toUriOrNull()
set(value) = prefs.edit { putString(KEY_BACKUP_PERIODICAL_OUTPUT, value?.toString()) }
fun isTipEnabled(tip: String): Boolean {
return prefs.getStringSet(KEY_TIPS_CLOSED, emptySet())?.contains(tip) != true
}
@@ -458,6 +473,10 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_ZOOM_MODE = "zoom_mode"
const val KEY_BACKUP = "backup"
const val KEY_RESTORE = "restore"
const val KEY_BACKUP_PERIODICAL_ENABLED = "backup_periodic"
const val KEY_BACKUP_PERIODICAL_FREQUENCY = "backup_periodic_freq"
const val KEY_BACKUP_PERIODICAL_OUTPUT = "backup_periodic_output"
const val KEY_BACKUP_PERIODICAL_LAST = "backup_periodic_last"
const val KEY_HISTORY_GROUPING = "history_grouping"
const val KEY_READING_INDICATORS = "reading_indicators"
const val KEY_REVERSE_CHAPTERS = "reverse_chapters"
@@ -514,6 +533,8 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_RELATED_MANGA = "related_manga"
const val KEY_NAV_MAIN = "nav_main"
const val KEY_32BIT_COLOR = "enhanced_colors"
const val KEY_SOURCES_ORDER = "sources_sort_order"
const val KEY_SOURCES_CATALOG = "sources_catalog"
// About
const val KEY_APP_UPDATE = "app_update"

View File

@@ -6,7 +6,6 @@ import android.graphics.Color
import android.os.Build
import android.os.Bundle
import android.view.KeyEvent
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import androidx.annotation.CallSuper
@@ -96,11 +95,10 @@ abstract class BaseActivity<B : ViewBinding> :
insetsDelegate.onViewCreated(binding.root)
}
override fun onOptionsItemSelected(item: MenuItem) = if (item.itemId == android.R.id.home) {
onBackPressedDispatcher.onBackPressed()
// TODO: navigateUpTo
true
} else super.onOptionsItemSelected(item)
override fun onSupportNavigateUp(): Boolean {
dispatchNavigateUp()
return true
}
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
if (BuildConfig.DEBUG && keyCode == KeyEvent.KEYCODE_VOLUME_UP) {
@@ -151,6 +149,17 @@ abstract class BaseActivity<B : ViewBinding> :
window.statusBarColor = defaultStatusBarColor
}
protected open fun dispatchNavigateUp() {
val upIntent = parentActivityIntent
if (upIntent != null) {
if (!navigateUpTo(upIntent)) {
startActivity(upIntent)
}
} else {
finishAfterTransition()
}
}
private fun putDataToExtras(intent: Intent?) {
intent?.putExtra(EXTRA_DATA, intent.data)
}

View File

@@ -4,11 +4,11 @@ import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.os.Bundle
import android.text.method.LinkMovementMethod
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.core.text.HtmlCompat
import androidx.core.text.htmlEncode
import androidx.core.text.method.LinkMovementMethodCompat
import androidx.core.text.parseAsHtml
import androidx.fragment.app.FragmentManager
import com.google.android.material.dialog.MaterialAlertDialogBuilder
@@ -37,7 +37,7 @@ class ErrorDetailsDialog : AlertDialogFragment<DialogErrorDetailsBinding>() {
override fun onViewBindingCreated(binding: DialogErrorDetailsBinding, savedInstanceState: Bundle?) {
super.onViewBindingCreated(binding, savedInstanceState)
with(binding.textViewMessage) {
movementMethod = LinkMovementMethod.getInstance()
movementMethod = LinkMovementMethodCompat.getInstance()
text = context.getString(
R.string.manga_error_description_pattern,
exception.message?.htmlEncode().orEmpty(),

View File

@@ -0,0 +1,40 @@
package org.koitharu.kotatsu.core.ui.list
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.AdapterDataObserver
class RecyclerScrollKeeper(
private val rv: RecyclerView,
) : AdapterDataObserver() {
private val scrollUpRunnable = Runnable {
(rv.layoutManager as? LinearLayoutManager)?.scrollToPositionWithOffset(0, 0)
}
fun attach() {
rv.adapter?.registerAdapterDataObserver(this)
}
override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
super.onItemRangeInserted(positionStart, itemCount)
if (positionStart == 0 && isScrolledToTop()) {
postScrollUp()
}
}
override fun onItemRangeMoved(fromPosition: Int, toPosition: Int, itemCount: Int) {
super.onItemRangeMoved(fromPosition, toPosition, itemCount)
if (toPosition == 0 && isScrolledToTop()) {
postScrollUp()
}
}
private fun postScrollUp() {
rv.postDelayed(scrollUpRunnable, 500L)
}
private fun isScrolledToTop(): Boolean {
return (rv.layoutManager as? LinearLayoutManager)?.findFirstVisibleItemPosition() == 0
}
}

View File

@@ -0,0 +1,74 @@
package org.koitharu.kotatsu.core.ui.widgets
import android.content.Context
import android.util.ArrayMap
import android.util.AttributeSet
import com.google.android.material.slider.Slider
import kotlin.math.cbrt
import kotlin.math.pow
class CubicSlider @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
) : Slider(context, attrs) {
private val changeListeners = ArrayMap<OnChangeListener, OnChangeListenerMapper>(1)
override fun setValue(value: Float) {
super.setValue(value.unmap())
}
override fun getValue(): Float {
return super.getValue().map()
}
override fun getValueFrom(): Float {
return super.getValueFrom().map()
}
override fun setValueFrom(valueFrom: Float) {
super.setValueFrom(valueFrom.unmap())
}
override fun getValueTo(): Float {
return super.getValueTo().map()
}
override fun setValueTo(valueTo: Float) {
super.setValueTo(valueTo.unmap())
}
override fun addOnChangeListener(listener: OnChangeListener) {
val mapper = OnChangeListenerMapper(listener)
super.addOnChangeListener(mapper)
changeListeners[listener] = mapper
}
override fun removeOnChangeListener(listener: OnChangeListener) {
changeListeners.remove(listener)?.let {
super.removeOnChangeListener(it)
}
}
override fun clearOnChangeListeners() {
super.clearOnChangeListeners()
changeListeners.clear()
}
private fun Float.map(): Float {
return this.pow(3)
}
private fun Float.unmap(): Float {
return cbrt(this)
}
private inner class OnChangeListenerMapper(
private val delegate: OnChangeListener,
) : OnChangeListener {
override fun onValueChange(slider: Slider, value: Float, fromUser: Boolean) {
delegate.onValueChange(slider, value.map(), fromUser)
}
}
}

View File

@@ -26,7 +26,7 @@ class CompositeMutex<T : Any> : Set<T> {
}
override fun isEmpty(): Boolean {
return state.isEmpty
return state.isEmpty()
}
override fun iterator(): Iterator<T> {

View File

@@ -19,7 +19,7 @@ class CompositeMutex2<T : Any> : Set<T> {
}
override fun isEmpty(): Boolean {
return delegates.isEmpty
return delegates.isEmpty()
}
override fun iterator(): Iterator<T> {

View File

@@ -0,0 +1,26 @@
package org.koitharu.kotatsu.core.util
import androidx.core.os.LocaleListCompat
import org.koitharu.kotatsu.core.util.ext.map
import java.util.Locale
class LocaleComparator : Comparator<Locale?> {
private val deviceLocales = LocaleListCompat.getAdjustedDefault()//LocaleManagerCompat.getSystemLocales(context)
.map { it.language }
.distinct()
override fun compare(a: Locale?, b: Locale?): Int {
return if (a === b) {
0
} else {
val indexA = if (a == null) -1 else deviceLocales.indexOf(a.language)
val indexB = if (b == null) -1 else deviceLocales.indexOf(b.language)
if (indexA < 0 && indexB < 0) {
compareValues(a?.language, b?.language)
} else {
-2 - (indexA - indexB)
}
}
}
}

View File

@@ -6,13 +6,16 @@ import android.content.res.Configuration
import android.database.ContentObserver
import android.os.Handler
import android.provider.Settings
import dagger.hilt.android.scopes.ActivityScoped
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.channels.trySendBlocking
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.onStart
import javax.inject.Inject
class ScreenOrientationHelper(private val activity: Activity) {
@ActivityScoped
class ScreenOrientationHelper @Inject constructor(private val activity: Activity) {
val isAutoRotationEnabled: Boolean
get() = Settings.System.getInt(
@@ -31,9 +34,15 @@ class ScreenOrientationHelper(private val activity: Activity) {
}
}
fun toggleOrientation() {
isLandscape = !isLandscape
}
var isLocked: Boolean
get() = activity.requestedOrientation == ActivityInfo.SCREEN_ORIENTATION_LOCKED
set(value) {
activity.requestedOrientation = if (value) {
ActivityInfo.SCREEN_ORIENTATION_LOCKED
} else {
ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
}
}
fun observeAutoOrientation() = callbackFlow {
val observer = object : ContentObserver(Handler(activity.mainLooper)) {

View File

@@ -83,7 +83,7 @@ fun <I> ActivityResultLauncher<I>.tryLaunch(
e.printStackTraceDebug()
}.isSuccess
fun SharedPreferences.observe() = callbackFlow<String?> {
fun SharedPreferences.observe(): Flow<String?> = callbackFlow {
val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
trySendBlocking(key)
}

View File

@@ -23,7 +23,7 @@ fun ImageView.newImageRequest(lifecycleOwner: LifecycleOwner, data: Any?): Image
return null
}
}
disposeImageRequest()
// disposeImageRequest()
return ImageRequest.Builder(context)
.data(data)
.lifecycle(lifecycleOwner)

View File

@@ -8,6 +8,7 @@ import okhttp3.Response
import okhttp3.internal.closeQuietly
import okio.IOException
import org.json.JSONObject
import org.jsoup.HttpStatusException
import java.net.HttpURLConnection
private val TYPE_JSON = "application/json".toMediaType()
@@ -34,9 +35,8 @@ val HttpUrl.isHttpOrHttps: Boolean
fun Response.ensureSuccess() = apply {
if (!isSuccessful || code == HttpURLConnection.HTTP_NO_CONTENT) {
val message = "Invalid response: $code $message at ${request.url}"
closeQuietly()
throw IllegalStateException(message)
throw HttpStatusException(message, code, request.url.toString())
}
}

View File

@@ -12,6 +12,7 @@ import androidx.core.view.children
import androidx.core.view.descendants
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.ViewHolder
import androidx.viewpager2.widget.ViewPager2
import com.google.android.material.progressindicator.BaseProgressIndicator
import com.google.android.material.slider.Slider
@@ -68,6 +69,10 @@ inline fun ViewPager2.doOnPageChanged(crossinline callback: (Int) -> Unit) {
val ViewPager2.recyclerView: RecyclerView?
get() = children.firstNotNullOfOrNull { it as? RecyclerView }
fun ViewPager2.findCurrentViewHolder(): ViewHolder? {
return recyclerView?.findViewHolderForAdapterPosition(currentItem)
}
fun View.resetTransformations() {
alpha = 1f
translationX = 0f

View File

@@ -87,5 +87,5 @@ class DetailsInteractor @Inject constructor(
}
}
suspend fun findLocal(seed: Manga) = localMangaRepository.getRemoteManga(seed)
suspend fun findRemote(seed: Manga) = localMangaRepository.getRemoteManga(seed)
}

View File

@@ -12,6 +12,7 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.runInterruptible
import org.koitharu.kotatsu.core.model.isLocal
import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.parser.MangaIntent
import org.koitharu.kotatsu.core.parser.MangaRepository
@@ -32,6 +33,7 @@ class DetailsLoadUseCase @Inject constructor(
private val mangaRepositoryFactory: MangaRepository.Factory,
private val recoverUseCase: RecoverMangaUseCase,
private val imageGetter: Html.ImageGetter,
private val networkState: NetworkState,
) {
operator fun invoke(intent: MangaIntent): Flow<MangaDetails> = channelFlow {
@@ -46,6 +48,13 @@ class DetailsLoadUseCase @Inject constructor(
null
}
send(MangaDetails(manga, null, null, false))
if (!networkState.value) {
// try load offline instead
local?.await()?.manga?.let { localManga ->
send(MangaDetails(localManga, null, localManga.description?.parseAsHtml(withImages = false), true))
return@channelFlow
}
}
val details = getDetails(manga)
send(MangaDetails(details, local?.peek(), details.description?.parseAsHtml(withImages = false), false))
send(MangaDetails(details, local?.await(), details.description?.parseAsHtml(withImages = true), true))

View File

@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.details.domain
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.model.findChapter
import org.koitharu.kotatsu.core.model.isLocal
import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.history.data.PROGRESS_NONE
import org.koitharu.kotatsu.local.data.LocalMangaRepository
@@ -13,6 +14,7 @@ class ProgressUpdateUseCase @Inject constructor(
private val mangaRepositoryFactory: MangaRepository.Factory,
private val database: MangaDatabase,
private val localMangaRepository: LocalMangaRepository,
private val networkState: NetworkState,
) {
suspend operator fun invoke(manga: Manga): Float {
@@ -22,6 +24,9 @@ class ProgressUpdateUseCase @Inject constructor(
} else {
manga
}
if (!seed.isLocal && !networkState.value) {
return PROGRESS_NONE
}
val repo = mangaRepositoryFactory.create(seed.source)
val details = if (manga.source != seed.source || seed.chapters.isNullOrEmpty()) {
repo.getDetails(seed)

View File

@@ -1,7 +1,6 @@
package org.koitharu.kotatsu.details.ui
import android.os.Bundle
import android.text.method.LinkMovementMethod
import android.transition.TransitionManager
import android.view.LayoutInflater
import android.view.View
@@ -13,6 +12,7 @@ import androidx.core.content.ContextCompat
import androidx.core.graphics.Insets
import androidx.core.text.buildSpannedString
import androidx.core.text.color
import androidx.core.text.method.LinkMovementMethodCompat
import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.core.view.updatePadding
@@ -107,7 +107,7 @@ class DetailsFragment :
binding.infoLayout.textViewSource.setOnClickListener(this)
binding.textViewDescription.addOnLayoutChangeListener(this)
binding.textViewDescription.viewTreeObserver.addOnDrawListener(this)
binding.textViewDescription.movementMethod = LinkMovementMethod.getInstance()
binding.textViewDescription.movementMethod = LinkMovementMethodCompat.getInstance()
binding.chipsTags.onChipClickListener = this
binding.recyclerViewRelated.addItemDecoration(
SpacingItemDecoration(resources.getDimensionPixelOffset(R.dimen.grid_spacing)),

View File

@@ -19,7 +19,7 @@ import org.koitharu.kotatsu.core.os.AppShortcutManager
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.util.ShareHelper
import org.koitharu.kotatsu.download.ui.dialog.DownloadOption
import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteSheet
import org.koitharu.kotatsu.favourites.ui.categories.select.FavoriteSheet
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.scrobbling.common.ui.selector.ScrobblingSelectorSheet
import org.koitharu.kotatsu.search.ui.multi.MultiSearchActivity
@@ -63,7 +63,7 @@ class DetailsMenuProvider(
R.id.action_favourite -> {
viewModel.manga.value?.let {
FavouriteSheet.show(activity.supportFragmentManager, it)
FavoriteSheet.show(activity.supportFragmentManager, it)
}
}

View File

@@ -149,15 +149,13 @@ class DetailsViewModel @Inject constructor(
val scrobblingInfo: StateFlow<List<ScrobblingInfo>> = interactor.observeScrobblingInfo(mangaId)
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList())
val relatedManga: StateFlow<List<MangaItemModel>> = manga
.mapLatest {
if (it != null && settings.isRelatedMangaEnabled) {
relatedMangaUseCase.invoke(it)?.toUi(ListMode.GRID, extraProvider).orEmpty()
} else {
emptyList()
}
val relatedManga: StateFlow<List<MangaItemModel>> = manga.mapLatest {
if (it != null && settings.isRelatedMangaEnabled) {
relatedMangaUseCase.invoke(it)?.toUi(ListMode.GRID, extraProvider).orEmpty()
} else {
emptyList()
}
.stateIn(viewModelScope, SharingStarted.Lazily, emptyList())
}.stateIn(viewModelScope, SharingStarted.Lazily, emptyList())
val branches: StateFlow<List<MangaBranch>> = combine(
details,
@@ -217,7 +215,7 @@ class DetailsViewModel @Inject constructor(
}
launchJob(Dispatchers.Default) {
val manga = details.firstOrNull { it != null && it.isLocal } ?: return@launchJob
remoteManga.value = interactor.findLocal(manga.toManga())
remoteManga.value = interactor.findRemote(manga.toManga())
}
}

View File

@@ -2,7 +2,6 @@ package org.koitharu.kotatsu.details.ui.scrobbling
import android.content.Intent
import android.os.Bundle
import android.text.method.LinkMovementMethod
import android.view.LayoutInflater
import android.view.MenuItem
import android.view.View
@@ -12,6 +11,7 @@ import android.widget.RatingBar
import android.widget.Toast
import androidx.appcompat.widget.PopupMenu
import androidx.core.net.toUri
import androidx.core.text.method.LinkMovementMethodCompat
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.activityViewModels
import coil.ImageLoader
@@ -71,7 +71,7 @@ class ScrobblingInfoSheet :
binding.ratingBar.onRatingBarChangeListener = this
binding.buttonMenu.setOnClickListener(this)
binding.imageViewCover.setOnClickListener(this)
binding.textViewDescription.movementMethod = LinkMovementMethod.getInstance()
binding.textViewDescription.movementMethod = LinkMovementMethodCompat.getInstance()
menu = PopupMenu(binding.root.context, binding.buttonMenu).apply {
inflate(R.menu.opt_scrobbling)

View File

@@ -18,8 +18,7 @@ data class DownloadState(
val currentPage: Int = 0,
val eta: Long = -1L,
val localManga: LocalManga? = null,
val downloadedChapters: LongArray = LongArray(0),
val scheduledChapters: LongArray = LongArray(0),
val downloadedChapters: Int = 0,
val timestamp: Long = System.currentTimeMillis(),
) {
@@ -42,68 +41,17 @@ data class DownloadState(
.putLong(DATA_ETA, eta)
.putLong(DATA_TIMESTAMP, timestamp)
.putString(DATA_ERROR, error)
.putLongArray(DATA_CHAPTERS, downloadedChapters)
.putLongArray(DATA_CHAPTERS_SRC, scheduledChapters)
.putInt(DATA_CHAPTERS, downloadedChapters)
.putBoolean(DATA_INDETERMINATE, isIndeterminate)
.putBoolean(DATA_PAUSED, isPaused)
.build()
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as DownloadState
if (manga != other.manga) return false
if (isIndeterminate != other.isIndeterminate) return false
if (isPaused != other.isPaused) return false
if (isStopped != other.isStopped) return false
if (error != other.error) return false
if (totalChapters != other.totalChapters) return false
if (currentChapter != other.currentChapter) return false
if (totalPages != other.totalPages) return false
if (currentPage != other.currentPage) return false
if (eta != other.eta) return false
if (localManga != other.localManga) return false
if (!downloadedChapters.contentEquals(other.downloadedChapters)) return false
if (!scheduledChapters.contentEquals(other.scheduledChapters)) return false
if (timestamp != other.timestamp) return false
if (max != other.max) return false
if (progress != other.progress) return false
if (percent != other.percent) return false
return true
}
override fun hashCode(): Int {
var result = manga.hashCode()
result = 31 * result + isIndeterminate.hashCode()
result = 31 * result + isPaused.hashCode()
result = 31 * result + isStopped.hashCode()
result = 31 * result + (error?.hashCode() ?: 0)
result = 31 * result + totalChapters
result = 31 * result + currentChapter
result = 31 * result + totalPages
result = 31 * result + currentPage
result = 31 * result + eta.hashCode()
result = 31 * result + (localManga?.hashCode() ?: 0)
result = 31 * result + downloadedChapters.contentHashCode()
result = 31 * result + scheduledChapters.contentHashCode()
result = 31 * result + timestamp.hashCode()
result = 31 * result + max
result = 31 * result + progress
result = 31 * result + percent.hashCode()
return result
}
companion object {
private const val DATA_MANGA_ID = "manga_id"
private const val DATA_MAX = "max"
private const val DATA_PROGRESS = "progress"
private const val DATA_CHAPTERS = "chapter"
private const val DATA_CHAPTERS_SRC = "chapters_src"
private const val DATA_CHAPTERS = "chapter_cnt"
private const val DATA_ETA = "eta"
private const val DATA_TIMESTAMP = "timestamp"
private const val DATA_ERROR = "error"
@@ -126,8 +74,6 @@ data class DownloadState(
fun getTimestamp(data: Data): Date = Date(data.getLong(DATA_TIMESTAMP, 0L))
fun getDownloadedChapters(data: Data): LongArray = data.getLongArray(DATA_CHAPTERS) ?: LongArray(0)
fun getScheduledChapters(data: Data): LongArray = data.getLongArray(DATA_CHAPTERS_SRC) ?: LongArray(0)
fun getDownloadedChapters(data: Data): Int = data.getInt(DATA_CHAPTERS, 0)
}
}

View File

@@ -2,28 +2,22 @@ package org.koitharu.kotatsu.download.ui.list
import android.transition.TransitionManager
import android.view.View
import androidx.core.content.ContextCompat
import androidx.core.view.isVisible
import androidx.lifecycle.LifecycleOwner
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.RecyclerView
import androidx.work.WorkInfo
import coil.ImageLoader
import coil.request.SuccessResult
import coil.util.CoilUtils
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.BaseListAdapter
import org.koitharu.kotatsu.core.ui.image.TrimTransformation
import org.koitharu.kotatsu.core.util.ext.drawableEnd
import org.koitharu.kotatsu.core.util.ext.enqueueWith
import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled
import org.koitharu.kotatsu.core.util.ext.newImageRequest
import org.koitharu.kotatsu.core.util.ext.source
import org.koitharu.kotatsu.core.util.ext.textAndVisible
import org.koitharu.kotatsu.databinding.ItemDownloadBinding
import org.koitharu.kotatsu.download.ui.list.chapters.DownloadChapter
import org.koitharu.kotatsu.download.ui.list.chapters.downloadChapterAD
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.util.format
@@ -36,9 +30,7 @@ fun downloadItemAD(
) {
val percentPattern = context.resources.getString(R.string.percent_string_pattern)
val expandIcon = ContextCompat.getDrawable(context, R.drawable.ic_expand_collapse)
val chaptersAdapter = BaseListAdapter<DownloadChapter>()
.addDelegate(ListItemType.CHAPTER, downloadChapterAD())
// val expandIcon = ContextCompat.getDrawable(context, R.drawable.ic_expand_collapse)
val clickListener = object : View.OnClickListener, View.OnLongClickListener {
override fun onClick(v: View) {
@@ -59,27 +51,26 @@ fun downloadItemAD(
binding.buttonResume.setOnClickListener(clickListener)
itemView.setOnClickListener(clickListener)
itemView.setOnLongClickListener(clickListener)
binding.recyclerViewChapters.addItemDecoration(DividerItemDecoration(context, RecyclerView.VERTICAL))
binding.recyclerViewChapters.adapter = chaptersAdapter
bind { payloads ->
if (ListModelDiffCallback.PAYLOAD_CHECKED_CHANGED in payloads && context.isAnimationsEnabled) {
TransitionManager.beginDelayedTransition(binding.constraintLayout)
}
binding.textViewTitle.text = item.manga.title
binding.imageViewCover.newImageRequest(lifecycleOwner, item.manga.coverUrl)?.apply {
placeholder(R.drawable.ic_placeholder)
fallback(R.drawable.ic_placeholder)
error(R.drawable.ic_error_placeholder)
allowRgb565(true)
transformations(TrimTransformation())
source(item.manga.source)
enqueueWith(coil)
if ((CoilUtils.result(binding.imageViewCover) as? SuccessResult)?.memoryCacheKey != item.coverCacheKey) {
binding.imageViewCover.newImageRequest(lifecycleOwner, item.manga.coverUrl)?.apply {
placeholder(R.drawable.ic_placeholder)
fallback(R.drawable.ic_placeholder)
error(R.drawable.ic_error_placeholder)
allowRgb565(true)
transformations(TrimTransformation())
memoryCacheKey(item.coverCacheKey)
source(item.manga.source)
enqueueWith(coil)
}
}
binding.textViewTitle.isChecked = item.isExpanded
binding.textViewTitle.drawableEnd = if (item.isExpandable) expandIcon else null
binding.cardDetails.isVisible = item.isExpanded
chaptersAdapter.items = item.chapters
// binding.textViewTitle.isChecked = item.isExpanded
// binding.textViewTitle.drawableEnd = if (item.isExpandable) expandIcon else null
when (item.workState) {
WorkInfo.State.ENQUEUED,
WorkInfo.State.BLOCKED -> {
@@ -117,11 +108,11 @@ fun downloadItemAD(
binding.progressBar.isVisible = false
binding.progressBar.isEnabled = true
binding.textViewPercent.isVisible = false
if (item.totalChapters > 0) {
if (item.chaptersDownloaded > 0) {
binding.textViewDetails.text = context.resources.getQuantityString(
R.plurals.chapters,
item.totalChapters,
item.totalChapters,
item.chaptersDownloaded,
item.chaptersDownloaded,
)
binding.textViewDetails.isVisible = true
} else {

View File

@@ -2,7 +2,7 @@ package org.koitharu.kotatsu.download.ui.list
import android.text.format.DateUtils
import androidx.work.WorkInfo
import org.koitharu.kotatsu.download.ui.list.chapters.DownloadChapter
import coil.memory.MemoryCache
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.model.Manga
@@ -17,14 +17,15 @@ data class DownloadItemModel(
val manga: Manga,
val error: String?,
val max: Int,
val totalChapters: Int,
val progress: Int,
val eta: Long,
val timestamp: Date,
val chapters: List<DownloadChapter>,
val chaptersDownloaded: Int,
val isExpanded: Boolean,
) : ListModel, Comparable<DownloadItemModel> {
val coverCacheKey = MemoryCache.Key(manga.coverUrl, mapOf("dl" to "1"))
val percent: Float
get() = if (max > 0) progress / max.toFloat() else 0f
@@ -38,7 +39,7 @@ data class DownloadItemModel(
get() = workState == WorkInfo.State.RUNNING && isPaused
val isExpandable: Boolean
get() = chapters.isNotEmpty()
get() = false // TODO
fun getEtaString(): CharSequence? = if (hasEta) {
DateUtils.getRelativeTimeSpanString(

View File

@@ -15,6 +15,7 @@ import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.ui.list.ListSelectionController
import org.koitharu.kotatsu.core.ui.list.RecyclerScrollKeeper
import org.koitharu.kotatsu.core.ui.util.MenuInvalidator
import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver
import org.koitharu.kotatsu.core.util.ext.observe
@@ -53,6 +54,7 @@ class DownloadsActivity : BaseActivity<ActivityDownloadsBinding>(),
addItemDecoration(decoration)
adapter = downloadsAdapter
selectionController.attachToRecyclerView(this)
RecyclerScrollKeeper(this).attach()
}
addMenuProvider(DownloadsMenuProvider(this, viewModel))
viewModel.items.observe(this) {

View File

@@ -28,7 +28,6 @@ import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.core.util.ext.daysDiff
import org.koitharu.kotatsu.download.domain.DownloadState
import org.koitharu.kotatsu.download.ui.list.chapters.DownloadChapter
import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
import org.koitharu.kotatsu.list.ui.model.EmptyState
import org.koitharu.kotatsu.list.ui.model.ListHeader
@@ -239,8 +238,6 @@ class DownloadsViewModel @Inject constructor(
val mangaId = DownloadState.getMangaId(workData)
if (mangaId == 0L) return null
val manga = getManga(mangaId) ?: return null
val downloadedChapters = DownloadState.getDownloadedChapters(workData)
val scheduledChapters = DownloadState.getScheduledChapters(workData).toSet()
return DownloadItemModel(
id = id,
workState = state,
@@ -252,19 +249,8 @@ class DownloadsViewModel @Inject constructor(
progress = DownloadState.getProgress(workData),
eta = DownloadState.getEta(workData),
timestamp = DownloadState.getTimestamp(workData),
totalChapters = downloadedChapters.size,
chaptersDownloaded = DownloadState.getDownloadedChapters(workData),
isExpanded = isExpanded,
chapters = manga.chapters?.mapNotNull {
if (it.id in scheduledChapters) {
DownloadChapter(
number = it.number,
name = it.name,
isDownloaded = it.id in downloadedChapters,
)
} else {
null
}
}.orEmpty(),
)
}

View File

@@ -38,6 +38,7 @@ import okio.buffer
import okio.sink
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.TooManyRequestExceptions
import org.koitharu.kotatsu.core.model.ids
import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.core.network.MangaHttpClient
import org.koitharu.kotatsu.core.parser.MangaDataRepository
@@ -46,7 +47,6 @@ import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.Throttler
import org.koitharu.kotatsu.core.util.ext.awaitFinishedWorkInfosByTag
import org.koitharu.kotatsu.core.util.ext.awaitUpdateWork
import org.koitharu.kotatsu.core.util.ext.awaitWorkInfoById
import org.koitharu.kotatsu.core.util.ext.awaitWorkInfosByTag
import org.koitharu.kotatsu.core.util.ext.deleteAwait
import org.koitharu.kotatsu.core.util.ext.deleteWork
@@ -105,11 +105,12 @@ class DownloadWorker @AssistedInject constructor(
setForeground(getForegroundInfo())
val mangaId = inputData.getLong(MANGA_ID, 0L)
val manga = mangaDataRepository.findMangaById(mangaId) ?: return Result.failure()
val chaptersIds = inputData.getLongArray(CHAPTERS_IDS)?.takeUnless { it.isEmpty() }
val downloadedIds = getDoneChapters()
lastPublishedState = DownloadState(manga, isIndeterminate = true)
publishState(DownloadState(manga, isIndeterminate = true))
val chaptersIds = inputData.getLongArray(CHAPTERS_IDS)?.takeUnless { it.isEmpty() }
val downloadedIds = getDoneChapters(manga)
return try {
downloadMangaImpl(chaptersIds, downloadedIds)
downloadMangaImpl(manga, chaptersIds, downloadedIds)
Result.success(currentState.toWorkData())
} catch (e: CancellationException) {
withContext(NonCancellable) {
@@ -147,10 +148,11 @@ class DownloadWorker @AssistedInject constructor(
}
private suspend fun downloadMangaImpl(
subject: Manga,
includedIds: LongArray?,
excludedIds: LongArray,
excludedIds: Set<Long>,
) {
var manga = currentState.manga
var manga = subject
val chaptersToSkip = excludedIds.toMutableSet()
withMangaLock(manga) {
ContextCompat.registerReceiver(
@@ -178,16 +180,9 @@ class DownloadWorker @AssistedInject constructor(
}
}
val chapters = getChapters(mangaDetails, includedIds)
publishState(
currentState.copy(scheduledChapters = LongArray(chapters.size) { i -> chapters[i].id }),
)
for ((chapterIndex, chapter) in chapters.withIndex()) {
if (chaptersToSkip.remove(chapter.id)) {
publishState(
currentState.copy(
downloadedChapters = currentState.downloadedChapters + chapter.id,
),
)
publishState(currentState.copy(downloadedChapters = currentState.downloadedChapters + 1))
continue
}
val pages = runFailsafe(pausingHandle) {
@@ -225,11 +220,7 @@ class DownloadWorker @AssistedInject constructor(
localStorageChanges.emit(LocalMangaInput.of(output.rootFile).getManga())
}.onFailure(Throwable::printStackTraceDebug)
}
publishState(
currentState.copy(
downloadedChapters = currentState.downloadedChapters + chapter.id,
),
)
publishState(currentState.copy(downloadedChapters = currentState.downloadedChapters + 1))
}
publishState(currentState.copy(isIndeterminate = true, eta = -1L))
output.mergeWithExisting()
@@ -336,11 +327,9 @@ class DownloadWorker @AssistedInject constructor(
setProgress(state.toWorkData())
}
private suspend fun getDoneChapters(): LongArray {
val work = WorkManager.getInstance(applicationContext).awaitWorkInfoById(id)
?: return LongArray(0)
return DownloadState.getDownloadedChapters(work.progress)
}
private suspend fun getDoneChapters(manga: Manga) = runCatchingCancellable {
localMangaRepository.getDetails(manga).chapters?.ids()
}.getOrNull().orEmpty()
private fun getChapters(
manga: Manga,

View File

@@ -19,6 +19,7 @@ import org.koitharu.kotatsu.core.prefs.observeAsFlow
import org.koitharu.kotatsu.core.ui.util.ReversibleHandle
import org.koitharu.kotatsu.parsers.model.ContentType
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.mapToSet
import java.util.Collections
import java.util.EnumSet
import javax.inject.Inject
@@ -43,15 +44,44 @@ class MangaSourcesRepository @Inject constructor(
get() = Collections.unmodifiableSet(remoteSources)
suspend fun getEnabledSources(): List<MangaSource> {
return dao.findAllEnabled().toSources(settings.isNsfwContentDisabled)
val order = settings.sourcesSortOrder
return dao.findAllEnabled(order).toSources(settings.isNsfwContentDisabled, order)
}
fun observeEnabledSources(): Flow<List<MangaSource>> = observeIsNsfwDisabled().flatMapLatest { skipNsfw ->
dao.observeEnabled().map {
it.toSources(skipNsfw)
}
suspend fun getDisabledSources(): List<MangaSource> {
return dao.findAllDisabled().toSources(settings.isNsfwContentDisabled, null)
}
fun observeEnabledSourcesCount(): Flow<Int> {
return combine(
observeIsNsfwDisabled(),
dao.observeEnabled(SourcesSortOrder.MANUAL),
) { skipNsfw, sources ->
sources.count { skipNsfw || !MangaSource(it.source).isNsfw() }
}.distinctUntilChanged()
}
fun observeAvailableSourcesCount(): Flow<Int> {
return combine(
observeIsNsfwDisabled(),
dao.observeEnabled(SourcesSortOrder.MANUAL),
) { skipNsfw, enabledSources ->
val enabled = enabledSources.mapToSet { it.source }
allMangaSources.count { x ->
x.name !in enabled && (!skipNsfw || !x.isNsfw())
}
}.distinctUntilChanged()
}
fun observeEnabledSources(): Flow<List<MangaSource>> = combine(
observeIsNsfwDisabled(),
observeSortOrder(),
) { skipNsfw, order ->
dao.observeEnabled(order).map {
it.toSources(skipNsfw, order)
}
}.flatMapLatest { it }
fun observeAll(): Flow<List<Pair<MangaSource, Boolean>>> = dao.observeAll().map { entities ->
val result = ArrayList<Pair<MangaSource, Boolean>>(entities.size)
for (entity in entities) {
@@ -146,7 +176,10 @@ class MangaSourcesRepository @Inject constructor(
return result
}
private fun List<MangaSourceEntity>.toSources(skipNsfwSources: Boolean): List<MangaSource> {
private fun List<MangaSourceEntity>.toSources(
skipNsfwSources: Boolean,
sortOrder: SourcesSortOrder?,
): List<MangaSource> {
val result = ArrayList<MangaSource>(size)
for (entity in this) {
val source = MangaSource(entity.source)
@@ -157,6 +190,9 @@ class MangaSourcesRepository @Inject constructor(
result.add(source)
}
}
if (sortOrder == SourcesSortOrder.ALPHABETIC) {
result.sortBy { it.title }
}
return result
}
@@ -167,4 +203,8 @@ class MangaSourcesRepository @Inject constructor(
private fun observeIsNewSourcesEnabled() = settings.observeAsFlow(AppSettings.KEY_SOURCES_NEW) {
isNewSourcesTipEnabled
}
private fun observeSortOrder() = settings.observeAsFlow(AppSettings.KEY_SOURCES_ORDER) {
sourcesSortOrder
}
}

View File

@@ -0,0 +1,12 @@
package org.koitharu.kotatsu.explore.data
import androidx.annotation.StringRes
import org.koitharu.kotatsu.R
enum class SourcesSortOrder(
@StringRes val titleResId: Int,
) {
ALPHABETIC(R.string.by_name),
POPULARITY(R.string.popular),
MANUAL(R.string.manual),
}

View File

@@ -1,6 +1,7 @@
package org.koitharu.kotatsu.explore.ui
import android.content.DialogInterface
import android.content.Intent
import android.os.Bundle
import android.view.LayoutInflater
import android.view.MenuItem
@@ -46,6 +47,7 @@ import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.search.ui.MangaListActivity
import org.koitharu.kotatsu.settings.SettingsActivity
import org.koitharu.kotatsu.settings.newsources.NewSourcesDialogFragment
import org.koitharu.kotatsu.settings.sources.catalog.SourcesCatalogActivity
import org.koitharu.kotatsu.suggestions.ui.SuggestionsActivity
import javax.inject.Inject
@@ -83,7 +85,7 @@ class ExploreFragment :
SpanSizeResolver(this, resources.getDimensionPixelSize(R.dimen.explore_grid_width)).attach()
addItemDecoration(TypedListSpacingDecoration(context, false))
}
addMenuProvider(ExploreMenuProvider(binding.root.context, viewModel))
addMenuProvider(ExploreMenuProvider(binding.root.context))
viewModel.content.observe(viewLifecycleOwner) {
exploreAdapter?.items = it
}
@@ -109,7 +111,7 @@ class ExploreFragment :
}
override fun onListHeaderClick(item: ListHeader, view: View) {
startActivity(SettingsActivity.newManageSourcesIntent(view.context))
startActivity(Intent(view.context, SourcesCatalogActivity::class.java))
}
override fun onPrimaryButtonClick(tipView: TipView) {
@@ -174,7 +176,6 @@ class ExploreFragment :
} else {
LinearLayoutManager(requireContext())
}
activity?.invalidateOptionsMenu()
}
private fun showSuggestionsTip() {

View File

@@ -6,10 +6,10 @@ import android.view.MenuInflater
import android.view.MenuItem
import androidx.core.view.MenuProvider
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.settings.SettingsActivity
class ExploreMenuProvider(
private val context: Context,
private val viewModel: ExploreViewModel,
) : MenuProvider {
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
@@ -18,17 +18,12 @@ class ExploreMenuProvider(
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
return when (menuItem.itemId) {
R.id.action_grid -> {
viewModel.setGridMode(!menuItem.isChecked)
R.id.action_manage -> {
context.startActivity(SettingsActivity.newSourcesSettingsIntent(context))
true
}
else -> false
}
}
override fun onPrepareMenu(menu: Menu) {
super.onPrepareMenu(menu)
menu.findItem(R.id.action_grid)?.isChecked = viewModel.isGrid.value == true
}
}

View File

@@ -21,6 +21,7 @@ import org.koitharu.kotatsu.core.ui.util.ReversibleAction
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
import org.koitharu.kotatsu.explore.data.SourcesSortOrder
import org.koitharu.kotatsu.explore.domain.ExploreRepository
import org.koitharu.kotatsu.explore.ui.model.ExploreButtons
import org.koitharu.kotatsu.explore.ui.model.MangaSourceItem
@@ -50,11 +51,13 @@ class ExploreViewModel @Inject constructor(
valueProducer = { isSourcesGridMode },
)
val isSuggestionsEnabled = settings.observeAsFlow(
private val isSuggestionsEnabled = settings.observeAsFlow(
key = AppSettings.KEY_SUGGESTIONS,
valueProducer = { isSuggestionsEnabled },
)
val sortOrder = MutableStateFlow(SourcesSortOrder.MANUAL) // TODO
val onOpenManga = MutableEventFlow<Manga>()
val onActionDone = MutableEventFlow<ReversibleAction>()
val onShowSuggestionsTip = MutableEventFlow<Unit>()
@@ -104,10 +107,6 @@ class ExploreViewModel @Inject constructor(
}
}
fun setGridMode(value: Boolean) {
settings.isSourcesGridMode = value
}
fun respondSuggestionTip(isAccepted: Boolean) {
settings.isSuggestionsEnabled = isAccepted
settings.closeTip(TIP_SUGGESTIONS)
@@ -137,7 +136,7 @@ class ExploreViewModel @Inject constructor(
result += RecommendationsItem(recommendation)
}
if (sources.isNotEmpty()) {
result += ListHeader(R.string.remote_sources, R.string.manage)
result += ListHeader(R.string.remote_sources, R.string.catalog)
if (newSources.isNotEmpty()) {
result += TipModel(
key = TIP_NEW_SOURCES,

View File

@@ -7,6 +7,7 @@ import androidx.swiperefreshlayout.widget.CircularProgressDrawable
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.parser.favicon.faviconUri
import org.koitharu.kotatsu.core.ui.image.FaviconDrawable
import org.koitharu.kotatsu.core.ui.image.TrimTransformation
@@ -48,8 +49,8 @@ fun exploreButtonsAD(
icon.setColorSchemeColors(
context.getThemeColor(
materialR.attr.colorPrimary,
Color.DKGRAY
)
Color.DKGRAY,
),
)
binding.buttonRandom.icon = icon
icon.start()
@@ -98,7 +99,7 @@ fun exploreSourceListItemAD(
ItemExploreSourceListBinding.inflate(
layoutInflater,
parent,
false
false,
)
},
on = { item, _, _ -> item is MangaSourceItem && !item.isGrid },
@@ -112,6 +113,7 @@ fun exploreSourceListItemAD(
bind {
binding.textViewTitle.text = item.source.title
binding.textViewSubtitle.text = item.source.getSummary(context)
val fallbackIcon = FaviconDrawable(context, R.style.FaviconDrawable_Small, item.source.name)
binding.imageViewIcon.newImageRequest(lifecycleOwner, item.source.faviconUri())?.run {
fallback(fallbackIcon)
@@ -132,7 +134,7 @@ fun exploreSourceGridItemAD(
ItemExploreSourceGridBinding.inflate(
layoutInflater,
parent,
false
false,
)
},
on = { item, _, _ -> item is MangaSourceItem && item.isGrid },

View File

@@ -106,6 +106,9 @@ abstract class FavouritesDao {
@Query("SELECT DISTINCT category_id FROM favourites WHERE manga_id = :id AND deleted_at = 0")
abstract fun observeIds(id: Long): Flow<List<Long>>
@Query("SELECT DISTINCT category_id FROM favourites WHERE manga_id IN (:mangaIds) AND deleted_at = 0")
abstract suspend fun findCategoriesIds(mangaIds: Collection<Long>): List<Long>
/** INSERT **/
@Insert(onConflict = OnConflictStrategy.REPLACE)
@@ -169,7 +172,9 @@ abstract class FavouritesDao {
ListSortOrder.NEWEST -> "favourites.created_at DESC"
ListSortOrder.ALPHABETIC -> "manga.title ASC"
ListSortOrder.NEW_CHAPTERS -> "(SELECT chapters_new FROM tracks WHERE tracks.manga_id = manga.manga_id) DESC"
ListSortOrder.UPDATED, // for legacy support
ListSortOrder.PROGRESS -> "(SELECT percent FROM history WHERE history.manga_id = manga.manga_id) DESC"
else -> throw IllegalArgumentException("Sort order $sortOrder is not supported")
}
}

View File

@@ -102,6 +102,10 @@ class FavouritesRepository @Inject constructor(
return db.getFavouriteCategoriesDao().find(id.toInt()).toFavouriteCategory()
}
suspend fun getCategoriesIds(mangaIds: Collection<Long>): Set<Long> {
return db.getFavouritesDao().findCategoriesIds(mangaIds).toSet()
}
suspend fun createCategory(
title: String,
sortOrder: ListSortOrder,

View File

@@ -19,17 +19,12 @@ import org.koitharu.kotatsu.core.util.ext.withArgs
import org.koitharu.kotatsu.databinding.SheetFavoriteCategoriesBinding
import org.koitharu.kotatsu.favourites.ui.categories.select.adapter.MangaCategoriesAdapter
import org.koitharu.kotatsu.favourites.ui.categories.select.model.MangaCategoryItem
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.model.Manga
@AndroidEntryPoint
class FavouriteSheet :
BaseAdaptiveSheet<SheetFavoriteCategoriesBinding>(),
OnListItemClickListener<MangaCategoryItem> {
class FavoriteSheet : BaseAdaptiveSheet<SheetFavoriteCategoriesBinding>(), OnListItemClickListener<MangaCategoryItem> {
private val viewModel: MangaCategoriesViewModel by viewModels()
private var adapter: MangaCategoriesAdapter? = null
private val viewModel by viewModels<FavoriteSheetViewModel>()
override fun onCreateViewBinding(
inflater: LayoutInflater,
@@ -41,44 +36,32 @@ class FavouriteSheet :
savedInstanceState: Bundle?,
) {
super.onViewBindingCreated(binding, savedInstanceState)
adapter = MangaCategoriesAdapter(this)
val adapter = MangaCategoriesAdapter(this)
binding.recyclerViewCategories.adapter = adapter
viewModel.content.observe(viewLifecycleOwner, this::onContentChanged)
viewModel.content.observe(viewLifecycleOwner, adapter)
viewModel.onError.observeEvent(viewLifecycleOwner, ::onError)
}
override fun onDestroyView() {
adapter = null
super.onDestroyView()
}
override fun onItemClick(item: MangaCategoryItem, view: View) {
viewModel.setChecked(item.category.id, !item.isChecked)
}
private fun onContentChanged(categories: List<ListModel>) {
adapter?.items = categories
}
private fun onError(e: Throwable) {
Toast.makeText(context ?: return, e.getDisplayMessage(resources), Toast.LENGTH_SHORT).show()
}
companion object {
private const val TAG = "FavouriteCategoriesDialog"
private const val TAG = "FavoriteSheet"
const val KEY_MANGA_LIST = "manga_list"
fun show(fm: FragmentManager, manga: Manga) = Companion.show(fm, listOf(manga))
fun show(fm: FragmentManager, manga: Manga) = show(fm, setOf(manga))
fun show(fm: FragmentManager, manga: Collection<Manga>) =
FavouriteSheet().withArgs(1) {
putParcelableArrayList(
KEY_MANGA_LIST,
manga.mapTo(ArrayList(manga.size)) {
ParcelableManga(it)
},
)
}.showDistinct(fm, TAG)
fun show(fm: FragmentManager, manga: Collection<Manga>) = FavoriteSheet().withArgs(1) {
putParcelableArrayList(
KEY_MANGA_LIST,
manga.mapTo(ArrayList(manga.size), ::ParcelableManga),
)
}.showDistinct(fm, TAG)
}
}

View File

@@ -4,77 +4,70 @@ import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.plus
import org.koitharu.kotatsu.core.model.ids
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.observeAsFlow
import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.util.ext.firstNotNull
import org.koitharu.kotatsu.core.util.ext.require
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteSheet.Companion.KEY_MANGA_LIST
import org.koitharu.kotatsu.favourites.ui.categories.select.model.CategoriesHeaderItem
import org.koitharu.kotatsu.favourites.ui.categories.select.model.MangaCategoryItem
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.util.mapToSet
import javax.inject.Inject
@HiltViewModel
class MangaCategoriesViewModel @Inject constructor(
class FavoriteSheetViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val favouritesRepository: FavouritesRepository,
settings: AppSettings,
) : BaseViewModel() {
private val manga = savedStateHandle.require<List<ParcelableManga>>(KEY_MANGA_LIST).map { it.manga }
private val manga = savedStateHandle.require<List<ParcelableManga>>(FavoriteSheet.KEY_MANGA_LIST).mapToSet {
it.manga
}
private val header = CategoriesHeaderItem()
val content: StateFlow<List<ListModel>> = combine(
private val checkedCategories = MutableStateFlow<Set<Long>?>(null)
val content = combine(
favouritesRepository.observeCategories(),
observeCategoriesIds(),
) { all, checked ->
buildList(all.size + 1) {
checkedCategories.filterNotNull(),
settings.observeAsFlow(AppSettings.KEY_TRACKER_ENABLED) { isTrackerEnabled },
) { categories, checked, tracker ->
buildList(categories.size + 1) {
add(header)
all.mapTo(this) {
categories.mapTo(this) { cat ->
MangaCategoryItem(
category = it,
isChecked = it.id in checked,
isTrackerEnabled = settings.isTrackerEnabled && AppSettings.TRACK_FAVOURITES in settings.trackSources,
category = cat,
isChecked = cat.id in checked,
isTrackerEnabled = tracker,
)
}
}
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList())
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(header))
init {
launchJob(Dispatchers.Default) {
checkedCategories.value = favouritesRepository.getCategoriesIds(manga.ids())
}
}
fun setChecked(categoryId: Long, isChecked: Boolean) {
launchJob(Dispatchers.Default) {
val checkedIds = checkedCategories.firstNotNull()
if (isChecked) {
checkedCategories.value = checkedIds + categoryId
favouritesRepository.addToCategory(categoryId, manga)
} else {
checkedCategories.value = checkedIds - categoryId
favouritesRepository.removeFromCategory(categoryId, manga.ids())
}
}
}
private fun observeCategoriesIds() = if (manga.size == 1) {
// Fast path
favouritesRepository.observeCategoriesIds(manga[0].id)
} else {
combine(
manga.map { favouritesRepository.observeCategoriesIds(it.id) },
) { array ->
val result = HashSet<Long>()
var isFirst = true
for (ids in array) {
if (isFirst) {
result.addAll(ids)
isFirst = false
} else {
result.retainAll(ids.toSet())
}
}
result
}
}
}

View File

@@ -15,6 +15,8 @@ import org.koitharu.kotatsu.core.db.entity.toMangaTag
import org.koitharu.kotatsu.core.db.entity.toMangaTags
import org.koitharu.kotatsu.core.model.MangaHistory
import org.koitharu.kotatsu.core.model.findById
import org.koitharu.kotatsu.core.model.isLocal
import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.util.ReversibleHandle
import org.koitharu.kotatsu.core.util.ext.mapItems
@@ -35,6 +37,7 @@ class HistoryRepository @Inject constructor(
private val trackingRepository: TrackingRepository,
private val settings: AppSettings,
private val scrobblers: Set<@JvmSuppressWildcards Scrobbler>,
private val mangaRepository: MangaDataRepository,
) {
suspend fun getList(offset: Int, limit: Int): List<Manga> {
@@ -91,13 +94,8 @@ class HistoryRepository @Inject constructor(
if (shouldSkip(manga)) {
return
}
val tags = manga.tags.toEntities()
db.withTransaction {
val existing = db.getMangaDao().find(manga.id)?.manga
if (existing == null || existing.source == manga.source.name) {
db.getTagsDao().upsert(tags)
db.getMangaDao().upsert(manga.toEntity(), tags)
}
mangaRepository.storeManga(manga)
db.getHistoryDao().upsert(
HistoryEntity(
mangaId = manga.id,
@@ -185,7 +183,7 @@ class HistoryRepository @Inject constructor(
private suspend fun HistoryEntity.recoverIfNeeded(manga: Manga): HistoryEntity {
val chapters = manga.chapters
if (chapters.isNullOrEmpty() || chapters.findById(chapterId) != null) {
if (manga.isLocal || chapters.isNullOrEmpty() || chapters.findById(chapterId) != null) {
return this
}
val newChapterId = chapters.getOrNull(

View File

@@ -9,6 +9,7 @@ import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.os.NetworkManageIntent
import org.koitharu.kotatsu.core.ui.list.ListSelectionController
import org.koitharu.kotatsu.core.ui.list.RecyclerScrollKeeper
import org.koitharu.kotatsu.core.util.ext.addMenuProvider
import org.koitharu.kotatsu.databinding.FragmentListBinding
import org.koitharu.kotatsu.list.ui.MangaListFragment
@@ -23,6 +24,7 @@ class HistoryListFragment : MangaListFragment() {
override fun onViewBindingCreated(binding: FragmentListBinding, savedInstanceState: Bundle?) {
super.onViewBindingCreated(binding, savedInstanceState)
RecyclerScrollKeeper(binding.recyclerView).attach()
addMenuProvider(HistoryListMenuProvider(binding.root.context, viewModel))
}

View File

@@ -41,7 +41,7 @@ import org.koitharu.kotatsu.core.util.ext.viewLifecycleScope
import org.koitharu.kotatsu.databinding.FragmentListBinding
import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.download.ui.worker.DownloadStartedObserver
import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteSheet
import org.koitharu.kotatsu.favourites.ui.categories.select.FavoriteSheet
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
import org.koitharu.kotatsu.list.ui.adapter.MangaListAdapter
import org.koitharu.kotatsu.list.ui.adapter.MangaListListener
@@ -284,7 +284,7 @@ abstract class MangaListFragment :
}
R.id.action_favourite -> {
FavouriteSheet.show(childFragmentManager, selectedItems)
FavoriteSheet.show(childFragmentManager, selectedItems)
mode.finish()
true
}

View File

@@ -1,12 +1,12 @@
package org.koitharu.kotatsu.list.ui.preview
import android.os.Bundle
import android.text.method.LinkMovementMethod
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.core.graphics.Insets
import androidx.core.text.method.LinkMovementMethodCompat
import androidx.core.view.isVisible
import androidx.fragment.app.viewModels
import coil.ImageLoader
@@ -52,7 +52,7 @@ class PreviewFragment : BaseFragment<FragmentPreviewBinding>(), View.OnClickList
super.onViewBindingCreated(binding, savedInstanceState)
binding.buttonClose.isVisible = activity is MangaListActivity
binding.buttonClose.setOnClickListener(this)
binding.textViewDescription.movementMethod = LinkMovementMethod.getInstance()
binding.textViewDescription.movementMethod = LinkMovementMethodCompat.getInstance()
binding.chipsTags.onChipClickListener = this
binding.textViewAuthor.setOnClickListener(this)
binding.imageViewCover.setOnClickListener(this)

View File

@@ -22,6 +22,7 @@ import androidx.core.view.updatePadding
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentTransaction
import androidx.fragment.app.commit
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.withResumed
import androidx.transition.TransitionManager
@@ -183,7 +184,8 @@ class MainActivity : BaseActivity<ActivityMainBinding>(), AppBarOwner, BottomNav
override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) {
android.R.id.home -> if (isSearchOpened()) {
super.onOptionsItemSelected(item)
closeSearchCallback.handleOnBackPressed()
true
} else {
viewBinding.searchView.requestFocus()
true
@@ -228,6 +230,9 @@ class MainActivity : BaseActivity<ActivityMainBinding>(), AppBarOwner, BottomNav
supportFragmentManager.commit {
setReorderingAllowed(true)
add(R.id.container, SearchSuggestionFragment.newInstance(), TAG_SEARCH)
navigationDelegate.primaryFragment?.let {
setMaxLifecycle(it, Lifecycle.State.STARTED)
}
setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE)
runOnCommit { onSearchOpened() }
}
@@ -413,16 +418,20 @@ class MainActivity : BaseActivity<ActivityMainBinding>(), AppBarOwner, BottomNav
private inner class CloseSearchCallback : OnBackPressedCallback(false) {
override fun handleOnBackPressed() {
val fragment = supportFragmentManager.findFragmentByTag(TAG_SEARCH)
val fm = supportFragmentManager
val fragment = fm.findFragmentByTag(TAG_SEARCH)
viewBinding.searchView.clearFocus()
if (fragment == null) {
// this should not happen but who knows
isEnabled = false
return
}
supportFragmentManager.commit {
fm.commit {
setReorderingAllowed(true)
remove(fragment)
navigationDelegate.primaryFragment?.let {
setMaxLifecycle(it, Lifecycle.State.RESUMED)
}
setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE)
runOnCommit { onSearchClosed() }
}

View File

@@ -40,6 +40,7 @@ import org.koitharu.kotatsu.core.parser.MangaIntent
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.ReaderMode
import org.koitharu.kotatsu.core.ui.BaseFullscreenActivity
import org.koitharu.kotatsu.core.ui.widgets.ZoomControl
import org.koitharu.kotatsu.core.util.GridTouchHelper
import org.koitharu.kotatsu.core.util.IdlingDetector
import org.koitharu.kotatsu.core.util.ShareHelper
@@ -52,6 +53,7 @@ import org.koitharu.kotatsu.core.util.ext.postDelayed
import org.koitharu.kotatsu.core.util.ext.setValueRounded
import org.koitharu.kotatsu.core.util.ext.zipWithPrevious
import org.koitharu.kotatsu.databinding.ActivityReaderBinding
import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.reader.ui.config.ReaderConfigSheet
@@ -72,7 +74,8 @@ class ReaderActivity :
ReaderConfigSheet.Callback,
ReaderControlDelegate.OnInteractionListener,
OnApplyWindowInsetsListener,
IdlingDetector.Callback {
IdlingDetector.Callback,
ZoomControl.ZoomControlListener {
@Inject
lateinit var settings: AppSettings
@@ -110,6 +113,7 @@ class ReaderActivity :
controlDelegate = ReaderControlDelegate(resources, settings, this, this)
viewBinding.toolbarBottom.setOnMenuItemClickListener(::onOptionsItemSelected)
viewBinding.slider.setLabelFormatter(PageLabelFormatter())
viewBinding.zoomControl.listener = this
ReaderSliderListener(this, viewModel).attachToSlider(viewBinding.slider)
insetsDelegate.interceptingWindowInsetsListener = this
idlingDetector.bindToLifecycle(this)
@@ -145,6 +149,14 @@ class ReaderActivity :
.setAnchorView(viewBinding.appbarBottom)
.show()
}
viewModel.isZoomControlsEnabled.observe(this) {
viewBinding.zoomControl.isVisible = it
}
}
override fun getParentActivityIntent(): Intent? {
val manga = viewModel.manga?.toManga() ?: return null
return DetailsActivity.newIntent(this, manga)
}
override fun onUserInteraction() {
@@ -157,6 +169,14 @@ class ReaderActivity :
viewModel.saveCurrentState(readerManager.currentReader?.getCurrentState())
}
override fun onZoomIn() {
readerManager.currentReader?.onZoomIn()
}
override fun onZoomOut() {
readerManager.currentReader?.onZoomOut()
}
private fun onInitReader(mode: ReaderMode?) {
if (mode == null) {
return
@@ -249,6 +269,7 @@ class ReaderActivity :
override fun dispatchTouchEvent(ev: MotionEvent): Boolean {
touchHelper.dispatchTouchEvent(ev)
scrollTimer.onTouchEvent(ev)
return super.dispatchTouchEvent(ev)
}
@@ -286,15 +307,13 @@ class ReaderActivity :
private fun onPageSaved(uri: Uri?) {
if (uri != null) {
Snackbar.make(viewBinding.container, R.string.page_saved, Snackbar.LENGTH_LONG)
.setAnchorView(viewBinding.appbarBottom)
.setAction(R.string.share) {
ShareHelper(this).shareImage(uri)
}.show()
}
} else {
Snackbar.make(viewBinding.container, R.string.error_occurred, Snackbar.LENGTH_SHORT)
.setAnchorView(viewBinding.appbarBottom)
.show()
}
}.setAnchorView(viewBinding.appbarBottom)
.show()
}
private fun setWindowSecure(isSecure: Boolean) {

View File

@@ -27,6 +27,7 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.plus
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository
@@ -118,17 +119,13 @@ class ReaderViewModel @Inject constructor(
valueProducer = { isReaderKeepScreenOn },
)
val isWebtoonZoomEnabled = settings.observeAsStateFlow(
scope = viewModelScope + Dispatchers.Default,
key = AppSettings.KEY_WEBTOON_ZOOM,
valueProducer = { isWebtoonZoomEnable },
)
val isZoomControlEnabled = settings.observeAsStateFlow(
scope = viewModelScope + Dispatchers.Default,
key = AppSettings.KEY_READER_ZOOM_BUTTONS,
valueProducer = { isReaderZoomButtonsEnabled },
)
val isZoomControlsEnabled = getObserveIsZoomControlEnabled().flatMapLatest { zoom ->
if (zoom) {
combine(readerMode, observeIsWebtoonZoomEnabled()) { mode, ze -> ze || mode != ReaderMode.WEBTOON }
} else {
flowOf(false)
}
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, false)
val readerSettings = ReaderSettings(
parentScope = viewModelScope,
@@ -259,10 +256,13 @@ class ReaderViewModel @Inject constructor(
@MainThread
fun onCurrentPageChanged(position: Int) {
val prevJob = stateChangeJob
val pages = content.value.pages // capture immediately
stateChangeJob = launchJob(Dispatchers.Default) {
prevJob?.cancelAndJoin()
loadingJob?.join()
val pages = content.value.pages
if (BuildConfig.DEBUG && pages.size != content.value.pages.size) {
throw IllegalStateException("Concurrent pages modification")
}
pages.getOrNull(position)?.let { page ->
currentState.update { cs ->
cs?.copy(chapterId = page.chapterId, page = page.index)
@@ -402,4 +402,14 @@ class ReaderViewModel @Inject constructor(
val ppc = 1f / chaptersCount
return ppc * chapterIndex + ppc * pagePercent
}
private fun observeIsWebtoonZoomEnabled() = settings.observeAsFlow(
key = AppSettings.KEY_WEBTOON_ZOOM,
valueProducer = { isWebtoonZoomEnable },
)
private fun getObserveIsZoomControlEnabled() = settings.observeAsFlow(
key = AppSettings.KEY_READER_ZOOM_BUTTONS,
valueProducer = { isReaderZoomButtonsEnabled },
)
}

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.reader.ui
import android.view.MotionEvent
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import dagger.assisted.Assisted
@@ -8,11 +9,14 @@ import dagger.assisted.AssistedInject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.yield
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.observeAsFlow
import kotlin.math.roundToLong
@@ -33,6 +37,7 @@ class ScrollTimer @AssistedInject constructor(
private var delayMs: Long = 10L
private var pageSwitchDelay: Long = 100L
private var resumeAt = 0L
private var isTouchDown = MutableStateFlow(false)
var isEnabled: Boolean = false
set(value) {
@@ -55,6 +60,19 @@ class ScrollTimer @AssistedInject constructor(
resumeAt = System.currentTimeMillis() + INTERACTION_SKIP_MS
}
fun onTouchEvent(event: MotionEvent) {
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> {
isTouchDown.value = true
}
MotionEvent.ACTION_UP,
MotionEvent.ACTION_CANCEL -> {
isTouchDown.value = false
}
}
}
private fun onSpeedChanged(speed: Float) {
if (speed <= 0f) {
delayMs = 0L
@@ -108,12 +126,18 @@ class ScrollTimer @AssistedInject constructor(
}
private fun isPaused(): Boolean {
return resumeAt > System.currentTimeMillis()
return isTouchDown.value || resumeAt > System.currentTimeMillis()
}
private suspend fun delayUntilResumed() {
while (isPaused()) {
delay(resumeAt - System.currentTimeMillis())
val delayTime = resumeAt - System.currentTimeMillis()
if (delayTime > 0) {
delay(delayTime)
} else {
yield()
}
isTouchDown.first { !it }
}
}

View File

@@ -47,7 +47,10 @@ class ReaderConfigSheet :
private val viewModel by activityViewModels<ReaderViewModel>()
private val savePageRequest = registerForActivityResult(PageSaveContract(), this)
private var orientationHelper: ScreenOrientationHelper? = null
@Inject
lateinit var orientationHelper: ScreenOrientationHelper
private lateinit var mode: ReaderMode
@Inject
@@ -113,7 +116,7 @@ class ReaderConfigSheet :
}
R.id.button_screen_rotate -> {
orientationHelper?.toggleOrientation()
orientationHelper.isLandscape = !orientationHelper.isLandscape
}
R.id.button_color_filter -> {
@@ -128,9 +131,13 @@ class ReaderConfigSheet :
when (buttonView.id) {
R.id.switch_scroll_timer -> {
findCallback()?.isAutoScrollEnabled = isChecked
requireViewBinding().labelTimer.isVisible = isChecked
requireViewBinding().layoutTimer.isVisible = isChecked
requireViewBinding().sliderTimer.isVisible = isChecked
}
R.id.switch_screen_lock_rotation -> {
orientationHelper.isLocked = isChecked
}
}
}
@@ -159,6 +166,7 @@ class ReaderConfigSheet :
if (fromUser) {
settings.readerAutoscrollSpeed = value
}
(viewBinding ?: return).labelTimerValue.text = getString(R.string.speed_value, value * 10f)
}
override fun onActivityResult(result: Uri?) {
@@ -167,14 +175,23 @@ class ReaderConfigSheet :
}
private fun observeScreenOrientation() {
val helper = ScreenOrientationHelper(requireActivity())
orientationHelper = helper
helper.observeAutoOrientation()
orientationHelper.observeAutoOrientation()
.onEach {
requireViewBinding().buttonScreenRotate.isGone = it
with(requireViewBinding()) {
buttonScreenRotate.isGone = it
switchScreenLockRotation.isVisible = it
updateOrientationLockSwitch()
}
}.launchIn(viewLifecycleScope)
}
private fun updateOrientationLockSwitch() {
val switch = viewBinding?.switchScreenLockRotation ?: return
switch.setOnCheckedChangeListener(null)
switch.isChecked = orientationHelper.isLocked
switch.setOnCheckedChangeListener(this)
}
private fun findCallback(): Callback? {
return (parentFragment as? Callback) ?: (activity as? Callback)
}

View File

@@ -46,9 +46,6 @@ class ReaderSettings(
val isPagesNumbersEnabled: Boolean
get() = settings.isPagesNumbersEnabled
val isZoomControlsEnabled: Boolean
get() = settings.isReaderZoomButtonsEnabled
fun applyBackground(view: View) {
val bg = settings.readerBackground
view.background = bg.resolve(view.context)
@@ -106,8 +103,6 @@ class ReaderSettings(
if (
key == AppSettings.KEY_ZOOM_MODE ||
key == AppSettings.KEY_PAGES_NUMBERS ||
key == AppSettings.KEY_WEBTOON_ZOOM ||
key == AppSettings.KEY_READER_ZOOM_BUTTONS ||
key == AppSettings.KEY_READER_BACKGROUND ||
key == AppSettings.KEY_32BIT_COLOR
) {

View File

@@ -6,6 +6,7 @@ import androidx.fragment.app.activityViewModels
import androidx.viewbinding.ViewBinding
import org.koitharu.kotatsu.core.prefs.ReaderAnimation
import org.koitharu.kotatsu.core.ui.BaseFragment
import org.koitharu.kotatsu.core.ui.widgets.ZoomControl
import org.koitharu.kotatsu.core.util.ext.getParcelableCompat
import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled
import org.koitharu.kotatsu.core.util.ext.observe
@@ -14,7 +15,7 @@ import org.koitharu.kotatsu.reader.ui.ReaderViewModel
private const val KEY_STATE = "state"
abstract class BaseReaderFragment<B : ViewBinding> : BaseFragment<B>() {
abstract class BaseReaderFragment<B : ViewBinding> : BaseFragment<B>(), ZoomControl.ZoomControlListener {
protected val viewModel by activityViewModels<ReaderViewModel>()
private var stateToSave: ReaderState? = null

View File

@@ -18,6 +18,7 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.core.prefs.ReaderAnimation
import org.koitharu.kotatsu.core.util.ext.doOnPageChanged
import org.koitharu.kotatsu.core.util.ext.findCurrentViewHolder
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.recyclerView
import org.koitharu.kotatsu.core.util.ext.resetTransformations
@@ -28,6 +29,7 @@ import org.koitharu.kotatsu.reader.ui.pager.BaseReaderAdapter
import org.koitharu.kotatsu.reader.ui.pager.BaseReaderFragment
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
import org.koitharu.kotatsu.reader.ui.pager.standard.NoAnimPageTransformer
import org.koitharu.kotatsu.reader.ui.pager.standard.PageHolder
import org.koitharu.kotatsu.reader.ui.pager.standard.PagerEventSupplier
import org.koitharu.kotatsu.reader.ui.pager.standard.PagerReaderFragment
import javax.inject.Inject
@@ -104,6 +106,15 @@ class ReversedReaderFragment : BaseReaderFragment<FragmentReaderStandardBinding>
exceptionResolver = exceptionResolver,
)
override fun onZoomIn() {
(viewBinding?.pager?.findCurrentViewHolder() as? PageHolder)?.onZoomIn()
}
override fun onZoomOut() {
(viewBinding?.pager?.findCurrentViewHolder() as? PageHolder)?.onZoomOut()
}
override fun switchPageBy(delta: Int) {
with(requireViewBinding().pager) {
setCurrentItem(currentItem - delta, isAnimationEnabled())

View File

@@ -4,6 +4,7 @@ import android.annotation.SuppressLint
import android.graphics.PointF
import android.net.Uri
import android.view.View
import android.view.animation.DecelerateInterpolator
import androidx.core.view.isVisible
import androidx.lifecycle.LifecycleOwner
import com.davemorrissey.labs.subscaleview.ImageSource
@@ -12,6 +13,7 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.model.ZoomMode
import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.core.ui.widgets.ZoomControl
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
import org.koitharu.kotatsu.core.util.ext.ifZero
import org.koitharu.kotatsu.core.util.ext.isLowRamDevice
@@ -29,7 +31,8 @@ open class PageHolder(
networkState: NetworkState,
exceptionResolver: ExceptionResolver,
) : BasePageHolder<ItemPageBinding>(binding, loader, settings, networkState, exceptionResolver),
View.OnClickListener {
View.OnClickListener,
ZoomControl.ZoomControlListener {
init {
binding.ssiv.bindToLifecycle(owner)
@@ -40,12 +43,10 @@ open class PageHolder(
@Suppress("LeakingThis")
bindingInfo.buttonErrorDetails.setOnClickListener(this)
binding.textViewNumber.isVisible = settings.isPagesNumbersEnabled
binding.zoomControl.listener = SsivZoomListener(binding.ssiv)
}
override fun onConfigChanged() {
super.onConfigChanged()
binding.zoomControl.isVisible = settings.isZoomControlsEnabled
@Suppress("SENSELESS_COMPARISON")
if (settings.applyBitmapConfig(binding.ssiv) && delegate != null) {
delegate.reload()
@@ -141,4 +142,23 @@ open class PageHolder(
bindingInfo.layoutError.isVisible = true
bindingInfo.progressBar.hide()
}
override fun onZoomIn() {
scaleBy(1.2f)
}
override fun onZoomOut() {
scaleBy(0.8f)
}
private fun scaleBy(factor: Float) {
val ssiv = binding.ssiv
val center = ssiv.getCenter() ?: return
val newScale = ssiv.scale * factor
ssiv.animateScaleAndCenter(newScale, center)?.apply {
withDuration(ssiv.resources.getInteger(android.R.integer.config_shortAnimTime).toLong())
withInterpolator(DecelerateInterpolator())
start()
}
}
}

View File

@@ -18,6 +18,7 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.core.prefs.ReaderAnimation
import org.koitharu.kotatsu.core.util.ext.doOnPageChanged
import org.koitharu.kotatsu.core.util.ext.findCurrentViewHolder
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.recyclerView
import org.koitharu.kotatsu.core.util.ext.resetTransformations
@@ -82,6 +83,14 @@ class PagerReaderFragment : BaseReaderFragment<FragmentReaderStandardBinding>(),
super.onDestroyView()
}
override fun onZoomIn() {
(viewBinding?.pager?.findCurrentViewHolder() as? PageHolder)?.onZoomIn()
}
override fun onZoomOut() {
(viewBinding?.pager?.findCurrentViewHolder() as? PageHolder)?.onZoomOut()
}
override fun onGenericMotion(v: View?, event: MotionEvent): Boolean {
if (event.source and InputDevice.SOURCE_CLASS_POINTER != 0) {
if (event.actionMasked == MotionEvent.ACTION_SCROLL) {

View File

@@ -1,28 +0,0 @@
package org.koitharu.kotatsu.reader.ui.pager.standard
import android.view.animation.DecelerateInterpolator
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
import org.koitharu.kotatsu.core.ui.widgets.ZoomControl
class SsivZoomListener(
private val ssiv: SubsamplingScaleImageView,
) : ZoomControl.ZoomControlListener {
override fun onZoomIn() {
scaleBy(1.2f)
}
override fun onZoomOut() {
scaleBy(0.8f)
}
private fun scaleBy(factor: Float) {
val center = ssiv.getCenter() ?: return
val newScale = ssiv.scale * factor
ssiv.animateScaleAndCenter(newScale, center)?.apply {
withDuration(ssiv.resources.getInteger(android.R.integer.config_shortAnimTime).toLong())
withInterpolator(DecelerateInterpolator())
start()
}
}
}

View File

@@ -4,18 +4,15 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.ViewGroup
import android.view.animation.DecelerateInterpolator
import androidx.core.view.isVisible
import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.yield
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.core.util.ext.findCenterViewPosition
import org.koitharu.kotatsu.core.util.ext.firstVisibleItemPosition
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.databinding.FragmentReaderWebtoonBinding
import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.reader.ui.ReaderState
@@ -47,15 +44,6 @@ class WebtoonReaderFragment : BaseReaderFragment<FragmentReaderWebtoonBinding>()
adapter = readerAdapter
addOnPageScrollListener(PageScrollListener())
}
binding.zoomControl.listener = binding.frame
viewModel.isWebtoonZoomEnabled.observe(viewLifecycleOwner) {
binding.frame.isZoomEnable = it
}
combine(viewModel.isWebtoonZoomEnabled, viewModel.isZoomControlEnabled, Boolean::and)
.observe(viewLifecycleOwner) {
binding.zoomControl.isVisible = it
}
}
override fun onDestroyView() {
@@ -111,6 +99,14 @@ class WebtoonReaderFragment : BaseReaderFragment<FragmentReaderWebtoonBinding>()
)
}
override fun onZoomIn() {
viewBinding?.frame?.onZoomIn()
}
override fun onZoomOut() {
viewBinding?.frame?.onZoomOut()
}
private fun notifyPageChanged(page: Int) {
viewModel.onCurrentPageChanged(page)
}

View File

@@ -12,11 +12,11 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.getAndUpdate
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.plus
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.distinctById
@@ -134,15 +134,17 @@ open class RemoteListViewModel @Inject constructor(
sortOrder = filterState.sortOrder,
tags = filterState.tags,
)
mangaList.update { oldList ->
val oldList = mangaList.getAndUpdate { oldList ->
if (!append || oldList.isNullOrEmpty()) {
list
} else {
oldList + list
}
}
if (append) {
hasNextPage.value = list.isNotEmpty()
}.orEmpty()
hasNextPage.value = if (append) {
list.isNotEmpty()
} else {
list.size > oldList.size || hasNextPage.value
}
} catch (e: CancellationException) {
throw e
@@ -152,6 +154,7 @@ open class RemoteListViewModel @Inject constructor(
if (!mangaList.value.isNullOrEmpty()) {
errorEvent.call(e)
}
hasNextPage.value = false
}
}.also { loadingJob = it }
}

View File

@@ -8,7 +8,6 @@ import android.view.MenuItem
import android.view.View
import androidx.activity.viewModels
import androidx.appcompat.view.ActionMode
import androidx.core.content.ContextCompat
import androidx.core.graphics.Insets
import androidx.core.view.updatePadding
import coil.ImageLoader
@@ -26,7 +25,7 @@ import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.databinding.ActivitySearchMultiBinding
import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.download.ui.worker.DownloadStartedObserver
import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteSheet
import org.koitharu.kotatsu.favourites.ui.categories.select.FavoriteSheet
import org.koitharu.kotatsu.list.ui.MangaSelectionDecoration
import org.koitharu.kotatsu.list.ui.adapter.MangaListListener
import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
@@ -159,7 +158,7 @@ class MultiSearchActivity :
}
R.id.action_favourite -> {
FavouriteSheet.show(supportFragmentManager, collectSelectedItems())
FavoriteSheet.show(supportFragmentManager, collectSelectedItems())
mode.finish()
true
}

View File

@@ -5,6 +5,7 @@ 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.parser.favicon.faviconUri
import org.koitharu.kotatsu.core.ui.image.FaviconDrawable
import org.koitharu.kotatsu.core.util.ext.enqueueWith
@@ -40,6 +41,7 @@ fun searchSuggestionSourceAD(
} else {
item.source.title
}
binding.textViewSubtitle.text = item.source.getSummary(context)
binding.switchLocal.isChecked = item.isEnabled
val fallbackIcon = FaviconDrawable(context, R.style.FaviconDrawable_Small, item.source.name)
binding.imageViewCover.newImageRequest(lifecycleOwner, item.source.faviconUri())?.run {

View File

@@ -1,6 +1,5 @@
package org.koitharu.kotatsu.settings
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.net.Uri
@@ -9,7 +8,6 @@ import android.os.Bundle
import android.provider.Settings
import android.view.View
import androidx.appcompat.app.AppCompatDelegate
import androidx.core.app.LocaleManagerCompat
import androidx.preference.ListPreference
import androidx.preference.Preference
import dagger.hilt.android.AndroidEntryPoint
@@ -18,8 +16,8 @@ import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.ListMode
import org.koitharu.kotatsu.core.ui.BasePreferenceFragment
import org.koitharu.kotatsu.core.ui.util.ActivityRecreationHandle
import org.koitharu.kotatsu.core.util.LocaleComparator
import org.koitharu.kotatsu.core.util.ext.getLocalesConfig
import org.koitharu.kotatsu.core.util.ext.map
import org.koitharu.kotatsu.core.util.ext.postDelayed
import org.koitharu.kotatsu.core.util.ext.setDefaultValueCompat
import org.koitharu.kotatsu.core.util.ext.toList
@@ -27,7 +25,6 @@ import org.koitharu.kotatsu.parsers.util.names
import org.koitharu.kotatsu.parsers.util.toTitleCase
import org.koitharu.kotatsu.settings.utils.ActivityListPreference
import org.koitharu.kotatsu.settings.utils.SliderPreference
import java.util.Locale
import javax.inject.Inject
@AndroidEntryPoint
@@ -110,7 +107,7 @@ class AppearanceSettingsFragment :
private fun initLocalePicker(preference: ListPreference) {
val locales = preference.context.getLocalesConfig()
.toList()
.sortedWith(LocaleComparator(preference.context))
.sortedWith(LocaleComparator())
preference.entries = Array(locales.size + 1) { i ->
if (i == 0) {
getString(R.string.automatic)
@@ -134,24 +131,4 @@ class AppearanceSettingsFragment :
getString(it.title)
}
}
private class LocaleComparator(context: Context) : Comparator<Locale> {
private val deviceLocales = LocaleManagerCompat.getSystemLocales(context)
.map { it.language }
override fun compare(a: Locale, b: Locale): Int {
return if (a === b) {
0
} else {
val indexA = deviceLocales.indexOf(a.language)
val indexB = deviceLocales.indexOf(b.language)
if (indexA == -1 && indexB == -1) {
compareValues(a.language, b.language)
} else {
-2 - (indexA - indexB)
}
}
}
}
}

View File

@@ -4,7 +4,6 @@ import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.plus
import org.koitharu.kotatsu.core.ui.BaseViewModel
@@ -18,7 +17,6 @@ class RootSettingsViewModel @Inject constructor(
val totalSourcesCount = sourcesRepository.allMangaSources.size
val enabledSourcesCount = sourcesRepository.observeEnabledSources()
.map { it.size }
val enabledSourcesCount = sourcesRepository.observeEnabledSourcesCount()
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, -1)
}

View File

@@ -31,7 +31,8 @@ import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.settings.about.AboutSettingsFragment
import org.koitharu.kotatsu.settings.about.AppUpdateDialog
import org.koitharu.kotatsu.settings.sources.SourceSettingsFragment
import org.koitharu.kotatsu.settings.sources.SourcesManageFragment
import org.koitharu.kotatsu.settings.sources.SourcesSettingsFragment
import org.koitharu.kotatsu.settings.sources.manage.SourcesManageFragment
import org.koitharu.kotatsu.settings.tracker.TrackerSettingsFragment
import org.koitharu.kotatsu.settings.userdata.UserDataSettingsFragment
@@ -153,6 +154,7 @@ class SettingsActivity :
ACTION_SUGGESTIONS -> SuggestionsSettingsFragment()
ACTION_HISTORY -> UserDataSettingsFragment()
ACTION_TRACKER -> TrackerSettingsFragment()
ACTION_SOURCES -> SourcesSettingsFragment()
ACTION_MANAGE_DOWNLOADS -> DownloadsSettingsFragment()
ACTION_SOURCE -> SourceSettingsFragment.newInstance(
intent.getSerializableExtraCompat(EXTRA_SOURCE) as? MangaSource ?: MangaSource.LOCAL,
@@ -182,6 +184,7 @@ class SettingsActivity :
private const val ACTION_TRACKER = "${BuildConfig.APPLICATION_ID}.action.MANAGE_TRACKER"
private const val ACTION_HISTORY = "${BuildConfig.APPLICATION_ID}.action.MANAGE_HISTORY"
private const val ACTION_SOURCE = "${BuildConfig.APPLICATION_ID}.action.MANAGE_SOURCE_SETTINGS"
private const val ACTION_SOURCES = "${BuildConfig.APPLICATION_ID}.action.MANAGE_SOURCES"
private const val ACTION_MANAGE_SOURCES = "${BuildConfig.APPLICATION_ID}.action.MANAGE_SOURCES_LIST"
private const val ACTION_MANAGE_DOWNLOADS = "${BuildConfig.APPLICATION_ID}.action.MANAGE_DOWNLOADS"
private const val EXTRA_SOURCE = "source"
@@ -206,6 +209,10 @@ class SettingsActivity :
Intent(context, SettingsActivity::class.java)
.setAction(ACTION_HISTORY)
fun newSourcesSettingsIntent(context: Context) =
Intent(context, SettingsActivity::class.java)
.setAction(ACTION_SOURCES)
fun newManageSourcesIntent(context: Context) =
Intent(context, SettingsActivity::class.java)
.setAction(ACTION_MANAGE_SOURCES)

View File

@@ -16,6 +16,7 @@ import org.koitharu.kotatsu.core.ui.AlertDialogFragment
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.tryLaunch
import org.koitharu.kotatsu.databinding.DialogProgressBinding
import java.io.File
import java.io.FileOutputStream
@@ -28,7 +29,7 @@ class BackupDialogFragment : AlertDialogFragment<DialogProgressBinding>() {
private var backup: File? = null
private val saveFileContract = registerForActivityResult(
ActivityResultContracts.CreateDocument("*/*"),
ActivityResultContracts.CreateDocument("application/zip"),
) { uri ->
val file = backup
if (uri != null && file != null) {
@@ -81,7 +82,10 @@ class BackupDialogFragment : AlertDialogFragment<DialogProgressBinding>() {
private fun onBackupDone(file: File) {
this.backup = file
saveFileContract.launch(file.name)
if (!saveFileContract.tryLaunch(file.name)) {
Toast.makeText(requireContext(), R.string.operation_not_supported, Toast.LENGTH_SHORT).show()
dismiss()
}
}
private fun saveBackup(file: File, output: Uri) {
@@ -91,7 +95,7 @@ class BackupDialogFragment : AlertDialogFragment<DialogProgressBinding>() {
it.write(file.readBytes())
}
}
Toast.makeText(requireContext(), R.string.backup_saved, Toast.LENGTH_LONG).show()
Toast.makeText(requireContext(), R.string.backup_saved, Toast.LENGTH_SHORT).show()
dismiss()
} catch (e: InterruptedException) {
throw e

View File

@@ -43,7 +43,6 @@ class BackupViewModel @Inject constructor(
backup.finish()
progress.value = 1f
backup.close()
backup.file
}
onBackupDone.call(file)

View File

@@ -0,0 +1,98 @@
package org.koitharu.kotatsu.settings.backup
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.view.View
import androidx.activity.result.ActivityResultCallback
import androidx.activity.result.contract.ActivityResultContracts
import androidx.documentfile.provider.DocumentFile
import androidx.preference.Preference
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.backup.DIR_BACKUPS
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.BasePreferenceFragment
import org.koitharu.kotatsu.core.util.ext.resolveFile
import org.koitharu.kotatsu.core.util.ext.tryLaunch
import org.koitharu.kotatsu.core.util.ext.viewLifecycleScope
import java.io.File
import java.text.SimpleDateFormat
import javax.inject.Inject
@AndroidEntryPoint
class PeriodicalBackupSettingsFragment : BasePreferenceFragment(R.string.periodic_backups),
ActivityResultCallback<Uri?> {
@Inject
lateinit var scheduler: PeriodicalBackupWorker.Scheduler
private val outputSelectCall = registerForActivityResult(
ActivityResultContracts.OpenDocumentTree(),
this,
)
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
addPreferencesFromResource(R.xml.pref_backup_periodic)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
bindOutputSummary()
bindLastBackupInfo()
}
override fun onPreferenceTreeClick(preference: Preference): Boolean {
return when (preference.key) {
AppSettings.KEY_BACKUP_PERIODICAL_OUTPUT -> outputSelectCall.tryLaunch(null)
else -> super.onPreferenceTreeClick(preference)
}
}
override fun onActivityResult(result: Uri?) {
if (result != null) {
val takeFlags: Int = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
context?.contentResolver?.takePersistableUriPermission(result, takeFlags)
settings.periodicalBackupOutput = result
bindOutputSummary()
}
}
private fun bindOutputSummary() {
val preference = findPreference<Preference>(AppSettings.KEY_BACKUP_PERIODICAL_OUTPUT) ?: return
viewLifecycleScope.launch {
preference.summary = withContext(Dispatchers.Default) {
val value = settings.periodicalBackupOutput
value?.toUserFriendlyString(preference.context) ?: preference.context.run {
getExternalFilesDir(DIR_BACKUPS) ?: File(filesDir, DIR_BACKUPS)
}.path
}
}
}
private fun bindLastBackupInfo() {
val preference = findPreference<Preference>(AppSettings.KEY_BACKUP_PERIODICAL_LAST) ?: return
viewLifecycleScope.launch {
val lastDate = withContext(Dispatchers.Default) {
scheduler.getLastSuccessfulBackup()
}
preference.summary = lastDate?.let {
val format = SimpleDateFormat.getDateTimeInstance(SimpleDateFormat.MEDIUM, SimpleDateFormat.SHORT)
preference.context.getString(R.string.last_successful_backup, format.format(it))
}
preference.isVisible = lastDate != null
}
}
private fun Uri.toUserFriendlyString(context: Context): String {
val df = DocumentFile.fromTreeUri(context, this)
if (df?.canWrite() != true) {
return context.getString(R.string.invalid_value_message)
}
return resolveFile(context)?.path ?: toString()
}
}

View File

@@ -0,0 +1,111 @@
package org.koitharu.kotatsu.settings.backup
import android.content.Context
import android.os.Build
import androidx.documentfile.provider.DocumentFile
import androidx.hilt.work.HiltWorker
import androidx.work.Constraints
import androidx.work.CoroutineWorker
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkInfo
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import androidx.work.await
import androidx.work.workDataOf
import dagger.Reusable
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import org.koitharu.kotatsu.core.backup.BackupRepository
import org.koitharu.kotatsu.core.backup.BackupZipOutput
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.ext.awaitUniqueWorkInfoByName
import org.koitharu.kotatsu.core.util.ext.deleteAwait
import org.koitharu.kotatsu.settings.work.PeriodicWorkScheduler
import java.util.Date
import java.util.concurrent.TimeUnit
import javax.inject.Inject
@HiltWorker
class PeriodicalBackupWorker @AssistedInject constructor(
@Assisted appContext: Context,
@Assisted params: WorkerParameters,
private val repository: BackupRepository,
private val settings: AppSettings,
) : CoroutineWorker(appContext, params) {
override suspend fun doWork(): Result {
val resultData = workDataOf(DATA_TIMESTAMP to Date().time)
val file = BackupZipOutput(applicationContext).use { backup ->
backup.put(repository.createIndex())
backup.put(repository.dumpHistory())
backup.put(repository.dumpCategories())
backup.put(repository.dumpFavourites())
backup.put(repository.dumpBookmarks())
backup.put(repository.dumpSettings())
backup.finish()
backup.file
}
val dirUri = settings.periodicalBackupOutput ?: return Result.success(resultData)
val target = DocumentFile.fromTreeUri(applicationContext, dirUri)
?.createFile("application/zip", file.nameWithoutExtension)
?.uri ?: return Result.failure()
applicationContext.contentResolver.openOutputStream(target, "wt")?.use { output ->
file.inputStream().copyTo(output)
} ?: return Result.failure()
file.deleteAwait()
return Result.success(resultData)
}
@Reusable
class Scheduler @Inject constructor(
private val workManager: WorkManager,
private val settings: AppSettings,
) : PeriodicWorkScheduler {
override suspend fun schedule() {
val constraints = Constraints.Builder()
.setRequiresStorageNotLow(true)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
constraints.setRequiresDeviceIdle(true)
}
val request = PeriodicWorkRequestBuilder<PeriodicalBackupWorker>(
settings.periodicalBackupFrequency,
TimeUnit.DAYS,
).setConstraints(constraints.build())
.keepResultsForAtLeast(20, TimeUnit.DAYS)
.addTag(TAG)
.build()
workManager
.enqueueUniquePeriodicWork(TAG, ExistingPeriodicWorkPolicy.UPDATE, request)
.await()
}
override suspend fun unschedule() {
workManager
.cancelUniqueWork(TAG)
.await()
}
override suspend fun isScheduled(): Boolean {
return workManager
.awaitUniqueWorkInfoByName(TAG)
.any { !it.state.isFinished }
}
suspend fun getLastSuccessfulBackup(): Date? {
return workManager
.awaitUniqueWorkInfoByName(TAG)
.lastOrNull { x -> x.state == WorkInfo.State.SUCCEEDED }
?.outputData
?.getLong(DATA_TIMESTAMP, 0)
?.let { if (it != 0L) Date(it) else null }
}
}
private companion object {
const val TAG = "backups"
const val DATA_TIMESTAMP = "ts"
}
}

View File

@@ -65,8 +65,6 @@ class NewSourcesDialogFragment :
viewModel.onItemEnabledChanged(item, isEnabled)
}
override fun onHeaderClick(header: SourceConfigItem.LocaleGroup) = Unit
override fun onCloseTip(tip: SourceConfigItem.Tip) = Unit
companion object {

View File

@@ -8,7 +8,6 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.plus
import org.koitharu.kotatsu.core.model.getLocaleTitle
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
@@ -35,7 +34,6 @@ class NewSourcesViewModel @Inject constructor(
SourceConfigItem.SourceItem(
source = source,
isEnabled = enabled,
summary = source.getLocaleTitle(),
isDraggable = false,
isAvailable = !skipNsfw || source.contentType != ContentType.HENTAI,
)

View File

@@ -0,0 +1,63 @@
package org.koitharu.kotatsu.settings.sources
import android.content.Intent
import android.os.Bundle
import android.view.View
import androidx.fragment.app.viewModels
import androidx.preference.ListPreference
import androidx.preference.Preference
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.BasePreferenceFragment
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.setDefaultValueCompat
import org.koitharu.kotatsu.explore.data.SourcesSortOrder
import org.koitharu.kotatsu.parsers.util.names
import org.koitharu.kotatsu.settings.sources.catalog.SourcesCatalogActivity
@AndroidEntryPoint
class SourcesSettingsFragment : BasePreferenceFragment(R.string.remote_sources) {
private val viewModel by viewModels<SourcesSettingsViewModel>()
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
addPreferencesFromResource(R.xml.pref_sources)
findPreference<ListPreference>(AppSettings.KEY_SOURCES_ORDER)?.run {
entryValues = SourcesSortOrder.entries.names()
entries = SourcesSortOrder.entries.map { context.getString(it.titleResId) }.toTypedArray()
setDefaultValueCompat(SourcesSortOrder.MANUAL.name)
}
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
findPreference<Preference>(AppSettings.KEY_REMOTE_SOURCES)?.let { pref ->
viewModel.enabledSourcesCount.observe(viewLifecycleOwner) {
pref.summary = if (it >= 0) {
resources.getQuantityString(R.plurals.items, it, it)
} else {
null
}
}
}
findPreference<Preference>(AppSettings.KEY_SOURCES_CATALOG)?.let { pref ->
viewModel.availableSourcesCount.observe(viewLifecycleOwner) {
pref.summary = if (it >= 0) {
getString(R.string.available_d, it)
} else {
null
}
}
}
}
override fun onPreferenceTreeClick(preference: Preference): Boolean = when (preference.key) {
AppSettings.KEY_SOURCES_CATALOG -> {
startActivity(Intent(preference.context, SourcesCatalogActivity::class.java))
true
}
else -> super.onPreferenceTreeClick(preference)
}
}

View File

@@ -0,0 +1,25 @@
package org.koitharu.kotatsu.settings.sources
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.plus
import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
import javax.inject.Inject
@HiltViewModel
class SourcesSettingsViewModel @Inject constructor(
private val sourcesRepository: MangaSourcesRepository,
) : BaseViewModel() {
val totalSourcesCount = sourcesRepository.allMangaSources.size
val enabledSourcesCount = sourcesRepository.observeEnabledSourcesCount()
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, -1)
val availableSourcesCount = sourcesRepository.observeAvailableSourcesCount()
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, -1)
}

View File

@@ -13,8 +13,6 @@ class SourceConfigAdapter(
init {
with(delegatesManager) {
addDelegate(sourceConfigHeaderDelegate())
addDelegate(sourceConfigGroupDelegate(listener))
addDelegate(sourceConfigItemDelegate2(listener, coil, lifecycleOwner))
addDelegate(sourceConfigEmptySearchDelegate())
addDelegate(sourceConfigTipDelegate(listener))

View File

@@ -18,6 +18,7 @@ import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegate
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.getSummary
import org.koitharu.kotatsu.core.parser.favicon.faviconUri
import org.koitharu.kotatsu.core.ui.image.FaviconDrawable
import org.koitharu.kotatsu.core.ui.list.OnTipCloseListener
@@ -26,144 +27,104 @@ import org.koitharu.kotatsu.core.util.ext.enqueueWith
import org.koitharu.kotatsu.core.util.ext.getThemeColor
import org.koitharu.kotatsu.core.util.ext.newImageRequest
import org.koitharu.kotatsu.core.util.ext.source
import org.koitharu.kotatsu.core.util.ext.textAndVisible
import org.koitharu.kotatsu.databinding.ItemExpandableBinding
import org.koitharu.kotatsu.databinding.ItemFilterHeaderBinding
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 sourceConfigHeaderDelegate() =
adapterDelegateViewBinding<SourceConfigItem.Header, SourceConfigItem, ItemFilterHeaderBinding>(
{ layoutInflater, parent ->
ItemFilterHeaderBinding.inflate(
layoutInflater,
parent,
false,
)
},
) {
bind {
binding.textViewTitle.setText(item.titleResId)
}
}
fun sourceConfigGroupDelegate(
listener: SourceConfigListener,
) =
adapterDelegateViewBinding<SourceConfigItem.LocaleGroup, SourceConfigItem, ItemExpandableBinding>(
{ layoutInflater, parent -> ItemExpandableBinding.inflate(layoutInflater, parent, false) },
) {
binding.root.setOnClickListener {
listener.onHeaderClick(item)
}
bind {
binding.root.text = item.title ?: getString(R.string.various_languages)
binding.root.isChecked = item.isExpanded
}
}
fun sourceConfigItemCheckableDelegate(
listener: SourceConfigListener,
coil: ImageLoader,
lifecycleOwner: LifecycleOwner,
) =
adapterDelegateViewBinding<SourceConfigItem.SourceItem, SourceConfigItem, ItemSourceConfigCheckableBinding>(
{ layoutInflater, parent ->
ItemSourceConfigCheckableBinding.inflate(
layoutInflater,
parent,
false,
)
},
) {
) = adapterDelegateViewBinding<SourceConfigItem.SourceItem, SourceConfigItem, ItemSourceConfigCheckableBinding>(
{ layoutInflater, parent ->
ItemSourceConfigCheckableBinding.inflate(
layoutInflater,
parent,
false,
)
},
) {
binding.switchToggle.setOnCheckedChangeListener { _, isChecked ->
listener.onItemEnabledChanged(item, isChecked)
binding.switchToggle.setOnCheckedChangeListener { _, isChecked ->
listener.onItemEnabledChanged(item, isChecked)
}
bind {
binding.textViewTitle.text = if (item.isNsfw) {
buildSpannedString {
append(item.source.title)
append(' ')
appendNsfwLabel(context)
}
} else {
item.source.title
}
bind {
binding.textViewTitle.text = if (item.isNsfw) {
buildSpannedString {
append(item.source.title)
append(' ')
appendNsfwLabel(context)
}
} else {
item.source.title
}
binding.switchToggle.isChecked = item.isEnabled
binding.switchToggle.isEnabled = item.isAvailable
binding.textViewDescription.textAndVisible = item.summary
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)
}
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,
lifecycleOwner: LifecycleOwner,
) =
adapterDelegateViewBinding<SourceConfigItem.SourceItem, SourceConfigItem, ItemSourceConfigBinding>(
{ layoutInflater, parent ->
ItemSourceConfigBinding.inflate(
layoutInflater,
parent,
false,
)
},
) {
) = adapterDelegateViewBinding<SourceConfigItem.SourceItem, SourceConfigItem, ItemSourceConfigBinding>(
{ layoutInflater, parent ->
ItemSourceConfigBinding.inflate(
layoutInflater,
parent,
false,
)
},
) {
val eventListener = View.OnClickListener { v ->
when (v.id) {
R.id.imageView_add -> listener.onItemEnabledChanged(item, true)
R.id.imageView_remove -> listener.onItemEnabledChanged(item, false)
R.id.imageView_menu -> showSourceMenu(v, item, listener)
}
}
binding.imageViewRemove.setOnClickListener(eventListener)
binding.imageViewAdd.setOnClickListener(eventListener)
binding.imageViewMenu.setOnClickListener(eventListener)
bind {
binding.textViewTitle.text = if (item.isNsfw) {
buildSpannedString {
append(item.source.title)
append(' ')
appendNsfwLabel(context)
}
} else {
item.source.title
}
binding.imageViewAdd.isGone = item.isEnabled || !item.isAvailable
binding.imageViewRemove.isVisible = item.isEnabled
binding.imageViewMenu.isVisible = item.isEnabled
binding.textViewDescription.textAndVisible = item.summary
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)
}
val eventListener = View.OnClickListener { v ->
when (v.id) {
R.id.imageView_add -> listener.onItemEnabledChanged(item, true)
R.id.imageView_remove -> listener.onItemEnabledChanged(item, false)
R.id.imageView_menu -> showSourceMenu(v, item, listener)
}
}
binding.imageViewRemove.setOnClickListener(eventListener)
binding.imageViewAdd.setOnClickListener(eventListener)
binding.imageViewMenu.setOnClickListener(eventListener)
bind {
binding.textViewTitle.text = if (item.isNsfw) {
buildSpannedString {
append(item.source.title)
append(' ')
appendNsfwLabel(context)
}
} else {
item.source.title
}
binding.imageViewAdd.isGone = item.isEnabled || !item.isAvailable
binding.imageViewRemove.isVisible = item.isEnabled
binding.imageViewMenu.isVisible = item.isEnabled
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 sourceConfigTipDelegate(
listener: OnTipCloseListener<SourceConfigItem.Tip>,
@@ -208,6 +169,7 @@ 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_lift)?.isVisible = item.isDraggable
menu.setOnMenuItemClickListener {
when (it.itemId) {
R.id.action_settings -> listener.onItemSettingsClick(item)

View File

@@ -12,6 +12,4 @@ interface SourceConfigListener : OnTipCloseListener<SourceConfigItem.Tip> {
fun onItemShortcutClick(item: SourceConfigItem.SourceItem)
fun onItemEnabledChanged(item: SourceConfigItem.SourceItem, isEnabled: Boolean)
fun onHeaderClick(header: SourceConfigItem.LocaleGroup)
}

View File

@@ -0,0 +1,29 @@
package org.koitharu.kotatsu.settings.sources.catalog
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.model.MangaSource
sealed interface SourceCatalogItem : ListModel {
data class Source(
val source: MangaSource
) : SourceCatalogItem {
override fun areItemsTheSame(other: ListModel): Boolean {
return other is Source && other.source == source
}
}
data class Hint(
@DrawableRes val icon: Int,
@StringRes val title: Int,
@StringRes val text: Int,
) : SourceCatalogItem {
override fun areItemsTheSame(other: ListModel): Boolean {
return other is Hint && other.title == title
}
}
}

View File

@@ -0,0 +1,72 @@
package org.koitharu.kotatsu.settings.sources.catalog
import androidx.core.text.buildSpannedString
import androidx.core.view.isVisible
import androidx.lifecycle.LifecycleOwner
import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.isNsfw
import org.koitharu.kotatsu.core.parser.favicon.faviconUri
import org.koitharu.kotatsu.core.ui.image.FaviconDrawable
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.util.ext.crossfade
import org.koitharu.kotatsu.core.util.ext.enqueueWith
import org.koitharu.kotatsu.core.util.ext.newImageRequest
import org.koitharu.kotatsu.core.util.ext.setTextAndVisible
import org.koitharu.kotatsu.core.util.ext.source
import org.koitharu.kotatsu.databinding.ItemEmptyHintBinding
import org.koitharu.kotatsu.databinding.ItemSourceCatalogBinding
import org.koitharu.kotatsu.settings.sources.adapter.appendNsfwLabel
fun sourceCatalogItemSourceAD(
coil: ImageLoader,
lifecycleOwner: LifecycleOwner,
listener: OnListItemClickListener<SourceCatalogItem.Source>
) = adapterDelegateViewBinding<SourceCatalogItem.Source, SourceCatalogItem, ItemSourceCatalogBinding>(
{ layoutInflater, parent ->
ItemSourceCatalogBinding.inflate(layoutInflater, parent, false)
},
) {
binding.imageViewAdd.setOnClickListener { v ->
listener.onItemClick(item, v)
}
bind {
binding.textViewTitle.text = if (item.source.isNsfw()) {
buildSpannedString {
append(item.source.title)
append(' ')
appendNsfwLabel(context)
}
} else {
item.source.title
}
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 sourceCatalogItemHintAD(
coil: ImageLoader,
lifecycleOwner: LifecycleOwner,
) = adapterDelegateViewBinding<SourceCatalogItem.Hint, SourceCatalogItem, ItemEmptyHintBinding>(
{ inflater, parent -> ItemEmptyHintBinding.inflate(inflater, parent, false) },
) {
binding.buttonRetry.isVisible = false
bind {
binding.icon.newImageRequest(lifecycleOwner, item.icon)?.enqueueWith(coil)
binding.textPrimary.setText(item.title)
binding.textSecondary.setTextAndVisible(item.text)
}
}

View File

@@ -0,0 +1,107 @@
package org.koitharu.kotatsu.settings.sources.catalog
import android.os.Bundle
import android.view.View
import androidx.activity.viewModels
import androidx.core.graphics.Insets
import androidx.core.view.updatePadding
import coil.ImageLoader
import com.google.android.material.appbar.AppBarLayout
import com.google.android.material.tabs.TabLayout
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.titleResId
import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver
import org.koitharu.kotatsu.core.util.ext.firstVisibleItemPosition
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.databinding.ActivitySourcesCatalogBinding
import org.koitharu.kotatsu.main.ui.owners.AppBarOwner
import org.koitharu.kotatsu.parsers.model.ContentType
import org.koitharu.kotatsu.parsers.util.toTitleCase
import java.util.Locale
import javax.inject.Inject
@AndroidEntryPoint
class SourcesCatalogActivity : BaseActivity<ActivitySourcesCatalogBinding>(),
TabLayout.OnTabSelectedListener,
OnListItemClickListener<SourceCatalogItem.Source>,
AppBarOwner {
@Inject
lateinit var coil: ImageLoader
override val appBar: AppBarLayout
get() = viewBinding.appbar
private val viewModel by viewModels<SourcesCatalogViewModel>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(ActivitySourcesCatalogBinding.inflate(layoutInflater))
supportActionBar?.setDisplayHomeAsUpEnabled(true)
initTabs()
val sourcesAdapter = SourcesCatalogAdapter(this, coil, this)
with(viewBinding.recyclerView) {
setHasFixedSize(true)
adapter = sourcesAdapter
}
viewModel.content.observe(this, sourcesAdapter)
viewModel.onActionDone.observeEvent(
this,
ReversibleActionObserver(viewBinding.recyclerView),
)
viewModel.locale.observe(this) {
supportActionBar?.subtitle = it.getLocaleDisplayName()
}
addMenuProvider(SourcesCatalogMenuProvider(this, viewModel))
}
override fun onWindowInsetsChanged(insets: Insets) {
viewBinding.root.updatePadding(
left = insets.left,
right = insets.right,
)
viewBinding.recyclerView.updatePadding(
bottom = insets.bottom + viewBinding.recyclerView.paddingTop,
)
}
override fun onItemClick(item: SourceCatalogItem.Source, view: View) {
viewModel.addSource(item.source)
}
override fun onTabSelected(tab: TabLayout.Tab) {
viewModel.setContentType(tab.tag as ContentType)
}
override fun onTabUnselected(tab: TabLayout.Tab) = Unit
override fun onTabReselected(tab: TabLayout.Tab) {
viewBinding.recyclerView.firstVisibleItemPosition = 0
}
private fun initTabs() {
val tabs = viewBinding.tabs
for (type in ContentType.entries) {
if (viewModel.isNsfwDisabled && type == ContentType.HENTAI) {
continue
}
val tab = tabs.newTab()
tab.setText(type.titleResId)
tab.tag = type
tabs.addTab(tab)
}
tabs.addOnTabSelectedListener(this)
}
private fun String?.getLocaleDisplayName(): String {
if (this == null) {
return getString(R.string.various_languages)
}
val lc = Locale(this)
return lc.getDisplayLanguage(lc).toTitleCase(lc)
}
}

View File

@@ -0,0 +1,25 @@
package org.koitharu.kotatsu.settings.sources.catalog
import android.content.Context
import androidx.lifecycle.LifecycleOwner
import coil.ImageLoader
import org.koitharu.kotatsu.core.ui.BaseListAdapter
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.ui.list.fastscroll.FastScroller
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
class SourcesCatalogAdapter(
listener: OnListItemClickListener<SourceCatalogItem.Source>,
coil: ImageLoader,
lifecycleOwner: LifecycleOwner,
) : BaseListAdapter<SourceCatalogItem>(), FastScroller.SectionIndexer {
init {
addDelegate(ListItemType.CHAPTER, sourceCatalogItemSourceAD(coil, lifecycleOwner, listener))
addDelegate(ListItemType.HINT_EMPTY, sourceCatalogItemHintAD(coil, lifecycleOwner))
}
override fun getSectionText(context: Context, position: Int): CharSequence? {
return (items.getOrNull(position) as? SourceCatalogItem.Source)?.source?.title?.take(1)
}
}

View File

@@ -0,0 +1,103 @@
package org.koitharu.kotatsu.settings.sources.catalog
import androidx.room.InvalidationTracker
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.ViewModelLifecycle
import dagger.hilt.android.lifecycle.RetainedLifecycle
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.TABLE_SOURCES
import org.koitharu.kotatsu.core.db.removeObserverAsync
import org.koitharu.kotatsu.core.util.ext.lifecycleScope
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
import org.koitharu.kotatsu.parsers.model.ContentType
class SourcesCatalogListProducer @AssistedInject constructor(
@Assisted private val locale: String?,
@Assisted private val contentType: ContentType,
@Assisted lifecycle: ViewModelLifecycle,
private val repository: MangaSourcesRepository,
private val database: MangaDatabase,
) : InvalidationTracker.Observer(TABLE_SOURCES), RetainedLifecycle.OnClearedListener {
private val scope = lifecycle.lifecycleScope
private var query: String = ""
val list = MutableStateFlow(emptyList<SourceCatalogItem>())
private var job = scope.launch(Dispatchers.Default) {
list.value = buildList()
}
init {
scope.launch(Dispatchers.Default) {
database.invalidationTracker.addObserver(this@SourcesCatalogListProducer)
}
lifecycle.addOnClearedListener(this)
}
override fun onCleared() {
database.invalidationTracker.removeObserverAsync(this)
}
override fun onInvalidated(tables: Set<String>) {
val prevJob = job
job = scope.launch(Dispatchers.Default) {
prevJob.cancelAndJoin()
list.update { buildList() }
}
}
fun setQuery(value: String) {
this.query = value
onInvalidated(emptySet())
}
private suspend fun buildList(): List<SourceCatalogItem> {
val sources = repository.getDisabledSources().toMutableList()
sources.retainAll { it.contentType == contentType && it.locale == locale }
if (query.isNotEmpty()) {
sources.retainAll { it.title.contains(query, ignoreCase = true) }
}
return if (sources.isEmpty()) {
listOf(
if (query.isEmpty()) {
SourceCatalogItem.Hint(
icon = R.drawable.ic_empty_feed,
title = R.string.no_manga_sources,
text = R.string.no_manga_sources_catalog_text,
)
} else {
SourceCatalogItem.Hint(
icon = R.drawable.ic_empty_feed,
title = R.string.nothing_found,
text = R.string.no_manga_sources_found,
)
},
)
} else {
sources.sortBy { it.title }
sources.map {
SourceCatalogItem.Source(
source = it,
)
}
}
}
@AssistedFactory
interface Factory {
fun create(
locale: String?,
contentType: ContentType,
lifecycle: ViewModelLifecycle,
): SourcesCatalogListProducer
}
}

View File

@@ -0,0 +1,74 @@
package org.koitharu.kotatsu.settings.sources.catalog
import android.app.Activity
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import androidx.appcompat.widget.PopupMenu
import androidx.appcompat.widget.SearchView
import androidx.core.view.MenuProvider
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.main.ui.owners.AppBarOwner
import org.koitharu.kotatsu.parsers.util.toTitleCase
class SourcesCatalogMenuProvider(
private val activity: Activity,
private val viewModel: SourcesCatalogViewModel,
) : MenuProvider,
MenuItem.OnActionExpandListener,
SearchView.OnQueryTextListener {
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menuInflater.inflate(R.menu.opt_sources_catalog, menu)
val searchMenuItem = menu.findItem(R.id.action_search)
searchMenuItem.setOnActionExpandListener(this)
val searchView = searchMenuItem.actionView as SearchView
searchView.setOnQueryTextListener(this)
searchView.setIconifiedByDefault(false)
searchView.queryHint = searchMenuItem.title
}
override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) {
R.id.action_locales -> {
showLocalesMenu()
true
}
else -> false
}
override fun onMenuItemActionExpand(item: MenuItem): Boolean {
(activity as? AppBarOwner)?.appBar?.setExpanded(false, true)
return true
}
override fun onMenuItemActionCollapse(item: MenuItem): Boolean {
(item.actionView as SearchView).setQuery("", false)
return true
}
override fun onQueryTextSubmit(query: String?): Boolean = false
override fun onQueryTextChange(newText: String?): Boolean {
viewModel.performSearch(newText.orEmpty())
return true
}
private fun showLocalesMenu() {
val locales = viewModel.locales
val anchor: View = (activity as AppBarOwner).appBar.let {
it.findViewById<View?>(R.id.toolbar) ?: it
}
val menu = PopupMenu(activity, anchor)
for ((i, lc) in locales.withIndex()) {
val title = lc?.getDisplayLanguage(lc)?.toTitleCase(lc) ?: activity.getString(R.string.various_languages)
menu.menu.add(Menu.NONE, Menu.NONE, i, title)
}
menu.setOnMenuItemClickListener {
viewModel.setLocale(locales.getOrNull(it.order)?.language)
true
}
menu.show()
}
}

View File

@@ -0,0 +1,87 @@
package org.koitharu.kotatsu.settings.sources.catalog
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.internal.lifecycle.RetainedLifecycleImpl
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.stateIn
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.ui.util.ReversibleAction
import org.koitharu.kotatsu.core.util.LocaleComparator
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
import org.koitharu.kotatsu.parsers.model.ContentType
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.mapToSet
import java.util.Locale
import javax.inject.Inject
@HiltViewModel
class SourcesCatalogViewModel @Inject constructor(
private val repository: MangaSourcesRepository,
private val listProducerFactory: SourcesCatalogListProducer.Factory,
private val settings: AppSettings,
) : BaseViewModel() {
private val lifecycle = RetainedLifecycleImpl()
private var searchQuery: String = ""
val onActionDone = MutableEventFlow<ReversibleAction>()
val contentType = MutableStateFlow(ContentType.entries.first())
val locales = getLocalesImpl()
val locale = MutableStateFlow(locales.firstOrNull()?.language)
val isNsfwDisabled = settings.isNsfwContentDisabled
private val listProducer: StateFlow<SourcesCatalogListProducer?> = combine(
locale,
contentType,
) { lc, type ->
listProducerFactory.create(lc, type, lifecycle).also {
it.setQuery(searchQuery)
}
}.stateIn(viewModelScope, SharingStarted.Eagerly, null)
val content = listProducer.flatMapLatest {
it?.list ?: emptyFlow()
}.stateIn(viewModelScope, SharingStarted.Lazily, emptyList())
override fun onCleared() {
super.onCleared()
lifecycle.dispatchOnCleared()
}
fun performSearch(query: String) {
searchQuery = query
listProducer.value?.setQuery(query)
}
fun setLocale(value: String?) {
locale.value = value
}
fun setContentType(value: ContentType) {
contentType.value = value
}
fun addSource(source: MangaSource) {
launchJob(Dispatchers.Default) {
val rollback = repository.setSourceEnabled(source, true)
onActionDone.call(ReversibleAction(R.string.source_enabled, rollback))
}
}
private fun getLocalesImpl(): List<Locale?> {
return repository.allMangaSources
.mapToSet { it.locale?.let(::Locale) }
.sortedWith(LocaleComparator())
}
}

View File

@@ -1,6 +1,5 @@
package org.koitharu.kotatsu.settings.sources
package org.koitharu.kotatsu.settings.sources.manage
import androidx.core.os.LocaleListCompat
import androidx.room.InvalidationTracker
import dagger.hilt.android.ViewModelLifecycle
import dagger.hilt.android.scopes.ViewModelScoped
@@ -15,19 +14,13 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.db.TABLE_SOURCES
import org.koitharu.kotatsu.core.model.getLocaleTitle
import org.koitharu.kotatsu.core.model.isNsfw
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.AlphanumComparator
import org.koitharu.kotatsu.core.util.ext.lifecycleScope
import org.koitharu.kotatsu.core.util.ext.map
import org.koitharu.kotatsu.core.util.ext.toEnumSet
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.toTitleCase
import org.koitharu.kotatsu.explore.data.SourcesSortOrder
import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem
import java.util.Locale
import java.util.TreeMap
import javax.inject.Inject
@ViewModelScoped
@@ -39,7 +32,6 @@ class SourcesListProducer @Inject constructor(
private val scope = lifecycle.lifecycleScope
private var query: String = ""
private val expanded = HashSet<String?>()
val list = MutableStateFlow(emptyList<SourceConfigItem>())
private var job = scope.launch(Dispatchers.Default) {
@@ -67,27 +59,19 @@ class SourcesListProducer @Inject constructor(
onInvalidated(emptySet())
}
fun expandCollapse(group: String?) {
if (!expanded.remove(group)) {
expanded.add(group)
}
onInvalidated(emptySet())
}
private suspend fun buildList(): List<SourceConfigItem> {
val allSources = repository.allMangaSources
val enabledSources = repository.getEnabledSources()
val isNsfwDisabled = settings.isNsfwContentDisabled
val withTip = settings.isTipEnabled(TIP_REORDER)
val isReorderAvailable = settings.sourcesSortOrder == SourcesSortOrder.MANUAL
val withTip = isReorderAvailable && settings.isTipEnabled(TIP_REORDER)
val enabledSet = enabledSources.toEnumSet()
if (query.isNotEmpty()) {
return allSources.mapNotNull {
return enabledSources.mapNotNull {
if (!it.title.contains(query, ignoreCase = true)) {
return@mapNotNull null
}
SourceConfigItem.SourceItem(
source = it,
summary = it.getLocaleTitle(),
isEnabled = it in enabledSet,
isDraggable = false,
isAvailable = !isNsfwDisabled || !it.isNsfw(),
@@ -96,17 +80,8 @@ class SourcesListProducer @Inject constructor(
listOf(SourceConfigItem.EmptySearchResult)
}
}
val map = allSources.groupByTo(TreeMap(LocaleKeyComparator())) {
if (it in enabledSet) {
KEY_ENABLED
} else {
it.locale
}
}
map.remove(KEY_ENABLED)
val result = ArrayList<SourceConfigItem>(allSources.size + map.size + 2)
val result = ArrayList<SourceConfigItem>(enabledSources.size + 1)
if (enabledSources.isNotEmpty()) {
result += SourceConfigItem.Header(R.string.enabled_sources)
if (withTip) {
result += SourceConfigItem.Tip(
TIP_REORDER,
@@ -117,70 +92,17 @@ class SourcesListProducer @Inject constructor(
enabledSources.mapTo(result) {
SourceConfigItem.SourceItem(
source = it,
summary = it.getLocaleTitle(),
isEnabled = true,
isDraggable = true,
isDraggable = isReorderAvailable,
isAvailable = false,
)
}
}
if (enabledSources.size != allSources.size) {
result += SourceConfigItem.Header(R.string.available_sources)
val comparator = compareBy<MangaSource, String>(AlphanumComparator()) { it.name }
for ((key, list) in map) {
list.sortWith(comparator)
val isExpanded = key in expanded
result += SourceConfigItem.LocaleGroup(
localeId = key,
title = getLocaleTitle(key),
isExpanded = isExpanded,
)
if (isExpanded) {
list.mapTo(result) {
SourceConfigItem.SourceItem(
source = it,
summary = null,
isEnabled = false,
isDraggable = false,
isAvailable = !isNsfwDisabled || !it.isNsfw(),
)
}
}
}
}
return result
}
private class LocaleKeyComparator : Comparator<String?> {
private val deviceLocales = LocaleListCompat.getAdjustedDefault()
.map { it.language }
override fun compare(a: String?, b: String?): Int {
when {
a == b -> return 0
a == null -> return 1
b == null -> return -1
}
val ai = deviceLocales.indexOf(a!!)
val bi = deviceLocales.indexOf(b!!)
return when {
ai < 0 && bi < 0 -> a.compareTo(b)
ai < 0 -> 1
bi < 0 -> -1
else -> ai.compareTo(bi)
}
}
}
companion object {
private fun getLocaleTitle(localeKey: String?): String? {
val locale = Locale(localeKey ?: return null)
return locale.getDisplayLanguage(locale).toTitleCase(locale)
}
private const val KEY_ENABLED = "!"
const val TIP_REORDER = "src_reorder"
}
}

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.settings.sources
package org.koitharu.kotatsu.settings.sources.manage
import android.content.Intent
import android.os.Bundle
import android.view.LayoutInflater
import android.view.Menu
@@ -30,8 +31,10 @@ import org.koitharu.kotatsu.core.util.ext.viewLifecycleScope
import org.koitharu.kotatsu.databinding.FragmentSettingsSourcesBinding
import org.koitharu.kotatsu.main.ui.owners.AppBarOwner
import org.koitharu.kotatsu.settings.SettingsActivity
import org.koitharu.kotatsu.settings.sources.SourceSettingsFragment
import org.koitharu.kotatsu.settings.sources.adapter.SourceConfigAdapter
import org.koitharu.kotatsu.settings.sources.adapter.SourceConfigListener
import org.koitharu.kotatsu.settings.sources.catalog.SourcesCatalogActivity
import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem
import javax.inject.Inject
@@ -77,14 +80,14 @@ class SourcesManageFragment :
viewModel.content.observe(viewLifecycleOwner, sourcesAdapter)
viewModel.onActionDone.observeEvent(
viewLifecycleOwner,
ReversibleActionObserver(binding.recyclerView)
ReversibleActionObserver(binding.recyclerView),
)
addMenuProvider(SourcesMenuProvider())
}
override fun onResume() {
super.onResume()
activity?.setTitle(R.string.remote_sources)
activity?.setTitle(R.string.manage_sources)
}
override fun onDestroyView() {
@@ -119,10 +122,6 @@ class SourcesManageFragment :
viewModel.setEnabled(item.source, isEnabled)
}
override fun onHeaderClick(header: SourceConfigItem.LocaleGroup) {
viewModel.expandOrCollapse(header.localeId)
}
override fun onCloseTip(tip: SourceConfigItem.Tip) {
viewModel.onTipClosed(tip)
}
@@ -143,6 +142,11 @@ class SourcesManageFragment :
}
override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) {
R.id.action_catalog -> {
startActivity(Intent(context, SourcesCatalogActivity::class.java))
true
}
R.id.action_disable_all -> {
viewModel.disableAll()
true

View File

@@ -1,4 +1,4 @@
package org.koitharu.kotatsu.settings.sources
package org.koitharu.kotatsu.settings.sources.manage
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
@@ -103,10 +103,6 @@ class SourcesManageViewModel @Inject constructor(
}
}
fun expandOrCollapse(headerId: String?) {
listProducer.expandCollapse(headerId)
}
fun performSearch(query: String?) {
listProducer.setQuery(query?.trim().orEmpty())
}

View File

@@ -2,45 +2,15 @@ package org.koitharu.kotatsu.settings.sources.model
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.model.ContentType
import org.koitharu.kotatsu.parsers.model.MangaSource
sealed interface SourceConfigItem : ListModel {
data class Header(
@StringRes val titleResId: Int,
) : SourceConfigItem {
override fun areItemsTheSame(other: ListModel): Boolean {
return other is Header && other.titleResId == titleResId
}
}
data class LocaleGroup(
val localeId: String?,
val title: String?,
val isExpanded: Boolean,
) : SourceConfigItem {
override fun areItemsTheSame(other: ListModel): Boolean {
return other is LocaleGroup && other.localeId == localeId
}
override fun getChangePayload(previousState: ListModel): Any? {
return if (previousState is LocaleGroup && previousState.isExpanded != isExpanded) {
ListModelDiffCallback.PAYLOAD_CHECKED_CHANGED
} else {
super.getChangePayload(previousState)
}
}
}
data class SourceItem(
val source: MangaSource,
val isEnabled: Boolean,
val summary: String?,
val isDraggable: Boolean,
val isAvailable: Boolean,
) : SourceConfigItem {
@@ -51,14 +21,6 @@ sealed interface SourceConfigItem : ListModel {
override fun areItemsTheSame(other: ListModel): Boolean {
return other is SourceItem && other.source == source
}
override fun getChangePayload(previousState: ListModel): Any? {
return if (previousState is SourceItem && previousState.isEnabled != isEnabled) {
ListModelDiffCallback.PAYLOAD_CHECKED_CHANGED
} else {
super.getChangePayload(previousState)
}
}
}
data class Tip(

View File

@@ -65,6 +65,7 @@ class UserDataSettingsFragment : BasePreferenceFragment(R.string.data_and_privac
findPreference<Preference>(AppSettings.KEY_PAGES_CACHE_CLEAR)?.bindBytesSizeSummary(checkNotNull(viewModel.cacheSizes[CacheDir.PAGES]))
findPreference<Preference>(AppSettings.KEY_THUMBS_CACHE_CLEAR)?.bindBytesSizeSummary(checkNotNull(viewModel.cacheSizes[CacheDir.THUMBS]))
findPreference<Preference>(AppSettings.KEY_HTTP_CACHE_CLEAR)?.bindBytesSizeSummary(viewModel.httpCacheSize)
bindPeriodicalBackupSummary()
findPreference<Preference>(AppSettings.KEY_SEARCH_HISTORY_CLEAR)?.let { pref ->
viewModel.searchHistoryCount.observe(viewLifecycleOwner) {
pref.summary = if (it < 0) {
@@ -200,6 +201,20 @@ class UserDataSettingsFragment : BasePreferenceFragment(R.string.data_and_privac
}
}
private fun bindPeriodicalBackupSummary() {
val preference = findPreference<Preference>(AppSettings.KEY_BACKUP_PERIODICAL_ENABLED) ?: return
val entries = resources.getStringArray(R.array.backup_frequency)
val entryValues = resources.getStringArray(R.array.values_backup_frequency)
viewModel.periodicalBackupFrequency.observe(viewLifecycleOwner) { freq ->
preference.summary = if (freq == 0L) {
getString(R.string.disabled)
} else {
val index = entryValues.indexOf(freq.toString())
entries.getOrNull(index)
}
}
}
private fun clearSearchHistory() {
MaterialAlertDialogBuilder(context ?: return)
.setTitle(R.string.clear_search_history)

View File

@@ -5,12 +5,15 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.runInterruptible
import okhttp3.Cache
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.observeAsFlow
import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.ui.util.ReversibleAction
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
@@ -29,6 +32,7 @@ class UserDataSettingsViewModel @Inject constructor(
private val searchRepository: MangaSearchRepository,
private val trackingRepository: TrackingRepository,
private val cookieJar: MutableCookieJar,
private val settings: AppSettings,
) : BaseViewModel() {
val onActionDone = MutableEventFlow<ReversibleAction>()
@@ -40,6 +44,20 @@ class UserDataSettingsViewModel @Inject constructor(
val cacheSizes = EnumMap<CacheDir, MutableStateFlow<Long>>(CacheDir::class.java)
val storageUsage = MutableStateFlow<StorageUsage?>(null)
val periodicalBackupFrequency = settings.observeAsFlow(
key = AppSettings.KEY_BACKUP_PERIODICAL_ENABLED,
valueProducer = { isPeriodicalBackupEnabled },
).flatMapLatest { isEnabled ->
if (isEnabled) {
settings.observeAsFlow(
key = AppSettings.KEY_BACKUP_PERIODICAL_FREQUENCY,
valueProducer = { periodicalBackupFrequency },
)
} else {
flowOf(0)
}
}
private var storageUsageJob: Job? = null
init {

View File

@@ -1,9 +1,9 @@
package org.koitharu.kotatsu.settings.utils
import android.content.Context
import android.text.method.LinkMovementMethod
import android.util.AttributeSet
import android.widget.TextView
import androidx.core.text.method.LinkMovementMethodCompat
import androidx.preference.Preference
import androidx.preference.PreferenceViewHolder
@@ -13,11 +13,9 @@ class LinksPreference @JvmOverloads constructor(
defStyleAttr: Int = androidx.preference.R.attr.preferenceStyle,
defStyleRes: Int = 0,
) : Preference(context, attrs, defStyleAttr, defStyleRes) {
override fun onBindViewHolder(holder: PreferenceViewHolder) {
super.onBindViewHolder(holder)
val summaryView = holder.findViewById(android.R.id.summary) as TextView
summaryView.movementMethod = LinkMovementMethod.getInstance()
summaryView.movementMethod = LinkMovementMethodCompat.getInstance()
}
}
}

View File

@@ -5,6 +5,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
import org.koitharu.kotatsu.settings.backup.PeriodicalBackupWorker
import org.koitharu.kotatsu.suggestions.ui.SuggestionsWorker
import org.koitharu.kotatsu.tracker.work.TrackWorker
import javax.inject.Inject
@@ -13,6 +14,7 @@ class WorkScheduleManager @Inject constructor(
private val settings: AppSettings,
private val suggestionScheduler: SuggestionsWorker.Scheduler,
private val trackerScheduler: TrackWorker.Scheduler,
private val periodicalBackupScheduler: PeriodicalBackupWorker.Scheduler,
) : SharedPreferences.OnSharedPreferenceChangeListener {
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) {
@@ -30,6 +32,13 @@ class WorkScheduleManager @Inject constructor(
isEnabled = settings.isSuggestionsEnabled,
force = key != AppSettings.KEY_SUGGESTIONS,
)
AppSettings.KEY_BACKUP_PERIODICAL_ENABLED,
AppSettings.KEY_BACKUP_PERIODICAL_FREQUENCY -> updateWorker(
scheduler = periodicalBackupScheduler,
isEnabled = settings.isPeriodicalBackupEnabled,
force = key != AppSettings.KEY_BACKUP_PERIODICAL_ENABLED,
)
}
}
@@ -38,6 +47,7 @@ class WorkScheduleManager @Inject constructor(
processLifecycleScope.launch(Dispatchers.Default) {
updateWorkerImpl(trackerScheduler, settings.isTrackerEnabled, false)
updateWorkerImpl(suggestionScheduler, settings.isSuggestionsEnabled, false)
updateWorkerImpl(periodicalBackupScheduler, settings.isPeriodicalBackupEnabled, false)
}
}

View File

@@ -130,9 +130,6 @@ class SyncHelper @AssistedInject constructor(
private fun upsertHistory(json: JSONArray, timestamp: Long): Array<ContentProviderResult> {
val uri = uri(authorityHistory, TABLE_HISTORY)
val operations = ArrayList<ContentProviderOperation>()
operations += ContentProviderOperation.newDelete(uri)
.withSelection("updated_at < ?", arrayOf(timestamp.toString()))
.build()
json.mapJSONTo(operations) { jo ->
operations.addAll(upsertManga(jo.removeJSONObject("manga"), authorityHistory))
ContentProviderOperation.newInsert(uri)
@@ -145,9 +142,6 @@ class SyncHelper @AssistedInject constructor(
private fun upsertFavouriteCategories(json: JSONArray, timestamp: Long): Array<ContentProviderResult> {
val uri = uri(authorityFavourites, TABLE_FAVOURITE_CATEGORIES)
val operations = ArrayList<ContentProviderOperation>()
operations += ContentProviderOperation.newDelete(uri)
.withSelection("created_at < ?", arrayOf(timestamp.toString()))
.build()
json.mapJSONTo(operations) { jo ->
ContentProviderOperation.newInsert(uri)
.withValues(jo.toContentValues())
@@ -159,9 +153,6 @@ class SyncHelper @AssistedInject constructor(
private fun upsertFavourites(json: JSONArray, timestamp: Long): Array<ContentProviderResult> {
val uri = uri(authorityFavourites, TABLE_FAVOURITES)
val operations = ArrayList<ContentProviderOperation>()
operations += ContentProviderOperation.newDelete(uri)
.withSelection("created_at < ?", arrayOf(timestamp.toString()))
.build()
json.mapJSONTo(operations) { jo ->
operations.addAll(upsertManga(jo.removeJSONObject("manga"), authorityFavourites))
ContentProviderOperation.newInsert(uri)

View File

@@ -0,0 +1,11 @@
<vector
xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#000000"
android:pathData="M16.8,2.5C16.8,1.56 17.56,0.8 18.5,0.8C19.44,0.8 20.2,1.56 20.2,2.5V3H16.8V2.5M16,9H21A1,1 0 0,0 22,8V4A1,1 0 0,0 21,3V2.5A2.5,2.5 0 0,0 18.5,0A2.5,2.5 0 0,0 16,2.5V3A1,1 0 0,0 15,4V8A1,1 0 0,0 16,9M8.47,20.5C5.2,18.94 2.86,15.76 2.5,12H1C1.5,18.16 6.66,23 12.95,23L13.61,22.97L9.8,19.15L8.47,20.5M23.25,12.77L20.68,10.2L19.27,11.61L21.5,13.83L15.83,19.5L4.5,8.17L10.17,2.5L12.27,4.61L13.68,3.2L11.23,0.75C10.64,0.16 9.69,0.16 9.11,0.75L2.75,7.11C2.16,7.7 2.16,8.65 2.75,9.23L14.77,21.25C15.36,21.84 16.31,21.84 16.89,21.25L23.25,14.89C23.84,14.3 23.84,13.35 23.25,12.77Z" />
</vector>

View File

@@ -22,6 +22,17 @@
android:visibility="gone"
tools:visibility="visible" />
<org.koitharu.kotatsu.core.ui.widgets.ZoomControl
android:id="@+id/zoomControl"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:layout_margin="16dp"
android:orientation="vertical"
android:visibility="gone"
app:layout_dodgeInsetEdges="bottom"
tools:visibility="visible" />
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/appbar_top"
android:layout_width="match_parent"

View File

@@ -12,6 +12,17 @@
android:layout_height="match_parent"
tools:background="@color/grey" />
<org.koitharu.kotatsu.core.ui.widgets.ZoomControl
android:id="@+id/zoomControl"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:layout_margin="16dp"
android:orientation="vertical"
android:visibility="gone"
app:layout_dodgeInsetEdges="bottom"
tools:visibility="visible" />
<org.koitharu.kotatsu.reader.ui.ReaderInfoBarView
android:id="@+id/infoBar"
android:layout_width="match_parent"
@@ -62,9 +73,9 @@
android:layout_height="wrap_content"
android:stepSize="1"
android:valueFrom="0"
app:trackColorInactive="?attr/m3ColorBackground"
app:labelBehavior="floating"
app:tickVisible="false" />
app:tickVisible="false"
app:trackColorInactive="?attr/m3ColorBackground" />
</com.google.android.material.appbar.MaterialToolbar>

View File

@@ -0,0 +1,47 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".settings.sources.catalog.SourcesCatalogActivity">
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/appbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:fitsSystemWindows="true">
<com.google.android.material.appbar.MaterialToolbar
android:id="@id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize" />
<com.google.android.material.tabs.TabLayout
android:id="@+id/tabs"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:tabGravity="start"
app:tabMode="scrollable" />
</com.google.android.material.appbar.AppBarLayout>
<FrameLayout
android:id="@+id/layout_list"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_behavior="com.google.android.material.appbar.AppBarLayout$ScrollingViewBehavior">
<org.koitharu.kotatsu.core.ui.list.fastscroll.FastScrollRecyclerView
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:orientation="vertical"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
tools:listitem="@layout/item_source_config" />
</FrameLayout>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View File

@@ -2,7 +2,6 @@
<org.koitharu.kotatsu.reader.ui.pager.webtoon.WebtoonScalingFrame
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/frame"
android:layout_width="match_parent"
android:layout_height="match_parent"
@@ -17,14 +16,4 @@
android:orientation="vertical"
app:layoutManager="org.koitharu.kotatsu.reader.ui.pager.webtoon.WebtoonLayoutManager" />
<org.koitharu.kotatsu.core.ui.widgets.ZoomControl
android:id="@+id/zoomControl"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:layout_margin="16dp"
android:orientation="vertical"
android:visibility="gone"
tools:visibility="visible" />
</org.koitharu.kotatsu.reader.ui.pager.webtoon.WebtoonScalingFrame>

View File

@@ -104,31 +104,6 @@
app:layout_constraintTop_toBottomOf="@id/textView_status"
tools:text="@tools:sample/lorem[3]" />
<com.google.android.material.card.MaterialCardView
android:id="@+id/card_details"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginHorizontal="12dp"
android:layout_marginTop="12dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHeight_default="wrap"
app:layout_constraintHeight_max="280dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/progressBar"
app:shapeAppearance="?shapeAppearanceCornerMedium">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView_chapters"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:scrollbars="vertical"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
tools:itemCount="200"
tools:listitem="@layout/item_chapter_download" />
</com.google.android.material.card.MaterialCardView>
<Button
android:id="@+id/button_pause"
style="?materialButtonOutlinedStyle"
@@ -139,7 +114,7 @@
android:text="@string/pause"
android:visibility="gone"
app:layout_constraintEnd_toStartOf="@id/button_resume"
app:layout_constraintTop_toBottomOf="@id/card_details"
app:layout_constraintTop_toBottomOf="@id/progressBar"
tools:visibility="visible" />
<Button
@@ -152,7 +127,7 @@
android:text="@string/resume"
android:visibility="gone"
app:layout_constraintEnd_toStartOf="@id/button_cancel"
app:layout_constraintTop_toBottomOf="@id/card_details" />
app:layout_constraintTop_toBottomOf="@id/progressBar" />
<Button
android:id="@+id/button_cancel"
@@ -164,7 +139,7 @@
android:text="@android:string/cancel"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/card_details"
app:layout_constraintTop_toBottomOf="@id/progressBar"
tools:visibility="visible" />
</androidx.constraintlayout.widget.ConstraintLayout>

Some files were not shown because too many files have changed in this diff Show More