Compare commits

..

92 Commits

Author SHA1 Message Date
J. Lavoie
ff58539e2e Translated using Weblate (French)
Currently translated at 100.0% (398 of 398 strings)

Co-authored-by: J. Lavoie <j.lavoie@net-c.ca>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fr/
Translation: Kotatsu/Strings
2022-10-22 18:33:34 +03:00
d8e7689a94 Update ssiv 2022-10-22 17:53:53 +03:00
32cfbb327c Fix potential crash related to slider 2022-10-21 17:57:35 +03:00
245e87256b Merge branch 'master' of github.com:KotatsuApp/Kotatsu 2022-10-20 10:38:34 +03:00
ed8c69037f Merge branch 'devel' 2022-10-20 10:31:23 +03:00
Eric
3f76d22d67 Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (398 of 398 strings)

Co-authored-by: Eric <hamburger1024@mailbox.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2022-10-20 10:05:11 +03:00
980988e684 Translated using Weblate (Russian)
Currently translated at 99.7% (397 of 398 strings)

Co-authored-by: Koitharu <nvasya95@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translation: Kotatsu/Strings
2022-10-20 10:05:11 +03:00
Allan Nordhøy
347811abb6 Translated using Weblate (Norwegian Bokmål)
Currently translated at 82.9% (330 of 398 strings)

Co-authored-by: Allan Nordhøy <epost@anotheragency.no>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/nb_NO/
Translation: Kotatsu/Strings
2022-10-20 10:05:11 +03:00
kuragehime
ccb8b0c8e7 Translated using Weblate (Japanese)
Currently translated at 100.0% (397 of 397 strings)

Co-authored-by: kuragehime <kuragehime641@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ja/
Translation: Kotatsu/Strings
2022-10-20 10:05:11 +03:00
Eric
18137ab48e Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (397 of 397 strings)

