Compare commits

..

68 Commits
v6.4 ... v6.5

Author SHA1 Message Date
4e0e5be726 Update parsers
(cherry picked from commit 451a155e08)
2023-12-16 08:13:24 +02:00
Weblate (bot)
f18eca52af Translations update from Hosted Weblate (#594)
* Translated using Weblate (Thai)

Currently translated at 71.1% (387 of 544 strings)

Co-authored-by: Nayuki <me@nayuki.cyou>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/th/
Translation: Kotatsu/Strings

* Translated using Weblate (Spanish)

Currently translated at 100.0% (546 of 546 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings

* Translated using Weblate (Turkish)

Currently translated at 100.0% (546 of 546 strings)

Co-authored-by: Oğuz Ersen <oguz@ersen.moe>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings

* Translated using Weblate (Russian)

Currently translated at 99.8% (545 of 546 strings)

Co-authored-by: Hotarun <ihotarun@proton.me>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translation: Kotatsu/Strings

---------

Co-authored-by: Nayuki <me@nayuki.cyou>
Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Co-authored-by: Oğuz Ersen <oguz@ersen.moe>
Co-authored-by: Hotarun <ihotarun@proton.me>
(cherry picked from commit 0612a7ad2c)
2023-12-16 08:13:17 +02:00
808fd13ad0 Fix saved chapters mapping 2023-12-14 15:16:15 +02:00
88c8dc4761 Fix crashes 2023-12-14 14:50:56 +02:00
a7eba67a97 Webtoon reader fixes 2023-12-14 14:36:03 +02:00
c27586231a Select which data will be restored from backup 2023-12-13 14:37:05 +02:00
db3db4637c Support manga sources in backups 2023-12-13 12:16:06 +02:00
bb2294f248 UI improvements 2023-12-13 11:33:55 +02:00
afd56c02e6 Update parsers 2023-12-12 19:13:29 +02:00
dcf1ffc976 Translated using Weblate (Russian)
Currently translated at 99.8% (543 of 544 strings)

Co-authored-by: Koitharu <nvasya95@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translation: Kotatsu/Strings
2023-12-12 19:13:17 +02:00
Nayuki
91b7028b1a Translated using Weblate (Thai)
Currently translated at 70.4% (382 of 542 strings)

Co-authored-by: Nayuki <me@nayuki.cyou>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/th/
Translation: Kotatsu/Strings
2023-12-12 19:13:17 +02:00
Anon
734c217c03 Translated using Weblate (Serbian)
Currently translated at 100.0% (542 of 542 strings)

Co-authored-by: Anon <anonymousprivate76@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/sr/
Translation: Kotatsu/Strings
2023-12-12 19:13:17 +02:00
SENPAi-03
de18c6eb71 Translated using Weblate (Arabic)
Currently translated at 34.3% (186 of 542 strings)

Co-authored-by: SENPAi-03 <naemamro1@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ar/
Translation: Kotatsu/Strings
2023-12-12 19:13:17 +02:00
Oğuz Ersen
034be6b44e Translated using Weblate (Turkish)
Currently translated at 100.0% (544 of 544 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (542 of 542 strings)

Co-authored-by: Oğuz Ersen <oguz@ersen.moe>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2023-12-12 19:13:17 +02:00
gallegonovato
995ff5a764 Translated using Weblate (Spanish)
Currently translated at 100.0% (542 of 542 strings)

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

Translated using Weblate (Russian)

Currently translated at 100.0% (542 of 542 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (542 of 542 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-12-12 19:13:17 +02:00
d05d807614 Slightly improve welcome screen 2023-12-12 20:05:59 +03:00
bffd75f4d9 Fix crashes 2023-12-12 18:55:22 +02:00
bdaf3da7e0 New welcome screen 2023-12-12 15:58:45 +02:00
353d856bf5 Fix chips style 2023-12-12 14:18:49 +02:00
fca9ba98cd Update parsers 2023-12-11 20:02:09 +03:00
5df76fd881 Fix crashes 2023-12-11 18:46:35 +03:00
54c646ceb0 Update parsers 2023-12-09 13:54:58 +02:00
3599f2f1b8 Improve onboarding 2023-12-09 13:46:39 +02:00
b2e53d4938 Fix some warnings and remove unused code 2023-12-09 13:31:43 +02:00
0d62408918 Update acra configuration 2023-12-09 11:25:14 +02:00
2ae046d4c5 Update acra configuration 2023-12-09 09:04:13 +02:00
66356dc094 Add doze disable preference for downloads 2023-12-08 12:18:18 +02:00
ae16110a80 Fix LocaleComparator 2023-12-08 08:47:56 +02:00
c3aff60a9c Spinner and EditText enhancements 2023-12-08 00:39:13 +03:00
gallegonovato
cfdca3434b Translated using Weblate (Spanish)
Currently translated at 100.0% (541 of 541 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (540 of 540 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (538 of 538 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (532 of 532 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2023-12-07 18:16:16 +02:00
Anon
b2c2693aba Translated using Weblate (Serbian)
Currently translated at 96.4% (519 of 538 strings)

Translated using Weblate (Serbian)

Currently translated at 97.5% (518 of 531 strings)

Translated using Weblate (Serbian)

Currently translated at 100.0% (7 of 7 strings)

Co-authored-by: Anon <anonymousprivate76@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/sr/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/sr/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2023-12-07 18:16:16 +02:00
Hasanur Rahman Biplob
5901c26ae0 Translated using Weblate (Bengali)
Currently translated at 30.6% (163 of 531 strings)

Co-authored-by: Hasanur Rahman Biplob <hrbiplob100@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/bn/
Translation: Kotatsu/Strings
2023-12-07 18:16:16 +02:00
ECBaris
d864c73faf Translated using Weblate (Turkish)
Currently translated at 100.0% (7 of 7 strings)

Co-authored-by: ECBaris <barisaklan@outlook.com.tr>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/tr/
Translation: Kotatsu/plurals
2023-12-07 18:16:16 +02:00
Feroli
30551a56b2 Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (531 of 531 strings)

Co-authored-by: Feroli <feroli@tuta.io>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt_BR/
Translation: Kotatsu/Strings
2023-12-07 18:16:16 +02:00
La prière
23cb023a85 Translated using Weblate (Japanese)
Currently translated at 100.0% (7 of 7 strings)

Translated using Weblate (Japanese)

Currently translated at 90.7% (482 of 531 strings)

Co-authored-by: La prière <lapriere@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/ja/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ja/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2023-12-07 18:16:16 +02:00
Макар Разин
10291d5b29 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (541 of 541 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (541 of 541 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (541 of 541 strings)

Translated using Weblate (Polish)

Currently translated at 90.3% (480 of 531 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (531 of 531 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/pl/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/uk/
Translation: Kotatsu/Strings
2023-12-07 18:16:16 +02:00
InfinityDouki56
e05e09f846 Translated using Weblate (Filipino)
Currently translated at 88.1% (477 of 541 strings)

Translated using Weblate (Filipino)

Currently translated at 88.5% (470 of 531 strings)

Co-authored-by: InfinityDouki56 <ced.paltep10@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fil/
Translation: Kotatsu/Strings
2023-12-07 18:16:16 +02:00
0ce7f7cf6b Missing local manga cleanup #587 2023-12-07 18:11:30 +02:00
4d9d15004c Reader fixes 2023-12-07 16:38:33 +02:00
1908ce3e46 New filter implementation 2023-12-06 17:31:57 +02:00
6c07abec56 New filter sheet draft implementation 2023-12-05 17:14:24 +02:00
64dc646fc5 Language filter support 2023-12-05 15:15:51 +02:00
357669d8b2 Fix list items reorder 2023-12-05 09:59:07 +02:00
21639ddcbc Fix directories manage screen 2023-12-04 16:40:57 +02:00
5183d5e882 Grayscale color filter 2023-12-04 16:21:02 +02:00
3008b7b89a Fix downloader skip action 2023-12-04 14:05:22 +02:00
53e00e4689 Global color filter initial implementation #562 2023-12-02 15:45:49 +02:00
963d7d8d42 Fix concurrent manga downloading 2023-12-02 15:43:47 +02:00
1a7b1e7bdc Ability to skip error in downloader 2023-12-02 15:03:15 +02:00
b1fa9d1d22 Fix mapping local chapters #577 2023-12-02 15:02:48 +02:00
91179ef901 Show chapters in downloads list 2023-12-02 14:18:39 +02:00
a7a9ee9d59 Downloader improvements 2023-12-02 09:11:33 +02:00
ff05f3f79d Upgrade WorkManager 2023-11-30 12:53:44 +02:00
c0062c83c8 Upgrade room 2023-11-30 11:52:17 +02:00
ef0cf4766a Fix webtoon mode 2023-11-29 18:07:53 +02:00
910069ec99 Update parsers 2023-11-29 17:25:13 +02:00
d56107bf1f Remove funding info 2023-11-29 09:53:23 +02:00
03426694c8 Bump version 2023-11-29 09:26:45 +02:00
385003bcc8 Fix filter chips #572 2023-11-28 16:11:30 +02:00
225aacff43 Temporary disable downsampling in webtoon mode 2023-11-28 15:57:18 +02:00
Макар Разин
208c0a494b Translated using Weblate (Ukrainian)
Currently translated at 100.0% (531 of 531 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (531 of 531 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (531 of 531 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-28 15:56:16 +02:00
Bai
0045c7cf44 Translated using Weblate (Turkish)
Currently translated at 100.0% (531 of 531 strings)

Co-authored-by: Bai <batuhanakkurt000@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2023-11-28 15:56:16 +02:00
J. Lavoie
eed7f89518 Translated using Weblate (French)
Currently translated at 99.8% (530 of 531 strings)

Co-authored-by: J. Lavoie <j.lavoie@net-c.ca>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fr/
Translation: Kotatsu/Strings
2023-11-28 15:56:16 +02:00
gallegonovato
80c8b9eac0 Translated using Weblate (Spanish)
Currently translated at 100.0% (531 of 531 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2023-11-28 15:56:16 +02:00
ECBaris
53a680d13c Translated using Weblate (Turkish)
Currently translated at 98.6% (520 of 527 strings)

Co-authored-by: ECBaris <barisaklan@outlook.com.tr>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2023-11-28 15:56:16 +02:00
3e77df20a2 Improve manga memory cache usage 2023-11-26 09:07:56 +02:00
7c1c0a38fa Update parsers lib 2023-11-25 19:53:07 +03:00
204 changed files with 4233 additions and 2378 deletions

2
.github/FUNDING.yml vendored
View File

@@ -1,2 +0,0 @@
ko_fi: xtimms
custom: ["https://yoomoney.ru/to/410012543938752"]

1
.gitignore vendored
View File

@@ -15,6 +15,7 @@
/.idea/kotlinc.xml /.idea/kotlinc.xml
/.idea/deploymentTargetDropDown.xml /.idea/deploymentTargetDropDown.xml
/.idea/androidTestResultsUserPreferences.xml /.idea/androidTestResultsUserPreferences.xml
/.idea/deploymentTargetSelector.xml
/.idea/render.experimental.xml /.idea/render.experimental.xml
/.idea/inspectionProfiles/ /.idea/inspectionProfiles/
.DS_Store .DS_Store

View File

@@ -16,13 +16,13 @@ android {
applicationId 'org.koitharu.kotatsu' applicationId 'org.koitharu.kotatsu'
minSdk = 21 minSdk = 21
targetSdk = 34 targetSdk = 34
versionCode = 600 versionCode = 606
versionName = '6.4' versionName = '6.5'
generatedDensities = [] generatedDensities = []
testInstrumentationRunner "org.koitharu.kotatsu.HiltTestRunner" testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner'
ksp { ksp {
// arg("room.generateKotlin", "true") TODO: enable later arg('room.generateKotlin', 'true')
arg("room.schemaLocation", "$projectDir/schemas") arg('room.schemaLocation', "$projectDir/schemas")
} }
androidResources { androidResources {
generateLocaleConfig true generateLocaleConfig true
@@ -82,12 +82,12 @@ afterEvaluate {
} }
dependencies { dependencies {
//noinspection GradleDependency //noinspection GradleDependency
implementation('com.github.KotatsuApp:kotatsu-parsers:46e863ef79') { implementation('com.github.KotatsuApp:kotatsu-parsers:42ecf8a958') {
exclude group: 'org.json', module: 'json' exclude group: 'org.json', module: 'json'
} }
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.4' coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.4'
implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.9.20' implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.9.21'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3' implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3'
implementation 'androidx.appcompat:appcompat:1.6.1' implementation 'androidx.appcompat:appcompat:1.6.1'
@@ -95,7 +95,6 @@ dependencies {
implementation 'androidx.activity:activity-ktx:1.8.1' implementation 'androidx.activity:activity-ktx:1.8.1'
implementation 'androidx.fragment:fragment-ktx:1.6.2' implementation 'androidx.fragment:fragment-ktx:1.6.2'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.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' implementation 'androidx.lifecycle:lifecycle-service:2.6.2'
implementation 'androidx.lifecycle:lifecycle-process:2.6.2' implementation 'androidx.lifecycle:lifecycle-process:2.6.2'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4' implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
@@ -107,8 +106,7 @@ dependencies {
implementation 'com.google.android.material:material:1.10.0' implementation 'com.google.android.material:material:1.10.0'
implementation 'androidx.lifecycle:lifecycle-common-java8:2.6.2' implementation 'androidx.lifecycle:lifecycle-common-java8:2.6.2'
// TODO https://issuetracker.google.com/issues/254846063 implementation 'androidx.work:work-runtime:2.9.0'
implementation 'androidx.work:work-runtime-ktx:2.8.1'
//noinspection GradleDependency //noinspection GradleDependency
implementation('com.google.guava:guava:32.0.1-android') { implementation('com.google.guava:guava:32.0.1-android') {
exclude group: 'com.google.guava', module: 'failureaccess' exclude group: 'com.google.guava', module: 'failureaccess'
@@ -116,9 +114,9 @@ dependencies {
exclude group: 'com.google.j2objc', module: 'j2objc-annotations' exclude group: 'com.google.j2objc', module: 'j2objc-annotations'
} }
implementation 'androidx.room:room-runtime:2.6.0' implementation 'androidx.room:room-runtime:2.6.1'
implementation 'androidx.room:room-ktx:2.6.0' implementation 'androidx.room:room-ktx:2.6.1'
ksp 'androidx.room:room-compiler:2.6.0' ksp 'androidx.room:room-compiler:2.6.1'
implementation 'com.squareup.okhttp3:okhttp:4.12.0' implementation 'com.squareup.okhttp3:okhttp:4.12.0'
implementation 'com.squareup.okhttp3:okhttp-dnsoverhttps:4.12.0' implementation 'com.squareup.okhttp3:okhttp-dnsoverhttps:4.12.0'
@@ -127,14 +125,14 @@ dependencies {
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.2' implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.2'
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:4.3.2' implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:4.3.2'
implementation 'com.google.dagger:hilt-android:2.48.1' implementation 'com.google.dagger:hilt-android:2.49'
kapt 'com.google.dagger:hilt-compiler:2.48.1' kapt 'com.google.dagger:hilt-compiler:2.49'
implementation 'androidx.hilt:hilt-work:1.1.0' implementation 'androidx.hilt:hilt-work:1.1.0'
kapt 'androidx.hilt:hilt-compiler:1.1.0' kapt 'androidx.hilt:hilt-compiler:1.1.0'
implementation 'io.coil-kt:coil-base:2.5.0' implementation 'io.coil-kt:coil-base:2.5.0'
implementation 'io.coil-kt:coil-svg:2.5.0' implementation 'io.coil-kt:coil-svg:2.5.0'
implementation 'com.github.KotatsuApp:subsampling-scale-image-view:c7dab3aefe' implementation 'com.github.KotatsuApp:subsampling-scale-image-view:02e6d6cfe9'
implementation 'com.github.solkin:disk-lru-cache:1.4' implementation 'com.github.solkin:disk-lru-cache:1.4'
implementation 'io.noties.markwon:core:4.6.2' implementation 'io.noties.markwon:core:4.6.2'
@@ -154,9 +152,9 @@ dependencies {
androidTestImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3' androidTestImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3'
androidTestImplementation 'androidx.room:room-testing:2.6.0' androidTestImplementation 'androidx.room:room-testing:2.6.1'
androidTestImplementation 'com.squareup.moshi:moshi-kotlin:1.15.0' androidTestImplementation 'com.squareup.moshi:moshi-kotlin:1.15.0'
androidTestImplementation 'com.google.dagger:hilt-android-testing:2.48.1' androidTestImplementation 'com.google.dagger:hilt-android-testing:2.49'
kaptAndroidTest 'com.google.dagger:hilt-android-compiler:2.48.1' kaptAndroidTest 'com.google.dagger:hilt-android-compiler:2.49'
} }

View File

@@ -11,6 +11,7 @@ import androidx.work.WorkManager
import dagger.hilt.android.HiltAndroidApp import dagger.hilt.android.HiltAndroidApp
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.acra.ACRA import org.acra.ACRA
import org.acra.ReportField import org.acra.ReportField
import org.acra.config.dialog import org.acra.config.dialog
@@ -39,7 +40,7 @@ open class BaseApp : Application(), Configuration.Provider {
lateinit var activityLifecycleCallbacks: Set<@JvmSuppressWildcards ActivityLifecycleCallbacks> lateinit var activityLifecycleCallbacks: Set<@JvmSuppressWildcards ActivityLifecycleCallbacks>
@Inject @Inject
lateinit var database: MangaDatabase lateinit var database: Provider<MangaDatabase>
@Inject @Inject
lateinit var settings: AppSettings lateinit var settings: AppSettings
@@ -51,21 +52,31 @@ open class BaseApp : Application(), Configuration.Provider {
lateinit var appValidator: AppValidator lateinit var appValidator: AppValidator
@Inject @Inject
lateinit var workScheduleManager: WorkScheduleManager lateinit var workScheduleManager: Provider<WorkScheduleManager>
@Inject @Inject
lateinit var workManagerProvider: Provider<WorkManager> lateinit var workManagerProvider: Provider<WorkManager>
override val workManagerConfiguration: Configuration
get() = Configuration.Builder()
.setWorkerFactory(workerFactory)
.build()
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
ACRA.errorReporter.putCustomData("isOriginalApp", appValidator.isOriginalApp.toString())
AppCompatDelegate.setDefaultNightMode(settings.theme) AppCompatDelegate.setDefaultNightMode(settings.theme)
AppCompatDelegate.setApplicationLocales(settings.appLocales) AppCompatDelegate.setApplicationLocales(settings.appLocales)
setupActivityLifecycleCallbacks() setupActivityLifecycleCallbacks()
processLifecycleScope.launch {
val isOriginalApp = withContext(Dispatchers.Default) {
appValidator.isOriginalApp
}
ACRA.errorReporter.putCustomData("isOriginalApp", isOriginalApp.toString())
}
processLifecycleScope.launch(Dispatchers.Default) { processLifecycleScope.launch(Dispatchers.Default) {
setupDatabaseObservers() setupDatabaseObservers()
} }
workScheduleManager.init() workScheduleManager.get().init()
WorkServiceStopHelper(workManagerProvider).setup() WorkServiceStopHelper(workManagerProvider).setup()
} }
@@ -74,13 +85,6 @@ open class BaseApp : Application(), Configuration.Provider {
initAcra { initAcra {
buildConfigClass = BuildConfig::class.java buildConfigClass = BuildConfig::class.java
reportFormat = StringFormat.JSON reportFormat = StringFormat.JSON
excludeMatchingSharedPreferencesKeys = listOf(
"sources_\\w+",
AppSettings.KEY_APP_PASSWORD,
AppSettings.KEY_PROXY_LOGIN,
AppSettings.KEY_PROXY_ADDRESS,
AppSettings.KEY_PROXY_PASSWORD,
)
httpSender { httpSender {
uri = getString(R.string.url_error_report) uri = getString(R.string.url_error_report)
basicAuthLogin = getString(R.string.acra_login) basicAuthLogin = getString(R.string.acra_login)
@@ -97,7 +101,6 @@ open class BaseApp : Application(), Configuration.Provider {
ReportField.STACK_TRACE, ReportField.STACK_TRACE,
ReportField.CRASH_CONFIGURATION, ReportField.CRASH_CONFIGURATION,
ReportField.CUSTOM_DATA, ReportField.CUSTOM_DATA,
ReportField.SHARED_PREFERENCES,
) )
dialog { dialog {
@@ -110,15 +113,9 @@ open class BaseApp : Application(), Configuration.Provider {
} }
} }
override fun getWorkManagerConfiguration(): Configuration {
return Configuration.Builder()
.setWorkerFactory(workerFactory)
.build()
}
@WorkerThread @WorkerThread
private fun setupDatabaseObservers() { private fun setupDatabaseObservers() {
val tracker = database.invalidationTracker val tracker = database.get().invalidationTracker
databaseObservers.forEach { databaseObservers.forEach {
tracker.addObserver(it) tracker.addObserver(it)
} }

View File

@@ -3,17 +3,20 @@ package org.koitharu.kotatsu.core.backup
import org.json.JSONArray import org.json.JSONArray
class BackupEntry( class BackupEntry(
val name: String, val name: Name,
val data: JSONArray val data: JSONArray
) { ) {
companion object Names { enum class Name(
val key: String,
) {
const val INDEX = "index" INDEX("index"),
const val HISTORY = "history" HISTORY("history"),
const val CATEGORIES = "categories" CATEGORIES("categories"),
const val FAVOURITES = "favourites" FAVOURITES("favourites"),
const val SETTINGS = "settings" SETTINGS("settings"),
const val BOOKMARKS = "bookmarks" BOOKMARKS("bookmarks"),
SOURCES("sources"),
} }
} }

View File

@@ -7,8 +7,10 @@ import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.parsers.util.json.JSONIterator import org.koitharu.kotatsu.parsers.util.json.JSONIterator
import org.koitharu.kotatsu.parsers.util.json.getLongOrDefault
import org.koitharu.kotatsu.parsers.util.json.mapJSON import org.koitharu.kotatsu.parsers.util.json.mapJSON
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import java.util.Date
import javax.inject.Inject import javax.inject.Inject
private const val PAGE_SIZE = 10 private const val PAGE_SIZE = 10
@@ -20,7 +22,7 @@ class BackupRepository @Inject constructor(
suspend fun dumpHistory(): BackupEntry { suspend fun dumpHistory(): BackupEntry {
var offset = 0 var offset = 0
val entry = BackupEntry(BackupEntry.HISTORY, JSONArray()) val entry = BackupEntry(BackupEntry.Name.HISTORY, JSONArray())
while (true) { while (true) {
val history = db.getHistoryDao().findAll(offset, PAGE_SIZE) val history = db.getHistoryDao().findAll(offset, PAGE_SIZE)
if (history.isEmpty()) { if (history.isEmpty()) {
@@ -41,7 +43,7 @@ class BackupRepository @Inject constructor(
} }
suspend fun dumpCategories(): BackupEntry { suspend fun dumpCategories(): BackupEntry {
val entry = BackupEntry(BackupEntry.CATEGORIES, JSONArray()) val entry = BackupEntry(BackupEntry.Name.CATEGORIES, JSONArray())
val categories = db.getFavouriteCategoriesDao().findAll() val categories = db.getFavouriteCategoriesDao().findAll()
for (item in categories) { for (item in categories) {
entry.data.put(JsonSerializer(item).toJson()) entry.data.put(JsonSerializer(item).toJson())
@@ -51,7 +53,7 @@ class BackupRepository @Inject constructor(
suspend fun dumpFavourites(): BackupEntry { suspend fun dumpFavourites(): BackupEntry {
var offset = 0 var offset = 0
val entry = BackupEntry(BackupEntry.FAVOURITES, JSONArray()) val entry = BackupEntry(BackupEntry.Name.FAVOURITES, JSONArray())
while (true) { while (true) {
val favourites = db.getFavouritesDao().findAll(offset, PAGE_SIZE) val favourites = db.getFavouritesDao().findAll(offset, PAGE_SIZE)
if (favourites.isEmpty()) { if (favourites.isEmpty()) {
@@ -72,7 +74,7 @@ class BackupRepository @Inject constructor(
} }
suspend fun dumpBookmarks(): BackupEntry { suspend fun dumpBookmarks(): BackupEntry {
val entry = BackupEntry(BackupEntry.BOOKMARKS, JSONArray()) val entry = BackupEntry(BackupEntry.Name.BOOKMARKS, JSONArray())
val all = db.getBookmarksDao().findAll() val all = db.getBookmarksDao().findAll()
for ((m, b) in all) { for ((m, b) in all) {
val json = JSONObject() val json = JSONObject()
@@ -90,7 +92,7 @@ class BackupRepository @Inject constructor(
} }
fun dumpSettings(): BackupEntry { fun dumpSettings(): BackupEntry {
val entry = BackupEntry(BackupEntry.SETTINGS, JSONArray()) val entry = BackupEntry(BackupEntry.Name.SETTINGS, JSONArray())
val settingsDump = settings.getAllValues().toMutableMap() val settingsDump = settings.getAllValues().toMutableMap()
settingsDump.remove(AppSettings.KEY_APP_PASSWORD) settingsDump.remove(AppSettings.KEY_APP_PASSWORD)
settingsDump.remove(AppSettings.KEY_PROXY_PASSWORD) settingsDump.remove(AppSettings.KEY_PROXY_PASSWORD)
@@ -101,8 +103,18 @@ class BackupRepository @Inject constructor(
return entry return entry
} }
suspend fun dumpSources(): BackupEntry {
val entry = BackupEntry(BackupEntry.Name.SOURCES, JSONArray())
val all = db.getSourcesDao().findAll()
for (source in all) {
val json = JsonSerializer(source).toJson()
entry.data.put(json)
}
return entry
}
fun createIndex(): BackupEntry { fun createIndex(): BackupEntry {
val entry = BackupEntry(BackupEntry.INDEX, JSONArray()) val entry = BackupEntry(BackupEntry.Name.INDEX, JSONArray())
val json = JSONObject() val json = JSONObject()
json.put("app_id", BuildConfig.APPLICATION_ID) json.put("app_id", BuildConfig.APPLICATION_ID)
json.put("app_version", BuildConfig.VERSION_CODE) json.put("app_version", BuildConfig.VERSION_CODE)
@@ -111,6 +123,11 @@ class BackupRepository @Inject constructor(
return entry return entry
} }
fun getBackupDate(entry: BackupEntry?): Date? {
val timestamp = entry?.data?.optJSONObject(0)?.getLongOrDefault("created_at", 0) ?: 0
return if (timestamp == 0L) null else Date(timestamp)
}
suspend fun restoreHistory(entry: BackupEntry): CompositeResult { suspend fun restoreHistory(entry: BackupEntry): CompositeResult {
val result = CompositeResult() val result = CompositeResult()
for (item in entry.data.JSONIterator()) { for (item in entry.data.JSONIterator()) {
@@ -184,6 +201,17 @@ class BackupRepository @Inject constructor(
return result return result
} }
suspend fun restoreSources(entry: BackupEntry): CompositeResult {
val result = CompositeResult()
for (item in entry.data.JSONIterator()) {
val source = JsonDeserializer(item).toMangaSourceEntity()
result += runCatchingCancellable {
db.getSourcesDao().upsert(source)
}
}
return result
}
fun restoreSettings(entry: BackupEntry): CompositeResult { fun restoreSettings(entry: BackupEntry): CompositeResult {
val result = CompositeResult() val result = CompositeResult()
for (item in entry.data.JSONIterator()) { for (item in entry.data.JSONIterator()) {

View File

@@ -1,25 +1,44 @@
package org.koitharu.kotatsu.core.backup package org.koitharu.kotatsu.core.backup
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.runInterruptible
import okio.Closeable import okio.Closeable
import org.json.JSONArray import org.json.JSONArray
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
import java.io.File import java.io.File
import java.util.EnumSet
import java.util.zip.ZipFile import java.util.zip.ZipFile
class BackupZipInput(val file: File) : Closeable { class BackupZipInput(val file: File) : Closeable {
private val zipFile = ZipFile(file) private val zipFile = ZipFile(file)
suspend fun getEntry(name: String): BackupEntry? = runInterruptible(Dispatchers.IO) { suspend fun getEntry(name: BackupEntry.Name): BackupEntry? = runInterruptible(Dispatchers.IO) {
val entry = zipFile.getEntry(name) ?: return@runInterruptible null val entry = zipFile.getEntry(name.key) ?: return@runInterruptible null
val json = zipFile.getInputStream(entry).use { val json = zipFile.getInputStream(entry).use {
JSONArray(it.bufferedReader().readText()) JSONArray(it.bufferedReader().readText())
} }
BackupEntry(name, json) BackupEntry(name, json)
} }
suspend fun entries(): Set<BackupEntry.Name> = runInterruptible(Dispatchers.IO) {
zipFile.entries().toList().mapNotNullTo(EnumSet.noneOf(BackupEntry.Name::class.java)) { ze ->
BackupEntry.Name.entries.find { it.key == ze.name }
}
}
override fun close() { override fun close() {
zipFile.close() zipFile.close()
} }
fun cleanupAsync() {
processLifecycleScope.launch(Dispatchers.IO, CoroutineStart.ATOMIC) {
runCatching {
close()
file.delete()
}
}
}
} }

View File

@@ -17,7 +17,7 @@ class BackupZipOutput(val file: File) : Closeable {
private val output = ZipOutput(file, Deflater.BEST_COMPRESSION) private val output = ZipOutput(file, Deflater.BEST_COMPRESSION)
suspend fun put(entry: BackupEntry) = runInterruptible(Dispatchers.IO) { suspend fun put(entry: BackupEntry) = runInterruptible(Dispatchers.IO) {
output.put(entry.name, entry.data.toString(2)) output.put(entry.name.key, entry.data.toString(2))
} }
suspend fun finish() = runInterruptible(Dispatchers.IO) { suspend fun finish() = runInterruptible(Dispatchers.IO) {

View File

@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.core.backup
import org.json.JSONObject import org.json.JSONObject
import org.koitharu.kotatsu.bookmarks.data.BookmarkEntity import org.koitharu.kotatsu.bookmarks.data.BookmarkEntity
import org.koitharu.kotatsu.core.db.entity.MangaEntity import org.koitharu.kotatsu.core.db.entity.MangaEntity
import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity
import org.koitharu.kotatsu.core.db.entity.TagEntity import org.koitharu.kotatsu.core.db.entity.TagEntity
import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity
import org.koitharu.kotatsu.favourites.data.FavouriteEntity import org.koitharu.kotatsu.favourites.data.FavouriteEntity
@@ -78,6 +79,12 @@ class JsonDeserializer(private val json: JSONObject) {
percent = json.getDouble("percent").toFloat(), percent = json.getDouble("percent").toFloat(),
) )
fun toMangaSourceEntity() = MangaSourceEntity(
source = json.getString("source"),
isEnabled = json.getBoolean("enabled"),
sortKey = json.getInt("sort_key"),
)
fun toMap(): Map<String, Any?> { fun toMap(): Map<String, Any?> {
val map = mutableMapOf<String, Any?>() val map = mutableMapOf<String, Any?>()
val keys = json.keys() val keys = json.keys()

View File

@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.core.backup
import org.json.JSONObject import org.json.JSONObject
import org.koitharu.kotatsu.bookmarks.data.BookmarkEntity import org.koitharu.kotatsu.bookmarks.data.BookmarkEntity
import org.koitharu.kotatsu.core.db.entity.MangaEntity import org.koitharu.kotatsu.core.db.entity.MangaEntity
import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity
import org.koitharu.kotatsu.core.db.entity.TagEntity import org.koitharu.kotatsu.core.db.entity.TagEntity
import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity
import org.koitharu.kotatsu.favourites.data.FavouriteEntity import org.koitharu.kotatsu.favourites.data.FavouriteEntity
@@ -82,6 +83,14 @@ class JsonSerializer private constructor(private val json: JSONObject) {
}, },
) )
constructor(e: MangaSourceEntity) : this(
JSONObject().apply {
put("source", e.source)
put("enabled", e.isEnabled)
put("sort_key", e.sortKey)
},
)
constructor(m: Map<String, *>) : this( constructor(m: Map<String, *>) : this(
JSONObject(m), JSONObject(m),
) )

View File

@@ -12,7 +12,7 @@ class ExpiringLruCache<T>(
private val cache = LruCache<ContentCache.Key, ExpiringValue<T>>(maxSize) private val cache = LruCache<ContentCache.Key, ExpiringValue<T>>(maxSize)
operator fun get(key: ContentCache.Key): T? { operator fun get(key: ContentCache.Key): T? {
val value = cache.get(key) ?: return null val value = cache[key] ?: return null
if (value.isExpired) { if (value.isExpired) {
cache.remove(key) cache.remove(key)
} }

View File

@@ -29,6 +29,7 @@ import org.koitharu.kotatsu.core.db.migrations.Migration13To14
import org.koitharu.kotatsu.core.db.migrations.Migration14To15 import org.koitharu.kotatsu.core.db.migrations.Migration14To15
import org.koitharu.kotatsu.core.db.migrations.Migration15To16 import org.koitharu.kotatsu.core.db.migrations.Migration15To16
import org.koitharu.kotatsu.core.db.migrations.Migration16To17 import org.koitharu.kotatsu.core.db.migrations.Migration16To17
import org.koitharu.kotatsu.core.db.migrations.Migration17To18
import org.koitharu.kotatsu.core.db.migrations.Migration1To2 import org.koitharu.kotatsu.core.db.migrations.Migration1To2
import org.koitharu.kotatsu.core.db.migrations.Migration2To3 import org.koitharu.kotatsu.core.db.migrations.Migration2To3
import org.koitharu.kotatsu.core.db.migrations.Migration3To4 import org.koitharu.kotatsu.core.db.migrations.Migration3To4
@@ -53,7 +54,7 @@ import org.koitharu.kotatsu.tracker.data.TrackEntity
import org.koitharu.kotatsu.tracker.data.TrackLogEntity import org.koitharu.kotatsu.tracker.data.TrackLogEntity
import org.koitharu.kotatsu.tracker.data.TracksDao import org.koitharu.kotatsu.tracker.data.TracksDao
const val DATABASE_VERSION = 17 const val DATABASE_VERSION = 18
@Database( @Database(
entities = [ entities = [
@@ -108,6 +109,7 @@ fun getDatabaseMigrations(context: Context): Array<Migration> = arrayOf(
Migration14To15(), Migration14To15(),
Migration15To16(), Migration15To16(),
Migration16To17(context), Migration16To17(context),
Migration17To18(),
) )
fun MangaDatabase(context: Context): MangaDatabase = Room fun MangaDatabase(context: Context): MangaDatabase = Room

View File

@@ -1,6 +1,7 @@
package org.koitharu.kotatsu.core.db.dao package org.koitharu.kotatsu.core.db.dao
import androidx.room.Dao import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert import androidx.room.Insert
import androidx.room.OnConflictStrategy import androidx.room.OnConflictStrategy
import androidx.room.Query import androidx.room.Query
@@ -23,6 +24,10 @@ abstract class MangaDao {
@Query("SELECT * FROM manga WHERE public_url = :publicUrl") @Query("SELECT * FROM manga WHERE public_url = :publicUrl")
abstract suspend fun findByPublicUrl(publicUrl: String): MangaWithTags? abstract suspend fun findByPublicUrl(publicUrl: String): MangaWithTags?
@Transaction
@Query("SELECT * FROM manga WHERE source = :source")
abstract suspend fun findAllBySource(source: String): List<MangaWithTags>
@Transaction @Transaction
@Query("SELECT * FROM manga WHERE (title LIKE :query OR alt_title LIKE :query) AND manga_id IN (SELECT manga_id FROM favourites UNION SELECT manga_id FROM history) LIMIT :limit") @Query("SELECT * FROM manga WHERE (title LIKE :query OR alt_title LIKE :query) AND manga_id IN (SELECT manga_id FROM favourites UNION SELECT manga_id FROM history) LIMIT :limit")
abstract suspend fun searchByTitle(query: String, limit: Int): List<MangaWithTags> abstract suspend fun searchByTitle(query: String, limit: Int): List<MangaWithTags>
@@ -43,6 +48,10 @@ abstract class MangaDao {
@Query("DELETE FROM manga_tags WHERE manga_id = :mangaId") @Query("DELETE FROM manga_tags WHERE manga_id = :mangaId")
abstract suspend fun clearTagRelation(mangaId: Long) abstract suspend fun clearTagRelation(mangaId: Long)
@Transaction
@Delete
abstract suspend fun delete(subjects: Collection<MangaEntity>)
@Transaction @Transaction
open suspend fun upsert(manga: MangaEntity, tags: Iterable<TagEntity>? = null) { open suspend fun upsert(manga: MangaEntity, tags: Iterable<TagEntity>? = null) {
upsert(manga) upsert(manga)

View File

@@ -24,4 +24,5 @@ data class MangaPrefsEntity(
@ColumnInfo(name = "cf_brightness") val cfBrightness: Float, @ColumnInfo(name = "cf_brightness") val cfBrightness: Float,
@ColumnInfo(name = "cf_contrast") val cfContrast: Float, @ColumnInfo(name = "cf_contrast") val cfContrast: Float,
@ColumnInfo(name = "cf_invert") val cfInvert: Boolean, @ColumnInfo(name = "cf_invert") val cfInvert: Boolean,
@ColumnInfo(name = "cf_grayscale") val cfGrayscale: Boolean,
) )

View File

@@ -0,0 +1,11 @@
package org.koitharu.kotatsu.core.db.migrations
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
class Migration17To18 : Migration(17, 18) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE preferences ADD COLUMN `cf_grayscale` INTEGER NOT NULL DEFAULT 0")
}
}

View File

@@ -1,8 +0,0 @@
package org.koitharu.kotatsu.core.exceptions
import org.koitharu.kotatsu.parsers.util.mapNotNullToSet
class CompositeException(val errors: Collection<Throwable>) : Exception() {
override val message: String = errors.mapNotNullToSet { it.message }.joinToString()
}

View File

@@ -1,6 +1,7 @@
package org.koitharu.kotatsu.core.model package org.koitharu.kotatsu.core.model
import android.net.Uri import android.net.Uri
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.core.os.LocaleListCompat import androidx.core.os.LocaleListCompat
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
@@ -43,6 +44,15 @@ val MangaState.titleResId: Int
MangaState.PAUSED -> R.string.state_paused MangaState.PAUSED -> R.string.state_paused
} }
@get:DrawableRes
val MangaState.iconResId: Int
get() = when (this) {
MangaState.ONGOING -> R.drawable.ic_state_ongoing
MangaState.FINISHED -> R.drawable.ic_state_finished
MangaState.ABANDONED -> R.drawable.ic_state_abandoned
MangaState.PAUSED -> R.drawable.ic_action_pause
}
fun Manga.findChapter(id: Long): MangaChapter? { fun Manga.findChapter(id: Long): MangaChapter? {
return chapters?.findById(id) return chapters?.findById(id)
} }

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.core.parser package org.koitharu.kotatsu.core.parser
import androidx.core.net.toUri
import androidx.room.withTransaction import androidx.room.withTransaction
import dagger.Reusable import dagger.Reusable
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@@ -13,6 +14,7 @@ import org.koitharu.kotatsu.core.db.entity.toManga
import org.koitharu.kotatsu.core.db.entity.toMangaTags import org.koitharu.kotatsu.core.db.entity.toMangaTags
import org.koitharu.kotatsu.core.model.isLocal import org.koitharu.kotatsu.core.model.isLocal
import org.koitharu.kotatsu.core.prefs.ReaderMode import org.koitharu.kotatsu.core.prefs.ReaderMode
import org.koitharu.kotatsu.core.util.ext.toFileOrNull
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
@@ -97,9 +99,18 @@ class MangaDataRepository @Inject constructor(
return db.getTagsDao().findTags(source.name).toMangaTags() return db.getTagsDao().findTags(source.name).toMangaTags()
} }
suspend fun cleanupLocalManga() {
val dao = db.getMangaDao()
val broken = dao.findAllBySource(MangaSource.LOCAL.name)
.filter { x -> x.manga.url.toUri().toFileOrNull()?.exists() == false }
if (broken.isNotEmpty()) {
dao.delete(broken.map { it.manga })
}
}
private fun MangaPrefsEntity.getColorFilterOrNull(): ReaderColorFilter? { private fun MangaPrefsEntity.getColorFilterOrNull(): ReaderColorFilter? {
return if (cfBrightness != 0f || cfContrast != 0f || cfInvert) { return if (cfBrightness != 0f || cfContrast != 0f || cfInvert || cfGrayscale) {
ReaderColorFilter(cfBrightness, cfContrast, cfInvert) ReaderColorFilter(cfBrightness, cfContrast, cfInvert, cfGrayscale)
} else { } else {
null null
} }
@@ -111,5 +122,6 @@ class MangaDataRepository @Inject constructor(
cfBrightness = 0f, cfBrightness = 0f,
cfContrast = 0f, cfContrast = 0f,
cfInvert = false, cfInvert = false,
cfGrayscale = false,
) )
} }

View File

@@ -1,6 +1,7 @@
package org.koitharu.kotatsu.core.parser package org.koitharu.kotatsu.core.parser
import android.net.Uri import android.net.Uri
import coil.request.CachePolicy
import dagger.Reusable import dagger.Reusable
import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty
@@ -85,7 +86,7 @@ class MangaLinkResolver @Inject constructor(
private suspend fun MangaRepository.getDetailsNoCache(manga: Manga): Manga { private suspend fun MangaRepository.getDetailsNoCache(manga: Manga): Manga {
return if (this is RemoteMangaRepository) { return if (this is RemoteMangaRepository) {
getDetails(manga, withCache = false) getDetails(manga, CachePolicy.READ_ONLY)
} else { } else {
getDetails(manga) getDetails(manga)
} }

View File

@@ -15,6 +15,7 @@ import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
import java.lang.ref.WeakReference import java.lang.ref.WeakReference
import java.util.EnumMap import java.util.EnumMap
import java.util.Locale
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
import kotlin.collections.set import kotlin.collections.set
@@ -41,6 +42,8 @@ interface MangaRepository {
suspend fun getTags(): Set<MangaTag> suspend fun getTags(): Set<MangaTag>
suspend fun getLocales(): Set<Locale>
suspend fun getRelated(seed: Manga): List<Manga> suspend fun getRelated(seed: Manga): List<Manga>
@Singleton @Singleton

View File

@@ -1,6 +1,7 @@
package org.koitharu.kotatsu.core.parser package org.koitharu.kotatsu.core.parser
import android.util.Log import android.util.Log
import coil.request.CachePolicy
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -31,6 +32,7 @@ import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.util.domain import org.koitharu.kotatsu.parsers.util.domain
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import java.util.Locale
class RemoteMangaRepository( class RemoteMangaRepository(
private val parser: MangaParser, private val parser: MangaParser,
@@ -82,7 +84,7 @@ class RemoteMangaRepository(
} }
} }
override suspend fun getDetails(manga: Manga): Manga = getDetails(manga, withCache = true) override suspend fun getDetails(manga: Manga): Manga = getDetails(manga, CachePolicy.ENABLED)
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> { override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
cache.getPages(source, chapter.url)?.let { return it } cache.getPages(source, chapter.url)?.let { return it }
@@ -103,6 +105,10 @@ class RemoteMangaRepository(
parser.getAvailableTags() parser.getAvailableTags()
} }
override suspend fun getLocales(): Set<Locale> {
return parser.getAvailableLocales()
}
suspend fun getFavicons(): Favicons = mirrorSwitchInterceptor.withMirrorSwitching { suspend fun getFavicons(): Favicons = mirrorSwitchInterceptor.withMirrorSwitching {
parser.getFavicons() parser.getFavicons()
} }
@@ -116,17 +122,18 @@ class RemoteMangaRepository(
return related.await() return related.await()
} }
suspend fun getDetails(manga: Manga, withCache: Boolean): Manga { suspend fun getDetails(manga: Manga, cachePolicy: CachePolicy): Manga {
if (!withCache) { if (cachePolicy.readEnabled) {
return parser.getDetails(manga) cache.getDetails(source, manga.url)?.let { return it }
} }
cache.getDetails(source, manga.url)?.let { return it }
val details = asyncSafe { val details = asyncSafe {
mirrorSwitchInterceptor.withMirrorSwitching { mirrorSwitchInterceptor.withMirrorSwitching {
parser.getDetails(manga) parser.getDetails(manga)
} }
} }
cache.putDetails(source, manga.url, details) if (cachePolicy.writeEnabled) {
cache.putDetails(source, manga.url, details)
}
return details.await() return details.await()
} }
@@ -149,6 +156,10 @@ class RemoteMangaRepository(
return parser.configKeyDomain.presetValues.toList() return parser.configKeyDomain.presetValues.toList()
} }
fun isSlowdownEnabled(): Boolean {
return getConfig().isSlowdownEnabled
}
private fun getConfig() = parser.config as SourceSettings private fun getConfig() = parser.config as SourceSettings
private suspend fun <T> asyncSafe(block: suspend CoroutineScope.() -> T): SafeDeferred<T> { private suspend fun <T> asyncSafe(block: suspend CoroutineScope.() -> T): SafeDeferred<T> {

View File

@@ -28,6 +28,7 @@ import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.util.find import org.koitharu.kotatsu.parsers.util.find
import org.koitharu.kotatsu.parsers.util.mapNotNullToSet import org.koitharu.kotatsu.parsers.util.mapNotNullToSet
import org.koitharu.kotatsu.parsers.util.mapToSet import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.reader.domain.ReaderColorFilter
import java.io.File import java.io.File
import java.net.Proxy import java.net.Proxy
import java.util.Locale import java.util.Locale
@@ -259,9 +260,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
} }
} }
val isDownloadsSlowdownEnabled: Boolean
get() = prefs.getBoolean(KEY_DOWNLOADS_SLOWDOWN, false)
val isDownloadsWiFiOnly: Boolean val isDownloadsWiFiOnly: Boolean
get() = prefs.getBoolean(KEY_DOWNLOADS_WIFI, false) get() = prefs.getBoolean(KEY_DOWNLOADS_WIFI, false)
@@ -296,6 +294,24 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val isReaderKeepScreenOn: Boolean val isReaderKeepScreenOn: Boolean
get() = prefs.getBoolean(KEY_READER_SCREEN_ON, true) get() = prefs.getBoolean(KEY_READER_SCREEN_ON, true)
var readerColorFilter: ReaderColorFilter?
get() {
val brightness = prefs.getFloat(KEY_CF_BRIGHTNESS, ReaderColorFilter.EMPTY.brightness)
val contrast = prefs.getFloat(KEY_CF_CONTRAST, ReaderColorFilter.EMPTY.contrast)
val inverted = prefs.getBoolean(KEY_CF_INVERTED, ReaderColorFilter.EMPTY.isInverted)
val grayscale = prefs.getBoolean(KEY_CF_GRAYSCALE, ReaderColorFilter.EMPTY.isGrayscale)
return ReaderColorFilter(brightness, contrast, inverted, grayscale).takeUnless { it.isEmpty }
}
set(value) {
prefs.edit {
val cf = value ?: ReaderColorFilter.EMPTY
putFloat(KEY_CF_BRIGHTNESS, cf.brightness)
putFloat(KEY_CF_CONTRAST, cf.contrast)
putBoolean(KEY_CF_INVERTED, cf.isInverted)
putBoolean(KEY_CF_GRAYSCALE, cf.isGrayscale)
}
}
val isImagesProxyEnabled: Boolean val isImagesProxyEnabled: Boolean
get() = prefs.getBoolean(KEY_IMAGES_PROXY, false) get() = prefs.getBoolean(KEY_IMAGES_PROXY, false)
@@ -495,7 +511,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_SHIKIMORI = "shikimori" const val KEY_SHIKIMORI = "shikimori"
const val KEY_ANILIST = "anilist" const val KEY_ANILIST = "anilist"
const val KEY_MAL = "mal" const val KEY_MAL = "mal"
const val KEY_DOWNLOADS_SLOWDOWN = "downloads_slowdown"
const val KEY_DOWNLOADS_WIFI = "downloads_wifi" const val KEY_DOWNLOADS_WIFI = "downloads_wifi"
const val KEY_ALL_FAVOURITES_VISIBLE = "all_favourites_visible" const val KEY_ALL_FAVOURITES_VISIBLE = "all_favourites_visible"
const val KEY_DOH = "doh" const val KEY_DOH = "doh"
@@ -539,6 +554,11 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_32BIT_COLOR = "enhanced_colors" const val KEY_32BIT_COLOR = "enhanced_colors"
const val KEY_SOURCES_ORDER = "sources_sort_order" const val KEY_SOURCES_ORDER = "sources_sort_order"
const val KEY_SOURCES_CATALOG = "sources_catalog" const val KEY_SOURCES_CATALOG = "sources_catalog"
const val KEY_CF_BRIGHTNESS = "cf_brightness"
const val KEY_CF_CONTRAST = "cf_contrast"
const val KEY_CF_INVERTED = "cf_inverted"
const val KEY_CF_GRAYSCALE = "cf_grayscale"
const val KEY_IGNORE_DOZE = "ignore_dose"
// About // About
const val KEY_APP_UPDATE = "app_update" const val KEY_APP_UPDATE = "app_update"

View File

@@ -11,6 +11,7 @@ import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
private const val KEY_SORT_ORDER = "sort_order" private const val KEY_SORT_ORDER = "sort_order"
private const val KEY_SLOWDOWN = "slowdown"
class SourceSettings(context: Context, source: MangaSource) : MangaSourceConfig { class SourceSettings(context: Context, source: MangaSource) : MangaSourceConfig {
@@ -20,6 +21,9 @@ class SourceSettings(context: Context, source: MangaSource) : MangaSourceConfig
get() = prefs.getEnumValue(KEY_SORT_ORDER, SortOrder::class.java) get() = prefs.getEnumValue(KEY_SORT_ORDER, SortOrder::class.java)
set(value) = prefs.edit { putEnumValue(KEY_SORT_ORDER, value) } set(value) = prefs.edit { putEnumValue(KEY_SORT_ORDER, value) }
val isSlowdownEnabled: Boolean
get() = prefs.getBoolean(KEY_SLOWDOWN, false)
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
override fun <T> get(key: ConfigKey<T>): T { override fun <T> get(key: ConfigKey<T>): T {
return when (key) { return when (key) {

View File

@@ -0,0 +1,75 @@
package org.koitharu.kotatsu.core.ui
import androidx.recyclerview.widget.AsyncListDiffer.ListListener
import androidx.recyclerview.widget.DiffUtil
import com.hannesdorfmann.adapterdelegates4.AdapterDelegate
import com.hannesdorfmann.adapterdelegates4.ListDelegationAdapter
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.FlowCollector
import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
import org.koitharu.kotatsu.list.ui.model.ListModel
import java.util.Collections
import java.util.LinkedList
open class ReorderableListAdapter<T : ListModel> : ListDelegationAdapter<List<T>>(), FlowCollector<List<T>?> {
private val listListeners = LinkedList<ListListener<T>>()
override suspend fun emit(value: List<T>?) {
val oldList = items.orEmpty()
val newList = value.orEmpty()
val diffResult = withContext(Dispatchers.Default) {
val diffCallback = DiffCallback(oldList, newList)
DiffUtil.calculateDiff(diffCallback)
}
super.setItems(newList)
diffResult.dispatchUpdatesTo(this)
listListeners.forEach { it.onCurrentListChanged(oldList, newList) }
}
@Deprecated("Use emit() to dispatch list updates", level = DeprecationLevel.ERROR)
override fun setItems(items: List<T>?) {
super.setItems(items)
}
fun reorderItems(oldPos: Int, newPos: Int) {
Collections.swap(items ?: return, oldPos, newPos)
notifyItemMoved(oldPos, newPos)
}
fun addDelegate(type: ListItemType, delegate: AdapterDelegate<List<T>>): ReorderableListAdapter<T> {
delegatesManager.addDelegate(type.ordinal, delegate)
return this
}
fun addListListener(listListener: ListListener<T>) {
listListeners.add(listListener)
}
fun removeListListener(listListener: ListListener<T>) {
listListeners.remove(listListener)
}
protected class DiffCallback<T : ListModel>(
val oldList: List<T>,
val newList: List<T>,
) : DiffUtil.Callback() {
override fun getOldListSize(): Int = oldList.size
override fun getNewListSize(): Int = newList.size
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
val oldItem = oldList[oldItemPosition]
val newItem = newList[newItemPosition]
return newItem.areItemsTheSame(oldItem)
}
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
val oldItem = oldList[oldItemPosition]
val newItem = newList[newItemPosition]
return newItem == oldItem
}
}
}

View File

@@ -1,100 +0,0 @@
package org.koitharu.kotatsu.core.ui.drawable
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.ColorFilter
import android.graphics.Paint
import android.graphics.PixelFormat
import android.graphics.Typeface
import android.graphics.drawable.Drawable
import android.os.Build
import android.text.Layout
import android.text.StaticLayout
import android.text.TextPaint
import androidx.annotation.ColorInt
import androidx.annotation.Px
import androidx.annotation.StyleRes
import androidx.core.graphics.withTranslation
import com.google.android.material.resources.TextAppearance
import com.google.android.material.resources.TextAppearanceFontCallback
import org.koitharu.kotatsu.core.util.ext.getThemeColor
class TextDrawable(
val text: CharSequence,
) : Drawable() {
private val paint = TextPaint(Paint.ANTI_ALIAS_FLAG)
private var cachedLayout: StaticLayout? = null
@SuppressLint("RestrictedApi")
constructor(context: Context, text: CharSequence, @StyleRes textAppearanceId: Int) : this(text) {
val ta = TextAppearance(context, textAppearanceId)
paint.color = ta.textColor?.defaultColor ?: context.getThemeColor(android.R.attr.textColorPrimary, Color.BLACK)
paint.typeface = ta.fallbackFont
ta.getFontAsync(
context, paint,
object : TextAppearanceFontCallback() {
override fun onFontRetrieved(typeface: Typeface?, fontResolvedSynchronously: Boolean) = Unit
override fun onFontRetrievalFailed(reason: Int) = Unit
},
)
paint.letterSpacing = ta.letterSpacing
}
var alignment = Layout.Alignment.ALIGN_NORMAL
var lineSpacingMultiplier = 1f
@Px
var lineSpacingExtra = 0f
@get:ColorInt
var textColor: Int
get() = paint.color
set(@ColorInt value) {
paint.color = value
}
override fun draw(canvas: Canvas) {
val b = bounds
if (b.isEmpty) {
return
}
canvas.withTranslation(x = b.left.toFloat(), y = b.top.toFloat()) {
obtainLayout().draw(canvas)
}
}
override fun setAlpha(alpha: Int) {
paint.alpha = alpha
}
override fun setColorFilter(colorFilter: ColorFilter?) {
paint.setColorFilter(colorFilter)
}
@Suppress("DeprecatedCallableAddReplaceWith")
@Deprecated("Deprecated in Java")
override fun getOpacity(): Int = PixelFormat.TRANSLUCENT
private fun obtainLayout(): StaticLayout {
val width = bounds.width()
cachedLayout?.let {
if (it.width == width) {
return it
}
}
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
StaticLayout.Builder.obtain(text, 0, text.length, paint, width)
.setAlignment(alignment)
.setLineSpacing(lineSpacingExtra, lineSpacingMultiplier)
.setIncludePad(true)
.build()
} else {
@Suppress("DEPRECATION")
StaticLayout(text, paint, width, alignment, lineSpacingMultiplier, lineSpacingExtra, true)
}.also { cachedLayout = it }
}
}

View File

@@ -1,64 +0,0 @@
package org.koitharu.kotatsu.core.ui.list
import android.os.Bundle
import android.os.Parcelable
import android.util.SparseArray
import androidx.core.os.BundleCompat
import androidx.core.view.doOnNextLayout
import androidx.recyclerview.widget.RecyclerView
import java.util.Collections
import java.util.WeakHashMap
class NestedScrollStateHandle(
savedInstanceState: Bundle?,
private val key: String,
) {
private val storage: SparseArray<Parcelable?> = savedInstanceState?.let {
BundleCompat.getSparseParcelableArray(it, key, Parcelable::class.java)
} ?: SparseArray<Parcelable?>()
private val controllers = Collections.newSetFromMap<Controller>(WeakHashMap())
fun attach(recycler: RecyclerView) = Controller(recycler).also(controllers::add)
fun onSaveInstanceState(outState: Bundle) {
controllers.forEach {
it.saveState()
}
outState.putSparseParcelableArray(key, storage)
}
inner class Controller(
private val recycler: RecyclerView
) {
private var lastPosition: Int = -1
fun onBind(position: Int) {
if (position != lastPosition) {
saveState()
lastPosition = position
storage[position]?.let {
restoreState(it)
}
}
}
fun onRecycled() {
saveState()
lastPosition = -1
}
fun saveState() {
if (lastPosition != -1) {
storage[lastPosition] = recycler.layoutManager?.onSaveInstanceState()
}
}
private fun restoreState(state: Parcelable) {
recycler.doOnNextLayout {
recycler.layoutManager?.onRestoreInstanceState(state)
}
}
}
}

View File

@@ -1,237 +1,4 @@
package org.koitharu.kotatsu.core.ui.list package org.koitharu.kotatsu.core.ui.list
import android.app.Activity
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.view.ActionMode
import androidx.collection.ArrayMap
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.LifecycleOwner
import androidx.recyclerview.widget.RecyclerView
import androidx.savedstate.SavedStateRegistry
import androidx.savedstate.SavedStateRegistryOwner
import kotlinx.coroutines.Dispatchers
import org.koitharu.kotatsu.core.ui.list.decor.AbstractSelectionItemDecoration
import kotlin.coroutines.EmptyCoroutineContext
private const val PROVIDER_NAME = "selection_decoration_sectioned" private const val PROVIDER_NAME = "selection_decoration_sectioned"
class SectionedSelectionController<T : Any>(
private val activity: Activity,
private val owner: SavedStateRegistryOwner,
private val callback: Callback<T>,
) : ActionMode.Callback, SavedStateRegistry.SavedStateProvider {
private var actionMode: ActionMode? = null
private var pendingData: MutableMap<String, Collection<Long>>? = null
private val decorations = ArrayMap<T, AbstractSelectionItemDecoration>()
val count: Int
get() = decorations.values.sumOf { it.checkedItemsCount }
init {
owner.lifecycle.addObserver(StateEventObserver())
}
fun snapshot(): Map<T, Set<Long>> {
return decorations.mapValues { it.value.checkedItemsIds.toSet() }
}
fun peekCheckedIds(): Map<T, Set<Long>> {
return decorations.mapValues { it.value.checkedItemsIds }
}
fun clear() {
decorations.values.forEach {
it.clearSelection()
}
notifySelectionChanged()
}
fun attachToRecyclerView(section: T, recyclerView: RecyclerView) {
val decoration = getDecoration(section)
val pendingIds = pendingData?.remove(section.toString())
if (!pendingIds.isNullOrEmpty()) {
decoration.checkAll(pendingIds)
startActionMode()
notifySelectionChanged()
}
var shouldAddDecoration = true
for (i in (0 until recyclerView.itemDecorationCount).reversed()) {
val decor = recyclerView.getItemDecorationAt(i)
if (decor === decoration) {
shouldAddDecoration = false
break
} else if (decor.javaClass == decoration.javaClass) {
recyclerView.removeItemDecorationAt(i)
}
}
if (shouldAddDecoration) {
recyclerView.addItemDecoration(decoration)
}
if (pendingData?.isEmpty() == true) {
pendingData = null
}
}
override fun saveState(): Bundle {
val bundle = Bundle(decorations.size)
for ((k, v) in decorations) {
bundle.putLongArray(k.toString(), v.checkedItemsIds.toLongArray())
}
return bundle
}
fun onItemClick(section: T, id: Long): Boolean {
val decoration = getDecoration(section)
if (isInSelectionMode()) {
decoration.toggleItemChecked(id)
if (isInSelectionMode()) {
actionMode?.invalidate()
} else {
actionMode?.finish()
}
notifySelectionChanged()
return true
}
return false
}
fun onItemLongClick(section: T, id: Long): Boolean {
val decoration = getDecoration(section)
startActionMode()
return actionMode?.also {
decoration.setItemIsChecked(id, true)
notifySelectionChanged()
} != null
}
fun getSectionCount(section: T): Int {
return decorations[section]?.checkedItemsCount ?: 0
}
fun addToSelection(section: T, ids: Collection<Long>): Boolean {
val decoration = getDecoration(section)
startActionMode()
return actionMode?.also {
decoration.checkAll(ids)
notifySelectionChanged()
} != null
}
fun clearSelection(section: T) {
decorations[section]?.clearSelection() ?: return
notifySelectionChanged()
}
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
return callback.onCreateActionMode(this, mode, menu)
}
override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean {
return callback.onPrepareActionMode(this, mode, menu)
}
override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
return callback.onActionItemClicked(this, mode, item)
}
override fun onDestroyActionMode(mode: ActionMode) {
callback.onDestroyActionMode(this, mode)
clear()
actionMode = null
}
private fun startActionMode() {
if (actionMode == null) {
actionMode = (activity as? AppCompatActivity)?.startSupportActionMode(this)
}
}
private fun isInSelectionMode(): Boolean {
return decorations.values.any { x -> x.checkedItemsCount > 0 }
}
private fun notifySelectionChanged() {
val count = this.count
callback.onSelectionChanged(this, count)
if (count == 0) {
actionMode?.finish()
} else {
actionMode?.invalidate()
}
}
private fun restoreState(ids: MutableMap<String, Collection<Long>>) {
if (ids.isEmpty() || isInSelectionMode()) {
return
}
for ((k, v) in decorations) {
val items = ids.remove(k.toString())
if (!items.isNullOrEmpty()) {
v.checkAll(items)
}
}
pendingData = ids
if (isInSelectionMode()) {
startActionMode()
notifySelectionChanged()
}
}
private fun getDecoration(section: T): AbstractSelectionItemDecoration {
return decorations.getOrPut(section) {
callback.onCreateItemDecoration(this, section)
}
}
interface Callback<T : Any> {
fun onSelectionChanged(controller: SectionedSelectionController<T>, count: Int)
fun onCreateActionMode(controller: SectionedSelectionController<T>, mode: ActionMode, menu: Menu): Boolean
fun onPrepareActionMode(controller: SectionedSelectionController<T>, mode: ActionMode, menu: Menu): Boolean {
mode.title = controller.count.toString()
return true
}
fun onDestroyActionMode(controller: SectionedSelectionController<T>, mode: ActionMode) = Unit
fun onActionItemClicked(
controller: SectionedSelectionController<T>,
mode: ActionMode,
item: MenuItem,
): Boolean
fun onCreateItemDecoration(
controller: SectionedSelectionController<T>,
section: T,
): AbstractSelectionItemDecoration
}
private inner class StateEventObserver : LifecycleEventObserver {
override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
if (event == Lifecycle.Event.ON_CREATE) {
val registry = owner.savedStateRegistry
registry.registerSavedStateProvider(PROVIDER_NAME, this@SectionedSelectionController)
val state = registry.consumeRestoredStateForKey(PROVIDER_NAME)
if (state != null) {
Dispatchers.Main.dispatch(EmptyCoroutineContext) { // == Handler.post
if (source.lifecycle.currentState.isAtLeast(Lifecycle.State.CREATED)) {
restoreState(
state.keySet()
.associateWithTo(HashMap()) { state.getLongArray(it)?.toList().orEmpty() },
)
}
}
}
}
}
}
}

View File

@@ -12,7 +12,12 @@ import android.view.MotionEvent
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.* import android.widget.*
import androidx.annotation.* import androidx.annotation.AttrRes
import androidx.annotation.ColorInt
import androidx.annotation.DimenRes
import androidx.annotation.DrawableRes
import androidx.annotation.Px
import androidx.annotation.StyleableRes
import androidx.constraintlayout.widget.ConstraintLayout import androidx.constraintlayout.widget.ConstraintLayout
import androidx.constraintlayout.widget.ConstraintSet import androidx.constraintlayout.widget.ConstraintSet
import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.coordinatorlayout.widget.CoordinatorLayout
@@ -131,19 +136,19 @@ class FastScroller @JvmOverloads constructor(
var showTrack = false var showTrack = false
context.withStyledAttributes(attrs, R.styleable.FastScroller, defStyleAttr) { context.withStyledAttributes(attrs, R.styleable.FastScrollRecyclerView, defStyleAttr) {
bubbleColor = getColor(R.styleable.FastScroller_bubbleColor, bubbleColor) bubbleColor = getColor(R.styleable.FastScrollRecyclerView_bubbleColor, bubbleColor)
handleColor = getColor(R.styleable.FastScroller_thumbColor, handleColor) handleColor = getColor(R.styleable.FastScrollRecyclerView_thumbColor, handleColor)
trackColor = getColor(R.styleable.FastScroller_trackColor, trackColor) trackColor = getColor(R.styleable.FastScrollRecyclerView_trackColor, trackColor)
textColor = getColor(R.styleable.FastScroller_bubbleTextColor, textColor) textColor = getColor(R.styleable.FastScrollRecyclerView_bubbleTextColor, textColor)
hideScrollbar = getBoolean(R.styleable.FastScroller_hideScrollbar, hideScrollbar) hideScrollbar = getBoolean(R.styleable.FastScrollRecyclerView_hideScrollbar, hideScrollbar)
showBubble = getBoolean(R.styleable.FastScroller_showBubble, showBubble) showBubble = getBoolean(R.styleable.FastScrollRecyclerView_showBubble, showBubble)
showBubbleAlways = getBoolean(R.styleable.FastScroller_showBubbleAlways, showBubbleAlways) showBubbleAlways = getBoolean(R.styleable.FastScrollRecyclerView_showBubbleAlways, showBubbleAlways)
showTrack = getBoolean(R.styleable.FastScroller_showTrack, showTrack) showTrack = getBoolean(R.styleable.FastScrollRecyclerView_showTrack, showTrack)
bubbleSize = getBubbleSize(R.styleable.FastScroller_bubbleSize, BubbleSize.NORMAL) bubbleSize = getBubbleSize(R.styleable.FastScrollRecyclerView_bubbleSize, BubbleSize.NORMAL)
val textSize = getDimension(R.styleable.FastScroller_bubbleTextSize, bubbleSize.textSize) val textSize = getDimension(R.styleable.FastScrollRecyclerView_bubbleTextSize, bubbleSize.textSize)
binding.bubble.setTextSize(TypedValue.COMPLEX_UNIT_PX, textSize) binding.bubble.setTextSize(TypedValue.COMPLEX_UNIT_PX, textSize)
offset = getDimensionPixelOffset(R.styleable.FastScroller_scrollerOffset, offset) offset = getDimensionPixelOffset(R.styleable.FastScrollRecyclerView_scrollerOffset, offset)
} }
setTrackColor(trackColor) setTrackColor(trackColor)

View File

@@ -79,7 +79,11 @@ sealed class DateTimeAgo {
private val day = date.daysDiff(0) private val day = date.daysDiff(0)
override fun format(resources: Resources): String { override fun format(resources: Resources): String {
return date.format("d MMMM") return if (date.time == 0L) {
resources.getString(R.string.unknown)
} else {
date.format("d MMMM")
}
} }
override fun equals(other: Any?): Boolean { override fun equals(other: Any?): Boolean {

View File

@@ -1,21 +1,16 @@
package org.koitharu.kotatsu.core.ui.widgets package org.koitharu.kotatsu.core.ui.widgets
import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.content.res.ColorStateList
import android.util.AttributeSet import android.util.AttributeSet
import android.view.View.OnClickListener import android.view.View.OnClickListener
import androidx.annotation.ColorRes import androidx.annotation.ColorRes
import androidx.annotation.DrawableRes import androidx.annotation.DrawableRes
import androidx.core.content.ContextCompat
import androidx.core.content.res.getColorStateListOrThrow
import androidx.core.view.children import androidx.core.view.children
import com.google.android.material.chip.Chip import com.google.android.material.chip.Chip
import com.google.android.material.chip.ChipDrawable import com.google.android.material.chip.ChipDrawable
import com.google.android.material.chip.ChipGroup import com.google.android.material.chip.ChipGroup
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ext.castOrNull import org.koitharu.kotatsu.core.util.ext.castOrNull
import com.google.android.material.R as materialR
class ChipsView @JvmOverloads constructor( class ChipsView @JvmOverloads constructor(
context: Context, context: Context,
@@ -31,9 +26,7 @@ class ChipsView @JvmOverloads constructor(
private val chipOnCloseListener = OnClickListener { private val chipOnCloseListener = OnClickListener {
onChipCloseClickListener?.onChipCloseClick(it as Chip, it.tag) onChipCloseClickListener?.onChipCloseClick(it as Chip, it.tag)
} }
private val defaultChipStrokeColor: ColorStateList private val chipStyle: Int
private val defaultChipTextColor: ColorStateList
private val defaultChipIconTint: ColorStateList
var onChipClickListener: OnChipClickListener? = null var onChipClickListener: OnChipClickListener? = null
set(value) { set(value) {
field = value field = value
@@ -48,12 +41,17 @@ class ChipsView @JvmOverloads constructor(
} }
init { init {
@SuppressLint("CustomViewStyleable") val ta = context.obtainStyledAttributes(attrs, R.styleable.ChipsView, defStyleAttr, 0)
val a = context.obtainStyledAttributes(null, materialR.styleable.Chip, 0, R.style.Widget_Kotatsu_Chip) chipStyle = ta.getResourceId(R.styleable.ChipsView_chipStyle, R.style.Widget_Kotatsu_Chip)
defaultChipStrokeColor = a.getColorStateListOrThrow(materialR.styleable.Chip_chipStrokeColor) ta.recycle()
defaultChipTextColor = a.getColorStateListOrThrow(materialR.styleable.Chip_android_textColor)
defaultChipIconTint = a.getColorStateListOrThrow(materialR.styleable.Chip_chipIconTint) if (isInEditMode) {
a.recycle() setChips(
List(5) {
ChipModel(0, "Chip $it", 0, isCheckable = false, isChecked = false)
},
)
}
} }
override fun requestLayout() { override fun requestLayout() {
@@ -91,15 +89,6 @@ class ChipsView @JvmOverloads constructor(
private fun bindChip(chip: Chip, model: ChipModel) { private fun bindChip(chip: Chip, model: ChipModel) {
chip.text = model.title chip.text = model.title
val tint = if (model.tint == 0) {
null
} else {
ContextCompat.getColorStateList(context, model.tint)
}
chip.chipIconTint = tint ?: defaultChipIconTint
chip.checkedIconTint = tint ?: defaultChipIconTint
chip.chipStrokeColor = tint ?: defaultChipStrokeColor
chip.setTextColor(tint ?: defaultChipTextColor)
chip.isClickable = onChipClickListener != null || model.isCheckable chip.isClickable = onChipClickListener != null || model.isCheckable
chip.isCheckable = model.isCheckable chip.isCheckable = model.isCheckable
if (model.icon == 0) { if (model.icon == 0) {
@@ -115,12 +104,11 @@ class ChipsView @JvmOverloads constructor(
private fun addChip(): Chip { private fun addChip(): Chip {
val chip = Chip(context) val chip = Chip(context)
val drawable = ChipDrawable.createFromAttributes(context, null, 0, R.style.Widget_Kotatsu_Chip) val drawable = ChipDrawable.createFromAttributes(context, null, 0, chipStyle)
chip.setChipDrawable(drawable) chip.setChipDrawable(drawable)
chip.isCheckedIconVisible = true chip.isCheckedIconVisible = true
chip.isChipIconVisible = false chip.isChipIconVisible = false
chip.setCheckedIconResource(R.drawable.ic_check) chip.setCheckedIconResource(R.drawable.ic_check)
chip.checkedIconTint = defaultChipIconTint
chip.isCloseIconVisible = onChipCloseClickListener != null chip.isCloseIconVisible = onChipCloseClickListener != null
chip.setOnCloseIconClickListener(chipOnCloseListener) chip.setOnCloseIconClickListener(chipOnCloseListener)
chip.setEnsureMinTouchTargetSize(false) chip.setEnsureMinTouchTargetSize(false)

View File

@@ -0,0 +1,44 @@
package org.koitharu.kotatsu.core.ui.widgets
import android.annotation.SuppressLint
import android.content.Context
import android.util.AttributeSet
import android.view.MotionEvent
import androidx.core.content.withStyledAttributes
import androidx.recyclerview.widget.RecyclerView
import org.koitharu.kotatsu.R
class NestedRecyclerView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null
) : RecyclerView(context, attrs) {
private var maxHeight: Int = 0
init {
context.withStyledAttributes(attrs, R.styleable.NestedRecyclerView) {
maxHeight = getDimensionPixelSize(R.styleable.NestedRecyclerView_maxHeight, maxHeight)
}
}
@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(e: MotionEvent?): Boolean {
if (e?.actionMasked == MotionEvent.ACTION_UP) {
requestDisallowInterceptTouchEvent(false)
} else {
requestDisallowInterceptTouchEvent(true)
}
return super.onTouchEvent(e)
}
override fun onMeasure(widthSpec: Int, heightSpec: Int) {
super.onMeasure(
widthSpec,
if (maxHeight == 0) {
heightSpec
} else {
MeasureSpec.makeMeasureSpec(maxHeight, MeasureSpec.AT_MOST)
},
)
}
}

View File

@@ -22,7 +22,6 @@ import org.koitharu.kotatsu.core.util.ext.getThemeColorStateList
import org.koitharu.kotatsu.core.util.ext.setTextAndVisible import org.koitharu.kotatsu.core.util.ext.setTextAndVisible
import org.koitharu.kotatsu.core.util.ext.textAndVisible import org.koitharu.kotatsu.core.util.ext.textAndVisible
import org.koitharu.kotatsu.databinding.ViewTipBinding import org.koitharu.kotatsu.databinding.ViewTipBinding
import com.google.android.material.R as materialR
class TipView @JvmOverloads constructor( class TipView @JvmOverloads constructor(
context: Context, context: Context,

View File

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

View File

@@ -21,7 +21,11 @@ class ViewBadge(
get() = badgeDrawable?.number ?: 0 get() = badgeDrawable?.number ?: 0
set(value) { set(value) {
val badge = badgeDrawable ?: initBadge() val badge = badgeDrawable ?: initBadge()
badge.number = value if (maxCharacterCount != 0) {
badge.number = value
} else {
badge.clearNumber()
}
badge.isVisible = value > 0 badge.isVisible = value > 0
} }
@@ -51,7 +55,13 @@ class ViewBadge(
fun setMaxCharacterCount(value: Int) { fun setMaxCharacterCount(value: Int) {
maxCharacterCount = value maxCharacterCount = value
badgeDrawable?.maxCharacterCount = value badgeDrawable?.let {
if (value == 0) {
it.clearNumber()
} else {
it.maxCharacterCount = value
}
}
} }
private fun initBadge(): BadgeDrawable { private fun initBadge(): BadgeDrawable {

View File

@@ -1,7 +1,6 @@
package org.koitharu.kotatsu.core.util package org.koitharu.kotatsu.core.util
import android.annotation.SuppressLint import android.annotation.SuppressLint
import androidx.lifecycle.asFlow
import androidx.work.WorkInfo import androidx.work.WorkInfo
import androidx.work.WorkManager import androidx.work.WorkManager
import androidx.work.WorkQuery import androidx.work.WorkQuery
@@ -27,8 +26,7 @@ class WorkServiceStopHelper(
fun setup() { fun setup() {
processLifecycleScope.launch(Dispatchers.Default) { processLifecycleScope.launch(Dispatchers.Default) {
workManagerProvider.get() workManagerProvider.get()
.getWorkInfosLiveData(WorkQuery.fromStates(WorkInfo.State.RUNNING)) .getWorkInfosFlow(WorkQuery.fromStates(WorkInfo.State.RUNNING))
.asFlow()
.map { it.isEmpty() } .map { it.isEmpty() }
.distinctUntilChanged() .distinctUntilChanged()
.collectLatest { .collectLatest {

View File

@@ -50,7 +50,7 @@ private fun getVolumePathBeforeAndroid11(volumeId: String, context: Context): St
val length = ArrayReflect.getLength(checkNotNull(result)) val length = ArrayReflect.getLength(checkNotNull(result))
(0 until length).firstNotNullOfOrNull { i -> (0 until length).firstNotNullOfOrNull { i ->
val storageVolumeElement = ArrayReflect.get(result, i) val storageVolumeElement = ArrayReflect.get(result, i)
val uuid = getUuid.invoke(storageVolumeElement) as String val uuid = getUuid.invoke(storageVolumeElement) as String?
val primary = isPrimary.invoke(storageVolumeElement) as Boolean val primary = isPrimary.invoke(storageVolumeElement) as Boolean
when { when {
primary && volumeId == PRIMARY_VOLUME_NAME -> getPath.invoke(storageVolumeElement) as String primary && volumeId == PRIMARY_VOLUME_NAME -> getPath.invoke(storageVolumeElement) as String

View File

@@ -19,6 +19,11 @@ import org.koitharu.kotatsu.core.exceptions.SyncApiException
import org.koitharu.kotatsu.core.exceptions.TooManyRequestExceptions import org.koitharu.kotatsu.core.exceptions.TooManyRequestExceptions
import org.koitharu.kotatsu.core.exceptions.UnsupportedFileException import org.koitharu.kotatsu.core.exceptions.UnsupportedFileException
import org.koitharu.kotatsu.core.exceptions.WrongPasswordException import org.koitharu.kotatsu.core.exceptions.WrongPasswordException
import org.koitharu.kotatsu.parsers.ErrorMessages.FILTER_BOTH_LOCALE_GENRES_NOT_SUPPORTED
import org.koitharu.kotatsu.parsers.ErrorMessages.FILTER_BOTH_STATES_GENRES_NOT_SUPPORTED
import org.koitharu.kotatsu.parsers.ErrorMessages.FILTER_MULTIPLE_GENRES_NOT_SUPPORTED
import org.koitharu.kotatsu.parsers.ErrorMessages.FILTER_MULTIPLE_STATES_NOT_SUPPORTED
import org.koitharu.kotatsu.parsers.ErrorMessages.SEARCH_NOT_SUPPORTED
import org.koitharu.kotatsu.parsers.exception.AuthRequiredException import org.koitharu.kotatsu.parsers.exception.AuthRequiredException
import org.koitharu.kotatsu.parsers.exception.ContentUnavailableException import org.koitharu.kotatsu.parsers.exception.ContentUnavailableException
import org.koitharu.kotatsu.parsers.exception.NotFoundException import org.koitharu.kotatsu.parsers.exception.NotFoundException
@@ -28,9 +33,6 @@ import java.net.UnknownHostException
private const val MSG_NO_SPACE_LEFT = "No space left on device" private const val MSG_NO_SPACE_LEFT = "No space left on device"
private const val IMAGE_FORMAT_NOT_SUPPORTED = "Image format not supported" private const val IMAGE_FORMAT_NOT_SUPPORTED = "Image format not supported"
private const val MULTIPLE_GENRES_NOT_SUPPORTED = "Multiple genres are not supported by this source"
private const val MULTIPLE_STATES_NOT_SUPPORTED = "Multiple states are not supported by this source"
private const val SEARCH_NOT_SUPPORTED = "Search is not supported by this source"
fun Throwable.getDisplayMessage(resources: Resources): String = when (this) { fun Throwable.getDisplayMessage(resources: Resources): String = when (this) {
is AuthRequiredException -> resources.getString(R.string.auth_required) is AuthRequiredException -> resources.getString(R.string.auth_required)
@@ -85,9 +87,11 @@ private fun getDisplayMessage(msg: String?, resources: Resources): String? = whe
msg.isNullOrEmpty() -> null msg.isNullOrEmpty() -> null
msg.contains(MSG_NO_SPACE_LEFT) -> resources.getString(R.string.error_no_space_left) msg.contains(MSG_NO_SPACE_LEFT) -> resources.getString(R.string.error_no_space_left)
msg.contains(IMAGE_FORMAT_NOT_SUPPORTED) -> resources.getString(R.string.error_corrupted_file) msg.contains(IMAGE_FORMAT_NOT_SUPPORTED) -> resources.getString(R.string.error_corrupted_file)
msg == MULTIPLE_GENRES_NOT_SUPPORTED -> resources.getString(R.string.error_multiple_genres_not_supported) msg == FILTER_MULTIPLE_GENRES_NOT_SUPPORTED -> resources.getString(R.string.error_multiple_genres_not_supported)
msg == MULTIPLE_STATES_NOT_SUPPORTED -> resources.getString(R.string.error_multiple_states_not_supported) msg == FILTER_MULTIPLE_STATES_NOT_SUPPORTED -> resources.getString(R.string.error_multiple_states_not_supported)
msg == SEARCH_NOT_SUPPORTED -> resources.getString(R.string.error_search_not_supported) msg == SEARCH_NOT_SUPPORTED -> resources.getString(R.string.error_search_not_supported)
msg == FILTER_BOTH_LOCALE_GENRES_NOT_SUPPORTED -> resources.getString(R.string.error_filter_locale_genre_not_supported)
msg == FILTER_BOTH_STATES_GENRES_NOT_SUPPORTED -> resources.getString(R.string.error_filter_states_genre_not_supported)
else -> null else -> null
} }

View File

@@ -104,6 +104,7 @@ fun RecyclerView.invalidateNestedItemDecorations() {
val View.parentView: ViewGroup? val View.parentView: ViewGroup?
get() = parent as? ViewGroup get() = parent as? ViewGroup
@Suppress("UnusedReceiverParameter")
fun View.measureDimension(desiredSize: Int, measureSpec: Int): Int { fun View.measureDimension(desiredSize: Int, measureSpec: Int): Int {
var result: Int var result: Int
val specMode = MeasureSpec.getMode(measureSpec) val specMode = MeasureSpec.getMode(measureSpec)

View File

@@ -1,12 +1,14 @@
package org.koitharu.kotatsu.core.util.ext package org.koitharu.kotatsu.core.util.ext
import android.annotation.SuppressLint import android.annotation.SuppressLint
import androidx.work.Data
import androidx.work.WorkInfo import androidx.work.WorkInfo
import androidx.work.WorkManager import androidx.work.WorkManager
import androidx.work.WorkQuery import androidx.work.WorkQuery
import androidx.work.WorkRequest import androidx.work.WorkRequest
import androidx.work.await import androidx.work.await
import androidx.work.impl.WorkManagerImpl import androidx.work.impl.WorkManagerImpl
import androidx.work.impl.model.WorkSpec
import java.util.UUID import java.util.UUID
import kotlin.coroutines.resume import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException import kotlin.coroutines.resumeWithException
@@ -69,5 +71,24 @@ suspend fun WorkManager.awaitUpdateWork(request: WorkRequest): WorkManager.Updat
return updateWork(request).await() return updateWork(request).await()
} }
@SuppressLint("RestrictedApi")
suspend fun WorkManager.getWorkSpec(id: UUID): WorkSpec? = suspendCoroutine { cont ->
workManagerImpl.workTaskExecutor.executeOnTaskThread {
try {
val spec = workManagerImpl.workDatabase.workSpecDao().getWorkSpec(id.toString())
cont.resume(spec)
} catch (e: Exception) {
cont.resumeWithException(e)
}
}
}
@SuppressLint("RestrictedApi")
suspend fun WorkManager.getWorkInputData(id: UUID): Data? = getWorkSpec(id)?.input
val Data.isEmpty: Boolean
get() = this == Data.EMPTY
private val WorkManager.workManagerImpl private val WorkManager.workManagerImpl
@SuppressLint("RestrictedApi") inline get() = this as WorkManagerImpl @SuppressLint("RestrictedApi") inline get() = this as WorkManagerImpl

View File

@@ -1,26 +0,0 @@
package org.koitharu.kotatsu.core.util.progress
import androidx.annotation.AnyThread
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.StateFlow
import org.koitharu.kotatsu.download.ui.worker.PausingHandle
class PausingProgressJob<P>(
job: Job,
progress: StateFlow<P>,
private val pausingHandle: PausingHandle,
) : ProgressJob<P>(job, progress) {
@get:AnyThread
val isPaused: Boolean
get() = pausingHandle.isPaused
@AnyThread
suspend fun awaitResumed() = pausingHandle.awaitResumed()
@AnyThread
fun pause() = pausingHandle.pause()
@AnyThread
fun resume() = pausingHandle.resume()
}

View File

@@ -1,16 +0,0 @@
package org.koitharu.kotatsu.core.util.progress
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
open class ProgressJob<P>(
private val job: Job,
private val progress: StateFlow<P>,
) : Job by job {
val progressValue: P
get() = progress.value
fun progressAsFlow(): Flow<P> = progress
}

View File

@@ -21,8 +21,7 @@ data class MangaDetails(
val branches: Set<String?> val branches: Set<String?>
get() = chapters.keys get() = chapters.keys
val allChapters: List<MangaChapter> val allChapters: List<MangaChapter> by lazy { mergeChapters() }
get() = manga.chapters.orEmpty()
val isLocal val isLocal
get() = manga.isLocal get() = manga.isLocal
@@ -40,4 +39,26 @@ data class MangaDetails(
description = description, description = description,
isLoaded = isLoaded, isLoaded = isLoaded,
) )
private fun mergeChapters(): List<MangaChapter> {
val chapters = manga.chapters
val localChapters = local?.manga?.chapters.orEmpty()
if (chapters.isNullOrEmpty()) {
return localChapters
}
val localMap = if (localChapters.isNotEmpty()) {
localChapters.associateByTo(LinkedHashMap(localChapters.size)) { it.id }
} else {
null
}
val result = ArrayList<MangaChapter>(chapters.size)
for (chapter in chapters) {
val local = localMap?.remove(chapter.id)
result += local ?: chapter
}
if (!localMap.isNullOrEmpty()) {
result.addAll(localMap.values)
}
return result
}
} }

View File

@@ -91,7 +91,7 @@ class MangaPrefetchService : CoroutineIntentService() {
val intent = Intent(context, MangaPrefetchService::class.java) val intent = Intent(context, MangaPrefetchService::class.java)
intent.action = ACTION_PREFETCH_DETAILS intent.action = ACTION_PREFETCH_DETAILS
intent.putExtra(EXTRA_MANGA, ParcelableManga(manga)) intent.putExtra(EXTRA_MANGA, ParcelableManga(manga))
context.startService(intent) tryStart(context, intent)
} }
fun prefetchPages(context: Context, chapter: MangaChapter) { fun prefetchPages(context: Context, chapter: MangaChapter) {
@@ -99,19 +99,14 @@ class MangaPrefetchService : CoroutineIntentService() {
val intent = Intent(context, MangaPrefetchService::class.java) val intent = Intent(context, MangaPrefetchService::class.java)
intent.action = ACTION_PREFETCH_PAGES intent.action = ACTION_PREFETCH_PAGES
intent.putExtra(EXTRA_CHAPTER, ParcelableChapter(chapter)) intent.putExtra(EXTRA_CHAPTER, ParcelableChapter(chapter))
try { tryStart(context, intent)
context.startService(intent)
} catch (e: IllegalStateException) {
// probably app is in background
e.printStackTraceDebug()
}
} }
fun prefetchLast(context: Context) { fun prefetchLast(context: Context) {
if (!isPrefetchAvailable(context, null)) return if (!isPrefetchAvailable(context, null)) return
val intent = Intent(context, MangaPrefetchService::class.java) val intent = Intent(context, MangaPrefetchService::class.java)
intent.action = ACTION_PREFETCH_LAST intent.action = ACTION_PREFETCH_LAST
context.startService(intent) tryStart(context, intent)
} }
private fun isPrefetchAvailable(context: Context, source: MangaSource?): Boolean { private fun isPrefetchAvailable(context: Context, source: MangaSource?): Boolean {
@@ -127,5 +122,14 @@ class MangaPrefetchService : CoroutineIntentService() {
) )
return entryPoint.contentCache.isCachingEnabled && entryPoint.settings.isContentPrefetchEnabled return entryPoint.contentCache.isCachingEnabled && entryPoint.settings.isContentPrefetchEnabled
} }
private fun tryStart(context: Context, intent: Intent) {
try {
context.startService(intent)
} catch (e: IllegalStateException) {
// probably app is in background
e.printStackTraceDebug()
}
}
} }
} }

View File

@@ -19,7 +19,6 @@ import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.util.RecyclerViewScrollCallback import org.koitharu.kotatsu.core.util.RecyclerViewScrollCallback
import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.scaleUpActivityOptionsOf
import org.koitharu.kotatsu.databinding.FragmentChaptersBinding import org.koitharu.kotatsu.databinding.FragmentChaptersBinding
import org.koitharu.kotatsu.details.ui.adapter.ChaptersAdapter import org.koitharu.kotatsu.details.ui.adapter.ChaptersAdapter
import org.koitharu.kotatsu.details.ui.adapter.ChaptersSelectionDecoration import org.koitharu.kotatsu.details.ui.adapter.ChaptersSelectionDecoration

View File

@@ -1,39 +0,0 @@
package org.koitharu.kotatsu.details.ui.adapter
import android.graphics.Color
import android.text.Spannable
import android.text.style.ForegroundColorSpan
import android.text.style.RelativeSizeSpan
import androidx.core.text.buildSpannedString
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.util.ext.getThemeColor
import org.koitharu.kotatsu.databinding.ItemCheckableNewBinding
import org.koitharu.kotatsu.details.ui.model.MangaBranch
fun branchAD(
clickListener: OnListItemClickListener<MangaBranch>,
) = adapterDelegateViewBinding<MangaBranch, MangaBranch, ItemCheckableNewBinding>(
{ inflater, parent -> ItemCheckableNewBinding.inflate(inflater, parent, false) },
) {
val clickAdapter = AdapterDelegateClickListenerAdapter(this, clickListener)
itemView.setOnClickListener(clickAdapter)
val counterColorSpan = ForegroundColorSpan(context.getThemeColor(android.R.attr.textColorSecondary, Color.LTGRAY))
val counterSizeSpan = RelativeSizeSpan(0.86f)
bind {
binding.root.text = buildSpannedString {
append(item.name ?: getString(R.string.system_default))
append(' ')
append(' ')
val start = length
append(item.count.toString())
setSpan(counterColorSpan, start, length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
setSpan(counterSizeSpan, start, length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
}
binding.root.isChecked = item.isSelected
}
}

View File

@@ -6,7 +6,6 @@ import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.util.ext.drawableEnd
import org.koitharu.kotatsu.core.util.ext.drawableStart import org.koitharu.kotatsu.core.util.ext.drawableStart
import org.koitharu.kotatsu.core.util.ext.getThemeColor import org.koitharu.kotatsu.core.util.ext.getThemeColor
import org.koitharu.kotatsu.core.util.ext.textAndVisible import org.koitharu.kotatsu.core.util.ext.textAndVisible

View File

@@ -53,7 +53,7 @@ data class DownloadState(
private const val DATA_PROGRESS = "progress" private const val DATA_PROGRESS = "progress"
private const val DATA_CHAPTERS = "chapter_cnt" private const val DATA_CHAPTERS = "chapter_cnt"
private const val DATA_ETA = "eta" private const val DATA_ETA = "eta"
private const val DATA_TIMESTAMP = "timestamp" const val DATA_TIMESTAMP = "timestamp"
private const val DATA_ERROR = "error" private const val DATA_ERROR = "error"
private const val DATA_INDETERMINATE = "indeterminate" private const val DATA_INDETERMINATE = "indeterminate"
private const val DATA_PAUSED = "paused" private const val DATA_PAUSED = "paused"

View File

@@ -1,23 +1,33 @@
package org.koitharu.kotatsu.download.ui.list package org.koitharu.kotatsu.download.ui.list
import android.transition.TransitionManager
import android.view.View import android.view.View
import androidx.core.view.isGone
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.work.WorkInfo import androidx.work.WorkInfo
import coil.ImageLoader import coil.ImageLoader
import coil.request.SuccessResult import coil.request.SuccessResult
import coil.util.CoilUtils import coil.util.CoilUtils
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import org.koitharu.kotatsu.R 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.ui.image.TrimTransformation
import org.koitharu.kotatsu.core.util.ext.enqueueWith 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.newImageRequest
import org.koitharu.kotatsu.core.util.ext.source import org.koitharu.kotatsu.core.util.ext.source
import org.koitharu.kotatsu.core.util.ext.textAndVisible import org.koitharu.kotatsu.core.util.ext.textAndVisible
import org.koitharu.kotatsu.databinding.ItemDownloadBinding 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.ListModelDiffCallback
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.util.format import org.koitharu.kotatsu.parsers.util.format
@@ -30,14 +40,16 @@ fun downloadItemAD(
) { ) {
val percentPattern = context.resources.getString(R.string.percent_string_pattern) val percentPattern = context.resources.getString(R.string.percent_string_pattern)
// val expandIcon = ContextCompat.getDrawable(context, R.drawable.ic_expand_collapse) var chaptersJob: Job? = null
val clickListener = object : View.OnClickListener, View.OnLongClickListener { val clickListener = object : View.OnClickListener, View.OnLongClickListener {
override fun onClick(v: View) { override fun onClick(v: View) {
when (v.id) { when (v.id) {
R.id.button_cancel -> listener.onCancelClick(item) R.id.button_cancel -> listener.onCancelClick(item)
R.id.button_resume -> listener.onResumeClick(item) R.id.button_resume -> listener.onResumeClick(item, skip = false)
R.id.button_skip -> listener.onResumeClick(item, skip = true)
R.id.button_pause -> listener.onPauseClick(item) R.id.button_pause -> listener.onPauseClick(item)
R.id.imageView_expand -> listener.onExpandClick(item)
else -> listener.onItemClick(item, v) else -> listener.onItemClick(item, v)
} }
} }
@@ -46,31 +58,62 @@ fun downloadItemAD(
return listener.onItemLongClick(item, v) return listener.onItemLongClick(item, v)
} }
} }
val chaptersAdapter = BaseListAdapter<DownloadChapter>()
.addDelegate(ListItemType.CHAPTER, downloadChapterAD())
binding.recyclerViewChapters.addItemDecoration(DividerItemDecoration(context, RecyclerView.VERTICAL))
binding.recyclerViewChapters.adapter = chaptersAdapter
binding.buttonCancel.setOnClickListener(clickListener) binding.buttonCancel.setOnClickListener(clickListener)
binding.buttonPause.setOnClickListener(clickListener) binding.buttonPause.setOnClickListener(clickListener)
binding.buttonResume.setOnClickListener(clickListener) binding.buttonResume.setOnClickListener(clickListener)
binding.buttonSkip.setOnClickListener(clickListener)
binding.imageViewExpand.setOnClickListener(clickListener)
itemView.setOnClickListener(clickListener) itemView.setOnClickListener(clickListener)
itemView.setOnLongClickListener(clickListener) itemView.setOnLongClickListener(clickListener)
bind { payloads -> fun scrollToCurrentChapter() {
if (ListModelDiffCallback.PAYLOAD_CHECKED_CHANGED in payloads && context.isAnimationsEnabled) { val rv = binding.recyclerViewChapters
TransitionManager.beginDelayedTransition(binding.constraintLayout) if (!rv.isVisible) {
return
} }
binding.textViewTitle.text = item.manga.title val chapters = chaptersAdapter.items
if (chapters.isEmpty()) {
return
}
val targetPos = item.chaptersDownloaded.coerceIn(chapters.indices)
(rv.layoutManager as LinearLayoutManager).scrollToPositionWithOffset(targetPos, rv.height / 3)
}
bind { payloads ->
binding.textViewTitle.text = item.manga?.title ?: getString(R.string.unknown)
if ((CoilUtils.result(binding.imageViewCover) as? SuccessResult)?.memoryCacheKey != item.coverCacheKey) { if ((CoilUtils.result(binding.imageViewCover) as? SuccessResult)?.memoryCacheKey != item.coverCacheKey) {
binding.imageViewCover.newImageRequest(lifecycleOwner, item.manga.coverUrl)?.apply { binding.imageViewCover.newImageRequest(lifecycleOwner, item.manga?.coverUrl)?.apply {
placeholder(R.drawable.ic_placeholder) placeholder(R.drawable.ic_placeholder)
fallback(R.drawable.ic_placeholder) fallback(R.drawable.ic_placeholder)
error(R.drawable.ic_error_placeholder) error(R.drawable.ic_error_placeholder)
allowRgb565(true) allowRgb565(true)
transformations(TrimTransformation()) transformations(TrimTransformation())
memoryCacheKey(item.coverCacheKey) memoryCacheKey(item.coverCacheKey)
source(item.manga.source) source(item.manga?.source)
enqueueWith(coil) enqueueWith(coil)
} }
} }
// binding.textViewTitle.isChecked = item.isExpanded if (chaptersJob == null || payloads.isEmpty()) {
// binding.textViewTitle.drawableEnd = if (item.isExpandable) expandIcon else null chaptersJob?.cancel()
chaptersJob = lifecycleOwner.lifecycleScope.launch(start = CoroutineStart.UNDISPATCHED) {
item.chapters.collect { chapters ->
binding.imageViewExpand.isGone = chapters.isNullOrEmpty()
chaptersAdapter.emit(chapters)
scrollToCurrentChapter()
}
}
} else if (ListModelDiffCallback.PAYLOAD_CHECKED_CHANGED in payloads) {
binding.recyclerViewChapters.post {
scrollToCurrentChapter()
}
}
binding.imageViewExpand.isChecked = item.isExpanded
binding.recyclerViewChapters.isVisible = item.isExpanded
when (item.workState) { when (item.workState) {
WorkInfo.State.ENQUEUED, WorkInfo.State.ENQUEUED,
WorkInfo.State.BLOCKED -> { WorkInfo.State.BLOCKED -> {
@@ -82,6 +125,7 @@ fun downloadItemAD(
binding.textViewDetails.isVisible = false binding.textViewDetails.isVisible = false
binding.buttonCancel.isVisible = true binding.buttonCancel.isVisible = true
binding.buttonResume.isVisible = false binding.buttonResume.isVisible = false
binding.buttonSkip.isVisible = false
binding.buttonPause.isVisible = false binding.buttonPause.isVisible = false
} }
@@ -96,9 +140,10 @@ fun downloadItemAD(
binding.progressBar.setProgressCompat(item.progress, payloads.isNotEmpty()) binding.progressBar.setProgressCompat(item.progress, payloads.isNotEmpty())
binding.textViewPercent.text = percentPattern.format((item.percent * 100f).format(1)) binding.textViewPercent.text = percentPattern.format((item.percent * 100f).format(1))
binding.textViewPercent.isVisible = true binding.textViewPercent.isVisible = true
binding.textViewDetails.textAndVisible = item.getEtaString() binding.textViewDetails.textAndVisible = if (item.isPaused) item.error else item.getEtaString()
binding.buttonCancel.isVisible = true binding.buttonCancel.isVisible = true
binding.buttonResume.isVisible = item.isPaused binding.buttonResume.isVisible = item.isPaused
binding.buttonSkip.isVisible = item.isPaused && item.error != null
binding.buttonPause.isVisible = item.canPause binding.buttonPause.isVisible = item.canPause
} }
@@ -120,6 +165,7 @@ fun downloadItemAD(
} }
binding.buttonCancel.isVisible = false binding.buttonCancel.isVisible = false
binding.buttonResume.isVisible = false binding.buttonResume.isVisible = false
binding.buttonSkip.isVisible = false
binding.buttonPause.isVisible = false binding.buttonPause.isVisible = false
} }
@@ -132,6 +178,7 @@ fun downloadItemAD(
binding.textViewDetails.textAndVisible = item.error binding.textViewDetails.textAndVisible = item.error
binding.buttonCancel.isVisible = false binding.buttonCancel.isVisible = false
binding.buttonResume.isVisible = false binding.buttonResume.isVisible = false
binding.buttonSkip.isVisible = false
binding.buttonPause.isVisible = false binding.buttonPause.isVisible = false
} }
@@ -144,6 +191,7 @@ fun downloadItemAD(
binding.textViewDetails.isVisible = false binding.textViewDetails.isVisible = false
binding.buttonCancel.isVisible = false binding.buttonCancel.isVisible = false
binding.buttonResume.isVisible = false binding.buttonResume.isVisible = false
binding.buttonSkip.isVisible = false
binding.buttonPause.isVisible = false binding.buttonPause.isVisible = false
} }
} }

View File

@@ -8,5 +8,7 @@ interface DownloadItemListener : OnListItemClickListener<DownloadItemModel> {
fun onPauseClick(item: DownloadItemModel) fun onPauseClick(item: DownloadItemModel)
fun onResumeClick(item: DownloadItemModel) fun onResumeClick(item: DownloadItemModel, skip: Boolean)
fun onExpandClick(item: DownloadItemModel)
} }

View File

@@ -3,6 +3,8 @@ package org.koitharu.kotatsu.download.ui.list
import android.text.format.DateUtils import android.text.format.DateUtils
import androidx.work.WorkInfo import androidx.work.WorkInfo
import coil.memory.MemoryCache import coil.memory.MemoryCache
import kotlinx.coroutines.flow.StateFlow
import org.koitharu.kotatsu.download.ui.list.chapters.DownloadChapter
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
@@ -14,7 +16,7 @@ data class DownloadItemModel(
val workState: WorkInfo.State, val workState: WorkInfo.State,
val isIndeterminate: Boolean, val isIndeterminate: Boolean,
val isPaused: Boolean, val isPaused: Boolean,
val manga: Manga, val manga: Manga?,
val error: String?, val error: String?,
val max: Int, val max: Int,
val progress: Int, val progress: Int,
@@ -22,9 +24,10 @@ data class DownloadItemModel(
val timestamp: Date, val timestamp: Date,
val chaptersDownloaded: Int, val chaptersDownloaded: Int,
val isExpanded: Boolean, val isExpanded: Boolean,
val chapters: StateFlow<List<DownloadChapter>?>,
) : ListModel, Comparable<DownloadItemModel> { ) : ListModel, Comparable<DownloadItemModel> {
val coverCacheKey = MemoryCache.Key(manga.coverUrl, mapOf("dl" to "1")) val coverCacheKey = MemoryCache.Key(manga?.coverUrl.orEmpty(), mapOf("dl" to "1"))
val percent: Float val percent: Float
get() = if (max > 0) progress / max.toFloat() else 0f get() = if (max > 0) progress / max.toFloat() else 0f
@@ -38,9 +41,6 @@ data class DownloadItemModel(
val canResume: Boolean val canResume: Boolean
get() = workState == WorkInfo.State.RUNNING && isPaused get() = workState == WorkInfo.State.RUNNING && isPaused
val isExpandable: Boolean
get() = false // TODO
fun getEtaString(): CharSequence? = if (hasEta) { fun getEtaString(): CharSequence? = if (hasEta) {
DateUtils.getRelativeTimeSpanString( DateUtils.getRelativeTimeSpanString(
eta, eta,

View File

@@ -84,17 +84,19 @@ class DownloadsActivity : BaseActivity<ActivityDownloadsBinding>(),
if (selectionController.onItemClick(item.id.mostSignificantBits)) { if (selectionController.onItemClick(item.id.mostSignificantBits)) {
return return
} }
if (item.isExpandable) { startActivity(DetailsActivity.newIntent(view.context, item.manga ?: return))
viewModel.expandCollapse(item)
} else {
startActivity(DetailsActivity.newIntent(view.context, item.manga))
}
} }
override fun onItemLongClick(item: DownloadItemModel, view: View): Boolean { override fun onItemLongClick(item: DownloadItemModel, view: View): Boolean {
return selectionController.onItemLongClick(item.id.mostSignificantBits) return selectionController.onItemLongClick(item.id.mostSignificantBits)
} }
override fun onExpandClick(item: DownloadItemModel) {
if (!selectionController.onItemClick(item.id.mostSignificantBits)) {
viewModel.expandCollapse(item)
}
}
override fun onCancelClick(item: DownloadItemModel) { override fun onCancelClick(item: DownloadItemModel) {
viewModel.cancel(item.id) viewModel.cancel(item.id)
} }
@@ -103,8 +105,8 @@ class DownloadsActivity : BaseActivity<ActivityDownloadsBinding>(),
sendBroadcast(PausingReceiver.getPauseIntent(this, item.id)) sendBroadcast(PausingReceiver.getPauseIntent(this, item.id))
} }
override fun onResumeClick(item: DownloadItemModel) { override fun onResumeClick(item: DownloadItemModel, skip: Boolean) {
sendBroadcast(PausingReceiver.getResumeIntent(this, item.id)) sendBroadcast(PausingReceiver.getResumeIntent(this, item.id, skip))
} }
override fun onSelectionChanged(controller: ListSelectionController, count: Int) { override fun onSelectionChanged(controller: ListSelectionController, count: Int) {

View File

@@ -1,16 +1,19 @@
package org.koitharu.kotatsu.download.ui.list package org.koitharu.kotatsu.download.ui.list
import androidx.collection.ArrayMap
import androidx.collection.LongSparseArray import androidx.collection.LongSparseArray
import androidx.collection.getOrElse import androidx.collection.getOrElse
import androidx.collection.set import androidx.collection.set
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.work.Data
import androidx.work.WorkInfo import androidx.work.WorkInfo
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
@@ -27,12 +30,17 @@ import org.koitharu.kotatsu.core.ui.util.ReversibleAction
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.ext.call import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.core.util.ext.daysDiff import org.koitharu.kotatsu.core.util.ext.daysDiff
import org.koitharu.kotatsu.core.util.ext.isEmpty
import org.koitharu.kotatsu.download.domain.DownloadState 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.download.ui.worker.DownloadWorker
import org.koitharu.kotatsu.list.ui.model.EmptyState import org.koitharu.kotatsu.list.ui.model.EmptyState
import org.koitharu.kotatsu.list.ui.model.ListHeader import org.koitharu.kotatsu.list.ui.model.ListHeader
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.LoadingState import org.koitharu.kotatsu.list.ui.model.LoadingState
import org.koitharu.kotatsu.local.data.LocalMangaRepository
import org.koitharu.kotatsu.local.data.LocalStorageChanges
import org.koitharu.kotatsu.local.domain.model.LocalManga
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.mapToSet import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
@@ -47,11 +55,15 @@ class DownloadsViewModel @Inject constructor(
private val workScheduler: DownloadWorker.Scheduler, private val workScheduler: DownloadWorker.Scheduler,
private val mangaDataRepository: MangaDataRepository, private val mangaDataRepository: MangaDataRepository,
private val mangaRepositoryFactory: MangaRepository.Factory, private val mangaRepositoryFactory: MangaRepository.Factory,
@LocalStorageChanges private val localStorageChanges: MutableSharedFlow<LocalManga?>,
private val localMangaRepository: LocalMangaRepository,
) : BaseViewModel() { ) : BaseViewModel() {
private val mangaCache = LongSparseArray<Manga>() private val mangaCache = LongSparseArray<Manga>()
private val cacheMutex = Mutex() private val cacheMutex = Mutex()
private val expanded = MutableStateFlow(emptySet<UUID>()) private val expanded = MutableStateFlow(emptySet<UUID>())
private val chaptersCache = ArrayMap<UUID, StateFlow<List<DownloadChapter>?>>()
private val works = combine( private val works = combine(
workScheduler.observeWorks(), workScheduler.observeWorks(),
expanded, expanded,
@@ -131,7 +143,7 @@ class DownloadsViewModel @Inject constructor(
var isResumed = false var isResumed = false
for (work in snapshot) { for (work in snapshot) {
if (work.workState == WorkInfo.State.RUNNING && work.isPaused) { if (work.workState == WorkInfo.State.RUNNING && work.isPaused) {
workScheduler.resume(work.id) workScheduler.resume(work.id, skipError = false)
isResumed = true isResumed = true
} }
} }
@@ -144,7 +156,7 @@ class DownloadsViewModel @Inject constructor(
val snapshot = works.value ?: return val snapshot = works.value ?: return
for (work in snapshot) { for (work in snapshot) {
if (work.id.mostSignificantBits in ids) { if (work.id.mostSignificantBits in ids) {
workScheduler.resume(work.id) workScheduler.resume(work.id, skipError = false)
} }
} }
onActionDone.call(ReversibleAction(R.string.downloads_resumed, null)) onActionDone.call(ReversibleAction(R.string.downloads_resumed, null))
@@ -234,10 +246,18 @@ class DownloadsViewModel @Inject constructor(
} }
private suspend fun WorkInfo.toUiModel(isExpanded: Boolean): DownloadItemModel? { private suspend fun WorkInfo.toUiModel(isExpanded: Boolean): DownloadItemModel? {
val workData = if (outputData == Data.EMPTY) progress else outputData val workData = outputData.takeUnless { it.isEmpty }
?: progress.takeUnless { it.isEmpty }
?: workScheduler.getInputData(id)
?: return null
val mangaId = DownloadState.getMangaId(workData) val mangaId = DownloadState.getMangaId(workData)
if (mangaId == 0L) return null if (mangaId == 0L) return null
val manga = getManga(mangaId) ?: return null val manga = getManga(mangaId) ?: return null
val chapters = synchronized(chaptersCache) {
chaptersCache.getOrPut(id) {
observeChapters(manga, id)
}
}
return DownloadItemModel( return DownloadItemModel(
id = id, id = id,
workState = state, workState = state,
@@ -251,6 +271,7 @@ class DownloadsViewModel @Inject constructor(
timestamp = DownloadState.getTimestamp(workData), timestamp = DownloadState.getTimestamp(workData),
chaptersDownloaded = DownloadState.getDownloadedChapters(workData), chaptersDownloaded = DownloadState.getDownloadedChapters(workData),
isExpanded = isExpanded, isExpanded = isExpanded,
chapters = chapters,
) )
} }
@@ -282,16 +303,42 @@ class DownloadsViewModel @Inject constructor(
} }
return cacheMutex.withLock { return cacheMutex.withLock {
mangaCache.getOrElse(mangaId) { mangaCache.getOrElse(mangaId) {
mangaDataRepository.findMangaById(mangaId)?.let { mangaDataRepository.findMangaById(mangaId)?.also {
tryLoad(it) ?: it
}?.also {
mangaCache[mangaId] = it mangaCache[mangaId] = it
} ?: return null } ?: return null
} }
} }
} }
private fun observeChapters(manga: Manga, workId: UUID): StateFlow<List<DownloadChapter>?> = flow {
val chapterIds = workScheduler.getInputChaptersIds(workId)?.toSet()
val chapters = (tryLoad(manga) ?: manga).chapters ?: return@flow
suspend fun mapChapters(): List<DownloadChapter> {
val size = chapterIds?.size ?: chapters.size
val localChapters =
localMangaRepository.findSavedManga(manga)?.manga?.chapters?.mapToSet { it.id }.orEmpty()
return chapters.mapNotNullTo(ArrayList(size)) {
if (chapterIds == null || it.id in chapterIds) {
DownloadChapter(
number = it.number,
name = it.name,
isDownloaded = it.id in localChapters,
)
} else {
null
}
}
}
emit(mapChapters())
localStorageChanges.collect {
if (it?.manga?.id == manga.id) {
emit(mapChapters())
}
}
}.stateIn(viewModelScope, SharingStarted.Eagerly, null)
private suspend fun tryLoad(manga: Manga) = runCatchingCancellable { private suspend fun tryLoad(manga: Manga) = runCatchingCancellable {
(mangaRepositoryFactory.create(manga.source) as RemoteMangaRepository).peekDetails(manga) (mangaRepositoryFactory.create(manga.source) as RemoteMangaRepository).getDetails(manga)
}.getOrNull() }.getOrNull()
} }

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.download.ui.list.chapters package org.koitharu.kotatsu.download.ui.list.chapters
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
data class DownloadChapter( data class DownloadChapter(
@@ -11,4 +12,12 @@ data class DownloadChapter(
override fun areItemsTheSame(other: ListModel): Boolean { override fun areItemsTheSame(other: ListModel): Boolean {
return other is DownloadChapter && other.name == name return other is DownloadChapter && other.name == name
} }
override fun getChangePayload(previousState: ListModel): Any? {
return if (previousState is DownloadChapter && previousState.name == name && previousState.number == number) {
ListModelDiffCallback.PAYLOAD_PROGRESS_CHANGED
} else {
super.getChangePayload(previousState)
}
}
} }

View File

@@ -82,7 +82,15 @@ class DownloadNotificationFactory @AssistedInject constructor(
NotificationCompat.Action( NotificationCompat.Action(
R.drawable.ic_action_resume, R.drawable.ic_action_resume,
context.getString(R.string.resume), context.getString(R.string.resume),
PausingReceiver.createResumePendingIntent(context, uuid), PausingReceiver.createResumePendingIntent(context, uuid, skipError = false),
)
}
private val actionSkip by lazy {
NotificationCompat.Action(
R.drawable.ic_action_skip,
context.getString(R.string.skip),
PausingReceiver.createResumePendingIntent(context, uuid, skipError = true),
) )
} }
@@ -163,6 +171,9 @@ class DownloadNotificationFactory @AssistedInject constructor(
builder.setSmallIcon(R.drawable.ic_stat_paused) builder.setSmallIcon(R.drawable.ic_stat_paused)
builder.addAction(actionCancel) builder.addAction(actionCancel)
builder.addAction(actionResume) builder.addAction(actionResume)
if (state.error != null) {
builder.addAction(actionSkip)
}
} }
state.error != null -> { // error, final state state.error != null -> { // error, final state

View File

@@ -0,0 +1,28 @@
package org.koitharu.kotatsu.download.ui.worker
import kotlinx.coroutines.delay
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.parsers.model.MangaSource
class DownloadSlowdownDispatcher(
private val mangaRepositoryFactory: MangaRepository.Factory,
private val defaultDelay: Long,
) {
private val timeMap = HashMap<MangaSource, Long>()
suspend fun delay(source: MangaSource) {
val repo = mangaRepositoryFactory.create(source) as? RemoteMangaRepository ?: return
if (!repo.isSlowdownEnabled()) {
return
}
val lastRequest = synchronized(timeMap) {
val res = timeMap[source] ?: 0L
timeMap[source] = System.currentTimeMillis()
res
}
if (lastRequest != 0L) {
delay(lastRequest + defaultDelay - System.currentTimeMillis())
}
}
}

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.download.ui.worker package org.koitharu.kotatsu.download.ui.worker
import android.annotation.SuppressLint
import android.app.NotificationManager import android.app.NotificationManager
import android.content.Context import android.content.Context
import android.content.pm.ServiceInfo import android.content.pm.ServiceInfo
@@ -7,7 +8,6 @@ import android.os.Build
import android.webkit.MimeTypeMap import android.webkit.MimeTypeMap
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.hilt.work.HiltWorker import androidx.hilt.work.HiltWorker
import androidx.lifecycle.asFlow
import androidx.work.BackoffPolicy import androidx.work.BackoffPolicy
import androidx.work.Constraints import androidx.work.Constraints
import androidx.work.CoroutineWorker import androidx.work.CoroutineWorker
@@ -29,6 +29,10 @@ import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
@@ -52,6 +56,8 @@ import org.koitharu.kotatsu.core.util.ext.deleteAwait
import org.koitharu.kotatsu.core.util.ext.deleteWork import org.koitharu.kotatsu.core.util.ext.deleteWork
import org.koitharu.kotatsu.core.util.ext.deleteWorks import org.koitharu.kotatsu.core.util.ext.deleteWorks
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
import org.koitharu.kotatsu.core.util.ext.getWorkInputData
import org.koitharu.kotatsu.core.util.ext.getWorkSpec
import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.writeAllCancellable import org.koitharu.kotatsu.core.util.ext.writeAllCancellable
@@ -60,6 +66,7 @@ import org.koitharu.kotatsu.download.domain.DownloadState
import org.koitharu.kotatsu.local.data.LocalMangaRepository import org.koitharu.kotatsu.local.data.LocalMangaRepository
import org.koitharu.kotatsu.local.data.LocalStorageChanges import org.koitharu.kotatsu.local.data.LocalStorageChanges
import org.koitharu.kotatsu.local.data.PagesCache import org.koitharu.kotatsu.local.data.PagesCache
import org.koitharu.kotatsu.local.data.TempFileFilter
import org.koitharu.kotatsu.local.data.input.LocalMangaInput import org.koitharu.kotatsu.local.data.input.LocalMangaInput
import org.koitharu.kotatsu.local.data.output.LocalMangaOutput import org.koitharu.kotatsu.local.data.output.LocalMangaOutput
import org.koitharu.kotatsu.local.domain.model.LocalManga import org.koitharu.kotatsu.local.domain.model.LocalManga
@@ -72,6 +79,7 @@ import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import java.io.File import java.io.File
import java.util.UUID import java.util.UUID
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
import javax.inject.Inject import javax.inject.Inject
@HiltWorker @HiltWorker
@@ -82,7 +90,6 @@ class DownloadWorker @AssistedInject constructor(
private val cache: PagesCache, private val cache: PagesCache,
private val localMangaRepository: LocalMangaRepository, private val localMangaRepository: LocalMangaRepository,
private val mangaDataRepository: MangaDataRepository, private val mangaDataRepository: MangaDataRepository,
private val settings: AppSettings,
private val mangaRepositoryFactory: MangaRepository.Factory, private val mangaRepositoryFactory: MangaRepository.Factory,
@LocalStorageChanges private val localStorageChanges: MutableSharedFlow<LocalManga?>, @LocalStorageChanges private val localStorageChanges: MutableSharedFlow<LocalManga?>,
notificationFactoryFactory: DownloadNotificationFactory.Factory, notificationFactoryFactory: DownloadNotificationFactory.Factory,
@@ -90,16 +97,15 @@ class DownloadWorker @AssistedInject constructor(
private val notificationFactory = notificationFactoryFactory.create(params.id) private val notificationFactory = notificationFactoryFactory.create(params.id)
private val notificationManager = appContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager private val notificationManager = appContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
private val slowdownDispatcher = DownloadSlowdownDispatcher(mangaRepositoryFactory, SLOWDOWN_DELAY)
@Volatile @Volatile
private var lastPublishedState: DownloadState? = null private var lastPublishedState: DownloadState? = null
private val currentState: DownloadState private val currentState: DownloadState
get() = checkNotNull(lastPublishedState) get() = checkNotNull(lastPublishedState)
private val pausingHandle = PausingHandle()
private val timeLeftEstimator = TimeLeftEstimator() private val timeLeftEstimator = TimeLeftEstimator()
private val notificationThrottler = Throttler(400) private val notificationThrottler = Throttler(400)
private val pausingReceiver = PausingReceiver(params.id, pausingHandle)
override suspend fun doWork(): Result { override suspend fun doWork(): Result {
setForeground(getForegroundInfo()) setForeground(getForegroundInfo())
@@ -110,14 +116,18 @@ class DownloadWorker @AssistedInject constructor(
val chaptersIds = inputData.getLongArray(CHAPTERS_IDS)?.takeUnless { it.isEmpty() } val chaptersIds = inputData.getLongArray(CHAPTERS_IDS)?.takeUnless { it.isEmpty() }
val downloadedIds = getDoneChapters(manga) val downloadedIds = getDoneChapters(manga)
return try { return try {
downloadMangaImpl(manga, chaptersIds, downloadedIds) withContext(PausingHandle()) {
downloadMangaImpl(manga, chaptersIds, downloadedIds)
}
Result.success(currentState.toWorkData()) Result.success(currentState.toWorkData())
} catch (e: CancellationException) { } catch (e: CancellationException) {
withContext(NonCancellable) { withContext(NonCancellable) {
val notification = notificationFactory.create(currentState.copy(isStopped = true)) val notification = notificationFactory.create(currentState.copy(isStopped = true))
notificationManager.notify(id.hashCode(), notification) notificationManager.notify(id.hashCode(), notification)
} }
throw e Result.failure(
currentState.copy(eta = -1L).toWorkData(),
)
} catch (e: IOException) { } catch (e: IOException) {
e.printStackTraceDebug() e.printStackTraceDebug()
Result.retry() Result.retry()
@@ -154,6 +164,7 @@ class DownloadWorker @AssistedInject constructor(
) { ) {
var manga = subject var manga = subject
val chaptersToSkip = excludedIds.toMutableSet() val chaptersToSkip = excludedIds.toMutableSet()
val pausingReceiver = PausingReceiver(id, PausingHandle.current())
withMangaLock(manga) { withMangaLock(manga) {
ContextCompat.registerReceiver( ContextCompat.registerReceiver(
applicationContext, applicationContext,
@@ -163,7 +174,6 @@ class DownloadWorker @AssistedInject constructor(
) )
val destination = localMangaRepository.getOutputDir(manga) val destination = localMangaRepository.getOutputDir(manga)
checkNotNull(destination) { applicationContext.getString(R.string.cannot_find_available_storage) } checkNotNull(destination) { applicationContext.getString(R.string.cannot_find_available_storage) }
val tempFileName = "${manga.id}_$id.tmp"
var output: LocalMangaOutput? = null var output: LocalMangaOutput? = null
try { try {
if (manga.source == MangaSource.LOCAL) { if (manga.source == MangaSource.LOCAL) {
@@ -175,45 +185,57 @@ class DownloadWorker @AssistedInject constructor(
output = LocalMangaOutput.getOrCreate(destination, mangaDetails) output = LocalMangaOutput.getOrCreate(destination, mangaDetails)
val coverUrl = mangaDetails.largeCoverUrl.ifNullOrEmpty { mangaDetails.coverUrl } val coverUrl = mangaDetails.largeCoverUrl.ifNullOrEmpty { mangaDetails.coverUrl }
if (coverUrl.isNotEmpty()) { if (coverUrl.isNotEmpty()) {
downloadFile(coverUrl, destination, tempFileName, repo.source).let { file -> downloadFile(coverUrl, destination, repo.source).let { file ->
output.addCover(file, MimeTypeMap.getFileExtensionFromUrl(coverUrl)) output.addCover(file, MimeTypeMap.getFileExtensionFromUrl(coverUrl))
file.deleteAwait()
} }
} }
val chapters = getChapters(mangaDetails, includedIds) val chapters = getChapters(mangaDetails, includedIds)
for ((chapterIndex, chapter) in chapters.withIndex()) { for ((chapterIndex, chapter) in chapters.withIndex()) {
checkIsPaused()
if (chaptersToSkip.remove(chapter.id)) { if (chaptersToSkip.remove(chapter.id)) {
publishState(currentState.copy(downloadedChapters = currentState.downloadedChapters + 1)) publishState(currentState.copy(downloadedChapters = currentState.downloadedChapters + 1))
continue continue
} }
val pages = runFailsafe(pausingHandle) { val pages = runFailsafe {
repo.getPages(chapter) repo.getPages(chapter)
} } ?: continue
for ((pageIndex, page) in pages.withIndex()) { val pageCounter = AtomicInteger(0)
runFailsafe(pausingHandle) { channelFlow {
val url = repo.getPageUrl(page) val semaphore = Semaphore(MAX_PAGES_PARALLELISM)
val file = cache.get(url) for ((pageIndex, page) in pages.withIndex()) {
?: downloadFile(url, destination, tempFileName, repo.source) checkIsPaused()
output.addPage( launch {
chapter = chapter, semaphore.withPermit {
file = file, runFailsafe {
pageNumber = pageIndex, val url = repo.getPageUrl(page)
ext = MimeTypeMap.getFileExtensionFromUrl(url), val file = cache.get(url)
) ?: downloadFile(url, destination, repo.source)
output.addPage(
chapter = chapter,
file = file,
pageNumber = pageIndex,
ext = MimeTypeMap.getFileExtensionFromUrl(url),
)
if (file.extension == "tmp") {
file.deleteAwait()
}
}
send(pageIndex)
}
}
} }
}.collect {
publishState( publishState(
currentState.copy( currentState.copy(
totalChapters = chapters.size, totalChapters = chapters.size,
currentChapter = chapterIndex, currentChapter = chapterIndex,
totalPages = pages.size, totalPages = pages.size,
currentPage = pageIndex, currentPage = pageCounter.incrementAndGet(),
isIndeterminate = false, isIndeterminate = false,
eta = timeLeftEstimator.getEta(), eta = timeLeftEstimator.getEta(),
), ),
) )
if (settings.isDownloadsSlowdownEnabled) {
delay(SLOWDOWN_DELAY)
}
} }
if (output.flushChapter(chapter)) { if (output.flushChapter(chapter)) {
runCatchingCancellable { runCatchingCancellable {
@@ -238,21 +260,18 @@ class DownloadWorker @AssistedInject constructor(
applicationContext.unregisterReceiver(pausingReceiver) applicationContext.unregisterReceiver(pausingReceiver)
output?.closeQuietly() output?.closeQuietly()
output?.cleanup() output?.cleanup()
File(destination, tempFileName).deleteAwait() destination.listFiles(TempFileFilter())?.forEach {
it.deleteAwait()
}
} }
} }
} }
} }
private suspend fun <R> runFailsafe( private suspend fun <R> runFailsafe(
pausingHandle: PausingHandle,
block: suspend () -> R, block: suspend () -> R,
): R { ): R? {
if (pausingHandle.isPaused) { checkIsPaused()
publishState(currentState.copy(isPaused = true, eta = -1L))
pausingHandle.awaitResumed()
publishState(currentState.copy(isPaused = false))
}
var countDown = MAX_FAILSAFE_ATTEMPTS var countDown = MAX_FAILSAFE_ATTEMPTS
failsafe@ while (true) { failsafe@ while (true) {
try { try {
@@ -267,9 +286,16 @@ class DownloadWorker @AssistedInject constructor(
), ),
) )
countDown = MAX_FAILSAFE_ATTEMPTS countDown = MAX_FAILSAFE_ATTEMPTS
val pausingHandle = PausingHandle.current()
pausingHandle.pause() pausingHandle.pause()
pausingHandle.awaitResumed() try {
publishState(currentState.copy(isPaused = false, error = null)) pausingHandle.awaitResumed()
if (pausingHandle.skipCurrentError()) {
return null
}
} finally {
publishState(currentState.copy(isPaused = false, error = null))
}
} else { } else {
countDown-- countDown--
val retryDelay = if (e is TooManyRequestExceptions) { val retryDelay = if (e is TooManyRequestExceptions) {
@@ -283,10 +309,21 @@ class DownloadWorker @AssistedInject constructor(
} }
} }
private suspend fun checkIsPaused() {
val pausingHandle = PausingHandle.current()
if (pausingHandle.isPaused) {
publishState(currentState.copy(isPaused = true, eta = -1L))
try {
pausingHandle.awaitResumed()
} finally {
publishState(currentState.copy(isPaused = false))
}
}
}
private suspend fun downloadFile( private suspend fun downloadFile(
url: String, url: String,
destination: File, destination: File,
tempFileName: String,
source: MangaSource, source: MangaSource,
): File { ): File {
val request = Request.Builder() val request = Request.Builder()
@@ -296,13 +333,19 @@ class DownloadWorker @AssistedInject constructor(
.cacheControl(CommonHeaders.CACHE_CONTROL_NO_STORE) .cacheControl(CommonHeaders.CACHE_CONTROL_NO_STORE)
.get() .get()
.build() .build()
slowdownDispatcher.delay(source)
val call = okHttp.newCall(request) val call = okHttp.newCall(request)
val file = File(destination, tempFileName) val file = File(destination, UUID.randomUUID().toString() + ".tmp")
val response = call.clone().await() try {
checkNotNull(response.body).use { body -> val response = call.clone().await()
file.sink(append = false).buffer().use { checkNotNull(response.body).use { body ->
it.writeAllCancellable(body.source()) file.sink(append = false).buffer().use {
it.writeAllCancellable(body.source())
}
} }
} catch (e: CancellationException) {
file.delete()
throw e
} }
return file return file
} }
@@ -385,8 +428,20 @@ class DownloadWorker @AssistedInject constructor(
} }
fun observeWorks(): Flow<List<WorkInfo>> = workManager fun observeWorks(): Flow<List<WorkInfo>> = workManager
.getWorkInfosByTagLiveData(TAG) .getWorkInfosByTagFlow(TAG)
.asFlow()
@SuppressLint("RestrictedApi")
suspend fun getInputData(id: UUID): Data? {
val spec = workManager.getWorkSpec(id) ?: return null
return Data.Builder()
.putAll(spec.input)
.putLong(DownloadState.DATA_TIMESTAMP, spec.scheduleRequestedAt)
.build()
}
suspend fun getInputChaptersIds(workId: UUID): LongArray? {
return workManager.getWorkInputData(workId)?.getLongArray(CHAPTERS_IDS)?.takeUnless { it.isEmpty() }
}
suspend fun cancel(id: UUID) { suspend fun cancel(id: UUID) {
workManager.cancelWorkById(id).await() workManager.cancelWorkById(id).await()
@@ -401,8 +456,8 @@ class DownloadWorker @AssistedInject constructor(
context.sendBroadcast(intent) context.sendBroadcast(intent)
} }
fun resume(id: UUID) { fun resume(id: UUID, skipError: Boolean) {
val intent = PausingReceiver.getResumeIntent(context, id) val intent = PausingReceiver.getResumeIntent(context, id, skipError)
context.sendBroadcast(intent) context.sendBroadcast(intent)
} }
@@ -463,8 +518,9 @@ class DownloadWorker @AssistedInject constructor(
private companion object { private companion object {
const val MAX_FAILSAFE_ATTEMPTS = 2 const val MAX_FAILSAFE_ATTEMPTS = 2
const val MAX_PAGES_PARALLELISM = 4
const val DOWNLOAD_ERROR_DELAY = 500L const val DOWNLOAD_ERROR_DELAY = 500L
const val SLOWDOWN_DELAY = 100L const val SLOWDOWN_DELAY = 200L
const val MANGA_ID = "manga_id" const val MANGA_ID = "manga_id"
const val CHAPTERS_IDS = "chapters" const val CHAPTERS_IDS = "chapters"
const val TAG = "download" const val TAG = "download"

View File

@@ -1,13 +1,16 @@
package org.koitharu.kotatsu.download.ui.worker package org.koitharu.kotatsu.download.ui.worker
import androidx.annotation.AnyThread import androidx.annotation.AnyThread
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlin.coroutines.AbstractCoroutineContextElement
import kotlin.coroutines.CoroutineContext
class PausingHandle { class PausingHandle : AbstractCoroutineContextElement(PausingHandle) {
private val paused = MutableStateFlow(false) private val paused = MutableStateFlow(false)
private val isSkipError = MutableStateFlow(false)
@get:AnyThread @get:AnyThread
val isPaused: Boolean val isPaused: Boolean
@@ -15,7 +18,7 @@ class PausingHandle {
@AnyThread @AnyThread
suspend fun awaitResumed() { suspend fun awaitResumed() {
paused.filter { !it }.first() paused.first { !it }
} }
@AnyThread @AnyThread
@@ -24,7 +27,23 @@ class PausingHandle {
} }
@AnyThread @AnyThread
fun resume() { fun resume(skipError: Boolean) {
isSkipError.value = skipError
paused.value = false paused.value = false
} }
suspend fun yield() {
if (paused.value) {
paused.first { !it }
}
}
fun skipCurrentError(): Boolean = isSkipError.compareAndSet(expect = true, update = false)
companion object : CoroutineContext.Key<PausingHandle> {
suspend fun current() = checkNotNull(currentCoroutineContext()[this]) {
"PausingHandle not found in current context"
}
}
} }

View File

@@ -21,7 +21,8 @@ class PausingReceiver(
return return
} }
when (intent.action) { when (intent.action) {
ACTION_RESUME -> pausingHandle.resume() ACTION_RESUME -> pausingHandle.resume(skipError = false)
ACTION_SKIP -> pausingHandle.resume(skipError = true)
ACTION_PAUSE -> pausingHandle.pause() ACTION_PAUSE -> pausingHandle.pause()
} }
} }
@@ -30,12 +31,14 @@ class PausingReceiver(
private const val ACTION_PAUSE = "org.koitharu.kotatsu.download.PAUSE" private const val ACTION_PAUSE = "org.koitharu.kotatsu.download.PAUSE"
private const val ACTION_RESUME = "org.koitharu.kotatsu.download.RESUME" private const val ACTION_RESUME = "org.koitharu.kotatsu.download.RESUME"
private const val ACTION_SKIP = "org.koitharu.kotatsu.download.SKIP"
private const val EXTRA_UUID = "uuid" private const val EXTRA_UUID = "uuid"
private const val SCHEME = "workuid" private const val SCHEME = "workuid"
fun createIntentFilter(id: UUID) = IntentFilter().apply { fun createIntentFilter(id: UUID) = IntentFilter().apply {
addAction(ACTION_PAUSE) addAction(ACTION_PAUSE)
addAction(ACTION_RESUME) addAction(ACTION_RESUME)
addAction(ACTION_SKIP)
addDataScheme(SCHEME) addDataScheme(SCHEME)
addDataPath(id.toString(), PatternMatcher.PATTERN_SIMPLE_GLOB) addDataPath(id.toString(), PatternMatcher.PATTERN_SIMPLE_GLOB)
} }
@@ -45,8 +48,9 @@ class PausingReceiver(
.setPackage(context.packageName) .setPackage(context.packageName)
.putExtra(EXTRA_UUID, id.toString()) .putExtra(EXTRA_UUID, id.toString())
fun getResumeIntent(context: Context, id: UUID) = Intent(ACTION_RESUME) fun getResumeIntent(context: Context, id: UUID, skipError: Boolean) = Intent(
.setData(Uri.parse("$SCHEME://$id")) if (skipError) ACTION_SKIP else ACTION_RESUME,
).setData(Uri.parse("$SCHEME://$id"))
.setPackage(context.packageName) .setPackage(context.packageName)
.putExtra(EXTRA_UUID, id.toString()) .putExtra(EXTRA_UUID, id.toString())
@@ -58,12 +62,13 @@ class PausingReceiver(
false, false,
) )
fun createResumePendingIntent(context: Context, id: UUID) = PendingIntentCompat.getBroadcast( fun createResumePendingIntent(context: Context, id: UUID, skipError: Boolean) =
context, PendingIntentCompat.getBroadcast(
0, context,
getResumeIntent(context, id), 0,
0, getResumeIntent(context, id, skipError),
false, 0,
) false,
)
} }
} }

View File

@@ -142,7 +142,11 @@ class FavouriteCategoriesActivity :
} }
val fromPos = viewHolder.bindingAdapterPosition val fromPos = viewHolder.bindingAdapterPosition
val toPos = target.bindingAdapterPosition val toPos = target.bindingAdapterPosition
return fromPos != toPos && fromPos != RecyclerView.NO_POSITION && toPos != RecyclerView.NO_POSITION if (fromPos == toPos || fromPos == RecyclerView.NO_POSITION || toPos == RecyclerView.NO_POSITION) {
return false
}
adapter.reorderItems(fromPos, toPos)
return true
} }
override fun canDropOver( override fun canDropOver(
@@ -151,25 +155,16 @@ class FavouriteCategoriesActivity :
target: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder,
): Boolean = current.itemViewType == target.itemViewType ): Boolean = current.itemViewType == target.itemViewType
override fun onMoved(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder,
fromPos: Int,
target: RecyclerView.ViewHolder,
toPos: Int,
x: Int,
y: Int,
) {
super.onMoved(recyclerView, viewHolder, fromPos, target, toPos, x, y)
viewModel.reorderCategories(fromPos, toPos)
}
override fun isLongPressDragEnabled(): Boolean = false override fun isLongPressDragEnabled(): Boolean = false
override fun onSelectedChanged(viewHolder: RecyclerView.ViewHolder?, actionState: Int) { override fun onSelectedChanged(viewHolder: RecyclerView.ViewHolder?, actionState: Int) {
super.onSelectedChanged(viewHolder, actionState) super.onSelectedChanged(viewHolder, actionState)
viewBinding.recyclerView.isNestedScrollingEnabled = viewBinding.recyclerView.isNestedScrollingEnabled = actionState == ItemTouchHelper.ACTION_STATE_IDLE
actionState == ItemTouchHelper.ACTION_STATE_IDLE }
override fun clearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) {
super.clearView(recyclerView, viewHolder)
viewModel.saveOrder(adapter.items ?: return)
} }
} }

View File

@@ -1,13 +1,14 @@
package org.koitharu.kotatsu.favourites.ui.categories package org.koitharu.kotatsu.favourites.ui.categories
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.delay import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.yield import kotlinx.coroutines.plus
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.FavouriteCategory import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
@@ -19,7 +20,6 @@ import org.koitharu.kotatsu.favourites.ui.categories.adapter.CategoryListModel
import org.koitharu.kotatsu.list.ui.model.EmptyState import org.koitharu.kotatsu.list.ui.model.EmptyState
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.LoadingState import org.koitharu.kotatsu.list.ui.model.LoadingState
import org.koitharu.kotatsu.parsers.util.move
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
@@ -30,17 +30,9 @@ class FavouritesCategoriesViewModel @Inject constructor(
private var commitJob: Job? = null private var commitJob: Job? = null
val content = MutableStateFlow<List<ListModel>>(listOf(LoadingState)) val content = repository.observeCategoriesWithCovers()
.map { it.toUiList() }
init { .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState))
launchJob(Dispatchers.Default) {
repository.observeCategoriesWithCovers()
.collectLatest {
commitJob?.join()
updateContent(it)
}
}
}
fun deleteCategories(ids: Set<Long>) { fun deleteCategories(ids: Set<Long>) {
launchJob(Dispatchers.Default) { launchJob(Dispatchers.Default) {
@@ -54,11 +46,17 @@ class FavouritesCategoriesViewModel @Inject constructor(
fun isEmpty(): Boolean = content.value.none { it is CategoryListModel } fun isEmpty(): Boolean = content.value.none { it is CategoryListModel }
fun reorderCategories(oldPos: Int, newPos: Int) { fun saveOrder(snapshot: List<ListModel>) {
val snapshot = content.requireValue().toMutableList() val prevJob = commitJob
snapshot.move(oldPos, newPos) commitJob = launchJob {
content.value = snapshot prevJob?.cancelAndJoin()
commit(snapshot) val ids = snapshot.mapNotNullTo(ArrayList(snapshot.size)) {
(it as? CategoryListModel)?.category?.id
}
if (ids.isNotEmpty()) {
repository.reorderCategories(ids)
}
}
} }
fun setIsVisible(ids: Set<Long>, isVisible: Boolean) { fun setIsVisible(ids: Set<Long>, isVisible: Boolean) {
@@ -76,36 +74,21 @@ class FavouritesCategoriesViewModel @Inject constructor(
} }
} }
private fun commit(snapshot: List<ListModel>) { private fun Map<FavouriteCategory, List<Cover>>.toUiList(): List<ListModel> = map { (category, covers) ->
val prevJob = commitJob CategoryListModel(
commitJob = launchJob { mangaCount = covers.size,
prevJob?.cancelAndJoin() covers = covers.take(3),
delay(500) category = category,
val ids = snapshot.mapNotNullTo(ArrayList(snapshot.size)) { isTrackerEnabled = settings.isTrackerEnabled && AppSettings.TRACK_FAVOURITES in settings.trackSources,
(it as? CategoryListModel)?.category?.id )
} }.ifEmpty {
repository.reorderCategories(ids) listOf(
yield() EmptyState(
} icon = R.drawable.ic_empty_favourites,
} textPrimary = R.string.text_empty_holder_primary,
textSecondary = R.string.empty_favourite_categories,
private fun updateContent(categories: Map<FavouriteCategory, List<Cover>>) { actionStringRes = 0,
content.value = categories.map { (category, covers) -> ),
CategoryListModel( )
mangaCount = covers.size,
covers = covers.take(3),
category = category,
isTrackerEnabled = settings.isTrackerEnabled && AppSettings.TRACK_FAVOURITES in settings.trackSources,
)
}.ifEmpty {
listOf(
EmptyState(
icon = R.drawable.ic_empty_favourites,
textPrimary = R.string.text_empty_holder_primary,
textSecondary = R.string.empty_favourite_categories,
actionStringRes = 0,
),
)
}
} }
} }

View File

@@ -2,7 +2,7 @@ package org.koitharu.kotatsu.favourites.ui.categories.adapter
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import coil.ImageLoader import coil.ImageLoader
import org.koitharu.kotatsu.core.ui.BaseListAdapter import org.koitharu.kotatsu.core.ui.ReorderableListAdapter
import org.koitharu.kotatsu.favourites.ui.categories.FavouriteCategoriesListListener import org.koitharu.kotatsu.favourites.ui.categories.FavouriteCategoriesListListener
import org.koitharu.kotatsu.list.ui.adapter.ListItemType import org.koitharu.kotatsu.list.ui.adapter.ListItemType
import org.koitharu.kotatsu.list.ui.adapter.ListStateHolderListener import org.koitharu.kotatsu.list.ui.adapter.ListStateHolderListener
@@ -15,7 +15,7 @@ class CategoriesAdapter(
lifecycleOwner: LifecycleOwner, lifecycleOwner: LifecycleOwner,
onItemClickListener: FavouriteCategoriesListListener, onItemClickListener: FavouriteCategoriesListListener,
listListener: ListStateHolderListener, listListener: ListStateHolderListener,
) : BaseListAdapter<ListModel>() { ) : ReorderableListAdapter<ListModel>() {
init { init {
addDelegate(ListItemType.CATEGORY_LARGE, categoryAD(coil, lifecycleOwner, onItemClickListener)) addDelegate(ListItemType.CATEGORY_LARGE, categoryAD(coil, lifecycleOwner, onItemClickListener))

View File

@@ -65,10 +65,7 @@ fun categoryAD(
binding.imageViewEdit.setOnClickListener(eventListener) binding.imageViewEdit.setOnClickListener(eventListener)
binding.imageViewHandle.setOnTouchListener(eventListener) binding.imageViewHandle.setOnTouchListener(eventListener)
bind { payloads -> bind {
if (payloads.isNotEmpty()) {
return@bind
}
binding.textViewTitle.text = item.category.title binding.textViewTitle.text = item.category.title
binding.textViewSubtitle.text = if (item.mangaCount == 0) { binding.textViewSubtitle.text = if (item.mangaCount == 0) {
getString(R.string.empty) getString(R.string.empty)

View File

@@ -15,13 +15,13 @@ fun categoriesHeaderAD() = adapterDelegateViewBinding<CategoriesHeaderItem, List
val onClickListener = View.OnClickListener { v -> val onClickListener = View.OnClickListener { v ->
val intent = when (v.id) { val intent = when (v.id) {
R.id.button_create -> FavouritesCategoryEditActivity.newIntent(v.context) R.id.chip_create -> FavouritesCategoryEditActivity.newIntent(v.context)
R.id.button_manage -> FavouriteCategoriesActivity.newIntent(v.context) R.id.chip_manage -> FavouriteCategoriesActivity.newIntent(v.context)
else -> return@OnClickListener else -> return@OnClickListener
} }
v.context.startActivity(intent) v.context.startActivity(intent)
} }
binding.buttonCreate.setOnClickListener(onClickListener) binding.chipCreate.setOnClickListener(onClickListener)
binding.buttonManage.setOnClickListener(onClickListener) binding.chipManage.setOnClickListener(onClickListener)
} }

View File

@@ -20,7 +20,7 @@ fun mangaCategoryAD(
} }
bind { payloads -> bind { payloads ->
binding.checkableImageView.setChecked(item.isChecked, ListModelDiffCallback.PAYLOAD_CHECKED_CHANGED !in payloads) binding.checkableImageView.setChecked(item.isChecked, ListModelDiffCallback.PAYLOAD_CHECKED_CHANGED in payloads)
binding.textViewTitle.text = item.category.title binding.textViewTitle.text = item.category.title
binding.imageViewTracker.isVisible = item.category.isTrackingEnabled && item.isTrackerEnabled binding.imageViewTracker.isVisible = item.category.isTrackingEnabled && item.isTrackerEnabled
binding.imageViewVisible.isVisible = item.category.isVisibleInLibrary binding.imageViewVisible.isVisible = item.category.isVisibleInLibrary

View File

@@ -1,41 +0,0 @@
package org.koitharu.kotatsu.filter.ui
import android.content.Context
import androidx.recyclerview.widget.AsyncListDiffer.ListListener
import org.koitharu.kotatsu.core.ui.BaseListAdapter
import org.koitharu.kotatsu.core.ui.list.fastscroll.FastScroller
import org.koitharu.kotatsu.filter.ui.model.FilterItem
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
import org.koitharu.kotatsu.list.ui.adapter.listHeaderAD
import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD
import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD
import org.koitharu.kotatsu.list.ui.model.ListModel
class FilterAdapter(
listener: OnFilterChangedListener,
listListener: ListListener<ListModel>,
) : BaseListAdapter<ListModel>(), FastScroller.SectionIndexer {
init {
addDelegate(ListItemType.FILTER_SORT, filterSortDelegate(listener))
addDelegate(ListItemType.FILTER_TAG, filterTagDelegate(listener))
addDelegate(ListItemType.FILTER_TAG_MULTI, filterTagMultipleDelegate(listener))
addDelegate(ListItemType.FILTER_STATE, filterStateDelegate(listener))
addDelegate(ListItemType.HEADER, listHeaderAD(listener))
addDelegate(ListItemType.STATE_LOADING, loadingStateAD())
addDelegate(ListItemType.FOOTER_LOADING, loadingFooterAD())
addDelegate(ListItemType.FOOTER_ERROR, filterErrorDelegate())
differ.addListListener(listListener)
}
override fun getSectionText(context: Context, position: Int): CharSequence? {
val list = items
for (i in (0..position).reversed()) {
val item = list.getOrNull(i) ?: continue
if (item is FilterItem.Tag) {
return item.tag.title.firstOrNull()?.toString()
}
}
return null
}
}

View File

@@ -1,86 +0,0 @@
package org.koitharu.kotatsu.filter.ui
import android.widget.TextView
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegate
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.titleResId
import org.koitharu.kotatsu.core.ui.model.titleRes
import org.koitharu.kotatsu.core.util.ext.setChecked
import org.koitharu.kotatsu.databinding.ItemCheckableMultipleBinding
import org.koitharu.kotatsu.databinding.ItemCheckableSingleBinding
import org.koitharu.kotatsu.filter.ui.model.FilterItem
import org.koitharu.kotatsu.list.ui.model.ListModel
fun filterSortDelegate(
listener: OnFilterChangedListener,
) = adapterDelegateViewBinding<FilterItem.Sort, ListModel, ItemCheckableSingleBinding>(
{ layoutInflater, parent -> ItemCheckableSingleBinding.inflate(layoutInflater, parent, false) },
) {
itemView.setOnClickListener {
listener.onSortItemClick(item)
}
bind { payloads ->
binding.root.setText(item.order.titleRes)
binding.root.setChecked(item.isSelected, payloads.isNotEmpty())
}
}
fun filterStateDelegate(
listener: OnFilterChangedListener,
) = adapterDelegateViewBinding<FilterItem.State, ListModel, ItemCheckableMultipleBinding>(
{ layoutInflater, parent -> ItemCheckableMultipleBinding.inflate(layoutInflater, parent, false) },
) {
itemView.setOnClickListener {
listener.onStateItemClick(item)
}
bind { payloads ->
binding.root.setText(item.state.titleResId)
binding.root.setChecked(item.isChecked, payloads.isNotEmpty())
}
}
fun filterTagDelegate(
listener: OnFilterChangedListener,
) = adapterDelegateViewBinding<FilterItem.Tag, ListModel, ItemCheckableSingleBinding>(
{ layoutInflater, parent -> ItemCheckableSingleBinding.inflate(layoutInflater, parent, false) },
on = { item, _, _ -> item is FilterItem.Tag && !item.isMultiple },
) {
itemView.setOnClickListener {
listener.onTagItemClick(item)
}
bind { payloads ->
binding.root.text = item.tag.title
binding.root.setChecked(item.isChecked, payloads.isNotEmpty())
}
}
fun filterTagMultipleDelegate(
listener: OnFilterChangedListener,
) = adapterDelegateViewBinding<FilterItem.Tag, ListModel, ItemCheckableMultipleBinding>(
{ layoutInflater, parent -> ItemCheckableMultipleBinding.inflate(layoutInflater, parent, false) },
on = { item, _, _ -> item is FilterItem.Tag && item.isMultiple },
) {
itemView.setOnClickListener {
listener.onTagItemClick(item)
}
bind { payloads ->
binding.root.text = item.tag.title
binding.root.setChecked(item.isChecked, payloads.isNotEmpty())
}
}
fun filterErrorDelegate() = adapterDelegate<FilterItem.Error, ListModel>(R.layout.item_sources_empty) {
bind {
(itemView as TextView).setText(item.textResId)
}
}

View File

@@ -1,39 +1,50 @@
package org.koitharu.kotatsu.filter.ui package org.koitharu.kotatsu.filter.ui
import android.view.View import android.view.View
import androidx.annotation.WorkerThread
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import dagger.hilt.android.ViewModelLifecycle import dagger.hilt.android.ViewModelLifecycle
import dagger.hilt.android.scopes.ViewModelScoped import dagger.hilt.android.scopes.ViewModelScoped
import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.plus import kotlinx.coroutines.plus
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.parser.MangaDataRepository import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.ui.widgets.ChipsView import org.koitharu.kotatsu.core.ui.widgets.ChipsView
import org.koitharu.kotatsu.core.util.LocaleComparator
import org.koitharu.kotatsu.core.util.ext.asArrayList
import org.koitharu.kotatsu.core.util.ext.lifecycleScope import org.koitharu.kotatsu.core.util.ext.lifecycleScope
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.require import org.koitharu.kotatsu.core.util.ext.require
import org.koitharu.kotatsu.core.util.ext.sortedByOrdinal
import org.koitharu.kotatsu.filter.ui.model.FilterHeaderModel import org.koitharu.kotatsu.filter.ui.model.FilterHeaderModel
import org.koitharu.kotatsu.filter.ui.model.FilterItem import org.koitharu.kotatsu.filter.ui.model.FilterProperty
import org.koitharu.kotatsu.filter.ui.model.TagCatalogItem
import org.koitharu.kotatsu.list.ui.model.ErrorFooter
import org.koitharu.kotatsu.list.ui.model.ListHeader import org.koitharu.kotatsu.list.ui.model.ListHeader
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.LoadingFooter import org.koitharu.kotatsu.list.ui.model.LoadingFooter
import org.koitharu.kotatsu.list.ui.model.LoadingState import org.koitharu.kotatsu.list.ui.model.LoadingState
import org.koitharu.kotatsu.list.ui.model.toErrorFooter
import org.koitharu.kotatsu.parsers.model.MangaListFilter import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.util.SuspendLazy import org.koitharu.kotatsu.parsers.util.SuspendLazy
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.remotelist.ui.RemoteListFragment import org.koitharu.kotatsu.remotelist.ui.RemoteListFragment
@@ -55,16 +66,84 @@ class FilterCoordinator @Inject constructor(
private val coroutineScope = lifecycle.lifecycleScope private val coroutineScope = lifecycle.lifecycleScope
private val repository = mangaRepositoryFactory.create(savedStateHandle.require(RemoteListFragment.ARG_SOURCE)) private val repository = mangaRepositoryFactory.create(savedStateHandle.require(RemoteListFragment.ARG_SOURCE))
private val currentState = private val currentState = MutableStateFlow(
MutableStateFlow(MangaListFilter.Advanced(repository.defaultSortOrder, emptySet(), null, emptySet())) MangaListFilter.Advanced(repository.defaultSortOrder, emptySet(), null, emptySet()),
private var searchQuery = MutableStateFlow("") )
private val localTags = SuspendLazy { private val localTags = SuspendLazy {
dataRepository.findTags(repository.source) dataRepository.findTags(repository.source)
} }
private var availableTagsDeferred = loadTagsAsync() private var availableTagsDeferred = loadTagsAsync()
private var availableLocalesDeferred = loadLocalesAsync()
private var allTagsLoadJob: Job? = null
override val filterItems: StateFlow<List<ListModel>> = getItemsFlow() override val allTags = MutableStateFlow<List<ListModel>>(listOf(LoadingState))
.stateIn(coroutineScope + Dispatchers.Default, SharingStarted.Lazily, listOf(LoadingState)) get() {
if (allTagsLoadJob == null || field.value.any { it is ErrorFooter }) {
loadAllTags()
}
return field
}
override val filterTags: StateFlow<FilterProperty<MangaTag>> = combine(
currentState.distinctUntilChangedBy { it.tags },
getTopTagsAsFlow(currentState.map { it.tags }, 16),
) { state, tags ->
FilterProperty(
availableItems = tags.items.asArrayList(),
selectedItems = state.tags,
isLoading = tags.isLoading,
error = tags.error,
)
}.stateIn(coroutineScope + Dispatchers.Default, SharingStarted.Lazily, loadingProperty())
override val filterSortOrder: StateFlow<FilterProperty<SortOrder>> = combine(
currentState.distinctUntilChangedBy { it.sortOrder },
flowOf(repository.sortOrders),
) { state, orders ->
FilterProperty(
availableItems = orders.sortedBy { it.ordinal },
selectedItems = setOf(state.sortOrder),
isLoading = false,
error = null,
)
}.stateIn(coroutineScope + Dispatchers.Default, SharingStarted.Lazily, loadingProperty())
override val filterState: StateFlow<FilterProperty<MangaState>> = combine(
currentState.distinctUntilChangedBy { it.states },
flowOf(repository.states),
) { state, states ->
FilterProperty(
availableItems = states.sortedBy { it.ordinal },
selectedItems = state.states,
isLoading = false,
error = null,
)
}.stateIn(coroutineScope + Dispatchers.Default, SharingStarted.Lazily, loadingProperty())
override val filterLocale: StateFlow<FilterProperty<Locale?>> = combine(
currentState.distinctUntilChangedBy { it.locale },
getLocalesAsFlow(),
) { state, locales ->
val list = if (locales.items.isNotEmpty()) {
val l = ArrayList<Locale?>(locales.items.size + 1)
l.add(null)
l.addAll(locales.items)
try {
l.sortWith(nullsFirst(LocaleComparator()))
} catch (e: IllegalArgumentException) {
e.printStackTraceDebug()
}
l
} else {
emptyList()
}
FilterProperty(
availableItems = list,
selectedItems = setOf(state.locale),
isLoading = locales.isLoading,
error = locales.error,
)
}.stateIn(coroutineScope + Dispatchers.Default, SharingStarted.Lazily, loadingProperty())
override val header: StateFlow<FilterHeaderModel> = getHeaderFlow().stateIn( override val header: StateFlow<FilterHeaderModel> = getHeaderFlow().stateIn(
scope = coroutineScope + Dispatchers.Default, scope = coroutineScope + Dispatchers.Default,
@@ -72,45 +151,52 @@ class FilterCoordinator @Inject constructor(
initialValue = FilterHeaderModel( initialValue = FilterHeaderModel(
chips = emptyList(), chips = emptyList(),
sortOrder = repository.defaultSortOrder, sortOrder = repository.defaultSortOrder,
hasSelectedTags = false, isFilterApplied = false,
allowMultipleTags = repository.isMultipleTagsSupported,
), ),
) )
init {
observeState()
}
override fun applyFilter(tags: Set<MangaTag>) { override fun applyFilter(tags: Set<MangaTag>) {
setTags(tags) setTags(tags)
} }
override fun onSortItemClick(item: FilterItem.Sort) { override fun setSortOrder(value: SortOrder) {
currentState.update { oldValue -> currentState.update { oldValue ->
oldValue.copy(sortOrder = item.order) oldValue.copy(sortOrder = value)
} }
repository.defaultSortOrder = item.order repository.defaultSortOrder = value
} }
override fun onTagItemClick(item: FilterItem.Tag) { override fun setLanguage(value: Locale?) {
currentState.update { oldValue -> currentState.update { oldValue ->
val newTags = if (!item.isMultiple) { oldValue.copy(locale = value)
setOf(item.tag) }
} else if (item.isChecked) { }
oldValue.tags - item.tag
override fun setTag(value: MangaTag, addOrRemove: Boolean) {
currentState.update { oldValue ->
val newTags = if (repository.isMultipleTagsSupported) {
if (addOrRemove) {
oldValue.tags + value
} else {
oldValue.tags - value
}
} else { } else {
oldValue.tags + item.tag if (addOrRemove) {
setOf(value)
} else {
emptySet()
}
} }
oldValue.copy(tags = newTags) oldValue.copy(tags = newTags)
} }
} }
override fun onStateItemClick(item: FilterItem.State) { override fun setState(value: MangaState, addOrRemove: Boolean) {
currentState.update { oldValue -> currentState.update { oldValue ->
val newStates = if (item.isChecked) { val newStates = if (addOrRemove) {
oldValue.states - item.state oldValue.states + value
} else { } else {
oldValue.states + item.state oldValue.states - value
} }
oldValue.copy(states = newStates) oldValue.copy(states = newStates)
} }
@@ -121,7 +207,7 @@ class FilterCoordinator @Inject constructor(
oldValue.copy( oldValue.copy(
sortOrder = oldValue.sortOrder, sortOrder = oldValue.sortOrder,
tags = if (item.payload == R.string.genres) emptySet() else oldValue.tags, tags = if (item.payload == R.string.genres) emptySet() else oldValue.tags,
locale = null, locale = if (item.payload == R.string.language) null else oldValue.locale,
states = if (item.payload == R.string.state) emptySet() else oldValue.states, states = if (item.payload == R.string.state) emptySet() else oldValue.states,
) )
} }
@@ -131,7 +217,7 @@ class FilterCoordinator @Inject constructor(
if (!availableTagsDeferred.isCompleted) { if (!availableTagsDeferred.isCompleted) {
emit(emptySet()) emit(emptySet())
} }
emit(availableTagsDeferred.await()) emit(availableTagsDeferred.await().getOrNull())
} }
fun observeState() = currentState.asStateFlow() fun observeState() = currentState.asStateFlow()
@@ -150,10 +236,6 @@ class FilterCoordinator @Inject constructor(
fun snapshot() = currentState.value fun snapshot() = currentState.value
fun performSearch(query: String) {
searchQuery.value = query
}
private fun getHeaderFlow() = combine( private fun getHeaderFlow() = combine(
observeState(), observeState(),
observeAvailableTags(), observeAvailableTags(),
@@ -162,28 +244,46 @@ class FilterCoordinator @Inject constructor(
FilterHeaderModel( FilterHeaderModel(
chips = chips, chips = chips,
sortOrder = state.sortOrder, sortOrder = state.sortOrder,
hasSelectedTags = state.tags.isNotEmpty(), isFilterApplied = !state.isEmpty(),
allowMultipleTags = repository.isMultipleTagsSupported,
) )
} }
private fun getItemsFlow() = combine(
getTagsAsFlow(),
currentState,
searchQuery,
) { tags, state, query ->
buildFilterList(tags, state, query)
}
private fun getTagsAsFlow() = flow { private fun getTagsAsFlow() = flow {
val localTags = localTags.get() val localTags = localTags.get()
emit(TagsWrapper(localTags, isLoading = true, isError = false)) emit(PendingData(localTags, isLoading = true, error = null))
val remoteTags = tryLoadTags() tryLoadTags()
if (remoteTags == null) { .onSuccess { remoteTags ->
emit(TagsWrapper(localTags, isLoading = false, isError = true)) emit(PendingData(mergeTags(remoteTags, localTags), isLoading = false, error = null))
} else { }.onFailure {
emit(TagsWrapper(mergeTags(remoteTags, localTags), isLoading = false, isError = false)) emit(PendingData(localTags, isLoading = false, error = it))
}
}
private fun getLocalesAsFlow(): Flow<PendingData<Locale>> = flow {
emit(PendingData(emptySet(), isLoading = true, error = null))
tryLoadLocales()
.onSuccess { locales ->
emit(PendingData(locales, isLoading = false, error = null))
}.onFailure {
emit(PendingData(emptySet(), isLoading = false, error = it))
}
}
private fun getTopTagsAsFlow(selectedTags: Flow<Set<MangaTag>>, limit: Int): Flow<PendingData<MangaTag>> = combine(
selectedTags.map {
if (it.isEmpty()) {
searchRepository.getTagsSuggestion("", limit, repository.source)
} else {
searchRepository.getTagsSuggestion(it).take(limit)
}
},
getTagsAsFlow(),
) { suggested, all ->
val res = suggested.toMutableList()
if (res.size < limit) {
res.addAll(all.items.shuffled().take(limit - res.size))
} }
PendingData(res, all.isLoading, all.error.takeIf { res.size < limit })
} }
private suspend fun createChipsList( private suspend fun createChipsList(
@@ -233,84 +333,40 @@ class FilterCoordinator @Inject constructor(
return result return result
} }
@WorkerThread private suspend fun tryLoadTags(): Result<Set<MangaTag>> {
private fun buildFilterList(
allTags: TagsWrapper,
state: MangaListFilter.Advanced,
query: String,
): List<ListModel> {
val sortOrders = repository.sortOrders.sortedByOrdinal()
val states = repository.states
val tags = mergeTags(state.tags, allTags.tags).toList()
val list = ArrayList<ListModel>(tags.size + states.size + sortOrders.size + 4)
val isMultiTag = repository.isMultipleTagsSupported
if (query.isEmpty()) {
if (sortOrders.isNotEmpty()) {
list.add(ListHeader(R.string.sort_order))
sortOrders.mapTo(list) {
FilterItem.Sort(it, isSelected = it == state.sortOrder)
}
}
if (states.isNotEmpty()) {
list.add(
ListHeader(
textRes = R.string.state,
buttonTextRes = if (state.states.isEmpty()) 0 else R.string.reset,
payload = R.string.state,
),
)
states.mapTo(list) {
FilterItem.State(it, isChecked = it in state.states)
}
}
if (allTags.isLoading || allTags.isError || tags.isNotEmpty()) {
list.add(
ListHeader(
textRes = R.string.genres,
buttonTextRes = if (state.tags.isEmpty()) 0 else R.string.reset,
payload = R.string.genres,
),
)
tags.mapTo(list) {
FilterItem.Tag(it, isMultiple = isMultiTag, isChecked = it in state.tags)
}
}
if (allTags.isError) {
list.add(FilterItem.Error(R.string.filter_load_error))
} else if (allTags.isLoading) {
list.add(LoadingFooter())
}
} else {
tags.mapNotNullTo(list) {
if (it.title.contains(query, ignoreCase = true)) {
FilterItem.Tag(it, isMultiple = isMultiTag, isChecked = it in state.tags)
} else {
null
}
}
if (list.isEmpty()) {
list.add(FilterItem.Error(R.string.nothing_found))
}
}
return list
}
private suspend fun tryLoadTags(): Set<MangaTag>? {
val shouldRetryOnError = availableTagsDeferred.isCompleted val shouldRetryOnError = availableTagsDeferred.isCompleted
val result = availableTagsDeferred.await() val result = availableTagsDeferred.await()
if (result == null && shouldRetryOnError) { if (result.isFailure && shouldRetryOnError) {
availableTagsDeferred = loadTagsAsync() availableTagsDeferred = loadTagsAsync()
return availableTagsDeferred.await() return availableTagsDeferred.await()
} }
return result return result
} }
private suspend fun tryLoadLocales(): Result<Set<Locale>> {
val shouldRetryOnError = availableLocalesDeferred.isCompleted
val result = availableLocalesDeferred.await()
if (result.isFailure && shouldRetryOnError) {
availableLocalesDeferred = loadLocalesAsync()
return availableLocalesDeferred.await()
}
return result
}
private fun loadTagsAsync() = coroutineScope.async(Dispatchers.Default, CoroutineStart.LAZY) { private fun loadTagsAsync() = coroutineScope.async(Dispatchers.Default, CoroutineStart.LAZY) {
runCatchingCancellable { runCatchingCancellable {
repository.getTags() repository.getTags()
}.onFailure { error -> }.onFailure { error ->
error.printStackTraceDebug() error.printStackTraceDebug()
}.getOrNull() }
}
private fun loadLocalesAsync() = coroutineScope.async(Dispatchers.Default, CoroutineStart.LAZY) {
runCatchingCancellable {
repository.getLocales()
}.onFailure { error ->
error.printStackTraceDebug()
}
} }
private fun mergeTags(primary: Set<MangaTag>, secondary: Set<MangaTag>): Set<MangaTag> { private fun mergeTags(primary: Set<MangaTag>, secondary: Set<MangaTag>): Set<MangaTag> {
@@ -320,12 +376,41 @@ class FilterCoordinator @Inject constructor(
return result return result
} }
private data class TagsWrapper( private fun loadAllTags() {
val tags: Set<MangaTag>, val prevJob = allTagsLoadJob
allTagsLoadJob = coroutineScope.launch(Dispatchers.Default) {
runCatchingCancellable {
prevJob?.cancelAndJoin()
appendTagsList(localTags.get(), isLoading = true)
appendTagsList(availableTagsDeferred.await().getOrThrow(), isLoading = false)
}.onFailure { e ->
allTags.value = allTags.value.filterIsInstance<TagCatalogItem>() + e.toErrorFooter()
}
}
}
private fun appendTagsList(newTags: Collection<MangaTag>, isLoading: Boolean) = allTags.update { oldList ->
val oldTags = oldList.filterIsInstance<TagCatalogItem>()
buildList(oldTags.size + newTags.size + if (isLoading) 1 else 0) {
addAll(oldTags)
newTags.mapTo(this) { TagCatalogItem(it, isChecked = false) }
val tempSet = HashSet<MangaTag>(size)
removeAll { x -> x is TagCatalogItem && !tempSet.add(x.tag) }
sortBy { (it as TagCatalogItem).tag.title }
if (isLoading) {
add(LoadingFooter())
}
}
}
private data class PendingData<T>(
val items: Collection<T>,
val isLoading: Boolean, val isLoading: Boolean,
val isError: Boolean, val error: Throwable?,
) )
private fun <T> loadingProperty() = FilterProperty<T>(emptyList(), emptySet(), true, null)
private class TagTitleComparator(lc: String?) : Comparator<MangaTag> { private class TagTitleComparator(lc: String?) : Comparator<MangaTag> {
private val collator = lc?.let { Collator.getInstance(Locale(it)) } private val collator = lc?.let { Collator.getInstance(Locale(it)) }

View File

@@ -13,7 +13,7 @@ import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled
import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.databinding.FragmentFilterHeaderBinding import org.koitharu.kotatsu.databinding.FragmentFilterHeaderBinding
import org.koitharu.kotatsu.filter.ui.model.FilterHeaderModel import org.koitharu.kotatsu.filter.ui.model.FilterHeaderModel
import org.koitharu.kotatsu.filter.ui.model.FilterItem import org.koitharu.kotatsu.filter.ui.tags.TagsCatalogSheet
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
import com.google.android.material.R as materialR import com.google.android.material.R as materialR
@@ -37,9 +37,9 @@ class FilterHeaderFragment : BaseFragment<FragmentFilterHeaderBinding>(), ChipsV
override fun onChipClick(chip: Chip, data: Any?) { override fun onChipClick(chip: Chip, data: Any?) {
val tag = data as? MangaTag val tag = data as? MangaTag
if (tag == null) { if (tag == null) {
FilterSheetFragment.show(parentFragmentManager) TagsCatalogSheet.show(parentFragmentManager)
} else { } else {
filter.onTagItemClick(FilterItem.Tag(tag, filter.header.value.allowMultipleTags, !chip.isChecked)) filter.setTag(tag, chip.isChecked)
} }
} }

View File

@@ -1,59 +0,0 @@
package org.koitharu.kotatsu.filter.ui
import android.os.Build
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.FragmentManager
import androidx.recyclerview.widget.AsyncListDiffer
import androidx.recyclerview.widget.LinearLayoutManager
import org.koitharu.kotatsu.core.ui.sheet.AdaptiveSheetBehavior
import org.koitharu.kotatsu.core.ui.sheet.AdaptiveSheetCallback
import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.showDistinct
import org.koitharu.kotatsu.databinding.SheetFilterBinding
import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
import org.koitharu.kotatsu.list.ui.model.ListModel
class FilterSheetFragment :
BaseAdaptiveSheet<SheetFilterBinding>(),
AdaptiveSheetCallback,
AsyncListDiffer.ListListener<ListModel> {
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): SheetFilterBinding {
return SheetFilterBinding.inflate(inflater, container, false)
}
override fun onViewBindingCreated(binding: SheetFilterBinding, savedInstanceState: Bundle?) {
super.onViewBindingCreated(binding, savedInstanceState)
val filter = (requireActivity() as FilterOwner).filter
addSheetCallback(this)
val adapter = FilterAdapter(filter, this)
binding.recyclerView.adapter = adapter
filter.filterItems.observe(viewLifecycleOwner, adapter)
binding.recyclerView.addItemDecoration(TypedListSpacingDecoration(binding.root.context, true))
if (dialog == null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
binding.recyclerView.scrollIndicators = 0
}
}
override fun onCurrentListChanged(previousList: MutableList<ListModel>, currentList: MutableList<ListModel>) {
if (currentList.size > previousList.size && view != null) {
(requireViewBinding().recyclerView.layoutManager as LinearLayoutManager).scrollToPositionWithOffset(0, 0)
}
}
override fun onStateChanged(sheet: View, newState: Int) {
viewBinding?.recyclerView?.isFastScrollerEnabled = newState == AdaptiveSheetBehavior.STATE_EXPANDED
}
companion object {
private const val TAG = "FilterBottomSheet"
fun show(fm: FragmentManager) = FilterSheetFragment().showDistinct(fm, TAG)
}
}

View File

@@ -2,12 +2,24 @@ package org.koitharu.kotatsu.filter.ui
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import org.koitharu.kotatsu.filter.ui.model.FilterHeaderModel import org.koitharu.kotatsu.filter.ui.model.FilterHeaderModel
import org.koitharu.kotatsu.filter.ui.model.FilterProperty
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder
import java.util.Locale
interface MangaFilter : OnFilterChangedListener { interface MangaFilter : OnFilterChangedListener {
val filterItems: StateFlow<List<ListModel>> val allTags: StateFlow<List<ListModel>>
val filterTags: StateFlow<FilterProperty<MangaTag>>
val filterSortOrder: StateFlow<FilterProperty<SortOrder>>
val filterState: StateFlow<FilterProperty<MangaState>>
val filterLocale: StateFlow<FilterProperty<Locale?>>
val header: StateFlow<FilterHeaderModel> val header: StateFlow<FilterHeaderModel>

View File

@@ -1,13 +1,18 @@
package org.koitharu.kotatsu.filter.ui package org.koitharu.kotatsu.filter.ui
import org.koitharu.kotatsu.filter.ui.model.FilterItem
import org.koitharu.kotatsu.list.ui.adapter.ListHeaderClickListener import org.koitharu.kotatsu.list.ui.adapter.ListHeaderClickListener
import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder
import java.util.Locale
interface OnFilterChangedListener : ListHeaderClickListener { interface OnFilterChangedListener : ListHeaderClickListener {
fun onSortItemClick(item: FilterItem.Sort) fun setSortOrder(value: SortOrder)
fun onTagItemClick(item: FilterItem.Tag) fun setLanguage(value: Locale?)
fun onStateItemClick(item: FilterItem.State) fun setTag(value: MangaTag, addOrRemove: Boolean)
fun setState(value: MangaState, addOrRemove: Boolean)
} }

View File

@@ -3,33 +3,12 @@ package org.koitharu.kotatsu.filter.ui.model
import org.koitharu.kotatsu.core.ui.widgets.ChipsView import org.koitharu.kotatsu.core.ui.widgets.ChipsView
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
class FilterHeaderModel( data class FilterHeaderModel(
val chips: Collection<ChipsView.ChipModel>, val chips: Collection<ChipsView.ChipModel>,
val sortOrder: SortOrder?, val sortOrder: SortOrder?,
val hasSelectedTags: Boolean, val isFilterApplied: Boolean,
val allowMultipleTags: Boolean,
) { ) {
val textSummary: String val textSummary: String
get() = chips.mapNotNull { if (it.isChecked) it.title else null }.joinToString() get() = chips.mapNotNull { if (it.isChecked) it.title else null }.joinToString()
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as FilterHeaderModel
if (chips != other.chips) return false
if (allowMultipleTags != other.allowMultipleTags) return false
return sortOrder == other.sortOrder
// Not need to check hasSelectedTags
}
override fun hashCode(): Int {
var result = chips.hashCode()
result = 31 * result + allowMultipleTags.hashCode()
result = 31 * result + (sortOrder?.hashCode() ?: 0)
return result
}
} }

View File

@@ -1,75 +0,0 @@
package org.koitharu.kotatsu.filter.ui.model
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.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder
sealed interface FilterItem : ListModel {
data class Sort(
val order: SortOrder,
val isSelected: Boolean,
) : FilterItem {
override fun areItemsTheSame(other: ListModel): Boolean {
return other is Sort && other.order == order
}
override fun getChangePayload(previousState: ListModel): Any? {
return if (previousState is Sort && previousState.isSelected != isSelected) {
ListModelDiffCallback.PAYLOAD_CHECKED_CHANGED
} else {
super.getChangePayload(previousState)
}
}
}
data class Tag(
val tag: MangaTag,
val isMultiple: Boolean,
val isChecked: Boolean,
) : FilterItem {
override fun areItemsTheSame(other: ListModel): Boolean {
return other is Tag && other.isMultiple == isMultiple && other.tag == tag
}
override fun getChangePayload(previousState: ListModel): Any? {
return if (previousState is Tag && previousState.isChecked != isChecked) {
ListModelDiffCallback.PAYLOAD_CHECKED_CHANGED
} else {
super.getChangePayload(previousState)
}
}
}
data class State(
val state: MangaState,
val isChecked: Boolean
) : FilterItem {
override fun areItemsTheSame(other: ListModel): Boolean {
return other is State && other.state == state
}
override fun getChangePayload(previousState: ListModel): Any? {
return if (previousState is State && previousState.isChecked != isChecked) {
ListModelDiffCallback.PAYLOAD_CHECKED_CHANGED
} else {
super.getChangePayload(previousState)
}
}
}
data class Error(
@StringRes val textResId: Int,
) : FilterItem {
override fun areItemsTheSame(other: ListModel): Boolean {
return other is Error && textResId == other.textResId
}
}
}

View File

@@ -0,0 +1,11 @@
package org.koitharu.kotatsu.filter.ui.model
data class FilterProperty<T>(
val availableItems: List<T>,
val selectedItems: Set<T>,
val isLoading: Boolean,
val error: Throwable?,
) {
fun isEmpty(): Boolean = availableItems.isEmpty()
}

View File

@@ -0,0 +1,23 @@
package org.koitharu.kotatsu.filter.ui.model
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.model.MangaTag
data class TagCatalogItem(
val tag: MangaTag,
val isChecked: Boolean,
) : ListModel {
override fun areItemsTheSame(other: ListModel): Boolean {
return other is TagCatalogItem && other.tag == tag
}
override fun getChangePayload(previousState: ListModel): Any? {
return if (previousState is TagCatalogItem && previousState.isChecked != isChecked) {
ListModelDiffCallback.PAYLOAD_CHECKED_CHANGED
} else {
super.getChangePayload(previousState)
}
}
}

View File

@@ -0,0 +1,197 @@
package org.koitharu.kotatsu.filter.ui.sheet
import android.os.Build
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.AdapterView
import android.widget.ArrayAdapter
import androidx.core.view.isGone
import androidx.core.view.updatePadding
import androidx.fragment.app.FragmentManager
import com.google.android.material.chip.Chip
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.titleResId
import org.koitharu.kotatsu.core.ui.model.titleRes
import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet
import org.koitharu.kotatsu.core.ui.widgets.ChipsView
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.showDistinct
import org.koitharu.kotatsu.core.util.ext.textAndVisible
import org.koitharu.kotatsu.databinding.SheetFilterBinding
import org.koitharu.kotatsu.filter.ui.FilterOwner
import org.koitharu.kotatsu.filter.ui.model.FilterProperty
import org.koitharu.kotatsu.filter.ui.tags.TagsCatalogSheet
import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.util.toTitleCase
import java.util.Locale
import com.google.android.material.R as materialR
class FilterSheetFragment :
BaseAdaptiveSheet<SheetFilterBinding>(), AdapterView.OnItemSelectedListener, ChipsView.OnChipClickListener {
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): SheetFilterBinding {
return SheetFilterBinding.inflate(inflater, container, false)
}
override fun onViewBindingCreated(binding: SheetFilterBinding, savedInstanceState: Bundle?) {
super.onViewBindingCreated(binding, savedInstanceState)
if (dialog == null) {
binding.layoutBody.updatePadding(top = binding.layoutBody.paddingBottom)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
binding.scrollView.scrollIndicators = 0
}
}
val filter = requireFilter()
filter.filterSortOrder.observe(viewLifecycleOwner, this::onSortOrderChanged)
filter.filterLocale.observe(viewLifecycleOwner, this::onLocaleChanged)
filter.filterTags.observe(viewLifecycleOwner, this::onTagsChanged)
filter.filterState.observe(viewLifecycleOwner, this::onStateChanged)
binding.spinnerLocale.onItemSelectedListener = this
binding.spinnerOrder.onItemSelectedListener = this
binding.chipsState.onChipClickListener = this
binding.chipsGenres.onChipClickListener = this
}
override fun onItemSelected(parent: AdapterView<*>, view: View?, position: Int, id: Long) {
val filter = requireFilter()
when (parent.id) {
R.id.spinner_order -> filter.setSortOrder(filter.filterSortOrder.value.availableItems[position])
R.id.spinner_locale -> filter.setLanguage(filter.filterLocale.value.availableItems[position])
}
}
override fun onNothingSelected(parent: AdapterView<*>?) = Unit
override fun onChipClick(chip: Chip, data: Any?) {
val filter = requireFilter()
when (data) {
is MangaState -> filter.setState(data, chip.isChecked)
is MangaTag -> filter.setTag(data, chip.isChecked)
null -> TagsCatalogSheet.show(childFragmentManager)
}
}
private fun onSortOrderChanged(value: FilterProperty<SortOrder>) {
val b = viewBinding ?: return
b.textViewOrderTitle.isGone = value.isEmpty()
b.cardOrder.isGone = value.isEmpty()
if (value.isEmpty()) {
return
}
val selected = value.selectedItems.single()
b.spinnerOrder.adapter = ArrayAdapter(
b.spinnerOrder.context,
android.R.layout.simple_spinner_dropdown_item,
android.R.id.text1,
value.availableItems.map { b.spinnerOrder.context.getString(it.titleRes) },
)
val selectedIndex = value.availableItems.indexOf(selected)
if (selectedIndex >= 0) {
b.spinnerOrder.setSelection(selectedIndex, false)
}
}
private fun onLocaleChanged(value: FilterProperty<Locale?>) {
val b = viewBinding ?: return
b.textViewLocaleTitle.isGone = value.isEmpty()
b.cardLocale.isGone = value.isEmpty()
if (value.isEmpty()) {
return
}
val selected = value.selectedItems.singleOrNull()
b.spinnerLocale.adapter = ArrayAdapter(
b.spinnerLocale.context,
android.R.layout.simple_spinner_dropdown_item,
android.R.id.text1,
value.availableItems.map {
it?.getDisplayLanguage(it)?.toTitleCase(it)
?: b.spinnerLocale.context.getString(R.string.various_languages)
},
)
val selectedIndex = value.availableItems.indexOf(selected)
if (selectedIndex >= 0) {
b.spinnerLocale.setSelection(selectedIndex, false)
}
}
private fun onTagsChanged(value: FilterProperty<MangaTag>) {
val b = viewBinding ?: return
b.textViewGenresTitle.isGone = value.isEmpty()
b.chipsGenres.isGone = value.isEmpty()
b.textViewGenresHint.textAndVisible = value.error?.getDisplayMessage(resources)
if (value.isEmpty()) {
return
}
val chips = ArrayList<ChipsView.ChipModel>(value.selectedItems.size + value.availableItems.size + 1)
value.selectedItems.mapTo(chips) { tag ->
ChipsView.ChipModel(
tint = 0,
title = tag.title,
icon = 0,
isCheckable = true,
isChecked = true,
data = tag,
)
}
value.availableItems.mapNotNullTo(chips) { tag ->
if (tag !in value.selectedItems) {
ChipsView.ChipModel(
tint = 0,
title = tag.title,
icon = 0,
isCheckable = true,
isChecked = false,
data = tag,
)
} else {
null
}
}
chips.add(
ChipsView.ChipModel(
tint = 0,
title = getString(R.string.more),
icon = materialR.drawable.abc_ic_menu_overflow_material,
isCheckable = false,
isChecked = false,
data = null,
),
)
b.chipsGenres.setChips(chips)
}
private fun onStateChanged(value: FilterProperty<MangaState>) {
val b = viewBinding ?: return
b.textViewStateTitle.isGone = value.isEmpty()
b.chipsState.isGone = value.isEmpty()
if (value.isEmpty()) {
return
}
val chips = value.availableItems.map { state ->
ChipsView.ChipModel(
tint = 0,
title = getString(state.titleResId),
icon = 0,
isCheckable = true,
isChecked = state in value.selectedItems,
data = state,
)
}
b.chipsState.setChips(chips)
}
private fun requireFilter() = (requireActivity() as FilterOwner).filter
companion object {
private const val TAG = "FilterSheet"
fun show(fm: FragmentManager) = FilterSheetFragment().showDistinct(fm, TAG)
}
}

View File

@@ -0,0 +1,48 @@
package org.koitharu.kotatsu.filter.ui.tags
import android.content.Context
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
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.core.util.ext.setChecked
import org.koitharu.kotatsu.databinding.ItemCheckableNewBinding
import org.koitharu.kotatsu.filter.ui.model.TagCatalogItem
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
import org.koitharu.kotatsu.list.ui.adapter.errorFooterAD
import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD
import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD
import org.koitharu.kotatsu.list.ui.model.ListModel
class TagsCatalogAdapter(
listener: OnListItemClickListener<TagCatalogItem>,
) : BaseListAdapter<ListModel>(), FastScroller.SectionIndexer {
init {
addDelegate(ListItemType.FILTER_TAG, tagCatalogDelegate(listener))
addDelegate(ListItemType.STATE_LOADING, loadingStateAD())
addDelegate(ListItemType.FOOTER_LOADING, loadingFooterAD())
addDelegate(ListItemType.FOOTER_ERROR, errorFooterAD(null))
}
override fun getSectionText(context: Context, position: Int): CharSequence? {
return (items.getOrNull(position) as? TagCatalogItem)?.tag?.title?.firstOrNull()?.uppercase()
}
private fun tagCatalogDelegate(
listener: OnListItemClickListener<TagCatalogItem>,
) = adapterDelegateViewBinding<TagCatalogItem, ListModel, ItemCheckableNewBinding>(
{ layoutInflater, parent -> ItemCheckableNewBinding.inflate(layoutInflater, parent, false) },
) {
itemView.setOnClickListener {
listener.onItemClick(item, itemView)
}
bind { payloads ->
binding.root.text = item.tag.title
binding.root.setChecked(item.isChecked, ListModelDiffCallback.PAYLOAD_CHECKED_CHANGED in payloads)
}
}
}

View File

@@ -0,0 +1,96 @@
package org.koitharu.kotatsu.filter.ui.tags
import android.os.Bundle
import android.text.Editable
import android.text.TextWatcher
import android.view.KeyEvent
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.inputmethod.EditorInfo
import android.widget.TextView
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.viewModels
import dagger.hilt.android.AndroidEntryPoint
import dagger.hilt.android.lifecycle.withCreationCallback
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.ui.sheet.AdaptiveSheetBehavior
import org.koitharu.kotatsu.core.ui.sheet.AdaptiveSheetCallback
import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.showDistinct
import org.koitharu.kotatsu.databinding.SheetTagsBinding
import org.koitharu.kotatsu.filter.ui.FilterOwner
import org.koitharu.kotatsu.filter.ui.model.TagCatalogItem
@AndroidEntryPoint
class TagsCatalogSheet : BaseAdaptiveSheet<SheetTagsBinding>(), OnListItemClickListener<TagCatalogItem>, TextWatcher,
AdaptiveSheetCallback, View.OnFocusChangeListener, TextView.OnEditorActionListener {
private val viewModel by viewModels<TagsCatalogViewModel>(
extrasProducer = {
defaultViewModelCreationExtras.withCreationCallback<TagsCatalogViewModel.Factory> { factory ->
factory.create((requireActivity() as FilterOwner).filter)
}
},
)
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): SheetTagsBinding {
return SheetTagsBinding.inflate(inflater, container, false)
}
override fun onViewBindingCreated(binding: SheetTagsBinding, savedInstanceState: Bundle?) {
super.onViewBindingCreated(binding, savedInstanceState)
val adapter = TagsCatalogAdapter(this)
binding.recyclerView.adapter = adapter
binding.recyclerView.setHasFixedSize(true)
binding.editSearch.setText(viewModel.searchQuery.value)
binding.editSearch.addTextChangedListener(this)
binding.editSearch.onFocusChangeListener = this
binding.editSearch.setOnEditorActionListener(this)
viewModel.content.observe(viewLifecycleOwner, adapter)
addSheetCallback(this)
disableFitToContents()
}
override fun onItemClick(item: TagCatalogItem, view: View) {
val filter = (requireActivity() as FilterOwner).filter
filter.setTag(item.tag, !item.isChecked)
}
override fun onFocusChange(v: View?, hasFocus: Boolean) {
setExpanded(
isExpanded = hasFocus || isExpanded,
isLocked = hasFocus,
)
}
override fun onEditorAction(v: TextView, actionId: Int, event: KeyEvent?): Boolean {
return if (actionId == EditorInfo.IME_ACTION_SEARCH) {
v.clearFocus()
true
} else {
false
}
}
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = Unit
override fun afterTextChanged(s: Editable?) {
val q = s?.toString().orEmpty()
viewModel.searchQuery.value = q
}
override fun onStateChanged(sheet: View, newState: Int) {
viewBinding?.recyclerView?.isFastScrollerEnabled = newState == AdaptiveSheetBehavior.STATE_EXPANDED
}
companion object {
private const val TAG = "TagsCatalogSheet"
fun show(fm: FragmentManager) = TagsCatalogSheet().showDistinct(fm, TAG)
}
}

View File

@@ -0,0 +1,60 @@
package org.koitharu.kotatsu.filter.ui.tags
import androidx.lifecycle.viewModelScope
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.plus
import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.filter.ui.MangaFilter
import org.koitharu.kotatsu.filter.ui.model.TagCatalogItem
import org.koitharu.kotatsu.list.ui.model.LoadingState
@HiltViewModel(assistedFactory = TagsCatalogViewModel.Factory::class)
class TagsCatalogViewModel @AssistedInject constructor(
@Assisted filter: MangaFilter,
mangaRepositoryFactory: MangaRepository.Factory,
dataRepository: MangaDataRepository,
) : BaseViewModel() {
val searchQuery = MutableStateFlow("")
private val tags = combine(
filter.allTags,
filter.filterTags.map { it.selectedItems },
) { all, selected ->
all.map { x ->
if (x is TagCatalogItem) {
val checked = x.tag in selected
if (x.isChecked == checked) {
x
} else {
x.copy(isChecked = checked)
}
} else {
x
}
}
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, filter.allTags.value)
val content = combine(tags, searchQuery) { raw, query ->
raw.filter { x ->
x !is TagCatalogItem || x.tag.title.contains(query, ignoreCase = true)
}
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, listOf(LoadingState))
@AssistedFactory
interface Factory {
fun create(filter: MangaFilter): TagsCatalogViewModel
}
}

View File

@@ -8,7 +8,6 @@ import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.onStart
import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.entity.toEntities
import org.koitharu.kotatsu.core.db.entity.toEntity import org.koitharu.kotatsu.core.db.entity.toEntity
import org.koitharu.kotatsu.core.db.entity.toManga import org.koitharu.kotatsu.core.db.entity.toManga
import org.koitharu.kotatsu.core.db.entity.toMangaTag import org.koitharu.kotatsu.core.db.entity.toMangaTag

View File

@@ -47,7 +47,7 @@ import javax.inject.Inject
@HiltViewModel @HiltViewModel
class HistoryListViewModel @Inject constructor( class HistoryListViewModel @Inject constructor(
private val repository: HistoryRepository, private val repository: HistoryRepository,
private val settings: AppSettings, settings: AppSettings,
private val extraProvider: ListExtraProvider, private val extraProvider: ListExtraProvider,
private val localMangaRepository: LocalMangaRepository, private val localMangaRepository: LocalMangaRepository,
networkState: NetworkState, networkState: NetworkState,

View File

@@ -7,13 +7,15 @@ import org.koitharu.kotatsu.list.ui.model.ErrorFooter
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
fun errorFooterAD( fun errorFooterAD(
listener: MangaListListener, listener: MangaListListener?,
) = adapterDelegateViewBinding<ErrorFooter, ListModel, ItemErrorFooterBinding>( ) = adapterDelegateViewBinding<ErrorFooter, ListModel, ItemErrorFooterBinding>(
{ inflater, parent -> ItemErrorFooterBinding.inflate(inflater, parent, false) }, { inflater, parent -> ItemErrorFooterBinding.inflate(inflater, parent, false) },
) { ) {
binding.root.setOnClickListener { if (listener != null) {
listener.onRetryClick(item.exception) binding.root.setOnClickListener {
listener.onRetryClick(item.exception)
}
} }
bind { bind {

View File

@@ -6,6 +6,7 @@ enum class ListItemType {
FILTER_TAG, FILTER_TAG,
FILTER_TAG_MULTI, FILTER_TAG_MULTI,
FILTER_STATE, FILTER_STATE,
FILTER_LANGUAGE,
HEADER, HEADER,
MANGA_LIST, MANGA_LIST,
MANGA_LIST_DETAILED, MANGA_LIST_DETAILED,

View File

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

View File

@@ -28,10 +28,10 @@ import org.koitharu.kotatsu.core.util.ext.textAndVisible
import org.koitharu.kotatsu.databinding.FragmentPreviewBinding import org.koitharu.kotatsu.databinding.FragmentPreviewBinding
import org.koitharu.kotatsu.details.ui.DetailsActivity import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.filter.ui.FilterOwner import org.koitharu.kotatsu.filter.ui.FilterOwner
import org.koitharu.kotatsu.filter.ui.model.FilterItem
import org.koitharu.kotatsu.image.ui.ImageActivity import org.koitharu.kotatsu.image.ui.ImageActivity
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.reader.ui.ReaderActivity
import org.koitharu.kotatsu.search.ui.MangaListActivity import org.koitharu.kotatsu.search.ui.MangaListActivity
import org.koitharu.kotatsu.search.ui.SearchActivity import org.koitharu.kotatsu.search.ui.SearchActivity
import javax.inject.Inject import javax.inject.Inject
@@ -57,8 +57,10 @@ class PreviewFragment : BaseFragment<FragmentPreviewBinding>(), View.OnClickList
binding.textViewAuthor.setOnClickListener(this) binding.textViewAuthor.setOnClickListener(this)
binding.imageViewCover.setOnClickListener(this) binding.imageViewCover.setOnClickListener(this)
binding.buttonOpen.setOnClickListener(this) binding.buttonOpen.setOnClickListener(this)
binding.buttonRead.setOnClickListener(this)
viewModel.manga.observe(viewLifecycleOwner, ::onMangaUpdated) viewModel.manga.observe(viewLifecycleOwner, ::onMangaUpdated)
viewModel.footer.observe(viewLifecycleOwner, ::onFooterUpdated)
viewModel.tagsChips.observe(viewLifecycleOwner, ::onTagsChipsChanged) viewModel.tagsChips.observe(viewLifecycleOwner, ::onTagsChipsChanged)
viewModel.description.observe(viewLifecycleOwner, ::onDescriptionChanged) viewModel.description.observe(viewLifecycleOwner, ::onDescriptionChanged)
} }
@@ -71,6 +73,14 @@ class PreviewFragment : BaseFragment<FragmentPreviewBinding>(), View.OnClickList
DetailsActivity.newIntent(v.context, manga), DetailsActivity.newIntent(v.context, manga),
) )
R.id.button_read -> {
startActivity(
ReaderActivity.IntentBuilder(v.context)
.manga(manga)
.build(),
)
}
R.id.textView_author -> startActivity( R.id.textView_author -> startActivity(
SearchActivity.newIntent( SearchActivity.newIntent(
context = v.context, context = v.context,
@@ -98,7 +108,7 @@ class PreviewFragment : BaseFragment<FragmentPreviewBinding>(), View.OnClickList
if (filter == null) { if (filter == null) {
startActivity(MangaListActivity.newIntent(requireContext(), setOf(tag))) startActivity(MangaListActivity.newIntent(requireContext(), setOf(tag)))
} else { } else {
filter.onTagItemClick(FilterItem.Tag(tag, filter.header.value.allowMultipleTags, false)) filter.setTag(tag, true)
closeSelf() closeSelf()
} }
} }
@@ -119,6 +129,43 @@ class PreviewFragment : BaseFragment<FragmentPreviewBinding>(), View.OnClickList
} }
} }
private fun onFooterUpdated(footer: PreviewViewModel.FooterInfo?) {
with(requireViewBinding()) {
toolbarBottom.isVisible = footer != null
if (footer == null) {
return
}
toolbarBottom.title = when {
footer.isInProgress() -> {
getString(R.string.chapter_d_of_d, footer.currentChapter, footer.totalChapters)
}
footer.totalChapters > 0 -> {
resources.getQuantityString(R.plurals.chapters, footer.totalChapters, footer.totalChapters)
}
else -> {
getString(R.string.no_chapters)
}
}
buttonRead.isEnabled = footer.totalChapters > 0
buttonRead.setIconResource(
when {
footer.isIncognito -> R.drawable.ic_incognito
footer.isInProgress() -> R.drawable.ic_play
else -> R.drawable.ic_read
},
)
buttonRead.setText(
if (footer.isInProgress()) {
R.string._continue
} else {
R.string.read
},
)
}
}
private fun onDescriptionChanged(description: CharSequence?) { private fun onDescriptionChanged(description: CharSequence?) {
val tv = viewBinding?.textViewDescription ?: return val tv = viewBinding?.textViewDescription ?: return
when { when {

View File

@@ -13,11 +13,14 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.distinctUntilChangedBy import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.transformLatest import kotlinx.coroutines.flow.transformLatest
import kotlinx.coroutines.plus import kotlinx.coroutines.plus
import org.koitharu.kotatsu.core.model.getPreferredBranch
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.parser.MangaIntent import org.koitharu.kotatsu.core.parser.MangaIntent
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
@@ -25,6 +28,7 @@ import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.ui.widgets.ChipsView import org.koitharu.kotatsu.core.ui.widgets.ChipsView
import org.koitharu.kotatsu.core.util.ext.require import org.koitharu.kotatsu.core.util.ext.require
import org.koitharu.kotatsu.core.util.ext.sanitize import org.koitharu.kotatsu.core.util.ext.sanitize
import org.koitharu.kotatsu.history.data.HistoryRepository
import org.koitharu.kotatsu.list.domain.ListExtraProvider import org.koitharu.kotatsu.list.domain.ListExtraProvider
import javax.inject.Inject import javax.inject.Inject
@@ -33,6 +37,7 @@ class PreviewViewModel @Inject constructor(
savedStateHandle: SavedStateHandle, savedStateHandle: SavedStateHandle,
private val extraProvider: ListExtraProvider, private val extraProvider: ListExtraProvider,
private val repositoryFactory: MangaRepository.Factory, private val repositoryFactory: MangaRepository.Factory,
private val historyRepository: HistoryRepository,
private val imageGetter: Html.ImageGetter, private val imageGetter: Html.ImageGetter,
) : BaseViewModel() { ) : BaseViewModel() {
@@ -40,6 +45,26 @@ class PreviewViewModel @Inject constructor(
savedStateHandle.require<ParcelableManga>(MangaIntent.KEY_MANGA).manga, savedStateHandle.require<ParcelableManga>(MangaIntent.KEY_MANGA).manga,
) )
val footer = combine(
manga,
historyRepository.observeOne(manga.value.id),
manga.flatMapLatest { historyRepository.observeShouldSkip(it) }.distinctUntilChanged(),
) { m, history, incognito ->
if (m.chapters == null) {
return@combine null
}
val b = m.getPreferredBranch(history)
val chapters = m.getChapters(b).orEmpty()
FooterInfo(
branch = b,
currentChapter = history?.chapterId?.let {
chapters.indexOfFirst { x -> x.id == it }
} ?: -1,
totalChapters = chapters.size,
isIncognito = incognito,
)
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, null)
val description = manga val description = manga
.distinctUntilChangedBy { it.description.orEmpty() } .distinctUntilChangedBy { it.description.orEmpty() }
.transformLatest { .transformLatest {
@@ -82,4 +107,14 @@ class PreviewViewModel @Inject constructor(
} }
return spannable.trim() return spannable.trim()
} }
data class FooterInfo(
val branch: String?,
val currentChapter: Int,
val totalChapters: Int,
val isIncognito: Boolean,
) {
fun isInProgress() = currentChapter >= 0
}
} }

View File

@@ -35,6 +35,7 @@ import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import java.io.File import java.io.File
import java.util.EnumSet import java.util.EnumSet
import java.util.Locale
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -132,7 +133,7 @@ class LocalMangaRepository @Inject constructor(
}.getOrNull() }.getOrNull()
} }
suspend fun findSavedManga(remoteManga: Manga): LocalManga? { suspend fun findSavedManga(remoteManga: Manga): LocalManga? = runCatchingCancellable {
// fast path // fast path
LocalMangaInput.find(storageManager.getReadableDirs(), remoteManga)?.let { LocalMangaInput.find(storageManager.getReadableDirs(), remoteManga)?.let {
return it.getManga() return it.getManga()
@@ -154,12 +155,16 @@ class LocalMangaRepository @Inject constructor(
} }
} }
}.firstOrNull()?.getManga() }.firstOrNull()?.getManga()
} }.onFailure {
it.printStackTraceDebug()
}.getOrNull()
override suspend fun getPageUrl(page: MangaPage) = page.url override suspend fun getPageUrl(page: MangaPage) = page.url
override suspend fun getTags() = emptySet<MangaTag>() override suspend fun getTags() = emptySet<MangaTag>()
override suspend fun getLocales() = emptySet<Locale>()
override suspend fun getRelated(seed: Manga): List<Manga> = emptyList() override suspend fun getRelated(seed: Manga): List<Manga> = emptyList()
suspend fun getOutputDir(manga: Manga): File? { suspend fun getOutputDir(manga: Manga): File? {

View File

@@ -2,13 +2,8 @@ package org.koitharu.kotatsu.local.data
import java.io.File import java.io.File
import java.io.FileFilter import java.io.FileFilter
import java.io.FilenameFilter
class TempFileFilter : FilenameFilter, FileFilter { class TempFileFilter : FileFilter {
override fun accept(dir: File, name: String): Boolean {
return name.endsWith(".tmp", ignoreCase = true)
}
override fun accept(file: File): Boolean { override fun accept(file: File): Boolean {
return file.name.endsWith(".tmp", ignoreCase = true) return file.name.endsWith(".tmp", ignoreCase = true)

View File

@@ -20,6 +20,7 @@ import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.toCamelCase import org.koitharu.kotatsu.parsers.util.toCamelCase
import java.io.File import java.io.File
import java.util.TreeMap
import java.util.zip.ZipFile import java.util.zip.ZipFile
/** /**
@@ -49,8 +50,15 @@ class LocalMangaDirInput(root: File) : LocalMangaInput(root) {
url = mangaUri, url = mangaUri,
coverUrl = cover, coverUrl = cover,
largeCoverUrl = cover, largeCoverUrl = cover,
chapters = info.chapters?.mapIndexed { i, c -> chapters = info.chapters?.mapIndexedNotNull { i, c ->
c.copy(url = chapterFiles[i].toUri().toString(), source = MangaSource.LOCAL) val fileName = index.getChapterFileName(c.id)
val file = if (fileName != null) {
chapterFiles[fileName]
} else {
// old downloads
chapterFiles.values.elementAtOrNull(i)
} ?: return@mapIndexedNotNull null
c.copy(url = file.toUri().toString(), source = MangaSource.LOCAL)
}, },
) ?: Manga( ) ?: Manga(
id = root.absolutePath.longHashCode(), id = root.absolutePath.longHashCode(),
@@ -59,7 +67,7 @@ class LocalMangaDirInput(root: File) : LocalMangaInput(root) {
publicUrl = mangaUri, publicUrl = mangaUri,
source = MangaSource.LOCAL, source = MangaSource.LOCAL,
coverUrl = findFirstImageEntry().orEmpty(), coverUrl = findFirstImageEntry().orEmpty(),
chapters = chapterFiles.mapIndexed { i, f -> chapters = chapterFiles.values.mapIndexed { i, f ->
MangaChapter( MangaChapter(
id = "$i${f.name}".longHashCode(), id = "$i${f.name}".longHashCode(),
name = f.nameWithoutExtension.toHumanReadable(), name = f.nameWithoutExtension.toHumanReadable(),
@@ -120,9 +128,9 @@ class LocalMangaDirInput(root: File) : LocalMangaInput(root) {
private fun String.toHumanReadable() = replace("_", " ").toCamelCase() private fun String.toHumanReadable() = replace("_", " ").toCamelCase()
private fun getChaptersFiles(): List<File> = root.walkCompat() private fun getChaptersFiles() = root.walkCompat()
.filter { it.hasCbzExtension() } .filter { it.hasCbzExtension() }
.toListSorted(compareBy(AlphanumComparator()) { it.name }) .associateByTo(TreeMap(AlphanumComparator())) { it.name }
private fun findFirstImageEntry(): String? { private fun findFirstImageEntry(): String? {
return root.walkCompat().firstOrNull { hasImageExtension(it) }?.toUri()?.toString() return root.walkCompat().firstOrNull { hasImageExtension(it) }?.toUri()?.toString()

View File

@@ -13,6 +13,7 @@ import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.parsers.util.toFileNameSafe import org.koitharu.kotatsu.parsers.util.toFileNameSafe
import java.io.File import java.io.File
@@ -54,7 +55,8 @@ sealed class LocalMangaInput(
zip.isFile -> LocalMangaZipInput(zip) zip.isFile -> LocalMangaZipInput(zip)
else -> null else -> null
} }
if (input?.getMangaInfo()?.id == manga.id) { val info = runCatchingCancellable { input?.getMangaInfo() }.getOrNull()
if (info?.id == manga.id) {
send(input) send(input)
} }
} }

View File

@@ -2,6 +2,8 @@ package org.koitharu.kotatsu.local.data.output
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.koitharu.kotatsu.core.model.findById import org.koitharu.kotatsu.core.model.findById
import org.koitharu.kotatsu.core.model.isLocal import org.koitharu.kotatsu.core.model.isLocal
import org.koitharu.kotatsu.core.util.ext.deleteAwait import org.koitharu.kotatsu.core.util.ext.deleteAwait
@@ -20,6 +22,7 @@ class LocalMangaDirOutput(
private val chaptersOutput = HashMap<MangaChapter, ZipOutput>() private val chaptersOutput = HashMap<MangaChapter, ZipOutput>()
private val index = MangaIndex(File(rootFile, ENTRY_NAME_INDEX).takeIfReadable()?.readText()) private val index = MangaIndex(File(rootFile, ENTRY_NAME_INDEX).takeIfReadable()?.readText())
private val mutex = Mutex()
init { init {
if (!manga.isLocal) { if (!manga.isLocal) {
@@ -29,7 +32,7 @@ class LocalMangaDirOutput(
override suspend fun mergeWithExisting() = Unit override suspend fun mergeWithExisting() = Unit
override suspend fun addCover(file: File, ext: String) { override suspend fun addCover(file: File, ext: String) = mutex.withLock {
val name = buildString { val name = buildString {
append("cover") append("cover")
if (ext.isNotEmpty() && ext.length <= 4) { if (ext.isNotEmpty() && ext.length <= 4) {
@@ -44,7 +47,7 @@ class LocalMangaDirOutput(
flushIndex() flushIndex()
} }
override suspend fun addPage(chapter: MangaChapter, file: File, pageNumber: Int, ext: String) { override suspend fun addPage(chapter: MangaChapter, file: File, pageNumber: Int, ext: String) = mutex.withLock {
val output = chaptersOutput.getOrPut(chapter) { val output = chaptersOutput.getOrPut(chapter) {
ZipOutput(File(rootFile, chapterFileName(chapter) + SUFFIX_TMP)) ZipOutput(File(rootFile, chapterFileName(chapter) + SUFFIX_TMP))
} }
@@ -61,14 +64,14 @@ class LocalMangaDirOutput(
index.addChapter(chapter, chapterFileName(chapter)) index.addChapter(chapter, chapterFileName(chapter))
} }
override suspend fun flushChapter(chapter: MangaChapter): Boolean { override suspend fun flushChapter(chapter: MangaChapter): Boolean = mutex.withLock {
val output = chaptersOutput.remove(chapter) ?: return false val output = chaptersOutput.remove(chapter) ?: return@withLock false
output.flushAndFinish() output.flushAndFinish()
flushIndex() flushIndex()
return true true
} }
override suspend fun finish() { override suspend fun finish() = mutex.withLock {
flushIndex() flushIndex()
for (output in chaptersOutput.values) { for (output in chaptersOutput.values) {
output.flushAndFinish() output.flushAndFinish()
@@ -76,7 +79,7 @@ class LocalMangaDirOutput(
chaptersOutput.clear() chaptersOutput.clear()
} }
override suspend fun cleanup() { override suspend fun cleanup() = mutex.withLock {
for (output in chaptersOutput.values) { for (output in chaptersOutput.values) {
output.file.deleteAwait() output.file.deleteAwait()
} }
@@ -88,7 +91,7 @@ class LocalMangaDirOutput(
} }
} }
suspend fun deleteChapter(chapterId: Long) { suspend fun deleteChapter(chapterId: Long) = mutex.withLock {
val chapter = checkNotNull(index.getMangaInfo()?.chapters) { val chapter = checkNotNull(index.getMangaInfo()?.chapters) {
"No chapters found" "No chapters found"
}.findById(chapterId) ?: error("Chapter not found") }.findById(chapterId) ?: error("Chapter not found")

View File

@@ -5,9 +5,11 @@ import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okio.Closeable import okio.Closeable
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.local.data.input.LocalMangaInput import org.koitharu.kotatsu.local.data.input.LocalMangaInput
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.parsers.util.toFileNameSafe import org.koitharu.kotatsu.parsers.util.toFileNameSafe
import java.io.File import java.io.File
@@ -86,7 +88,11 @@ sealed class LocalMangaOutput(
} }
private suspend fun canWriteTo(file: File, manga: Manga): Boolean { private suspend fun canWriteTo(file: File, manga: Manga): Boolean {
val info = LocalMangaInput.of(file).getMangaInfo() ?: return false val info = runCatchingCancellable {
LocalMangaInput.of(file).getMangaInfo()
}.onFailure {
it.printStackTraceDebug()
}.getOrNull() ?: return false
return info.id == manga.id return info.id == manga.id
} }
} }

View File

@@ -3,6 +3,8 @@ package org.koitharu.kotatsu.local.data.output
import androidx.annotation.WorkerThread import androidx.annotation.WorkerThread
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.koitharu.kotatsu.core.model.isLocal import org.koitharu.kotatsu.core.model.isLocal
import org.koitharu.kotatsu.core.util.ext.deleteAwait import org.koitharu.kotatsu.core.util.ext.deleteAwait
import org.koitharu.kotatsu.core.util.ext.readText import org.koitharu.kotatsu.core.util.ext.readText
@@ -20,6 +22,7 @@ class LocalMangaZipOutput(
private val output = ZipOutput(File(rootFile.path + ".tmp")) private val output = ZipOutput(File(rootFile.path + ".tmp"))
private val index = MangaIndex(null) private val index = MangaIndex(null)
private val mutex = Mutex()
init { init {
if (!manga.isLocal) { if (!manga.isLocal) {
@@ -27,7 +30,7 @@ class LocalMangaZipOutput(
} }
} }
override suspend fun mergeWithExisting() { override suspend fun mergeWithExisting() = mutex.withLock {
if (rootFile.exists()) { if (rootFile.exists()) {
runInterruptible(Dispatchers.IO) { runInterruptible(Dispatchers.IO) {
mergeWith(rootFile) mergeWith(rootFile)
@@ -35,7 +38,7 @@ class LocalMangaZipOutput(
} }
} }
override suspend fun addCover(file: File, ext: String) { override suspend fun addCover(file: File, ext: String) = mutex.withLock {
val name = buildString { val name = buildString {
append(FILENAME_PATTERN.format(0, 0, 0)) append(FILENAME_PATTERN.format(0, 0, 0))
if (ext.isNotEmpty() && ext.length <= 4) { if (ext.isNotEmpty() && ext.length <= 4) {
@@ -49,7 +52,7 @@ class LocalMangaZipOutput(
index.setCoverEntry(name) index.setCoverEntry(name)
} }
override suspend fun addPage(chapter: MangaChapter, file: File, pageNumber: Int, ext: String) { override suspend fun addPage(chapter: MangaChapter, file: File, pageNumber: Int, ext: String) = mutex.withLock {
val name = buildString { val name = buildString {
append(FILENAME_PATTERN.format(chapter.branch.hashCode(), chapter.number, pageNumber)) append(FILENAME_PATTERN.format(chapter.branch.hashCode(), chapter.number, pageNumber))
if (ext.isNotEmpty() && ext.length <= 4) { if (ext.isNotEmpty() && ext.length <= 4) {
@@ -65,7 +68,7 @@ class LocalMangaZipOutput(
override suspend fun flushChapter(chapter: MangaChapter): Boolean = false override suspend fun flushChapter(chapter: MangaChapter): Boolean = false
override suspend fun finish() { override suspend fun finish() = mutex.withLock {
runInterruptible(Dispatchers.IO) { runInterruptible(Dispatchers.IO) {
output.put(ENTRY_NAME_INDEX, index.toString()) output.put(ENTRY_NAME_INDEX, index.toString())
output.finish() output.finish()
@@ -73,10 +76,12 @@ class LocalMangaZipOutput(
} }
rootFile.deleteAwait() rootFile.deleteAwait()
output.file.renameTo(rootFile) output.file.renameTo(rootFile)
Unit
} }
override suspend fun cleanup() { override suspend fun cleanup() = mutex.withLock {
output.file.deleteAwait() output.file.deleteAwait()
Unit
} }
override fun close() { override fun close() {

View File

@@ -18,8 +18,8 @@ import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.withArgs import org.koitharu.kotatsu.core.util.ext.withArgs
import org.koitharu.kotatsu.databinding.FragmentListBinding import org.koitharu.kotatsu.databinding.FragmentListBinding
import org.koitharu.kotatsu.filter.ui.FilterOwner import org.koitharu.kotatsu.filter.ui.FilterOwner
import org.koitharu.kotatsu.filter.ui.FilterSheetFragment
import org.koitharu.kotatsu.filter.ui.MangaFilter import org.koitharu.kotatsu.filter.ui.MangaFilter
import org.koitharu.kotatsu.filter.ui.sheet.FilterSheetFragment
import org.koitharu.kotatsu.list.ui.MangaListFragment import org.koitharu.kotatsu.list.ui.MangaListFragment
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.remotelist.ui.RemoteListFragment import org.koitharu.kotatsu.remotelist.ui.RemoteListFragment
@@ -94,7 +94,7 @@ class LocalListFragment : MangaListFragment(), FilterOwner {
Snackbar.make( Snackbar.make(
requireViewBinding().recyclerView, requireViewBinding().recyclerView,
R.string.removal_completed, R.string.removal_completed,
Snackbar.LENGTH_SHORT Snackbar.LENGTH_SHORT,
).show() ).show()
} }
@@ -103,7 +103,7 @@ class LocalListFragment : MangaListFragment(), FilterOwner {
fun newInstance() = LocalListFragment().withArgs(1) { fun newInstance() = LocalListFragment().withArgs(1) {
putSerializable( putSerializable(
RemoteListFragment.ARG_SOURCE, RemoteListFragment.ARG_SOURCE,
MangaSource.LOCAL MangaSource.LOCAL,
) // required by FilterCoordinator ) // required by FilterCoordinator
} }
} }

View File

@@ -12,6 +12,7 @@ import androidx.work.WorkerParameters
import androidx.work.await import androidx.work.await
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.local.data.LocalMangaRepository import org.koitharu.kotatsu.local.data.LocalMangaRepository
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
@@ -20,10 +21,12 @@ class LocalStorageCleanupWorker @AssistedInject constructor(
@Assisted appContext: Context, @Assisted appContext: Context,
@Assisted params: WorkerParameters, @Assisted params: WorkerParameters,
private val localMangaRepository: LocalMangaRepository, private val localMangaRepository: LocalMangaRepository,
private val dataRepository: MangaDataRepository,
) : CoroutineWorker(appContext, params) { ) : CoroutineWorker(appContext, params) {
override suspend fun doWork(): Result { override suspend fun doWork(): Result {
return if (localMangaRepository.cleanup()) { return if (localMangaRepository.cleanup()) {
dataRepository.cleanupLocalManga()
Result.success() Result.success()
} else { } else {
Result.retry() Result.retry()

View File

@@ -55,6 +55,7 @@ import org.koitharu.kotatsu.history.ui.HistoryListFragment
import org.koitharu.kotatsu.local.ui.LocalStorageCleanupWorker import org.koitharu.kotatsu.local.ui.LocalStorageCleanupWorker
import org.koitharu.kotatsu.main.ui.owners.AppBarOwner import org.koitharu.kotatsu.main.ui.owners.AppBarOwner
import org.koitharu.kotatsu.main.ui.owners.BottomNavOwner import org.koitharu.kotatsu.main.ui.owners.BottomNavOwner
import org.koitharu.kotatsu.main.ui.welcome.WelcomeSheet
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
@@ -66,7 +67,6 @@ import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionListener
import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionViewModel import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionViewModel
import org.koitharu.kotatsu.settings.SettingsActivity import org.koitharu.kotatsu.settings.SettingsActivity
import org.koitharu.kotatsu.settings.about.AppUpdateDialog import org.koitharu.kotatsu.settings.about.AppUpdateDialog
import org.koitharu.kotatsu.settings.onboard.OnboardDialogFragment
import javax.inject.Inject import javax.inject.Inject
import com.google.android.material.R as materialR import com.google.android.material.R as materialR
@@ -142,7 +142,7 @@ class MainActivity : BaseActivity<ActivityMainBinding>(), AppBarOwner, BottomNav
viewModel.counters.observe(this, ::onCountersChanged) viewModel.counters.observe(this, ::onCountersChanged)
viewModel.appUpdate.observe(this, MenuInvalidator(this)) viewModel.appUpdate.observe(this, MenuInvalidator(this))
viewModel.onFirstStart.observeEvent(this) { viewModel.onFirstStart.observeEvent(this) {
OnboardDialogFragment.show(supportFragmentManager) WelcomeSheet.show(supportFragmentManager)
} }
viewModel.isIncognitoMode.observe(this) { viewModel.isIncognitoMode.observe(this) {
adjustSearchUI(isSearchOpened(), false) adjustSearchUI(isSearchOpened(), false)

View File

@@ -0,0 +1,133 @@
package org.koitharu.kotatsu.main.ui.welcome
import android.accounts.AccountManager
import android.net.Uri
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.activity.result.ActivityResultCallback
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.view.isGone
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.viewModels
import com.google.android.material.chip.Chip
import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.titleResId
import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet
import org.koitharu.kotatsu.core.ui.widgets.ChipsView
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.showDistinct
import org.koitharu.kotatsu.core.util.ext.tryLaunch
import org.koitharu.kotatsu.databinding.SheetWelcomeBinding
import org.koitharu.kotatsu.filter.ui.model.FilterProperty
import org.koitharu.kotatsu.parsers.model.ContentType
import org.koitharu.kotatsu.parsers.util.toTitleCase
import org.koitharu.kotatsu.settings.backup.RestoreDialogFragment
import java.util.Locale
@AndroidEntryPoint
class WelcomeSheet : BaseAdaptiveSheet<SheetWelcomeBinding>(), ChipsView.OnChipClickListener, View.OnClickListener,
ActivityResultCallback<Uri?> {
private val viewModel by viewModels<WelcomeViewModel>()
private val backupSelectCall = registerForActivityResult(
ActivityResultContracts.OpenDocument(),
this,
)
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): SheetWelcomeBinding {
return SheetWelcomeBinding.inflate(inflater, container, false)
}
override fun onViewBindingCreated(binding: SheetWelcomeBinding, savedInstanceState: Bundle?) {
super.onViewBindingCreated(binding, savedInstanceState)
binding.textViewWelcomeTitle.isGone = resources.getBoolean(R.bool.is_tablet)
binding.chipsLocales.onChipClickListener = this
binding.chipsType.onChipClickListener = this
binding.chipBackup.setOnClickListener(this)
binding.chipSync.setOnClickListener(this)
viewModel.locales.observe(viewLifecycleOwner, ::onLocalesChanged)
viewModel.types.observe(viewLifecycleOwner, ::onTypesChanged)
}
override fun onChipClick(chip: Chip, data: Any?) {
when (data) {
is ContentType -> viewModel.setTypeChecked(data, chip.isChecked)
is Locale? -> viewModel.setLocaleChecked(data, chip.isChecked)
}
}
override fun onClick(v: View) {
when (v.id) {
R.id.chip_backup -> {
if (!backupSelectCall.tryLaunch(arrayOf("*/*"))) {
Snackbar.make(
v, R.string.operation_not_supported, Snackbar.LENGTH_SHORT,
).show()
}
}
R.id.chip_sync -> {
val am = AccountManager.get(v.context)
val accountType = getString(R.string.account_type_sync)
am.addAccount(accountType, accountType, null, null, requireActivity(), null, null)
}
}
}
override fun onActivityResult(result: Uri?) {
if (result != null) {
RestoreDialogFragment.show(parentFragmentManager, result)
}
}
private fun onLocalesChanged(value: FilterProperty<Locale?>) {
val chips = viewBinding?.chipsLocales ?: return
chips.setChips(
value.availableItems.map {
ChipsView.ChipModel(
tint = 0,
title = it?.getDisplayLanguage(it)?.toTitleCase(it) ?: getString(R.string.various_languages),
icon = 0,
isCheckable = true,
isChecked = it in value.selectedItems,
data = it,
)
},
)
}
private fun onTypesChanged(value: FilterProperty<ContentType>) {
val chips = viewBinding?.chipsType ?: return
chips.setChips(
value.availableItems.map {
ChipsView.ChipModel(
tint = 0,
title = getString(it.titleResId),
icon = 0,
isCheckable = true,
isChecked = it in value.selectedItems,
data = it,
)
},
)
}
companion object {
private const val TAG = "WelcomeSheet"
fun show(fm: FragmentManager) = WelcomeSheet().showDistinct(fm, TAG)
fun dismiss(fm: FragmentManager): Boolean {
val sheet = fm.findFragmentByTag(TAG) as? WelcomeSheet ?: return false
sheet.dismissAllowingStateLoss()
return true
}
}
}

View File

@@ -0,0 +1,107 @@
package org.koitharu.kotatsu.main.ui.welcome
import android.content.Context
import androidx.core.os.ConfigurationCompat
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.util.LocaleComparator
import org.koitharu.kotatsu.core.util.ext.sortedWithSafe
import org.koitharu.kotatsu.core.util.ext.toList
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
import org.koitharu.kotatsu.filter.ui.model.FilterProperty
import org.koitharu.kotatsu.parsers.model.ContentType
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.mapToSet
import java.util.EnumSet
import java.util.Locale
import javax.inject.Inject
@HiltViewModel
class WelcomeViewModel @Inject constructor(
private val repository: MangaSourcesRepository,
@ApplicationContext context: Context,
) : BaseViewModel() {
private val allSources = repository.allMangaSources
private val localesGroups by lazy { allSources.groupBy { it.locale?.let { x -> Locale(x) } } }
private var updateJob: Job
val locales = MutableStateFlow(
FilterProperty<Locale?>(
availableItems = listOf(null),
selectedItems = setOf(null),
isLoading = true,
error = null,
),
)
val types = MutableStateFlow(
FilterProperty(
availableItems = ContentType.entries.toList(),
selectedItems = setOf(ContentType.MANGA),
isLoading = false,
error = null,
),
)
init {
updateJob = launchJob(Dispatchers.Default) {
val languages = localesGroups.keys.associateBy { x -> x?.language }
val selectedLocales = HashSet<Locale?>(2)
selectedLocales += ConfigurationCompat.getLocales(context.resources.configuration).toList()
.firstNotNullOfOrNull { lc -> languages[lc.language] }
selectedLocales += null
locales.value = locales.value.copy(
availableItems = localesGroups.keys.sortedWithSafe(nullsFirst(LocaleComparator())),
selectedItems = selectedLocales,
isLoading = false,
)
}
}
fun setLocaleChecked(locale: Locale?, isChecked: Boolean) {
val snapshot = locales.value
locales.value = snapshot.copy(
selectedItems = if (isChecked) {
snapshot.selectedItems + locale
} else {
snapshot.selectedItems - locale
},
)
val prevJob = updateJob
updateJob = launchJob(Dispatchers.Default) {
prevJob.join()
commit()
}
}
fun setTypeChecked(type: ContentType, isChecked: Boolean) {
val snapshot = types.value
types.value = snapshot.copy(
selectedItems = if (isChecked) {
snapshot.selectedItems + type
} else {
snapshot.selectedItems - type
},
)
val prevJob = updateJob
updateJob = launchJob(Dispatchers.Default) {
prevJob.join()
commit()
}
}
private suspend fun commit() {
val languages = locales.value.selectedItems.mapToSet { it?.language }
val types = types.value.selectedItems
val enabledSources = allSources.filterTo(EnumSet.noneOf(MangaSource::class.java)) { x ->
x.contentType in types && x.locale in languages
}
repository.setSourcesEnabledExclusive(enabledSources)
}
}

View File

@@ -7,13 +7,17 @@ data class ReaderColorFilter(
val brightness: Float, val brightness: Float,
val contrast: Float, val contrast: Float,
val isInverted: Boolean, val isInverted: Boolean,
val isGrayscale: Boolean,
) { ) {
val isEmpty: Boolean val isEmpty: Boolean
get() = !isInverted && brightness == 0f && contrast == 0f get() = !isGrayscale && !isInverted && brightness == 0f && contrast == 0f
fun toColorFilter(): ColorMatrixColorFilter { fun toColorFilter(): ColorMatrixColorFilter {
val cm = ColorMatrix() val cm = ColorMatrix()
if (isGrayscale) {
cm.grayscale()
}
if (isInverted) { if (isInverted) {
cm.inverted() cm.inverted()
} }
@@ -49,6 +53,20 @@ data class ReaderColorFilter(
0.0f, 0.0f, -1.0f, 1.0f, 1.0f, 0.0f, 0.0f, -1.0f, 1.0f, 1.0f,
0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f,
) )
set(matrix) postConcat(ColorMatrix(matrix))
}
private fun ColorMatrix.grayscale() {
setSaturation(0f)
}
companion object {
val EMPTY = ReaderColorFilter(
brightness = 0.0f,
contrast = 0.0f,
isInverted = false,
isGrayscale = false,
)
} }
} }

View File

@@ -16,6 +16,7 @@ import coil.ImageLoader
import coil.request.ImageRequest import coil.request.ImageRequest
import coil.size.Scale import coil.size.Scale
import coil.size.ViewSizeResolver import coil.size.ViewSizeResolver
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.slider.LabelFormatter import com.google.android.material.slider.LabelFormatter
import com.google.android.material.slider.Slider import com.google.android.material.slider.Slider
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
@@ -62,6 +63,7 @@ class ColorFilterConfigActivity :
viewBinding.sliderContrast.setLabelFormatter(formatter) viewBinding.sliderContrast.setLabelFormatter(formatter)
viewBinding.sliderBrightness.setLabelFormatter(formatter) viewBinding.sliderBrightness.setLabelFormatter(formatter)
viewBinding.switchInvert.setOnCheckedChangeListener(this) viewBinding.switchInvert.setOnCheckedChangeListener(this)
viewBinding.switchGrayscale.setOnCheckedChangeListener(this)
viewBinding.buttonDone.setOnClickListener(this) viewBinding.buttonDone.setOnClickListener(this)
viewBinding.buttonReset.setOnClickListener(this) viewBinding.buttonReset.setOnClickListener(this)
@@ -84,13 +86,16 @@ class ColorFilterConfigActivity :
} }
} }
override fun onCheckedChanged(buttonView: CompoundButton?, isChecked: Boolean) { override fun onCheckedChanged(buttonView: CompoundButton, isChecked: Boolean) {
viewModel.setInversion(isChecked) when (buttonView.id) {
R.id.switch_invert -> viewModel.setInversion(isChecked)
R.id.switch_grayscale -> viewModel.setGrayscale(isChecked)
}
} }
override fun onClick(v: View) { override fun onClick(v: View) {
when (v.id) { when (v.id) {
R.id.button_done -> viewModel.save() R.id.button_done -> showSaveConfirmation()
R.id.button_reset -> viewModel.reset() R.id.button_reset -> viewModel.reset()
} }
} }
@@ -108,10 +113,23 @@ class ColorFilterConfigActivity :
} }
} }
fun showSaveConfirmation() {
MaterialAlertDialogBuilder(this)
.setTitle(R.string.apply)
.setMessage(R.string.color_correction_apply_text)
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(R.string.this_manga) { _, _ ->
viewModel.saveGlobally()
}.setNeutralButton(R.string.globally) { _, _ ->
viewModel.save()
}.show()
}
private fun onColorFilterChanged(readerColorFilter: ReaderColorFilter?) { private fun onColorFilterChanged(readerColorFilter: ReaderColorFilter?) {
viewBinding.sliderBrightness.setValueRounded(readerColorFilter?.brightness ?: 0f) viewBinding.sliderBrightness.setValueRounded(readerColorFilter?.brightness ?: 0f)
viewBinding.sliderContrast.setValueRounded(readerColorFilter?.contrast ?: 0f) viewBinding.sliderContrast.setValueRounded(readerColorFilter?.contrast ?: 0f)
viewBinding.switchInvert.setChecked(readerColorFilter?.isInverted ?: false, false) viewBinding.switchInvert.setChecked(readerColorFilter?.isInverted ?: false, false)
viewBinding.switchGrayscale.setChecked(readerColorFilter?.isGrayscale ?: false, false)
viewBinding.imageViewAfter.colorFilter = readerColorFilter?.toColorFilter() viewBinding.imageViewAfter.colorFilter = readerColorFilter?.toColorFilter()
} }
@@ -133,6 +151,8 @@ class ColorFilterConfigActivity :
private fun onLoadingChanged(isLoading: Boolean) { private fun onLoadingChanged(isLoading: Boolean) {
viewBinding.sliderContrast.isEnabled = !isLoading viewBinding.sliderContrast.isEnabled = !isLoading
viewBinding.sliderBrightness.isEnabled = !isLoading viewBinding.sliderBrightness.isEnabled = !isLoading
viewBinding.switchInvert.isEnabled = !isLoading
viewBinding.switchGrayscale.isEnabled = !isLoading
viewBinding.buttonDone.isEnabled = !isLoading viewBinding.buttonDone.isEnabled = !isLoading
} }

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