Co-authored-by: Eric <lessonaeamazon@paranoid.email>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2022-10-20 10:05:11 +03:00
Dpper
a9f435ae3d Translated using Weblate (Ukrainian)
Currently translated at 100.0% (397 of 397 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (8 of 8 strings)

Co-authored-by: Dpper <ruslan20020401@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/uk/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/uk/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2022-10-20 10:05:11 +03:00
0758cfef64 Translated using Weblate (Russian)
Currently translated at 99.7% (396 of 397 strings)

Co-authored-by: Zakhar Timoshenko <vp1984tanki@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translation: Kotatsu/Strings
2022-10-20 10:05:11 +03:00
06d1d56448 Update parsers 2022-10-20 10:04:52 +03:00
07c70eaccc Update strings in list mode bs 2022-10-20 09:25:20 +03:00
5ad6413952 Update parsers 2022-10-19 15:28:42 +03:00
Shippo
0b9d9ac7f2 Translated using Weblate (Arabic)
Currently translated at 14.9% (59 of 395 strings)

Translated using Weblate (Arabic)

Currently translated at 6.0% (24 of 395 strings)

Translated using Weblate (Arabic)

Currently translated at 25.0% (2 of 8 strings)

Added translation using Weblate (Arabic)

Added translation using Weblate (Arabic)

Co-authored-by: Shippo <shiposhouyou@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/ar/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ar/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2022-10-19 15:28:31 +03:00
3ab87027ab Translated using Weblate (Russian)
Currently translated at 99.7% (394 of 395 strings)

Co-authored-by: Koitharu <nvasya95@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translation: Kotatsu/Strings
2022-10-19 15:28:31 +03:00
Eric
7545a774ba Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (395 of 395 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (392 of 392 strings)

Co-authored-by: Eric <hamburger1024@mailbox.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2022-10-19 15:28:31 +03:00
db16eb8e29 Translated using Weblate (Russian)
Currently translated at 99.7% (391 of 392 strings)

Co-authored-by: Zakhar Timoshenko <vp1984tanki@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translation: Kotatsu/Strings
2022-10-19 15:28:31 +03:00
Kyoya
e5a27a7c6f Translated using Weblate (Turkish)
Currently translated at 98.4% (386 of 392 strings)

Co-authored-by: Kyoya <thelol9181@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2022-10-19 15:28:31 +03:00
J. Lavoie
d26bc102d1 Translated using Weblate (French)
Currently translated at 100.0% (395 of 395 strings)

Translated using Weblate (French)

Currently translated at 100.0% (392 of 392 strings)

Translated using Weblate (German)

Currently translated at 94.1% (369 of 392 strings)

Co-authored-by: J. Lavoie <j.lavoie@net-c.ca>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/de/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fr/
Translation: Kotatsu/Strings
2022-10-19 15:28:31 +03:00
Eduardo Malaspina
fc6a8afd93 Translated using Weblate (Spanish)
Currently translated at 99.2% (389 of 392 strings)

Co-authored-by: Eduardo Malaspina <vaio0@swismail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2022-10-19 15:28:31 +03:00
5a9d401446 Ui updates 2022-10-19 12:29:57 +03:00
77ac40b445 Option to clear new chapters counters 2022-10-19 11:26:45 +03:00
a29454f672 Incognito mode indicator 2022-10-19 11:05:11 +03:00
80ee7c8e54 Add installation id to acra reports 2022-10-19 10:26:12 +03:00
fb202f80a5 User-friendly message for HttpStatusException 2022-10-19 10:10:25 +03:00
45dbd5aa44 Fix crash on page loading 2022-10-16 10:41:53 +03:00
ee65251bf5 Update parsers 2022-10-16 10:38:23 +03:00
eaeb11f9ce Fix crash on page loading 2022-10-16 10:36:02 +03:00
2f74633abb Fix popup dark color text on dark dynamic theme 2022-10-16 00:06:57 +03:00
0f346dc725 Fix paddings 2022-10-16 00:05:57 +03:00
1b92848964 Wrong fourth screenshot... 2022-10-15 23:00:07 +03:00
3d91583585 Reduced screenshot resolution 2022-10-15 22:58:31 +03:00
f76d9fa3e4 Update phone screenshots 2022-10-15 22:51:12 +03:00
b00b2e406e Adjust app theme and add new empty states 2022-10-15 18:18:59 +03:00
74717e2b93 Handle offline mode in shelf 2022-10-14 17:04:04 +03:00
9b54ed6bc7 Show local section in shelf 2022-10-14 15:34:58 +03:00
7b36c64b34 Hide feed section if tracker is disabled 2022-10-14 15:02:50 +03:00
da09884136 Add updated manga to shelf 2022-10-14 14:49:53 +03:00
64aaf37556 Show sources names in onboarding 2022-10-14 13:36:33 +03:00
11104223eb Disable under-scaling in webtoon zoom 2022-10-14 13:14:04 +03:00
0c119bc137 Update parsers 2022-10-12 19:29:41 +03:00
5c058e626b Merge branch 'VietAnh14-webtoon_zoom' into devel 2022-10-12 12:52:50 +03:00
2005ae2bf3 Merge branch 'devel' of github.com:KotatsuApp/Kotatsu into VietAnh14-webtoon_zoom 2022-10-12 12:38:59 +03:00
d0650c7cf4 Refactor webtoon zoom option 2022-10-12 12:37:37 +03:00
Allan Nordhøy
2df4e6480a Translated using Weblate (Norwegian Bokmål)
Currently translated at 83.0% (324 of 390 strings)

Translated using Weblate (English)

Currently translated at 100.0% (390 of 390 strings)

Co-authored-by: Allan Nordhøy <epost@anotheragency.no>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/en/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/nb_NO/
Translation: Kotatsu/Strings
2022-10-12 12:28:54 +03:00
J. Lavoie
017a1686dc Translated using Weblate (French)
Currently translated at 100.0% (390 of 390 strings)

Translated using Weblate (German)

Currently translated at 94.6% (369 of 390 strings)

Co-authored-by: J. Lavoie <j.lavoie@net-c.ca>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/de/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fr/
Translation: Kotatsu/Strings
2022-10-12 12:28:54 +03:00
Cường Bá
279dc03695 Translated using Weblate (Vietnamese)
Currently translated at 62.5% (5 of 8 strings)

Added translation using Weblate (Vietnamese)

Co-authored-by: Cường Bá <cuongba956@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/vi/
Translation: Kotatsu/plurals
2022-10-12 12:28:54 +03:00
kuragehime
c8c482f692 Translated using Weblate (Japanese)
Currently translated at 100.0% (390 of 390 strings)

Co-authored-by: kuragehime <kuragehime641@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ja/
Translation: Kotatsu/Strings
2022-10-12 12:28:54 +03:00
Eric
02a0e3ebcd Translated using Weblate (Chinese (Simplified))
Currently translated at 99.4% (388 of 390 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (390 of 390 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (389 of 389 strings)

Co-authored-by: Eric <hamburger1024@mailbox.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2022-10-12 12:28:54 +03:00
Daniel Rozario
fc0b3f3b38 Translated using Weblate (Bengali)
Currently translated at 2.5% (10 of 389 strings)

Translated using Weblate (Bengali)

Currently translated at 37.5% (3 of 8 strings)

Added translation using Weblate (Bengali)

Added translation using Weblate (Bengali)

Co-authored-by: Daniel Rozario <rozario@tuta.io>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/bn/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/bn/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2022-10-12 12:28:54 +03:00
vianh
2925900214 Add disable webtoon zoom setting 2022-10-11 18:13:46 +07:00
vianh
eae370e41c Adjust zoom focus point 2022-10-07 22:17:53 +07:00
vianh
d0338a604a Minor adjustment for webtoon scale 2022-10-07 16:15:53 +07:00
vianh
e22b98b476 Support zoom for webtoon mode 2022-10-07 11:13:30 +07:00
4d838d290d Update dependencies 2022-10-03 08:41:32 +03:00
048efdf59f Fix crash on slider 2022-10-03 08:07:25 +03:00
65dbc6b8e5 Fix crash on slider 2022-10-03 08:02:49 +03:00
627a00beb4 Update parsers 2022-10-01 14:00:11 +03:00
e00ed13ad1 Fix badge alignment on details screen 2022-10-01 13:06:00 +03:00
af2adeba13 Fix opening fingerprint dialog 2022-10-01 12:19:36 +03:00
893fa6bd90 Fix opening fingerprint dialog 2022-10-01 12:19:24 +03:00
512188c8dd Option to disable reader slider #228 2022-10-01 11:42:28 +03:00
aae6761809 Remove unused code 2022-10-01 10:39:58 +03:00
c3f055d0c4 Fix widgets colors 2022-10-01 10:39:58 +03:00
93c6bec452 Fix widgets colors 2022-10-01 10:34:25 +03:00
Tony
04d5df20d1 Translated using Weblate (Portuguese)
Currently translated at 69.0% (268 of 388 strings)

Translated using Weblate (Portuguese)

Currently translated at 68.8% (267 of 388 strings)

Co-authored-by: Tony <t.tony.br01@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt/
Translation: Kotatsu/Strings
2022-10-01 10:23:12 +03:00
Eric
665eca0699 Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (8 of 8 strings)

Co-authored-by: Eric <hamburger1024@mailbox.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/zh_Hans/
Translation: Kotatsu/plurals
2022-10-01 10:23:12 +03:00
Faris Daffa
9a1534464f Translated using Weblate (Indonesian)
Currently translated at 93.2% (362 of 388 strings)

Co-authored-by: Faris Daffa <faris.6dsdiaf@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/id/
Translation: Kotatsu/Strings
2022-10-01 10:23:12 +03:00
kuragehime
f856fc6fac Translated using Weblate (Chinese (Traditional))
Currently translated at 27.5% (107 of 388 strings)

Translated using Weblate (Chinese (Traditional))

Currently translated at 26.2% (102 of 388 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (388 of 388 strings)

Added translation using Weblate (Chinese (Traditional))

Co-authored-by: kuragehime <kuragehime641@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ja/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hant/
Translation: Kotatsu/Strings
2022-10-01 10:23:12 +03:00
4af8e73303 Fix crashes in CoroutineIntentService 2022-10-01 09:28:11 +03:00
23239f1fec Add setReorderingAllowed for fragment transactions 2022-09-23 18:30:38 +03:00
853e4d6fde Follow guidelines for some dialogs 2022-09-22 20:00:43 +03:00
14a37ad16e Minor adjustments 2022-09-22 20:00:34 +03:00
c944044465 Update version 2022-09-22 17:38:08 +03:00
8a63ca2310 Fix coroutines cancellation 2022-09-22 17:33:40 +03:00
12e5e3b35e Update gitignore 2022-09-22 16:53:27 +03:00
553a85ef86 Widget theme fix #225 2022-09-22 16:49:38 +03:00
d604ff3c24 Update ssiv 2022-09-22 16:28:25 +03:00
Sergio Varela
4f9eee7d46 Translated using Weblate (Spanish)
Currently translated at 100.0% (388 of 388 strings)

Co-authored-by: Sergio Varela <sergitroll9@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2022-09-22 16:24:06 +03:00
Zero O
fe673a94ed Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (388 of 388 strings)

Co-authored-by: Zero O <godarms2010@live.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2022-09-22 16:24:06 +03:00
bcd891d653 Upgrade plugins 2022-09-21 20:48:29 +03:00
1e75edf262 Fix coroutines cancellation 2022-09-21 18:50:20 +03:00
73478d6a81 Fix getParcelableCompat extensions 2022-09-19 10:06:43 +03:00
982080a930 Update ssiv 2022-09-18 13:26:25 +03:00
66cd5070dc Update FUNDING.yml 2022-09-17 10:53:46 +03:00
vianh
c05b0eaa59 Move fallback inset to dimens 2022-09-16 08:29:54 +03:00
vianh
29a073d844 Adjust reader info bar 2022-09-16 08:29:54 +03:00
a0e69428e4 Widget theme fix #225 2022-09-16 08:29:32 +03:00
de7012cabf Change acra sender to http 2022-09-15 08:36:20 +03:00
3f6a103915 Rename Library to Shelf 2022-09-14 18:46:32 +03:00
195 changed files with 2775 additions and 1398 deletions

1
.github/FUNDING.yml vendored
View File

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

1
.gitignore vendored
View File

@@ -7,6 +7,7 @@
/.idea/modules.xml /.idea/modules.xml
/.idea/misc.xml /.idea/misc.xml
/.idea/discord.xml /.idea/discord.xml
/.idea/compiler.xml
/.idea/workspace.xml /.idea/workspace.xml
/.idea/navEditor.xml /.idea/navEditor.xml
/.idea/assetWizardSettings.xml /.idea/assetWizardSettings.xml

6
.idea/compiler.xml generated
View File

@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<bytecodeTargetLevel target="11" />
</component>
</project>

3
.idea/kotlinc.xml generated
View File

@@ -3,4 +3,7 @@
<component name="Kotlin2JvmCompilerArguments"> <component name="Kotlin2JvmCompilerArguments">
<option name="jvmTarget" value="1.8" /> <option name="jvmTarget" value="1.8" />
</component> </component>
<component name="KotlinJpsPluginSettings">
<option name="version" value="1.7.20" />
</component>
</project> </project>

View File

@@ -15,8 +15,8 @@ android {
applicationId 'org.koitharu.kotatsu' applicationId 'org.koitharu.kotatsu'
minSdkVersion 21 minSdkVersion 21
targetSdkVersion 33 targetSdkVersion 33
versionCode 497 versionCode 501
versionName '4.0-beta1' versionName '4.0.1'
generatedDensities = [] generatedDensities = []
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
@@ -60,6 +60,7 @@ android {
'-opt-in=kotlinx.coroutines.FlowPreview', '-opt-in=kotlinx.coroutines.FlowPreview',
'-opt-in=kotlin.contracts.ExperimentalContracts', '-opt-in=kotlin.contracts.ExperimentalContracts',
'-opt-in=coil.annotation.ExperimentalCoilApi', '-opt-in=coil.annotation.ExperimentalCoilApi',
'-opt-in=com.google.android.material.badge.ExperimentalBadgeUtils',
] ]
} }
lint { lint {
@@ -82,15 +83,15 @@ afterEvaluate {
} }
} }
dependencies { dependencies {
implementation('com.github.KotatsuApp:kotatsu-parsers:b3a9c5fcda') { implementation('com.github.KotatsuApp:kotatsu-parsers:a1441e7ed7') {
exclude group: 'org.json', module: 'json' exclude group: 'org.json', module: 'json'
} }
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4' implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4'
implementation 'androidx.core:core-ktx:1.9.0' implementation 'androidx.core:core-ktx:1.9.0'
implementation 'androidx.activity:activity-ktx:1.5.1' implementation 'androidx.activity:activity-ktx:1.6.0'
implementation 'androidx.fragment:fragment-ktx:1.5.2' implementation 'androidx.fragment:fragment-ktx:1.5.3'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.1' implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.1'
implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.5.1' implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.5.1'
implementation 'androidx.lifecycle:lifecycle-service:2.5.1' implementation 'androidx.lifecycle:lifecycle-service:2.5.1'
@@ -101,8 +102,8 @@ dependencies {
implementation 'androidx.viewpager2:viewpager2:1.1.0-beta01' implementation 'androidx.viewpager2:viewpager2:1.1.0-beta01'
implementation 'androidx.preference:preference-ktx:1.2.0' implementation 'androidx.preference:preference-ktx:1.2.0'
implementation 'androidx.work:work-runtime-ktx:2.7.1' implementation 'androidx.work:work-runtime-ktx:2.7.1'
implementation 'androidx.biometric:biometric-ktx:1.2.0-alpha04' implementation 'androidx.biometric:biometric-ktx:1.2.0-alpha05'
implementation 'com.google.android.material:material:1.7.0-rc01' implementation 'com.google.android.material:material:1.7.0'
//noinspection LifecycleAnnotationProcessorWithJava8 //noinspection LifecycleAnnotationProcessorWithJava8
kapt 'androidx.lifecycle:lifecycle-compiler:2.5.1' kapt 'androidx.lifecycle:lifecycle-compiler:2.5.1'
@@ -117,14 +118,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.43.2" implementation "com.google.dagger:hilt-android:2.44"
kapt "com.google.dagger:hilt-compiler:2.43.2" kapt "com.google.dagger:hilt-compiler:2.44"
implementation 'androidx.hilt:hilt-work:1.0.0' implementation 'androidx.hilt:hilt-work:1.0.0'
kapt 'androidx.hilt:hilt-compiler:1.0.0' kapt 'androidx.hilt:hilt-compiler:1.0.0'
implementation 'io.coil-kt:coil-base:2.2.1' implementation 'io.coil-kt:coil-base:2.2.2'
implementation 'io.coil-kt:coil-svg:2.2.1' implementation 'io.coil-kt:coil-svg:2.2.2'
implementation 'com.github.KotatsuApp:subsampling-scale-image-view:a9ab74c8b4' implementation 'com.github.KotatsuApp:subsampling-scale-image-view:f8a38b08fe'
implementation 'com.github.solkin:disk-lru-cache:1.4' implementation 'com.github.solkin:disk-lru-cache:1.4'
implementation 'ch.acra:acra-http:5.9.6' implementation 'ch.acra:acra-http:5.9.6'
@@ -133,7 +134,7 @@ dependencies {
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.9.1' debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.9.1'
testImplementation 'junit:junit:4.13.2' testImplementation 'junit:junit:4.13.2'
testImplementation 'org.json:json:20220320' testImplementation 'org.json:json:20220924'
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.4' testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.4'
androidTestImplementation 'androidx.test:runner:1.4.0' androidTestImplementation 'androidx.test:runner:1.4.0'
@@ -146,6 +147,6 @@ dependencies {
androidTestImplementation 'androidx.room:room-testing:2.4.3' androidTestImplementation 'androidx.room:room-testing:2.4.3'
androidTestImplementation 'com.squareup.moshi:moshi-kotlin:1.14.0' androidTestImplementation 'com.squareup.moshi:moshi-kotlin:1.14.0'
androidTestImplementation 'com.google.dagger:hilt-android-testing:2.43.2' androidTestImplementation 'com.google.dagger:hilt-android-testing:2.44'
kaptAndroidTest 'com.google.dagger:hilt-android-compiler:2.43.2' kaptAndroidTest 'com.google.dagger:hilt-android-compiler:2.44'
} }

View File

@@ -68,6 +68,9 @@
<activity <activity
android:name="org.koitharu.kotatsu.history.ui.HistoryActivity" android:name="org.koitharu.kotatsu.history.ui.HistoryActivity"
android:label="@string/history" /> android:label="@string/history" />
<activity
android:name="org.koitharu.kotatsu.tracker.ui.updates.UpdatesActivity"
android:label="@string/updates" />
<activity <activity
android:name="org.koitharu.kotatsu.favourites.ui.FavouritesActivity" android:name="org.koitharu.kotatsu.favourites.ui.FavouritesActivity"
android:label="@string/favourites" /> android:label="@string/favourites" />

View File

@@ -10,7 +10,6 @@ import androidx.hilt.work.HiltWorkerFactory
import androidx.room.InvalidationTracker import androidx.room.InvalidationTracker
import androidx.work.Configuration import androidx.work.Configuration
import dagger.hilt.android.HiltAndroidApp import dagger.hilt.android.HiltAndroidApp
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.acra.ReportField import org.acra.ReportField
@@ -25,6 +24,7 @@ import org.koitharu.kotatsu.local.data.PagesCache
import org.koitharu.kotatsu.local.domain.LocalMangaRepository import org.koitharu.kotatsu.local.domain.LocalMangaRepository
import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.utils.ext.processLifecycleScope import org.koitharu.kotatsu.utils.ext.processLifecycleScope
import javax.inject.Inject
@HiltAndroidApp @HiltAndroidApp
class KotatsuApp : Application(), Configuration.Provider { class KotatsuApp : Application(), Configuration.Provider {
@@ -72,6 +72,7 @@ class KotatsuApp : Application(), Configuration.Provider {
} }
reportContent = listOf( reportContent = listOf(
ReportField.PACKAGE_NAME, ReportField.PACKAGE_NAME,
ReportField.INSTALLATION_ID,
ReportField.APP_VERSION_CODE, ReportField.APP_VERSION_CODE,
ReportField.APP_VERSION_NAME, ReportField.APP_VERSION_NAME,
ReportField.ANDROID_VERSION, ReportField.ANDROID_VERSION,

View File

@@ -1,9 +1,12 @@
package org.koitharu.kotatsu.base.domain package org.koitharu.kotatsu.base.domain
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import org.koitharu.kotatsu.utils.ext.processLifecycleScope import org.koitharu.kotatsu.utils.ext.processLifecycleScope
import org.koitharu.kotatsu.utils.ext.runCatchingCancellable
fun interface ReversibleHandle { fun interface ReversibleHandle {
@@ -11,8 +14,10 @@ fun interface ReversibleHandle {
} }
fun ReversibleHandle.reverseAsync() = processLifecycleScope.launch(Dispatchers.Default) { fun ReversibleHandle.reverseAsync() = processLifecycleScope.launch(Dispatchers.Default) {
runCatching { runCatchingCancellable {
reverse() withContext(NonCancellable) {
reverse()
}
}.onFailure { }.onFailure {
it.printStackTraceDebug() it.printStackTraceDebug()
} }
@@ -21,4 +26,4 @@ fun ReversibleHandle.reverseAsync() = processLifecycleScope.launch(Dispatchers.D
operator fun ReversibleHandle.plus(other: ReversibleHandle) = ReversibleHandle { operator fun ReversibleHandle.plus(other: ReversibleHandle) = ReversibleHandle {
this.reverse() this.reverse()
other.reverse() other.reverse()
} }

View File

@@ -20,7 +20,6 @@ import androidx.core.view.WindowInsetsCompat
import androidx.core.view.updateLayoutParams import androidx.core.view.updateLayoutParams
import androidx.viewbinding.ViewBinding import androidx.viewbinding.ViewBinding
import dagger.hilt.android.EntryPointAccessors import dagger.hilt.android.EntryPointAccessors
import javax.inject.Inject
import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.util.ActionModeDelegate import org.koitharu.kotatsu.base.ui.util.ActionModeDelegate
@@ -30,6 +29,7 @@ import org.koitharu.kotatsu.base.ui.util.inject
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.utils.ext.getThemeColor import org.koitharu.kotatsu.utils.ext.getThemeColor
import javax.inject.Inject
abstract class BaseActivity<B : ViewBinding> : abstract class BaseActivity<B : ViewBinding> :
AppCompatActivity(), AppCompatActivity(),
@@ -134,6 +134,7 @@ abstract class BaseActivity<B : ViewBinding> :
window.statusBarColor = getThemeColor(android.R.attr.statusBarColor) window.statusBarColor = getThemeColor(android.R.attr.statusBarColor)
} }
@Suppress("OVERRIDE_DEPRECATION", "DEPRECATION")
override fun onBackPressed() { override fun onBackPressed() {
if ( // https://issuetracker.google.com/issues/139738913 if ( // https://issuetracker.google.com/issues/139738913
Build.VERSION.SDK_INT == Build.VERSION_CODES.Q && Build.VERSION.SDK_INT == Build.VERSION_CODES.Q &&

View File

@@ -4,11 +4,13 @@ import android.app.Service
import android.content.Intent import android.content.Intent
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
abstract class CoroutineIntentService : BaseService() { abstract class CoroutineIntentService : BaseService() {
@@ -21,11 +23,13 @@ abstract class CoroutineIntentService : BaseService() {
return Service.START_REDELIVER_INTENT return Service.START_REDELIVER_INTENT
} }
private fun launchCoroutine(intent: Intent?, startId: Int) = lifecycleScope.launch { private fun launchCoroutine(intent: Intent?, startId: Int) = lifecycleScope.launch(errorHandler(startId)) {
mutex.withLock { mutex.withLock {
try { try {
withContext(dispatcher) { if (intent != null) {
processIntent(intent) withContext(dispatcher) {
processIntent(startId, intent)
}
} }
} finally { } finally {
stopSelf(startId) stopSelf(startId)
@@ -33,5 +37,12 @@ abstract class CoroutineIntentService : BaseService() {
} }
} }
protected abstract suspend fun processIntent(intent: Intent?) protected abstract suspend fun processIntent(startId: Int, intent: Intent)
protected abstract fun onError(startId: Int, error: Throwable)
private fun errorHandler(startId: Int) = CoroutineExceptionHandler { _, throwable ->
throwable.printStackTraceDebug()
onError(startId, throwable)
}
} }

View File

@@ -0,0 +1,22 @@
package org.koitharu.kotatsu.base.ui
import android.app.Activity
import android.app.Application.ActivityLifecycleCallbacks
import android.os.Bundle
interface DefaultActivityLifecycleCallbacks : ActivityLifecycleCallbacks {
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) = Unit
override fun onActivityStarted(activity: Activity) = Unit
override fun onActivityResumed(activity: Activity) = Unit
override fun onActivityPaused(activity: Activity) = Unit
override fun onActivityStopped(activity: Activity) = Unit
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) = Unit
override fun onActivityDestroyed(activity: Activity) = Unit
}

View File

@@ -1,14 +1,14 @@
package org.koitharu.kotatsu.base.ui.util package org.koitharu.kotatsu.base.ui.util
import android.app.Activity import android.app.Activity
import android.app.Application.ActivityLifecycleCallbacks
import android.os.Bundle import android.os.Bundle
import java.util.* import org.koitharu.kotatsu.base.ui.DefaultActivityLifecycleCallbacks
import java.util.WeakHashMap
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@Singleton @Singleton
class ActivityRecreationHandle @Inject constructor() : ActivityLifecycleCallbacks { class ActivityRecreationHandle @Inject constructor() : DefaultActivityLifecycleCallbacks {
private val activities = WeakHashMap<Activity, Unit>() private val activities = WeakHashMap<Activity, Unit>()
@@ -16,16 +16,6 @@ class ActivityRecreationHandle @Inject constructor() : ActivityLifecycleCallback
activities[activity] = Unit activities[activity] = Unit
} }
override fun onActivityStarted(activity: Activity) = Unit
override fun onActivityResumed(activity: Activity) = Unit
override fun onActivityPaused(activity: Activity) = Unit
override fun onActivityStopped(activity: Activity) = Unit
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) = Unit
override fun onActivityDestroyed(activity: Activity) { override fun onActivityDestroyed(activity: Activity) {
activities.remove(activity) activities.remove(activity)
} }

View File

@@ -18,8 +18,6 @@ import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import dagger.multibindings.ElementsIntoSet import dagger.multibindings.ElementsIntoSet
import java.util.concurrent.TimeUnit
import javax.inject.Singleton
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import okhttp3.CookieJar import okhttp3.CookieJar
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
@@ -40,9 +38,12 @@ import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.search.ui.MangaSuggestionsProvider import org.koitharu.kotatsu.search.ui.MangaSuggestionsProvider
import org.koitharu.kotatsu.settings.backup.BackupObserver import org.koitharu.kotatsu.settings.backup.BackupObserver
import org.koitharu.kotatsu.sync.domain.SyncController import org.koitharu.kotatsu.sync.domain.SyncController
import org.koitharu.kotatsu.utils.IncognitoModeIndicator
import org.koitharu.kotatsu.utils.ext.isLowRamDevice import org.koitharu.kotatsu.utils.ext.isLowRamDevice
import org.koitharu.kotatsu.utils.image.CoilImageGetter import org.koitharu.kotatsu.utils.image.CoilImageGetter
import org.koitharu.kotatsu.widget.WidgetUpdater import org.koitharu.kotatsu.widget.WidgetUpdater
import java.util.concurrent.TimeUnit
import javax.inject.Singleton
@Module @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
@@ -152,9 +153,11 @@ interface AppModule {
fun provideActivityLifecycleCallbacks( fun provideActivityLifecycleCallbacks(
appProtectHelper: AppProtectHelper, appProtectHelper: AppProtectHelper,
activityRecreationHandle: ActivityRecreationHandle, activityRecreationHandle: ActivityRecreationHandle,
incognitoModeIndicator: IncognitoModeIndicator,
): Set<@JvmSuppressWildcards Application.ActivityLifecycleCallbacks> = arraySetOf( ): Set<@JvmSuppressWildcards Application.ActivityLifecycleCallbacks> = arraySetOf(
appProtectHelper, appProtectHelper,
activityRecreationHandle, activityRecreationHandle,
incognitoModeIndicator,
) )
} }
} }

View File

@@ -1,13 +1,14 @@
package org.koitharu.kotatsu.core.backup package org.koitharu.kotatsu.core.backup
import androidx.room.withTransaction import androidx.room.withTransaction
import javax.inject.Inject
import org.json.JSONArray import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.parsers.util.json.JSONIterator import org.koitharu.kotatsu.parsers.util.json.JSONIterator
import org.koitharu.kotatsu.parsers.util.json.mapJSON import org.koitharu.kotatsu.parsers.util.json.mapJSON
import org.koitharu.kotatsu.utils.ext.runCatchingCancellable
import javax.inject.Inject
private const val PAGE_SIZE = 10 private const val PAGE_SIZE = 10
@@ -85,7 +86,7 @@ class BackupRepository @Inject constructor(private val db: MangaDatabase) {
JsonDeserializer(it).toTagEntity() JsonDeserializer(it).toTagEntity()
} }
val history = JsonDeserializer(item).toHistoryEntity() val history = JsonDeserializer(item).toHistoryEntity()
result += runCatching { result += runCatchingCancellable {
db.withTransaction { db.withTransaction {
db.tagsDao.upsert(tags) db.tagsDao.upsert(tags)
db.mangaDao.upsert(manga, tags) db.mangaDao.upsert(manga, tags)
@@ -100,7 +101,7 @@ class BackupRepository @Inject constructor(private val db: MangaDatabase) {
val result = CompositeResult() val result = CompositeResult()
for (item in entry.data.JSONIterator()) { for (item in entry.data.JSONIterator()) {
val category = JsonDeserializer(item).toFavouriteCategoryEntity() val category = JsonDeserializer(item).toFavouriteCategoryEntity()
result += runCatching { result += runCatchingCancellable {
db.favouriteCategoriesDao.upsert(category) db.favouriteCategoriesDao.upsert(category)
} }
} }
@@ -116,7 +117,7 @@ class BackupRepository @Inject constructor(private val db: MangaDatabase) {
JsonDeserializer(it).toTagEntity() JsonDeserializer(it).toTagEntity()
} }
val favourite = JsonDeserializer(item).toFavouriteEntity() val favourite = JsonDeserializer(item).toFavouriteEntity()
result += runCatching { result += runCatchingCancellable {
db.withTransaction { db.withTransaction {
db.tagsDao.upsert(tags) db.tagsDao.upsert(tags)
db.mangaDao.upsert(manga, tags) db.mangaDao.upsert(manga, tags)

View File

@@ -4,13 +4,6 @@ import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.content.pm.PackageManager import android.content.pm.PackageManager
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import java.io.ByteArrayInputStream
import java.io.InputStream
import java.security.MessageDigest
import java.security.cert.CertificateFactory
import java.security.cert.X509Certificate
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@@ -24,6 +17,14 @@ import org.koitharu.kotatsu.parsers.util.json.mapJSONNotNull
import org.koitharu.kotatsu.parsers.util.parseJsonArray import org.koitharu.kotatsu.parsers.util.parseJsonArray
import org.koitharu.kotatsu.utils.ext.asArrayList import org.koitharu.kotatsu.utils.ext.asArrayList
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import org.koitharu.kotatsu.utils.ext.runCatchingCancellable
import java.io.ByteArrayInputStream
import java.io.InputStream
import java.security.MessageDigest
import java.security.cert.CertificateFactory
import java.security.cert.X509Certificate
import javax.inject.Inject
import javax.inject.Singleton
private const val CERT_SHA1 = "2C:19:C7:E8:07:61:2B:8E:94:51:1B:FD:72:67:07:64:5D:C2:58:AE" private const val CERT_SHA1 = "2C:19:C7:E8:07:61:2B:8E:94:51:1B:FD:72:67:07:64:5D:C2:58:AE"
@@ -59,7 +60,7 @@ class AppUpdateRepository @Inject constructor(
if (!isUpdateSupported()) { if (!isUpdateSupported()) {
return@withContext null return@withContext null
} }
runCatching { runCatchingCancellable {
val currentVersion = VersionId(BuildConfig.VERSION_NAME) val currentVersion = VersionId(BuildConfig.VERSION_NAME)
val available = getAvailableVersions().asArrayList() val available = getAvailableVersions().asArrayList()
available.sortBy { it.versionId } available.sortBy { it.versionId }

View File

@@ -2,7 +2,12 @@ package org.koitharu.kotatsu.core.model.parcelable
import android.os.Parcel import android.os.Parcel
import androidx.core.os.ParcelCompat import androidx.core.os.ParcelCompat
import org.koitharu.kotatsu.parsers.model.* import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.utils.ext.readParcelableCompat
import org.koitharu.kotatsu.utils.ext.readSerializableCompat
fun Manga.writeToParcel(out: Parcel, flags: Int, withChapters: Boolean) { fun Manga.writeToParcel(out: Parcel, flags: Int, withChapters: Boolean) {
out.writeLong(id) out.writeLong(id)
@@ -37,11 +42,11 @@ fun Parcel.readManga() = Manga(
coverUrl = requireNotNull(readString()), coverUrl = requireNotNull(readString()),
largeCoverUrl = readString(), largeCoverUrl = readString(),
description = readString(), description = readString(),
tags = requireNotNull(readParcelable<ParcelableMangaTags>(ParcelableMangaTags::class.java.classLoader)).tags, tags = requireNotNull(readParcelableCompat<ParcelableMangaTags>()).tags,
state = readSerializable() as MangaState?, state = readSerializableCompat(),
author = readString(), author = readString(),
chapters = readParcelable<ParcelableMangaChapters>(ParcelableMangaChapters::class.java.classLoader)?.chapters, chapters = readParcelableCompat<ParcelableMangaChapters>()?.chapters,
source = readSerializable() as MangaSource, source = checkNotNull(readSerializableCompat()),
) )
fun MangaPage.writeToParcel(out: Parcel) { fun MangaPage.writeToParcel(out: Parcel) {
@@ -57,7 +62,7 @@ fun Parcel.readMangaPage() = MangaPage(
url = requireNotNull(readString()), url = requireNotNull(readString()),
referer = requireNotNull(readString()), referer = requireNotNull(readString()),
preview = readString(), preview = readString(),
source = readSerializable() as MangaSource, source = checkNotNull(readSerializableCompat()),
) )
fun MangaChapter.writeToParcel(out: Parcel) { fun MangaChapter.writeToParcel(out: Parcel) {
@@ -79,7 +84,7 @@ fun Parcel.readMangaChapter() = MangaChapter(
scanlator = readString(), scanlator = readString(),
uploadDate = readLong(), uploadDate = readLong(),
branch = readString(), branch = readString(),
source = readSerializable() as MangaSource, source = checkNotNull(readSerializableCompat()),
) )
fun MangaTag.writeToParcel(out: Parcel) { fun MangaTag.writeToParcel(out: Parcel) {
@@ -91,5 +96,5 @@ fun MangaTag.writeToParcel(out: Parcel) {
fun Parcel.readMangaTag() = MangaTag( fun Parcel.readMangaTag() = MangaTag(
title = requireNotNull(readString()), title = requireNotNull(readString()),
key = requireNotNull(readString()), key = requireNotNull(readString()),
source = readSerializable() as MangaSource, source = checkNotNull(readSerializableCompat()),
) )

View File

@@ -50,4 +50,4 @@ class ParcelableManga(
return arrayOfNulls(size) return arrayOfNulls(size)
} }
} }
} }

View File

@@ -0,0 +1,64 @@
package org.koitharu.kotatsu.core.os
import android.content.Context
import android.net.ConnectivityManager.NetworkCallback
import android.net.Network
import android.net.NetworkRequest
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.channels.ProducerScope
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.channels.trySendBlocking
import kotlinx.coroutines.flow.FlowCollector
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.callbackFlow
import org.koitharu.kotatsu.utils.ext.connectivityManager
import org.koitharu.kotatsu.utils.ext.isNetworkAvailable
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class NetworkStateObserver @Inject constructor(
@ApplicationContext context: Context,
) : StateFlow<Boolean> {
private val connectivityManager = context.connectivityManager
override val replayCache: List<Boolean>
get() = listOf(value)
override var value: Boolean = connectivityManager.isNetworkAvailable
override suspend fun collect(collector: FlowCollector<Boolean>): Nothing {
collector.emit(value)
while (true) {
observeImpl().collect(collector)
}
}
private fun observeImpl() = callbackFlow<Boolean> {
val request = NetworkRequest.Builder().build()
val callback = FlowNetworkCallback(this)
connectivityManager.registerNetworkCallback(request, callback)
awaitClose {
connectivityManager.unregisterNetworkCallback(callback)
}
}
inner class FlowNetworkCallback(
private val producerScope: ProducerScope<Boolean>,
) : NetworkCallback() {
override fun onAvailable(network: Network) = update()
override fun onLost(network: Network) = update()
override fun onUnavailable() = update()
private fun update() {
val newValue = connectivityManager.isNetworkAvailable
if (value != newValue) {
value = newValue
producerScope.trySendBlocking(newValue)
}
}
}
}

View File

@@ -17,8 +17,6 @@ import coil.request.ImageRequest
import coil.size.Precision import coil.size.Precision
import coil.size.Scale import coil.size.Scale
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -32,6 +30,9 @@ import org.koitharu.kotatsu.reader.ui.ReaderActivity
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import org.koitharu.kotatsu.utils.ext.processLifecycleScope import org.koitharu.kotatsu.utils.ext.processLifecycleScope
import org.koitharu.kotatsu.utils.ext.requireBitmap import org.koitharu.kotatsu.utils.ext.requireBitmap
import org.koitharu.kotatsu.utils.ext.runCatchingCancellable
import javax.inject.Inject
import javax.inject.Singleton
@Singleton @Singleton
class ShortcutsUpdater @Inject constructor( class ShortcutsUpdater @Inject constructor(
@@ -92,7 +93,7 @@ class ShortcutsUpdater @Inject constructor(
} }
@RequiresApi(Build.VERSION_CODES.N_MR1) @RequiresApi(Build.VERSION_CODES.N_MR1)
private suspend fun updateShortcutsImpl() = runCatching { private suspend fun updateShortcutsImpl() = runCatchingCancellable {
val manager = context.getSystemService(Context.SHORTCUT_SERVICE) as ShortcutManager val manager = context.getSystemService(Context.SHORTCUT_SERVICE) as ShortcutManager
val shortcuts = historyRepository.getList(0, manager.maxShortcutCountPerActivity) val shortcuts = historyRepository.getList(0, manager.maxShortcutCountPerActivity)
.filter { x -> x.title.isNotEmpty() } .filter { x -> x.title.isNotEmpty() }
@@ -112,7 +113,7 @@ class ShortcutsUpdater @Inject constructor(
} }
private suspend fun buildShortcutInfo(manga: Manga): ShortcutInfoCompat.Builder { private suspend fun buildShortcutInfo(manga: Manga): ShortcutInfoCompat.Builder {
val icon = runCatching { val icon = runCatchingCancellable {
coil.execute( coil.execute(
ImageRequest.Builder(context) ImageRequest.Builder(context)
.data(manga.coverUrl) .data(manga.coverUrl)

View File

@@ -11,12 +11,6 @@ import androidx.core.content.edit
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import com.google.android.material.color.DynamicColors import com.google.android.material.color.DynamicColors
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import java.io.File
import java.text.DateFormat
import java.text.SimpleDateFormat
import java.util.*
import javax.inject.Inject
import javax.inject.Singleton
import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.model.ZoomMode import org.koitharu.kotatsu.core.model.ZoomMode
import org.koitharu.kotatsu.core.network.DoHProvider import org.koitharu.kotatsu.core.network.DoHProvider
@@ -26,6 +20,14 @@ import org.koitharu.kotatsu.utils.ext.getEnumValue
import org.koitharu.kotatsu.utils.ext.observe import org.koitharu.kotatsu.utils.ext.observe
import org.koitharu.kotatsu.utils.ext.putEnumValue import org.koitharu.kotatsu.utils.ext.putEnumValue
import org.koitharu.kotatsu.utils.ext.toUriOrNull import org.koitharu.kotatsu.utils.ext.toUriOrNull
import java.io.File
import java.text.DateFormat
import java.text.SimpleDateFormat
import java.util.Collections
import java.util.EnumSet
import java.util.Locale
import javax.inject.Inject
import javax.inject.Singleton
@Singleton @Singleton
class AppSettings @Inject constructor(@ApplicationContext context: Context) { class AppSettings @Inject constructor(@ApplicationContext context: Context) {
@@ -206,6 +208,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val isReaderBarEnabled: Boolean val isReaderBarEnabled: Boolean
get() = prefs.getBoolean(KEY_READER_BAR, true) get() = prefs.getBoolean(KEY_READER_BAR, true)
val isReaderSliderEnabled: Boolean
get() = prefs.getBoolean(KEY_READER_SLIDER, true)
val dnsOverHttps: DoHProvider val dnsOverHttps: DoHProvider
get() = prefs.getEnumValue(KEY_DOH, DoHProvider.NONE) get() = prefs.getEnumValue(KEY_DOH, DoHProvider.NONE)
@@ -213,6 +218,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
get() = prefs.getEnumValue(KEY_LOCAL_LIST_ORDER, SortOrder.NEWEST) get() = prefs.getEnumValue(KEY_LOCAL_LIST_ORDER, SortOrder.NEWEST)
set(value) = prefs.edit { putEnumValue(KEY_LOCAL_LIST_ORDER, value) } set(value) = prefs.edit { putEnumValue(KEY_LOCAL_LIST_ORDER, value) }
val isWebtoonZoomEnable: Boolean
get() = prefs.getBoolean(KEY_WEBTOON_ZOOM, true)
fun isPagesPreloadAllowed(cm: ConnectivityManager): Boolean { fun isPagesPreloadAllowed(cm: ConnectivityManager): Boolean {
return when (prefs.getString(KEY_PAGES_PRELOAD, null)?.toIntOrNull()) { return when (prefs.getString(KEY_PAGES_PRELOAD, null)?.toIntOrNull()) {
NETWORK_ALWAYS -> true NETWORK_ALWAYS -> true
@@ -328,9 +336,11 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_INCOGNITO_MODE = "incognito" const val KEY_INCOGNITO_MODE = "incognito"
const val KEY_SYNC = "sync" const val KEY_SYNC = "sync"
const val KEY_READER_BAR = "reader_bar" const val KEY_READER_BAR = "reader_bar"
const val KEY_READER_SLIDER = "reader_slider"
const val KEY_SHORTCUTS = "dynamic_shortcuts" const val KEY_SHORTCUTS = "dynamic_shortcuts"
const val KEY_READER_TAPS_LTR = "reader_taps_ltr" const val KEY_READER_TAPS_LTR = "reader_taps_ltr"
const val KEY_LOCAL_LIST_ORDER = "local_order" const val KEY_LOCAL_LIST_ORDER = "local_order"
const val KEY_WEBTOON_ZOOM = "webtoon_zoom"
// About // About
const val KEY_APP_UPDATE = "app_update" const val KEY_APP_UPDATE = "app_update"

View File

@@ -54,7 +54,7 @@ class MangaErrorDialog : AlertDialogFragment<DialogMangaErrorBinding>() {
.setNegativeButton(android.R.string.cancel, null) .setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(R.string.report) { _, _ -> .setPositiveButton(R.string.report) { _, _ ->
dismiss() dismiss()
error.report(TAG) error.report()
}.setTitle(R.string.error_occurred) }.setTitle(R.string.error_occurred)
} }

View File

@@ -19,12 +19,10 @@ import androidx.core.view.isGone
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.google.android.material.badge.BadgeDrawable
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.BaseTransientBottomBar import com.google.android.material.snackbar.BaseTransientBottomBar
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.domain.MangaIntent import org.koitharu.kotatsu.base.domain.MangaIntent
@@ -37,12 +35,17 @@ import org.koitharu.kotatsu.core.ui.MangaErrorDialog
import org.koitharu.kotatsu.databinding.ActivityDetailsBinding import org.koitharu.kotatsu.databinding.ActivityDetailsBinding
import org.koitharu.kotatsu.details.ui.model.HistoryInfo import org.koitharu.kotatsu.details.ui.model.HistoryInfo
import org.koitharu.kotatsu.download.ui.service.DownloadService import org.koitharu.kotatsu.download.ui.service.DownloadService
import org.koitharu.kotatsu.list.ui.adapter.bindBadge
import org.koitharu.kotatsu.main.ui.owners.NoModalBottomSheetOwner import org.koitharu.kotatsu.main.ui.owners.NoModalBottomSheetOwner
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.reader.ui.ReaderActivity import org.koitharu.kotatsu.reader.ui.ReaderActivity
import org.koitharu.kotatsu.reader.ui.ReaderState import org.koitharu.kotatsu.reader.ui.ReaderState
import org.koitharu.kotatsu.utils.ext.* import org.koitharu.kotatsu.utils.ViewBadge
import org.koitharu.kotatsu.utils.ext.assistedViewModels
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.isReportable
import org.koitharu.kotatsu.utils.ext.setNavigationBarTransparentCompat
import org.koitharu.kotatsu.utils.ext.textAndVisible
import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class DetailsActivity : class DetailsActivity :
@@ -60,7 +63,7 @@ class DetailsActivity :
@Inject @Inject
lateinit var shortcutsUpdater: ShortcutsUpdater lateinit var shortcutsUpdater: ShortcutsUpdater
private var badge: BadgeDrawable? = null private lateinit var viewBadge: ViewBadge
private val viewModel: DetailsViewModel by assistedViewModels { private val viewModel: DetailsViewModel by assistedViewModels {
viewModelFactory.create(MangaIntent(intent)) viewModelFactory.create(MangaIntent(intent))
@@ -83,6 +86,7 @@ class DetailsActivity :
} }
binding.buttonRead.setOnClickListener(this) binding.buttonRead.setOnClickListener(this)
binding.buttonDropdown.setOnClickListener(this) binding.buttonDropdown.setOnClickListener(this)
viewBadge = ViewBadge(binding.buttonRead, this)
chaptersMenuProvider = if (binding.layoutBottom != null) { chaptersMenuProvider = if (binding.layoutBottom != null) {
val bsMediator = ChaptersBottomSheetMediator(checkNotNull(binding.layoutBottom)) val bsMediator = ChaptersBottomSheetMediator(checkNotNull(binding.layoutBottom))
@@ -151,6 +155,7 @@ class DetailsActivity :
) )
} }
} }
R.id.button_dropdown -> showBranchPopupMenu() R.id.button_dropdown -> showBranchPopupMenu()
} }
} }
@@ -188,10 +193,12 @@ class DetailsActivity :
ExceptionResolver.canResolve(e) -> { ExceptionResolver.canResolve(e) -> {
resolveError(e) resolveError(e)
} }
manga == null -> { manga == null -> {
Toast.makeText(this, e.getDisplayMessage(resources), Toast.LENGTH_LONG).show() Toast.makeText(this, e.getDisplayMessage(resources), Toast.LENGTH_LONG).show()
finishAfterTransition() finishAfterTransition()
} }
else -> { else -> {
val snackbar = makeSnackbar( val snackbar = makeSnackbar(
e.getDisplayMessage(resources), e.getDisplayMessage(resources),
@@ -242,7 +249,7 @@ class DetailsActivity :
} }
private fun onNewChaptersChanged(newChapters: Int) { private fun onNewChaptersChanged(newChapters: Int) {
badge = binding.buttonRead.bindBadge(badge, newChapters) viewBadge.counter = newChapters
} }
fun showChapterMissingDialog(chapterId: Long) { fun showChapterMissingDialog(chapterId: Long) {

View File

@@ -45,6 +45,7 @@ import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.asFlowLiveData import org.koitharu.kotatsu.utils.asFlowLiveData
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import org.koitharu.kotatsu.utils.ext.runCatchingCancellable
class DetailsViewModel @AssistedInject constructor( class DetailsViewModel @AssistedInject constructor(
@Assisted intent: MangaIntent, @Assisted intent: MangaIntent,
@@ -189,7 +190,7 @@ class DetailsViewModel @AssistedInject constructor(
checkNotNull(manga) { "Cannot find saved manga for ${m.title}" } checkNotNull(manga) { "Cannot find saved manga for ${m.title}" }
val original = localMangaRepository.getRemoteManga(manga) val original = localMangaRepository.getRemoteManga(manga)
localMangaRepository.delete(manga) || throw IOException("Unable to delete file") localMangaRepository.delete(manga) || throw IOException("Unable to delete file")
runCatching { runCatchingCancellable {
historyRepository.deleteOrSwap(manga, original) historyRepository.deleteOrSwap(manga, original)
} }
onMangaRemoved.postCall(manga) onMangaRemoved.postCall(manga)
@@ -228,7 +229,7 @@ class DetailsViewModel @AssistedInject constructor(
reload() reload()
} else { } else {
viewModelScope.launch(Dispatchers.Default) { viewModelScope.launch(Dispatchers.Default) {
runCatching { runCatchingCancellable {
localMangaRepository.getDetails(downloadedManga) localMangaRepository.getDetails(downloadedManga)
}.onSuccess { }.onSuccess {
delegate.relatedManga.value = it delegate.relatedManga.value = it

View File

@@ -17,6 +17,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.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import org.koitharu.kotatsu.utils.ext.runCatchingCancellable
class MangaDetailsDelegate( class MangaDetailsDelegate(
private val intent: MangaIntent, private val intent: MangaIntent,
@@ -45,9 +46,9 @@ class MangaDetailsDelegate(
val hist = historyRepository.getOne(manga) val hist = historyRepository.getOne(manga)
selectedBranch.value = manga.getPreferredBranch(hist) selectedBranch.value = manga.getPreferredBranch(hist)
mangaData.value = manga mangaData.value = manga
relatedManga.value = runCatching { relatedManga.value = runCatchingCancellable {
if (manga.source == MangaSource.LOCAL) { if (manga.source == MangaSource.LOCAL) {
val m = localMangaRepository.getRemoteManga(manga) ?: return@runCatching null val m = localMangaRepository.getRemoteManga(manga) ?: return@runCatchingCancellable null
mangaRepositoryFactory.create(m.source).getDetails(m) mangaRepositoryFactory.create(m.source).getDetails(m)
} else { } else {
localMangaRepository.findSavedManga(manga) localMangaRepository.findSavedManga(manga)

View File

@@ -34,7 +34,7 @@ fun chapterListItemAD(
when (item.status) { when (item.status) {
FLAG_UNREAD -> { FLAG_UNREAD -> {
binding.textViewNumber.setBackgroundResource(R.drawable.bg_badge_default) binding.textViewNumber.setBackgroundResource(R.drawable.bg_badge_default)
binding.textViewNumber.setTextColor(context.getThemeColor(android.R.attr.textColorSecondaryInverse)) binding.textViewNumber.setTextColor(context.getThemeColor(com.google.android.material.R.attr.colorOnTertiary))
} }
FLAG_CURRENT -> { FLAG_CURRENT -> {
binding.textViewNumber.setBackgroundResource(R.drawable.bg_badge_accent) binding.textViewNumber.setBackgroundResource(R.drawable.bg_badge_accent)
@@ -53,4 +53,4 @@ fun chapterListItemAD(
binding.imageViewDownloaded.isVisible = item.hasFlag(FLAG_DOWNLOADED) binding.imageViewDownloaded.isVisible = item.hasFlag(FLAG_DOWNLOADED)
binding.imageViewNew.isVisible = item.hasFlag(FLAG_NEW) binding.imageViewNew.isVisible = item.hasFlag(FLAG_NEW)
} }
} }

View File

@@ -9,11 +9,18 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import java.io.File import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.* import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.internal.closeQuietly import okhttp3.internal.closeQuietly
@@ -32,7 +39,9 @@ import org.koitharu.kotatsu.parsers.util.await
import org.koitharu.kotatsu.utils.ext.deleteAwait import org.koitharu.kotatsu.utils.ext.deleteAwait
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import org.koitharu.kotatsu.utils.ext.referer import org.koitharu.kotatsu.utils.ext.referer
import org.koitharu.kotatsu.utils.ext.runCatchingCancellable
import org.koitharu.kotatsu.utils.progress.PausingProgressJob import org.koitharu.kotatsu.utils.progress.PausingProgressJob
import java.io.File
private const val MAX_FAILSAFE_ATTEMPTS = 2 private const val MAX_FAILSAFE_ATTEMPTS = 2
private const val DOWNLOAD_ERROR_DELAY = 500L private const val DOWNLOAD_ERROR_DELAY = 500L
@@ -231,7 +240,7 @@ class DownloadManager @AssistedInject constructor(
) )
} }
private suspend fun loadCover(manga: Manga) = runCatching { private suspend fun loadCover(manga: Manga) = runCatchingCancellable {
imageLoader.execute( imageLoader.execute(
ImageRequest.Builder(context) ImageRequest.Builder(context)
.data(manga.coverUrl) .data(manga.coverUrl)

View File

@@ -34,7 +34,7 @@ class DownloadsActivity : BaseActivity<ActivityDownloadsBinding>() {
setContentView(ActivityDownloadsBinding.inflate(layoutInflater)) setContentView(ActivityDownloadsBinding.inflate(layoutInflater))
supportActionBar?.setDisplayHomeAsUpEnabled(true) supportActionBar?.setDisplayHomeAsUpEnabled(true)
val adapter = DownloadsAdapter(lifecycleScope, coil) val adapter = DownloadsAdapter(lifecycleScope, coil)
val spacing = resources.getDimensionPixelOffset(R.dimen.grid_spacing) val spacing = resources.getDimensionPixelOffset(R.dimen.list_spacing)
binding.recyclerView.addItemDecoration(SpacingItemDecoration(spacing)) binding.recyclerView.addItemDecoration(SpacingItemDecoration(spacing))
binding.recyclerView.setHasFixedSize(true) binding.recyclerView.setHasFixedSize(true)
binding.recyclerView.adapter = adapter binding.recyclerView.adapter = adapter

View File

@@ -16,10 +16,12 @@ import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import java.util.concurrent.TimeUnit import kotlinx.coroutines.flow.Flow
import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow
import kotlin.collections.set import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.transformWhile
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
@@ -28,10 +30,14 @@ import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.download.domain.DownloadManager import org.koitharu.kotatsu.download.domain.DownloadManager
import org.koitharu.kotatsu.download.domain.DownloadState import org.koitharu.kotatsu.download.domain.DownloadState
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.utils.ext.getParcelableExtraCompat
import org.koitharu.kotatsu.utils.ext.throttle import org.koitharu.kotatsu.utils.ext.throttle
import org.koitharu.kotatsu.utils.progress.PausingProgressJob import org.koitharu.kotatsu.utils.progress.PausingProgressJob
import org.koitharu.kotatsu.utils.progress.ProgressJob import org.koitharu.kotatsu.utils.progress.ProgressJob
import org.koitharu.kotatsu.utils.progress.TimeLeftEstimator import org.koitharu.kotatsu.utils.progress.TimeLeftEstimator
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import kotlin.collections.set
@AndroidEntryPoint @AndroidEntryPoint
class DownloadService : BaseService() { class DownloadService : BaseService() {
@@ -65,7 +71,7 @@ class DownloadService : BaseService() {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
super.onStartCommand(intent, flags, startId) super.onStartCommand(intent, flags, startId)
val manga = intent?.getParcelableExtra<ParcelableManga>(EXTRA_MANGA)?.manga val manga = intent?.getParcelableExtraCompat<ParcelableManga>(EXTRA_MANGA)?.manga
val chapters = intent?.getLongArrayExtra(EXTRA_CHAPTERS_IDS) val chapters = intent?.getLongArrayExtra(EXTRA_CHAPTERS_IDS)
return if (manga != null) { return if (manga != null) {
jobs[startId] = downloadManga(startId, manga, chapters) jobs[startId] = downloadManga(startId, manga, chapters)
@@ -84,7 +90,9 @@ class DownloadService : BaseService() {
override fun onDestroy() { override fun onDestroy() {
unregisterReceiver(controlReceiver) unregisterReceiver(controlReceiver)
wakeLock.release() if (wakeLock.isHeld) {
wakeLock.release()
}
isRunning = false isRunning = false
super.onDestroy() super.onDestroy()
} }
@@ -168,6 +176,7 @@ class DownloadService : BaseService() {
val cancelId = intent.getIntExtra(EXTRA_CANCEL_ID, 0) val cancelId = intent.getIntExtra(EXTRA_CANCEL_ID, 0)
jobs[cancelId]?.cancel() jobs[cancelId]?.cancel()
} }
ACTION_DOWNLOAD_RESUME -> { ACTION_DOWNLOAD_RESUME -> {
val cancelId = intent.getIntExtra(EXTRA_CANCEL_ID, 0) val cancelId = intent.getIntExtra(EXTRA_CANCEL_ID, 0)
jobs[cancelId]?.resume() jobs[cancelId]?.resume()
@@ -254,7 +263,7 @@ class DownloadService : BaseService() {
fun getDownloadedManga(intent: Intent?): Manga? { fun getDownloadedManga(intent: Intent?): Manga? {
if (intent?.action == ACTION_DOWNLOAD_COMPLETE) { if (intent?.action == ACTION_DOWNLOAD_COMPLETE) {
return intent.getParcelableExtra<ParcelableManga>(EXTRA_MANGA)?.manga return intent.getParcelableExtraCompat<ParcelableManga>(EXTRA_MANGA)?.manga
} }
return null return null
} }

View File

@@ -61,7 +61,7 @@ class ExploreViewModel @Inject constructor(
sources.mapTo(result) { ExploreItem.Source(it) } sources.mapTo(result) { ExploreItem.Source(it) }
} else { } else {
result += ExploreItem.EmptyHint( result += ExploreItem.EmptyHint(
icon = R.drawable.ic_empty_search, icon = R.drawable.ic_empty_common,
textPrimary = R.string.no_manga_sources, textPrimary = R.string.no_manga_sources,
textSecondary = R.string.no_manga_sources_text, textSecondary = R.string.no_manga_sources_text,
actionStringRes = R.string.manage, actionStringRes = R.string.manage,

View File

@@ -1,6 +1,5 @@
package org.koitharu.kotatsu.explore.ui.model package org.koitharu.kotatsu.explore.ui.model
import android.net.Uri
import androidx.annotation.DrawableRes import androidx.annotation.DrawableRes
import androidx.annotation.StringRes import androidx.annotation.StringRes
import org.koitharu.kotatsu.list.ui.model.EmptyState import org.koitharu.kotatsu.list.ui.model.EmptyState
@@ -73,6 +72,7 @@ sealed interface ExploreItem : ListModel {
} }
} }
@Deprecated("")
class EmptyHint( class EmptyHint(
@DrawableRes icon: Int, @DrawableRes icon: Int,
@StringRes textPrimary: Int, @StringRes textPrimary: Int,
@@ -81,4 +81,4 @@ sealed interface ExploreItem : ListModel {
) : EmptyState(icon, textPrimary, textSecondary, actionStringRes), ExploreItem ) : EmptyState(icon, textPrimary, textSecondary, actionStringRes), ExploreItem
object Loading : ExploreItem object Loading : ExploreItem
} }

View File

@@ -13,6 +13,7 @@ import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.databinding.ActivityContainerBinding import org.koitharu.kotatsu.databinding.ActivityContainerBinding
import org.koitharu.kotatsu.favourites.ui.list.FavouritesListFragment import org.koitharu.kotatsu.favourites.ui.list.FavouritesListFragment
import org.koitharu.kotatsu.favourites.ui.list.FavouritesListFragment.Companion.NO_ID import org.koitharu.kotatsu.favourites.ui.list.FavouritesListFragment.Companion.NO_ID
import kotlin.text.Typography.dagger
@AndroidEntryPoint @AndroidEntryPoint
class FavouritesActivity : BaseActivity<ActivityContainerBinding>() { class FavouritesActivity : BaseActivity<ActivityContainerBinding>() {
@@ -28,6 +29,7 @@ class FavouritesActivity : BaseActivity<ActivityContainerBinding>() {
val fm = supportFragmentManager val fm = supportFragmentManager
if (fm.findFragmentById(R.id.container) == null) { if (fm.findFragmentById(R.id.container) == null) {
fm.commit { fm.commit {
setReorderingAllowed(true)
val fragment = FavouritesListFragment.newInstance(intent.getLongExtra(EXTRA_CATEGORY_ID, NO_ID)) val fragment = FavouritesListFragment.newInstance(intent.getLongExtra(EXTRA_CATEGORY_ID, NO_ID))
replace(R.id.container, fragment) replace(R.id.container, fragment)
} }

View File

@@ -28,6 +28,7 @@ import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.tracker.domain.TrackingRepository import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.utils.asFlowLiveData import org.koitharu.kotatsu.utils.asFlowLiveData
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
import org.koitharu.kotatsu.utils.ext.runCatchingCancellable
class FavouritesListViewModel @AssistedInject constructor( class FavouritesListViewModel @AssistedInject constructor(
@Assisted private val categoryId: Long, @Assisted private val categoryId: Long,
@@ -69,6 +70,7 @@ class FavouritesListViewModel @AssistedInject constructor(
actionStringRes = 0, actionStringRes = 0,
), ),
) )
else -> list.toUi(mode, this) else -> list.toUi(mode, this)
} }
}.catch { }.catch {
@@ -79,7 +81,7 @@ class FavouritesListViewModel @AssistedInject constructor(
if (categoryId != NO_ID) { if (categoryId != NO_ID) {
launchJob { launchJob {
categoryName = withContext(Dispatchers.Default) { categoryName = withContext(Dispatchers.Default) {
runCatching { runCatchingCancellable {
repository.getCategory(categoryId).title repository.getCategory(categoryId).title
}.getOrNull() }.getOrNull()
} }

View File

@@ -12,6 +12,7 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseActivity import org.koitharu.kotatsu.base.ui.BaseActivity
import org.koitharu.kotatsu.databinding.ActivityContainerBinding import org.koitharu.kotatsu.databinding.ActivityContainerBinding
import org.koitharu.kotatsu.main.ui.owners.AppBarOwner import org.koitharu.kotatsu.main.ui.owners.AppBarOwner
import kotlin.text.Typography.dagger
@AndroidEntryPoint @AndroidEntryPoint
class HistoryActivity : class HistoryActivity :
@@ -28,6 +29,7 @@ class HistoryActivity :
val fm = supportFragmentManager val fm = supportFragmentManager
if (fm.findFragmentById(R.id.container) == null) { if (fm.findFragmentById(R.id.container) == null) {
fm.commit { fm.commit {
setReorderingAllowed(true)
val fragment = HistoryListFragment.newInstance() val fragment = HistoryListFragment.newInstance()
replace(R.id.container, fragment) replace(R.id.container, fragment)
} }

View File

@@ -1,38 +0,0 @@
package org.koitharu.kotatsu.library.domain
import javax.inject.Inject
import kotlinx.coroutines.flow.*
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.entity.toManga
import org.koitharu.kotatsu.core.db.entity.toMangaTags
import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity
import org.koitharu.kotatsu.favourites.data.toFavouriteCategory
import org.koitharu.kotatsu.parsers.model.Manga
class LibraryRepository @Inject constructor(
private val db: MangaDatabase,
) {
fun observeFavourites(): Flow<Map<FavouriteCategory, List<Manga>>> {
return db.favouriteCategoriesDao.observeAll()
.flatMapLatest { categories ->
val cats = categories.filter { it.isVisibleInLibrary }
if (cats.isEmpty()) {
flowOf(emptyMap())
} else {
observeCategoriesContent(cats)
}
}
}
private fun observeCategoriesContent(
categories: List<FavouriteCategoryEntity>,
) = combine<Pair<FavouriteCategory, List<Manga>>, Map<FavouriteCategory, List<Manga>>>(
categories.map { cat ->
val category = cat.toFavouriteCategory()
db.favouritesDao.observeAll(category.id, category.order)
.map { category to it.map { x -> x.manga.toManga(x.tags.toMangaTags()) } }
},
) { array -> array.toMap() }
}

View File

@@ -1,15 +0,0 @@
package org.koitharu.kotatsu.library.ui.adapter
import android.view.View
import org.koitharu.kotatsu.library.ui.model.LibrarySectionModel
import org.koitharu.kotatsu.list.ui.adapter.ListStateHolderListener
import org.koitharu.kotatsu.parsers.model.Manga
interface LibraryListEventListener : ListStateHolderListener {
fun onItemClick(item: Manga, section: LibrarySectionModel, view: View)
fun onItemLongClick(item: Manga, section: LibrarySectionModel, view: View): Boolean
fun onSectionClick(section: LibrarySectionModel, view: View)
}

View File

@@ -1,94 +0,0 @@
package org.koitharu.kotatsu.library.ui.model
import android.content.res.Resources
import androidx.annotation.StringRes
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.core.ui.DateTimeAgo
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.MangaItemModel
sealed class LibrarySectionModel(
val items: List<MangaItemModel>,
@StringRes val showAllButtonText: Int,
) : ListModel {
abstract val key: Any
abstract fun getTitle(resources: Resources): CharSequence
class History(
items: List<MangaItemModel>,
val timeAgo: DateTimeAgo?,
showAllButtonText: Int,
) : LibrarySectionModel(items, showAllButtonText) {
override val key: Any
get() = timeAgo?.javaClass ?: this::class.java
override fun getTitle(resources: Resources): CharSequence {
return timeAgo?.format(resources) ?: resources.getString(R.string.history)
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as History
if (timeAgo != other.timeAgo) return false
if (showAllButtonText != other.showAllButtonText) return false
if (items != other.items) return false
return true
}
override fun hashCode(): Int {
var result = items.hashCode()
result = 31 * result + (timeAgo?.hashCode() ?: 0)
result = 31 * result + showAllButtonText.hashCode()
return result
}
override fun toString(): String {
return "hist_$timeAgo"
}
}
class Favourites(
items: List<MangaItemModel>,
val category: FavouriteCategory,
showAllButtonText: Int,
) : LibrarySectionModel(items, showAllButtonText) {
override val key: Any
get() = category.id
override fun getTitle(resources: Resources): CharSequence {
return category.title
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as Favourites
if (category != other.category) return false
if (showAllButtonText != other.showAllButtonText) return false
if (items != other.items) return false
return true
}
override fun hashCode(): Int {
var result = items.hashCode()
result = 31 * result + category.hashCode()
result = 31 * result + showAllButtonText.hashCode()
return result
}
override fun toString(): String {
return "fav_${category.id}"
}
}
}

View File

@@ -6,24 +6,23 @@ import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.fragment.app.FragmentManager import androidx.fragment.app.FragmentManager
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.button.MaterialButtonToggleGroup
import com.google.android.material.slider.Slider import com.google.android.material.slider.Slider
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.AlertDialogFragment import org.koitharu.kotatsu.base.ui.BaseBottomSheet
import org.koitharu.kotatsu.base.ui.widgets.CheckableButtonGroup
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.ListMode import org.koitharu.kotatsu.core.prefs.ListMode
import org.koitharu.kotatsu.databinding.DialogListModeBinding import org.koitharu.kotatsu.databinding.DialogListModeBinding
import org.koitharu.kotatsu.utils.ext.setValueRounded import org.koitharu.kotatsu.utils.ext.setValueRounded
import org.koitharu.kotatsu.utils.progress.IntPercentLabelFormatter import org.koitharu.kotatsu.utils.progress.IntPercentLabelFormatter
import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class ListModeSelectDialog : class ListModeBottomSheet :
AlertDialogFragment<DialogListModeBinding>(), BaseBottomSheet<DialogListModeBinding>(),
CheckableButtonGroup.OnCheckedChangeListener, Slider.OnChangeListener,
Slider.OnChangeListener { MaterialButtonToggleGroup.OnButtonCheckedListener {
@Inject @Inject
lateinit var settings: AppSettings lateinit var settings: AppSettings
@@ -33,13 +32,6 @@ class ListModeSelectDialog :
container: ViewGroup?, container: ViewGroup?,
) = DialogListModeBinding.inflate(inflater, container, false) ) = DialogListModeBinding.inflate(inflater, container, false)
override fun onBuildDialog(builder: MaterialAlertDialogBuilder): MaterialAlertDialogBuilder {
return super.onBuildDialog(builder)
.setTitle(R.string.list_mode)
.setPositiveButton(R.string.done, null)
.setCancelable(true)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
val mode = settings.listMode val mode = settings.listMode
@@ -53,10 +45,10 @@ class ListModeSelectDialog :
binding.sliderGrid.setValueRounded(settings.gridSize.toFloat()) binding.sliderGrid.setValueRounded(settings.gridSize.toFloat())
binding.sliderGrid.addOnChangeListener(this) binding.sliderGrid.addOnChangeListener(this)
binding.checkableGroup.onCheckedChangeListener = this binding.checkableGroup.addOnButtonCheckedListener(this)
} }
override fun onCheckedChanged(group: CheckableButtonGroup, checkedId: Int) { override fun onButtonChecked(group: MaterialButtonToggleGroup?, checkedId: Int, isChecked: Boolean) {
val mode = when (checkedId) { val mode = when (checkedId) {
R.id.button_list -> ListMode.LIST R.id.button_list -> ListMode.LIST
R.id.button_list_detailed -> ListMode.DETAILED_LIST R.id.button_list_detailed -> ListMode.DETAILED_LIST
@@ -78,6 +70,6 @@ class ListModeSelectDialog :
private const val TAG = "ListModeSelectDialog" private const val TAG = "ListModeSelectDialog"
fun show(fm: FragmentManager) = ListModeSelectDialog().show(fm, TAG) fun show(fm: FragmentManager) = ListModeBottomSheet().show(fm, TAG)
} }
} }

View File

@@ -17,9 +17,10 @@ class MangaListMenuProvider(
override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) { override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) {
R.id.action_list_mode -> { R.id.action_list_mode -> {
ListModeSelectDialog.show(fragment.childFragmentManager) ListModeBottomSheet.show(fragment.childFragmentManager)
true true
} }
else -> false else -> false
} }
} }

View File

@@ -1,8 +1,5 @@
@file:SuppressLint("UnsafeOptInUsageError")
package org.koitharu.kotatsu.list.ui.adapter package org.koitharu.kotatsu.list.ui.adapter
import android.annotation.SuppressLint
import android.view.View import android.view.View
import androidx.annotation.CheckResult import androidx.annotation.CheckResult
import androidx.core.view.doOnNextLayout import androidx.core.view.doOnNextLayout
@@ -42,4 +39,4 @@ private fun initBadge(anchor: View): BadgeDrawable {
private fun BadgeDrawable.align() { private fun BadgeDrawable.align() {
horizontalOffset = intrinsicWidth horizontalOffset = intrinsicWidth
verticalOffset = intrinsicHeight verticalOffset = intrinsicHeight
} }

View File

@@ -0,0 +1,23 @@
package org.koitharu.kotatsu.list.ui.adapter
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.databinding.ItemEmptyCardBinding
import org.koitharu.kotatsu.list.ui.model.EmptyHint
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.utils.ext.setTextAndVisible
fun emptyHintAD(
listener: ListStateHolderListener,
) = adapterDelegateViewBinding<EmptyHint, ListModel, ItemEmptyCardBinding>(
{ inflater, parent -> ItemEmptyCardBinding.inflate(inflater, parent, false) },
) {
binding.buttonRetry.setOnClickListener { listener.onEmptyActionClick() }
bind {
binding.icon.setImageResource(item.icon)
binding.textPrimary.setText(item.textPrimary)
binding.textSecondary.setTextAndVisible(item.textSecondary)
binding.buttonRetry.setTextAndVisible(item.actionStringRes)
}
}

View File

@@ -6,15 +6,22 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.update
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.domain.MangaDataRepository import org.koitharu.kotatsu.base.domain.MangaDataRepository
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import org.koitharu.kotatsu.utils.ext.runCatchingCancellable
import java.text.Collator import java.text.Collator
import java.util.* import java.util.Locale
import java.util.TreeSet
class FilterCoordinator( class FilterCoordinator(
private val repository: RemoteMangaRepository, private val repository: RemoteMangaRepository,
@@ -153,7 +160,7 @@ class FilterCoordinator(
} }
private fun loadTagsAsync() = coroutineScope.async(Dispatchers.Default, CoroutineStart.LAZY) { private fun loadTagsAsync() = coroutineScope.async(Dispatchers.Default, CoroutineStart.LAZY) {
runCatching { runCatchingCancellable {
repository.getTags() repository.getTags()
}.onFailure { error -> }.onFailure { error ->
error.printStackTraceDebug() error.printStackTraceDebug()
@@ -204,4 +211,4 @@ class FilterCoordinator(
return collator?.compare(t1, t2) ?: compareValues(t1, t2) return collator?.compare(t1, t2) ?: compareValues(t1, t2)
} }
} }
} }

View File

@@ -0,0 +1,14 @@
package org.koitharu.kotatsu.list.ui.model
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
class EmptyHint(
@DrawableRes icon: Int,
@StringRes textPrimary: Int,
@StringRes textSecondary: Int,
@StringRes actionStringRes: Int,
) : EmptyState(icon, textPrimary, textSecondary, actionStringRes) {
fun toState() = EmptyState(icon, textPrimary, textSecondary, actionStringRes)
}

View File

@@ -7,28 +7,40 @@ import androidx.annotation.WorkerThread
import androidx.collection.ArraySet import androidx.collection.ArraySet
import androidx.core.net.toFile import androidx.core.net.toFile
import androidx.core.net.toUri import androidx.core.net.toUri
import java.io.File import kotlinx.coroutines.CoroutineScope
import java.util.* import kotlinx.coroutines.Deferred
import java.util.zip.ZipEntry import kotlinx.coroutines.Dispatchers
import java.util.zip.ZipFile import kotlinx.coroutines.async
import javax.inject.Inject import kotlinx.coroutines.awaitAll
import javax.inject.Singleton import kotlinx.coroutines.coroutineScope
import kotlin.coroutines.CoroutineContext
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.filterNot import kotlinx.coroutines.flow.filterNot
import kotlinx.coroutines.runInterruptible
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.local.data.CbzFilter import org.koitharu.kotatsu.local.data.CbzFilter
import org.koitharu.kotatsu.local.data.LocalStorageManager import org.koitharu.kotatsu.local.data.LocalStorageManager
import org.koitharu.kotatsu.local.data.MangaIndex import org.koitharu.kotatsu.local.data.MangaIndex
import org.koitharu.kotatsu.local.data.TempFileFilter import org.koitharu.kotatsu.local.data.TempFileFilter
import org.koitharu.kotatsu.parsers.model.* import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.util.toCamelCase import org.koitharu.kotatsu.parsers.util.toCamelCase
import org.koitharu.kotatsu.utils.AlphanumComparator import org.koitharu.kotatsu.utils.AlphanumComparator
import org.koitharu.kotatsu.utils.CompositeMutex import org.koitharu.kotatsu.utils.CompositeMutex
import org.koitharu.kotatsu.utils.ext.deleteAwait import org.koitharu.kotatsu.utils.ext.deleteAwait
import org.koitharu.kotatsu.utils.ext.longHashCode import org.koitharu.kotatsu.utils.ext.longHashCode
import org.koitharu.kotatsu.utils.ext.readText import org.koitharu.kotatsu.utils.ext.readText
import org.koitharu.kotatsu.utils.ext.runCatchingCancellable
import java.io.File
import java.util.Enumeration
import java.util.zip.ZipEntry
import java.util.zip.ZipFile
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.coroutines.CoroutineContext
private const val MAX_PARALLELISM = 4 private const val MAX_PARALLELISM = 4
@@ -58,13 +70,14 @@ class LocalMangaRepository @Inject constructor(private val storageManager: Local
if (!tags.isNullOrEmpty()) { if (!tags.isNullOrEmpty()) {
list.retainAll { x -> x.containsTags(tags) } list.retainAll { x -> x.containsTags(tags) }
} }
@Suppress("NON_EXHAUSTIVE_WHEN_STATEMENT")
when (sortOrder) { when (sortOrder) {
SortOrder.ALPHABETICAL -> list.sortWith(compareBy(AlphanumComparator()) { x -> x.manga.title }) SortOrder.ALPHABETICAL -> list.sortWith(compareBy(AlphanumComparator()) { x -> x.manga.title })
SortOrder.RATING -> list.sortByDescending { it.manga.rating } SortOrder.RATING -> list.sortByDescending { it.manga.rating }
SortOrder.NEWEST, SortOrder.NEWEST,
SortOrder.UPDATED, SortOrder.UPDATED,
-> list.sortByDescending { it.createdAt } -> list.sortByDescending { it.createdAt }
else -> Unit
} }
return list.unwrap() return list.unwrap()
} }
@@ -73,6 +86,7 @@ class LocalMangaRepository @Inject constructor(private val storageManager: Local
manga.source != MangaSource.LOCAL -> requireNotNull(findSavedManga(manga)) { manga.source != MangaSource.LOCAL -> requireNotNull(findSavedManga(manga)) {
"Manga is not local or saved" "Manga is not local or saved"
} }
else -> getFromFile(Uri.parse(manga.url).toFile()) else -> getFromFile(Uri.parse(manga.url).toFile())
} }
@@ -236,7 +250,7 @@ class LocalMangaRepository @Inject constructor(private val storageManager: Local
context: CoroutineContext, context: CoroutineContext,
): Deferred<LocalManga?> = async(context) { ): Deferred<LocalManga?> = async(context) {
runInterruptible { runInterruptible {
runCatching { LocalManga(getFromFile(file), file) }.getOrNull() runCatchingCancellable { LocalManga(getFromFile(file), file) }.getOrNull()
} }
} }

View File

@@ -23,7 +23,12 @@ import org.koitharu.kotatsu.download.ui.service.DownloadService
import org.koitharu.kotatsu.local.domain.importer.MangaImporter import org.koitharu.kotatsu.local.domain.importer.MangaImporter
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.utils.PendingIntentCompat import org.koitharu.kotatsu.utils.PendingIntentCompat
import org.koitharu.kotatsu.utils.ext.* import org.koitharu.kotatsu.utils.ext.asArrayList
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import org.koitharu.kotatsu.utils.ext.referer
import org.koitharu.kotatsu.utils.ext.report
import org.koitharu.kotatsu.utils.ext.toBitmapOrNull
import javax.inject.Inject import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
@@ -48,8 +53,8 @@ class ImportService : CoroutineIntentService() {
super.onDestroy() super.onDestroy()
} }
override suspend fun processIntent(intent: Intent?) { override suspend fun processIntent(startId: Int, intent: Intent) {
val uris = intent?.getParcelableArrayListExtra<Uri>(EXTRA_URIS) val uris = intent.getParcelableArrayListExtra<Uri>(EXTRA_URIS)
if (uris.isNullOrEmpty()) { if (uris.isNullOrEmpty()) {
return return
} }
@@ -69,6 +74,10 @@ class ImportService : CoroutineIntentService() {
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE) ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
} }
override fun onError(startId: Int, error: Throwable) {
error.report()
}
private suspend fun importImpl(uri: Uri): Manga { private suspend fun importImpl(uri: Uri): Manga {
val importer = importerFactory.create(uri) val importer = importerFactory.create(uri)
return importer.import(uri) return importer.import(uri)

View File

@@ -9,13 +9,15 @@ import androidx.core.app.NotificationCompat
import androidx.core.app.ServiceCompat import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.CoroutineIntentService import org.koitharu.kotatsu.base.ui.CoroutineIntentService
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.download.ui.service.DownloadService import org.koitharu.kotatsu.download.ui.service.DownloadService
import org.koitharu.kotatsu.local.domain.LocalMangaRepository import org.koitharu.kotatsu.local.domain.LocalMangaRepository
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.getParcelableExtraCompat
import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class LocalChaptersRemoveService : CoroutineIntentService() { class LocalChaptersRemoveService : CoroutineIntentService() {
@@ -33,8 +35,8 @@ class LocalChaptersRemoveService : CoroutineIntentService() {
super.onDestroy() super.onDestroy()
} }
override suspend fun processIntent(intent: Intent?) { override suspend fun processIntent(startId: Int, intent: Intent) {
val manga = intent?.getParcelableExtra<ParcelableManga>(EXTRA_MANGA)?.manga ?: return val manga = intent.getParcelableExtraCompat<ParcelableManga>(EXTRA_MANGA)?.manga ?: return
val chaptersIds = intent.getLongArrayExtra(EXTRA_CHAPTERS_IDS)?.toSet() ?: return val chaptersIds = intent.getLongArrayExtra(EXTRA_CHAPTERS_IDS)?.toSet() ?: return
startForeground() startForeground()
val mangaWithChapters = localMangaRepository.getDetails(manga) val mangaWithChapters = localMangaRepository.getDetails(manga)
@@ -46,6 +48,21 @@ class LocalChaptersRemoveService : CoroutineIntentService() {
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE) ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
} }
override fun onError(startId: Int, error: Throwable) {
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle(getString(R.string.error_occurred))
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setDefaults(0)
.setColor(ContextCompat.getColor(this, R.color.blue_primary_dark))
.setSilent(true)
.setContentText(error.getDisplayMessage(resources))
.setSmallIcon(android.R.drawable.stat_notify_error)
.setAutoCancel(true)
.build()
val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
nm.notify(NOTIFICATION_ID + startId, notification)
}
private fun startForeground() { private fun startForeground() {
val title = getString(R.string.local_manga_processing) val title = getString(R.string.local_manga_processing)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {

View File

@@ -29,6 +29,7 @@ import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.utils.SingleLiveEvent import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import org.koitharu.kotatsu.utils.ext.runCatchingCancellable
@HiltViewModel @HiltViewModel
class LocalListViewModel @Inject constructor( class LocalListViewModel @Inject constructor(
@@ -63,6 +64,7 @@ class LocalListViewModel @Inject constructor(
actionStringRes = R.string._import, actionStringRes = R.string._import,
), ),
) )
else -> buildList(list.size + 1) { else -> buildList(list.size + 1) {
add(createHeader(list, tags, order)) add(createHeader(list, tags, order))
list.toUi(this, mode, this@LocalListViewModel) list.toUi(this, mode, this@LocalListViewModel)
@@ -104,7 +106,7 @@ class LocalListViewModel @Inject constructor(
for (manga in itemsToRemove) { for (manga in itemsToRemove) {
val original = repository.getRemoteManga(manga) val original = repository.getRemoteManga(manga)
repository.delete(manga) || throw IOException("Unable to delete file") repository.delete(manga) || throw IOException("Unable to delete file")
runCatching { runCatchingCancellable {
historyRepository.deleteOrSwap(manga, original) historyRepository.deleteOrSwap(manga, original)
} }
mangaList.update { list -> mangaList.update { list ->
@@ -120,6 +122,8 @@ class LocalListViewModel @Inject constructor(
try { try {
listError.value = null listError.value = null
mangaList.value = repository.getList(0, selectedTags.value, sortOrder.value) mangaList.value = repository.getList(0, selectedTags.value, sortOrder.value)
} catch (e: CancellationException) {
throw e
} catch (e: Throwable) { } catch (e: Throwable) {
listError.value = e listError.value = e
} }
@@ -128,7 +132,7 @@ class LocalListViewModel @Inject constructor(
private fun cleanup() { private fun cleanup() {
if (!DownloadService.isRunning && !ImportService.isRunning && !LocalChaptersRemoveService.isRunning) { if (!DownloadService.isRunning && !ImportService.isRunning && !LocalChaptersRemoveService.isRunning) {
viewModelScope.launch { viewModelScope.launch {
runCatching { runCatchingCancellable {
repository.cleanup() repository.cleanup()
}.onFailure { error -> }.onFailure { error ->
error.printStackTraceDebug() error.printStackTraceDebug()

View File

@@ -15,15 +15,21 @@ import androidx.core.app.ActivityOptionsCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.graphics.Insets import androidx.core.graphics.Insets
import androidx.core.util.size import androidx.core.util.size
import androidx.core.view.* import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams
import androidx.core.view.updatePadding
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentTransaction import androidx.fragment.app.FragmentTransaction
import androidx.fragment.app.commit import androidx.fragment.app.commit
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.transition.TransitionManager import androidx.transition.TransitionManager
import com.google.android.material.R as materialR
import com.google.android.material.appbar.AppBarLayout import com.google.android.material.appbar.AppBarLayout
import com.google.android.material.appbar.AppBarLayout.LayoutParams.* import com.google.android.material.appbar.AppBarLayout.LayoutParams.SCROLL_FLAG_ENTER_ALWAYS
import com.google.android.material.appbar.AppBarLayout.LayoutParams.SCROLL_FLAG_NO_SCROLL
import com.google.android.material.appbar.AppBarLayout.LayoutParams.SCROLL_FLAG_SCROLL
import com.google.android.material.appbar.AppBarLayout.LayoutParams.SCROLL_FLAG_SNAP
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -34,7 +40,6 @@ import org.koitharu.kotatsu.base.ui.BaseActivity
import org.koitharu.kotatsu.base.ui.widgets.SlidingBottomNavigationView import org.koitharu.kotatsu.base.ui.widgets.SlidingBottomNavigationView
import org.koitharu.kotatsu.databinding.ActivityMainBinding import org.koitharu.kotatsu.databinding.ActivityMainBinding
import org.koitharu.kotatsu.details.ui.DetailsActivity import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.library.ui.LibraryFragment
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.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
@@ -48,10 +53,18 @@ 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.newsources.NewSourcesDialogFragment import org.koitharu.kotatsu.settings.newsources.NewSourcesDialogFragment
import org.koitharu.kotatsu.settings.onboard.OnboardDialogFragment import org.koitharu.kotatsu.settings.onboard.OnboardDialogFragment
import org.koitharu.kotatsu.shelf.ui.ShelfFragment
import org.koitharu.kotatsu.suggestions.ui.SuggestionsWorker import org.koitharu.kotatsu.suggestions.ui.SuggestionsWorker
import org.koitharu.kotatsu.tracker.work.TrackWorker import org.koitharu.kotatsu.tracker.work.TrackWorker
import org.koitharu.kotatsu.utils.VoiceInputContract import org.koitharu.kotatsu.utils.VoiceInputContract
import org.koitharu.kotatsu.utils.ext.* import org.koitharu.kotatsu.utils.ext.drawableEnd
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.hideKeyboard
import org.koitharu.kotatsu.utils.ext.resolve
import org.koitharu.kotatsu.utils.ext.scaleUpActivityOptionsOf
import org.koitharu.kotatsu.utils.ext.setNavigationBarTransparentCompat
import org.koitharu.kotatsu.utils.ext.tryLaunch
import com.google.android.material.R as materialR
private const val TAG_SEARCH = "search" private const val TAG_SEARCH = "search"
@@ -115,6 +128,7 @@ class MainActivity :
viewModel.isLoading.observe(this, this::onLoadingStateChanged) viewModel.isLoading.observe(this, this::onLoadingStateChanged)
viewModel.isResumeEnabled.observe(this, this::onResumeEnabledChanged) viewModel.isResumeEnabled.observe(this, this::onResumeEnabledChanged)
viewModel.counters.observe(this, ::onCountersChanged) viewModel.counters.observe(this, ::onCountersChanged)
viewModel.isFeedAvailable.observe(this, ::onFeedAvailabilityChanged)
} }
override fun onRestoreInstanceState(savedInstanceState: Bundle) { override fun onRestoreInstanceState(savedInstanceState: Bundle) {
@@ -127,20 +141,18 @@ class MainActivity :
binding.searchView.clearFocus() binding.searchView.clearFocus()
when { when {
fragment != null -> supportFragmentManager.commit { fragment != null -> supportFragmentManager.commit {
setReorderingAllowed(true)
remove(fragment) remove(fragment)
setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE) setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE)
runOnCommit { onSearchClosed() } runOnCommit { onSearchClosed() }
} }
else -> super.onBackPressed() else -> super.onBackPressed()
} }
} }
override fun onFragmentChanged(fragment: Fragment, fromUser: Boolean) { override fun onFragmentChanged(fragment: Fragment, fromUser: Boolean) {
if (fragment is LibraryFragment) { adjustFabVisibility(topFragment = fragment)
binding.fab?.show()
} else {
binding.fab?.hide()
}
if (fromUser) { if (fromUser) {
binding.appbar.setExpanded(true) binding.appbar.setExpanded(true)
} }
@@ -173,6 +185,7 @@ class MainActivity :
if (v?.id == R.id.searchView && hasFocus) { if (v?.id == R.id.searchView && hasFocus) {
if (fragment == null) { if (fragment == null) {
supportFragmentManager.commit { supportFragmentManager.commit {
setReorderingAllowed(true)
add(R.id.container, SearchSuggestionFragment.newInstance(), TAG_SEARCH) add(R.id.container, SearchSuggestionFragment.newInstance(), TAG_SEARCH)
setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE) setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE)
runOnCommit { onSearchOpened() } runOnCommit { onSearchOpened() }
@@ -257,6 +270,10 @@ class MainActivity :
} }
} }
private fun onFeedAvailabilityChanged(isFeedAvailable: Boolean) {
navigationDelegate.setItemVisibility(R.id.nav_feed, isFeedAvailable)
}
private fun onLoadingStateChanged(isLoading: Boolean) { private fun onLoadingStateChanged(isLoading: Boolean) {
binding.fab?.isEnabled = !isLoading binding.fab?.isEnabled = !isLoading
} }
@@ -308,18 +325,18 @@ class MainActivity :
topFragment: Fragment? = navigationDelegate.primaryFragment, topFragment: Fragment? = navigationDelegate.primaryFragment,
isSearchOpened: Boolean = isSearchOpened(), isSearchOpened: Boolean = isSearchOpened(),
) { ) {
val fab = binding.fab val fab = binding.fab ?: return
if ( if (
isResumeEnabled && isResumeEnabled &&
!actionModeDelegate.isActionModeStarted && !actionModeDelegate.isActionModeStarted &&
!isSearchOpened && !isSearchOpened &&
topFragment is LibraryFragment topFragment is ShelfFragment
) { ) {
if (fab?.isVisible == false) { if (!fab.isVisible) {
fab.show() fab.show()
} }
} else { } else {
if (fab?.isVisible == true) { if (fab.isVisible) {
fab.hide() fab.hide()
} }
} }

View File

@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.main.ui
import android.os.Bundle import android.os.Bundle
import android.view.MenuItem import android.view.MenuItem
import androidx.annotation.IdRes import androidx.annotation.IdRes
import androidx.core.view.iterator
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager import androidx.fragment.app.FragmentManager
import androidx.fragment.app.FragmentTransaction import androidx.fragment.app.FragmentTransaction
@@ -10,10 +11,10 @@ import com.google.android.material.navigation.NavigationBarView
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.util.RecyclerViewOwner import org.koitharu.kotatsu.base.ui.util.RecyclerViewOwner
import org.koitharu.kotatsu.explore.ui.ExploreFragment import org.koitharu.kotatsu.explore.ui.ExploreFragment
import org.koitharu.kotatsu.library.ui.LibraryFragment
import org.koitharu.kotatsu.settings.tools.ToolsFragment import org.koitharu.kotatsu.settings.tools.ToolsFragment
import org.koitharu.kotatsu.tracker.ui.FeedFragment import org.koitharu.kotatsu.shelf.ui.ShelfFragment
import java.util.* import org.koitharu.kotatsu.tracker.ui.feed.FeedFragment
import java.util.LinkedList
private const val TAG_PRIMARY = "primary" private const val TAG_PRIMARY = "primary"
@@ -62,6 +63,14 @@ class MainNavigationDelegate(
} }
} }
fun setItemVisibility(@IdRes itemId: Int, isVisible: Boolean) {
val item = navBar.menu.findItem(itemId) ?: return
item.isVisible = isVisible
if (item.isChecked && !isVisible) {
navBar.selectedItemId = firstItem()?.itemId ?: return
}
}
fun addOnFragmentChangedListener(listener: OnFragmentChangedListener) { fun addOnFragmentChangedListener(listener: OnFragmentChangedListener) {
listeners.add(listener) listeners.add(listener)
} }
@@ -73,7 +82,7 @@ class MainNavigationDelegate(
private fun onNavigationItemSelected(@IdRes itemId: Int): Boolean { private fun onNavigationItemSelected(@IdRes itemId: Int): Boolean {
setPrimaryFragment( setPrimaryFragment(
when (itemId) { when (itemId) {
R.id.nav_library -> LibraryFragment.newInstance() R.id.nav_shelf -> ShelfFragment.newInstance()
R.id.nav_explore -> ExploreFragment.newInstance() R.id.nav_explore -> ExploreFragment.newInstance()
R.id.nav_feed -> FeedFragment.newInstance() R.id.nav_feed -> FeedFragment.newInstance()
R.id.nav_tools -> ToolsFragment.newInstance() R.id.nav_tools -> ToolsFragment.newInstance()
@@ -85,6 +94,7 @@ class MainNavigationDelegate(
private fun setPrimaryFragment(fragment: Fragment) { private fun setPrimaryFragment(fragment: Fragment) {
fragmentManager.beginTransaction() fragmentManager.beginTransaction()
.setReorderingAllowed(true)
.replace(R.id.container, fragment, TAG_PRIMARY) .replace(R.id.container, fragment, TAG_PRIMARY)
.setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE) .setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE)
.commit() .commit()
@@ -95,6 +105,14 @@ class MainNavigationDelegate(
listeners.forEach { it.onFragmentChanged(fragment, fromUser) } listeners.forEach { it.onFragmentChanged(fragment, fromUser) }
} }
private fun firstItem(): MenuItem? {
val menu = navBar.menu
for (item in menu) {
if (item.isVisible) return item
}
return null
}
interface OnFragmentChangedListener { interface OnFragmentChangedListener {
fun onFragmentChanged(fragment: Fragment, fromUser: Boolean) fun onFragmentChanged(fragment: Fragment, fromUser: Boolean)

View File

@@ -4,7 +4,6 @@ import android.util.SparseIntArray
import androidx.core.util.set import androidx.core.util.set
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
@@ -12,6 +11,8 @@ import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.exceptions.EmptyHistoryException import org.koitharu.kotatsu.core.exceptions.EmptyHistoryException
import org.koitharu.kotatsu.core.github.AppUpdateRepository import org.koitharu.kotatsu.core.github.AppUpdateRepository
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.observeAsLiveData
import org.koitharu.kotatsu.history.domain.HistoryRepository import org.koitharu.kotatsu.history.domain.HistoryRepository
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.sync.domain.SyncController import org.koitharu.kotatsu.sync.domain.SyncController
@@ -19,6 +20,7 @@ import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.utils.SingleLiveEvent import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.asFlowLiveData import org.koitharu.kotatsu.utils.asFlowLiveData
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
import javax.inject.Inject
@HiltViewModel @HiltViewModel
class MainViewModel @Inject constructor( class MainViewModel @Inject constructor(
@@ -27,6 +29,7 @@ class MainViewModel @Inject constructor(
private val trackingRepository: TrackingRepository, private val trackingRepository: TrackingRepository,
syncController: SyncController, syncController: SyncController,
database: MangaDatabase, database: MangaDatabase,
private val settings: AppSettings,
) : BaseViewModel() { ) : BaseViewModel() {
val onOpenReader = SingleLiveEvent<Manga>() val onOpenReader = SingleLiveEvent<Manga>()
@@ -35,6 +38,12 @@ class MainViewModel @Inject constructor(
.observeHasItems() .observeHasItems()
.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, false) .asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, false)
val isFeedAvailable = settings.observeAsLiveData(
context = viewModelScope.coroutineContext + Dispatchers.Default,
key = AppSettings.KEY_TRACKER_ENABLED,
valueProducer = { isTrackerEnabled },
)
val counters = combine( val counters = combine(
appUpdateRepository.observeAvailableUpdate(), appUpdateRepository.observeAvailableUpdate(),
trackingRepository.observeUpdatedMangaCount(), trackingRepository.observeUpdatedMangaCount(),

View File

@@ -1,16 +1,16 @@
package org.koitharu.kotatsu.main.ui.protect package org.koitharu.kotatsu.main.ui.protect
import android.app.Activity import android.app.Activity
import android.app.Application
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import org.acra.dialog.CrashReportDialog
import org.koitharu.kotatsu.base.ui.DefaultActivityLifecycleCallbacks
import org.koitharu.kotatsu.core.prefs.AppSettings
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
import org.acra.dialog.CrashReportDialog
import org.koitharu.kotatsu.core.prefs.AppSettings
@Singleton @Singleton
class AppProtectHelper @Inject constructor(private val settings: AppSettings) : Application.ActivityLifecycleCallbacks { class AppProtectHelper @Inject constructor(private val settings: AppSettings) : DefaultActivityLifecycleCallbacks {
private var isUnlocked = settings.appPassword.isNullOrEmpty() private var isUnlocked = settings.appPassword.isNullOrEmpty()
@@ -27,16 +27,6 @@ class AppProtectHelper @Inject constructor(private val settings: AppSettings) :
} }
} }
override fun onActivityStarted(activity: Activity) = Unit
override fun onActivityResumed(activity: Activity) = Unit
override fun onActivityPaused(activity: Activity) = Unit
override fun onActivityStopped(activity: Activity) = Unit
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) = Unit
override fun onActivityDestroyed(activity: Activity) { override fun onActivityDestroyed(activity: Activity) {
if (activity !is ProtectActivity && activity.isFinishing && activity.isTaskRoot) { if (activity !is ProtectActivity && activity.isFinishing && activity.isTaskRoot) {
restoreLock() restoreLock()

View File

@@ -49,7 +49,10 @@ class ProtectActivity :
startActivity(intent) startActivity(intent)
finishAfterTransition() finishAfterTransition()
} }
}
override fun onStart() {
super.onStart()
if (!useFingerprint()) { if (!useFingerprint()) {
binding.editPassword.requestFocus() binding.editPassword.requestFocus()
} }

View File

@@ -7,14 +7,16 @@ import android.net.Uri
import androidx.collection.LongSparseArray import androidx.collection.LongSparseArray
import androidx.collection.set import androidx.collection.set
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import java.io.File import kotlinx.coroutines.CompletableDeferred
import java.util.* import kotlinx.coroutines.CoroutineExceptionHandler
import java.util.concurrent.atomic.AtomicInteger import kotlinx.coroutines.CoroutineScope
import java.util.zip.ZipFile import kotlinx.coroutines.Dispatchers
import javax.inject.Inject import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.* import kotlinx.coroutines.async
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
@@ -30,7 +32,15 @@ import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.await import org.koitharu.kotatsu.parsers.util.await
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
import org.koitharu.kotatsu.utils.ext.connectivityManager import org.koitharu.kotatsu.utils.ext.connectivityManager
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import org.koitharu.kotatsu.utils.progress.ProgressDeferred import org.koitharu.kotatsu.utils.progress.ProgressDeferred
import java.io.File
import java.util.LinkedList
import java.util.concurrent.atomic.AtomicInteger
import java.util.zip.ZipFile
import javax.inject.Inject
import kotlin.coroutines.AbstractCoroutineContextElement
import kotlin.coroutines.CoroutineContext
private const val PROGRESS_UNDEFINED = -1f private const val PROGRESS_UNDEFINED = -1f
private const val PREFETCH_LIMIT_DEFAULT = 10 private const val PREFETCH_LIMIT_DEFAULT = 10
@@ -43,7 +53,7 @@ class PageLoader @Inject constructor(
private val mangaRepositoryFactory: MangaRepository.Factory, private val mangaRepositoryFactory: MangaRepository.Factory,
) : Closeable { ) : Closeable {
val loaderScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) val loaderScope = CoroutineScope(SupervisorJob() + InternalErrorHandler() + Dispatchers.Default)
private val connectivityManager = context.connectivityManager private val connectivityManager = context.connectivityManager
private val tasks = LongSparseArray<ProgressDeferred<File, Float>>() private val tasks = LongSparseArray<ProgressDeferred<File, Float>>()
@@ -197,4 +207,13 @@ class PageLoader @Inject constructor(
val deferred = CompletableDeferred(file) val deferred = CompletableDeferred(file)
return ProgressDeferred(deferred, emptyProgressFlow) return ProgressDeferred(deferred, emptyProgressFlow)
} }
private class InternalErrorHandler : AbstractCoroutineContextElement(CoroutineExceptionHandler),
CoroutineExceptionHandler {
override fun handleException(context: CoroutineContext, exception: Throwable) {
exception.printStackTraceDebug()
}
}
} }

View File

@@ -9,15 +9,23 @@ import android.transition.Fade
import android.transition.Slide import android.transition.Slide
import android.transition.TransitionManager import android.transition.TransitionManager
import android.transition.TransitionSet import android.transition.TransitionSet
import android.view.* import android.view.Gravity
import android.view.KeyEvent
import android.view.Menu
import android.view.MenuItem
import android.view.MotionEvent
import android.view.View
import android.view.WindowManager
import androidx.core.graphics.Insets import androidx.core.graphics.Insets
import androidx.core.view.* import androidx.core.view.OnApplyWindowInsetsListener
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.core.view.updatePadding
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -42,7 +50,17 @@ import org.koitharu.kotatsu.settings.SettingsActivity
import org.koitharu.kotatsu.utils.GridTouchHelper import org.koitharu.kotatsu.utils.GridTouchHelper
import org.koitharu.kotatsu.utils.IdlingDetector import org.koitharu.kotatsu.utils.IdlingDetector
import org.koitharu.kotatsu.utils.ShareHelper import org.koitharu.kotatsu.utils.ShareHelper
import org.koitharu.kotatsu.utils.ext.* import org.koitharu.kotatsu.utils.ext.assistedViewModels
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.getParcelableExtraCompat
import org.koitharu.kotatsu.utils.ext.hasGlobalPoint
import org.koitharu.kotatsu.utils.ext.isReportable
import org.koitharu.kotatsu.utils.ext.observeWithPrevious
import org.koitharu.kotatsu.utils.ext.postDelayed
import org.koitharu.kotatsu.utils.ext.report
import org.koitharu.kotatsu.utils.ext.setValueRounded
import java.util.concurrent.TimeUnit
import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class ReaderActivity : class ReaderActivity :
@@ -145,6 +163,7 @@ class ReaderActivity :
R.id.action_settings -> { R.id.action_settings -> {
startActivity(SettingsActivity.newReaderSettingsIntent(this)) startActivity(SettingsActivity.newReaderSettingsIntent(this))
} }
R.id.action_chapters -> { R.id.action_chapters -> {
ChaptersBottomSheet.show( ChaptersBottomSheet.show(
supportFragmentManager, supportFragmentManager,
@@ -152,6 +171,7 @@ class ReaderActivity :
viewModel.getCurrentState()?.chapterId ?: 0L, viewModel.getCurrentState()?.chapterId ?: 0L,
) )
} }
R.id.action_pages_thumbs -> { R.id.action_pages_thumbs -> {
val pages = viewModel.getCurrentChapterPages() val pages = viewModel.getCurrentChapterPages()
if (!pages.isNullOrEmpty()) { if (!pages.isNullOrEmpty()) {
@@ -165,6 +185,7 @@ class ReaderActivity :
return false return false
} }
} }
R.id.action_bookmark -> { R.id.action_bookmark -> {
if (viewModel.isBookmarkAdded.value == true) { if (viewModel.isBookmarkAdded.value == true) {
viewModel.removeBookmark() viewModel.removeBookmark()
@@ -172,11 +193,13 @@ class ReaderActivity :
viewModel.addBookmark() viewModel.addBookmark()
} }
} }
R.id.action_options -> { R.id.action_options -> {
viewModel.saveCurrentState(readerManager.currentReader?.getCurrentState()) viewModel.saveCurrentState(readerManager.currentReader?.getCurrentState())
val currentMode = readerManager.currentMode ?: return false val currentMode = readerManager.currentMode ?: return false
ReaderConfigBottomSheet.show(supportFragmentManager, currentMode) ReaderConfigBottomSheet.show(supportFragmentManager, currentMode)
} }
else -> return super.onOptionsItemSelected(item) else -> return super.onOptionsItemSelected(item)
} }
return true return true
@@ -364,9 +387,9 @@ class ReaderActivity :
binding.toastView.showTemporary(uiState.chapterName, TOAST_DURATION) binding.toastView.showTemporary(uiState.chapterName, TOAST_DURATION)
} }
} }
if (uiState.totalPages > 1 && uiState.currentPage < uiState.totalPages) { if (uiState.isSliderAvailable()) {
binding.slider.valueTo = uiState.totalPages.toFloat() - 1 binding.slider.valueTo = uiState.totalPages.toFloat() - 1
binding.slider.value = uiState.currentPage.toFloat() binding.slider.setValueRounded(uiState.currentPage.toFloat())
binding.slider.isVisible = true binding.slider.isVisible = true
} else { } else {
binding.slider.isVisible = false binding.slider.isVisible = false
@@ -383,7 +406,7 @@ class ReaderActivity :
if (ExceptionResolver.canResolve(exception)) { if (ExceptionResolver.canResolve(exception)) {
tryResolve(exception) tryResolve(exception)
} else { } else {
exception.report("ReaderActivity::onError") exception.report()
} }
} else { } else {
onCancel(dialog) onCancel(dialog)

View File

@@ -1,83 +0,0 @@
package org.koitharu.kotatsu.reader.ui
import android.content.DialogInterface
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.FragmentManager
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.AlertDialogFragment
import org.koitharu.kotatsu.base.ui.widgets.CheckableButtonGroup
import org.koitharu.kotatsu.core.prefs.ReaderMode
import org.koitharu.kotatsu.databinding.DialogReaderConfigBinding
import org.koitharu.kotatsu.utils.ext.withArgs
@Deprecated("Not in use")
class ReaderConfigDialog :
AlertDialogFragment<DialogReaderConfigBinding>(),
CheckableButtonGroup.OnCheckedChangeListener {
private lateinit var mode: ReaderMode
override fun onInflateView(
inflater: LayoutInflater,
container: ViewGroup?,
) = DialogReaderConfigBinding.inflate(inflater, container, false)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
mode = arguments?.getInt(ARG_MODE)
?.let { ReaderMode.valueOf(it) }
?: ReaderMode.STANDARD
}
override fun onBuildDialog(builder: MaterialAlertDialogBuilder): MaterialAlertDialogBuilder {
return super.onBuildDialog(builder)
.setTitle(R.string.read_mode)
.setPositiveButton(R.string.done, null)
.setCancelable(true)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.buttonStandard.isChecked = mode == ReaderMode.STANDARD
binding.buttonReversed.isChecked = mode == ReaderMode.REVERSED
binding.buttonWebtoon.isChecked = mode == ReaderMode.WEBTOON
binding.checkableGroup.onCheckedChangeListener = this
}
override fun onDismiss(dialog: DialogInterface) {
(
(parentFragment as? Callback)
?: (activity as? Callback)
)?.onReaderModeChanged(mode)
super.onDismiss(dialog)
}
override fun onCheckedChanged(group: CheckableButtonGroup, checkedId: Int) {
mode = when (checkedId) {
R.id.button_standard -> ReaderMode.STANDARD
R.id.button_webtoon -> ReaderMode.WEBTOON
R.id.button_reversed -> ReaderMode.REVERSED
else -> return
}
}
interface Callback {
fun onReaderModeChanged(mode: ReaderMode)
}
companion object {
private const val TAG = "ReaderConfigDialog"
private const val ARG_MODE = "mode"
fun show(fm: FragmentManager, mode: ReaderMode) = ReaderConfigDialog().withArgs(1) {
putInt(ARG_MODE, mode.id)
}.show(fm, TAG)
}
}

View File

@@ -62,8 +62,9 @@ class ReaderInfoBarView @JvmOverloads constructor(
init { init {
paint.strokeWidth = context.resources.resolveDp(2f) paint.strokeWidth = context.resources.resolveDp(2f)
val insetCorner = getSystemUiDimensionOffset("rounded_corner_content_padding") val insetCorner = getSystemUiDimensionOffset("rounded_corner_content_padding")
val insetStart = getSystemUiDimensionOffset("status_bar_padding_start") + insetCorner val fallbackInset = resources.getDimensionPixelOffset(R.dimen.reader_bar_inset_fallback)
val insetEnd = getSystemUiDimensionOffset("status_bar_padding_end") + insetCorner val insetStart = getSystemUiDimensionOffset("status_bar_padding_start", fallbackInset) + insetCorner
val insetEnd = getSystemUiDimensionOffset("status_bar_padding_end", fallbackInset) + insetCorner
val isRtl = layoutDirection == LAYOUT_DIRECTION_RTL val isRtl = layoutDirection == LAYOUT_DIRECTION_RTL
insetLeft = if (isRtl) insetEnd else insetStart insetLeft = if (isRtl) insetEnd else insetStart
insetRight = if (isRtl) insetStart else insetEnd insetRight = if (isRtl) insetStart else insetEnd
@@ -179,12 +180,12 @@ class ReaderInfoBarView @JvmOverloads constructor(
} }
} }
private fun getSystemUiDimensionOffset(name: String): Int = runCatching { private fun getSystemUiDimensionOffset(name: String, fallback: Int = 0): Int = runCatching {
val manager = context.packageManager val manager = context.packageManager
val resources = manager.getResourcesForApplication("com.android.systemui") val resources = manager.getResourcesForApplication("com.android.systemui")
val resId = resources.getIdentifier(name, "dimen", "com.android.systemui") val resId = resources.getIdentifier(name, "dimen", "com.android.systemui")
resources.getDimensionPixelOffset(resId) resources.getDimensionPixelOffset(resId)
}.onFailure { }.onFailure {
it.printStackTraceDebug() it.printStackTraceDebug()
}.getOrDefault(0) }.getOrDefault(fallback)
} }

View File

@@ -8,7 +8,7 @@ import org.koitharu.kotatsu.reader.ui.pager.BaseReader
import org.koitharu.kotatsu.reader.ui.pager.reversed.ReversedReaderFragment import org.koitharu.kotatsu.reader.ui.pager.reversed.ReversedReaderFragment
import org.koitharu.kotatsu.reader.ui.pager.standard.PagerReaderFragment import org.koitharu.kotatsu.reader.ui.pager.standard.PagerReaderFragment
import org.koitharu.kotatsu.reader.ui.pager.webtoon.WebtoonReaderFragment import org.koitharu.kotatsu.reader.ui.pager.webtoon.WebtoonReaderFragment
import java.util.* import java.util.EnumMap
class ReaderManager( class ReaderManager(
private val fragmentManager: FragmentManager, private val fragmentManager: FragmentManager,
@@ -35,11 +35,15 @@ class ReaderManager(
fun replace(newMode: ReaderMode) { fun replace(newMode: ReaderMode) {
val readerClass = requireNotNull(modeMap[newMode]) val readerClass = requireNotNull(modeMap[newMode])
fragmentManager.commit { fragmentManager.commit {
setReorderingAllowed(true)
replace(containerResId, readerClass, null, null) replace(containerResId, readerClass, null, null)
} }
} }
fun replace(reader: BaseReader<*>) { fun replace(reader: BaseReader<*>) {
fragmentManager.commit { replace(containerResId, reader) } fragmentManager.commit {
setReorderingAllowed(true)
replace(containerResId, reader)
}
} }
} }

View File

@@ -10,10 +10,22 @@ import androidx.lifecycle.viewModelScope
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import java.util.* import kotlinx.coroutines.CancellationException
import javax.inject.Provider import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.* import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.* import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.plus
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.domain.MangaDataRepository import org.koitharu.kotatsu.base.domain.MangaDataRepository
import org.koitharu.kotatsu.base.domain.MangaIntent import org.koitharu.kotatsu.base.domain.MangaIntent
@@ -21,7 +33,11 @@ import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.bookmarks.domain.Bookmark import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.prefs.* import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.ReaderMode
import org.koitharu.kotatsu.core.prefs.ScreenshotsPolicy
import org.koitharu.kotatsu.core.prefs.observeAsFlow
import org.koitharu.kotatsu.core.prefs.observeAsLiveData
import org.koitharu.kotatsu.history.domain.HistoryRepository import org.koitharu.kotatsu.history.domain.HistoryRepository
import org.koitharu.kotatsu.history.domain.PROGRESS_NONE import org.koitharu.kotatsu.history.domain.PROGRESS_NONE
import org.koitharu.kotatsu.parsers.exception.NotFoundException import org.koitharu.kotatsu.parsers.exception.NotFoundException
@@ -38,6 +54,9 @@ import org.koitharu.kotatsu.utils.asFlowLiveData
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import org.koitharu.kotatsu.utils.ext.processLifecycleScope import org.koitharu.kotatsu.utils.ext.processLifecycleScope
import org.koitharu.kotatsu.utils.ext.requireValue import org.koitharu.kotatsu.utils.ext.requireValue
import org.koitharu.kotatsu.utils.ext.runCatchingCancellable
import java.util.Date
import javax.inject.Provider
private const val BOUNDS_PAGE_OFFSET = 2 private const val BOUNDS_PAGE_OFFSET = 2
private const val PREFETCH_LIMIT = 10 private const val PREFETCH_LIMIT = 10
@@ -87,6 +106,12 @@ class ReaderViewModel @AssistedInject constructor(
valueProducer = { isReaderBarEnabled }, valueProducer = { isReaderBarEnabled },
) )
val isWebtoonZoomEnabled = settings.observeAsLiveData(
context = viewModelScope.coroutineContext + Dispatchers.Default,
key = AppSettings.KEY_WEBTOON_ZOOM,
valueProducer = { isWebtoonZoomEnable },
)
val readerSettings = ReaderSettings( val readerSettings = ReaderSettings(
parentScope = viewModelScope, parentScope = viewModelScope,
settings = settings, settings = settings,
@@ -115,6 +140,10 @@ class ReaderViewModel @AssistedInject constructor(
init { init {
loadImpl() loadImpl()
settings.observe()
.onEach { key ->
if (key == AppSettings.KEY_READER_SLIDER) notifyStateChanged()
}.launchIn(viewModelScope)
} }
override fun onCleared() { override fun onCleared() {
@@ -326,7 +355,7 @@ class ReaderViewModel @AssistedInject constructor(
?: manga.chapters?.randomOrNull() ?: manga.chapters?.randomOrNull()
?: error("There are no chapters in this manga") ?: error("There are no chapters in this manga")
val pages = repo.getPages(chapter) val pages = repo.getPages(chapter)
return runCatching { return runCatchingCancellable {
val isWebtoon = dataRepository.determineMangaIsWebtoon(repo, pages) val isWebtoon = dataRepository.determineMangaIsWebtoon(repo, pages)
if (isWebtoon) ReaderMode.WEBTOON else defaultMode if (isWebtoon) ReaderMode.WEBTOON else defaultMode
}.onSuccess { }.onSuccess {
@@ -347,6 +376,7 @@ class ReaderViewModel @AssistedInject constructor(
chaptersTotal = chapters.size(), chaptersTotal = chapters.size(),
totalPages = if (chapter != null) chaptersLoader.getPagesCount(chapter.id) else 0, totalPages = if (chapter != null) chaptersLoader.getPagesCount(chapter.id) else 0,
currentPage = state?.page ?: 0, currentPage = state?.page ?: 0,
isSliderEnabled = settings.isReaderSliderEnabled,
) )
uiState.postValue(newState) uiState.postValue(newState)
} }
@@ -381,7 +411,7 @@ class ReaderViewModel @AssistedInject constructor(
*/ */
private fun HistoryRepository.saveStateAsync(manga: Manga, state: ReaderState, percent: Float): Job { private fun HistoryRepository.saveStateAsync(manga: Manga, state: ReaderState, percent: Float): Job {
return processLifecycleScope.launch(Dispatchers.Default) { return processLifecycleScope.launch(Dispatchers.Default) {
runCatching { runCatchingCancellable {
addOrUpdate( addOrUpdate(
manga = manga, manga = manga,
chapterId = state.chapterId, chapterId = state.chapterId,

View File

@@ -103,8 +103,8 @@ class ColorFilterConfigActivity :
} }
private fun onColorFilterChanged(readerColorFilter: ReaderColorFilter?) { private fun onColorFilterChanged(readerColorFilter: ReaderColorFilter?) {
binding.sliderBrightness.value = readerColorFilter?.brightness ?: 0f binding.sliderBrightness.setValueRounded(readerColorFilter?.brightness ?: 0f)
binding.sliderContrast.value = readerColorFilter?.contrast ?: 0f binding.sliderContrast.setValueRounded(readerColorFilter?.contrast ?: 0f)
binding.imageViewAfter.colorFilter = readerColorFilter?.toColorFilter() binding.imageViewAfter.colorFilter = readerColorFilter?.toColorFilter()
} }

View File

@@ -23,6 +23,7 @@ import org.koitharu.kotatsu.reader.ui.ReaderViewModel
import org.koitharu.kotatsu.reader.ui.colorfilter.ColorFilterConfigActivity import org.koitharu.kotatsu.reader.ui.colorfilter.ColorFilterConfigActivity
import org.koitharu.kotatsu.settings.SettingsActivity import org.koitharu.kotatsu.settings.SettingsActivity
import org.koitharu.kotatsu.utils.ScreenOrientationHelper import org.koitharu.kotatsu.utils.ScreenOrientationHelper
import org.koitharu.kotatsu.utils.ext.setValueRounded
import org.koitharu.kotatsu.utils.ext.viewLifecycleScope import org.koitharu.kotatsu.utils.ext.viewLifecycleScope
import org.koitharu.kotatsu.utils.ext.withArgs import org.koitharu.kotatsu.utils.ext.withArgs
@@ -65,7 +66,7 @@ class ReaderConfigBottomSheet :
binding.sliderTimer.setLabelFormatter(PageSwitchTimer.DelayLabelFormatter(view.resources)) binding.sliderTimer.setLabelFormatter(PageSwitchTimer.DelayLabelFormatter(view.resources))
findCallback()?.run { findCallback()?.run {
binding.sliderTimer.value = pageSwitchDelay binding.sliderTimer.setValueRounded(pageSwitchDelay)
} }
} }
@@ -75,13 +76,16 @@ class ReaderConfigBottomSheet :
startActivity(SettingsActivity.newReaderSettingsIntent(v.context)) startActivity(SettingsActivity.newReaderSettingsIntent(v.context))
dismissAllowingStateLoss() dismissAllowingStateLoss()
} }
R.id.button_save_page -> { R.id.button_save_page -> {
val page = viewModel.getCurrentPage() ?: return val page = viewModel.getCurrentPage() ?: return
viewModel.saveCurrentPage(page, savePageRequest) viewModel.saveCurrentPage(page, savePageRequest)
} }
R.id.button_screen_rotate -> { R.id.button_screen_rotate -> {
orientationHelper?.toggleOrientation() orientationHelper?.toggleOrientation()
} }
R.id.button_color_filter -> { R.id.button_color_filter -> {
val page = viewModel.getCurrentPage() ?: return val page = viewModel.getCurrentPage() ?: return
val manga = viewModel.manga ?: return val manga = viewModel.manga ?: return

View File

@@ -2,9 +2,13 @@ package org.koitharu.kotatsu.reader.ui.config
import android.content.SharedPreferences import android.content.SharedPreferences
import androidx.lifecycle.MediatorLiveData import androidx.lifecycle.MediatorLiveData
import kotlinx.coroutines.* import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.FlowCollector import kotlinx.coroutines.flow.FlowCollector
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.core.model.ZoomMode import org.koitharu.kotatsu.core.model.ZoomMode
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.reader.domain.ReaderColorFilter import org.koitharu.kotatsu.reader.domain.ReaderColorFilter
@@ -60,7 +64,7 @@ class ReaderSettings(
} }
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) { override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) {
if (key == AppSettings.KEY_ZOOM_MODE || key == AppSettings.KEY_PAGES_NUMBERS) { if (key == AppSettings.KEY_ZOOM_MODE || key == AppSettings.KEY_PAGES_NUMBERS || key == AppSettings.KEY_WEBTOON_ZOOM) {
notifyChanged() notifyChanged()
} }
} }

View File

@@ -4,17 +4,23 @@ import android.net.Uri
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.lifecycle.Observer import androidx.lifecycle.Observer
import com.davemorrissey.labs.subscaleview.DefaultOnImageEventListener import com.davemorrissey.labs.subscaleview.DefaultOnImageEventListener
import java.io.File import kotlinx.coroutines.CancellationException
import java.io.IOException import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.* import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.plus
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.reader.domain.PageLoader import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.reader.ui.config.ReaderSettings import org.koitharu.kotatsu.reader.ui.config.ReaderSettings
import java.io.File
import java.io.IOException
class PageHolderDelegate( class PageHolderDelegate(
private val loader: PageLoader, private val loader: PageLoader,
@@ -102,6 +108,8 @@ class PageHolderDelegate(
loader.convertInPlace(file) loader.convertInPlace(file)
state = State.CONVERTED state = State.CONVERTED
callback.onImageReady(file.toUri()) callback.onImageReady(file.toUri())
} catch (ce: CancellationException) {
throw ce
} catch (e2: Throwable) { } catch (e2: Throwable) {
e.addSuppressed(e2) e.addSuppressed(e2)
state = State.ERROR state = State.ERROR
@@ -124,7 +132,7 @@ class PageHolderDelegate(
callback.onImageReady(file.toUri()) callback.onImageReady(file.toUri())
} catch (e: CancellationException) { } catch (e: CancellationException) {
throw e throw e
} catch (e: Exception) { } catch (e: Throwable) {
state = State.ERROR state = State.ERROR
error = e error = e
callback.onError(e) callback.onError(e)

View File

@@ -7,8 +7,13 @@ data class ReaderUiState(
val chaptersTotal: Int, val chaptersTotal: Int,
val currentPage: Int, val currentPage: Int,
val totalPages: Int, val totalPages: Int,
private val isSliderEnabled: Boolean,
) { ) {
fun isSliderAvailable(): Boolean {
return isSliderEnabled && totalPages > 1 && currentPage < totalPages
}
fun computePercent(): Float { fun computePercent(): Float {
val ppc = 1f / chaptersTotal val ppc = 1f / chaptersTotal
val chapterIndex = chapterNumber - 1 val chapterIndex = chapterNumber - 1

View File

@@ -35,6 +35,10 @@ class WebtoonReaderFragment : BaseReader<FragmentReaderWebtoonBinding>() {
adapter = webtoonAdapter adapter = webtoonAdapter
addOnPageScrollListener(PageScrollListener()) addOnPageScrollListener(PageScrollListener())
} }
viewModel.isWebtoonZoomEnabled.observe(viewLifecycleOwner) {
binding.frame.isZoomEnable = it
}
} }
override fun onDestroyView() { override fun onDestroyView() {

View File

@@ -0,0 +1,213 @@
package org.koitharu.kotatsu.reader.ui.pager.webtoon
import android.animation.ObjectAnimator
import android.content.Context
import android.graphics.Matrix
import android.graphics.Rect
import android.graphics.RectF
import android.util.AttributeSet
import android.view.GestureDetector
import android.view.MotionEvent
import android.view.ScaleGestureDetector
import android.view.animation.AccelerateDecelerateInterpolator
import android.widget.FrameLayout
import android.widget.OverScroller
import androidx.core.view.GestureDetectorCompat
private const val MAX_SCALE = 2.5f
private const val MIN_SCALE = 1f // under-scaling disabled due to buggy nested scroll
class WebtoonScalingFrame @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyles: Int = 0,
) : FrameLayout(context, attrs, defStyles), ScaleGestureDetector.OnScaleGestureListener {
private val targetChild by lazy(LazyThreadSafetyMode.NONE) { getChildAt(0) }
private val scaleDetector = ScaleGestureDetector(context, this)
private val gestureDetector = GestureDetectorCompat(context, GestureListener())
private val overScroller = OverScroller(context, AccelerateDecelerateInterpolator())
private val transformMatrix = Matrix()
private val matrixValues = FloatArray(9)
private val scale
get() = matrixValues[Matrix.MSCALE_X]
private val transX
get() = halfWidth * (scale - 1f) + matrixValues[Matrix.MTRANS_X]
private val transY
get() = halfHeight * (scale - 1f) + matrixValues[Matrix.MTRANS_Y]
private var halfWidth = 0f
private var halfHeight = 0f
private val translateBounds = RectF()
private val targetHitRect = Rect()
private var pendingScroll = 0
var isZoomEnable = true
set(value) {
field = value
if (scale != 1f) {
scaleChild(1f, halfWidth, halfHeight)
}
}
init {
syncMatrixValues()
}
override fun dispatchTouchEvent(ev: MotionEvent?): Boolean {
if (!isZoomEnable || ev == null) {
return super.dispatchTouchEvent(ev)
}
if (ev.action == MotionEvent.ACTION_DOWN && overScroller.computeScrollOffset()) {
overScroller.forceFinished(true)
}
gestureDetector.onTouchEvent(ev)
scaleDetector.onTouchEvent(ev)
// Offset event to inside the child view
if (scale < 1 && !targetHitRect.contains(ev.x.toInt(), ev.y.toInt())) {
ev.offsetLocation(halfWidth - ev.x + targetHitRect.width() / 3, 0f)
}
// Send action cancel to avoid recycler jump when scale end
if (scaleDetector.isInProgress) {
ev.action = MotionEvent.ACTION_CANCEL
}
return super.dispatchTouchEvent(ev)
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
halfWidth = measuredWidth / 2f
halfHeight = measuredHeight / 2f
}
private fun invalidateTarget() {
adjustBounds()
targetChild.run {
scaleX = scale
scaleY = scale
translationX = transX
translationY = transY
}
val newHeight = if (scale < 1f) (height / scale).toInt() else height
if (newHeight != targetChild.height) {
targetChild.layoutParams.height = newHeight
targetChild.requestLayout()
}
if (scale < 1) {
targetChild.getHitRect(targetHitRect)
targetChild.scrollBy(0, pendingScroll)
pendingScroll = 0
}
}
private fun syncMatrixValues() {
transformMatrix.getValues(matrixValues)
}
private fun adjustBounds() {
syncMatrixValues()
val dx = when {
transX < translateBounds.left -> translateBounds.left - transX
transX > translateBounds.right -> translateBounds.right - transX
else -> 0f
}
val dy = when {
transY < translateBounds.top -> translateBounds.top - transY
transY > translateBounds.bottom -> translateBounds.bottom - transY
else -> 0f
}
pendingScroll = dy.toInt()
transformMatrix.postTranslate(dx, dy)
syncMatrixValues()
}
private fun scaleChild(newScale: Float, focusX: Float, focusY: Float) {
val factor = newScale / scale
if (newScale > 1) {
translateBounds.set(
halfWidth * (1 - newScale),
halfHeight * (1 - newScale),
halfWidth * (newScale - 1),
halfHeight * (newScale - 1),
)
} else {
translateBounds.set(
0f,
halfHeight - halfHeight / newScale,
0f,
halfHeight - halfHeight / newScale,
)
}
transformMatrix.postScale(factor, factor, focusX, focusY)
invalidateTarget()
}
override fun onScale(detector: ScaleGestureDetector): Boolean {
val newScale = (scale * detector.scaleFactor).coerceIn(MIN_SCALE, MAX_SCALE)
scaleChild(newScale, detector.focusX, detector.focusY)
return true
}
override fun onScaleBegin(detector: ScaleGestureDetector): Boolean = true
override fun onScaleEnd(p0: ScaleGestureDetector) {
pendingScroll = 0
}
private inner class GestureListener : GestureDetector.SimpleOnGestureListener(), Runnable {
override fun onScroll(e1: MotionEvent, e2: MotionEvent, distanceX: Float, distanceY: Float): Boolean {
if (scale <= 1f) return false
transformMatrix.postTranslate(-distanceX, -distanceY)
invalidateTarget()
return true
}
override fun onDoubleTap(e: MotionEvent): Boolean {
val newScale = if (scale != 1f) 1f else MAX_SCALE * 0.8f
ObjectAnimator.ofFloat(scale, newScale).run {
interpolator = AccelerateDecelerateInterpolator()
duration = 300
addUpdateListener {
scaleChild(it.animatedValue as Float, e.x, e.y)
}
start()
}
return true
}
override fun onFling(e1: MotionEvent, e2: MotionEvent, velocityX: Float, velocityY: Float): Boolean {
if (scale <= 1) return false
overScroller.fling(
transX.toInt(),
transY.toInt(),
velocityX.toInt(),
velocityY.toInt(),
translateBounds.left.toInt(),
translateBounds.right.toInt(),
translateBounds.top.toInt(),
translateBounds.bottom.toInt(),
)
postOnAnimation(this)
return true
}
override fun run() {
if (overScroller.computeScrollOffset()) {
transformMatrix.postTranslate(overScroller.currX - transX, overScroller.currY - transY)
invalidateTarget()
postOnAnimation(this)
}
}
}
}

View File

@@ -17,6 +17,7 @@ import org.koitharu.kotatsu.reader.ui.thumbnails.PageThumbnail
import org.koitharu.kotatsu.utils.ext.decodeRegion import org.koitharu.kotatsu.utils.ext.decodeRegion
import org.koitharu.kotatsu.utils.ext.isLowRamDevice import org.koitharu.kotatsu.utils.ext.isLowRamDevice
import org.koitharu.kotatsu.utils.ext.referer import org.koitharu.kotatsu.utils.ext.referer
import org.koitharu.kotatsu.utils.ext.runCatchingCancellable
import org.koitharu.kotatsu.utils.ext.setTextColorAttr import org.koitharu.kotatsu.utils.ext.setTextColorAttr
fun pageThumbnailAD( fun pageThumbnailAD(
@@ -72,7 +73,7 @@ fun pageThumbnailAD(
text = (item.number).toString() text = (item.number).toString()
} }
job = scope.launch { job = scope.launch {
val drawable = runCatching { val drawable = runCatchingCancellable {
loadPageThumbnail(item) loadPageThumbnail(item)
}.getOrNull() }.getOrNull()
binding.imageViewThumb.setImageDrawable(drawable) binding.imageViewThumb.setImageDrawable(drawable)

View File

@@ -5,11 +5,16 @@ import androidx.lifecycle.viewModelScope
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import java.util.* import kotlinx.coroutines.CancellationException
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.flow.* import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.domain.MangaDataRepository import org.koitharu.kotatsu.base.domain.MangaDataRepository
import org.koitharu.kotatsu.base.ui.widgets.ChipsView import org.koitharu.kotatsu.base.ui.widgets.ChipsView
@@ -21,13 +26,20 @@ import org.koitharu.kotatsu.list.ui.filter.FilterCoordinator
import org.koitharu.kotatsu.list.ui.filter.FilterItem import org.koitharu.kotatsu.list.ui.filter.FilterItem
import org.koitharu.kotatsu.list.ui.filter.FilterState import org.koitharu.kotatsu.list.ui.filter.FilterState
import org.koitharu.kotatsu.list.ui.filter.OnFilterChangedListener import org.koitharu.kotatsu.list.ui.filter.OnFilterChangedListener
import org.koitharu.kotatsu.list.ui.model.* import org.koitharu.kotatsu.list.ui.model.EmptyState
import org.koitharu.kotatsu.list.ui.model.ListHeader2
import org.koitharu.kotatsu.list.ui.model.LoadingFooter
import org.koitharu.kotatsu.list.ui.model.LoadingState
import org.koitharu.kotatsu.list.ui.model.toErrorFooter
import org.koitharu.kotatsu.list.ui.model.toErrorState
import org.koitharu.kotatsu.list.ui.model.toUi
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
import org.koitharu.kotatsu.search.domain.MangaSearchRepository import org.koitharu.kotatsu.search.domain.MangaSearchRepository
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import java.util.LinkedList
private const val FILTER_MIN_INTERVAL = 250L private const val FILTER_MIN_INTERVAL = 250L
@@ -138,6 +150,8 @@ class RemoteListViewModel @AssistedInject constructor(
mangaList.value = mangaList.value?.plus(list) ?: list mangaList.value = mangaList.value?.plus(list) ?: list
} }
hasNextPage.value = list.isNotEmpty() hasNextPage.value = list.isNotEmpty()
} catch (e: CancellationException) {
throw e
} catch (e: Throwable) { } catch (e: Throwable) {
e.printStackTraceDebug() e.printStackTraceDebug()
listError.value = e listError.value = e
@@ -149,7 +163,7 @@ class RemoteListViewModel @AssistedInject constructor(
} }
private fun createEmptyState(canResetFilter: Boolean) = EmptyState( private fun createEmptyState(canResetFilter: Boolean) = EmptyState(
icon = R.drawable.ic_empty_search, icon = R.drawable.ic_empty_common,
textPrimary = R.string.nothing_found, textPrimary = R.string.nothing_found,
textSecondary = 0, textSecondary = 0,
actionStringRes = if (canResetFilter) R.string.reset_filter else 0, actionStringRes = if (canResetFilter) R.string.reset_filter else 0,

View File

@@ -3,15 +3,20 @@ package org.koitharu.kotatsu.scrobbling.domain
import androidx.collection.LongSparseArray import androidx.collection.LongSparseArray
import androidx.collection.getOrElse import androidx.collection.getOrElse
import androidx.core.text.parseAsHtml import androidx.core.text.parseAsHtml
import java.util.*
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.scrobbling.data.ScrobblingEntity import org.koitharu.kotatsu.scrobbling.data.ScrobblingEntity
import org.koitharu.kotatsu.scrobbling.domain.model.* import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerManga
import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerMangaInfo
import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerService
import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblingInfo
import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblingStatus
import org.koitharu.kotatsu.utils.ext.findKeyByValue import org.koitharu.kotatsu.utils.ext.findKeyByValue
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import org.koitharu.kotatsu.utils.ext.runCatchingCancellable
import java.util.EnumMap
abstract class Scrobbler( abstract class Scrobbler(
protected val db: MangaDatabase, protected val db: MangaDatabase,
@@ -47,7 +52,7 @@ abstract class Scrobbler(
private suspend fun ScrobblingEntity.toScrobblingInfo(mangaId: Long): ScrobblingInfo? { private suspend fun ScrobblingEntity.toScrobblingInfo(mangaId: Long): ScrobblingInfo? {
val mangaInfo = infoCache.getOrElse(targetId) { val mangaInfo = infoCache.getOrElse(targetId) {
runCatching { runCatchingCancellable {
getMangaInfo(targetId) getMangaInfo(targetId)
}.onFailure { }.onFailure {
it.printStackTraceDebug() it.printStackTraceDebug()
@@ -72,9 +77,9 @@ abstract class Scrobbler(
} }
suspend fun Scrobbler.tryScrobble(mangaId: Long, chapter: MangaChapter): Boolean { suspend fun Scrobbler.tryScrobble(mangaId: Long, chapter: MangaChapter): Boolean {
return runCatching { return runCatchingCancellable {
scrobble(mangaId, chapter) scrobble(mangaId, chapter)
}.onFailure { }.onFailure {
it.printStackTraceDebug() it.printStackTraceDebug()
}.isSuccess }.isSuccess
} }

View File

@@ -21,6 +21,7 @@ import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.util.levenshteinDistance import org.koitharu.kotatsu.parsers.util.levenshteinDistance
import org.koitharu.kotatsu.search.ui.MangaSuggestionsProvider import org.koitharu.kotatsu.search.ui.MangaSuggestionsProvider
import org.koitharu.kotatsu.utils.ext.runCatchingCancellable
class MangaSearchRepository @Inject constructor( class MangaSearchRepository @Inject constructor(
private val settings: AppSettings, private val settings: AppSettings,
@@ -33,7 +34,7 @@ class MangaSearchRepository @Inject constructor(
fun globalSearch(query: String, concurrency: Int = DEFAULT_CONCURRENCY): Flow<Manga> = fun globalSearch(query: String, concurrency: Int = DEFAULT_CONCURRENCY): Flow<Manga> =
settings.getMangaSources(includeHidden = false).asFlow() settings.getMangaSources(includeHidden = false).asFlow()
.flatMapMerge(concurrency) { source -> .flatMapMerge(concurrency) { source ->
runCatching { runCatchingCancellable {
mangaRepositoryFactory.create(source).getList( mangaRepositoryFactory.create(source).getList(
offset = 0, offset = 0,
query = query, query = query,

View File

@@ -18,6 +18,7 @@ import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.remotelist.ui.RemoteListFragment import org.koitharu.kotatsu.remotelist.ui.RemoteListFragment
import org.koitharu.kotatsu.utils.ext.getParcelableExtraCompat import org.koitharu.kotatsu.utils.ext.getParcelableExtraCompat
import kotlin.text.Typography.dagger
@AndroidEntryPoint @AndroidEntryPoint
class MangaListActivity : class MangaListActivity :
@@ -41,6 +42,7 @@ class MangaListActivity :
val fm = supportFragmentManager val fm = supportFragmentManager
if (fm.findFragmentById(R.id.container) == null) { if (fm.findFragmentById(R.id.container) == null) {
fm.commit { fm.commit {
setReorderingAllowed(true)
val fragment = if (source == MangaSource.LOCAL) { val fragment = if (source == MangaSource.LOCAL) {
LocalListFragment.newInstance() LocalListFragment.newInstance()
} else { } else {

View File

@@ -15,6 +15,7 @@ import org.koitharu.kotatsu.databinding.ActivitySearchBinding
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionViewModel import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionViewModel
import org.koitharu.kotatsu.utils.ext.showKeyboard import org.koitharu.kotatsu.utils.ext.showKeyboard
import kotlin.text.Typography.dagger
@AndroidEntryPoint @AndroidEntryPoint
class SearchActivity : BaseActivity<ActivitySearchBinding>(), SearchView.OnQueryTextListener { class SearchActivity : BaseActivity<ActivitySearchBinding>(), SearchView.OnQueryTextListener {
@@ -61,6 +62,7 @@ class SearchActivity : BaseActivity<ActivitySearchBinding>(), SearchView.OnQuery
} }
title = query title = query
supportFragmentManager.commit { supportFragmentManager.commit {
setReorderingAllowed(true)
replace(R.id.container, SearchFragment.newInstance(source, q)) replace(R.id.container, SearchFragment.newInstance(source, q))
} }
binding.searchView.clearFocus() binding.searchView.clearFocus()

View File

@@ -4,6 +4,7 @@ import androidx.lifecycle.viewModelScope
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -12,7 +13,13 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.list.ui.MangaListViewModel import org.koitharu.kotatsu.list.ui.MangaListViewModel
import org.koitharu.kotatsu.list.ui.model.* import org.koitharu.kotatsu.list.ui.model.EmptyState
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.LoadingFooter
import org.koitharu.kotatsu.list.ui.model.LoadingState
import org.koitharu.kotatsu.list.ui.model.toErrorFooter
import org.koitharu.kotatsu.list.ui.model.toErrorState
import org.koitharu.kotatsu.list.ui.model.toUi
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.utils.ext.asLiveDataDistinct import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
@@ -41,12 +48,13 @@ class SearchViewModel @AssistedInject constructor(
list == null -> listOf(LoadingState) list == null -> listOf(LoadingState)
list.isEmpty() -> listOf( list.isEmpty() -> listOf(
EmptyState( EmptyState(
icon = R.drawable.ic_empty_search, icon = R.drawable.ic_empty_common,
textPrimary = R.string.nothing_found, textPrimary = R.string.nothing_found,
textSecondary = R.string.text_search_holder_secondary, textSecondary = R.string.text_search_holder_secondary,
actionStringRes = 0, actionStringRes = 0,
), ),
) )
else -> { else -> {
val result = ArrayList<ListModel>(list.size + 1) val result = ArrayList<ListModel>(list.size + 1)
list.toUi(result, mode) list.toUi(result, mode)
@@ -94,6 +102,8 @@ class SearchViewModel @AssistedInject constructor(
mangaList.value = mangaList.value?.plus(list) ?: list mangaList.value = mangaList.value?.plus(list) ?: list
} }
hasNextPage.value = list.isNotEmpty() hasNextPage.value = list.isNotEmpty()
} catch (e: CancellationException) {
throw e
} catch (e: Throwable) { } catch (e: Throwable) {
listError.value = e listError.value = e
} }

View File

@@ -20,6 +20,7 @@ import org.koitharu.kotatsu.list.ui.model.*
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import org.koitharu.kotatsu.utils.ext.runCatchingCancellable
private const val MAX_PARALLELISM = 4 private const val MAX_PARALLELISM = 4
private const val MIN_HAS_MORE_ITEMS = 8 private const val MIN_HAS_MORE_ITEMS = 8
@@ -47,13 +48,14 @@ class MultiSearchViewModel @AssistedInject constructor(
loading -> LoadingState loading -> LoadingState
error != null -> error.toErrorState(canRetry = true) error != null -> error.toErrorState(canRetry = true)
else -> EmptyState( else -> EmptyState(
icon = R.drawable.ic_empty_search, icon = R.drawable.ic_empty_common,
textPrimary = R.string.nothing_found, textPrimary = R.string.nothing_found,
textSecondary = R.string.text_search_holder_secondary, textSecondary = R.string.text_search_holder_secondary,
actionStringRes = 0, actionStringRes = 0,
) )
}, },
) )
loading -> list + LoadingFooter loading -> list + LoadingFooter
else -> list else -> list
} }
@@ -85,6 +87,8 @@ class MultiSearchViewModel @AssistedInject constructor(
loadingData.value = true loadingData.value = true
query.postValue(q) query.postValue(q)
searchImpl(q) searchImpl(q)
} catch (e: CancellationException) {
throw e
} catch (e: Throwable) { } catch (e: Throwable) {
listError.value = e listError.value = e
} finally { } finally {
@@ -98,7 +102,7 @@ class MultiSearchViewModel @AssistedInject constructor(
val dispatcher = Dispatchers.Default.limitedParallelism(MAX_PARALLELISM) val dispatcher = Dispatchers.Default.limitedParallelism(MAX_PARALLELISM)
val deferredList = sources.map { source -> val deferredList = sources.map { source ->
async(dispatcher) { async(dispatcher) {
runCatching { runCatchingCancellable {
val list = mangaRepositoryFactory.create(source).getList(offset = 0, query = q) val list = mangaRepositoryFactory.create(source).getList(offset = 0, query = q)
.toUi(ListMode.GRID) .toUi(ListMode.GRID)
if (list.isNotEmpty()) { if (list.isNotEmpty()) {

View File

@@ -8,7 +8,7 @@ import androidx.preference.Preference
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BasePreferenceFragment import org.koitharu.kotatsu.base.ui.BasePreferenceFragment
@@ -23,6 +23,7 @@ import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.utils.FileSize import org.koitharu.kotatsu.utils.FileSize
import org.koitharu.kotatsu.utils.ext.getDisplayMessage import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.viewLifecycleScope import org.koitharu.kotatsu.utils.ext.viewLifecycleScope
import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class HistorySettingsFragment : BasePreferenceFragment(R.string.history_and_cache) { class HistorySettingsFragment : BasePreferenceFragment(R.string.history_and_cache) {
@@ -82,18 +83,22 @@ class HistorySettingsFragment : BasePreferenceFragment(R.string.history_and_cach
clearCache(preference, CacheDir.PAGES) clearCache(preference, CacheDir.PAGES)
true true
} }
AppSettings.KEY_THUMBS_CACHE_CLEAR -> { AppSettings.KEY_THUMBS_CACHE_CLEAR -> {
clearCache(preference, CacheDir.THUMBS) clearCache(preference, CacheDir.THUMBS)
true true
} }
AppSettings.KEY_COOKIES_CLEAR -> { AppSettings.KEY_COOKIES_CLEAR -> {
clearCookies() clearCookies()
true true
} }
AppSettings.KEY_SEARCH_HISTORY_CLEAR -> { AppSettings.KEY_SEARCH_HISTORY_CLEAR -> {
clearSearchHistory(preference) clearSearchHistory(preference)
true true
} }
AppSettings.KEY_UPDATES_FEED_CLEAR -> { AppSettings.KEY_UPDATES_FEED_CLEAR -> {
viewLifecycleScope.launch { viewLifecycleScope.launch {
trackerRepo.clearLogs() trackerRepo.clearLogs()
@@ -107,6 +112,7 @@ class HistorySettingsFragment : BasePreferenceFragment(R.string.history_and_cach
} }
true true
} }
AppSettings.KEY_SHIKIMORI -> { AppSettings.KEY_SHIKIMORI -> {
if (!shikimoriRepository.isAuthorized) { if (!shikimoriRepository.isAuthorized) {
launchShikimoriAuth() launchShikimoriAuth()
@@ -115,6 +121,7 @@ class HistorySettingsFragment : BasePreferenceFragment(R.string.history_and_cach
super.onPreferenceTreeClick(preference) super.onPreferenceTreeClick(preference)
} }
} }
else -> super.onPreferenceTreeClick(preference) else -> super.onPreferenceTreeClick(preference)
} }
} }
@@ -127,6 +134,8 @@ class HistorySettingsFragment : BasePreferenceFragment(R.string.history_and_cach
storageManager.clearCache(cache) storageManager.clearCache(cache)
val size = storageManager.computeCacheSize(cache) val size = storageManager.computeCacheSize(cache)
preference.summary = FileSize.BYTES.format(ctx, size) preference.summary = FileSize.BYTES.format(ctx, size)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) { } catch (e: Exception) {
preference.summary = e.getDisplayMessage(ctx.resources) preference.summary = e.getDisplayMessage(ctx.resources)
} finally { } finally {

View File

@@ -7,7 +7,6 @@ import androidx.lifecycle.lifecycleScope
import androidx.preference.Preference import androidx.preference.Preference
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ensureActive import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -20,7 +19,14 @@ import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.parsers.exception.AuthRequiredException import org.koitharu.kotatsu.parsers.exception.AuthRequiredException
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.settings.sources.auth.SourceAuthActivity import org.koitharu.kotatsu.settings.sources.auth.SourceAuthActivity
import org.koitharu.kotatsu.utils.ext.* import org.koitharu.kotatsu.utils.ext.awaitViewLifecycle
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import org.koitharu.kotatsu.utils.ext.runCatchingCancellable
import org.koitharu.kotatsu.utils.ext.serializableArgument
import org.koitharu.kotatsu.utils.ext.viewLifecycleScope
import org.koitharu.kotatsu.utils.ext.withArgs
import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class SourceSettingsFragment : BasePreferenceFragment(0) { class SourceSettingsFragment : BasePreferenceFragment(0) {
@@ -66,12 +72,13 @@ class SourceSettingsFragment : BasePreferenceFragment(0) {
startActivity(SourceAuthActivity.newIntent(preference.context, source)) startActivity(SourceAuthActivity.newIntent(preference.context, source))
true true
} }
else -> super.onPreferenceTreeClick(preference) else -> super.onPreferenceTreeClick(preference)
} }
} }
private fun loadUsername(owner: LifecycleOwner, preference: Preference) = owner.lifecycleScope.launch { private fun loadUsername(owner: LifecycleOwner, preference: Preference) = owner.lifecycleScope.launch {
runCatching { runCatchingCancellable {
preference.summary = null preference.summary = null
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
requireNotNull(repository?.getAuthProvider()?.getUsername()) requireNotNull(repository?.getAuthProvider()?.getUsername())
@@ -91,6 +98,7 @@ class SourceSettingsFragment : BasePreferenceFragment(0) {
).setAction(ExceptionResolver.getResolveStringId(error)) { resolveError(error) } ).setAction(ExceptionResolver.getResolveStringId(error)) { resolveError(error) }
.show() .show()
} }
else -> preference.summary = error.getDisplayMessage(preference.context.resources) else -> preference.summary = error.getDisplayMessage(preference.context.resources)
} }
error.printStackTraceDebug() error.printStackTraceDebug()

View File

@@ -11,13 +11,13 @@ import androidx.core.view.isVisible
import androidx.fragment.app.viewModels import androidx.fragment.app.viewModels
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import java.io.File
import java.io.FileOutputStream
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.AlertDialogFragment import org.koitharu.kotatsu.base.ui.AlertDialogFragment
import org.koitharu.kotatsu.databinding.DialogProgressBinding import org.koitharu.kotatsu.databinding.DialogProgressBinding
import org.koitharu.kotatsu.utils.ext.getDisplayMessage import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.progress.Progress import org.koitharu.kotatsu.utils.progress.Progress
import java.io.File
import java.io.FileOutputStream
@AndroidEntryPoint @AndroidEntryPoint
class BackupDialogFragment : AlertDialogFragment<DialogProgressBinding>() { class BackupDialogFragment : AlertDialogFragment<DialogProgressBinding>() {
@@ -91,6 +91,8 @@ class BackupDialogFragment : AlertDialogFragment<DialogProgressBinding>() {
} }
Toast.makeText(requireContext(), R.string.backup_saved, Toast.LENGTH_LONG).show() Toast.makeText(requireContext(), R.string.backup_saved, Toast.LENGTH_LONG).show()
dismiss() dismiss()
} catch (e: InterruptedException) {
throw e
} catch (e: Exception) { } catch (e: Exception) {
onError(e) onError(e)
} }

View File

@@ -11,8 +11,8 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.AlertDialogFragment import org.koitharu.kotatsu.base.ui.AlertDialogFragment
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.databinding.DialogOnboardBinding import org.koitharu.kotatsu.databinding.DialogOnboardBinding
import org.koitharu.kotatsu.settings.onboard.adapter.SourceLocaleListener
import org.koitharu.kotatsu.settings.onboard.adapter.SourceLocalesAdapter import org.koitharu.kotatsu.settings.onboard.adapter.SourceLocalesAdapter
import org.koitharu.kotatsu.settings.onboard.model.SourceLocale import org.koitharu.kotatsu.settings.onboard.model.SourceLocale
import org.koitharu.kotatsu.utils.ext.showAllowStateLoss import org.koitharu.kotatsu.utils.ext.showAllowStateLoss
@@ -21,8 +21,7 @@ import org.koitharu.kotatsu.utils.ext.withArgs
@AndroidEntryPoint @AndroidEntryPoint
class OnboardDialogFragment : class OnboardDialogFragment :
AlertDialogFragment<DialogOnboardBinding>(), AlertDialogFragment<DialogOnboardBinding>(),
OnListItemClickListener<SourceLocale>, DialogInterface.OnClickListener, SourceLocaleListener {
DialogInterface.OnClickListener {
private val viewModel by viewModels<OnboardViewModel>() private val viewModel by viewModels<OnboardViewModel>()
private var isWelcome: Boolean = false private var isWelcome: Boolean = false
@@ -63,8 +62,8 @@ class OnboardDialogFragment :
} }
} }
override fun onItemClick(item: SourceLocale, view: View) { override fun onItemCheckedChanged(item: SourceLocale, isChecked: Boolean) {
viewModel.setItemChecked(item.key, !item.isChecked) viewModel.setItemChecked(item.key, isChecked)
} }
override fun onClick(dialog: DialogInterface?, which: Int) { override fun onClick(dialog: DialogInterface?, which: Int) {

View File

@@ -1,11 +1,8 @@
package org.koitharu.kotatsu.settings.onboard package org.koitharu.kotatsu.settings.onboard
import androidx.collection.ArraySet
import androidx.core.os.LocaleListCompat import androidx.core.os.LocaleListCompat
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import java.util.*
import javax.inject.Inject
import org.koitharu.kotatsu.base.ui.BaseViewModel import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
@@ -15,6 +12,8 @@ import org.koitharu.kotatsu.parsers.util.toTitleCase
import org.koitharu.kotatsu.settings.onboard.model.SourceLocale import org.koitharu.kotatsu.settings.onboard.model.SourceLocale
import org.koitharu.kotatsu.utils.ext.map import org.koitharu.kotatsu.utils.ext.map
import org.koitharu.kotatsu.utils.ext.mapToSet import org.koitharu.kotatsu.utils.ext.mapToSet
import java.util.Locale
import javax.inject.Inject
@HiltViewModel @HiltViewModel
class OnboardViewModel @Inject constructor( class OnboardViewModel @Inject constructor(
@@ -23,9 +22,9 @@ class OnboardViewModel @Inject constructor(
private val allSources = settings.remoteMangaSources private val allSources = settings.remoteMangaSources
private val locales = allSources.mapTo(ArraySet()) { it.locale } private val locales = allSources.groupBy { it.locale }
private val selectedLocales = locales.toMutableSet() private val selectedLocales = locales.keys.toMutableSet()
val list = MutableLiveData<List<SourceLocale>?>() val list = MutableLiveData<List<SourceLocale>?>()
@@ -64,13 +63,14 @@ class OnboardViewModel @Inject constructor(
} }
private fun rebuildList() { private fun rebuildList() {
list.value = locales.map { key -> list.value = locales.map { (key, srcs) ->
val locale = if (key != null) { val locale = if (key != null) {
Locale(key) Locale(key)
} else null } else null
SourceLocale( SourceLocale(
key = key, key = key,
title = locale?.getDisplayLanguage(locale)?.toTitleCase(locale), title = locale?.getDisplayLanguage(locale)?.toTitleCase(locale),
summary = srcs.joinToString { it.title },
isChecked = key in selectedLocales, isChecked = key in selectedLocales,
) )
}.sortedWith(SourceLocaleComparator()) }.sortedWith(SourceLocaleComparator())
@@ -87,11 +87,12 @@ class OnboardViewModel @Inject constructor(
a?.key == null -> 1 a?.key == null -> 1
b?.key == null -> -1 b?.key == null -> -1
else -> { else -> {
val index = deviceLocales.indexOf(a.key) val indexA = deviceLocales.indexOf(a.key)
if (index == -1) { val indexB = deviceLocales.indexOf(b.key)
if (indexA == -1 && indexB == -1) {
compareValues(a.title, b.title) compareValues(a.title, b.title)
} else { } else {
-2 - index -2 - (indexA - indexB)
} }
} }
} }

View File

@@ -2,22 +2,23 @@ package org.koitharu.kotatsu.settings.onboard.adapter
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.databinding.ItemSourceLocaleBinding import org.koitharu.kotatsu.databinding.ItemSourceLocaleBinding
import org.koitharu.kotatsu.settings.onboard.model.SourceLocale import org.koitharu.kotatsu.settings.onboard.model.SourceLocale
import org.koitharu.kotatsu.utils.ext.textAndVisible
fun sourceLocaleAD( fun sourceLocaleAD(
clickListener: OnListItemClickListener<SourceLocale> listener: SourceLocaleListener,
) = adapterDelegateViewBinding<SourceLocale, SourceLocale, ItemSourceLocaleBinding>( ) = adapterDelegateViewBinding<SourceLocale, SourceLocale, ItemSourceLocaleBinding>(
{ inflater, parent -> ItemSourceLocaleBinding.inflate(inflater, parent, false) } { inflater, parent -> ItemSourceLocaleBinding.inflate(inflater, parent, false) },
) { ) {
binding.root.setOnClickListener { binding.switchToggle.setOnCheckedChangeListener { _, isChecked ->
clickListener.onItemClick(item, it) listener.onItemCheckedChanged(item, isChecked)
} }
bind { bind {
binding.root.text = item.title ?: getString(R.string.other) binding.textViewTitle.text = item.title ?: getString(R.string.different_languages)
binding.root.isChecked = item.isChecked binding.textViewDescription.textAndVisible = item.summary
binding.switchToggle.isChecked = item.isChecked
} }
} }

View File

@@ -0,0 +1,8 @@
package org.koitharu.kotatsu.settings.onboard.adapter
import org.koitharu.kotatsu.settings.onboard.model.SourceLocale
interface SourceLocaleListener {
fun onItemCheckedChanged(item: SourceLocale, isChecked: Boolean)
}

View File

@@ -2,15 +2,14 @@ package org.koitharu.kotatsu.settings.onboard.adapter
import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.DiffUtil
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.settings.onboard.model.SourceLocale import org.koitharu.kotatsu.settings.onboard.model.SourceLocale
class SourceLocalesAdapter( class SourceLocalesAdapter(
clickListener: OnListItemClickListener<SourceLocale>, listener: SourceLocaleListener,
) : AsyncListDifferDelegationAdapter<SourceLocale>(DiffCallback()) { ) : AsyncListDifferDelegationAdapter<SourceLocale>(DiffCallback()) {
init { init {
delegatesManager.addDelegate(sourceLocaleAD(clickListener)) delegatesManager.addDelegate(sourceLocaleAD(listener))
} }
private class DiffCallback : DiffUtil.ItemCallback<SourceLocale>() { private class DiffCallback : DiffUtil.ItemCallback<SourceLocale>() {
@@ -25,4 +24,4 @@ class SourceLocalesAdapter(
newItem: SourceLocale, newItem: SourceLocale,
): Boolean = oldItem == newItem ): Boolean = oldItem == newItem
} }
} }

View File

@@ -1,10 +1,11 @@
package org.koitharu.kotatsu.settings.onboard.model package org.koitharu.kotatsu.settings.onboard.model
import java.util.* import java.util.Locale
data class SourceLocale( data class SourceLocale(
val key: String?, val key: String?,
val title: String?, val title: String?,
val summary: String?,
val isChecked: Boolean, val isChecked: Boolean,
) : Comparable<SourceLocale> { ) : Comparable<SourceLocale> {

View File

@@ -91,8 +91,8 @@ class ToolsFragment :
binding.cardUpdate.root.isVisible = false binding.cardUpdate.root.isVisible = false
return return
} }
binding.cardUpdate.textPrimary.text = getString(R.string.app_update_available_s, version.name) binding.cardUpdate.textSecondary.text = getString(R.string.new_version_s, version.name)
binding.cardUpdate.textSecondary.text = version.description binding.cardUpdate.textChangelog.text = version.description
binding.cardUpdate.root.isVisible = true binding.cardUpdate.root.isVisible = true
} }

View File

@@ -7,6 +7,7 @@ import android.net.Uri
import android.provider.Settings import android.provider.Settings
import androidx.activity.result.contract.ActivityResultContract import androidx.activity.result.contract.ActivityResultContract
import androidx.annotation.StringRes import androidx.annotation.StringRes
import org.koitharu.kotatsu.utils.ext.getParcelableExtraCompat
class RingtonePickContract(@StringRes private val titleResId: Int) : ActivityResultContract<Uri?, Uri?>() { class RingtonePickContract(@StringRes private val titleResId: Int) : ActivityResultContract<Uri?, Uri?>() {
@@ -30,6 +31,6 @@ class RingtonePickContract(@StringRes private val titleResId: Int) : ActivityRes
} }
override fun parseResult(resultCode: Int, intent: Intent?): Uri? { override fun parseResult(resultCode: Int, intent: Intent?): Uri? {
return intent?.getParcelableExtra(RingtoneManager.EXTRA_RINGTONE_PICKED_URI) return intent?.getParcelableExtraCompat(RingtoneManager.EXTRA_RINGTONE_PICKED_URI)
} }
} }

View File

@@ -0,0 +1,80 @@
package org.koitharu.kotatsu.shelf.domain
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.entity.toManga
import org.koitharu.kotatsu.core.db.entity.toMangaTags
import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity
import org.koitharu.kotatsu.favourites.data.toFavouriteCategory
import org.koitharu.kotatsu.history.domain.HistoryRepository
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.utils.ext.runCatchingCancellable
import javax.inject.Inject
class ShelfRepository @Inject constructor(
private val localMangaRepository: LocalMangaRepository,
private val historyRepository: HistoryRepository,
private val db: MangaDatabase,
) {
fun observeLocalManga(sortOrder: SortOrder): Flow<List<Manga>> {
return flow {
emit(null)
emitAll(localMangaRepository.watchReadableDirs())
}.mapLatest {
localMangaRepository.getList(0, null, sortOrder)
}
}
fun observeFavourites(): Flow<Map<FavouriteCategory, List<Manga>>> {
return db.favouriteCategoriesDao.observeAll()
.flatMapLatest { categories ->
val cats = categories.filter { it.isVisibleInLibrary }
if (cats.isEmpty()) {
flowOf(emptyMap())
} else {
observeCategoriesContent(cats)
}
}
}
suspend fun deleteLocalManga(ids: Set<Long>) {
val list = localMangaRepository.getList(0, null, null)
.filter { x -> x.id in ids }
coroutineScope {
list.map { manga ->
async {
val original = localMangaRepository.getRemoteManga(manga)
if (localMangaRepository.delete(manga)) {
runCatchingCancellable {
historyRepository.deleteOrSwap(manga, original)
}
}
}
}.awaitAll()
}
}
private fun observeCategoriesContent(
categories: List<FavouriteCategoryEntity>,
) = combine<Pair<FavouriteCategory, List<Manga>>, Map<FavouriteCategory, List<Manga>>>(
categories.map { cat ->
val category = cat.toFavouriteCategory()
db.favouritesDao.observeAll(category.id, category.order)
.map { category to it.map { x -> x.manga.toManga(x.tags.toMangaTags()) } }
},
) { array -> array.toMap() }
}

View File

@@ -1,6 +1,9 @@
package org.koitharu.kotatsu.library.ui package org.koitharu.kotatsu.shelf.ui
import android.content.Intent
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.provider.Settings
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
@@ -11,7 +14,6 @@ import androidx.recyclerview.widget.RecyclerView
import coil.ImageLoader import coil.ImageLoader
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.domain.reverseAsync import org.koitharu.kotatsu.base.domain.reverseAsync
import org.koitharu.kotatsu.base.ui.BaseFragment import org.koitharu.kotatsu.base.ui.BaseFragment
@@ -19,25 +21,29 @@ import org.koitharu.kotatsu.base.ui.list.SectionedSelectionController
import org.koitharu.kotatsu.base.ui.util.RecyclerViewOwner import org.koitharu.kotatsu.base.ui.util.RecyclerViewOwner
import org.koitharu.kotatsu.base.ui.util.ReversibleAction import org.koitharu.kotatsu.base.ui.util.ReversibleAction
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.databinding.FragmentLibraryBinding import org.koitharu.kotatsu.databinding.FragmentShelfBinding
import org.koitharu.kotatsu.details.ui.DetailsActivity import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.favourites.ui.FavouritesActivity import org.koitharu.kotatsu.favourites.ui.FavouritesActivity
import org.koitharu.kotatsu.history.ui.HistoryActivity import org.koitharu.kotatsu.history.ui.HistoryActivity
import org.koitharu.kotatsu.library.ui.adapter.LibraryAdapter
import org.koitharu.kotatsu.library.ui.adapter.LibraryListEventListener
import org.koitharu.kotatsu.library.ui.model.LibrarySectionModel
import org.koitharu.kotatsu.list.ui.ItemSizeResolver import org.koitharu.kotatsu.list.ui.ItemSizeResolver
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.main.ui.owners.BottomNavOwner import org.koitharu.kotatsu.main.ui.owners.BottomNavOwner
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.search.ui.MangaListActivity
import org.koitharu.kotatsu.shelf.ui.adapter.ShelfAdapter
import org.koitharu.kotatsu.shelf.ui.adapter.ShelfListEventListener
import org.koitharu.kotatsu.shelf.ui.model.ShelfSectionModel
import org.koitharu.kotatsu.tracker.ui.updates.UpdatesActivity
import org.koitharu.kotatsu.utils.ext.addMenuProvider import org.koitharu.kotatsu.utils.ext.addMenuProvider
import org.koitharu.kotatsu.utils.ext.getDisplayMessage import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class LibraryFragment : class ShelfFragment :
BaseFragment<FragmentLibraryBinding>(), BaseFragment<FragmentShelfBinding>(),
RecyclerViewOwner, RecyclerViewOwner,
LibraryListEventListener { ShelfListEventListener {
@Inject @Inject
lateinit var coil: ImageLoader lateinit var coil: ImageLoader
@@ -45,15 +51,15 @@ class LibraryFragment :
@Inject @Inject
lateinit var settings: AppSettings lateinit var settings: AppSettings
private val viewModel by viewModels<LibraryViewModel>() private val viewModel by viewModels<ShelfViewModel>()
private var adapter: LibraryAdapter? = null private var adapter: ShelfAdapter? = null
private var selectionController: SectionedSelectionController<LibrarySectionModel>? = null private var selectionController: SectionedSelectionController<ShelfSectionModel>? = null
override val recyclerView: RecyclerView override val recyclerView: RecyclerView
get() = binding.recyclerView get() = binding.recyclerView
override fun onInflateView(inflater: LayoutInflater, container: ViewGroup?): FragmentLibraryBinding { override fun onInflateView(inflater: LayoutInflater, container: ViewGroup?): FragmentShelfBinding {
return FragmentLibraryBinding.inflate(inflater, container, false) return FragmentShelfBinding.inflate(inflater, container, false)
} }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
@@ -62,9 +68,9 @@ class LibraryFragment :
selectionController = SectionedSelectionController( selectionController = SectionedSelectionController(
activity = requireActivity(), activity = requireActivity(),
owner = this, owner = this,
callback = LibrarySelectionCallback(binding.recyclerView, childFragmentManager, viewModel), callback = ShelfSelectionCallback(binding.recyclerView, childFragmentManager, viewModel),
) )
adapter = LibraryAdapter( adapter = ShelfAdapter(
lifecycleOwner = viewLifecycleOwner, lifecycleOwner = viewLifecycleOwner,
coil = coil, coil = coil,
listener = this, listener = this,
@@ -73,7 +79,7 @@ class LibraryFragment :
) )
binding.recyclerView.adapter = adapter binding.recyclerView.adapter = adapter
binding.recyclerView.setHasFixedSize(true) binding.recyclerView.setHasFixedSize(true)
addMenuProvider(LibraryMenuProvider(view.context, childFragmentManager, viewModel)) addMenuProvider(ShelfMenuProvider(view.context, childFragmentManager, viewModel))
viewModel.content.observe(viewLifecycleOwner, ::onListChanged) viewModel.content.observe(viewLifecycleOwner, ::onListChanged)
viewModel.onError.observe(viewLifecycleOwner, ::onError) viewModel.onError.observe(viewLifecycleOwner, ::onError)
@@ -86,29 +92,38 @@ class LibraryFragment :
selectionController = null selectionController = null
} }
override fun onItemClick(item: Manga, section: LibrarySectionModel, view: View) { override fun onItemClick(item: Manga, section: ShelfSectionModel, view: View) {
if (selectionController?.onItemClick(section, item.id) != true) { if (selectionController?.onItemClick(section, item.id) != true) {
val intent = DetailsActivity.newIntent(view.context, item) val intent = DetailsActivity.newIntent(view.context, item)
startActivity(intent) startActivity(intent)
} }
} }
override fun onItemLongClick(item: Manga, section: LibrarySectionModel, view: View): Boolean { override fun onItemLongClick(item: Manga, section: ShelfSectionModel, view: View): Boolean {
return selectionController?.onItemLongClick(section, item.id) ?: false return selectionController?.onItemLongClick(section, item.id) ?: false
} }
override fun onSectionClick(section: LibrarySectionModel, view: View) { override fun onSectionClick(section: ShelfSectionModel, view: View) {
selectionController?.clear() selectionController?.clear()
val intent = when (section) { val intent = when (section) {
is LibrarySectionModel.History -> HistoryActivity.newIntent(view.context) is ShelfSectionModel.History -> HistoryActivity.newIntent(view.context)
is LibrarySectionModel.Favourites -> FavouritesActivity.newIntent(view.context, section.category) is ShelfSectionModel.Favourites -> FavouritesActivity.newIntent(view.context, section.category)
is ShelfSectionModel.Updated -> UpdatesActivity.newIntent(view.context)
is ShelfSectionModel.Local -> MangaListActivity.newIntent(view.context, MangaSource.LOCAL)
} }
startActivity(intent) startActivity(intent)
} }
override fun onRetryClick(error: Throwable) = Unit override fun onRetryClick(error: Throwable) = Unit
override fun onEmptyActionClick() = Unit override fun onEmptyActionClick() {
val action = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
Settings.Panel.ACTION_INTERNET_CONNECTIVITY
} else {
Settings.ACTION_WIRELESS_SETTINGS
}
startActivity(Intent(action))
}
override fun onWindowInsetsChanged(insets: Insets) { override fun onWindowInsetsChanged(insets: Insets) {
binding.recyclerView.updatePadding( binding.recyclerView.updatePadding(
@@ -143,6 +158,6 @@ class LibraryFragment :
companion object { companion object {
fun newInstance() = LibraryFragment() fun newInstance() = ShelfFragment()
} }
} }

View File

@@ -1,4 +1,4 @@
package org.koitharu.kotatsu.library.ui package org.koitharu.kotatsu.shelf.ui
import android.content.Context import android.content.Context
import android.view.Menu import android.view.Menu
@@ -12,19 +12,19 @@ import java.util.*
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.dialog.RememberSelectionDialogListener import org.koitharu.kotatsu.base.ui.dialog.RememberSelectionDialogListener
import org.koitharu.kotatsu.library.ui.config.categories.LibraryCategoriesConfigSheet import org.koitharu.kotatsu.shelf.ui.config.categories.ShelfCategoriesConfigSheet
import org.koitharu.kotatsu.library.ui.config.size.LibrarySizeBottomSheet import org.koitharu.kotatsu.shelf.ui.config.size.ShelfSizeBottomSheet
import org.koitharu.kotatsu.local.ui.ImportDialogFragment import org.koitharu.kotatsu.local.ui.ImportDialogFragment
import org.koitharu.kotatsu.utils.ext.startOfDay import org.koitharu.kotatsu.utils.ext.startOfDay
class LibraryMenuProvider( class ShelfMenuProvider(
private val context: Context, private val context: Context,
private val fragmentManager: FragmentManager, private val fragmentManager: FragmentManager,
private val viewModel: LibraryViewModel, private val viewModel: ShelfViewModel,
) : MenuProvider { ) : MenuProvider {
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menuInflater.inflate(R.menu.opt_library, menu) menuInflater.inflate(R.menu.opt_shelf, menu)
} }
override fun onMenuItemSelected(menuItem: MenuItem): Boolean { override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
@@ -34,7 +34,7 @@ class LibraryMenuProvider(
true true
} }
R.id.action_grid_size -> { R.id.action_grid_size -> {
LibrarySizeBottomSheet.show(fragmentManager) ShelfSizeBottomSheet.show(fragmentManager)
true true
} }
R.id.action_import -> { R.id.action_import -> {
@@ -42,7 +42,7 @@ class LibraryMenuProvider(
true true
} }
R.id.action_categories -> { R.id.action_categories -> {
LibraryCategoriesConfigSheet.show(fragmentManager) ShelfCategoriesConfigSheet.show(fragmentManager)
true true
} }
else -> false else -> false

View File

@@ -1,4 +1,4 @@
package org.koitharu.kotatsu.library.ui package org.koitharu.kotatsu.shelf.ui
import android.content.Context import android.content.Context
import android.view.Menu import android.view.Menu
@@ -6,48 +6,51 @@ import android.view.MenuItem
import androidx.appcompat.view.ActionMode import androidx.appcompat.view.ActionMode
import androidx.fragment.app.FragmentManager import androidx.fragment.app.FragmentManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.list.SectionedSelectionController import org.koitharu.kotatsu.base.ui.list.SectionedSelectionController
import org.koitharu.kotatsu.base.ui.list.decor.AbstractSelectionItemDecoration import org.koitharu.kotatsu.base.ui.list.decor.AbstractSelectionItemDecoration
import org.koitharu.kotatsu.download.ui.service.DownloadService import org.koitharu.kotatsu.download.ui.service.DownloadService
import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteCategoriesBottomSheet import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteCategoriesBottomSheet
import org.koitharu.kotatsu.library.ui.model.LibrarySectionModel
import org.koitharu.kotatsu.list.ui.MangaSelectionDecoration import org.koitharu.kotatsu.list.ui.MangaSelectionDecoration
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.flattenTo import org.koitharu.kotatsu.parsers.util.flattenTo
import org.koitharu.kotatsu.shelf.ui.model.ShelfSectionModel
import org.koitharu.kotatsu.utils.ShareHelper import org.koitharu.kotatsu.utils.ShareHelper
import org.koitharu.kotatsu.utils.ext.invalidateNestedItemDecorations import org.koitharu.kotatsu.utils.ext.invalidateNestedItemDecorations
class LibrarySelectionCallback( class ShelfSelectionCallback(
private val recyclerView: RecyclerView, private val recyclerView: RecyclerView,
private val fragmentManager: FragmentManager, private val fragmentManager: FragmentManager,
private val viewModel: LibraryViewModel, private val viewModel: ShelfViewModel,
) : SectionedSelectionController.Callback<LibrarySectionModel> { ) : SectionedSelectionController.Callback<ShelfSectionModel> {
private val context: Context private val context: Context
get() = recyclerView.context get() = recyclerView.context
override fun onCreateActionMode( override fun onCreateActionMode(
controller: SectionedSelectionController<LibrarySectionModel>, controller: SectionedSelectionController<ShelfSectionModel>,
mode: ActionMode, mode: ActionMode,
menu: Menu, menu: Menu,
): Boolean { ): Boolean {
mode.menuInflater.inflate(R.menu.mode_library, menu) mode.menuInflater.inflate(R.menu.mode_shelf, menu)
return true return true
} }
override fun onPrepareActionMode( override fun onPrepareActionMode(
controller: SectionedSelectionController<LibrarySectionModel>, controller: SectionedSelectionController<ShelfSectionModel>,
mode: ActionMode, mode: ActionMode,
menu: Menu, menu: Menu,
): Boolean { ): Boolean {
menu.findItem(R.id.action_remove).isVisible = val checkedIds = controller.peekCheckedIds().entries
controller.peekCheckedIds().count { (_, v) -> v.isNotEmpty() } == 1 val singleKey = checkedIds.singleOrNull { (_, ids) -> ids.isNotEmpty() }?.key
menu.findItem(R.id.action_remove)?.isVisible = singleKey != null && singleKey !is ShelfSectionModel.Updated
menu.findItem(R.id.action_save)?.isVisible = singleKey !is ShelfSectionModel.Local
return super.onPrepareActionMode(controller, mode, menu) return super.onPrepareActionMode(controller, mode, menu)
} }
override fun onActionItemClicked( override fun onActionItemClicked(
controller: SectionedSelectionController<LibrarySectionModel>, controller: SectionedSelectionController<ShelfSectionModel>,
mode: ActionMode, mode: ActionMode,
item: MenuItem, item: MenuItem,
): Boolean { ): Boolean {
@@ -57,41 +60,50 @@ class LibrarySelectionCallback(
mode.finish() mode.finish()
true true
} }
R.id.action_favourite -> { R.id.action_favourite -> {
FavouriteCategoriesBottomSheet.show(fragmentManager, collectSelectedItems(controller)) FavouriteCategoriesBottomSheet.show(fragmentManager, collectSelectedItems(controller))
mode.finish() mode.finish()
true true
} }
R.id.action_save -> { R.id.action_save -> {
DownloadService.confirmAndStart(context, collectSelectedItems(controller)) DownloadService.confirmAndStart(context, collectSelectedItems(controller))
mode.finish() mode.finish()
true true
} }
R.id.action_remove -> { R.id.action_remove -> {
val (group, ids) = controller.snapshot().entries.singleOrNull { it.value.isNotEmpty() } ?: return false val (group, ids) = controller.snapshot().entries.singleOrNull { it.value.isNotEmpty() } ?: return false
when (group) { when (group) {
is LibrarySectionModel.Favourites -> viewModel.removeFromFavourites(group.category, ids) is ShelfSectionModel.Favourites -> viewModel.removeFromFavourites(group.category, ids)
is LibrarySectionModel.History -> viewModel.removeFromHistory(ids) is ShelfSectionModel.History -> viewModel.removeFromHistory(ids)
is ShelfSectionModel.Updated -> return false
is ShelfSectionModel.Local -> {
showDeletionConfirm(ids, mode)
return true
}
} }
mode.finish() mode.finish()
true true
} }
else -> false else -> false
} }
} }
override fun onSelectionChanged(controller: SectionedSelectionController<LibrarySectionModel>, count: Int) { override fun onSelectionChanged(controller: SectionedSelectionController<ShelfSectionModel>, count: Int) {
recyclerView.invalidateNestedItemDecorations() recyclerView.invalidateNestedItemDecorations()
} }
override fun onCreateItemDecoration( override fun onCreateItemDecoration(
controller: SectionedSelectionController<LibrarySectionModel>, controller: SectionedSelectionController<ShelfSectionModel>,
section: LibrarySectionModel, section: ShelfSectionModel,
): AbstractSelectionItemDecoration = MangaSelectionDecoration(context) ): AbstractSelectionItemDecoration = MangaSelectionDecoration(context)
private fun collectSelectedItemsMap( private fun collectSelectedItemsMap(
controller: SectionedSelectionController<LibrarySectionModel>, controller: SectionedSelectionController<ShelfSectionModel>,
): Map<LibrarySectionModel, Set<Manga>> { ): Map<ShelfSectionModel, Set<Manga>> {
val snapshot = controller.peekCheckedIds() val snapshot = controller.peekCheckedIds()
if (snapshot.isEmpty()) { if (snapshot.isEmpty()) {
return emptyMap() return emptyMap()
@@ -100,7 +112,7 @@ class LibrarySelectionCallback(
} }
private fun collectSelectedItems( private fun collectSelectedItems(
controller: SectionedSelectionController<LibrarySectionModel>, controller: SectionedSelectionController<ShelfSectionModel>,
): Set<Manga> { ): Set<Manga> {
val snapshot = controller.peekCheckedIds() val snapshot = controller.peekCheckedIds()
if (snapshot.isEmpty()) { if (snapshot.isEmpty()) {
@@ -108,4 +120,19 @@ class LibrarySelectionCallback(
} }
return viewModel.getManga(snapshot.values.flattenTo(HashSet())) return viewModel.getManga(snapshot.values.flattenTo(HashSet()))
} }
private fun showDeletionConfirm(ids: Set<Long>, mode: ActionMode) {
if (ids.isEmpty()) {
return
}
MaterialAlertDialogBuilder(context ?: return)
.setTitle(R.string.delete_manga)
.setMessage(context.getString(R.string.text_delete_local_manga_batch))
.setPositiveButton(R.string.delete) { _, _ ->
viewModel.deleteLocal(ids)
mode.finish()
}
.setNegativeButton(android.R.string.cancel, null)
.show()
}
} }

View File

@@ -1,56 +1,66 @@
package org.koitharu.kotatsu.library.ui package org.koitharu.kotatsu.shelf.ui
import androidx.collection.ArraySet import androidx.collection.ArraySet
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import java.util.*
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseViewModel import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.base.ui.util.ReversibleAction import org.koitharu.kotatsu.base.ui.util.ReversibleAction
import org.koitharu.kotatsu.core.model.FavouriteCategory import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.core.os.NetworkStateObserver
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.ListMode import org.koitharu.kotatsu.core.prefs.ListMode
import org.koitharu.kotatsu.core.ui.DateTimeAgo
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.history.domain.HistoryRepository import org.koitharu.kotatsu.history.domain.HistoryRepository
import org.koitharu.kotatsu.history.domain.MangaWithHistory import org.koitharu.kotatsu.history.domain.MangaWithHistory
import org.koitharu.kotatsu.history.domain.PROGRESS_NONE import org.koitharu.kotatsu.history.domain.PROGRESS_NONE
import org.koitharu.kotatsu.library.domain.LibraryRepository
import org.koitharu.kotatsu.library.ui.model.LibrarySectionModel
import org.koitharu.kotatsu.list.domain.ListExtraProvider import org.koitharu.kotatsu.list.domain.ListExtraProvider
import org.koitharu.kotatsu.list.ui.model.* import org.koitharu.kotatsu.list.ui.model.EmptyHint
import org.koitharu.kotatsu.list.ui.model.EmptyState
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.LoadingState
import org.koitharu.kotatsu.list.ui.model.toErrorState
import org.koitharu.kotatsu.list.ui.model.toGridModel
import org.koitharu.kotatsu.list.ui.model.toUi
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.SortOrder
import org.koitharu.kotatsu.shelf.domain.ShelfRepository
import org.koitharu.kotatsu.shelf.ui.model.ShelfSectionModel
import org.koitharu.kotatsu.tracker.domain.TrackingRepository import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.utils.SingleLiveEvent import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.asFlowLiveData import org.koitharu.kotatsu.utils.asFlowLiveData
import org.koitharu.kotatsu.utils.ext.daysDiff import javax.inject.Inject
private const val HISTORY_MAX_SEGMENTS = 2
@HiltViewModel @HiltViewModel
class LibraryViewModel @Inject constructor( class ShelfViewModel @Inject constructor(
repository: LibraryRepository, private val repository: ShelfRepository,
private val historyRepository: HistoryRepository, private val historyRepository: HistoryRepository,
private val favouritesRepository: FavouritesRepository, private val favouritesRepository: FavouritesRepository,
private val trackingRepository: TrackingRepository, private val trackingRepository: TrackingRepository,
private val settings: AppSettings, private val settings: AppSettings,
private val networkStateObserver: NetworkStateObserver,
) : BaseViewModel(), ListExtraProvider { ) : BaseViewModel(), ListExtraProvider {
val onActionDone = SingleLiveEvent<ReversibleAction>() val onActionDone = SingleLiveEvent<ReversibleAction>()
val content: LiveData<List<ListModel>> = combine( val content: LiveData<List<ListModel>> = combine(
networkStateObserver,
historyRepository.observeAllWithHistory(), historyRepository.observeAllWithHistory(),
repository.observeLocalManga(SortOrder.UPDATED),
repository.observeFavourites(), repository.observeFavourites(),
) { history, favourites -> trackingRepository.observeUpdatedManga(),
mapList(history, favourites) ) { isConnected, history, local, favourites, updated ->
}.catch { e -> mapList(history, favourites, updated, local, isConnected)
emit(listOf(e.toErrorState(canRetry = false))) }.debounce(500)
}.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState)) .catch { e ->
emit(listOf(e.toErrorState(canRetry = false)))
}.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState))
override suspend fun getCounter(mangaId: Long): Int { override suspend fun getCounter(mangaId: Long): Int {
return trackingRepository.getNewChaptersCount(mangaId) return trackingRepository.getNewChaptersCount(mangaId)
@@ -84,6 +94,13 @@ class LibraryViewModel @Inject constructor(
} }
} }
fun deleteLocal(ids: Set<Long>) {
launchLoadingJob(Dispatchers.Default) {
repository.deleteLocalManga(ids)
onActionDone.postCall(ReversibleAction(R.string.removal_completed, null))
}
}
fun clearHistory(minDate: Long) { fun clearHistory(minDate: Long) {
launchJob(Dispatchers.Default) { launchJob(Dispatchers.Default) {
val stringRes = if (minDate <= 0) { val stringRes = if (minDate <= 0) {
@@ -101,7 +118,7 @@ class LibraryViewModel @Inject constructor(
val snapshot = content.value ?: return emptySet() val snapshot = content.value ?: return emptySet()
val result = ArraySet<Manga>(ids.size) val result = ArraySet<Manga>(ids.size)
for (section in snapshot) { for (section in snapshot) {
if (section !is LibrarySectionModel) { if (section !is ShelfSectionModel) {
continue continue
} }
for (item in section.items) { for (item in section.items) {
@@ -119,13 +136,38 @@ class LibraryViewModel @Inject constructor(
private suspend fun mapList( private suspend fun mapList(
history: List<MangaWithHistory>, history: List<MangaWithHistory>,
favourites: Map<FavouriteCategory, List<Manga>>, favourites: Map<FavouriteCategory, List<Manga>>,
updated: Map<Manga, Int>,
local: List<Manga>,
isNetworkAvailable: Boolean,
): List<ListModel> { ): List<ListModel> {
val result = ArrayList<ListModel>(favourites.keys.size + 1) val result = ArrayList<ListModel>(favourites.keys.size + 3)
if (history.isNotEmpty()) { if (isNetworkAvailable) {
mapHistory(result, history) if (history.isNotEmpty()) {
} mapHistory(result, history)
if (favourites.isNotEmpty()) { }
mapFavourites(result, favourites) if (local.isNotEmpty()) {
mapLocal(result, local)
}
if (updated.isNotEmpty()) {
mapUpdated(result, updated)
}
if (favourites.isNotEmpty()) {
mapFavourites(result, favourites)
}
} else {
result += EmptyHint(
icon = R.drawable.ic_empty_common,
textPrimary = R.string.network_unavailable,
textSecondary = R.string.network_unavailable_hint,
actionStringRes = R.string.manage,
)
val offlineHistory = history.filter { it.manga.source == MangaSource.LOCAL }
if (offlineHistory.isNotEmpty()) {
mapHistory(result, offlineHistory)
}
if (local.isNotEmpty()) {
mapLocal(result, local)
}
} }
if (result.isEmpty()) { if (result.isEmpty()) {
result += EmptyState( result += EmptyState(
@@ -134,42 +176,61 @@ class LibraryViewModel @Inject constructor(
textSecondary = R.string.text_shelf_holder_secondary, textSecondary = R.string.text_shelf_holder_secondary,
actionStringRes = 0, actionStringRes = 0,
) )
} else {
val one = result.singleOrNull()
if (one is EmptyHint) {
result[0] = one.toState()
}
} }
result.trimToSize()
return result return result
} }
private suspend fun mapHistory( private suspend fun mapHistory(
destination: MutableList<in LibrarySectionModel.History>, destination: MutableList<in ShelfSectionModel.History>,
list: List<MangaWithHistory>, list: List<MangaWithHistory>,
) { ) {
val showPercent = settings.isReadingIndicatorsEnabled val showPercent = settings.isReadingIndicatorsEnabled
val groups = list.groupByTo(LinkedHashMap()) { timeAgo(it.history.updatedAt) } destination += ShelfSectionModel.History(
while (groups.size > HISTORY_MAX_SEGMENTS) { items = list.map { (manga, history) ->
val lastKey = groups.keys.last() val counter = trackingRepository.getNewChaptersCount(manga.id)
val subList = groups.remove(lastKey) ?: continue val percent = if (showPercent) history.percent else PROGRESS_NONE
groups[groups.keys.last()]?.addAll(subList) manga.toGridModel(counter, percent)
} },
for ((timeAgo, subList) in groups) { showAllButtonText = R.string.show_all,
destination += LibrarySectionModel.History( )
items = subList.map { (manga, history) -> }
val counter = trackingRepository.getNewChaptersCount(manga.id)
val percent = if (showPercent) history.percent else PROGRESS_NONE private suspend fun mapUpdated(
manga.toGridModel(counter, percent) destination: MutableList<in ShelfSectionModel.Updated>,
}, updated: Map<Manga, Int>,
timeAgo = timeAgo, ) {
showAllButtonText = R.string.show_all, val showPercent = settings.isReadingIndicatorsEnabled
) destination += ShelfSectionModel.Updated(
} items = updated.map { (manga, counter) ->
val percent = if (showPercent) getProgress(manga.id) else PROGRESS_NONE
manga.toGridModel(counter, percent)
},
showAllButtonText = R.string.show_all,
)
}
private suspend fun mapLocal(
destination: MutableList<in ShelfSectionModel.Local>,
local: List<Manga>,
) {
destination += ShelfSectionModel.Local(
items = local.toUi(ListMode.GRID, this),
showAllButtonText = R.string.show_all,
)
} }
private suspend fun mapFavourites( private suspend fun mapFavourites(
destination: MutableList<in LibrarySectionModel.Favourites>, destination: MutableList<in ShelfSectionModel.Favourites>,
favourites: Map<FavouriteCategory, List<Manga>>, favourites: Map<FavouriteCategory, List<Manga>>,
) { ) {
for ((category, list) in favourites) { for ((category, list) in favourites) {
if (list.isNotEmpty()) { if (list.isNotEmpty()) {
destination += LibrarySectionModel.Favourites( destination += ShelfSectionModel.Favourites(
items = list.toUi(ListMode.GRID, this), items = list.toUi(ListMode.GRID, this),
category = category, category = category,
showAllButtonText = R.string.show_all, showAllButtonText = R.string.show_all,
@@ -177,14 +238,4 @@ class LibraryViewModel @Inject constructor(
} }
} }
} }
private fun timeAgo(date: Date): DateTimeAgo {
val diffDays = -date.daysDiff(System.currentTimeMillis())
return when {
diffDays < 1 -> DateTimeAgo.Today
diffDays == 1 -> DateTimeAgo.Yesterday
diffDays <= 3 -> DateTimeAgo.DaysAgo(diffDays)
else -> DateTimeAgo.LongAgo
}
}
} }

View File

@@ -1,4 +1,4 @@
package org.koitharu.kotatsu.library.ui.adapter package org.koitharu.kotatsu.shelf.ui.adapter
import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.DiffUtil
import org.koitharu.kotatsu.list.ui.adapter.MangaListAdapter import org.koitharu.kotatsu.list.ui.adapter.MangaListAdapter
@@ -27,4 +27,4 @@ class MangaItemDiffCallback : DiffUtil.ItemCallback<ListModel>() {
else -> super.getChangePayload(oldItem, newItem) else -> super.getChangePayload(oldItem, newItem)
} }
} }
} }

View File

@@ -1,4 +1,4 @@
package org.koitharu.kotatsu.library.ui.adapter package org.koitharu.kotatsu.shelf.ui.adapter
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView

View File

@@ -1,4 +1,4 @@
package org.koitharu.kotatsu.library.ui.adapter package org.koitharu.kotatsu.shelf.ui.adapter
import android.content.Context import android.content.Context
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
@@ -6,30 +6,31 @@ import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import coil.ImageLoader import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
import kotlin.jvm.internal.Intrinsics
import org.koitharu.kotatsu.base.ui.list.SectionedSelectionController import org.koitharu.kotatsu.base.ui.list.SectionedSelectionController
import org.koitharu.kotatsu.base.ui.list.fastscroll.FastScroller import org.koitharu.kotatsu.base.ui.list.fastscroll.FastScroller
import org.koitharu.kotatsu.library.ui.model.LibrarySectionModel
import org.koitharu.kotatsu.list.ui.ItemSizeResolver import org.koitharu.kotatsu.list.ui.ItemSizeResolver
import org.koitharu.kotatsu.list.ui.adapter.emptyHintAD
import org.koitharu.kotatsu.list.ui.adapter.emptyStateListAD import org.koitharu.kotatsu.list.ui.adapter.emptyStateListAD
import org.koitharu.kotatsu.list.ui.adapter.errorStateListAD import org.koitharu.kotatsu.list.ui.adapter.errorStateListAD
import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD
import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.shelf.ui.model.ShelfSectionModel
import kotlin.jvm.internal.Intrinsics
class LibraryAdapter( class ShelfAdapter(
lifecycleOwner: LifecycleOwner, lifecycleOwner: LifecycleOwner,
coil: ImageLoader, coil: ImageLoader,
listener: LibraryListEventListener, listener: ShelfListEventListener,
sizeResolver: ItemSizeResolver, sizeResolver: ItemSizeResolver,
selectionController: SectionedSelectionController<LibrarySectionModel>, selectionController: SectionedSelectionController<ShelfSectionModel>,
) : AsyncListDifferDelegationAdapter<ListModel>(DiffCallback()), FastScroller.SectionIndexer { ) : AsyncListDifferDelegationAdapter<ListModel>(DiffCallback()), FastScroller.SectionIndexer {
init { init {
val pool = RecyclerView.RecycledViewPool() val pool = RecyclerView.RecycledViewPool()
delegatesManager delegatesManager
.addDelegate( .addDelegate(
libraryGroupAD( shelfGroupAD(
sharedPool = pool, sharedPool = pool,
lifecycleOwner = lifecycleOwner, lifecycleOwner = lifecycleOwner,
coil = coil, coil = coil,
@@ -40,12 +41,13 @@ class LibraryAdapter(
) )
.addDelegate(loadingStateAD()) .addDelegate(loadingStateAD())
.addDelegate(loadingFooterAD()) .addDelegate(loadingFooterAD())
.addDelegate(emptyHintAD(listener))
.addDelegate(emptyStateListAD(coil, listener)) .addDelegate(emptyStateListAD(coil, listener))
.addDelegate(errorStateListAD(listener)) .addDelegate(errorStateListAD(listener))
} }
override fun getSectionText(context: Context, position: Int): CharSequence { override fun getSectionText(context: Context, position: Int): CharSequence {
val item = items.getOrNull(position) as? LibrarySectionModel val item = items.getOrNull(position) as? ShelfSectionModel
return item?.getTitle(context.resources) ?: "" return item?.getTitle(context.resources) ?: ""
} }
@@ -53,9 +55,10 @@ class LibraryAdapter(
override fun areItemsTheSame(oldItem: ListModel, newItem: ListModel): Boolean { override fun areItemsTheSame(oldItem: ListModel, newItem: ListModel): Boolean {
return when { return when {
oldItem is LibrarySectionModel && newItem is LibrarySectionModel -> { oldItem is ShelfSectionModel && newItem is ShelfSectionModel -> {
oldItem.key == newItem.key oldItem.key == newItem.key
} }
else -> oldItem.javaClass == newItem.javaClass else -> oldItem.javaClass == newItem.javaClass
} }
} }
@@ -66,7 +69,7 @@ class LibraryAdapter(
override fun getChangePayload(oldItem: ListModel, newItem: ListModel): Any? { override fun getChangePayload(oldItem: ListModel, newItem: ListModel): Any? {
return when { return when {
oldItem is LibrarySectionModel && newItem is LibrarySectionModel -> Unit oldItem is ShelfSectionModel && newItem is ShelfSectionModel -> Unit
else -> super.getChangePayload(oldItem, newItem) else -> super.getChangePayload(oldItem, newItem)
} }
} }

View File

@@ -1,4 +1,4 @@
package org.koitharu.kotatsu.library.ui.adapter package org.koitharu.kotatsu.shelf.ui.adapter
import android.view.View import android.view.View
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
@@ -12,7 +12,7 @@ import org.koitharu.kotatsu.base.ui.list.SectionedSelectionController
import org.koitharu.kotatsu.base.ui.list.decor.AbstractSelectionItemDecoration import org.koitharu.kotatsu.base.ui.list.decor.AbstractSelectionItemDecoration
import org.koitharu.kotatsu.base.ui.list.decor.SpacingItemDecoration import org.koitharu.kotatsu.base.ui.list.decor.SpacingItemDecoration
import org.koitharu.kotatsu.databinding.ItemListGroupBinding import org.koitharu.kotatsu.databinding.ItemListGroupBinding
import org.koitharu.kotatsu.library.ui.model.LibrarySectionModel import org.koitharu.kotatsu.shelf.ui.model.ShelfSectionModel
import org.koitharu.kotatsu.list.ui.ItemSizeResolver import org.koitharu.kotatsu.list.ui.ItemSizeResolver
import org.koitharu.kotatsu.list.ui.adapter.mangaGridItemAD import org.koitharu.kotatsu.list.ui.adapter.mangaGridItemAD
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
@@ -20,14 +20,14 @@ import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.utils.ext.removeItemDecoration import org.koitharu.kotatsu.utils.ext.removeItemDecoration
import org.koitharu.kotatsu.utils.ext.setTextAndVisible import org.koitharu.kotatsu.utils.ext.setTextAndVisible
fun libraryGroupAD( fun shelfGroupAD(
sharedPool: RecyclerView.RecycledViewPool, sharedPool: RecyclerView.RecycledViewPool,
lifecycleOwner: LifecycleOwner, lifecycleOwner: LifecycleOwner,
coil: ImageLoader, coil: ImageLoader,
sizeResolver: ItemSizeResolver, sizeResolver: ItemSizeResolver,
selectionController: SectionedSelectionController<LibrarySectionModel>, selectionController: SectionedSelectionController<ShelfSectionModel>,
listener: LibraryListEventListener, listener: ShelfListEventListener,
) = adapterDelegateViewBinding<LibrarySectionModel, ListModel, ItemListGroupBinding>( ) = adapterDelegateViewBinding<ShelfSectionModel, ListModel, ItemListGroupBinding>(
{ layoutInflater, parent -> ItemListGroupBinding.inflate(layoutInflater, parent, false) }, { layoutInflater, parent -> ItemListGroupBinding.inflate(layoutInflater, parent, false) },
) { ) {
val listenerAdapter = object : OnListItemClickListener<Manga>, View.OnClickListener { val listenerAdapter = object : OnListItemClickListener<Manga>, View.OnClickListener {

View File

@@ -0,0 +1,15 @@
package org.koitharu.kotatsu.shelf.ui.adapter
import android.view.View
import org.koitharu.kotatsu.shelf.ui.model.ShelfSectionModel
import org.koitharu.kotatsu.list.ui.adapter.ListStateHolderListener
import org.koitharu.kotatsu.parsers.model.Manga
interface ShelfListEventListener : ListStateHolderListener {
fun onItemClick(item: Manga, section: ShelfSectionModel, view: View)
fun onItemLongClick(item: Manga, section: ShelfSectionModel, view: View): Boolean
fun onSectionClick(section: ShelfSectionModel, view: View)
}

View File

@@ -1,16 +1,16 @@
package org.koitharu.kotatsu.library.ui.config.categories package org.koitharu.kotatsu.shelf.ui.config.categories
import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.DiffUtil
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.model.FavouriteCategory import org.koitharu.kotatsu.core.model.FavouriteCategory
class LibraryCategoriesConfigAdapter( class ShelfCategoriesConfigAdapter(
listener: OnListItemClickListener<FavouriteCategory>, listener: OnListItemClickListener<FavouriteCategory>,
) : AsyncListDifferDelegationAdapter<FavouriteCategory>(DiffCallback()) { ) : AsyncListDifferDelegationAdapter<FavouriteCategory>(DiffCallback()) {
init { init {
delegatesManager.addDelegate(libraryCategoryAD(listener)) delegatesManager.addDelegate(shelfCategoryAD(listener))
} }
class DiffCallback : DiffUtil.ItemCallback<FavouriteCategory>() { class DiffCallback : DiffUtil.ItemCallback<FavouriteCategory>() {

View File

@@ -1,4 +1,4 @@
package org.koitharu.kotatsu.library.ui.config.categories package org.koitharu.kotatsu.shelf.ui.config.categories
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
@@ -15,12 +15,12 @@ import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.databinding.SheetBaseBinding import org.koitharu.kotatsu.databinding.SheetBaseBinding
@AndroidEntryPoint @AndroidEntryPoint
class LibraryCategoriesConfigSheet : class ShelfCategoriesConfigSheet :
BaseBottomSheet<SheetBaseBinding>(), BaseBottomSheet<SheetBaseBinding>(),
OnListItemClickListener<FavouriteCategory>, OnListItemClickListener<FavouriteCategory>,
View.OnClickListener { View.OnClickListener {
private val viewModel by viewModels<LibraryCategoriesConfigViewModel>() private val viewModel by viewModels<ShelfCategoriesConfigViewModel>()
override fun onInflateView(inflater: LayoutInflater, container: ViewGroup?): SheetBaseBinding { override fun onInflateView(inflater: LayoutInflater, container: ViewGroup?): SheetBaseBinding {
return SheetBaseBinding.inflate(inflater, container, false) return SheetBaseBinding.inflate(inflater, container, false)
@@ -31,7 +31,7 @@ class LibraryCategoriesConfigSheet :
binding.headerBar.toolbar.setTitle(R.string.favourites_categories) binding.headerBar.toolbar.setTitle(R.string.favourites_categories)
binding.buttonDone.isVisible = true binding.buttonDone.isVisible = true
binding.buttonDone.setOnClickListener(this) binding.buttonDone.setOnClickListener(this)
val adapter = LibraryCategoriesConfigAdapter(this) val adapter = ShelfCategoriesConfigAdapter(this)
binding.recyclerView.adapter = adapter binding.recyclerView.adapter = adapter
viewModel.content.observe(viewLifecycleOwner) { adapter.items = it } viewModel.content.observe(viewLifecycleOwner) { adapter.items = it }
@@ -47,8 +47,8 @@ class LibraryCategoriesConfigSheet :
companion object { companion object {
private const val TAG = "LibraryCategoriesConfigSheet" private const val TAG = "ShelfCategoriesConfigSheet"
fun show(fm: FragmentManager) = LibraryCategoriesConfigSheet().show(fm, TAG) fun show(fm: FragmentManager) = ShelfCategoriesConfigSheet().show(fm, TAG)
} }
} }

View File

@@ -1,4 +1,4 @@
package org.koitharu.kotatsu.library.ui.config.categories package org.koitharu.kotatsu.shelf.ui.config.categories
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
@@ -11,7 +11,7 @@ import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
@HiltViewModel @HiltViewModel
class LibraryCategoriesConfigViewModel @Inject constructor( class ShelfCategoriesConfigViewModel @Inject constructor(
private val favouritesRepository: FavouritesRepository, private val favouritesRepository: FavouritesRepository,
) : BaseViewModel() { ) : BaseViewModel() {

View File

@@ -1,4 +1,4 @@
package org.koitharu.kotatsu.library.ui.config.categories package org.koitharu.kotatsu.shelf.ui.config.categories
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.base.ui.list.AdapterDelegateClickListenerAdapter import org.koitharu.kotatsu.base.ui.list.AdapterDelegateClickListenerAdapter
@@ -6,7 +6,7 @@ import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.model.FavouriteCategory import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.databinding.ItemCategoryCheckableMultipleBinding import org.koitharu.kotatsu.databinding.ItemCategoryCheckableMultipleBinding
fun libraryCategoryAD( fun shelfCategoryAD(
listener: OnListItemClickListener<FavouriteCategory>, listener: OnListItemClickListener<FavouriteCategory>,
) = adapterDelegateViewBinding<FavouriteCategory, FavouriteCategory, ItemCategoryCheckableMultipleBinding>( ) = adapterDelegateViewBinding<FavouriteCategory, FavouriteCategory, ItemCategoryCheckableMultipleBinding>(
{ layoutInflater, parent -> ItemCategoryCheckableMultipleBinding.inflate(layoutInflater, parent, false) }, { layoutInflater, parent -> ItemCategoryCheckableMultipleBinding.inflate(layoutInflater, parent, false) },

View File

@@ -1,4 +1,4 @@
package org.koitharu.kotatsu.library.ui.config.size package org.koitharu.kotatsu.shelf.ui.config.size
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
@@ -8,17 +8,17 @@ import androidx.fragment.app.FragmentManager
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
import javax.inject.Inject
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseBottomSheet import org.koitharu.kotatsu.base.ui.BaseBottomSheet
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.databinding.SheetLibrarySizeBinding import org.koitharu.kotatsu.databinding.SheetShelfSizeBinding
import org.koitharu.kotatsu.utils.ext.setValueRounded import org.koitharu.kotatsu.utils.ext.setValueRounded
import org.koitharu.kotatsu.utils.progress.IntPercentLabelFormatter import org.koitharu.kotatsu.utils.progress.IntPercentLabelFormatter
import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class LibrarySizeBottomSheet : class ShelfSizeBottomSheet :
BaseBottomSheet<SheetLibrarySizeBinding>(), BaseBottomSheet<SheetShelfSizeBinding>(),
Slider.OnChangeListener, Slider.OnChangeListener,
View.OnClickListener { View.OnClickListener {
@@ -26,8 +26,8 @@ class LibrarySizeBottomSheet :
lateinit var settings: AppSettings lateinit var settings: AppSettings
private var labelFormatter: LabelFormatter? = null private var labelFormatter: LabelFormatter? = null
override fun onInflateView(inflater: LayoutInflater, container: ViewGroup?): SheetLibrarySizeBinding { override fun onInflateView(inflater: LayoutInflater, container: ViewGroup?): SheetShelfSizeBinding {
return SheetLibrarySizeBinding.inflate(inflater, container, false) return SheetShelfSizeBinding.inflate(inflater, container, false)
} }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
@@ -51,16 +51,17 @@ class LibrarySizeBottomSheet :
} }
override fun onClick(v: View) { override fun onClick(v: View) {
val slider = binding.sliderGrid
when (v.id) { when (v.id) {
R.id.button_small -> binding.sliderGrid.value -= binding.sliderGrid.stepSize R.id.button_small -> slider.setValueRounded(slider.value - slider.stepSize)
R.id.button_large -> binding.sliderGrid.value += binding.sliderGrid.stepSize R.id.button_large -> slider.setValueRounded(slider.value + slider.stepSize)
} }
} }
companion object { companion object {
private const val TAG = "LibrarySizeBottomSheet" private const val TAG = "ShelfSizeBottomSheet"
fun show(fm: FragmentManager) = LibrarySizeBottomSheet().show(fm, TAG) fun show(fm: FragmentManager) = ShelfSizeBottomSheet().show(fm, TAG)
} }
} }

View File

@@ -0,0 +1,144 @@
package org.koitharu.kotatsu.shelf.ui.model
import android.content.res.Resources
import androidx.annotation.StringRes
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.MangaItemModel
sealed interface ShelfSectionModel : ListModel {
val items: List<MangaItemModel>
@get:StringRes
val showAllButtonText: Int
val key: String
fun getTitle(resources: Resources): CharSequence
override fun toString(): String
class History(
override val items: List<MangaItemModel>,
override val showAllButtonText: Int,
) : ShelfSectionModel {
override val key = "history"
override fun getTitle(resources: Resources) = resources.getString(R.string.history)
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as History
if (showAllButtonText != other.showAllButtonText) return false
if (items != other.items) return false
return true
}
override fun hashCode(): Int {
var result = items.hashCode()
result = 31 * result + showAllButtonText.hashCode()
return result
}
override fun toString(): String = key
}
class Favourites(
override val items: List<MangaItemModel>,
val category: FavouriteCategory,
override val showAllButtonText: Int,
) : ShelfSectionModel {
override val key = "fav_${category.id}"
override fun getTitle(resources: Resources) = category.title
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as Favourites
if (category != other.category) return false
if (showAllButtonText != other.showAllButtonText) return false
if (items != other.items) return false
return true
}
override fun hashCode(): Int {
var result = items.hashCode()
result = 31 * result + category.hashCode()
result = 31 * result + showAllButtonText.hashCode()
return result
}
override fun toString(): String = key
}
class Updated(
override val items: List<MangaItemModel>,
override val showAllButtonText: Int,
) : ShelfSectionModel {
override val key = "upd"
override fun getTitle(resources: Resources) = resources.getString(R.string.updated)
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as Updated
if (items != other.items) return false
if (showAllButtonText != other.showAllButtonText) return false
return true
}
override fun hashCode(): Int {
var result = items.hashCode()
result = 31 * result + showAllButtonText
return result
}
override fun toString(): String = key
}
class Local(
override val items: List<MangaItemModel>,
override val showAllButtonText: Int,
) : ShelfSectionModel {
override val key = "local"
override fun getTitle(resources: Resources) = resources.getString(R.string.local_storage)
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as Local
if (items != other.items) return false
if (showAllButtonText != other.showAllButtonText) return false
return true
}
override fun hashCode(): Int {
var result = items.hashCode()
result = 31 * result + showAllButtonText
return result
}
override fun toString(): String = key
}
}

View File

@@ -12,6 +12,7 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseActivity import org.koitharu.kotatsu.base.ui.BaseActivity
import org.koitharu.kotatsu.databinding.ActivityContainerBinding import org.koitharu.kotatsu.databinding.ActivityContainerBinding
import org.koitharu.kotatsu.main.ui.owners.AppBarOwner import org.koitharu.kotatsu.main.ui.owners.AppBarOwner
import kotlin.text.Typography.dagger
@AndroidEntryPoint @AndroidEntryPoint
class SuggestionsActivity : class SuggestionsActivity :
@@ -28,6 +29,7 @@ class SuggestionsActivity :
val fm = supportFragmentManager val fm = supportFragmentManager
if (fm.findFragmentById(R.id.container) == null) { if (fm.findFragmentById(R.id.container) == null) {
fm.commit { fm.commit {
setReorderingAllowed(true)
val fragment = SuggestionsFragment.newInstance() val fragment = SuggestionsFragment.newInstance()
replace(R.id.container, fragment) replace(R.id.container, fragment)
} }

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