Compare commits

...

85 Commits
v5.0 ... v5.1.2

Author SHA1 Message Date
Koitharu
b5cd92fb5f Update parsers 2023-05-23 13:05:37 +03:00
Koitharu
08e5c148fd Limit cache max-age and action to clear cache manually 2023-05-23 09:07:56 +03:00
Koitharu
8323d399ff Fix focus changes on sync authorization screen 2023-05-23 09:07:16 +03:00
Koitharu
5108f45111 Limit lifetime of memory content cache 2023-05-23 09:06:22 +03:00
Koitharu
bf0d34e9cf Validate header value in settings 2023-05-23 09:04:57 +03:00
gallegonovato
3778a9e1d4 Translated using Weblate (Spanish)
Currently translated at 100.0% (416 of 416 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2023-05-17 18:51:56 +03:00
Макар Разин
71ecd9d8e2 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (416 of 416 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (416 of 416 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (416 of 416 strings)

Co-authored-by: Макар Разин <makarrazin14@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/be/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/uk/
Translation: Kotatsu/Strings
2023-05-17 18:51:56 +03:00
Subham Jena
7cba8d2dc7 Translated using Weblate (Odia)
Currently translated at 3.8% (16 of 416 strings)

Translated using Weblate (Odia)

Currently translated at 2.4% (10 of 415 strings)

Co-authored-by: Subham Jena <subhamjena8465@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/or/
Translation: Kotatsu/Strings
2023-05-17 18:51:56 +03:00
GpixeL
79c2927da2 Translated using Weblate (Indonesian)
Currently translated at 96.8% (402 of 415 strings)

Co-authored-by: GpixeL <gamesfire313@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/id/
Translation: Kotatsu/Strings
2023-05-17 18:51:56 +03:00
ctntt
a4a28c7342 Translated using Weblate (German)
Currently translated at 99.2% (413 of 416 strings)

Translated using Weblate (German)

Currently translated at 99.5% (413 of 415 strings)

Co-authored-by: ctntt <pavlov_mainstreamed@slmail.me>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/de/
Translation: Kotatsu/Strings
2023-05-17 18:51:56 +03:00
Koitharu
43a92bdf08 Improve sync logging 2023-05-17 17:00:42 +03:00
Koitharu
51ff1ff7b7 Fix concurrent chapters loading in reader 2023-05-17 16:17:18 +03:00
Koitharu
2e0eb5de54 Fix handling special characters in local manga filenames 2023-05-16 13:07:11 +03:00
Koitharu
4f68e7d0e6 Handle WebView unavailability 2023-05-16 07:56:05 +03:00
ctntt
c6d303980b Translated using Weblate (German)
Currently translated at 100.0% (7 of 7 strings)

Translated using Weblate (German)

Currently translated at 99.5% (412 of 414 strings)

Co-authored-by: ctntt <pavlov_mainstreamed@slmail.me>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/de/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/de/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2023-05-15 18:51:27 +03:00
Koitharu
18e6ee1453 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (414 of 414 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (414 of 414 strings)

Co-authored-by: Koitharu <nvasya95@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/be/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/uk/
Translation: Kotatsu/Strings
2023-05-15 18:51:27 +03:00
J. Lavoie
09144d7f55 Translated using Weblate (French)
Currently translated at 100.0% (414 of 414 strings)

Co-authored-by: J. Lavoie <j.lavoie@net-c.ca>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fr/
Translation: Kotatsu/Strings
2023-05-15 18:51:27 +03:00
gallegonovato
05583504ee Translated using Weblate (Spanish)
Currently translated at 100.0% (7 of 7 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (414 of 414 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/es/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2023-05-15 18:51:27 +03:00
GpixeL
8dc02967fc Translated using Weblate (Indonesian)
Currently translated at 97.1% (402 of 414 strings)

Co-authored-by: GpixeL <gamesfire313@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/id/
Translation: Kotatsu/Strings
2023-05-15 18:51:27 +03:00
Макар Разин
4c206746c9 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (414 of 414 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (414 of 414 strings)

Co-authored-by: Макар Разин <makarrazin14@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/be/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/uk/
Translation: Kotatsu/Strings
2023-05-15 18:51:27 +03:00
Subham Jena
e9b0e9f740 Translated using Weblate (Odia)
Currently translated at 2.1% (9 of 414 strings)

Translated using Weblate (Odia)

Currently translated at 85.7% (6 of 7 strings)

Added translation using Weblate (Odia)

Translated using Weblate (Odia)

Currently translated at 14.2% (1 of 7 strings)

Added translation using Weblate (Odia)

Co-authored-by: Subham Jena <subhamjena8465@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/or/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/or/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2023-05-15 18:51:27 +03:00
Koitharu
6d0cd49db3 Improve branch selection 2023-05-15 18:37:25 +03:00
Koitharu
e69964d1f5 Fix obtaining manga output 2023-05-12 15:56:50 +03:00
Koitharu
f368277d73 Fix lint warnings 2023-05-12 13:42:31 +03:00
Hosted Weblate
f3a8eb3216 Update translation files
Updated by "Remove blank strings" hook in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/
Translation: Kotatsu/Strings
2023-05-11 19:07:36 +03:00
Koitharu
f7a585ef55 Translated using Weblate (Arabic)
Currently translated at 19.0% (79 of 414 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (414 of 414 strings)

Co-authored-by: Koitharu <nvasya95@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ar/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translation: Kotatsu/Strings
2023-05-11 19:07:36 +03:00
Koitharu
96d6f8d8e6 Option to read in incognito mode instantly 2023-05-11 18:48:51 +03:00
Koitharu
67bbd3e6d3 Resources cleanup 2023-05-11 17:22:08 +03:00
Koitharu
e7afe1c802 Translated using Weblate (Russian)
Currently translated at 100.0% (461 of 461 strings)

Co-authored-by: Koitharu <nvasya95@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translation: Kotatsu/Strings
2023-05-11 17:13:41 +03:00
Abay Emes
f63beba8af Translated using Weblate (Kazakh)
Currently translated at 34.0% (153 of 450 strings)

Co-authored-by: Abay Emes <abayemes@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/kk/
Translation: Kotatsu/Strings
2023-05-11 17:13:41 +03:00
J. Lavoie
4ddeb1acce Translated using Weblate (French)
Currently translated at 100.0% (450 of 450 strings)

Translated using Weblate (German)

Currently translated at 99.5% (448 of 450 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
2023-05-11 17:13:41 +03:00
Boris
10b178aed3 Translated using Weblate (Russian)
Currently translated at 100.0% (450 of 450 strings)

Co-authored-by: Boris <no4nick@no4nick.ru>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translation: Kotatsu/Strings
2023-05-11 17:13:41 +03:00
Koitharu
514594edb0 Added translation using Weblate (Kazakh)
Co-authored-by: Koitharu <nvasya95@gmail.com>
2023-05-11 17:13:41 +03:00
gallegonovato
b2a9f7d594 Translated using Weblate (Spanish)
Currently translated at 100.0% (450 of 450 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (448 of 448 strings)

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

Translated using Weblate (Russian)

Currently translated at 100.0% (448 of 448 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (448 of 448 strings)

Co-authored-by: Макар Разин <makarrazin14@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/be/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/uk/
Translation: Kotatsu/Strings
2023-05-11 17:13:41 +03:00
ctntt
30ad67ef52 Translated using Weblate (German)
Currently translated at 98.8% (436 of 441 strings)

Co-authored-by: ctntt <pavlov_mainstreamed@slmail.me>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/de/
Translation: Kotatsu/Strings
2023-05-11 17:13:41 +03:00
Koitharu
810434fef5 Fix Referer header for non-ascii domains 2023-05-11 17:06:03 +03:00
Koitharu
26a7a7a2e8 Show suggestions on the shelf 2023-05-11 16:47:36 +03:00
Koitharu
4d8da40885 Merge branch 'feature/suggestions_v2' into devel 2023-05-11 11:47:21 +03:00
Koitharu
248bf8ed03 UI improvements 2023-05-11 11:46:45 +03:00
Koitharu
090f7a5858 Update random manga selecting 2023-05-10 20:01:15 +03:00
Koitharu
5f38b01fd1 Suggestions enable tip 2023-05-10 19:39:38 +03:00
Koitharu
2169ee7a5b Tune suggestions 2023-05-10 19:16:17 +03:00
Koitharu
d633204efa Merge branch 'devel' into feature/suggestions_v2 2023-05-10 13:49:34 +03:00
Koitharu
2b12dbd8d7 Disallow multiple sync accounts 2023-05-10 13:38:15 +03:00
Koitharu
8bfdc73072 Fix sync via https 2023-05-10 13:09:26 +03:00
Koitharu
be666b7854 Two buttons alert dialog 2023-05-10 12:14:03 +03:00
Koitharu
52655cad2c New suggestions algorithm 2023-05-09 18:35:33 +03:00
Zakhar Timoshenko
8e856211aa Use system color for notifications 2023-05-09 17:29:02 +03:00
Zakhar Timoshenko
e1f82d147c Fix ActionMode background on Android Lollipop 2023-05-09 17:28:15 +03:00
Koitharu
023605e246 Refactor skipping checkable state animation 2023-05-08 19:46:17 +03:00
Koitharu
c0544e25af Fix status bar colors 2023-05-08 19:35:06 +03:00
Koitharu
235b02870b Improve download notifications 2023-05-08 19:25:34 +03:00
Koitharu
25e7ab2d8e Improve workers notifications 2023-05-08 17:12:43 +03:00
Koitharu
2d171657dc Merge branch 'feature/downloads_worker' into devel 2023-05-08 17:00:32 +03:00
Koitharu
ac9680b5c0 Refactor downloading-related classes 2023-05-08 16:55:10 +03:00
Koitharu
42df607f52 Resume download on network becomes available 2023-05-08 16:06:43 +03:00
Koitharu
fc1755612b Option to disable automatic mirror switching 2023-05-07 19:19:57 +03:00
Koitharu
1ead369ee2 Made synchronization server address configurable 2023-05-07 19:02:52 +03:00
Koitharu
07aa04aa4d Fix incognito mode switcher state 2023-05-07 10:42:32 +03:00
Koitharu
7b8bbf9fe1 Fix incognito mode switcher state 2023-05-07 10:42:19 +03:00
Koitharu
8883e73971 Migrate to Okio somewhere 2023-05-07 10:38:50 +03:00
Koitharu
0cb1238143 Update download settings 2023-05-07 09:53:22 +03:00
Koitharu
f4628f7ab5 Merge branch 'devel' into feature/downloads_worker 2023-05-06 18:30:45 +03:00
Koitharu
3d0b69024d Fix badges offsets 2023-05-06 18:29:51 +03:00
Koitharu
12e9fb5aab Fix download error with empty largeCoverUrl 2023-05-06 17:41:15 +03:00
Koitharu
5fc08d9ecb Update parsers 2023-05-06 17:34:46 +03:00
InfinityDouki56
e7eb61e3e5 Translated using Weblate (Filipino)
Currently translated at 91.4% (398 of 435 strings)

Co-authored-by: InfinityDouki56 <ced.paltep10@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fil/
Translation: Kotatsu/Strings
2023-05-06 17:34:29 +03:00
vividly
dae3982e67 Translated using Weblate (Japanese)
Currently translated at 97.0% (422 of 435 strings)

Co-authored-by: vividly <vividly@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ja/
Translation: Kotatsu/Strings
2023-05-06 17:34:29 +03:00
Макар Разин
45b2d2bebe Translated using Weblate (Belarusian)
Currently translated at 100.0% (435 of 435 strings)

Co-authored-by: Макар Разин <makarrazin14@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/be/
Translation: Kotatsu/Strings
2023-05-06 17:34:29 +03:00
Zakhar Timoshenko
77fa34835f Fix remaining incorrect sample data 2023-05-06 17:23:28 +03:00
Zakhar Timoshenko
cee68069d6 Remove sample data 2023-05-06 17:19:41 +03:00
CakesTwix
48afc8624b Translated using Weblate (Ukrainian)
Currently translated at 100.0% (435 of 435 strings)

Co-authored-by: CakesTwix <cakestwix1@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/uk/
Translation: Kotatsu/Strings
2023-05-06 16:07:11 +03:00
Koitharu
b9b41ed491 Fix local manga list update on deletion 2023-05-06 16:05:39 +03:00
Koitharu
632b42ea86 Improve downloads list 2023-05-06 15:48:24 +03:00
Zakhar Timoshenko
427272aac1 Update Material components to 1.9.0 2023-05-05 19:19:41 +03:00
Koitharu
41ac50c76a Manage download states 2023-05-05 17:03:52 +03:00
Koitharu
f05bb20428 Use WorkManager for downloads 2023-05-01 16:09:16 +03:00
Koitharu
78f417ebe1 Fix downloading chapters with the same names 2023-05-01 12:52:35 +03:00
Koitharu
3fd6bec433 Update parsers 2023-04-28 17:24:21 +03:00
Koitharu
262e26a0cc Fix crashes 2023-04-28 17:23:29 +03:00
Koitharu
1b64c2a330 Update parsers 2023-04-28 17:23:29 +03:00
Koitharu
5ea0ecbd12 Permormance improvements 2023-04-28 17:23:29 +03:00
Dawid Jarubas
f9a1d1617e Translated using Weblate (Polish)
Currently translated at 91.4% (398 of 435 strings)

Co-authored-by: Dawid Jarubas <jarubas.dawid@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pl/
Translation: Kotatsu/Strings
2023-04-28 17:22:58 +03:00
gallegonovato
10d8365fc1 Translated using Weblate (Spanish)
Currently translated at 100.0% (435 of 435 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2023-04-28 17:22:58 +03:00
342 changed files with 9845 additions and 8800 deletions

View File

@@ -15,8 +15,8 @@ android {
applicationId 'org.koitharu.kotatsu' applicationId 'org.koitharu.kotatsu'
minSdkVersion 21 minSdkVersion 21
targetSdkVersion 33 targetSdkVersion 33
versionCode 538 versionCode 546
versionName '5.0' versionName '5.1.2'
generatedDensities = [] generatedDensities = []
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
@@ -78,15 +78,15 @@ afterEvaluate {
} }
dependencies { dependencies {
//noinspection GradleDependency //noinspection GradleDependency
implementation('com.github.KotatsuApp:kotatsu-parsers:306d46ea93') { implementation('com.github.KotatsuApp:kotatsu-parsers:ebcc6391d6') {
exclude group: 'org.json', module: 'json' exclude group: 'org.json', module: 'json'
} }
implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.8.21' implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.8.21'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4' implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.1'
implementation 'androidx.appcompat:appcompat:1.6.1' implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'androidx.core:core-ktx:1.10.0' implementation 'androidx.core:core-ktx:1.10.1'
implementation 'androidx.activity:activity-ktx:1.7.1' implementation 'androidx.activity:activity-ktx:1.7.1'
implementation 'androidx.fragment:fragment-ktx:1.5.7' implementation 'androidx.fragment:fragment-ktx:1.5.7'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.1' implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.1'
@@ -98,12 +98,19 @@ dependencies {
implementation 'androidx.recyclerview:recyclerview:1.3.0' implementation 'androidx.recyclerview:recyclerview:1.3.0'
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.8.1'
implementation 'androidx.biometric:biometric-ktx:1.2.0-alpha05' implementation 'androidx.biometric:biometric-ktx:1.2.0-alpha05'
implementation 'com.google.android.material:material:1.8.0' implementation 'com.google.android.material:material:1.9.0'
//noinspection LifecycleAnnotationProcessorWithJava8 //noinspection LifecycleAnnotationProcessorWithJava8
kapt 'androidx.lifecycle:lifecycle-compiler:2.6.1' kapt 'androidx.lifecycle:lifecycle-compiler:2.6.1'
implementation 'androidx.work:work-runtime-ktx:2.8.1'
//noinspection GradleDependency
implementation('com.google.guava:guava:31.1-android') {
exclude group: 'com.google.guava', module: 'failureaccess'
exclude group: 'org.checkerframework', module: 'checker-qual'
exclude group: 'com.google.j2objc', module: 'j2objc-annotations'
}
implementation 'androidx.room:room-runtime:2.5.1' implementation 'androidx.room:room-runtime:2.5.1'
implementation 'androidx.room:room-ktx:2.5.1' implementation 'androidx.room:room-ktx:2.5.1'
kapt 'androidx.room:room-compiler:2.5.1' kapt 'androidx.room:room-compiler:2.5.1'
@@ -115,8 +122,8 @@ 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.45' implementation 'com.google.dagger:hilt-android:2.46.1'
kapt 'com.google.dagger:hilt-compiler:2.45' kapt 'com.google.dagger:hilt-compiler:2.46.1'
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'
@@ -133,18 +140,18 @@ dependencies {
testImplementation 'junit:junit:4.13.2' testImplementation 'junit:junit:4.13.2'
testImplementation 'org.json:json:20230227' testImplementation 'org.json:json:20230227'
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.4' testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.1'
androidTestImplementation 'androidx.test:runner:1.5.2' androidTestImplementation 'androidx.test:runner:1.5.2'
androidTestImplementation 'androidx.test:rules:1.5.0' androidTestImplementation 'androidx.test:rules:1.5.0'
androidTestImplementation 'androidx.test:core-ktx:1.5.0' androidTestImplementation 'androidx.test:core-ktx:1.5.0'
androidTestImplementation 'androidx.test.ext:junit-ktx:1.1.5' androidTestImplementation 'androidx.test.ext:junit-ktx:1.1.5'
androidTestImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.4' androidTestImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.1'
androidTestImplementation 'androidx.room:room-testing:2.5.1' androidTestImplementation 'androidx.room:room-testing:2.5.1'
androidTestImplementation 'com.squareup.moshi:moshi-kotlin:1.14.0' androidTestImplementation 'com.squareup.moshi:moshi-kotlin:1.15.0'
androidTestImplementation 'com.google.dagger:hilt-android-testing:2.45' androidTestImplementation 'com.google.dagger:hilt-android-testing:2.46.1'
kaptAndroidTest 'com.google.dagger:hilt-android-compiler:2.45' kaptAndroidTest 'com.google.dagger:hilt-android-compiler:2.46.1'
} }

View File

@@ -1,4 +1,5 @@
-optimizationpasses 8 -optimizationpasses 8
-dontobfuscate
-assumenosideeffects class kotlin.jvm.internal.Intrinsics { -assumenosideeffects class kotlin.jvm.internal.Intrinsics {
public static void checkExpressionValueIsNotNull(...); public static void checkExpressionValueIsNotNull(...);
public static void checkNotNullExpressionValue(...); public static void checkNotNullExpressionValue(...);

Binary file not shown.

Before

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 439 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 495 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 791 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 844 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 386 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 375 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 398 KiB

View File

@@ -1,10 +0,0 @@
Slice of Life, Mystery
Slice of Life, Mystery
Psychological, Romance, Comedy, Slice of Life, Supernatural
Sci-Fi, Comedy
Reincarnation, Sci-Fi, Historical, Psychological, Drama, Slice of Life, Supernatural, Mystery
Reincarnation, Sci-Fi, Historical, Psychological, Drama, Slice of Life, Supernatural, Mystery
Reincarnation, Sci-Fi, Historical, Psychological, Drama, Slice of Life, Supernatural, Mystery
Reincarnation, Sci-Fi, Historical, Psychological, Drama, Slice of Life, Supernatural, Mystery
Adventure, Slice of Life, Mystery
Adventure, Slice of Life, Mystery

View File

@@ -1,10 +0,0 @@
Forget-me-not Vol. 1
Forget-me-not Vol. 2
La Pomme Prisoinniere
Momo Kanchou no Himitsu Kichi
Omoide Emanon
Sasurai Emanon Vol. 1
Sasurai Emanon Vol. 2
Sasurai Emanon Vol. 3
Wandering Island Vol. 1
Wandering Island Vol. 2

View File

@@ -5,8 +5,6 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry import androidx.test.platform.app.InstrumentationRegistry
import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest import dagger.hilt.android.testing.HiltAndroidTest
import java.io.File
import javax.inject.Inject
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Assert.* import org.junit.Assert.*
@@ -20,6 +18,8 @@ import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.entity.toMangaTags import org.koitharu.kotatsu.core.db.entity.toMangaTags
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 java.io.File
import javax.inject.Inject
@HiltAndroidTest @HiltAndroidTest
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
@@ -52,6 +52,7 @@ class AppBackupAgentTest {
title = SampleData.favouriteCategory.title, title = SampleData.favouriteCategory.title,
sortOrder = SampleData.favouriteCategory.order, sortOrder = SampleData.favouriteCategory.order,
isTrackerEnabled = SampleData.favouriteCategory.isTrackingEnabled, isTrackerEnabled = SampleData.favouriteCategory.isTrackingEnabled,
isVisibleOnShelf = SampleData.favouriteCategory.isVisibleInLibrary,
) )
favouritesRepository.addToCategory(categoryId = category.id, mangas = listOf(SampleData.manga)) favouritesRepository.addToCategory(categoryId = category.id, mangas = listOf(SampleData.manga))
historyRepository.addOrUpdate( historyRepository.addOrUpdate(

View File

@@ -95,6 +95,7 @@
<data android:scheme="kotatsu" /> <data android:scheme="kotatsu" />
<data android:host="about" /> <data android:host="about" />
<data android:host="sync-settings" />
</intent-filter> </intent-filter>
</activity> </activity>
<activity <activity
@@ -128,7 +129,7 @@
android:name="org.koitharu.kotatsu.settings.protect.ProtectSetupActivity" android:name="org.koitharu.kotatsu.settings.protect.ProtectSetupActivity"
android:windowSoftInputMode="adjustResize" /> android:windowSoftInputMode="adjustResize" />
<activity <activity
android:name="org.koitharu.kotatsu.download.ui.DownloadsActivity" android:name="org.koitharu.kotatsu.download.ui.list.DownloadsActivity"
android:label="@string/downloads" android:label="@string/downloads"
android:launchMode="singleTop" /> android:launchMode="singleTop" />
<activity android:name="org.koitharu.kotatsu.image.ui.ImageActivity" /> <activity android:name="org.koitharu.kotatsu.image.ui.ImageActivity" />
@@ -162,10 +163,6 @@
</activity> </activity>
<service
android:name="org.koitharu.kotatsu.download.ui.service.DownloadService"
android:foregroundServiceType="dataSync"
android:stopWithTask="false" />
<service android:name="org.koitharu.kotatsu.local.ui.LocalChaptersRemoveService" /> <service android:name="org.koitharu.kotatsu.local.ui.LocalChaptersRemoveService" />
<service <service
android:name="org.koitharu.kotatsu.widget.shelf.ShelfWidgetService" android:name="org.koitharu.kotatsu.widget.shelf.ShelfWidgetService"
@@ -240,7 +237,13 @@
<provider <provider
android:name="androidx.startup.InitializationProvider" android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup" android:authorities="${applicationId}.androidx-startup"
tools:node="remove" /> android:exported="false"
tools:node="remove">
<meta-data
android:name="androidx.work.WorkManagerInitializer"
android:value="androidx.startup"
tools:node="remove" />
</provider>
<receiver <receiver
android:name="org.koitharu.kotatsu.widget.shelf.ShelfWidgetProvider" android:name="org.koitharu.kotatsu.widget.shelf.ShelfWidgetProvider"

View File

@@ -24,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.reader.domain.PageLoader import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.utils.WorkServiceStopHelper
import org.koitharu.kotatsu.utils.ext.processLifecycleScope import org.koitharu.kotatsu.utils.ext.processLifecycleScope
import javax.inject.Inject import javax.inject.Inject
@@ -56,6 +57,7 @@ class KotatsuApp : Application(), Configuration.Provider {
processLifecycleScope.launch(Dispatchers.Default) { processLifecycleScope.launch(Dispatchers.Default) {
setupDatabaseObservers() setupDatabaseObservers()
} }
WorkServiceStopHelper(applicationContext).setup()
} }
override fun attachBaseContext(base: Context?) { override fun attachBaseContext(base: Context?) {

View File

@@ -2,6 +2,8 @@ package org.koitharu.kotatsu.base.ui
import android.content.Intent import android.content.Intent
import android.content.res.Configuration import android.content.res.Configuration
import android.graphics.Color
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.view.KeyEvent import android.view.KeyEvent
import android.view.MenuItem import android.view.MenuItem
@@ -48,6 +50,8 @@ abstract class BaseActivity<B : ViewBinding> :
@JvmField @JvmField
val actionModeDelegate = ActionModeDelegate() val actionModeDelegate = ActionModeDelegate()
private var defaultStatusBarColor = Color.TRANSPARENT
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
val settings = EntryPointAccessors.fromApplication(this, BaseActivityEntryPoint::class.java).settings val settings = EntryPointAccessors.fromApplication(this, BaseActivityEntryPoint::class.java).settings
isAmoledTheme = settings.isAmoledTheme isAmoledTheme = settings.isAmoledTheme
@@ -119,10 +123,14 @@ abstract class BaseActivity<B : ViewBinding> :
override fun onSupportActionModeStarted(mode: ActionMode) { override fun onSupportActionModeStarted(mode: ActionMode) {
super.onSupportActionModeStarted(mode) super.onSupportActionModeStarted(mode)
actionModeDelegate.onSupportActionModeStarted(mode) actionModeDelegate.onSupportActionModeStarted(mode)
val actionModeColor = ColorUtils.compositeColors( val actionModeColor = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
ContextCompat.getColor(this, com.google.android.material.R.color.m3_appbar_overlay_color), ColorUtils.compositeColors(
getThemeColor(com.google.android.material.R.attr.colorSurface), ContextCompat.getColor(this, com.google.android.material.R.color.m3_appbar_overlay_color),
) getThemeColor(com.google.android.material.R.attr.colorSurface),
)
} else {
ContextCompat.getColor(this, R.color.kotatsu_secondaryContainer)
}
val insets = ViewCompat.getRootWindowInsets(binding.root) val insets = ViewCompat.getRootWindowInsets(binding.root)
?.getInsets(WindowInsetsCompat.Type.systemBars()) ?: return ?.getInsets(WindowInsetsCompat.Type.systemBars()) ?: return
findViewById<ActionBarContextView?>(androidx.appcompat.R.id.action_mode_bar).apply { findViewById<ActionBarContextView?>(androidx.appcompat.R.id.action_mode_bar).apply {
@@ -131,6 +139,7 @@ abstract class BaseActivity<B : ViewBinding> :
topMargin = insets.top topMargin = insets.top
} }
} }
defaultStatusBarColor = window.statusBarColor
window.statusBarColor = actionModeColor window.statusBarColor = actionModeColor
} }
@@ -138,7 +147,7 @@ abstract class BaseActivity<B : ViewBinding> :
override fun onSupportActionModeFinished(mode: ActionMode) { override fun onSupportActionModeFinished(mode: ActionMode) {
super.onSupportActionModeFinished(mode) super.onSupportActionModeFinished(mode)
actionModeDelegate.onSupportActionModeFinished(mode) actionModeDelegate.onSupportActionModeFinished(mode)
window.statusBarColor = getThemeColor(android.R.attr.statusBarColor) window.statusBarColor = defaultStatusBarColor
} }
private fun putDataToExtras(intent: Intent?) { private fun putDataToExtras(intent: Intent?) {

View File

@@ -0,0 +1,93 @@
package org.koitharu.kotatsu.base.ui.dialog
import android.content.Context
import android.content.DialogInterface
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.appcompat.app.AlertDialog
import androidx.core.view.updatePadding
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.hannesdorfmann.adapterdelegates4.AdapterDelegate
import com.hannesdorfmann.adapterdelegates4.AdapterDelegatesManager
import com.hannesdorfmann.adapterdelegates4.ListDelegationAdapter
import org.koitharu.kotatsu.R
class RecyclerViewAlertDialog private constructor(
private val delegate: AlertDialog
) : DialogInterface by delegate {
fun show() = delegate.show()
class Builder<T>(context: Context) {
private val recyclerView = RecyclerView(context)
private val delegatesManager = AdapterDelegatesManager<List<T>>()
private var items: List<T>? = null
private val delegate = MaterialAlertDialogBuilder(context)
.setView(recyclerView)
init {
recyclerView.layoutManager = LinearLayoutManager(context)
recyclerView.updatePadding(
top = context.resources.getDimensionPixelOffset(R.dimen.list_spacing),
)
recyclerView.clipToPadding = false
}
fun setTitle(@StringRes titleResId: Int): Builder<T> {
delegate.setTitle(titleResId)
return this
}
fun setTitle(title: CharSequence): Builder<T> {
delegate.setTitle(title)
return this
}
fun setIcon(@DrawableRes iconId: Int): Builder<T> {
delegate.setIcon(iconId)
return this
}
fun setPositiveButton(
@StringRes textId: Int,
listener: DialogInterface.OnClickListener,
): Builder<T> {
delegate.setPositiveButton(textId, listener)
return this
}
fun setNegativeButton(
@StringRes textId: Int,
listener: DialogInterface.OnClickListener? = null
): Builder<T> {
delegate.setNegativeButton(textId, listener)
return this
}
fun setCancelable(isCancelable: Boolean): Builder<T> {
delegate.setCancelable(isCancelable)
return this
}
fun addAdapterDelegate(subject: AdapterDelegate<List<T>>): Builder<T> {
delegatesManager.addDelegate(subject)
return this
}
fun setItems(list: List<T>): Builder<T> {
items = list
return this
}
fun create(): RecyclerViewAlertDialog {
recyclerView.adapter = ListDelegationAdapter(delegatesManager).also {
it.items = items
}
return RecyclerViewAlertDialog(delegate.create())
}
}
}

View File

@@ -0,0 +1,79 @@
package org.koitharu.kotatsu.base.ui.dialog
import android.content.Context
import android.content.DialogInterface
import android.view.LayoutInflater
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.appcompat.app.AlertDialog
import androidx.core.view.isVisible
import com.google.android.material.button.MaterialButton
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.koitharu.kotatsu.databinding.DialogTwoButtonsBinding
class TwoButtonsAlertDialog private constructor(
private val delegate: AlertDialog
) : DialogInterface by delegate {
fun show() = delegate.show()
class Builder(context: Context) {
private val binding = DialogTwoButtonsBinding.inflate(LayoutInflater.from(context))
private val delegate = MaterialAlertDialogBuilder(context)
.setView(binding.root)
fun setTitle(@StringRes titleResId: Int): Builder {
binding.title.setText(titleResId)
return this
}
fun setTitle(title: CharSequence): Builder {
binding.title.text = title
return this
}
fun setIcon(@DrawableRes iconId: Int): Builder {
binding.icon.setImageResource(iconId)
return this
}
fun setPositiveButton(
@StringRes textId: Int,
listener: DialogInterface.OnClickListener,
): Builder {
initButton(binding.button1, DialogInterface.BUTTON_POSITIVE, textId, listener)
return this
}
fun setNegativeButton(
@StringRes textId: Int,
listener: DialogInterface.OnClickListener? = null
): Builder {
initButton(binding.button2, DialogInterface.BUTTON_NEGATIVE, textId, listener)
return this
}
fun create(): TwoButtonsAlertDialog {
val dialog = delegate.create()
binding.root.tag = dialog
return TwoButtonsAlertDialog(dialog)
}
private fun initButton(
button: MaterialButton,
which: Int,
@StringRes textId: Int,
listener: DialogInterface.OnClickListener?,
) {
button.setText(textId)
button.isVisible = true
button.setOnClickListener {
val dialog = binding.root.tag as DialogInterface
listener?.onClick(dialog, which)
dialog.dismiss()
}
}
}
}

View File

@@ -8,19 +8,22 @@ abstract class BoundsScrollListener(private val offsetTop: Int, private val offs
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(recyclerView, dx, dy) super.onScrolled(recyclerView, dx, dy)
if (recyclerView.hasPendingAdapterUpdates()) {
return
}
val layoutManager = (recyclerView.layoutManager as? LinearLayoutManager) ?: return val layoutManager = (recyclerView.layoutManager as? LinearLayoutManager) ?: return
val firstVisibleItemPosition = layoutManager.findFirstVisibleItemPosition() val firstVisibleItemPosition = layoutManager.findFirstVisibleItemPosition()
if (firstVisibleItemPosition == RecyclerView.NO_POSITION) { if (firstVisibleItemPosition == RecyclerView.NO_POSITION) {
return return
} }
if (firstVisibleItemPosition <= offsetTop) {
onScrolledToStart(recyclerView)
}
val visibleItemCount = layoutManager.childCount val visibleItemCount = layoutManager.childCount
val totalItemCount = layoutManager.itemCount val totalItemCount = layoutManager.itemCount
if (visibleItemCount + firstVisibleItemPosition >= totalItemCount - offsetBottom) { if (visibleItemCount + firstVisibleItemPosition >= totalItemCount - offsetBottom) {
onScrolledToEnd(recyclerView) onScrolledToEnd(recyclerView)
} }
if (firstVisibleItemPosition <= offsetTop) {
onScrolledToStart(recyclerView)
}
} }
abstract fun onScrolledToStart(recyclerView: RecyclerView) abstract fun onScrolledToStart(recyclerView: RecyclerView)

View File

@@ -2,9 +2,9 @@ package org.koitharu.kotatsu.base.ui.list
import android.view.View import android.view.View
interface OnListItemClickListener<I> { fun interface OnListItemClickListener<I> {
fun onItemClick(item: I, view: View) fun onItemClick(item: I, view: View)
fun onItemLongClick(item: I, view: View) = false fun onItemLongClick(item: I, view: View) = false
} }

View File

@@ -0,0 +1,30 @@
package org.koitharu.kotatsu.base.ui.list
import androidx.recyclerview.widget.RecyclerView
class ScrollListenerInvalidationObserver(
private val recyclerView: RecyclerView,
private val scrollListener: RecyclerView.OnScrollListener,
) : RecyclerView.AdapterDataObserver() {
override fun onChanged() {
super.onChanged()
invalidateScroll()
}
override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
super.onItemRangeInserted(positionStart, itemCount)
invalidateScroll()
}
override fun onItemRangeRemoved(positionStart: Int, itemCount: Int) {
super.onItemRangeRemoved(positionStart, itemCount)
invalidateScroll()
}
private fun invalidateScroll() {
recyclerView.post {
scrollListener.onScrolled(recyclerView, 0, 0)
}
}
}

View File

@@ -5,8 +5,6 @@ import androidx.lifecycle.Observer
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
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.main.ui.owners.BottomNavOwner
import org.koitharu.kotatsu.utils.ext.findActivity
class ReversibleActionObserver( class ReversibleActionObserver(
private val snackbarHost: View, private val snackbarHost: View,
@@ -22,9 +20,6 @@ class ReversibleActionObserver(
if (handle != null) { if (handle != null) {
snackbar.setAction(R.string.undo) { handle.reverseAsync() } snackbar.setAction(R.string.undo) { handle.reverseAsync() }
} }
(snackbarHost.context.findActivity() as? BottomNavOwner)?.let {
snackbar.anchorView = it.bottomNav
}
snackbar.show() snackbar.show()
} }
} }

View File

@@ -10,9 +10,7 @@ import com.google.android.material.floatingactionbutton.ExtendedFloatingActionBu
open class ShrinkOnScrollBehavior : Behavior<ExtendedFloatingActionButton> { open class ShrinkOnScrollBehavior : Behavior<ExtendedFloatingActionButton> {
@Suppress("unused")
constructor() : super() constructor() : super()
@Suppress("unused")
constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs) constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs)
override fun onStartNestedScroll( override fun onStartNestedScroll(

View File

@@ -48,7 +48,7 @@ class BookmarksViewModel @Inject constructor(
fun removeBookmarks(ids: Map<Manga, Set<Long>>) { fun removeBookmarks(ids: Map<Manga, Set<Long>>) {
launchJob(Dispatchers.Default) { launchJob(Dispatchers.Default) {
val handle = repository.removeBookmarks(ids) val handle = repository.removeBookmarks(ids)
onActionDone.postCall(ReversibleAction(R.string.bookmarks_removed, handle)) onActionDone.emitCall(ReversibleAction(R.string.bookmarks_removed, handle))
} }
} }
} }

View File

@@ -15,6 +15,7 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseActivity import org.koitharu.kotatsu.base.ui.BaseActivity
import org.koitharu.kotatsu.core.network.CommonHeadersInterceptor import org.koitharu.kotatsu.core.network.CommonHeadersInterceptor
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
import org.koitharu.kotatsu.utils.ext.catchingWebViewUnavailability
import com.google.android.material.R as materialR import com.google.android.material.R as materialR
@SuppressLint("SetJavaScriptEnabled") @SuppressLint("SetJavaScriptEnabled")
@@ -24,7 +25,9 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(ActivityBrowserBinding.inflate(layoutInflater)) if (!catchingWebViewUnavailability { setContentView(ActivityBrowserBinding.inflate(layoutInflater)) }) {
return
}
supportActionBar?.run { supportActionBar?.run {
setDisplayHomeAsUpEnabled(true) setDisplayHomeAsUpEnabled(true)
setHomeAsUpIndicator(materialR.drawable.abc_ic_clear_material) setHomeAsUpIndicator(materialR.drawable.abc_ic_clear_material)

View File

@@ -20,6 +20,10 @@ 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 kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import okhttp3.Cache
import okhttp3.CookieJar import okhttp3.CookieJar
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.BuildConfig
@@ -40,6 +44,8 @@ import org.koitharu.kotatsu.core.parser.favicon.FaviconFetcher
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.local.data.CacheDir import org.koitharu.kotatsu.local.data.CacheDir
import org.koitharu.kotatsu.local.data.CbzFetcher import org.koitharu.kotatsu.local.data.CbzFetcher
import org.koitharu.kotatsu.local.data.LocalManga
import org.koitharu.kotatsu.local.data.LocalStorageChanges
import org.koitharu.kotatsu.local.data.LocalStorageManager import org.koitharu.kotatsu.local.data.LocalStorageManager
import org.koitharu.kotatsu.main.ui.protect.AppProtectHelper import org.koitharu.kotatsu.main.ui.protect.AppProtectHelper
import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.MangaLoaderContext
@@ -83,14 +89,19 @@ interface AppModule {
@Provides @Provides
@Singleton @Singleton
fun provideOkHttpClient( fun provideHttpCache(
localStorageManager: LocalStorageManager, localStorageManager: LocalStorageManager,
): Cache = localStorageManager.createHttpCache()
@Provides
@Singleton
fun provideOkHttpClient(
cache: Cache,
commonHeadersInterceptor: CommonHeadersInterceptor, commonHeadersInterceptor: CommonHeadersInterceptor,
mirrorSwitchInterceptor: MirrorSwitchInterceptor, mirrorSwitchInterceptor: MirrorSwitchInterceptor,
cookieJar: CookieJar, cookieJar: CookieJar,
settings: AppSettings, settings: AppSettings,
): OkHttpClient { ): OkHttpClient {
val cache = localStorageManager.createHttpCache()
return OkHttpClient.Builder().apply { return OkHttpClient.Builder().apply {
connectTimeout(20, TimeUnit.SECONDS) connectTimeout(20, TimeUnit.SECONDS)
readTimeout(60, TimeUnit.SECONDS) readTimeout(60, TimeUnit.SECONDS)
@@ -101,6 +112,7 @@ interface AppModule {
bypassSSLErrors() bypassSSLErrors()
} }
cache(cache) cache(cache)
addNetworkInterceptor(CacheLimitInterceptor())
addInterceptor(GZipInterceptor()) addInterceptor(GZipInterceptor())
addInterceptor(commonHeadersInterceptor) addInterceptor(commonHeadersInterceptor)
addInterceptor(CloudFlareInterceptor()) addInterceptor(CloudFlareInterceptor())
@@ -205,5 +217,16 @@ interface AppModule {
MemoryContentCache(application) MemoryContentCache(application)
} }
} }
@Provides
@Singleton
@LocalStorageChanges
fun provideMutableLocalStorageChangesFlow(): MutableSharedFlow<LocalManga?> = MutableSharedFlow()
@Provides
@LocalStorageChanges
fun provideLocalStorageChangesFlow(
@LocalStorageChanges flow: MutableSharedFlow<LocalManga?>,
): SharedFlow<LocalManga?> = flow.asSharedFlow()
} }
} }

View File

@@ -1,5 +0,0 @@
package org.koitharu.kotatsu.core.cache
import androidx.collection.LruCache
class DeferredLruCache<T>(maxSize: Int) : LruCache<ContentCache.Key, SafeDeferred<T>>(maxSize)

View File

@@ -0,0 +1,33 @@
package org.koitharu.kotatsu.core.cache
import androidx.collection.LruCache
import java.util.concurrent.TimeUnit
class ExpiringLruCache<T>(
val maxSize: Int,
private val lifetime: Long,
private val timeUnit: TimeUnit,
) {
private val cache = LruCache<ContentCache.Key, ExpiringValue<T>>(maxSize)
operator fun get(key: ContentCache.Key): T? {
val value = cache.get(key) ?: return null
if (value.isExpired) {
cache.remove(key)
}
return value.get()
}
operator fun set(key: ContentCache.Key, value: T) {
cache.put(key, ExpiringValue(value, lifetime, timeUnit))
}
fun clear() {
cache.evictAll()
}
fun trimToSize(size: Int) {
cache.trimToSize(size)
}
}

View File

@@ -0,0 +1,34 @@
package org.koitharu.kotatsu.core.cache
import android.os.SystemClock
import java.util.concurrent.TimeUnit
class ExpiringValue<T>(
private val value: T,
lifetime: Long,
timeUnit: TimeUnit,
) {
private val expiresAt = SystemClock.elapsedRealtime() + timeUnit.toMillis(lifetime)
val isExpired: Boolean
get() = SystemClock.elapsedRealtime() >= expiresAt
fun get(): T? = if (isExpired) null else value
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as ExpiringValue<*>
if (value != other.value) return false
return expiresAt == other.expiresAt
}
override fun hashCode(): Int {
var result = value?.hashCode() ?: 0
result = 31 * result + expiresAt.hashCode()
return result
}
}

View File

@@ -6,6 +6,7 @@ import android.content.res.Configuration
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import java.util.concurrent.TimeUnit
class MemoryContentCache(application: Application) : ContentCache, ComponentCallbacks2 { class MemoryContentCache(application: Application) : ContentCache, ComponentCallbacks2 {
@@ -13,8 +14,8 @@ class MemoryContentCache(application: Application) : ContentCache, ComponentCall
application.registerComponentCallbacks(this) application.registerComponentCallbacks(this)
} }
private val detailsCache = DeferredLruCache<Manga>(4) private val detailsCache = ExpiringLruCache<SafeDeferred<Manga>>(4, 5, TimeUnit.MINUTES)
private val pagesCache = DeferredLruCache<List<MangaPage>>(4) private val pagesCache = ExpiringLruCache<SafeDeferred<List<MangaPage>>>(4, 10, TimeUnit.MINUTES)
override val isCachingEnabled: Boolean = true override val isCachingEnabled: Boolean = true
@@ -23,7 +24,7 @@ class MemoryContentCache(application: Application) : ContentCache, ComponentCall
} }
override fun putDetails(source: MangaSource, url: String, details: SafeDeferred<Manga>) { override fun putDetails(source: MangaSource, url: String, details: SafeDeferred<Manga>) {
detailsCache.put(ContentCache.Key(source, url), details) detailsCache[ContentCache.Key(source, url)] = details
} }
override suspend fun getPages(source: MangaSource, url: String): List<MangaPage>? { override suspend fun getPages(source: MangaSource, url: String): List<MangaPage>? {
@@ -31,7 +32,7 @@ class MemoryContentCache(application: Application) : ContentCache, ComponentCall
} }
override fun putPages(source: MangaSource, url: String, pages: SafeDeferred<List<MangaPage>>) { override fun putPages(source: MangaSource, url: String, pages: SafeDeferred<List<MangaPage>>) {
pagesCache.put(ContentCache.Key(source, url), pages) pagesCache[ContentCache.Key(source, url)] = pages
} }
override fun onConfigurationChanged(newConfig: Configuration) = Unit override fun onConfigurationChanged(newConfig: Configuration) = Unit
@@ -43,17 +44,17 @@ class MemoryContentCache(application: Application) : ContentCache, ComponentCall
trimCache(pagesCache, level) trimCache(pagesCache, level)
} }
private fun trimCache(cache: DeferredLruCache<*>, level: Int) { private fun trimCache(cache: ExpiringLruCache<*>, level: Int) {
when (level) { when (level) {
ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL, ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL,
ComponentCallbacks2.TRIM_MEMORY_COMPLETE, ComponentCallbacks2.TRIM_MEMORY_COMPLETE,
ComponentCallbacks2.TRIM_MEMORY_MODERATE -> cache.evictAll() ComponentCallbacks2.TRIM_MEMORY_MODERATE -> cache.clear()
ComponentCallbacks2.TRIM_MEMORY_UI_HIDDEN, ComponentCallbacks2.TRIM_MEMORY_UI_HIDDEN,
ComponentCallbacks2.TRIM_MEMORY_RUNNING_LOW, ComponentCallbacks2.TRIM_MEMORY_RUNNING_LOW,
ComponentCallbacks2.TRIM_MEMORY_BACKGROUND -> cache.trimToSize(1) ComponentCallbacks2.TRIM_MEMORY_BACKGROUND -> cache.trimToSize(1)
else -> cache.trimToSize(cache.maxSize() / 2) else -> cache.trimToSize(cache.maxSize / 2)
} }
} }
} }

View File

@@ -1,7 +1,10 @@
package org.koitharu.kotatsu.core.db.entity package org.koitharu.kotatsu.core.db.entity
import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.parsers.model.* import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.util.mapToSet import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.parsers.util.toTitleCase import org.koitharu.kotatsu.parsers.util.toTitleCase
import org.koitharu.kotatsu.utils.ext.longHashCode import org.koitharu.kotatsu.utils.ext.longHashCode
@@ -66,7 +69,6 @@ fun SortOrder(name: String, fallback: SortOrder): SortOrder = runCatching {
SortOrder.valueOf(name) SortOrder.valueOf(name)
}.getOrDefault(fallback) }.getOrDefault(fallback)
@Suppress("FunctionName")
fun MangaState(name: String): MangaState? = runCatching { fun MangaState(name: String): MangaState? = runCatching {
MangaState.valueOf(name) MangaState.valueOf(name)
}.getOrNull() }.getOrNull()

View File

@@ -5,5 +5,5 @@ import okio.IOException
class CloudFlareProtectedException( class CloudFlareProtectedException(
val url: String, val url: String,
val headers: Headers, @Transient val headers: Headers,
) : IOException("Protected by CloudFlare") ) : IOException("Protected by CloudFlare")

View File

@@ -1,3 +1,3 @@
package org.koitharu.kotatsu.core.exceptions package org.koitharu.kotatsu.core.exceptions
class WrongPasswordException : SecurityException() class WrongPasswordException : IllegalArgumentException()

View File

@@ -4,12 +4,15 @@ import android.content.Context
import androidx.annotation.WorkerThread import androidx.annotation.WorkerThread
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.runInterruptible 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 kotlinx.coroutines.withContext
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
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
@@ -82,6 +85,15 @@ class FileLogger(
flushImpl() flushImpl()
} }
@WorkerThread
fun flushBlocking() {
if (!isEnabled) {
return
}
runBlockingSafe { flushJob?.cancelAndJoin() }
runBlockingSafe { flushImpl() }
}
private fun postFlush() { private fun postFlush() {
if (flushJob?.isActive == true) { if (flushJob?.isActive == true) {
return return
@@ -96,10 +108,10 @@ class FileLogger(
} }
} }
private suspend fun flushImpl() { private suspend fun flushImpl() = withContext(NonCancellable) {
mutex.withLock { mutex.withLock {
if (buffer.isEmpty()) { if (buffer.isEmpty()) {
return return@withContext
} }
runInterruptible(Dispatchers.IO) { runInterruptible(Dispatchers.IO) {
if (file.length() > MAX_SIZE_BYTES) { if (file.length() > MAX_SIZE_BYTES) {
@@ -131,4 +143,9 @@ class FileLogger(
} }
bakFile.delete() bakFile.delete()
} }
private inline fun runBlockingSafe(crossinline block: suspend () -> Unit) = try {
runBlocking(NonCancellable) { block() }
} catch (_: InterruptedException) {
}
} }

View File

@@ -3,12 +3,14 @@ package org.koitharu.kotatsu.core.model
import androidx.core.os.LocaleListCompat import androidx.core.os.LocaleListCompat
import org.koitharu.kotatsu.details.ui.model.ChapterListItem import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.util.mapToSet import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.parsers.util.toTitleCase
import org.koitharu.kotatsu.utils.ext.iterator import org.koitharu.kotatsu.utils.ext.iterator
fun Collection<Manga>.ids() = mapToSet { it.id } fun Collection<Manga>.ids() = mapToSet { it.id }
fun Collection<Manga>.distinctById() = distinctBy { it.id }
fun Collection<ChapterListItem>.countChaptersByBranch(): Int { fun Collection<ChapterListItem>.countChaptersByBranch(): Int {
if (size <= 1) { if (size <= 1) {
return size return size
@@ -33,15 +35,22 @@ fun Manga.getPreferredBranch(history: MangaHistory?): String? {
} }
} }
val groups = ch.groupBy { it.branch } val groups = ch.groupBy { it.branch }
if (groups.size == 1) {
return groups.keys.first()
}
val candidates = HashMap<String?, List<MangaChapter>>(groups.size)
for (locale in LocaleListCompat.getAdjustedDefault()) { for (locale in LocaleListCompat.getAdjustedDefault()) {
var language = locale.getDisplayLanguage(locale).toTitleCase(locale) val displayLanguage = locale.getDisplayLanguage(locale)
if (groups.containsKey(language)) { val displayName = locale.getDisplayName(locale)
return language for (branch in groups.keys) {
} if (branch != null && (
language = locale.getDisplayName(locale).toTitleCase(locale) branch.contains(displayLanguage, ignoreCase = true) ||
if (groups.containsKey(language)) { branch.contains(displayName, ignoreCase = true)
return language )
) {
candidates[branch] = groups[branch] ?: continue
}
} }
} }
return groups.maxByOrNull { it.value.size }?.key return candidates.ifEmpty { groups }.maxByOrNull { it.value.size }?.key
} }

View File

@@ -0,0 +1,26 @@
package org.koitharu.kotatsu.core.network
import okhttp3.CacheControl
import okhttp3.Interceptor
import okhttp3.Response
import java.util.concurrent.TimeUnit
class CacheLimitInterceptor : Interceptor {
private val defaultMaxAge = TimeUnit.HOURS.toSeconds(1)
private val defaultCacheControl = CacheControl.Builder()
.maxAge(defaultMaxAge.toInt(), TimeUnit.SECONDS)
.build()
.toString()
override fun intercept(chain: Interceptor.Chain): Response {
val response = chain.proceed(chain.request())
val responseCacheControl = CacheControl.parse(response.headers)
if (responseCacheControl.noStore || responseCacheControl.maxAgeSeconds <= defaultMaxAge) {
return response
}
return response.newBuilder()
.header(CommonHeaders.CACHE_CONTROL, defaultCacheControl)
.build()
}
}

View File

@@ -13,6 +13,7 @@ object CommonHeaders {
const val CONTENT_ENCODING = "Content-Encoding" const val CONTENT_ENCODING = "Content-Encoding"
const val ACCEPT_ENCODING = "Accept-Encoding" const val ACCEPT_ENCODING = "Accept-Encoding"
const val AUTHORIZATION = "Authorization" const val AUTHORIZATION = "Authorization"
const val CACHE_CONTROL = "Cache-Control"
val CACHE_CONTROL_NO_STORE: CacheControl val CACHE_CONTROL_NO_STORE: CacheControl
get() = CacheControl.Builder().noStore().build() get() = CacheControl.Builder().noStore().build()

View File

@@ -13,6 +13,7 @@ import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.mergeWith import org.koitharu.kotatsu.parsers.util.mergeWith
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import java.net.IDN
import java.util.Locale import java.util.Locale
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -41,7 +42,8 @@ class CommonHeadersInterceptor @Inject constructor(
headersBuilder[CommonHeaders.USER_AGENT] = userAgentFallback headersBuilder[CommonHeaders.USER_AGENT] = userAgentFallback
} }
if (headersBuilder[CommonHeaders.REFERER] == null && repository != null) { if (headersBuilder[CommonHeaders.REFERER] == null && repository != null) {
headersBuilder.trySet(CommonHeaders.REFERER, "https://${repository.domain}/") val idn = IDN.toASCII(repository.domain)
headersBuilder.trySet(CommonHeaders.REFERER, "https://$idn/")
} }
val newRequest = request.newBuilder().headers(headersBuilder.build()).build() val newRequest = request.newBuilder().headers(headersBuilder.build()).build()
return repository?.intercept(ProxyChain(chain, newRequest)) ?: chain.proceed(newRequest) return repository?.intercept(ProxyChain(chain, newRequest)) ?: chain.proceed(newRequest)

View File

@@ -11,6 +11,7 @@ import okhttp3.internal.closeQuietly
import okhttp3.internal.publicsuffix.PublicSuffixDatabase import okhttp3.internal.publicsuffix.PublicSuffixDatabase
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -18,10 +19,14 @@ import javax.inject.Singleton
@Singleton @Singleton
class MirrorSwitchInterceptor @Inject constructor( class MirrorSwitchInterceptor @Inject constructor(
private val mangaRepositoryFactoryLazy: Lazy<MangaRepository.Factory>, private val mangaRepositoryFactoryLazy: Lazy<MangaRepository.Factory>,
private val settings: AppSettings,
) : Interceptor { ) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response { override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request() val request = chain.request()
if (!settings.isMirrorSwitchingAvailable) {
return chain.proceed(request)
}
return try { return try {
val response = chain.proceed(request) val response = chain.proceed(request)
if (response.isFailed) { if (response.isFailed) {

View File

@@ -11,6 +11,7 @@ import androidx.annotation.VisibleForTesting
import androidx.core.content.pm.ShortcutInfoCompat import androidx.core.content.pm.ShortcutInfoCompat
import androidx.core.content.pm.ShortcutManagerCompat import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.graphics.drawable.IconCompat import androidx.core.graphics.drawable.IconCompat
import androidx.core.graphics.drawable.toBitmap
import androidx.room.InvalidationTracker import androidx.room.InvalidationTracker
import coil.ImageLoader import coil.ImageLoader
import coil.request.ImageRequest import coil.request.ImageRequest
@@ -27,9 +28,9 @@ import org.koitharu.kotatsu.core.prefs.AppSettings
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.reader.ui.ReaderActivity import org.koitharu.kotatsu.reader.ui.ReaderActivity
import org.koitharu.kotatsu.utils.ext.getDrawableOrThrow
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.runCatchingCancellable import org.koitharu.kotatsu.utils.ext.runCatchingCancellable
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -92,6 +93,14 @@ class ShortcutsUpdater @Inject constructor(
return manager.maxShortcutCountPerActivity > 0 return manager.maxShortcutCountPerActivity > 0
} }
fun notifyMangaOpened(mangaId: Long) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N_MR1) {
return
}
val manager = context.getSystemService(Context.SHORTCUT_SERVICE) as ShortcutManager
manager.reportShortcutUsed(mangaId.toString())
}
@RequiresApi(Build.VERSION_CODES.N_MR1) @RequiresApi(Build.VERSION_CODES.N_MR1)
private suspend fun updateShortcutsImpl() = runCatchingCancellable { private suspend fun updateShortcutsImpl() = runCatchingCancellable {
val manager = context.getSystemService(Context.SHORTCUT_SERVICE) as ShortcutManager val manager = context.getSystemService(Context.SHORTCUT_SERVICE) as ShortcutManager
@@ -122,7 +131,7 @@ class ShortcutsUpdater @Inject constructor(
.precision(Precision.EXACT) .precision(Precision.EXACT)
.scale(Scale.FILL) .scale(Scale.FILL)
.build(), .build(),
).requireBitmap() ).getDrawableOrThrow().toBitmap()
}.fold( }.fold(
onSuccess = { IconCompat.createWithAdaptiveBitmap(it) }, onSuccess = { IconCompat.createWithAdaptiveBitmap(it) },
onFailure = { IconCompat.createWithResource(context, R.drawable.ic_shortcut_default) }, onFailure = { IconCompat.createWithResource(context, R.drawable.ic_shortcut_default) },

View File

@@ -19,10 +19,13 @@ import okhttp3.Request
import okhttp3.Response import okhttp3.Response
import okhttp3.ResponseBody import okhttp3.ResponseBody
import okhttp3.internal.closeQuietly import okhttp3.internal.closeQuietly
import okio.Closeable
import okio.buffer
import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.local.data.CacheDir import org.koitharu.kotatsu.local.data.CacheDir
import org.koitharu.kotatsu.local.data.util.withExtraCloseable
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.await import org.koitharu.kotatsu.parsers.util.await
import java.net.HttpURLConnection import java.net.HttpURLConnection
@@ -54,7 +57,9 @@ class FaviconFetcher(
val icon = checkNotNull(favicons.find(sizePx)) { "No favicons found" } val icon = checkNotNull(favicons.find(sizePx)) { "No favicons found" }
val response = loadIcon(icon.url, mangaSource) val response = loadIcon(icon.url, mangaSource)
val responseBody = response.requireBody() val responseBody = response.requireBody()
val source = writeToDiskCache(responseBody)?.toImageSource() ?: responseBody.toImageSource() val source = writeToDiskCache(responseBody)?.toImageSource()?.also {
response.closeQuietly()
} ?: responseBody.toImageSource(response)
return SourceResult( return SourceResult(
source = source, source = source,
mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(icon.type), mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(icon.type),
@@ -71,7 +76,7 @@ class FaviconFetcher(
options.tags.asMap().forEach { request.tag(it.key as Class<Any>, it.value) } options.tags.asMap().forEach { request.tag(it.key as Class<Any>, it.value) }
val response = okHttpClient.newCall(request.build()).await() val response = okHttpClient.newCall(request.build()).await()
if (!response.isSuccessful && response.code != HttpURLConnection.HTTP_NOT_MODIFIED) { if (!response.isSuccessful && response.code != HttpURLConnection.HTTP_NOT_MODIFIED) {
response.body?.closeQuietly() response.closeQuietly()
throw HttpException(response) throw HttpException(response)
} }
return response return response
@@ -116,8 +121,12 @@ class FaviconFetcher(
return ImageSource(data, fileSystem, diskCacheKey, this) return ImageSource(data, fileSystem, diskCacheKey, this)
} }
private fun ResponseBody.toImageSource(): ImageSource { private fun ResponseBody.toImageSource(response: Closeable): ImageSource {
return ImageSource(source(), options.context, FaviconMetadata(mangaSource)) return ImageSource(
source().withExtraCloseable(response).buffer(),
options.context,
FaviconMetadata(mangaSource),
)
} }
private fun Response.toDataSource(): DataSource { private fun Response.toDataSource(): DataSource {

View File

@@ -16,6 +16,7 @@ import org.koitharu.kotatsu.core.model.ZoomMode
import org.koitharu.kotatsu.core.network.DoHProvider import org.koitharu.kotatsu.core.network.DoHProvider
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.shelf.domain.ShelfSection import org.koitharu.kotatsu.shelf.domain.ShelfSection
import org.koitharu.kotatsu.utils.ext.connectivityManager import org.koitharu.kotatsu.utils.ext.connectivityManager
import org.koitharu.kotatsu.utils.ext.filterToSet import org.koitharu.kotatsu.utils.ext.filterToSet
@@ -165,6 +166,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
get() = prefs.getBoolean(KEY_PROTECT_APP_BIOMETRIC, true) get() = prefs.getBoolean(KEY_PROTECT_APP_BIOMETRIC, true)
set(value) = prefs.edit { putBoolean(KEY_PROTECT_APP_BIOMETRIC, value) } set(value) = prefs.edit { putBoolean(KEY_PROTECT_APP_BIOMETRIC, value) }
val isMirrorSwitchingAvailable: Boolean
get() = prefs.getBoolean(KEY_MIRROR_SWITCHING, true)
val isExitConfirmationEnabled: Boolean val isExitConfirmationEnabled: Boolean
get() = prefs.getBoolean(KEY_EXIT_CONFIRM, false) get() = prefs.getBoolean(KEY_EXIT_CONFIRM, false)
@@ -238,15 +242,28 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val isDownloadsSlowdownEnabled: Boolean val isDownloadsSlowdownEnabled: Boolean
get() = prefs.getBoolean(KEY_DOWNLOADS_SLOWDOWN, false) get() = prefs.getBoolean(KEY_DOWNLOADS_SLOWDOWN, false)
val downloadsParallelism: Int val isDownloadsWiFiOnly: Boolean
get() = prefs.getInt(KEY_DOWNLOADS_PARALLELISM, 2) get() = prefs.getBoolean(KEY_DOWNLOADS_WIFI, false)
val isSuggestionsEnabled: Boolean var isSuggestionsEnabled: Boolean
get() = prefs.getBoolean(KEY_SUGGESTIONS, false) get() = prefs.getBoolean(KEY_SUGGESTIONS, false)
set(value) = prefs.edit { putBoolean(KEY_SUGGESTIONS, value) }
val isSuggestionsExcludeNsfw: Boolean val isSuggestionsExcludeNsfw: Boolean
get() = prefs.getBoolean(KEY_SUGGESTIONS_EXCLUDE_NSFW, false) get() = prefs.getBoolean(KEY_SUGGESTIONS_EXCLUDE_NSFW, false)
val isSuggestionsNotificationAvailable: Boolean
get() = prefs.getBoolean(KEY_SUGGESTIONS_NOTIFICATIONS, true)
val suggestionsTagsBlacklist: Set<String>
get() {
val string = prefs.getString(KEY_SUGGESTIONS_EXCLUDE_TAGS, null)?.trimEnd(' ', ',')
if (string.isNullOrEmpty()) {
return emptySet()
}
return string.split(',').mapToSet { it.trim() }
}
val isReaderBarEnabled: Boolean val isReaderBarEnabled: Boolean
get() = prefs.getBoolean(KEY_READER_BAR, true) get() = prefs.getBoolean(KEY_READER_BAR, true)
@@ -276,18 +293,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
return policy.isNetworkAllowed(connectivityManager) return policy.isNetworkAllowed(connectivityManager)
} }
fun getSuggestionsTagsBlacklistRegex(): Regex? {
val string = prefs.getString(KEY_SUGGESTIONS_EXCLUDE_TAGS, null)?.trimEnd(' ', ',')
if (string.isNullOrEmpty()) {
return null
}
val tags = string.split(',')
val regex = tags.joinToString(prefix = "(", separator = "|", postfix = ")") { tag ->
Regex.escape(tag.trim())
}
return Regex(regex, RegexOption.IGNORE_CASE)
}
fun getMangaSources(includeHidden: Boolean): List<MangaSource> { fun getMangaSources(includeHidden: Boolean): List<MangaSource> {
val list = remoteSources.toMutableList() val list = remoteSources.toMutableList()
val order = sourcesOrder val order = sourcesOrder
@@ -340,6 +345,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_SOURCES_HIDDEN = "sources_hidden" const val KEY_SOURCES_HIDDEN = "sources_hidden"
const val KEY_TRAFFIC_WARNING = "traffic_warning" const val KEY_TRAFFIC_WARNING = "traffic_warning"
const val KEY_PAGES_CACHE_CLEAR = "pages_cache_clear" const val KEY_PAGES_CACHE_CLEAR = "pages_cache_clear"
const val KEY_HTTP_CACHE_CLEAR = "http_cache_clear"
const val KEY_COOKIES_CLEAR = "cookies_clear" const val KEY_COOKIES_CLEAR = "cookies_clear"
const val KEY_THUMBS_CACHE_CLEAR = "thumbs_cache_clear" const val KEY_THUMBS_CACHE_CLEAR = "thumbs_cache_clear"
const val KEY_SEARCH_HISTORY_CLEAR = "search_history_clear" const val KEY_SEARCH_HISTORY_CLEAR = "search_history_clear"
@@ -378,16 +384,18 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_SUGGESTIONS = "suggestions" const val KEY_SUGGESTIONS = "suggestions"
const val KEY_SUGGESTIONS_EXCLUDE_NSFW = "suggestions_exclude_nsfw" const val KEY_SUGGESTIONS_EXCLUDE_NSFW = "suggestions_exclude_nsfw"
const val KEY_SUGGESTIONS_EXCLUDE_TAGS = "suggestions_exclude_tags" const val KEY_SUGGESTIONS_EXCLUDE_TAGS = "suggestions_exclude_tags"
const val KEY_SUGGESTIONS_NOTIFICATIONS = "suggestions_notifications"
const val KEY_SHIKIMORI = "shikimori" const val KEY_SHIKIMORI = "shikimori"
const val KEY_ANILIST = "anilist" const val KEY_ANILIST = "anilist"
const val KEY_MAL = "mal" const val KEY_MAL = "mal"
const val KEY_DOWNLOADS_PARALLELISM = "downloads_parallelism"
const val KEY_DOWNLOADS_SLOWDOWN = "downloads_slowdown" const val KEY_DOWNLOADS_SLOWDOWN = "downloads_slowdown"
const val KEY_DOWNLOADS_WIFI = "downloads_wifi"
const val KEY_ALL_FAVOURITES_VISIBLE = "all_favourites_visible" const val KEY_ALL_FAVOURITES_VISIBLE = "all_favourites_visible"
const val KEY_DOH = "doh" const val KEY_DOH = "doh"
const val KEY_EXIT_CONFIRM = "exit_confirm" const val KEY_EXIT_CONFIRM = "exit_confirm"
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_SYNC_SETTINGS = "sync_settings"
const val KEY_READER_BAR = "reader_bar" const val KEY_READER_BAR = "reader_bar"
const val KEY_READER_SLIDER = "reader_slider" const val KEY_READER_SLIDER = "reader_slider"
const val KEY_SHORTCUTS = "dynamic_shortcuts" const val KEY_SHORTCUTS = "dynamic_shortcuts"
@@ -404,6 +412,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_TIPS_CLOSED = "tips_closed" const val KEY_TIPS_CLOSED = "tips_closed"
const val KEY_SSL_BYPASS = "ssl_bypass" const val KEY_SSL_BYPASS = "ssl_bypass"
const val KEY_READER_AUTOSCROLL_SPEED = "as_speed" const val KEY_READER_AUTOSCROLL_SPEED = "as_speed"
const val KEY_MIRROR_SWITCHING = "mirror_switching"
// About // About
const val KEY_APP_UPDATE = "app_update" const val KEY_APP_UPDATE = "app_update"

View File

@@ -1,6 +1,8 @@
package org.koitharu.kotatsu.details.domain package org.koitharu.kotatsu.details.domain
class BranchComparator : Comparator<String?> { import org.koitharu.kotatsu.details.ui.model.MangaBranch
override fun compare(o1: String?, o2: String?): Int = compareValues(o1, o2) class BranchComparator : Comparator<MangaBranch> {
}
override fun compare(o1: MangaBranch, o2: MangaBranch): Int = compareValues(o1.name, o2.name)
}

View File

@@ -14,6 +14,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.getParcelableExtraCompat import org.koitharu.kotatsu.utils.ext.getParcelableExtraCompat
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import org.koitharu.kotatsu.utils.ext.runCatchingCancellable import org.koitharu.kotatsu.utils.ext.runCatchingCancellable
import javax.inject.Inject import javax.inject.Inject
@@ -95,7 +96,12 @@ class MangaPrefetchService : CoroutineIntentService() {
val intent = Intent(context, MangaPrefetchService::class.java) val intent = Intent(context, MangaPrefetchService::class.java)
intent.action = ACTION_PREFETCH_PAGES intent.action = ACTION_PREFETCH_PAGES
intent.putExtra(EXTRA_CHAPTER, ParcelableMangaChapters(listOf(chapter))) intent.putExtra(EXTRA_CHAPTER, ParcelableMangaChapters(listOf(chapter)))
context.startService(intent) try {
context.startService(intent)
} catch (e: IllegalStateException) {
// probably app is in background
e.printStackTraceDebug()
}
} }
fun prefetchLast(context: Context) { fun prefetchLast(context: Context) {

View File

@@ -19,7 +19,6 @@ import org.koitharu.kotatsu.databinding.FragmentChaptersBinding
import org.koitharu.kotatsu.details.ui.adapter.ChaptersAdapter import org.koitharu.kotatsu.details.ui.adapter.ChaptersAdapter
import org.koitharu.kotatsu.details.ui.adapter.ChaptersSelectionDecoration import org.koitharu.kotatsu.details.ui.adapter.ChaptersSelectionDecoration
import org.koitharu.kotatsu.details.ui.model.ChapterListItem import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.download.ui.service.DownloadService
import org.koitharu.kotatsu.local.ui.LocalChaptersRemoveService import org.koitharu.kotatsu.local.ui.LocalChaptersRemoveService
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.reader.ui.ReaderActivity import org.koitharu.kotatsu.reader.ui.ReaderActivity
@@ -95,11 +94,7 @@ class ChaptersFragment :
override fun onActionItemClicked(controller: ListSelectionController, mode: ActionMode, item: MenuItem): Boolean { override fun onActionItemClicked(controller: ListSelectionController, mode: ActionMode, item: MenuItem): Boolean {
return when (item.itemId) { return when (item.itemId) {
R.id.action_save -> { R.id.action_save -> {
DownloadService.start( viewModel.download(selectionController?.snapshot())
binding.recyclerViewChapters,
viewModel.getRemoteManga() ?: viewModel.manga.value ?: return false,
selectionController?.snapshot(),
)
mode.finish() mode.finish()
true true
} }

View File

@@ -1,14 +1,13 @@
package org.koitharu.kotatsu.details.ui package org.koitharu.kotatsu.details.ui
import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.DialogInterface
import android.content.Intent import android.content.Intent
import android.content.IntentFilter
import android.os.Bundle import android.os.Bundle
import android.transition.Slide import android.transition.Slide
import android.transition.TransitionManager import android.transition.TransitionManager
import android.view.Gravity import android.view.Gravity
import android.view.Menu import android.view.MenuItem
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.view.animation.AccelerateDecelerateInterpolator import android.view.animation.AccelerateDecelerateInterpolator
@@ -20,30 +19,31 @@ 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.Observer import androidx.lifecycle.Observer
import androidx.lifecycle.lifecycleScope
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 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
import org.koitharu.kotatsu.base.ui.BaseActivity import org.koitharu.kotatsu.base.ui.BaseActivity
import org.koitharu.kotatsu.base.ui.dialog.RecyclerViewAlertDialog
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.base.ui.widgets.BottomSheetHeaderBar import org.koitharu.kotatsu.base.ui.widgets.BottomSheetHeaderBar
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.os.ShortcutsUpdater import org.koitharu.kotatsu.core.os.ShortcutsUpdater
import org.koitharu.kotatsu.databinding.ActivityDetailsBinding import org.koitharu.kotatsu.databinding.ActivityDetailsBinding
import org.koitharu.kotatsu.details.service.MangaPrefetchService import org.koitharu.kotatsu.details.service.MangaPrefetchService
import org.koitharu.kotatsu.details.ui.adapter.branchAD
import org.koitharu.kotatsu.details.ui.model.ChapterListItem import org.koitharu.kotatsu.details.ui.model.ChapterListItem
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.details.ui.model.MangaBranch
import org.koitharu.kotatsu.download.ui.worker.DownloadStartedObserver
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.ViewBadge import org.koitharu.kotatsu.utils.ViewBadge
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.setNavigationBarTransparentCompat import org.koitharu.kotatsu.utils.ext.setNavigationBarTransparentCompat
import org.koitharu.kotatsu.utils.ext.textAndVisible import org.koitharu.kotatsu.utils.ext.textAndVisible
import javax.inject.Inject import javax.inject.Inject
@@ -53,7 +53,9 @@ class DetailsActivity :
BaseActivity<ActivityDetailsBinding>(), BaseActivity<ActivityDetailsBinding>(),
View.OnClickListener, View.OnClickListener,
BottomSheetHeaderBar.OnExpansionChangeListener, BottomSheetHeaderBar.OnExpansionChangeListener,
NoModalBottomSheetOwner { NoModalBottomSheetOwner,
View.OnLongClickListener,
PopupMenu.OnMenuItemClickListener {
override val bsHeader: BottomSheetHeaderBar? override val bsHeader: BottomSheetHeaderBar?
get() = binding.headerChapters get() = binding.headerChapters
@@ -66,13 +68,6 @@ class DetailsActivity :
private val viewModel: DetailsViewModel by viewModels() private val viewModel: DetailsViewModel by viewModels()
private lateinit var chaptersMenuProvider: ChaptersMenuProvider private lateinit var chaptersMenuProvider: ChaptersMenuProvider
private val downloadReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
val downloadedManga = DownloadService.getDownloadedManga(intent) ?: return
viewModel.onDownloadComplete(downloadedManga)
}
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(ActivityDetailsBinding.inflate(layoutInflater)) setContentView(ActivityDetailsBinding.inflate(layoutInflater))
@@ -81,6 +76,7 @@ class DetailsActivity :
setDisplayShowTitleEnabled(false) setDisplayShowTitleEnabled(false)
} }
binding.buttonRead.setOnClickListener(this) binding.buttonRead.setOnClickListener(this)
binding.buttonRead.setOnLongClickListener(this)
binding.buttonDropdown.setOnClickListener(this) binding.buttonDropdown.setOnClickListener(this)
viewBadge = ViewBadge(binding.buttonRead, this) viewBadge = ViewBadge(binding.buttonRead, this)
@@ -129,8 +125,8 @@ class DetailsActivity :
binding.buttonDropdown.isVisible = it.size > 1 binding.buttonDropdown.isVisible = it.size > 1
} }
viewModel.chapters.observe(this, PrefetchObserver(this)) viewModel.chapters.observe(this, PrefetchObserver(this))
viewModel.onDownloadStarted.observe(this, DownloadStartedObserver(binding.containerDetails))
registerReceiver(downloadReceiver, IntentFilter(DownloadService.ACTION_DOWNLOAD_COMPLETE))
addMenuProvider( addMenuProvider(
DetailsMenuProvider( DetailsMenuProvider(
activity = this, activity = this,
@@ -142,33 +138,35 @@ class DetailsActivity :
binding.headerChapters?.addOnExpansionChangeListener(this) ?: addMenuProvider(chaptersMenuProvider) binding.headerChapters?.addOnExpansionChangeListener(this) ?: addMenuProvider(chaptersMenuProvider)
} }
override fun onDestroy() {
unregisterReceiver(downloadReceiver)
super.onDestroy()
}
override fun onClick(v: View) { override fun onClick(v: View) {
val manga = viewModel.manga.value ?: return
when (v.id) { when (v.id) {
R.id.button_read -> { R.id.button_read -> openReader(isIncognitoMode = false)
val chapterId = viewModel.historyInfo.value?.history?.chapterId
if (chapterId != null && manga.chapters?.none { x -> x.id == chapterId } == true) {
showChapterMissingDialog(chapterId)
} else {
startActivity(
ReaderActivity.newIntent(
context = this,
manga = manga,
branch = viewModel.selectedBranchValue,
),
)
}
}
R.id.button_dropdown -> showBranchPopupMenu() R.id.button_dropdown -> showBranchPopupMenu()
} }
} }
override fun onLongClick(v: View): Boolean = when (v.id) {
R.id.button_read -> {
val menu = PopupMenu(v.context, v)
menu.inflate(R.menu.popup_read)
menu.setOnMenuItemClickListener(this)
menu.setForceShowIcon(true)
menu.show()
true
}
else -> false
}
override fun onMenuItemClick(item: MenuItem): Boolean = when (item.itemId) {
R.id.action_incognito -> {
openReader(isIncognitoMode = true)
true
}
else -> false
}
override fun onExpansionStateChanged(headerBar: BottomSheetHeaderBar, isExpanded: Boolean) { override fun onExpansionStateChanged(headerBar: BottomSheetHeaderBar, isExpanded: Boolean) {
if (isExpanded) { if (isExpanded) {
headerBar.addMenuProvider(chaptersMenuProvider) headerBar.addMenuProvider(chaptersMenuProvider)
@@ -251,34 +249,44 @@ class DetailsActivity :
) )
} }
setNeutralButton(R.string.download) { _, _ -> setNeutralButton(R.string.download) { _, _ ->
DownloadService.start(binding.appbar, remoteManga, setOf(chapterId)) viewModel.download(setOf(chapterId))
} }
setCancelable(true) setCancelable(true)
}.show() }.show()
} }
private fun showBranchPopupMenu() { private fun showBranchPopupMenu() {
val menu = PopupMenu(this, binding.headerChapters ?: binding.buttonDropdown) var dialog: DialogInterface? = null
val currentBranch = viewModel.selectedBranchValue val listener = OnListItemClickListener<MangaBranch> { item, _ ->
for (branch in viewModel.branches.value ?: return) { viewModel.setSelectedBranch(item.name)
val item = menu.menu.add(R.id.group_branches, Menu.NONE, Menu.NONE, branch) dialog?.dismiss()
item.isChecked = branch == currentBranch
} }
menu.menu.setGroupCheckable(R.id.group_branches, true, true) dialog = RecyclerViewAlertDialog.Builder<MangaBranch>(this)
menu.setOnMenuItemClickListener { item -> .addAdapterDelegate(branchAD(listener))
viewModel.setSelectedBranch(item.title?.toString()) .setCancelable(true)
true .setNegativeButton(android.R.string.cancel, null)
} .setTitle(R.string.translations)
menu.show() .setItems(viewModel.branches.value.orEmpty())
.create()
.also { it.show() }
} }
private fun resolveError(e: Throwable) { private fun openReader(isIncognitoMode: Boolean) {
lifecycleScope.launch { val manga = viewModel.manga.value ?: return
if (exceptionResolver.resolve(e)) { val chapterId = viewModel.historyInfo.value?.history?.chapterId
viewModel.reload() if (chapterId != null && manga.chapters?.none { x -> x.id == chapterId } == true) {
} else if (viewModel.manga.value == null) { showChapterMissingDialog(chapterId)
Toast.makeText(this@DetailsActivity, e.getDisplayMessage(resources), Toast.LENGTH_LONG).show() } else {
finishAfterTransition() startActivity(
ReaderActivity.newIntent(
context = this,
manga = manga,
branch = viewModel.selectedBranchValue,
isIncognitoMode = isIncognitoMode,
),
)
if (isIncognitoMode) {
Toast.makeText(this, R.string.incognito_mode, Toast.LENGTH_SHORT).show()
} }
} }
} }

View File

@@ -9,7 +9,6 @@ import android.widget.Toast
import androidx.appcompat.widget.PopupMenu import androidx.appcompat.widget.PopupMenu
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.graphics.Insets import androidx.core.graphics.Insets
import androidx.core.net.toUri
import androidx.core.view.isGone 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
@@ -19,7 +18,6 @@ import coil.request.ImageRequest
import coil.util.CoilUtils import coil.util.CoilUtils
import com.google.android.material.chip.Chip import com.google.android.material.chip.Chip
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseFragment import org.koitharu.kotatsu.base.ui.BaseFragment
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
@@ -46,7 +44,6 @@ import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo
import org.koitharu.kotatsu.search.ui.MangaListActivity import org.koitharu.kotatsu.search.ui.MangaListActivity
import org.koitharu.kotatsu.search.ui.SearchActivity import org.koitharu.kotatsu.search.ui.SearchActivity
import org.koitharu.kotatsu.utils.FileSize import org.koitharu.kotatsu.utils.FileSize
import org.koitharu.kotatsu.utils.ext.computeSize
import org.koitharu.kotatsu.utils.ext.crossfade import org.koitharu.kotatsu.utils.ext.crossfade
import org.koitharu.kotatsu.utils.ext.drawableTop import org.koitharu.kotatsu.utils.ext.drawableTop
import org.koitharu.kotatsu.utils.ext.enqueueWith import org.koitharu.kotatsu.utils.ext.enqueueWith
@@ -55,8 +52,6 @@ import org.koitharu.kotatsu.utils.ext.measureHeight
import org.koitharu.kotatsu.utils.ext.resolveDp import org.koitharu.kotatsu.utils.ext.resolveDp
import org.koitharu.kotatsu.utils.ext.scaleUpActivityOptionsOf import org.koitharu.kotatsu.utils.ext.scaleUpActivityOptionsOf
import org.koitharu.kotatsu.utils.ext.textAndVisible import org.koitharu.kotatsu.utils.ext.textAndVisible
import org.koitharu.kotatsu.utils.ext.toFileOrNull
import org.koitharu.kotatsu.utils.ext.viewLifecycleScope
import org.koitharu.kotatsu.utils.image.CoverSizeResolver import org.koitharu.kotatsu.utils.image.CoverSizeResolver
import javax.inject.Inject import javax.inject.Inject
@@ -94,6 +89,7 @@ class DetailsFragment :
viewModel.scrobblingInfo.observe(viewLifecycleOwner, ::onScrobblingInfoChanged) viewModel.scrobblingInfo.observe(viewLifecycleOwner, ::onScrobblingInfoChanged)
viewModel.description.observe(viewLifecycleOwner, ::onDescriptionChanged) viewModel.description.observe(viewLifecycleOwner, ::onDescriptionChanged)
viewModel.chapters.observe(viewLifecycleOwner, ::onChaptersChanged) viewModel.chapters.observe(viewLifecycleOwner, ::onChaptersChanged)
viewModel.localSize.observe(viewLifecycleOwner, ::onLocalSizeChanged)
} }
override fun onItemClick(item: Bookmark, view: View) { override fun onItemClick(item: Bookmark, view: View) {
@@ -150,20 +146,9 @@ class DetailsFragment :
} }
if (manga.source == MangaSource.LOCAL) { if (manga.source == MangaSource.LOCAL) {
infoLayout.textViewSource.isVisible = false infoLayout.textViewSource.isVisible = false
val file = manga.url.toUri().toFileOrNull()
if (file != null) {
viewLifecycleScope.launch {
val size = file.computeSize()
infoLayout.textViewSize.text = FileSize.BYTES.format(requireContext(), size)
infoLayout.textViewSize.isVisible = true
}
} else {
infoLayout.textViewSize.isVisible = false
}
} else { } else {
infoLayout.textViewSource.text = manga.source.title infoLayout.textViewSource.text = manga.source.title
infoLayout.textViewSource.isVisible = true infoLayout.textViewSource.isVisible = true
infoLayout.textViewSize.isVisible = false
} }
infoLayout.textViewNsfw.isVisible = manga.isNsfw infoLayout.textViewNsfw.isVisible = manga.isNsfw
@@ -192,6 +177,16 @@ class DetailsFragment :
} }
} }
private fun onLocalSizeChanged(size: Long) {
val textView = binding.infoLayout.textViewSize
if (size == 0L) {
textView.isVisible = false
} else {
textView.text = FileSize.BYTES.format(textView.context, size)
textView.isVisible = true
}
}
private fun onHistoryChanged(history: HistoryInfo) { private fun onHistoryChanged(history: HistoryInfo) {
binding.progressView.setPercent(history.history?.percent ?: PROGRESS_NONE, animate = true) binding.progressView.setPercent(history.history?.percent ?: PROGRESS_NONE, animate = true)
} }

View File

@@ -16,7 +16,7 @@ import kotlinx.coroutines.launch
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.browser.BrowserActivity import org.koitharu.kotatsu.browser.BrowserActivity
import org.koitharu.kotatsu.core.os.ShortcutsUpdater import org.koitharu.kotatsu.core.os.ShortcutsUpdater
import org.koitharu.kotatsu.download.ui.service.DownloadService import org.koitharu.kotatsu.details.ui.model.MangaBranch
import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteCategoriesBottomSheet import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteCategoriesBottomSheet
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
@@ -86,7 +86,7 @@ class DetailsMenuProvider(
if (chaptersCount > 5 || branches.size > 1) { if (chaptersCount > 5 || branches.size > 1) {
showSaveConfirmation(it, chaptersCount, branches) showSaveConfirmation(it, chaptersCount, branches)
} else { } else {
DownloadService.start(snackbarHost, it) viewModel.download(null)
} }
} }
} }
@@ -125,22 +125,24 @@ class DetailsMenuProvider(
return true return true
} }
private fun showSaveConfirmation(manga: Manga, chaptersCount: Int, branches: List<String?>) { private fun showSaveConfirmation(manga: Manga, chaptersCount: Int, branches: List<MangaBranch>) {
val dialogBuilder = MaterialAlertDialogBuilder(activity) val dialogBuilder = MaterialAlertDialogBuilder(activity)
.setTitle(R.string.save_manga) .setTitle(R.string.save_manga)
.setNegativeButton(android.R.string.cancel, null) .setNegativeButton(android.R.string.cancel, null)
if (branches.size > 1) { if (branches.size > 1) {
val items = Array(branches.size) { i -> branches[i].orEmpty() } val items = Array(branches.size) { i -> branches[i].name.orEmpty() }
val currentBranch = viewModel.selectedBranchIndex.value ?: -1 val currentBranch = branches.indexOfFirst { it.isSelected }
val checkedIndices = BooleanArray(branches.size) { i -> i == currentBranch } val checkedIndices = BooleanArray(branches.size) { i -> i == currentBranch }
dialogBuilder.setMultiChoiceItems(items, checkedIndices) { _, i, checked -> dialogBuilder.setMultiChoiceItems(items, checkedIndices) { _, i, checked ->
checkedIndices[i] = checked checkedIndices[i] = checked
}.setPositiveButton(R.string.save) { _, _ -> }.setPositiveButton(R.string.save) { _, _ ->
val selectedBranches = branches.filterIndexedTo(HashSet()) { i, _ -> checkedIndices[i] } val selectedBranches = branches.mapIndexedNotNullTo(HashSet()) { i, b ->
if (checkedIndices[i]) b.name else null
}
val chaptersIds = manga.chapters?.mapNotNullToSet { c -> val chaptersIds = manga.chapters?.mapNotNullToSet { c ->
if (c.branch in selectedBranches) c.id else null if (c.branch in selectedBranches) c.id else null
} }
DownloadService.start(snackbarHost, manga, chaptersIds) viewModel.download(chaptersIds)
} }
} else { } else {
dialogBuilder.setMessage( dialogBuilder.setMessage(
@@ -149,7 +151,7 @@ class DetailsMenuProvider(
activity.resources.getQuantityString(R.plurals.chapters, chaptersCount, chaptersCount), activity.resources.getQuantityString(R.plurals.chapters, chaptersCount, chaptersCount),
), ),
).setPositiveButton(R.string.save) { _, _ -> ).setPositiveButton(R.string.save) { _, _ ->
DownloadService.start(snackbarHost, manga) viewModel.download(null)
} }
} }
dialogBuilder.show() dialogBuilder.show()

View File

@@ -4,6 +4,7 @@ import android.text.Html
import android.text.SpannableString import android.text.SpannableString
import android.text.Spanned import android.text.Spanned
import android.text.style.ForegroundColorSpan import android.text.style.ForegroundColorSpan
import androidx.core.net.toUri
import androidx.core.text.getSpans import androidx.core.text.getSpans
import androidx.core.text.parseAsHtml import androidx.core.text.parseAsHtml
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
@@ -14,6 +15,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChangedBy import kotlinx.coroutines.flow.distinctUntilChangedBy
@@ -34,20 +36,25 @@ import org.koitharu.kotatsu.core.prefs.observeAsFlow
import org.koitharu.kotatsu.details.domain.BranchComparator import org.koitharu.kotatsu.details.domain.BranchComparator
import org.koitharu.kotatsu.details.ui.model.ChapterListItem import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.details.ui.model.HistoryInfo import org.koitharu.kotatsu.details.ui.model.HistoryInfo
import org.koitharu.kotatsu.details.ui.model.MangaBranch
import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
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.local.data.LocalManga
import org.koitharu.kotatsu.local.data.LocalStorageChanges
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.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.scrobbling.common.domain.Scrobbler import org.koitharu.kotatsu.scrobbling.common.domain.Scrobbler
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingStatus import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingStatus
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.computeSize
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import org.koitharu.kotatsu.utils.ext.runCatchingCancellable import org.koitharu.kotatsu.utils.ext.runCatchingCancellable
import org.koitharu.kotatsu.utils.ext.toFileOrNull
import java.io.IOException import java.io.IOException
import javax.inject.Inject import javax.inject.Inject
@@ -62,11 +69,14 @@ class DetailsViewModel @Inject constructor(
private val scrobblers: Set<@JvmSuppressWildcards Scrobbler>, private val scrobblers: Set<@JvmSuppressWildcards Scrobbler>,
private val imageGetter: Html.ImageGetter, private val imageGetter: Html.ImageGetter,
private val delegate: MangaDetailsDelegate, private val delegate: MangaDetailsDelegate,
@LocalStorageChanges private val localStorageChanges: SharedFlow<LocalManga?>,
private val downloadScheduler: DownloadWorker.Scheduler,
) : BaseViewModel() { ) : BaseViewModel() {
private var loadingJob: Job private var loadingJob: Job
val onShowToast = SingleLiveEvent<Int>() val onShowToast = SingleLiveEvent<Int>()
val onDownloadStarted = SingleLiveEvent<Unit>()
private val history = historyRepository.observeOne(delegate.mangaId) private val history = historyRepository.observeOne(delegate.mangaId)
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null) .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)
@@ -109,6 +119,23 @@ class DetailsViewModel @Inject constructor(
if (it != null) bookmarksRepository.observeBookmarks(it) else flowOf(emptyList()) if (it != null) bookmarksRepository.observeBookmarks(it) else flowOf(emptyList())
}.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, emptyList()) }.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, emptyList())
val localSize = combine(
delegate.manga,
delegate.relatedManga,
) { m1, m2 ->
val url = when {
m1?.source == MangaSource.LOCAL -> m1.url
m2?.source == MangaSource.LOCAL -> m2.url
else -> null
}
if (url != null) {
val file = url.toUri().toFileOrNull()
file?.computeSize() ?: 0L
} else {
0L
}
}.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, 0)
val description = delegate.manga val description = delegate.manga
.distinctUntilChangedBy { it?.description.orEmpty() } .distinctUntilChangedBy { it?.description.orEmpty() }
.transformLatest { .transformLatest {
@@ -131,17 +158,15 @@ class DetailsViewModel @Inject constructor(
scrobblingInfo.filterNotNull() scrobblingInfo.filterNotNull()
}.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, emptyList()) }.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, emptyList())
val branches: LiveData<List<String?>> = delegate.manga.map { val branches: LiveData<List<MangaBranch>> = combine(
val chapters = it?.chapters ?: return@map emptyList() delegate.manga,
chapters.mapToSet { x -> x.branch }.sortedWith(BranchComparator())
}.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, emptyList())
val selectedBranchIndex = combine(
branches.asFlow(),
delegate.selectedBranch, delegate.selectedBranch,
) { branches, selected -> ) { m, b ->
branches.indexOf(selected) val chapters = m?.chapters ?: return@combine emptyList()
}.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, -1) chapters.groupBy { x -> x.branch }
.map { x -> MangaBranch(x.key, x.value.size, x.key == b) }
.sortedWith(BranchComparator())
}.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, emptyList())
val selectedBranchName = delegate.selectedBranch val selectedBranchName = delegate.selectedBranch
.asFlowLiveData(viewModelScope.coroutineContext, null) .asFlowLiveData(viewModelScope.coroutineContext, null)
@@ -174,6 +199,10 @@ class DetailsViewModel @Inject constructor(
init { init {
loadingJob = doLoad() loadingJob = doLoad()
launchJob(Dispatchers.Default) {
localStorageChanges
.collect { onDownloadComplete(it) }
}
} }
fun reload() { fun reload() {
@@ -195,7 +224,7 @@ class DetailsViewModel @Inject constructor(
runCatchingCancellable { runCatchingCancellable {
historyRepository.deleteOrSwap(manga, original) historyRepository.deleteOrSwap(manga, original)
} }
onMangaRemoved.postCall(manga) onMangaRemoved.emitCall(manga)
} }
} }
@@ -222,26 +251,6 @@ class DetailsViewModel @Inject constructor(
chaptersQuery.value = query?.trim().orEmpty() chaptersQuery.value = query?.trim().orEmpty()
} }
fun onDownloadComplete(downloadedManga: Manga) {
val currentManga = delegate.manga.value ?: return
if (currentManga.id != downloadedManga.id) {
return
}
if (currentManga.source == MangaSource.LOCAL) {
reload()
} else {
viewModelScope.launch(Dispatchers.Default) {
runCatchingCancellable {
localMangaRepository.getDetails(downloadedManga)
}.onSuccess {
delegate.relatedManga.value = it
}.onFailure {
it.printStackTraceDebug()
}
}
}
}
fun updateScrobbling(index: Int, rating: Float, status: ScrobblingStatus?) { fun updateScrobbling(index: Int, rating: Float, status: ScrobblingStatus?) {
val scrobbler = getScrobbler(index) ?: return val scrobbler = getScrobbler(index) ?: return
launchJob(Dispatchers.Default) { launchJob(Dispatchers.Default) {
@@ -274,6 +283,16 @@ class DetailsViewModel @Inject constructor(
} }
} }
fun download(chaptersIds: Set<Long>?) {
launchJob(Dispatchers.Default) {
downloadScheduler.schedule(
getRemoteManga() ?: checkNotNull(manga.value),
chaptersIds,
)
onDownloadStarted.emitCall(Unit)
}
}
private fun doLoad() = launchLoadingJob(Dispatchers.Default) { private fun doLoad() = launchLoadingJob(Dispatchers.Default) {
delegate.doLoad() delegate.doLoad()
} }
@@ -287,6 +306,27 @@ class DetailsViewModel @Inject constructor(
} }
} }
private suspend fun onDownloadComplete(downloadedManga: LocalManga?) {
downloadedManga ?: return
val currentManga = delegate.manga.value ?: return
if (currentManga.id != downloadedManga.manga.id) {
return
}
if (currentManga.source == MangaSource.LOCAL) {
reload()
} else {
viewModelScope.launch(Dispatchers.Default) {
runCatchingCancellable {
localMangaRepository.getDetails(downloadedManga.manga)
}.onSuccess {
delegate.relatedManga.value = it
}.onFailure {
it.printStackTraceDebug()
}
}
}
}
private fun Spanned.filterSpans(): CharSequence { private fun Spanned.filterSpans(): CharSequence {
val spannable = SpannableString.valueOf(this) val spannable = SpannableString.valueOf(this)
val spans = spannable.getSpans<ForegroundColorSpan>() val spans = spannable.getSpans<ForegroundColorSpan>()

View File

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

View File

@@ -1,45 +1,16 @@
package org.koitharu.kotatsu.details.ui.adapter package org.koitharu.kotatsu.details.ui.adapter
import android.view.LayoutInflater import com.hannesdorfmann.adapterdelegates4.ListDelegationAdapter
import android.view.View import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import android.view.ViewGroup import org.koitharu.kotatsu.details.ui.model.MangaBranch
import android.widget.BaseAdapter
import android.widget.TextView
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.parsers.util.replaceWith
class BranchesAdapter : BaseAdapter() { class BranchesAdapter(
list: List<MangaBranch>,
listener: OnListItemClickListener<MangaBranch>,
) : ListDelegationAdapter<List<MangaBranch>>() {
private val dataSet = ArrayList<String?>() init {
delegatesManager.addDelegate(branchAD(listener))
override fun getCount(): Int { items = list
return dataSet.size
} }
}
override fun getItem(position: Int): Any? {
return dataSet[position]
}
override fun getItemId(position: Int): Long {
return dataSet[position].hashCode().toLong()
}
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
val view = convertView ?: LayoutInflater.from(parent.context)
.inflate(R.layout.item_branch, parent, false)
(view as TextView).text = dataSet[position]
return view
}
override fun getDropDownView(position: Int, convertView: View?, parent: ViewGroup): View {
val view = convertView ?: LayoutInflater.from(parent.context)
.inflate(R.layout.item_branch_dropdown, parent, false)
(view as TextView).text = dataSet[position]
return view
}
fun setItems(items: Collection<String?>) {
dataSet.replaceWith(items)
notifyDataSetChanged()
}
}

View File

@@ -0,0 +1,32 @@
package org.koitharu.kotatsu.details.ui.model
import org.koitharu.kotatsu.list.ui.model.ListModel
class MangaBranch(
val name: String?,
val count: Int,
val isSelected: Boolean,
) : ListModel {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as MangaBranch
if (name != other.name) return false
if (count != other.count) return false
return isSelected == other.isSelected
}
override fun hashCode(): Int {
var result = name.hashCode()
result = 31 * result + count
result = 31 * result + isSelected.hashCode()
return result
}
override fun toString(): String {
return "$name: $count"
}
}

View File

@@ -1,269 +0,0 @@
package org.koitharu.kotatsu.download.domain
import android.app.Service
import android.content.Context
import android.webkit.MimeTypeMap
import androidx.lifecycle.LifecycleService
import androidx.lifecycle.lifecycleScope
import coil.ImageLoader
import coil.request.ImageRequest
import coil.size.Scale
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.scopes.ServiceScoped
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.internal.closeQuietly
import okio.IOException
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.download.ui.service.PausingHandle
import org.koitharu.kotatsu.local.data.PagesCache
import org.koitharu.kotatsu.local.data.input.LocalMangaInput
import org.koitharu.kotatsu.local.data.output.LocalMangaOutput
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.await
import org.koitharu.kotatsu.utils.ext.copyToSuspending
import org.koitharu.kotatsu.utils.ext.deleteAwait
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import org.koitharu.kotatsu.utils.ext.runCatchingCancellable
import org.koitharu.kotatsu.utils.progress.PausingProgressJob
import java.io.File
import javax.inject.Inject
private const val MAX_FAILSAFE_ATTEMPTS = 2
private const val DOWNLOAD_ERROR_DELAY = 500L
private const val SLOWDOWN_DELAY = 150L
@ServiceScoped
class DownloadManager @Inject constructor(
service: Service,
@ApplicationContext private val context: Context,
private val imageLoader: ImageLoader,
private val okHttp: OkHttpClient,
private val cache: PagesCache,
private val localMangaRepository: LocalMangaRepository,
private val settings: AppSettings,
private val mangaRepositoryFactory: MangaRepository.Factory,
) {
private val coverWidth = context.resources.getDimensionPixelSize(
androidx.core.R.dimen.compat_notification_large_icon_max_width,
)
private val coverHeight = context.resources.getDimensionPixelSize(
androidx.core.R.dimen.compat_notification_large_icon_max_height,
)
private val semaphore = Semaphore(settings.downloadsParallelism)
private val coroutineScope = (service as LifecycleService).lifecycleScope
fun downloadManga(
manga: Manga,
chaptersIds: LongArray?,
startId: Int,
): PausingProgressJob<DownloadState> {
val stateFlow = MutableStateFlow<DownloadState>(
DownloadState.Queued(startId = startId, manga = manga, cover = null),
)
val pausingHandle = PausingHandle()
val job = coroutineScope.launch(Dispatchers.Default + errorStateHandler(stateFlow)) {
try {
downloadMangaImpl(manga, chaptersIds?.takeUnless { it.isEmpty() }, stateFlow, pausingHandle, startId)
} catch (e: CancellationException) { // handle cancellation if not handled already
val state = stateFlow.value
if (state !is DownloadState.Cancelled) {
stateFlow.value = DownloadState.Cancelled(startId, state.manga, state.cover)
}
throw e
}
}
return PausingProgressJob(job, stateFlow, pausingHandle)
}
private suspend fun downloadMangaImpl(
manga: Manga,
chaptersIds: LongArray?,
outState: MutableStateFlow<DownloadState>,
pausingHandle: PausingHandle,
startId: Int,
) {
@Suppress("NAME_SHADOWING")
var manga = manga
val chaptersIdsSet = chaptersIds?.toMutableSet()
val cover = loadCover(manga)
outState.value = DownloadState.Queued(startId, manga, cover)
withMangaLock(manga) {
semaphore.withPermit {
outState.value = DownloadState.Preparing(startId, manga, null)
val destination = localMangaRepository.getOutputDir(manga)
checkNotNull(destination) { context.getString(R.string.cannot_find_available_storage) }
val tempFileName = "${manga.id}_$startId.tmp"
var output: LocalMangaOutput? = null
try {
if (manga.source == MangaSource.LOCAL) {
manga = localMangaRepository.getRemoteManga(manga)
?: error("Cannot obtain remote manga instance")
}
val repo = mangaRepositoryFactory.create(manga.source)
outState.value = DownloadState.Preparing(startId, manga, cover)
val data = if (manga.chapters.isNullOrEmpty()) repo.getDetails(manga) else manga
output = LocalMangaOutput.getOrCreate(destination, data)
val coverUrl = data.largeCoverUrl ?: data.coverUrl
downloadFile(coverUrl, destination, tempFileName, repo.source).let { file ->
output.addCover(file, MimeTypeMap.getFileExtensionFromUrl(coverUrl))
}
val chapters = checkNotNull(
if (chaptersIdsSet == null) {
data.chapters
} else {
data.chapters?.filter { x -> chaptersIdsSet.remove(x.id) }
},
) { "Chapters list must not be null" }
check(chapters.isNotEmpty()) { "Chapters list must not be empty" }
check(chaptersIdsSet.isNullOrEmpty()) {
"${chaptersIdsSet?.size} of ${chaptersIds?.size} requested chapters not found in manga"
}
for ((chapterIndex, chapter) in chapters.withIndex()) {
val pages = runFailsafe(outState, pausingHandle) {
repo.getPages(chapter)
}
for ((pageIndex, page) in pages.withIndex()) {
runFailsafe(outState, pausingHandle) {
val url = repo.getPageUrl(page)
val file = cache.get(url)
?: downloadFile(url, destination, tempFileName, repo.source)
output.addPage(
chapter = chapter,
file = file,
pageNumber = pageIndex,
ext = MimeTypeMap.getFileExtensionFromUrl(url),
)
}
outState.value = DownloadState.Progress(
startId = startId,
manga = data,
cover = cover,
totalChapters = chapters.size,
currentChapter = chapterIndex,
totalPages = pages.size,
currentPage = pageIndex,
)
if (settings.isDownloadsSlowdownEnabled) {
delay(SLOWDOWN_DELAY)
}
}
output.flushChapter(chapter)
}
outState.value = DownloadState.PostProcessing(startId, data, cover)
output.mergeWithExisting()
output.finish()
val localManga = LocalMangaInput.of(output.rootFile).getManga().manga
outState.value = DownloadState.Done(startId, data, cover, localManga)
} catch (e: CancellationException) {
outState.value = DownloadState.Cancelled(startId, manga, cover)
throw e
} catch (e: Throwable) {
e.printStackTraceDebug()
outState.value = DownloadState.Error(startId, manga, cover, e, false)
} finally {
withContext(NonCancellable) {
output?.closeQuietly()
output?.cleanup()
File(destination, tempFileName).deleteAwait()
}
}
}
}
}
private suspend fun <R> runFailsafe(
outState: MutableStateFlow<DownloadState>,
pausingHandle: PausingHandle,
block: suspend () -> R,
): R {
var countDown = MAX_FAILSAFE_ATTEMPTS
failsafe@ while (true) {
try {
return block()
} catch (e: IOException) {
if (countDown <= 0) {
val state = outState.value
outState.value = DownloadState.Error(state.startId, state.manga, state.cover, e, true)
countDown = MAX_FAILSAFE_ATTEMPTS
pausingHandle.pause()
pausingHandle.awaitResumed()
outState.value = state
} else {
countDown--
delay(DOWNLOAD_ERROR_DELAY)
}
}
}
}
private suspend fun downloadFile(
url: String,
destination: File,
tempFileName: String,
source: MangaSource,
): File {
val request = Request.Builder()
.url(url)
.tag(MangaSource::class.java, source)
.cacheControl(CommonHeaders.CACHE_CONTROL_NO_STORE)
.get()
.build()
val call = okHttp.newCall(request)
val file = File(destination, tempFileName)
val response = call.clone().await()
file.outputStream().use { out ->
checkNotNull(response.body).byteStream().copyToSuspending(out)
}
return file
}
private fun errorStateHandler(outState: MutableStateFlow<DownloadState>) =
CoroutineExceptionHandler { _, throwable ->
throwable.printStackTraceDebug()
val prevValue = outState.value
outState.value = DownloadState.Error(
startId = prevValue.startId,
manga = prevValue.manga,
cover = prevValue.cover,
error = throwable,
canRetry = false,
)
}
private suspend fun loadCover(manga: Manga) = runCatchingCancellable {
imageLoader.execute(
ImageRequest.Builder(context)
.data(manga.coverUrl)
.allowHardware(false)
.tag(manga.source)
.size(coverWidth, coverHeight)
.scale(Scale.FILL)
.build(),
).drawable
}.getOrNull()
private suspend inline fun <T> withMangaLock(manga: Manga, block: () -> T) = try {
localMangaRepository.lockManga(manga.id)
block()
} finally {
localMangaRepository.unlockManga(manga.id)
}
}

View File

@@ -1,234 +1,123 @@
package org.koitharu.kotatsu.download.domain package org.koitharu.kotatsu.download.domain
import android.graphics.drawable.Drawable import androidx.work.Data
import org.koitharu.kotatsu.history.domain.PROGRESS_NONE
import org.koitharu.kotatsu.local.data.LocalManga
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import java.util.Date
sealed interface DownloadState { data class DownloadState(
val manga: Manga,
val isIndeterminate: Boolean,
val isPaused: Boolean = false,
val isStopped: Boolean = false,
val error: String? = null,
val totalChapters: Int = 0,
val currentChapter: Int = 0,
val totalPages: Int = 0,
val currentPage: Int = 0,
val eta: Long = -1L,
val localManga: LocalManga? = null,
val downloadedChapters: LongArray = LongArray(0),
val timestamp: Long = System.currentTimeMillis(),
) {
val startId: Int val max: Int = totalChapters * totalPages
val manga: Manga
val cover: Drawable?
override fun equals(other: Any?): Boolean val progress: Int = totalPages * currentChapter + currentPage + 1
override fun hashCode(): Int val percent: Float = if (max > 0) progress.toFloat() / max else PROGRESS_NONE
val isTerminal: Boolean val isFinalState: Boolean
get() = this is Done || this is Cancelled || (this is Error && !canRetry) get() = localManga != null || (error != null && !isPaused)
class Queued( val isParticularProgress: Boolean
override val startId: Int, get() = localManga == null && error == null && !isPaused && !isStopped && max > 0 && !isIndeterminate
override val manga: Manga,
override val cover: Drawable?,
) : DownloadState {
override fun equals(other: Any?): Boolean { fun toWorkData() = Data.Builder()
if (this === other) return true .putLong(DATA_MANGA_ID, manga.id)
if (javaClass != other?.javaClass) return false .putInt(DATA_MAX, max)
.putInt(DATA_PROGRESS, progress)
.putLong(DATA_ETA, eta)
.putLong(DATA_TIMESTAMP, timestamp)
.putString(DATA_ERROR, error)
.putLongArray(DATA_CHAPTERS, downloadedChapters)
.putBoolean(DATA_INDETERMINATE, isIndeterminate)
.putBoolean(DATA_PAUSED, isPaused)
.build()
other as Queued override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
if (startId != other.startId) return false other as DownloadState
if (manga != other.manga) return false
if (cover != other.cover) return false
return true if (manga != other.manga) return false
} if (isIndeterminate != other.isIndeterminate) return false
if (isPaused != other.isPaused) return false
override fun hashCode(): Int { if (isStopped != other.isStopped) return false
var result = startId if (error != other.error) return false
result = 31 * result + manga.hashCode() if (totalChapters != other.totalChapters) return false
result = 31 * result + (cover?.hashCode() ?: 0) if (currentChapter != other.currentChapter) return false
return result if (totalPages != other.totalPages) return false
} if (currentPage != other.currentPage) return false
if (eta != other.eta) return false
if (localManga != other.localManga) return false
if (!downloadedChapters.contentEquals(other.downloadedChapters)) return false
if (timestamp != other.timestamp) return false
if (max != other.max) return false
if (progress != other.progress) return false
return percent == other.percent
} }
class Preparing( override fun hashCode(): Int {
override val startId: Int, var result = manga.hashCode()
override val manga: Manga, result = 31 * result + isIndeterminate.hashCode()
override val cover: Drawable?, result = 31 * result + isPaused.hashCode()
) : DownloadState { result = 31 * result + isStopped.hashCode()
result = 31 * result + (error?.hashCode() ?: 0)
override fun equals(other: Any?): Boolean { result = 31 * result + totalChapters
if (this === other) return true result = 31 * result + currentChapter
if (javaClass != other?.javaClass) return false result = 31 * result + totalPages
result = 31 * result + currentPage
other as Preparing result = 31 * result + eta.hashCode()
result = 31 * result + (localManga?.hashCode() ?: 0)
if (startId != other.startId) return false result = 31 * result + downloadedChapters.contentHashCode()
if (manga != other.manga) return false result = 31 * result + timestamp.hashCode()
if (cover != other.cover) return false result = 31 * result + max
result = 31 * result + progress
return true result = 31 * result + percent.hashCode()
} return result
override fun hashCode(): Int {
var result = startId
result = 31 * result + manga.hashCode()
result = 31 * result + (cover?.hashCode() ?: 0)
return result
}
} }
class Progress( companion object {
override val startId: Int,
override val manga: Manga,
override val cover: Drawable?,
val totalChapters: Int,
val currentChapter: Int,
val totalPages: Int,
val currentPage: Int,
) : DownloadState {
val max: Int = totalChapters * totalPages private const val DATA_MANGA_ID = "manga_id"
private const val DATA_MAX = "max"
private const val DATA_PROGRESS = "progress"
private const val DATA_CHAPTERS = "chapter"
private const val DATA_ETA = "eta"
private const val DATA_TIMESTAMP = "timestamp"
private const val DATA_ERROR = "error"
private const val DATA_INDETERMINATE = "indeterminate"
private const val DATA_PAUSED = "paused"
val progress: Int = totalPages * currentChapter + currentPage + 1 fun getMangaId(data: Data): Long = data.getLong(DATA_MANGA_ID, 0L)
val percent: Float = progress.toFloat() / max fun isIndeterminate(data: Data): Boolean = data.getBoolean(DATA_INDETERMINATE, false)
override fun equals(other: Any?): Boolean { fun isPaused(data: Data): Boolean = data.getBoolean(DATA_PAUSED, false)
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as Progress fun getMax(data: Data): Int = data.getInt(DATA_MAX, 0)
if (startId != other.startId) return false fun getError(data: Data): String? = data.getString(DATA_ERROR)
if (manga != other.manga) return false
if (cover != other.cover) return false
if (totalChapters != other.totalChapters) return false
if (currentChapter != other.currentChapter) return false
if (totalPages != other.totalPages) return false
if (currentPage != other.currentPage) return false
return true fun getProgress(data: Data): Int = data.getInt(DATA_PROGRESS, 0)
}
override fun hashCode(): Int { fun getEta(data: Data): Long = data.getLong(DATA_ETA, -1L)
var result = startId
result = 31 * result + manga.hashCode()
result = 31 * result + (cover?.hashCode() ?: 0)
result = 31 * result + totalChapters
result = 31 * result + currentChapter
result = 31 * result + totalPages
result = 31 * result + currentPage
return result
}
}
class Done( fun getTimestamp(data: Data): Date = Date(data.getLong(DATA_TIMESTAMP, 0L))
override val startId: Int,
override val manga: Manga,
override val cover: Drawable?,
val localManga: Manga,
) : DownloadState {
override fun equals(other: Any?): Boolean { fun getDownloadedChapters(data: Data): LongArray = data.getLongArray(DATA_CHAPTERS) ?: LongArray(0)
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as Done
if (startId != other.startId) return false
if (manga != other.manga) return false
if (cover != other.cover) return false
if (localManga != other.localManga) return false
return true
}
override fun hashCode(): Int {
var result = startId
result = 31 * result + manga.hashCode()
result = 31 * result + (cover?.hashCode() ?: 0)
result = 31 * result + localManga.hashCode()
return result
}
}
class Error(
override val startId: Int,
override val manga: Manga,
override val cover: Drawable?,
val error: Throwable,
val canRetry: Boolean,
) : DownloadState {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as Error
if (startId != other.startId) return false
if (manga != other.manga) return false
if (cover != other.cover) return false
if (error != other.error) return false
if (canRetry != other.canRetry) return false
return true
}
override fun hashCode(): Int {
var result = startId
result = 31 * result + manga.hashCode()
result = 31 * result + (cover?.hashCode() ?: 0)
result = 31 * result + error.hashCode()
result = 31 * result + canRetry.hashCode()
return result
}
}
class Cancelled(
override val startId: Int,
override val manga: Manga,
override val cover: Drawable?,
) : DownloadState {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as Cancelled
if (startId != other.startId) return false
if (manga != other.manga) return false
if (cover != other.cover) return false
return true
}
override fun hashCode(): Int {
var result = startId
result = 31 * result + manga.hashCode()
result = 31 * result + (cover?.hashCode() ?: 0)
return result
}
}
class PostProcessing(
override val startId: Int,
override val manga: Manga,
override val cover: Drawable?,
) : DownloadState {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as PostProcessing
if (startId != other.startId) return false
if (manga != other.manga) return false
if (cover != other.cover) return false
return true
}
override fun hashCode(): Int {
var result = startId
result = 31 * result + manga.hashCode()
result = 31 * result + (cover?.hashCode() ?: 0)
return result
}
} }
} }

View File

@@ -1,140 +0,0 @@
package org.koitharu.kotatsu.download.ui
import android.view.View
import androidx.core.view.isVisible
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.databinding.ItemDownloadBinding
import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.download.domain.DownloadState
import org.koitharu.kotatsu.parsers.util.format
import org.koitharu.kotatsu.utils.ext.enqueueWith
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.newImageRequest
import org.koitharu.kotatsu.utils.ext.onFirst
import org.koitharu.kotatsu.utils.ext.source
fun downloadItemAD(
lifecycleOwner: LifecycleOwner,
coil: ImageLoader,
) = adapterDelegateViewBinding<DownloadItem, DownloadItem, ItemDownloadBinding>(
{ inflater, parent -> ItemDownloadBinding.inflate(inflater, parent, false) },
) {
var job: Job? = null
val percentPattern = context.resources.getString(R.string.percent_string_pattern)
val clickListener = View.OnClickListener { v ->
when (v.id) {
R.id.button_cancel -> item.cancel()
R.id.button_resume -> item.resume()
else -> context.startActivity(
DetailsActivity.newIntent(context, item.progressValue.manga),
)
}
}
binding.buttonCancel.setOnClickListener(clickListener)
binding.buttonResume.setOnClickListener(clickListener)
itemView.setOnClickListener(clickListener)
bind {
job?.cancel()
job = item.progressAsFlow().onFirst { state ->
binding.imageViewCover.newImageRequest(lifecycleOwner, state.manga.coverUrl)?.run {
placeholder(state.cover)
fallback(R.drawable.ic_placeholder)
error(R.drawable.ic_error_placeholder)
source(state.manga.source)
allowRgb565(true)
enqueueWith(coil)
}
}.onEach { state ->
binding.textViewTitle.text = state.manga.title
when (state) {
is DownloadState.Cancelled -> {
binding.textViewStatus.setText(R.string.cancelling_)
binding.progressBar.isIndeterminate = true
binding.progressBar.isVisible = true
binding.textViewPercent.isVisible = false
binding.textViewDetails.isVisible = false
binding.buttonCancel.isVisible = false
binding.buttonResume.isVisible = false
}
is DownloadState.Done -> {
binding.textViewStatus.setText(R.string.download_complete)
binding.progressBar.isIndeterminate = false
binding.progressBar.isVisible = false
binding.textViewPercent.isVisible = false
binding.textViewDetails.isVisible = false
binding.buttonCancel.isVisible = false
binding.buttonResume.isVisible = false
}
is DownloadState.Error -> {
binding.textViewStatus.setText(R.string.error_occurred)
binding.progressBar.isIndeterminate = false
binding.progressBar.isVisible = false
binding.textViewPercent.isVisible = false
binding.textViewDetails.text = state.error.getDisplayMessage(context.resources)
binding.textViewDetails.isVisible = true
binding.buttonCancel.isVisible = state.canRetry
binding.buttonResume.isVisible = state.canRetry
}
is DownloadState.PostProcessing -> {
binding.textViewStatus.setText(R.string.processing_)
binding.progressBar.isIndeterminate = true
binding.progressBar.isVisible = true
binding.textViewPercent.isVisible = false
binding.textViewDetails.isVisible = false
binding.buttonCancel.isVisible = false
binding.buttonResume.isVisible = false
}
is DownloadState.Preparing -> {
binding.textViewStatus.setText(R.string.preparing_)
binding.progressBar.isIndeterminate = true
binding.progressBar.isVisible = true
binding.textViewPercent.isVisible = false
binding.textViewDetails.isVisible = false
binding.buttonCancel.isVisible = true
binding.buttonResume.isVisible = false
}
is DownloadState.Progress -> {
binding.textViewStatus.setText(R.string.manga_downloading_)
binding.progressBar.isIndeterminate = false
binding.progressBar.isVisible = true
binding.progressBar.max = state.max
binding.progressBar.setProgressCompat(state.progress, true)
binding.textViewPercent.text = percentPattern.format((state.percent * 100f).format(1))
binding.textViewPercent.isVisible = true
binding.textViewDetails.isVisible = false
binding.buttonCancel.isVisible = true
binding.buttonResume.isVisible = false
}
is DownloadState.Queued -> {
binding.textViewStatus.setText(R.string.queued)
binding.progressBar.isIndeterminate = false
binding.progressBar.isVisible = false
binding.textViewPercent.isVisible = false
binding.textViewDetails.isVisible = false
binding.buttonCancel.isVisible = true
binding.buttonResume.isVisible = false
}
}
}.launchIn(lifecycleOwner.lifecycleScope)
}
onViewRecycled {
job?.cancel()
job = null
}
}

View File

@@ -1,58 +0,0 @@
package org.koitharu.kotatsu.download.ui
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.core.graphics.Insets
import androidx.core.view.isVisible
import androidx.core.view.updatePadding
import coil.ImageLoader
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseActivity
import org.koitharu.kotatsu.base.ui.list.decor.SpacingItemDecoration
import org.koitharu.kotatsu.databinding.ActivityDownloadsBinding
import javax.inject.Inject
@AndroidEntryPoint
class DownloadsActivity : BaseActivity<ActivityDownloadsBinding>() {
@Inject
lateinit var coil: ImageLoader
private lateinit var serviceConnection: DownloadsConnection
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(ActivityDownloadsBinding.inflate(layoutInflater))
supportActionBar?.setDisplayHomeAsUpEnabled(true)
val adapter = DownloadsAdapter(this, coil)
val spacing = resources.getDimensionPixelOffset(R.dimen.list_spacing)
binding.recyclerView.addItemDecoration(SpacingItemDecoration(spacing))
binding.recyclerView.setHasFixedSize(true)
binding.recyclerView.adapter = adapter
serviceConnection = DownloadsConnection(this, this)
serviceConnection.items.observe(this) { items ->
adapter.items = items
binding.textViewHolder.isVisible = items.isNullOrEmpty()
}
serviceConnection.bind()
}
override fun onWindowInsetsChanged(insets: Insets) {
binding.recyclerView.updatePadding(
left = insets.left,
right = insets.right,
bottom = insets.bottom,
)
binding.toolbar.updatePadding(
left = insets.left,
right = insets.right,
)
}
companion object {
fun newIntent(context: Context) = Intent(context, DownloadsActivity::class.java)
}
}

View File

@@ -1,46 +0,0 @@
package org.koitharu.kotatsu.download.ui
import androidx.lifecycle.LifecycleOwner
import androidx.recyclerview.widget.DiffUtil
import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
import org.koitharu.kotatsu.download.domain.DownloadState
import org.koitharu.kotatsu.utils.progress.PausingProgressJob
typealias DownloadItem = PausingProgressJob<DownloadState>
class DownloadsAdapter(
lifecycleOwner: LifecycleOwner,
coil: ImageLoader,
) : AsyncListDifferDelegationAdapter<DownloadItem>(DiffCallback()) {
init {
delegatesManager.addDelegate(downloadItemAD(lifecycleOwner, coil))
setHasStableIds(true)
}
override fun getItemId(position: Int): Long {
return items[position].progressValue.startId.toLong()
}
private class DiffCallback : DiffUtil.ItemCallback<DownloadItem>() {
override fun areItemsTheSame(
oldItem: DownloadItem,
newItem: DownloadItem,
): Boolean {
return oldItem.progressValue.startId == newItem.progressValue.startId
}
override fun areContentsTheSame(
oldItem: DownloadItem,
newItem: DownloadItem,
): Boolean {
return oldItem.progressValue == newItem.progressValue && oldItem.isPaused == newItem.isPaused
}
override fun getChangePayload(oldItem: DownloadItem, newItem: DownloadItem): Any {
return Unit
}
}
}

View File

@@ -1,76 +0,0 @@
package org.koitharu.kotatsu.download.ui
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.os.IBinder
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import org.koitharu.kotatsu.download.domain.DownloadState
import org.koitharu.kotatsu.download.ui.service.DownloadService
import org.koitharu.kotatsu.utils.asFlowLiveData
import org.koitharu.kotatsu.utils.progress.PausingProgressJob
class DownloadsConnection(
private val context: Context,
private val lifecycleOwner: LifecycleOwner,
) : ServiceConnection {
private var bindingObserver: BindingLifecycleObserver? = null
private var collectJob: Job? = null
private val itemsFlow = MutableStateFlow<List<PausingProgressJob<DownloadState>>>(emptyList())
val items
get() = itemsFlow.asFlowLiveData()
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
collectJob?.cancel()
val binder = (service as? DownloadService.DownloadBinder)
collectJob = if (binder == null) {
null
} else {
lifecycleOwner.lifecycleScope.launch {
binder.downloads.collect {
itemsFlow.value = it
}
}
}
}
override fun onServiceDisconnected(name: ComponentName?) {
collectJob?.cancel()
collectJob = null
itemsFlow.value = itemsFlow.value.filter { it.progressValue.isTerminal }
}
fun bind() {
if (bindingObserver != null) {
return
}
bindingObserver = BindingLifecycleObserver().also {
lifecycleOwner.lifecycle.addObserver(it)
}
context.bindService(Intent(context, DownloadService::class.java), this, 0)
}
fun unbind() {
bindingObserver?.let {
lifecycleOwner.lifecycle.removeObserver(it)
}
bindingObserver = null
context.unbindService(this)
}
private inner class BindingLifecycleObserver : DefaultLifecycleObserver {
override fun onDestroy(owner: LifecycleOwner) {
super.onDestroy(owner)
unbind()
}
}
}

View File

@@ -0,0 +1,140 @@
package org.koitharu.kotatsu.download.ui.list
import android.view.View
import androidx.core.view.isVisible
import androidx.lifecycle.LifecycleOwner
import androidx.work.WorkInfo
import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.databinding.ItemDownloadBinding
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.util.format
import org.koitharu.kotatsu.utils.ext.disposeImageRequest
import org.koitharu.kotatsu.utils.ext.enqueueWith
import org.koitharu.kotatsu.utils.ext.newImageRequest
import org.koitharu.kotatsu.utils.ext.source
import org.koitharu.kotatsu.utils.ext.textAndVisible
fun downloadItemAD(
lifecycleOwner: LifecycleOwner,
coil: ImageLoader,
listener: DownloadItemListener,
) = adapterDelegateViewBinding<DownloadItemModel, ListModel, ItemDownloadBinding>(
{ inflater, parent -> ItemDownloadBinding.inflate(inflater, parent, false) },
) {
val percentPattern = context.resources.getString(R.string.percent_string_pattern)
val clickListener = object : View.OnClickListener, View.OnLongClickListener {
override fun onClick(v: View) {
when (v.id) {
R.id.button_cancel -> listener.onCancelClick(item)
R.id.button_resume -> listener.onResumeClick(item)
R.id.button_pause -> listener.onPauseClick(item)
else -> listener.onItemClick(item, v)
}
}
override fun onLongClick(v: View): Boolean {
return listener.onItemLongClick(item, v)
}
}
binding.buttonCancel.setOnClickListener(clickListener)
binding.buttonPause.setOnClickListener(clickListener)
binding.buttonResume.setOnClickListener(clickListener)
itemView.setOnClickListener(clickListener)
itemView.setOnLongClickListener(clickListener)
bind { payloads ->
binding.textViewTitle.text = item.manga.title
binding.imageViewCover.newImageRequest(lifecycleOwner, item.manga.coverUrl)?.apply {
placeholder(R.drawable.ic_placeholder)
fallback(R.drawable.ic_placeholder)
error(R.drawable.ic_error_placeholder)
allowRgb565(true)
source(item.manga.source)
enqueueWith(coil)
}
when (item.workState) {
WorkInfo.State.ENQUEUED,
WorkInfo.State.BLOCKED -> {
binding.textViewStatus.setText(R.string.queued)
binding.progressBar.isIndeterminate = false
binding.progressBar.isVisible = false
binding.progressBar.isEnabled = true
binding.textViewPercent.isVisible = false
binding.textViewDetails.isVisible = false
binding.buttonCancel.isVisible = true
binding.buttonResume.isVisible = false
binding.buttonPause.isVisible = false
}
WorkInfo.State.RUNNING -> {
binding.textViewStatus.setText(
if (item.isPaused) R.string.paused else R.string.manga_downloading_,
)
binding.progressBar.isIndeterminate = item.isIndeterminate
binding.progressBar.isVisible = true
binding.progressBar.max = item.max
binding.progressBar.isEnabled = !item.isPaused
binding.progressBar.setProgressCompat(item.progress, payloads.isNotEmpty())
binding.textViewPercent.text = percentPattern.format((item.percent * 100f).format(1))
binding.textViewPercent.isVisible = true
binding.textViewDetails.textAndVisible = item.getEtaString()
binding.buttonCancel.isVisible = true
binding.buttonResume.isVisible = item.isPaused
binding.buttonPause.isVisible = item.canPause
}
WorkInfo.State.SUCCEEDED -> {
binding.textViewStatus.setText(R.string.download_complete)
binding.progressBar.isIndeterminate = false
binding.progressBar.isVisible = false
binding.progressBar.isEnabled = true
binding.textViewPercent.isVisible = false
if (item.totalChapters > 0) {
binding.textViewDetails.text = context.resources.getQuantityString(
R.plurals.chapters,
item.totalChapters,
item.totalChapters,
)
binding.textViewDetails.isVisible = true
} else {
binding.textViewDetails.isVisible = false
}
binding.buttonCancel.isVisible = false
binding.buttonResume.isVisible = false
binding.buttonPause.isVisible = false
}
WorkInfo.State.FAILED -> {
binding.textViewStatus.setText(R.string.error_occurred)
binding.progressBar.isIndeterminate = false
binding.progressBar.isVisible = false
binding.progressBar.isEnabled = true
binding.textViewPercent.isVisible = false
binding.textViewDetails.textAndVisible = item.error
binding.buttonCancel.isVisible = false
binding.buttonResume.isVisible = false
binding.buttonPause.isVisible = false
}
WorkInfo.State.CANCELLED -> {
binding.textViewStatus.setText(R.string.canceled)
binding.progressBar.isIndeterminate = false
binding.progressBar.isVisible = false
binding.progressBar.isEnabled = true
binding.textViewPercent.isVisible = false
binding.textViewDetails.isVisible = false
binding.buttonCancel.isVisible = false
binding.buttonResume.isVisible = false
binding.buttonPause.isVisible = false
}
}
}
onViewRecycled {
binding.imageViewCover.disposeImageRequest()
}
}

View File

@@ -0,0 +1,12 @@
package org.koitharu.kotatsu.download.ui.list
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
interface DownloadItemListener : OnListItemClickListener<DownloadItemModel> {
fun onCancelClick(item: DownloadItemModel)
fun onPauseClick(item: DownloadItemModel)
fun onResumeClick(item: DownloadItemModel)
}

View File

@@ -0,0 +1,83 @@
package org.koitharu.kotatsu.download.ui.list
import android.text.format.DateUtils
import androidx.work.WorkInfo
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.model.Manga
import java.util.Date
import java.util.UUID
class DownloadItemModel(
val id: UUID,
val workState: WorkInfo.State,
val isIndeterminate: Boolean,
val isPaused: Boolean,
val manga: Manga,
val error: String?,
val max: Int,
val totalChapters: Int,
val progress: Int,
val eta: Long,
val timestamp: Date,
) : ListModel, Comparable<DownloadItemModel> {
val percent: Float
get() = if (max > 0) progress / max.toFloat() else 0f
val hasEta: Boolean
get() = workState == WorkInfo.State.RUNNING && !isPaused && eta > 0L
val canPause: Boolean
get() = workState == WorkInfo.State.RUNNING && !isPaused && error == null
val canResume: Boolean
get() = workState == WorkInfo.State.RUNNING && isPaused
fun getEtaString(): CharSequence? = if (hasEta) {
DateUtils.getRelativeTimeSpanString(
eta,
System.currentTimeMillis(),
DateUtils.SECOND_IN_MILLIS,
)
} else {
null
}
override fun compareTo(other: DownloadItemModel): Int {
return timestamp.compareTo(other.timestamp)
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as DownloadItemModel
if (id != other.id) return false
if (workState != other.workState) return false
if (isIndeterminate != other.isIndeterminate) return false
if (isPaused != other.isPaused) return false
if (manga != other.manga) return false
if (error != other.error) return false
if (max != other.max) return false
if (totalChapters != other.totalChapters) return false
if (progress != other.progress) return false
if (eta != other.eta) return false
return timestamp == other.timestamp
}
override fun hashCode(): Int {
var result = id.hashCode()
result = 31 * result + workState.hashCode()
result = 31 * result + isIndeterminate.hashCode()
result = 31 * result + isPaused.hashCode()
result = 31 * result + manga.hashCode()
result = 31 * result + (error?.hashCode() ?: 0)
result = 31 * result + max
result = 31 * result + totalChapters
result = 31 * result + progress
result = 31 * result + eta.hashCode()
result = 31 * result + timestamp.hashCode()
return result
}
}

View File

@@ -0,0 +1,173 @@
package org.koitharu.kotatsu.download.ui.list
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import android.view.View
import androidx.activity.viewModels
import androidx.annotation.Px
import androidx.appcompat.view.ActionMode
import androidx.core.graphics.Insets
import androidx.core.view.updatePadding
import androidx.lifecycle.Observer
import coil.ImageLoader
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseActivity
import org.koitharu.kotatsu.base.ui.list.ListSelectionController
import org.koitharu.kotatsu.base.ui.list.decor.SpacingItemDecoration
import org.koitharu.kotatsu.base.ui.util.ReversibleActionObserver
import org.koitharu.kotatsu.databinding.ActivityDownloadsBinding
import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.download.ui.worker.PausingReceiver
import javax.inject.Inject
@AndroidEntryPoint
class DownloadsActivity : BaseActivity<ActivityDownloadsBinding>(),
DownloadItemListener,
ListSelectionController.Callback2 {
@Inject
lateinit var coil: ImageLoader
private val viewModel by viewModels<DownloadsViewModel>()
private lateinit var selectionController: ListSelectionController
@Px
private var listSpacing = 0
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(ActivityDownloadsBinding.inflate(layoutInflater))
listSpacing = resources.getDimensionPixelOffset(R.dimen.list_spacing)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
val downloadsAdapter = DownloadsAdapter(this, coil, this)
val decoration = SpacingItemDecoration(listSpacing)
selectionController = ListSelectionController(
activity = this,
decoration = DownloadsSelectionDecoration(this),
registryOwner = this,
callback = this,
)
with(binding.recyclerView) {
setHasFixedSize(true)
addItemDecoration(decoration)
adapter = downloadsAdapter
selectionController.attachToRecyclerView(this)
}
addMenuProvider(DownloadsMenuProvider(this, viewModel))
viewModel.items.observe(this) {
downloadsAdapter.items = it
}
viewModel.onActionDone.observe(this, ReversibleActionObserver(binding.recyclerView))
val menuObserver = Observer<Any> { _ -> invalidateOptionsMenu() }
viewModel.hasActiveWorks.observe(this, menuObserver)
viewModel.hasPausedWorks.observe(this, menuObserver)
viewModel.hasCancellableWorks.observe(this, menuObserver)
}
override fun onWindowInsetsChanged(insets: Insets) {
binding.recyclerView.updatePadding(
left = insets.left + listSpacing,
right = insets.right + listSpacing,
bottom = insets.bottom,
)
binding.toolbar.updatePadding(
left = insets.left,
right = insets.right,
)
}
override fun onItemClick(item: DownloadItemModel, view: View) {
if (selectionController.onItemClick(item.id.mostSignificantBits)) {
return
}
startActivity(DetailsActivity.newIntent(view.context, item.manga))
}
override fun onItemLongClick(item: DownloadItemModel, view: View): Boolean {
return selectionController.onItemLongClick(item.id.mostSignificantBits)
}
override fun onCancelClick(item: DownloadItemModel) {
viewModel.cancel(item.id)
}
override fun onPauseClick(item: DownloadItemModel) {
sendBroadcast(PausingReceiver.getPauseIntent(item.id))
}
override fun onResumeClick(item: DownloadItemModel) {
sendBroadcast(PausingReceiver.getResumeIntent(item.id))
}
override fun onSelectionChanged(controller: ListSelectionController, count: Int) {
binding.recyclerView.invalidateItemDecorations()
}
override fun onCreateActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean {
mode.menuInflater.inflate(R.menu.mode_downloads, menu)
return true
}
override fun onActionItemClicked(controller: ListSelectionController, mode: ActionMode, item: MenuItem): Boolean {
return when (item.itemId) {
R.id.action_resume -> {
viewModel.resume(controller.snapshot())
mode.finish()
true
}
R.id.action_pause -> {
viewModel.pause(controller.snapshot())
mode.finish()
true
}
R.id.action_cancel -> {
viewModel.cancel(controller.snapshot())
mode.finish()
true
}
R.id.action_remove -> {
viewModel.remove(controller.snapshot())
mode.finish()
true
}
R.id.action_select_all -> {
controller.addAll(viewModel.allIds())
true
}
else -> false
}
}
override fun onPrepareActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean {
val snapshot = viewModel.snapshot(controller.peekCheckedIds())
var canPause = true
var canResume = true
var canCancel = true
var canRemove = true
for (item in snapshot) {
canPause = canPause and item.canPause
canResume = canResume and item.canResume
canCancel = canCancel and !item.workState.isFinished
canRemove = canRemove and item.workState.isFinished
}
menu.findItem(R.id.action_pause)?.isVisible = canPause
menu.findItem(R.id.action_resume)?.isVisible = canResume
menu.findItem(R.id.action_cancel)?.isVisible = canCancel
menu.findItem(R.id.action_remove)?.isVisible = canRemove
return super.onPrepareActionMode(controller, mode, menu)
}
companion object {
fun newIntent(context: Context) = Intent(context, DownloadsActivity::class.java)
}
}

View File

@@ -0,0 +1,65 @@
package org.koitharu.kotatsu.download.ui.list
import androidx.lifecycle.LifecycleOwner
import androidx.recyclerview.widget.DiffUtil
import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
import org.koitharu.kotatsu.core.ui.DateTimeAgo
import org.koitharu.kotatsu.list.ui.adapter.emptyStateListAD
import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD
import org.koitharu.kotatsu.list.ui.adapter.relatedDateItemAD
import org.koitharu.kotatsu.list.ui.model.ListModel
import kotlin.jvm.internal.Intrinsics
class DownloadsAdapter(
lifecycleOwner: LifecycleOwner,
coil: ImageLoader,
listener: DownloadItemListener,
) : AsyncListDifferDelegationAdapter<ListModel>(DiffCallback()) {
init {
delegatesManager.addDelegate(ITEM_TYPE_DOWNLOAD, downloadItemAD(lifecycleOwner, coil, listener))
.addDelegate(loadingStateAD())
.addDelegate(emptyStateListAD(coil, lifecycleOwner, null))
.addDelegate(relatedDateItemAD())
}
private class DiffCallback : DiffUtil.ItemCallback<ListModel>() {
override fun areItemsTheSame(oldItem: ListModel, newItem: ListModel) = when {
oldItem is DownloadItemModel && newItem is DownloadItemModel -> {
oldItem.id == newItem.id
}
oldItem is DateTimeAgo && newItem is DateTimeAgo -> {
oldItem == newItem
}
else -> oldItem.javaClass == newItem.javaClass
}
override fun areContentsTheSame(oldItem: ListModel, newItem: ListModel): Boolean {
return Intrinsics.areEqual(oldItem, newItem)
}
override fun getChangePayload(oldItem: ListModel, newItem: ListModel): Any? {
return when (newItem) {
is DownloadItemModel -> {
oldItem as DownloadItemModel
if (oldItem.workState == newItem.workState) {
Unit
} else {
null
}
}
else -> super.getChangePayload(oldItem, newItem)
}
}
}
companion object {
const val ITEM_TYPE_DOWNLOAD = 0
}
}

View File

@@ -0,0 +1,63 @@
package org.koitharu.kotatsu.download.ui.list
import android.content.Context
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import androidx.core.view.MenuProvider
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.koitharu.kotatsu.R
class DownloadsMenuProvider(
private val context: Context,
private val viewModel: DownloadsViewModel,
) : MenuProvider {
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menuInflater.inflate(R.menu.opt_downloads, menu)
}
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
when (menuItem.itemId) {
R.id.action_pause -> viewModel.pauseAll()
R.id.action_resume -> viewModel.resumeAll()
R.id.action_cancel_all -> confirmCancelAll()
R.id.action_remove_completed -> confirmRemoveCompleted()
else -> return false
}
return true
}
override fun onPrepareMenu(menu: Menu) {
super.onPrepareMenu(menu)
menu.findItem(R.id.action_pause)?.isVisible = viewModel.hasActiveWorks.value == true
menu.findItem(R.id.action_resume)?.isVisible = viewModel.hasPausedWorks.value == true
menu.findItem(R.id.action_cancel_all)?.isVisible = viewModel.hasCancellableWorks.value == true
}
private fun confirmCancelAll() {
MaterialAlertDialogBuilder(
context,
com.google.android.material.R.style.ThemeOverlay_Material3_MaterialAlertDialog_Centered,
).setTitle(R.string.cancel_all)
.setMessage(R.string.cancel_all_downloads_confirm)
.setIcon(R.drawable.ic_cancel_multiple)
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(R.string.confirm) { _, _ ->
viewModel.cancelAll()
}.show()
}
private fun confirmRemoveCompleted() {
MaterialAlertDialogBuilder(
context,
com.google.android.material.R.style.ThemeOverlay_Material3_MaterialAlertDialog_Centered,
).setTitle(R.string.remove_completed)
.setMessage(R.string.remove_completed_downloads_confirm)
.setIcon(R.drawable.ic_clear_all)
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(R.string.clear) { _, _ ->
viewModel.removeCompleted()
}.show()
}
}

View File

@@ -0,0 +1,75 @@
package org.koitharu.kotatsu.download.ui.list
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.RectF
import android.view.View
import androidx.cardview.widget.CardView
import androidx.core.content.ContextCompat
import androidx.core.graphics.ColorUtils
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.NO_ID
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.list.decor.AbstractSelectionItemDecoration
import org.koitharu.kotatsu.utils.ext.getItem
import org.koitharu.kotatsu.utils.ext.getThemeColor
import com.google.android.material.R as materialR
class DownloadsSelectionDecoration(context: Context) : AbstractSelectionItemDecoration() {
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
private val checkIcon = ContextCompat.getDrawable(context, materialR.drawable.ic_mtrl_checked_circle)
private val iconOffset = context.resources.getDimensionPixelOffset(R.dimen.card_indicator_offset)
private val iconSize = context.resources.getDimensionPixelOffset(R.dimen.card_indicator_size)
private val strokeColor = context.getThemeColor(materialR.attr.colorPrimary, Color.RED)
private val fillColor = ColorUtils.setAlphaComponent(
ColorUtils.blendARGB(strokeColor, context.getThemeColor(materialR.attr.colorSurface), 0.8f),
0x74,
)
private val defaultRadius = context.resources.getDimension(R.dimen.list_selector_corner)
init {
hasBackground = false
hasForeground = true
isIncludeDecorAndMargins = false
paint.strokeWidth = context.resources.getDimension(R.dimen.selection_stroke_width)
checkIcon?.setTint(strokeColor)
}
override fun getItemId(parent: RecyclerView, child: View): Long {
val holder = parent.getChildViewHolder(child) ?: return NO_ID
val item = holder.getItem(DownloadItemModel::class.java) ?: return NO_ID
return item.id.mostSignificantBits
}
override fun onDrawForeground(
canvas: Canvas,
parent: RecyclerView,
child: View,
bounds: RectF,
state: RecyclerView.State,
) {
val isCard = child is CardView
val radius = (child as? CardView)?.radius ?: defaultRadius
paint.color = fillColor
paint.style = Paint.Style.FILL
canvas.drawRoundRect(bounds, radius, radius, paint)
paint.color = strokeColor
paint.style = Paint.Style.STROKE
canvas.drawRoundRect(bounds, radius, radius, paint)
if (isCard) {
checkIcon?.run {
setBounds(
(bounds.right - iconSize - iconOffset).toInt(),
(bounds.top + iconOffset).toInt(),
(bounds.right - iconOffset).toInt(),
(bounds.top + iconOffset + iconSize).toInt(),
)
draw(canvas)
}
}
}
}

View File

@@ -0,0 +1,246 @@
package org.koitharu.kotatsu.download.ui.list
import androidx.collection.LongSparseArray
import androidx.collection.getOrElse
import androidx.collection.set
import androidx.lifecycle.viewModelScope
import androidx.work.Data
import androidx.work.WorkInfo
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.plus
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.domain.MangaDataRepository
import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.base.ui.util.ReversibleAction
import org.koitharu.kotatsu.core.ui.DateTimeAgo
import org.koitharu.kotatsu.download.domain.DownloadState
import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
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.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.asFlowLiveData
import org.koitharu.kotatsu.utils.ext.daysDiff
import java.util.Date
import java.util.UUID
import java.util.concurrent.TimeUnit
import javax.inject.Inject
@HiltViewModel
class DownloadsViewModel @Inject constructor(
private val workScheduler: DownloadWorker.Scheduler,
private val mangaDataRepository: MangaDataRepository,
) : BaseViewModel() {
private val mangaCache = LongSparseArray<Manga>()
private val cacheMutex = Mutex()
private val works = workScheduler.observeWorks()
.mapLatest { it.toDownloadsList() }
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)
val onActionDone = SingleLiveEvent<ReversibleAction>()
val items = works.map {
it?.toUiList() ?: listOf(LoadingState)
}.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState))
val hasPausedWorks = works.map {
it?.any { x -> x.canResume } == true
}.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, false)
val hasActiveWorks = works.map {
it?.any { x -> x.canPause } == true
}.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, false)
val hasCancellableWorks = works.map {
it?.any { x -> !x.workState.isFinished } == true
}.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, false)
fun cancel(id: UUID) {
launchJob(Dispatchers.Default) {
workScheduler.cancel(id)
}
}
fun cancel(ids: Set<Long>) {
launchJob(Dispatchers.Default) {
val snapshot = works.value ?: return@launchJob
for (work in snapshot) {
if (work.id.mostSignificantBits in ids) {
workScheduler.cancel(work.id)
}
}
onActionDone.emitCall(ReversibleAction(R.string.downloads_cancelled, null))
}
}
fun cancelAll() {
launchJob(Dispatchers.Default) {
workScheduler.cancelAll()
onActionDone.emitCall(ReversibleAction(R.string.downloads_cancelled, null))
}
}
fun pause(ids: Set<Long>) {
val snapshot = works.value ?: return
for (work in snapshot) {
if (work.id.mostSignificantBits in ids) {
workScheduler.pause(work.id)
}
}
onActionDone.call(ReversibleAction(R.string.downloads_paused, null))
}
fun pauseAll() {
val snapshot = works.value ?: return
var isPaused = false
for (work in snapshot) {
if (work.canPause) {
workScheduler.pause(work.id)
isPaused = true
}
}
if (isPaused) {
onActionDone.call(ReversibleAction(R.string.downloads_paused, null))
}
}
fun resumeAll() {
val snapshot = works.value ?: return
var isResumed = false
for (work in snapshot) {
if (work.workState == WorkInfo.State.RUNNING && work.isPaused) {
workScheduler.resume(work.id)
isResumed = true
}
}
if (isResumed) {
onActionDone.call(ReversibleAction(R.string.downloads_resumed, null))
}
}
fun resume(ids: Set<Long>) {
val snapshot = works.value ?: return
for (work in snapshot) {
if (work.id.mostSignificantBits in ids) {
workScheduler.resume(work.id)
}
}
onActionDone.call(ReversibleAction(R.string.downloads_resumed, null))
}
fun remove(ids: Set<Long>) {
launchJob(Dispatchers.Default) {
val snapshot = works.value ?: return@launchJob
for (work in snapshot) {
if (work.id.mostSignificantBits in ids) {
workScheduler.delete(work.id)
}
}
onActionDone.emitCall(ReversibleAction(R.string.downloads_removed, null))
}
}
fun removeCompleted() {
launchJob(Dispatchers.Default) {
workScheduler.removeCompleted()
onActionDone.emitCall(ReversibleAction(R.string.downloads_removed, null))
}
}
fun snapshot(ids: Set<Long>): Collection<DownloadItemModel> {
return works.value?.filterTo(ArrayList(ids.size)) { x -> x.id.mostSignificantBits in ids }.orEmpty()
}
fun allIds(): Set<Long> = works.value?.mapToSet {
it.id.mostSignificantBits
} ?: emptySet()
private suspend fun List<WorkInfo>.toDownloadsList(): List<DownloadItemModel> {
if (isEmpty()) {
return emptyList()
}
val list = mapNotNullTo(ArrayList(size)) { it.toUiModel() }
list.sortByDescending { it.timestamp }
return list
}
private fun List<DownloadItemModel>.toUiList(): List<ListModel> {
if (isEmpty()) {
return emptyStateList()
}
val destination = ArrayList<ListModel>((size * 1.4).toInt())
var prevDate: DateTimeAgo? = null
for (item in this) {
val date = timeAgo(item.timestamp)
if (prevDate != date) {
destination += date
}
prevDate = date
destination += item
}
return destination
}
private suspend fun WorkInfo.toUiModel(): DownloadItemModel? {
val workData = if (outputData == Data.EMPTY) progress else outputData
val mangaId = DownloadState.getMangaId(workData)
if (mangaId == 0L) return null
val manga = getManga(mangaId) ?: return null
return DownloadItemModel(
id = id,
workState = state,
manga = manga,
error = DownloadState.getError(workData),
isIndeterminate = DownloadState.isIndeterminate(workData),
isPaused = DownloadState.isPaused(workData),
max = DownloadState.getMax(workData),
progress = DownloadState.getProgress(workData),
eta = DownloadState.getEta(workData),
timestamp = DownloadState.getTimestamp(workData),
totalChapters = DownloadState.getDownloadedChapters(workData).size,
)
}
private fun timeAgo(date: Date): DateTimeAgo {
val diff = (System.currentTimeMillis() - date.time).coerceAtLeast(0L)
val diffMinutes = TimeUnit.MILLISECONDS.toMinutes(diff).toInt()
val diffDays = -date.daysDiff(System.currentTimeMillis())
return when {
diffMinutes < 3 -> DateTimeAgo.JustNow
diffDays < 1 -> DateTimeAgo.Today
diffDays == 1 -> DateTimeAgo.Yesterday
diffDays < 6 -> DateTimeAgo.DaysAgo(diffDays)
else -> DateTimeAgo.Absolute(date)
}
}
private fun emptyStateList() = listOf(
EmptyState(
icon = R.drawable.ic_empty_common,
textPrimary = R.string.text_downloads_list_holder,
textSecondary = 0,
actionStringRes = 0,
),
)
private suspend fun getManga(mangaId: Long): Manga? {
mangaCache[mangaId]?.let {
return it
}
return cacheMutex.withLock {
mangaCache.getOrElse(mangaId) {
mangaDataRepository.findMangaById(mangaId)?.also { mangaCache[mangaId] = it } ?: return null
}
}
}
}

View File

@@ -1,356 +0,0 @@
package org.koitharu.kotatsu.download.ui.service
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.os.Build
import android.text.format.DateUtils
import android.util.SparseArray
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.PendingIntentCompat
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.toBitmap
import androidx.core.text.HtmlCompat
import androidx.core.text.htmlEncode
import androidx.core.text.parseAsHtml
import androidx.core.util.forEach
import androidx.core.util.isNotEmpty
import androidx.core.util.size
import com.google.android.material.R as materialR
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.download.domain.DownloadState
import org.koitharu.kotatsu.download.ui.DownloadsActivity
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.ellipsize
import org.koitharu.kotatsu.parsers.util.format
import org.koitharu.kotatsu.search.ui.MangaListActivity
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
class DownloadNotification(private val context: Context) {
private val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
private val states = SparseArray<DownloadState>()
private val groupBuilder = NotificationCompat.Builder(context, CHANNEL_ID)
private val queueIntent = PendingIntentCompat.getActivity(
context,
REQUEST_QUEUE,
DownloadsActivity.newIntent(context),
0,
false,
)
private val localListIntent = PendingIntentCompat.getActivity(
context,
REQUEST_LIST_LOCAL,
MangaListActivity.newIntent(context, MangaSource.LOCAL),
0,
false,
)
init {
groupBuilder.setOnlyAlertOnce(true)
groupBuilder.setDefaults(0)
groupBuilder.color = ContextCompat.getColor(context, R.color.blue_primary)
groupBuilder.foregroundServiceBehavior = NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE
groupBuilder.setSilent(true)
groupBuilder.setGroup(GROUP_ID)
groupBuilder.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN)
groupBuilder.setGroupSummary(true)
groupBuilder.setContentTitle(context.getString(R.string.downloading_manga))
}
fun buildGroupNotification(): Notification {
val style = NotificationCompat.InboxStyle(groupBuilder)
var progress = 0f
var isAllDone = true
var isInProgress = false
groupBuilder.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
states.forEach { _, state ->
if (state.manga.isNsfw) {
groupBuilder.setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
}
val summary = when (state) {
is DownloadState.Cancelled -> {
progress++
context.getString(R.string.cancelling_)
}
is DownloadState.Done -> {
progress++
context.getString(R.string.download_complete)
}
is DownloadState.Error -> {
isAllDone = false
context.getString(R.string.error)
}
is DownloadState.PostProcessing -> {
progress++
isInProgress = true
isAllDone = false
context.getString(R.string.processing_)
}
is DownloadState.Preparing -> {
isAllDone = false
isInProgress = true
context.getString(R.string.preparing_)
}
is DownloadState.Progress -> {
isAllDone = false
isInProgress = true
progress += state.percent
context.getString(R.string.percent_string_pattern, (state.percent * 100).format())
}
is DownloadState.Queued -> {
isAllDone = false
isInProgress = true
context.getString(R.string.queued)
}
}
style.addLine(
context.getString(
R.string.download_summary_pattern,
state.manga.title.ellipsize(16).htmlEncode(),
summary.htmlEncode(),
).parseAsHtml(HtmlCompat.FROM_HTML_MODE_LEGACY),
)
}
progress = if (isInProgress) {
progress / states.size.toFloat()
} else {
1f
}
style.setBigContentTitle(
context.getString(if (isAllDone) R.string.download_complete else R.string.downloading_manga),
)
groupBuilder.setContentText(context.resources.getQuantityString(R.plurals.items, states.size, states.size()))
groupBuilder.setNumber(states.size)
groupBuilder.setSmallIcon(
if (isInProgress) android.R.drawable.stat_sys_download else android.R.drawable.stat_sys_download_done,
)
groupBuilder.setContentIntent(if (isAllDone) localListIntent else queueIntent)
groupBuilder.setAutoCancel(isAllDone)
when (progress) {
1f -> groupBuilder.setProgress(0, 0, false)
0f -> groupBuilder.setProgress(1, 0, true)
else -> groupBuilder.setProgress(100, (progress * 100f).toInt(), false)
}
return groupBuilder.build()
}
fun detach() {
if (states.isNotEmpty()) {
val notification = buildGroupNotification()
manager.notify(ID_GROUP_DETACHED, notification)
}
manager.cancel(ID_GROUP)
}
fun newItem(startId: Int) = Item(startId)
inner class Item(
private val startId: Int,
) {
private val builder = NotificationCompat.Builder(context, CHANNEL_ID)
private val cancelAction = NotificationCompat.Action(
materialR.drawable.material_ic_clear_black_24dp,
context.getString(android.R.string.cancel),
PendingIntentCompat.getBroadcast(
context,
startId * 2,
DownloadService.getCancelIntent(startId),
PendingIntent.FLAG_CANCEL_CURRENT,
false,
),
)
private val retryAction = NotificationCompat.Action(
R.drawable.ic_restart_black,
context.getString(R.string.try_again),
PendingIntentCompat.getBroadcast(
context,
startId * 2 + 1,
DownloadService.getResumeIntent(startId),
PendingIntent.FLAG_CANCEL_CURRENT,
false,
),
)
init {
builder.setOnlyAlertOnce(true)
builder.setDefaults(0)
builder.color = ContextCompat.getColor(context, R.color.blue_primary)
builder.foregroundServiceBehavior = NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE
builder.setSilent(true)
builder.setGroup(GROUP_ID)
builder.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN)
}
fun notify(state: DownloadState, timeLeft: Long) {
builder.setContentTitle(state.manga.title)
builder.setContentText(context.getString(R.string.manga_downloading_))
builder.setProgress(1, 0, true)
builder.setSmallIcon(android.R.drawable.stat_sys_download)
builder.setContentIntent(queueIntent)
builder.setStyle(null)
builder.setLargeIcon(state.cover?.toBitmap())
builder.clearActions()
builder.setSubText(null)
builder.setShowWhen(false)
builder.setVisibility(
if (state.manga.isNsfw) {
NotificationCompat.VISIBILITY_PRIVATE
} else {
NotificationCompat.VISIBILITY_PUBLIC
},
)
when (state) {
is DownloadState.Cancelled -> {
builder.setProgress(1, 0, true)
builder.setContentText(context.getString(R.string.cancelling_))
builder.setContentIntent(null)
builder.setStyle(null)
builder.setOngoing(true)
builder.priority = NotificationCompat.PRIORITY_DEFAULT
}
is DownloadState.Done -> {
builder.setProgress(0, 0, false)
builder.setContentText(context.getString(R.string.download_complete))
builder.setContentIntent(createMangaIntent(context, state.localManga))
builder.setAutoCancel(true)
builder.setSmallIcon(android.R.drawable.stat_sys_download_done)
builder.setCategory(null)
builder.setStyle(null)
builder.setOngoing(false)
builder.setShowWhen(true)
builder.setWhen(System.currentTimeMillis())
builder.priority = NotificationCompat.PRIORITY_DEFAULT
}
is DownloadState.Error -> {
val message = state.error.getDisplayMessage(context.resources)
builder.setProgress(0, 0, false)
builder.setSmallIcon(android.R.drawable.stat_notify_error)
builder.setSubText(context.getString(R.string.error))
builder.setContentText(message)
builder.setAutoCancel(!state.canRetry)
builder.setOngoing(state.canRetry)
builder.setCategory(NotificationCompat.CATEGORY_ERROR)
builder.setShowWhen(true)
builder.setWhen(System.currentTimeMillis())
builder.setStyle(NotificationCompat.BigTextStyle().bigText(message))
if (state.canRetry) {
builder.addAction(cancelAction)
builder.addAction(retryAction)
}
builder.priority = NotificationCompat.PRIORITY_DEFAULT
}
is DownloadState.PostProcessing -> {
builder.setProgress(1, 0, true)
builder.setContentText(context.getString(R.string.processing_))
builder.setStyle(null)
builder.setOngoing(true)
builder.priority = NotificationCompat.PRIORITY_DEFAULT
}
is DownloadState.Queued -> {
builder.setProgress(0, 0, false)
builder.setContentText(context.getString(R.string.queued))
builder.setStyle(null)
builder.setOngoing(true)
builder.addAction(cancelAction)
builder.priority = NotificationCompat.PRIORITY_LOW
}
is DownloadState.Preparing -> {
builder.setProgress(1, 0, true)
builder.setContentText(context.getString(R.string.preparing_))
builder.setStyle(null)
builder.setOngoing(true)
builder.addAction(cancelAction)
builder.priority = NotificationCompat.PRIORITY_DEFAULT
}
is DownloadState.Progress -> {
builder.setProgress(state.max, state.progress, false)
val percent = context.getString(R.string.percent_string_pattern, (state.percent * 100).format())
if (timeLeft > 0L) {
val eta = DateUtils.getRelativeTimeSpanString(timeLeft, 0L, DateUtils.SECOND_IN_MILLIS)
builder.setContentText(eta)
builder.setSubText(percent)
} else {
builder.setContentText(percent)
}
builder.setCategory(NotificationCompat.CATEGORY_PROGRESS)
builder.setStyle(null)
builder.setOngoing(true)
builder.addAction(cancelAction)
builder.priority = NotificationCompat.PRIORITY_DEFAULT
}
}
val notification = builder.build()
states.append(startId, state)
updateGroupNotification()
manager.notify(TAG, startId, notification)
}
fun dismiss() {
manager.cancel(TAG, startId)
states.remove(startId)
updateGroupNotification()
}
}
private fun updateGroupNotification() {
val notification = buildGroupNotification()
manager.notify(ID_GROUP, notification)
}
private fun createMangaIntent(context: Context, manga: Manga) = PendingIntentCompat.getActivity(
context,
manga.hashCode(),
DetailsActivity.newIntent(context, manga),
PendingIntent.FLAG_CANCEL_CURRENT,
false,
)
companion object {
private const val TAG = "download"
private const val CHANNEL_ID = "download"
private const val GROUP_ID = "downloads"
private const val REQUEST_QUEUE = 6
private const val REQUEST_LIST_LOCAL = 7
const val ID_GROUP = 9999
private const val ID_GROUP_DETACHED = 9998
fun createChannel(context: Context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val manager = NotificationManagerCompat.from(context)
if (manager.getNotificationChannel(CHANNEL_ID) == null) {
val channel = NotificationChannel(
CHANNEL_ID,
context.getString(R.string.downloads),
NotificationManager.IMPORTANCE_LOW,
)
channel.enableVibration(false)
channel.enableLights(false)
channel.setSound(null, null)
manager.createNotificationChannel(channel)
}
}
}
}
}

View File

@@ -1,276 +0,0 @@
package org.koitharu.kotatsu.download.ui.service
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.Binder
import android.os.IBinder
import android.os.PowerManager
import android.view.View
import androidx.annotation.MainThread
import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.transformWhile
import kotlinx.coroutines.launch
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseService
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.download.domain.DownloadManager
import org.koitharu.kotatsu.download.domain.DownloadState
import org.koitharu.kotatsu.download.ui.DownloadsActivity
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.progress.PausingProgressJob
import org.koitharu.kotatsu.utils.progress.ProgressJob
import org.koitharu.kotatsu.utils.progress.TimeLeftEstimator
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import kotlin.collections.set
@AndroidEntryPoint
class DownloadService : BaseService() {
private lateinit var downloadNotification: DownloadNotification
private lateinit var wakeLock: PowerManager.WakeLock
@Inject
lateinit var downloadManager: DownloadManager
private val jobs = LinkedHashMap<Int, PausingProgressJob<DownloadState>>()
private val jobCount = MutableStateFlow(0)
private val controlReceiver = ControlReceiver()
override fun onCreate() {
super.onCreate()
isRunning = true
downloadNotification = DownloadNotification(this)
wakeLock = (applicationContext.getSystemService(Context.POWER_SERVICE) as PowerManager)
.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "kotatsu:downloading")
wakeLock.acquire(TimeUnit.HOURS.toMillis(8))
DownloadNotification.createChannel(this)
startForeground(DownloadNotification.ID_GROUP, downloadNotification.buildGroupNotification())
val intentFilter = IntentFilter()
intentFilter.addAction(ACTION_DOWNLOAD_CANCEL)
intentFilter.addAction(ACTION_DOWNLOAD_RESUME)
ContextCompat.registerReceiver(this, controlReceiver, intentFilter, ContextCompat.RECEIVER_NOT_EXPORTED)
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
super.onStartCommand(intent, flags, startId)
val manga = intent?.getParcelableExtraCompat<ParcelableManga>(EXTRA_MANGA)?.manga
val chapters = intent?.getLongArrayExtra(EXTRA_CHAPTERS_IDS)
return if (manga != null) {
jobs[startId] = downloadManga(startId, manga, chapters)
jobCount.value = jobs.size
START_REDELIVER_INTENT
} else {
stopSelfIfIdle()
START_NOT_STICKY
}
}
override fun onBind(intent: Intent): IBinder {
super.onBind(intent)
return DownloadBinder(this)
}
override fun onDestroy() {
unregisterReceiver(controlReceiver)
if (wakeLock.isHeld) {
wakeLock.release()
}
isRunning = false
super.onDestroy()
}
private fun downloadManga(
startId: Int,
manga: Manga,
chaptersIds: LongArray?,
): PausingProgressJob<DownloadState> {
val job = downloadManager.downloadManga(manga, chaptersIds, startId)
listenJob(job)
return job
}
private fun listenJob(job: ProgressJob<DownloadState>) {
lifecycleScope.launch {
val startId = job.progressValue.startId
val notificationItem = downloadNotification.newItem(startId)
try {
val timeLeftEstimator = TimeLeftEstimator()
notificationItem.notify(job.progressValue, -1L)
job.progressAsFlow()
.onEach { state ->
if (state is DownloadState.Progress) {
timeLeftEstimator.tick(value = state.progress, total = state.max)
} else {
timeLeftEstimator.emptyTick()
}
}
.throttle { state -> if (state is DownloadState.Progress) 400L else 0L }
.whileActive()
.collect { state ->
val timeLeft = timeLeftEstimator.getEstimatedTimeLeft()
notificationItem.notify(state, timeLeft)
}
job.join()
} finally {
(job.progressValue as? DownloadState.Done)?.let {
sendBroadcast(
Intent(ACTION_DOWNLOAD_COMPLETE)
.putExtra(EXTRA_MANGA, ParcelableManga(it.localManga, withChapters = false)),
)
}
if (job.isCancelled) {
notificationItem.dismiss()
if (jobs.remove(startId) != null) {
jobCount.value = jobs.size
}
} else {
notificationItem.notify(job.progressValue, -1L)
}
}
}.invokeOnCompletion {
stopSelfIfIdle()
}
}
private fun Flow<DownloadState>.whileActive(): Flow<DownloadState> = transformWhile { state ->
emit(state)
!state.isTerminal
}
@MainThread
private fun stopSelfIfIdle() {
if (jobs.any { (_, job) -> job.isActive }) {
return
}
downloadNotification.detach()
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
stopSelf()
}
inner class ControlReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent?) {
when (intent?.action) {
ACTION_DOWNLOAD_CANCEL -> {
val cancelId = intent.getIntExtra(EXTRA_CANCEL_ID, 0)
jobs[cancelId]?.cancel()
}
ACTION_DOWNLOAD_RESUME -> {
val cancelId = intent.getIntExtra(EXTRA_CANCEL_ID, 0)
jobs[cancelId]?.resume()
}
}
}
}
class DownloadBinder(service: DownloadService) : Binder(), DefaultLifecycleObserver {
private var downloadsStateFlow = MutableStateFlow<List<PausingProgressJob<DownloadState>>>(emptyList())
init {
service.lifecycle.addObserver(this)
service.jobCount.onEach {
downloadsStateFlow.value = service.jobs.values.toList()
}.launchIn(service.lifecycleScope)
}
override fun onDestroy(owner: LifecycleOwner) {
owner.lifecycle.removeObserver(this)
downloadsStateFlow.value = emptyList()
super.onDestroy(owner)
}
val downloads
get() = downloadsStateFlow.asStateFlow()
}
companion object {
var isRunning: Boolean = false
private set
@Deprecated("Use LocalMangaRepository.watchReadableDirs instead")
const val ACTION_DOWNLOAD_COMPLETE = "${BuildConfig.APPLICATION_ID}.action.ACTION_DOWNLOAD_COMPLETE"
private const val ACTION_DOWNLOAD_CANCEL = "${BuildConfig.APPLICATION_ID}.action.ACTION_DOWNLOAD_CANCEL"
private const val ACTION_DOWNLOAD_RESUME = "${BuildConfig.APPLICATION_ID}.action.ACTION_DOWNLOAD_RESUME"
const val EXTRA_MANGA = "manga"
private const val EXTRA_CHAPTERS_IDS = "chapters_ids"
private const val EXTRA_CANCEL_ID = "cancel_id"
fun start(view: View, manga: Manga, chaptersIds: Collection<Long>? = null) {
if (chaptersIds?.isEmpty() == true) {
return
}
val intent = Intent(view.context, DownloadService::class.java)
intent.putExtra(EXTRA_MANGA, ParcelableManga(manga, withChapters = false))
if (chaptersIds != null) {
intent.putExtra(EXTRA_CHAPTERS_IDS, chaptersIds.toLongArray())
}
ContextCompat.startForegroundService(view.context, intent)
showStartedSnackbar(view)
}
fun start(view: View, manga: Collection<Manga>) {
if (manga.isEmpty()) {
return
}
for (item in manga) {
val intent = Intent(view.context, DownloadService::class.java)
intent.putExtra(EXTRA_MANGA, ParcelableManga(item, withChapters = false))
ContextCompat.startForegroundService(view.context, intent)
}
showStartedSnackbar(view)
}
fun confirmAndStart(view: View, items: Set<Manga>) {
MaterialAlertDialogBuilder(view.context)
.setTitle(R.string.save_manga)
.setMessage(R.string.batch_manga_save_confirm)
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(R.string.save) { _, _ ->
start(view, items)
}.show()
}
fun getCancelIntent(startId: Int) = Intent(ACTION_DOWNLOAD_CANCEL)
.putExtra(EXTRA_CANCEL_ID, startId)
fun getResumeIntent(startId: Int) = Intent(ACTION_DOWNLOAD_RESUME)
.putExtra(EXTRA_CANCEL_ID, startId)
fun getDownloadedManga(intent: Intent?): Manga? {
if (intent?.action == ACTION_DOWNLOAD_COMPLETE) {
return intent.getParcelableExtraCompat<ParcelableManga>(EXTRA_MANGA)?.manga
}
return null
}
private fun showStartedSnackbar(view: View) {
Snackbar.make(view, R.string.download_started, Snackbar.LENGTH_LONG)
.setAction(R.string.details) {
it.context.startActivity(DownloadsActivity.newIntent(it.context))
}.show()
}
}
}

View File

@@ -0,0 +1,270 @@
package org.koitharu.kotatsu.download.ui.worker
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.graphics.drawable.Drawable
import android.os.Build
import android.text.format.DateUtils
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.PendingIntentCompat
import androidx.core.graphics.drawable.toBitmap
import androidx.work.WorkManager
import coil.ImageLoader
import coil.request.ImageRequest
import coil.size.Scale
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.download.domain.DownloadState
import org.koitharu.kotatsu.download.ui.list.DownloadsActivity
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.format
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.search.ui.MangaListActivity
import org.koitharu.kotatsu.utils.ext.getDrawableOrThrow
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import java.util.UUID
import com.google.android.material.R as materialR
private const val CHANNEL_ID = "download"
private const val GROUP_ID = "downloads"
class DownloadNotificationFactory @AssistedInject constructor(
@ApplicationContext private val context: Context,
private val coil: ImageLoader,
@Assisted private val uuid: UUID,
) {
private val covers = HashMap<Manga, Drawable>()
private val builder = NotificationCompat.Builder(context, CHANNEL_ID)
private val mutex = Mutex()
private val coverWidth = context.resources.getDimensionPixelSize(
androidx.core.R.dimen.compat_notification_large_icon_max_width,
)
private val coverHeight = context.resources.getDimensionPixelSize(
androidx.core.R.dimen.compat_notification_large_icon_max_height,
)
private val queueIntent = PendingIntentCompat.getActivity(
context,
0,
DownloadsActivity.newIntent(context),
0,
false,
)
private val actionCancel by lazy {
NotificationCompat.Action(
materialR.drawable.material_ic_clear_black_24dp,
context.getString(android.R.string.cancel),
WorkManager.getInstance(context).createCancelPendingIntent(uuid),
)
}
private val actionPause by lazy {
NotificationCompat.Action(
R.drawable.ic_action_pause,
context.getString(R.string.pause),
PausingReceiver.createPausePendingIntent(context, uuid),
)
}
private val actionResume by lazy {
NotificationCompat.Action(
R.drawable.ic_action_resume,
context.getString(R.string.resume),
PausingReceiver.createResumePendingIntent(context, uuid),
)
}
init {
createChannel()
builder.setOnlyAlertOnce(true)
builder.setDefaults(0)
builder.foregroundServiceBehavior = NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE
builder.setSilent(true)
builder.setGroup(GROUP_ID)
builder.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN)
builder.priority = NotificationCompat.PRIORITY_DEFAULT
}
suspend fun create(state: DownloadState?): Notification = mutex.withLock {
if (state == null) {
builder.setContentTitle(context.getString(R.string.manga_downloading_))
builder.setContentText(context.getString(R.string.preparing_))
} else {
builder.setContentTitle(state.manga.title)
builder.setContentText(context.getString(R.string.manga_downloading_))
}
builder.setProgress(1, 0, true)
builder.setSmallIcon(android.R.drawable.stat_sys_download)
builder.setContentIntent(queueIntent)
builder.setStyle(null)
builder.setLargeIcon(if (state != null) getCover(state.manga)?.toBitmap() else null)
builder.clearActions()
builder.setSubText(null)
builder.setShowWhen(false)
builder.setVisibility(
if (state != null && state.manga.isNsfw) {
NotificationCompat.VISIBILITY_PRIVATE
} else {
NotificationCompat.VISIBILITY_PUBLIC
},
)
when {
state == null -> Unit
state.localManga != null -> { // downloaded, final state
builder.setProgress(0, 0, false)
builder.setContentText(context.getString(R.string.download_complete))
builder.setContentIntent(createMangaIntent(context, state.localManga.manga))
builder.setAutoCancel(true)
builder.setSmallIcon(android.R.drawable.stat_sys_download_done)
builder.setCategory(null)
builder.setStyle(null)
builder.setOngoing(false)
builder.setShowWhen(true)
builder.setWhen(System.currentTimeMillis())
}
state.isStopped -> {
builder.setProgress(0, 0, false)
builder.setContentText(context.getString(R.string.queued))
builder.setCategory(NotificationCompat.CATEGORY_PROGRESS)
builder.setStyle(null)
builder.setOngoing(true)
builder.setSmallIcon(R.drawable.ic_stat_paused)
builder.addAction(actionCancel)
}
state.isPaused -> { // paused (with error or manually)
builder.setProgress(state.max, state.progress, false)
val percent = if (state.percent >= 0) {
context.getString(R.string.percent_string_pattern, (state.percent * 100).format())
} else {
null
}
if (state.error != null) {
builder.setContentText(context.getString(R.string.download_summary_pattern, percent, state.error))
} else {
builder.setContentText(percent)
}
builder.setCategory(NotificationCompat.CATEGORY_PROGRESS)
builder.setStyle(null)
builder.setOngoing(true)
builder.setSmallIcon(R.drawable.ic_stat_paused)
builder.addAction(actionCancel)
builder.addAction(actionResume)
}
state.error != null -> { // error, final state
builder.setProgress(0, 0, false)
builder.setSmallIcon(android.R.drawable.stat_notify_error)
builder.setSubText(context.getString(R.string.error))
builder.setContentText(state.error)
builder.setAutoCancel(true)
builder.setOngoing(false)
builder.setCategory(NotificationCompat.CATEGORY_ERROR)
builder.setShowWhen(true)
builder.setWhen(System.currentTimeMillis())
builder.setStyle(NotificationCompat.BigTextStyle().bigText(state.error))
}
else -> {
builder.setProgress(state.max, state.progress, false)
builder.setContentText(getProgressString(state.percent, state.eta))
builder.setCategory(NotificationCompat.CATEGORY_PROGRESS)
builder.setStyle(null)
builder.setOngoing(true)
builder.addAction(actionCancel)
builder.addAction(actionPause)
}
}
return builder.build()
}
private fun getProgressString(percent: Float, eta: Long): CharSequence? {
val percentString = if (percent >= 0f) {
context.getString(R.string.percent_string_pattern, (percent * 100).format())
} else {
null
}
val etaString = if (eta > 0L) {
DateUtils.getRelativeTimeSpanString(
eta,
System.currentTimeMillis(),
DateUtils.SECOND_IN_MILLIS,
)
} else {
null
}
return when {
percentString == null && etaString == null -> null
percentString != null && etaString == null -> percentString
percentString == null && etaString != null -> etaString
else -> context.getString(R.string.download_summary_pattern, percentString, etaString)
}
}
private fun createMangaIntent(context: Context, manga: Manga?) = PendingIntentCompat.getActivity(
context,
manga.hashCode(),
if (manga != null) {
DetailsActivity.newIntent(context, manga)
} else {
MangaListActivity.newIntent(context, MangaSource.LOCAL)
},
PendingIntent.FLAG_CANCEL_CURRENT,
false,
)
private suspend fun getCover(manga: Manga) = covers[manga] ?: run {
runCatchingCancellable {
coil.execute(
ImageRequest.Builder(context)
.data(manga.coverUrl)
.allowHardware(false)
.tag(manga.source)
.size(coverWidth, coverHeight)
.scale(Scale.FILL)
.build(),
).getDrawableOrThrow()
}.onSuccess {
covers[manga] = it
}.onFailure {
it.printStackTraceDebug()
}.getOrNull()
}
private fun createChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val manager = NotificationManagerCompat.from(context)
if (manager.getNotificationChannel(CHANNEL_ID) == null) {
val channel = NotificationChannel(
CHANNEL_ID,
context.getString(R.string.downloads),
NotificationManager.IMPORTANCE_LOW,
)
channel.enableVibration(false)
channel.enableLights(false)
channel.setSound(null, null)
manager.createNotificationChannel(channel)
}
}
}
@AssistedFactory
interface Factory {
fun create(uuid: UUID): DownloadNotificationFactory
}
}

View File

@@ -0,0 +1,25 @@
package org.koitharu.kotatsu.download.ui.worker
import android.view.View
import androidx.lifecycle.Observer
import com.google.android.material.snackbar.Snackbar
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.download.ui.list.DownloadsActivity
import org.koitharu.kotatsu.main.ui.owners.BottomNavOwner
import org.koitharu.kotatsu.utils.ext.findActivity
class DownloadStartedObserver(
private val snackbarHost: View,
) : Observer<Unit> {
override fun onChanged(value: Unit) {
val snackbar = Snackbar.make(snackbarHost, R.string.download_started, Snackbar.LENGTH_LONG)
(snackbarHost.context.findActivity() as? BottomNavOwner)?.let {
snackbar.anchorView = it.bottomNav
}
snackbar.setAction(R.string.details) {
it.context.startActivity(DownloadsActivity.newIntent(it.context))
}
snackbar.show()
}
}

View File

@@ -0,0 +1,454 @@
package org.koitharu.kotatsu.download.ui.worker
import android.app.NotificationManager
import android.content.Context
import android.webkit.MimeTypeMap
import androidx.core.content.ContextCompat
import androidx.hilt.work.HiltWorker
import androidx.lifecycle.asFlow
import androidx.work.BackoffPolicy
import androidx.work.Constraints
import androidx.work.CoroutineWorker
import androidx.work.Data
import androidx.work.ForegroundInfo
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.OutOfQuotaPolicy
import androidx.work.WorkInfo
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import androidx.work.await
import dagger.Reusable
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.internal.closeQuietly
import okio.IOException
import okio.buffer
import okio.sink
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.domain.MangaDataRepository
import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.download.domain.DownloadState
import org.koitharu.kotatsu.local.data.LocalManga
import org.koitharu.kotatsu.local.data.LocalStorageChanges
import org.koitharu.kotatsu.local.data.PagesCache
import org.koitharu.kotatsu.local.data.input.LocalMangaInput
import org.koitharu.kotatsu.local.data.output.LocalMangaOutput
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.await
import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.utils.Throttler
import org.koitharu.kotatsu.utils.WorkManagerHelper
import org.koitharu.kotatsu.utils.ext.deleteAwait
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.ifNullOrEmpty
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import org.koitharu.kotatsu.utils.ext.runCatchingCancellable
import org.koitharu.kotatsu.utils.ext.writeAllCancellable
import org.koitharu.kotatsu.utils.progress.TimeLeftEstimator
import java.io.File
import java.util.UUID
import java.util.concurrent.TimeUnit
import javax.inject.Inject
@HiltWorker
class DownloadWorker @AssistedInject constructor(
@Assisted appContext: Context,
@Assisted params: WorkerParameters,
private val okHttp: OkHttpClient,
private val cache: PagesCache,
private val localMangaRepository: LocalMangaRepository,
private val mangaDataRepository: MangaDataRepository,
private val settings: AppSettings,
private val mangaRepositoryFactory: MangaRepository.Factory,
@LocalStorageChanges private val localStorageChanges: MutableSharedFlow<LocalManga?>,
notificationFactoryFactory: DownloadNotificationFactory.Factory,
) : CoroutineWorker(appContext, params) {
private val notificationFactory = notificationFactoryFactory.create(params.id)
private val notificationManager = appContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
@Volatile
private var lastPublishedState: DownloadState? = null
private val currentState: DownloadState
get() = checkNotNull(lastPublishedState)
private val pausingHandle = PausingHandle()
private val timeLeftEstimator = TimeLeftEstimator()
private val notificationThrottler = Throttler(400)
private val pausingReceiver = PausingReceiver(params.id, pausingHandle)
override suspend fun doWork(): Result {
setForeground(getForegroundInfo())
val mangaId = inputData.getLong(MANGA_ID, 0L)
val manga = mangaDataRepository.findMangaById(mangaId) ?: return Result.failure()
val chaptersIds = inputData.getLongArray(CHAPTERS_IDS)?.takeUnless { it.isEmpty() }
val downloadedIds = getDoneChapters()
lastPublishedState = DownloadState(manga, isIndeterminate = true)
return try {
downloadMangaImpl(chaptersIds, downloadedIds)
Result.success(currentState.toWorkData())
} catch (e: CancellationException) {
withContext(NonCancellable) {
val notification = notificationFactory.create(currentState.copy(isStopped = true))
notificationManager.notify(id.hashCode(), notification)
}
throw e
} catch (e: IOException) {
e.printStackTraceDebug()
Result.retry()
} catch (e: Exception) {
e.printStackTraceDebug()
Result.failure(
currentState.copy(
error = e.getDisplayMessage(applicationContext.resources),
eta = -1L,
).toWorkData(),
)
} finally {
notificationManager.cancel(id.hashCode())
}
}
override suspend fun getForegroundInfo() = ForegroundInfo(
id.hashCode(),
notificationFactory.create(lastPublishedState),
)
private suspend fun downloadMangaImpl(
includedIds: LongArray?,
excludedIds: LongArray,
) {
var manga = currentState.manga
val chaptersToSkip = excludedIds.toMutableSet()
withMangaLock(manga) {
ContextCompat.registerReceiver(
applicationContext,
pausingReceiver,
PausingReceiver.createIntentFilter(id),
ContextCompat.RECEIVER_NOT_EXPORTED,
)
val destination = localMangaRepository.getOutputDir(manga)
checkNotNull(destination) { applicationContext.getString(R.string.cannot_find_available_storage) }
val tempFileName = "${manga.id}_$id.tmp"
var output: LocalMangaOutput? = null
try {
if (manga.source == MangaSource.LOCAL) {
manga = localMangaRepository.getRemoteManga(manga)
?: error("Cannot obtain remote manga instance")
}
val repo = mangaRepositoryFactory.create(manga.source)
val mangaDetails = if (manga.chapters.isNullOrEmpty()) repo.getDetails(manga) else manga
output = LocalMangaOutput.getOrCreate(destination, mangaDetails)
val coverUrl = mangaDetails.largeCoverUrl.ifNullOrEmpty { mangaDetails.coverUrl }
if (coverUrl.isNotEmpty()) {
downloadFile(coverUrl, destination, tempFileName, repo.source).let { file ->
output.addCover(file, MimeTypeMap.getFileExtensionFromUrl(coverUrl))
}
}
val chapters = getChapters(mangaDetails, includedIds)
for ((chapterIndex, chapter) in chapters.withIndex()) {
if (chaptersToSkip.remove(chapter.id)) {
publishState(
currentState.copy(
downloadedChapters = currentState.downloadedChapters + chapter.id,
),
)
continue
}
val pages = runFailsafe(pausingHandle) {
repo.getPages(chapter)
}
for ((pageIndex, page) in pages.withIndex()) {
runFailsafe(pausingHandle) {
val url = repo.getPageUrl(page)
val file = cache.get(url)
?: downloadFile(url, destination, tempFileName, repo.source)
output.addPage(
chapter = chapter,
file = file,
pageNumber = pageIndex,
ext = MimeTypeMap.getFileExtensionFromUrl(url),
)
}
publishState(
currentState.copy(
totalChapters = chapters.size,
currentChapter = chapterIndex,
totalPages = pages.size,
currentPage = pageIndex,
isIndeterminate = false,
eta = timeLeftEstimator.getEta(),
),
)
if (settings.isDownloadsSlowdownEnabled) {
delay(SLOWDOWN_DELAY)
}
}
if (output.flushChapter(chapter)) {
runCatchingCancellable {
localStorageChanges.emit(LocalMangaInput.of(output.rootFile).getManga())
}.onFailure(Throwable::printStackTraceDebug)
}
publishState(
currentState.copy(
downloadedChapters = currentState.downloadedChapters + chapter.id,
),
)
}
publishState(currentState.copy(isIndeterminate = true, eta = -1L))
output.mergeWithExisting()
output.finish()
val localManga = LocalMangaInput.of(output.rootFile).getManga()
localStorageChanges.emit(localManga)
publishState(currentState.copy(localManga = localManga, eta = -1L))
} catch (e: Exception) {
if (e !is CancellationException) {
publishState(currentState.copy(error = e.getDisplayMessage(applicationContext.resources)))
}
throw e
} finally {
withContext(NonCancellable) {
applicationContext.unregisterReceiver(pausingReceiver)
output?.closeQuietly()
output?.cleanup()
File(destination, tempFileName).deleteAwait()
}
}
}
}
private suspend fun <R> runFailsafe(
pausingHandle: PausingHandle,
block: suspend () -> R,
): R {
if (pausingHandle.isPaused) {
publishState(currentState.copy(isPaused = true, eta = -1L))
pausingHandle.awaitResumed()
publishState(currentState.copy(isPaused = false))
}
var countDown = MAX_FAILSAFE_ATTEMPTS
failsafe@ while (true) {
try {
return block()
} catch (e: IOException) {
if (countDown <= 0) {
publishState(
currentState.copy(
isPaused = true,
error = e.getDisplayMessage(applicationContext.resources),
eta = -1L,
),
)
countDown = MAX_FAILSAFE_ATTEMPTS
pausingHandle.pause()
pausingHandle.awaitResumed()
publishState(currentState.copy(isPaused = false, error = null))
} else {
countDown--
delay(DOWNLOAD_ERROR_DELAY)
}
}
}
}
private suspend fun downloadFile(
url: String,
destination: File,
tempFileName: String,
source: MangaSource,
): File {
val request = Request.Builder()
.url(url)
.tag(MangaSource::class.java, source)
.header(CommonHeaders.ACCEPT, "image/webp,image/png;q=0.9,image/jpeg,*/*;q=0.8")
.cacheControl(CommonHeaders.CACHE_CONTROL_NO_STORE)
.get()
.build()
val call = okHttp.newCall(request)
val file = File(destination, tempFileName)
val response = call.clone().await()
checkNotNull(response.body).use { body ->
file.sink(append = false).buffer().use {
it.writeAllCancellable(body.source())
}
}
return file
}
private suspend fun publishState(state: DownloadState) {
val previousState = currentState
lastPublishedState = state
if (previousState.isParticularProgress && state.isParticularProgress) {
timeLeftEstimator.tick(state.progress, state.max)
} else {
timeLeftEstimator.emptyTick()
notificationThrottler.reset()
}
val notification = notificationFactory.create(state)
if (state.isFinalState) {
notificationManager.notify(id.toString(), id.hashCode(), notification)
} else if (notificationThrottler.throttle()) {
notificationManager.notify(id.hashCode(), notification)
} else {
return
}
setProgress(state.toWorkData())
}
private suspend fun getDoneChapters(): LongArray {
val work = WorkManagerHelper(WorkManager.getInstance(applicationContext)).getWorkInfoById(id)
?: return LongArray(0)
return DownloadState.getDownloadedChapters(work.progress)
}
private fun getChapters(
manga: Manga,
includedIds: LongArray?,
): List<MangaChapter> {
val chapters = checkNotNull(manga.chapters?.toMutableList()) {
"Chapters list must not be null"
}
if (includedIds != null) {
val chaptersIdsSet = includedIds.toMutableSet()
chapters.retainAll { x -> chaptersIdsSet.remove(x.id) }
check(chaptersIdsSet.isEmpty()) {
"${chaptersIdsSet.size} of ${includedIds.size} requested chapters not found in manga"
}
}
check(chapters.isNotEmpty()) { "Chapters list must not be empty" }
return chapters
}
private suspend inline fun <T> withMangaLock(manga: Manga, block: () -> T) = try {
localMangaRepository.lockManga(manga.id)
block()
} finally {
localMangaRepository.unlockManga(manga.id)
}
@Reusable
class Scheduler @Inject constructor(
@ApplicationContext private val context: Context,
private val dataRepository: MangaDataRepository,
private val settings: AppSettings,
) {
private val workManager: WorkManager
inline get() = WorkManager.getInstance(context)
suspend fun schedule(manga: Manga, chaptersIds: Collection<Long>?) {
dataRepository.storeManga(manga)
val data = Data.Builder()
.putLong(MANGA_ID, manga.id)
if (!chaptersIds.isNullOrEmpty()) {
data.putLongArray(CHAPTERS_IDS, chaptersIds.toLongArray())
}
scheduleImpl(listOf(data.build()))
}
suspend fun schedule(manga: Collection<Manga>) {
val data = manga.map {
dataRepository.storeManga(it)
Data.Builder()
.putLong(MANGA_ID, it.id)
.build()
}
scheduleImpl(data)
}
fun observeWorks(): Flow<List<WorkInfo>> = workManager
.getWorkInfosByTagLiveData(TAG)
.asFlow()
suspend fun cancel(id: UUID) {
workManager.cancelWorkById(id).await()
}
suspend fun cancelAll() {
workManager.cancelAllWorkByTag(TAG).await()
}
fun pause(id: UUID) {
val intent = PausingReceiver.getPauseIntent(id)
context.sendBroadcast(intent)
}
fun resume(id: UUID) {
val intent = PausingReceiver.getResumeIntent(id)
context.sendBroadcast(intent)
}
suspend fun delete(id: UUID) {
WorkManagerHelper(workManager).deleteWork(id)
}
suspend fun removeCompleted() {
val helper = WorkManagerHelper(workManager)
val finishedWorks = helper.getFinishedWorkInfosByTag(TAG)
helper.deleteWorks(finishedWorks.mapToSet { it.id })
}
suspend fun updateConstraints() {
val constraints = Constraints.Builder()
.setRequiresStorageNotLow(true)
.setRequiredNetworkType(if (settings.isDownloadsWiFiOnly) NetworkType.UNMETERED else NetworkType.CONNECTED)
.build()
val helper = WorkManagerHelper(workManager)
val works = helper.getWorkInfosByTag(TAG)
for (work in works) {
if (work.state.isFinished) {
continue
}
val request = OneTimeWorkRequestBuilder<DownloadWorker>()
.setConstraints(constraints)
.setId(work.id)
.build()
helper.updateWork(request)
}
}
private suspend fun scheduleImpl(data: Collection<Data>) {
val constraints = Constraints.Builder()
.setRequiresStorageNotLow(true)
.setRequiredNetworkType(if (settings.isDownloadsWiFiOnly) NetworkType.UNMETERED else NetworkType.CONNECTED)
.build()
val requests = data.map { inputData ->
OneTimeWorkRequestBuilder<DownloadWorker>()
.setConstraints(constraints)
.addTag(TAG)
.keepResultsForAtLeast(7, TimeUnit.DAYS)
.setBackoffCriteria(BackoffPolicy.LINEAR, 10, TimeUnit.SECONDS)
.setInputData(inputData)
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
.build()
}
workManager.enqueue(requests).await()
}
}
private companion object {
const val MAX_FAILSAFE_ATTEMPTS = 2
const val DOWNLOAD_ERROR_DELAY = 500L
const val SLOWDOWN_DELAY = 100L
const val MANGA_ID = "manga_id"
const val CHAPTERS_IDS = "chapters"
const val TAG = "download"
}
}

View File

@@ -1,4 +1,4 @@
package org.koitharu.kotatsu.download.ui.service package org.koitharu.kotatsu.download.ui.worker
import androidx.annotation.AnyThread import androidx.annotation.AnyThread
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -27,4 +27,4 @@ class PausingHandle {
fun resume() { fun resume() {
paused.value = false paused.value = false
} }
} }

View File

@@ -0,0 +1,67 @@
package org.koitharu.kotatsu.download.ui.worker
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.net.Uri
import android.os.PatternMatcher
import androidx.core.app.PendingIntentCompat
import org.koitharu.kotatsu.utils.ext.toUUIDOrNull
import java.util.UUID
class PausingReceiver(
private val id: UUID,
private val pausingHandle: PausingHandle,
) : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent?) {
val uuid = intent?.getStringExtra(EXTRA_UUID)?.toUUIDOrNull()
if (uuid != id) {
return
}
when (intent.action) {
ACTION_RESUME -> pausingHandle.resume()
ACTION_PAUSE -> pausingHandle.pause()
}
}
companion object {
private const val ACTION_PAUSE = "org.koitharu.kotatsu.download.PAUSE"
private const val ACTION_RESUME = "org.koitharu.kotatsu.download.RESUME"
private const val EXTRA_UUID = "uuid"
private const val SCHEME = "workuid"
fun createIntentFilter(id: UUID) = IntentFilter().apply {
addAction(ACTION_PAUSE)
addAction(ACTION_RESUME)
addDataScheme(SCHEME)
addDataPath(id.toString(), PatternMatcher.PATTERN_SIMPLE_GLOB)
}
fun getPauseIntent(id: UUID) = Intent(ACTION_PAUSE)
.setData(Uri.parse("$SCHEME://$id"))
.putExtra(EXTRA_UUID, id.toString())
fun getResumeIntent(id: UUID) = Intent(ACTION_RESUME)
.setData(Uri.parse("$SCHEME://$id"))
.putExtra(EXTRA_UUID, id.toString())
fun createPausePendingIntent(context: Context, id: UUID) = PendingIntentCompat.getBroadcast(
context,
0,
getPauseIntent(id),
0,
false,
)
fun createResumePendingIntent(context: Context, id: UUID) = PendingIntentCompat.getBroadcast(
context,
0,
getResumeIntent(id),
0,
false,
)
}
}

View File

@@ -1,11 +1,16 @@
package org.koitharu.kotatsu.explore.domain package org.koitharu.kotatsu.explore.domain
import javax.inject.Inject
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.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.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.suggestions.domain.TagsBlacklist
import org.koitharu.kotatsu.utils.ext.almostEquals
import org.koitharu.kotatsu.utils.ext.asArrayList
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import org.koitharu.kotatsu.utils.ext.runCatchingCancellable
import javax.inject.Inject
class ExploreRepository @Inject constructor( class ExploreRepository @Inject constructor(
private val settings: AppSettings, private val settings: AppSettings,
@@ -14,29 +19,47 @@ class ExploreRepository @Inject constructor(
) { ) {
suspend fun findRandomManga(tagsLimit: Int): Manga { suspend fun findRandomManga(tagsLimit: Int): Manga {
val blacklistTagRegex = settings.getSuggestionsTagsBlacklistRegex() val blacklistTagRegex = TagsBlacklist(settings.suggestionsTagsBlacklist, 0.4f)
val allTags = historyRepository.getPopularTags(tagsLimit).filterNot { val tags = historyRepository.getPopularTags(tagsLimit).mapNotNull {
blacklistTagRegex?.containsMatchIn(it.title) ?: false if (it in blacklistTagRegex) null else it.title
} }
val tag = allTags.randomOrNull() val sources = settings.getMangaSources(includeHidden = false)
val source = checkNotNull(tag?.source ?: settings.getMangaSources(includeHidden = false).randomOrNull()) { check(sources.isNotEmpty()) { "No sources available" }
"No sources found" for (i in 0..4) {
} val list = getList(sources.random(), tags, blacklistTagRegex)
val repo = mangaRepositoryFactory.create(source) val manga = list.randomOrNull() ?: continue
val list = repo.getList( val details = runCatchingCancellable {
offset = 0, mangaRepositoryFactory.create(manga.source).getDetails(manga)
sortOrder = if (SortOrder.UPDATED in repo.sortOrders) SortOrder.UPDATED else null, }.getOrNull() ?: continue
tags = setOfNotNull(tag), if ((settings.isSuggestionsExcludeNsfw && details.isNsfw) || details in blacklistTagRegex) {
).shuffled()
for (item in list) {
if (settings.isSuggestionsExcludeNsfw && item.isNsfw) {
continue continue
} }
if (blacklistTagRegex != null && item.tags.any { x -> blacklistTagRegex.containsMatchIn(x.title) }) { return details
continue
}
return item
} }
return list.random() throw NoSuchElementException()
} }
private suspend fun getList(
source: MangaSource,
tags: List<String>,
blacklist: TagsBlacklist,
): List<Manga> = runCatchingCancellable {
val repository = mangaRepositoryFactory.create(source)
val order = repository.sortOrders.random()
val availableTags = repository.getTags()
val tag = tags.firstNotNullOfOrNull { title ->
availableTags.find { x -> x.title.almostEquals(title, 0.4f) }
}
val list = repository.getList(0, setOfNotNull(tag), order).asArrayList()
if (settings.isSuggestionsExcludeNsfw) {
list.removeAll { it.isNsfw }
}
if (blacklist.isNotEmpty()) {
list.removeAll { manga -> manga in blacklist }
}
list.shuffle()
list
}.onFailure {
it.printStackTraceDebug()
}.getOrDefault(emptyList())
} }

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.explore.ui package org.koitharu.kotatsu.explore.ui
import android.content.DialogInterface
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.MenuItem import android.view.MenuItem
@@ -16,6 +17,7 @@ import coil.ImageLoader
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.BaseFragment import org.koitharu.kotatsu.base.ui.BaseFragment
import org.koitharu.kotatsu.base.ui.dialog.TwoButtonsAlertDialog
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.base.ui.util.RecyclerViewOwner import org.koitharu.kotatsu.base.ui.util.RecyclerViewOwner
import org.koitharu.kotatsu.base.ui.util.ReversibleActionObserver import org.koitharu.kotatsu.base.ui.util.ReversibleActionObserver
@@ -76,6 +78,9 @@ class ExploreFragment :
viewModel.onOpenManga.observe(viewLifecycleOwner, ::onOpenManga) viewModel.onOpenManga.observe(viewLifecycleOwner, ::onOpenManga)
viewModel.onActionDone.observe(viewLifecycleOwner, ReversibleActionObserver(binding.recyclerView)) viewModel.onActionDone.observe(viewLifecycleOwner, ReversibleActionObserver(binding.recyclerView))
viewModel.isGrid.observe(viewLifecycleOwner, ::onGridModeChanged) viewModel.isGrid.observe(viewLifecycleOwner, ::onGridModeChanged)
viewModel.onShowSuggestionsTip.observe(viewLifecycleOwner) {
showSuggestionsTip()
}
} }
override fun onDestroyView() { override fun onDestroyView() {
@@ -143,6 +148,19 @@ class ExploreFragment :
activity?.invalidateOptionsMenu() activity?.invalidateOptionsMenu()
} }
private fun showSuggestionsTip() {
val listener = DialogInterface.OnClickListener { _, which ->
viewModel.respondSuggestionTip(which == DialogInterface.BUTTON_POSITIVE)
}
TwoButtonsAlertDialog.Builder(requireContext())
.setIcon(R.drawable.ic_suggestion)
.setTitle(R.string.suggestions_enable_prompt)
.setPositiveButton(R.string.enable, listener)
.setNegativeButton(R.string.no_thanks, listener)
.create()
.show()
}
private inner class SourceMenuListener( private inner class SourceMenuListener(
private val sourceItem: ExploreItem.Source, private val sourceItem: ExploreItem.Source,
) : PopupMenu.OnMenuItemClickListener { ) : PopupMenu.OnMenuItemClickListener {

View File

@@ -6,7 +6,6 @@ import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
@@ -27,6 +26,8 @@ import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.asFlowLiveData import org.koitharu.kotatsu.utils.asFlowLiveData
import javax.inject.Inject import javax.inject.Inject
private const val TIP_SUGGESTIONS = "suggestions"
@HiltViewModel @HiltViewModel
class ExploreViewModel @Inject constructor( class ExploreViewModel @Inject constructor(
private val settings: AppSettings, private val settings: AppSettings,
@@ -41,6 +42,7 @@ class ExploreViewModel @Inject constructor(
val onOpenManga = SingleLiveEvent<Manga>() val onOpenManga = SingleLiveEvent<Manga>()
val onActionDone = SingleLiveEvent<ReversibleAction>() val onActionDone = SingleLiveEvent<ReversibleAction>()
val onShowSuggestionsTip = SingleLiveEvent<Unit>()
val isGrid = gridMode.asFlowLiveData(viewModelScope.coroutineContext) val isGrid = gridMode.asFlowLiveData(viewModelScope.coroutineContext)
val content: LiveData<List<ExploreItem>> = isLoading.asFlow().flatMapLatest { loading -> val content: LiveData<List<ExploreItem>> = isLoading.asFlow().flatMapLatest { loading ->
@@ -51,10 +53,18 @@ class ExploreViewModel @Inject constructor(
} }
}.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, listOf(ExploreItem.Loading)) }.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, listOf(ExploreItem.Loading))
init {
launchJob(Dispatchers.Default) {
if (!settings.isSuggestionsEnabled && settings.isTipEnabled(TIP_SUGGESTIONS)) {
onShowSuggestionsTip.emitCall(Unit)
}
}
}
fun openRandom() { fun openRandom() {
launchLoadingJob(Dispatchers.Default) { launchLoadingJob(Dispatchers.Default) {
val manga = exploreRepository.findRandomManga(tagsLimit = 8) val manga = exploreRepository.findRandomManga(tagsLimit = 8)
onOpenManga.postCall(manga) onOpenManga.emitCall(manga)
} }
} }
@@ -64,7 +74,7 @@ class ExploreViewModel @Inject constructor(
val rollback = ReversibleHandle { val rollback = ReversibleHandle {
settings.hiddenSources -= source.name settings.hiddenSources -= source.name
} }
onActionDone.postCall(ReversibleAction(R.string.source_disabled, rollback)) onActionDone.emitCall(ReversibleAction(R.string.source_disabled, rollback))
} }
} }
@@ -72,6 +82,11 @@ class ExploreViewModel @Inject constructor(
settings.isSourcesGridMode = value settings.isSourcesGridMode = value
} }
fun respondSuggestionTip(isAccepted: Boolean) {
settings.isSuggestionsEnabled = isAccepted
settings.closeTip(TIP_SUGGESTIONS)
}
private fun createContentFlow() = settings.observe() private fun createContentFlow() = settings.observe()
.filter { .filter {
it == AppSettings.KEY_SOURCES_HIDDEN || it == AppSettings.KEY_SOURCES_HIDDEN ||
@@ -80,7 +95,6 @@ class ExploreViewModel @Inject constructor(
} }
.onStart { emit("") } .onStart { emit("") }
.map { settings.getMangaSources(includeHidden = false) } .map { settings.getMangaSources(includeHidden = false) }
.distinctUntilChanged()
.combine(gridMode) { content, grid -> buildList(content, grid) } .combine(gridMode) { content, grid -> buildList(content, grid) }
private fun buildList(sources: List<MangaSource>, isGrid: Boolean): List<ExploreItem> { private fun buildList(sources: List<MangaSource>, isGrid: Boolean): List<ExploreItem> {

View File

@@ -1,9 +1,11 @@
package org.koitharu.kotatsu.favourites.data package org.koitharu.kotatsu.favourites.data
import org.koitharu.kotatsu.core.db.entity.SortOrder import org.koitharu.kotatsu.core.db.entity.SortOrder
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.core.model.FavouriteCategory
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
import java.util.* import java.util.Date
fun FavouriteCategoryEntity.toFavouriteCategory(id: Long = categoryId.toLong()) = FavouriteCategory( fun FavouriteCategoryEntity.toFavouriteCategory(id: Long = categoryId.toLong()) = FavouriteCategory(
id = id, id = id,
@@ -13,4 +15,8 @@ fun FavouriteCategoryEntity.toFavouriteCategory(id: Long = categoryId.toLong())
createdAt = Date(createdAt), createdAt = Date(createdAt),
isTrackingEnabled = track, isTrackingEnabled = track,
isVisibleInLibrary = isVisibleInLibrary, isVisibleInLibrary = isVisibleInLibrary,
) )
fun FavouriteManga.toManga() = manga.toManga(tags.toMangaTags())
fun Collection<FavouriteManga>.toMangaList() = map { it.toManga() }

View File

@@ -17,6 +17,10 @@ abstract class FavouritesDao {
@Query("SELECT * FROM favourites WHERE deleted_at = 0 GROUP BY manga_id ORDER BY created_at DESC") @Query("SELECT * FROM favourites WHERE deleted_at = 0 GROUP BY manga_id ORDER BY created_at DESC")
abstract suspend fun findAll(): List<FavouriteManga> abstract suspend fun findAll(): List<FavouriteManga>
@Transaction
@Query("SELECT * FROM favourites WHERE deleted_at = 0 GROUP BY manga_id ORDER BY created_at DESC LIMIT :limit")
abstract suspend fun findLast(limit: Int): List<FavouriteManga>
fun observeAll(order: SortOrder): Flow<List<FavouriteManga>> { fun observeAll(order: SortOrder): Flow<List<FavouriteManga>> {
val orderBy = getOrderBy(order) val orderBy = getOrderBy(order)

View File

@@ -12,12 +12,12 @@ import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.entity.SortOrder import org.koitharu.kotatsu.core.db.entity.SortOrder
import org.koitharu.kotatsu.core.db.entity.toEntities import org.koitharu.kotatsu.core.db.entity.toEntities
import org.koitharu.kotatsu.core.db.entity.toEntity import org.koitharu.kotatsu.core.db.entity.toEntity
import org.koitharu.kotatsu.core.db.entity.toManga
import org.koitharu.kotatsu.core.db.entity.toMangaTags
import org.koitharu.kotatsu.core.model.FavouriteCategory import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity
import org.koitharu.kotatsu.favourites.data.FavouriteEntity import org.koitharu.kotatsu.favourites.data.FavouriteEntity
import org.koitharu.kotatsu.favourites.data.toFavouriteCategory import org.koitharu.kotatsu.favourites.data.toFavouriteCategory
import org.koitharu.kotatsu.favourites.data.toManga
import org.koitharu.kotatsu.favourites.data.toMangaList
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.tracker.work.TrackerNotificationChannels import org.koitharu.kotatsu.tracker.work.TrackerNotificationChannels
@@ -32,22 +32,27 @@ class FavouritesRepository @Inject constructor(
suspend fun getAllManga(): List<Manga> { suspend fun getAllManga(): List<Manga> {
val entities = db.favouritesDao.findAll() val entities = db.favouritesDao.findAll()
return entities.map { it.manga.toManga(it.tags.toMangaTags()) } return entities.toMangaList()
}
suspend fun getLastManga(limit: Int): List<Manga> {
val entities = db.favouritesDao.findLast(limit)
return entities.toMangaList()
} }
fun observeAll(order: SortOrder): Flow<List<Manga>> { fun observeAll(order: SortOrder): Flow<List<Manga>> {
return db.favouritesDao.observeAll(order) return db.favouritesDao.observeAll(order)
.mapItems { it.manga.toManga(it.tags.toMangaTags()) } .mapItems { it.toManga() }
} }
suspend fun getManga(categoryId: Long): List<Manga> { suspend fun getManga(categoryId: Long): List<Manga> {
val entities = db.favouritesDao.findAll(categoryId) val entities = db.favouritesDao.findAll(categoryId)
return entities.map { it.manga.toManga(it.tags.toMangaTags()) } return entities.toMangaList()
} }
fun observeAll(categoryId: Long, order: SortOrder): Flow<List<Manga>> { fun observeAll(categoryId: Long, order: SortOrder): Flow<List<Manga>> {
return db.favouritesDao.observeAll(categoryId, order) return db.favouritesDao.observeAll(categoryId, order)
.mapItems { it.manga.toManga(it.tags.toMangaTags()) } .mapItems { it.toManga() }
} }
fun observeAll(categoryId: Long): Flow<List<Manga>> { fun observeAll(categoryId: Long): Flow<List<Manga>> {

View File

@@ -25,6 +25,7 @@ import org.koitharu.kotatsu.favourites.ui.categories.FavouriteCategoriesActivity
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.utils.ext.getDisplayMessage import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.getSerializableCompat import org.koitharu.kotatsu.utils.ext.getSerializableCompat
import org.koitharu.kotatsu.utils.ext.setChecked
import com.google.android.material.R as materialR import com.google.android.material.R as materialR
@AndroidEntryPoint @AndroidEntryPoint
@@ -112,10 +113,8 @@ class FavouritesCategoryEditActivity :
selectedSortOrder = category?.order selectedSortOrder = category?.order
val sortText = getString((category?.order ?: SortOrder.NEWEST).titleRes) val sortText = getString((category?.order ?: SortOrder.NEWEST).titleRes)
binding.editSort.setText(sortText, false) binding.editSort.setText(sortText, false)
binding.switchTracker.isChecked = category?.isTrackingEnabled ?: true binding.switchTracker.setChecked(category?.isTrackingEnabled ?: true, false)
binding.switchTracker.jumpDrawablesToCurrentState() binding.switchShelf.setChecked(category?.isVisibleInLibrary ?: true, false)
binding.switchShelf.isChecked = category?.isVisibleInLibrary ?: true
binding.switchShelf.jumpDrawablesToCurrentState()
} }
private fun onError(e: Throwable) { private fun onError(e: Throwable) {

View File

@@ -14,6 +14,7 @@ import org.koitharu.kotatsu.favourites.ui.categories.edit.FavouritesCategoryEdit
import org.koitharu.kotatsu.favourites.ui.categories.edit.FavouritesCategoryEditActivity.Companion.NO_ID import org.koitharu.kotatsu.favourites.ui.categories.edit.FavouritesCategoryEditActivity.Companion.NO_ID
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.utils.SingleLiveEvent import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.ext.emitValue
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
@@ -34,7 +35,7 @@ class FavouritesCategoryEditViewModel @Inject constructor(
init { init {
launchLoadingJob(Dispatchers.Default) { launchLoadingJob(Dispatchers.Default) {
category.postValue( category.emitValue(
if (categoryId != NO_ID) { if (categoryId != NO_ID) {
repository.getCategory(categoryId) repository.getCategory(categoryId)
} else { } else {
@@ -57,7 +58,7 @@ class FavouritesCategoryEditViewModel @Inject constructor(
} else { } else {
repository.updateCategory(categoryId, title, sortOrder, isTrackerEnabled, isVisibleOnShelf) repository.updateCategory(categoryId, title, sortOrder, isTrackerEnabled, isVisibleOnShelf)
} }
onSaved.postCall(Unit) onSaved.emitCall(Unit)
} }
} }
} }

View File

@@ -9,11 +9,11 @@ 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.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.util.ReversibleAction import org.koitharu.kotatsu.base.ui.util.ReversibleAction
import org.koitharu.kotatsu.core.parser.MangaTagHighlighter import org.koitharu.kotatsu.core.parser.MangaTagHighlighter
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.favourites.ui.list.FavouritesListFragment.Companion.ARG_CATEGORY_ID import org.koitharu.kotatsu.favourites.ui.list.FavouritesListFragment.Companion.ARG_CATEGORY_ID
import org.koitharu.kotatsu.favourites.ui.list.FavouritesListFragment.Companion.NO_ID import org.koitharu.kotatsu.favourites.ui.list.FavouritesListFragment.Companion.NO_ID
@@ -28,7 +28,6 @@ import org.koitharu.kotatsu.list.ui.model.toUi
import org.koitharu.kotatsu.parsers.model.SortOrder 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.runCatchingCancellable
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
@@ -39,13 +38,11 @@ class FavouritesListViewModel @Inject constructor(
private val historyRepository: HistoryRepository, private val historyRepository: HistoryRepository,
private val settings: AppSettings, private val settings: AppSettings,
private val tagHighlighter: MangaTagHighlighter, private val tagHighlighter: MangaTagHighlighter,
) : MangaListViewModel(settings), ListExtraProvider { downloadScheduler: DownloadWorker.Scheduler,
) : MangaListViewModel(settings, downloadScheduler), ListExtraProvider {
val categoryId: Long = savedStateHandle[ARG_CATEGORY_ID] ?: NO_ID val categoryId: Long = savedStateHandle[ARG_CATEGORY_ID] ?: NO_ID
var categoryName: String? = null
private set
val sortOrder: LiveData<SortOrder?> = if (categoryId == NO_ID) { val sortOrder: LiveData<SortOrder?> = if (categoryId == NO_ID) {
MutableLiveData(null) MutableLiveData(null)
} else { } else {
@@ -82,18 +79,6 @@ class FavouritesListViewModel @Inject constructor(
emit(listOf(it.toErrorState(canRetry = false))) emit(listOf(it.toErrorState(canRetry = false)))
}.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState)) }.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState))
init {
if (categoryId != NO_ID) {
launchJob {
categoryName = withContext(Dispatchers.Default) {
runCatchingCancellable {
repository.getCategory(categoryId).title
}.getOrNull()
}
}
}
}
override fun onRefresh() = Unit override fun onRefresh() = Unit
override fun onRetry() = Unit override fun onRetry() = Unit
@@ -108,7 +93,7 @@ class FavouritesListViewModel @Inject constructor(
} else { } else {
repository.removeFromCategory(categoryId, ids) repository.removeFromCategory(categoryId, ids)
} }
onActionDone.postCall(ReversibleAction(R.string.removed_from_favourites, handle)) onActionDone.emitCall(ReversibleAction(R.string.removed_from_favourites, handle))
} }
} }

View File

@@ -15,6 +15,7 @@ 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.prefs.observeAsFlow import org.koitharu.kotatsu.core.prefs.observeAsFlow
import org.koitharu.kotatsu.core.ui.DateTimeAgo import org.koitharu.kotatsu.core.ui.DateTimeAgo
import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
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
@@ -29,6 +30,7 @@ import org.koitharu.kotatsu.list.ui.model.toListModel
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.daysDiff import org.koitharu.kotatsu.utils.ext.daysDiff
import org.koitharu.kotatsu.utils.ext.emitValue
import org.koitharu.kotatsu.utils.ext.onFirst import org.koitharu.kotatsu.utils.ext.onFirst
import java.util.Date import java.util.Date
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
@@ -40,12 +42,13 @@ class HistoryListViewModel @Inject constructor(
private val settings: AppSettings, private val settings: AppSettings,
private val trackingRepository: TrackingRepository, private val trackingRepository: TrackingRepository,
private val tagHighlighter: MangaTagHighlighter, private val tagHighlighter: MangaTagHighlighter,
) : MangaListViewModel(settings) { downloadScheduler: DownloadWorker.Scheduler,
) : MangaListViewModel(settings, downloadScheduler) {
val isGroupingEnabled = MutableLiveData<Boolean>() val isGroupingEnabled = MutableLiveData<Boolean>()
private val historyGrouping = settings.observeAsFlow(AppSettings.KEY_HISTORY_GROUPING) { isHistoryGroupingEnabled } private val historyGrouping = settings.observeAsFlow(AppSettings.KEY_HISTORY_GROUPING) { isHistoryGroupingEnabled }
.onEach { isGroupingEnabled.postValue(it) } .onEach { isGroupingEnabled.emitValue(it) }
override val content = combine( override val content = combine(
repository.observeAllWithHistory(), repository.observeAllWithHistory(),
@@ -77,7 +80,7 @@ class HistoryListViewModel @Inject constructor(
override fun onRetry() = Unit override fun onRetry() = Unit
fun clearHistory() { fun clearHistory() {
launchLoadingJob { launchLoadingJob(Dispatchers.Default) {
repository.clear() repository.clear()
} }
} }
@@ -88,7 +91,7 @@ class HistoryListViewModel @Inject constructor(
} }
launchJob(Dispatchers.Default) { launchJob(Dispatchers.Default) {
val handle = repository.delete(ids) val handle = repository.delete(ids)
onActionDone.postCall(ReversibleAction(R.string.removed_from_history, handle)) onActionDone.emitCall(ReversibleAction(R.string.removed_from_history, handle))
} }
} }

View File

@@ -17,11 +17,9 @@ import androidx.core.view.updatePadding
import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.GridLayoutManager
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import coil.ImageLoader import coil.ImageLoader
import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.domain.reverseAsync
import org.koitharu.kotatsu.base.ui.BaseFragment import org.koitharu.kotatsu.base.ui.BaseFragment
import org.koitharu.kotatsu.base.ui.list.FitHeightGridLayoutManager import org.koitharu.kotatsu.base.ui.list.FitHeightGridLayoutManager
import org.koitharu.kotatsu.base.ui.list.FitHeightLinearLayoutManager import org.koitharu.kotatsu.base.ui.list.FitHeightLinearLayoutManager
@@ -30,13 +28,13 @@ import org.koitharu.kotatsu.base.ui.list.PaginationScrollListener
import org.koitharu.kotatsu.base.ui.list.decor.SpacingItemDecoration import org.koitharu.kotatsu.base.ui.list.decor.SpacingItemDecoration
import org.koitharu.kotatsu.base.ui.list.decor.TypedSpacingItemDecoration import org.koitharu.kotatsu.base.ui.list.decor.TypedSpacingItemDecoration
import org.koitharu.kotatsu.base.ui.list.fastscroll.FastScroller import org.koitharu.kotatsu.base.ui.list.fastscroll.FastScroller
import org.koitharu.kotatsu.base.ui.util.ReversibleAction import org.koitharu.kotatsu.base.ui.util.ReversibleActionObserver
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
import org.koitharu.kotatsu.core.prefs.ListMode import org.koitharu.kotatsu.core.prefs.ListMode
import org.koitharu.kotatsu.databinding.FragmentListBinding import org.koitharu.kotatsu.databinding.FragmentListBinding
import org.koitharu.kotatsu.details.ui.DetailsActivity import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.download.ui.service.DownloadService import org.koitharu.kotatsu.download.ui.worker.DownloadStartedObserver
import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteCategoriesBottomSheet import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteCategoriesBottomSheet
import org.koitharu.kotatsu.list.ui.adapter.MangaListAdapter import org.koitharu.kotatsu.list.ui.adapter.MangaListAdapter
import org.koitharu.kotatsu.list.ui.adapter.MangaListAdapter.Companion.ITEM_TYPE_MANGA_GRID import org.koitharu.kotatsu.list.ui.adapter.MangaListAdapter.Companion.ITEM_TYPE_MANGA_GRID
@@ -46,7 +44,6 @@ import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.MangaItemModel import org.koitharu.kotatsu.list.ui.model.MangaItemModel
import org.koitharu.kotatsu.main.ui.MainActivity import org.koitharu.kotatsu.main.ui.MainActivity
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.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.reader.ui.ReaderActivity import org.koitharu.kotatsu.reader.ui.ReaderActivity
@@ -127,7 +124,8 @@ abstract class MangaListFragment :
viewModel.isLoading.observe(viewLifecycleOwner, ::onLoadingStateChanged) viewModel.isLoading.observe(viewLifecycleOwner, ::onLoadingStateChanged)
viewModel.content.observe(viewLifecycleOwner, ::onListChanged) viewModel.content.observe(viewLifecycleOwner, ::onListChanged)
viewModel.onError.observe(viewLifecycleOwner, SnackbarErrorObserver(binding.recyclerView, this)) viewModel.onError.observe(viewLifecycleOwner, SnackbarErrorObserver(binding.recyclerView, this))
viewModel.onActionDone.observe(viewLifecycleOwner, ::onActionDone) viewModel.onActionDone.observe(viewLifecycleOwner, ReversibleActionObserver(binding.recyclerView))
viewModel.onDownloadStarted.observe(viewLifecycleOwner, DownloadStartedObserver(binding.recyclerView))
} }
override fun onDestroyView() { override fun onDestroyView() {
@@ -173,17 +171,6 @@ abstract class MangaListFragment :
listAdapter?.setItems(list, listCommitCallback) listAdapter?.setItems(list, listCommitCallback)
} }
private fun onActionDone(action: ReversibleAction) {
val handle = action.handle
val length = if (handle == null) Snackbar.LENGTH_SHORT else Snackbar.LENGTH_LONG
val snackbar = Snackbar.make(binding.recyclerView, action.stringResId, length)
snackbar.anchorView = (activity as? BottomNavOwner)?.bottomNav
if (handle != null) {
snackbar.setAction(R.string.undo) { handle.reverseAsync() }
}
snackbar.show()
}
private fun resolveException(e: Throwable) { private fun resolveException(e: Throwable) {
if (ExceptionResolver.canResolve(e)) { if (ExceptionResolver.canResolve(e)) {
viewLifecycleScope.launch { viewLifecycleScope.launch {
@@ -313,7 +300,7 @@ abstract class MangaListFragment :
} }
R.id.action_save -> { R.id.action_save -> {
DownloadService.confirmAndStart(binding.recyclerView, selectedItems) viewModel.download(selectedItems)
mode.finish() mode.finish()
true true
} }

View File

@@ -11,13 +11,16 @@ 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.core.prefs.observeAsFlow import org.koitharu.kotatsu.core.prefs.observeAsFlow
import org.koitharu.kotatsu.core.prefs.observeAsLiveData import org.koitharu.kotatsu.core.prefs.observeAsLiveData
import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.utils.SingleLiveEvent import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.asFlowLiveData import org.koitharu.kotatsu.utils.asFlowLiveData
abstract class MangaListViewModel( abstract class MangaListViewModel(
private val settings: AppSettings, private val settings: AppSettings,
private val downloadScheduler: DownloadWorker.Scheduler,
) : BaseViewModel() { ) : BaseViewModel() {
abstract val content: LiveData<List<ListModel>> abstract val content: LiveData<List<ListModel>>
@@ -30,10 +33,18 @@ abstract class MangaListViewModel(
key = AppSettings.KEY_GRID_SIZE, key = AppSettings.KEY_GRID_SIZE,
valueProducer = { gridSize / 100f }, valueProducer = { gridSize / 100f },
) )
val onDownloadStarted = SingleLiveEvent<Unit>()
open fun onUpdateFilter(tags: Set<MangaTag>) = Unit open fun onUpdateFilter(tags: Set<MangaTag>) = Unit
abstract fun onRefresh() abstract fun onRefresh()
abstract fun onRetry() abstract fun onRetry()
fun download(items: Set<Manga>) {
launchJob(Dispatchers.Default) {
downloadScheduler.schedule(items)
onDownloadStarted.emitCall(Unit)
}
}
} }

View File

@@ -1,11 +1,16 @@
@file:androidx.annotation.OptIn(ExperimentalBadgeUtils::class)
package org.koitharu.kotatsu.list.ui.adapter package org.koitharu.kotatsu.list.ui.adapter
import android.view.View import android.view.View
import androidx.annotation.CheckResult import androidx.annotation.CheckResult
import androidx.cardview.widget.CardView
import androidx.core.view.doOnNextLayout import androidx.core.view.doOnNextLayout
import com.google.android.material.badge.BadgeDrawable import com.google.android.material.badge.BadgeDrawable
import com.google.android.material.badge.BadgeUtils import com.google.android.material.badge.BadgeUtils
import com.google.android.material.badge.ExperimentalBadgeUtils
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import com.google.android.material.R as materialR
@CheckResult @CheckResult
fun View.bindBadge(badge: BadgeDrawable?, counter: Int): BadgeDrawable? { fun View.bindBadge(badge: BadgeDrawable?, counter: Int): BadgeDrawable? {
@@ -13,7 +18,7 @@ fun View.bindBadge(badge: BadgeDrawable?, counter: Int): BadgeDrawable? {
val badgeDrawable = badge ?: initBadge(this) val badgeDrawable = badge ?: initBadge(this)
badgeDrawable.number = counter badgeDrawable.number = counter
badgeDrawable.isVisible = true badgeDrawable.isVisible = true
badgeDrawable.align() badgeDrawable.align(this)
badgeDrawable badgeDrawable
} else { } else {
badge?.isVisible = false badge?.isVisible = false
@@ -31,12 +36,17 @@ private fun initBadge(anchor: View): BadgeDrawable {
badge.maxCharacterCount = resources.getInteger(R.integer.manga_badge_max_character_count) badge.maxCharacterCount = resources.getInteger(R.integer.manga_badge_max_character_count)
anchor.doOnNextLayout { anchor.doOnNextLayout {
BadgeUtils.attachBadgeDrawable(badge, it) BadgeUtils.attachBadgeDrawable(badge, it)
badge.align() badge.align(it)
} }
return badge return badge
} }
private fun BadgeDrawable.align() { private fun BadgeDrawable.align(anchor: View) {
horizontalOffset = intrinsicWidth val extraOffset = if (anchor is CardView) {
verticalOffset = intrinsicHeight (anchor.radius / 2f).toInt()
} else {
anchor.resources.getDimensionPixelOffset(materialR.dimen.m3_badge_offset)
}
horizontalOffset = intrinsicWidth + extraOffset
verticalOffset = intrinsicHeight + extraOffset
} }

View File

@@ -1,12 +1,19 @@
package org.koitharu.kotatsu.list.ui.adapter package org.koitharu.kotatsu.list.ui.adapter
import androidx.lifecycle.LifecycleOwner
import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.databinding.ItemEmptyCardBinding import org.koitharu.kotatsu.databinding.ItemEmptyCardBinding
import org.koitharu.kotatsu.list.ui.model.EmptyHint import org.koitharu.kotatsu.list.ui.model.EmptyHint
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.utils.ext.disposeImageRequest
import org.koitharu.kotatsu.utils.ext.enqueueWith
import org.koitharu.kotatsu.utils.ext.newImageRequest
import org.koitharu.kotatsu.utils.ext.setTextAndVisible import org.koitharu.kotatsu.utils.ext.setTextAndVisible
fun emptyHintAD( fun emptyHintAD(
coil: ImageLoader,
lifecycleOwner: LifecycleOwner,
listener: ListStateHolderListener, listener: ListStateHolderListener,
) = adapterDelegateViewBinding<EmptyHint, ListModel, ItemEmptyCardBinding>( ) = adapterDelegateViewBinding<EmptyHint, ListModel, ItemEmptyCardBinding>(
{ inflater, parent -> ItemEmptyCardBinding.inflate(inflater, parent, false) }, { inflater, parent -> ItemEmptyCardBinding.inflate(inflater, parent, false) },
@@ -15,9 +22,13 @@ fun emptyHintAD(
binding.buttonRetry.setOnClickListener { listener.onEmptyActionClick() } binding.buttonRetry.setOnClickListener { listener.onEmptyActionClick() }
bind { bind {
binding.icon.setImageResource(item.icon) binding.icon.newImageRequest(lifecycleOwner, item.icon)?.enqueueWith(coil)
binding.textPrimary.setText(item.textPrimary) binding.textPrimary.setText(item.textPrimary)
binding.textSecondary.setTextAndVisible(item.textSecondary) binding.textSecondary.setTextAndVisible(item.textSecondary)
binding.buttonRetry.setTextAndVisible(item.actionStringRes) binding.buttonRetry.setTextAndVisible(item.actionStringRes)
} }
onViewRecycled {
binding.icon.disposeImageRequest()
}
} }

View File

@@ -1 +0,0 @@
package org.koitharu.kotatsu.local

View File

@@ -1,5 +1,7 @@
package org.koitharu.kotatsu.local.data package org.koitharu.kotatsu.local.data
import androidx.core.net.toFile
import androidx.core.net.toUri
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
import java.io.File import java.io.File
@@ -9,6 +11,8 @@ class LocalManga(
val manga: Manga, val manga: Manga,
) { ) {
constructor(manga: Manga) : this(manga.url.toUri().toFile(), manga)
var createdAt: Long = -1L var createdAt: Long = -1L
private set private set
get() { get() {

View File

@@ -7,15 +7,10 @@ import androidx.annotation.WorkerThread
import dagger.Reusable import dagger.Reusable
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flatMapMerge
import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.Cache import okhttp3.Cache
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.local.data.util.observe
import org.koitharu.kotatsu.parsers.util.mapToSet import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.utils.ext.computeSize import org.koitharu.kotatsu.utils.ext.computeSize
import org.koitharu.kotatsu.utils.ext.getStorageName import org.koitharu.kotatsu.utils.ext.getStorageName
@@ -36,6 +31,7 @@ class LocalStorageManager @Inject constructor(
val contentResolver: ContentResolver val contentResolver: ContentResolver
get() = context.contentResolver get() = context.contentResolver
@WorkerThread
fun createHttpCache(): Cache { fun createHttpCache(): Cache {
val directory = File(context.externalCacheDir ?: context.cacheDir, "http") val directory = File(context.externalCacheDir ?: context.cacheDir, "http")
directory.mkdirs() directory.mkdirs()
@@ -80,14 +76,6 @@ class LocalStorageManager @Inject constructor(
fun getStorageDisplayName(file: File) = file.getStorageName(context) fun getStorageDisplayName(file: File) = file.getStorageName(context)
fun observe(files: List<File>): Flow<File> {
if (files.isEmpty()) {
return emptyFlow()
}
return files.asFlow()
.flatMapMerge(files.size) { it.observe() }
}
@WorkerThread @WorkerThread
private fun getConfiguredStorageDirs(): MutableSet<File> { private fun getConfiguredStorageDirs(): MutableSet<File> {
val set = getAvailableStorageDirs() val set = getAvailableStorageDirs()

View File

@@ -84,7 +84,7 @@ class MangaIndex(source: String?) {
fun getCoverEntry(): String? = json.getStringOrNull("cover_entry") fun getCoverEntry(): String? = json.getStringOrNull("cover_entry")
fun addChapter(chapter: MangaChapter) { fun addChapter(chapter: MangaChapter, filename: String?) {
val chapters = json.getJSONObject("chapters") val chapters = json.getJSONObject("chapters")
if (!chapters.has(chapter.id.toString())) { if (!chapters.has(chapter.id.toString())) {
val jo = JSONObject() val jo = JSONObject()
@@ -95,6 +95,7 @@ class MangaIndex(source: String?) {
jo.put("scanlator", chapter.scanlator) jo.put("scanlator", chapter.scanlator)
jo.put("branch", chapter.branch) jo.put("branch", chapter.branch)
jo.put("entries", "%08d_%03d\\d{3}".format(chapter.branch.hashCode(), chapter.number)) jo.put("entries", "%08d_%03d\\d{3}".format(chapter.branch.hashCode(), chapter.number))
jo.put("file", filename)
chapters.put(chapter.id.toString(), jo) chapters.put(chapter.id.toString(), jo)
} }
} }
@@ -103,6 +104,10 @@ class MangaIndex(source: String?) {
return json.getJSONObject("chapters").remove(id.toString()) != null return json.getJSONObject("chapters").remove(id.toString()) != null
} }
fun getChapterFileName(chapterId: Long): String? {
return json.optJSONObject("chapters")?.optJSONObject(chapterId.toString())?.getStringOrNull("file")
}
fun setCoverEntry(name: String) { fun setCoverEntry(name: String) {
json.put("cover_entry", name) json.put("cover_entry", name)
} }

View File

@@ -6,61 +6,60 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okio.Source
import okio.buffer
import okio.sink
import org.koitharu.kotatsu.parsers.util.SuspendLazy
import org.koitharu.kotatsu.utils.FileSize import org.koitharu.kotatsu.utils.FileSize
import org.koitharu.kotatsu.utils.ext.copyToSuspending
import org.koitharu.kotatsu.utils.ext.longHashCode import org.koitharu.kotatsu.utils.ext.longHashCode
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import org.koitharu.kotatsu.utils.ext.runCatchingCancellable
import org.koitharu.kotatsu.utils.ext.subdir import org.koitharu.kotatsu.utils.ext.subdir
import org.koitharu.kotatsu.utils.ext.takeIfReadable import org.koitharu.kotatsu.utils.ext.takeIfReadable
import org.koitharu.kotatsu.utils.ext.takeIfWriteable import org.koitharu.kotatsu.utils.ext.takeIfWriteable
import org.koitharu.kotatsu.utils.ext.writeAllCancellable
import java.io.File import java.io.File
import java.io.InputStream
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@Singleton @Singleton
class PagesCache @Inject constructor(@ApplicationContext context: Context) { class PagesCache @Inject constructor(@ApplicationContext context: Context) {
private val cacheDir = checkNotNull(findSuitableDir(context)) { private val cacheDir = SuspendLazy {
val dirs = (context.externalCacheDirs + context.cacheDir).joinToString(";") { val dirs = context.externalCacheDirs + context.cacheDir
it?.absolutePath.toString() dirs.firstNotNullOf {
it?.subdir(CacheDir.PAGES.dir)?.takeIfWriteable()
} }
"Cannot find any suitable directory for PagesCache: [$dirs]"
} }
private val lruCache = createDiskLruCacheSafe( private val lruCache = SuspendLazy {
dir = cacheDir, val dir = cacheDir.get()
size = FileSize.MEGABYTES.convert(200, FileSize.BYTES), val size = FileSize.MEGABYTES.convert(200, FileSize.BYTES)
) runCatchingCancellable {
DiskLruCache.create(dir, size)
suspend fun get(url: String): File? = runInterruptible(Dispatchers.IO) { }.recoverCatching { error ->
lruCache.get(url)?.takeIfReadable() error.printStackTraceDebug()
dir.deleteRecursively()
dir.mkdir()
DiskLruCache.create(dir, size)
}.getOrThrow()
} }
suspend fun put(url: String, inputStream: InputStream): File = withContext(Dispatchers.IO) { suspend fun get(url: String): File? {
val file = File(cacheDir.parentFile, url.longHashCode().toString()) val cache = lruCache.get()
return runInterruptible(Dispatchers.IO) {
cache.get(url)?.takeIfReadable()
}
}
suspend fun put(url: String, source: Source): File = withContext(Dispatchers.IO) {
val file = File(cacheDir.get().parentFile, url.longHashCode().toString())
try { try {
file.outputStream().use { out -> file.sink(append = false).buffer().use {
inputStream.copyToSuspending(out) it.writeAllCancellable(source)
} }
lruCache.put(url, file) lruCache.get().put(url, file)
} finally { } finally {
file.delete() file.delete()
} }
} }
} }
private fun createDiskLruCacheSafe(dir: File, size: Long): DiskLruCache {
return try {
DiskLruCache.create(dir, size)
} catch (e: Exception) {
dir.deleteRecursively()
dir.mkdir()
DiskLruCache.create(dir, size)
}
}
private fun findSuitableDir(context: Context): File? {
val dirs = context.externalCacheDirs + context.cacheDir
return dirs.firstNotNullOfOrNull {
it?.subdir(CacheDir.PAGES.dir)?.takeIfWriteable()
}
}

View File

@@ -0,0 +1,7 @@
package org.koitharu.kotatsu.local.data
import javax.inject.Qualifier
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class LocalStorageChanges

View File

@@ -6,15 +6,20 @@ import androidx.documentfile.provider.DocumentFile
import dagger.Reusable import dagger.Reusable
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.withContext
import okio.buffer
import okio.sink
import okio.source
import org.koitharu.kotatsu.core.exceptions.UnsupportedFileException import org.koitharu.kotatsu.core.exceptions.UnsupportedFileException
import org.koitharu.kotatsu.local.data.CbzFilter import org.koitharu.kotatsu.local.data.CbzFilter
import org.koitharu.kotatsu.local.data.LocalManga import org.koitharu.kotatsu.local.data.LocalManga
import org.koitharu.kotatsu.local.data.LocalStorageChanges
import org.koitharu.kotatsu.local.data.LocalStorageManager import org.koitharu.kotatsu.local.data.LocalStorageManager
import org.koitharu.kotatsu.local.data.input.LocalMangaInput import org.koitharu.kotatsu.local.data.input.LocalMangaInput
import org.koitharu.kotatsu.utils.ext.copyToSuspending
import org.koitharu.kotatsu.utils.ext.resolveName import org.koitharu.kotatsu.utils.ext.resolveName
import org.koitharu.kotatsu.utils.ext.writeAllCancellable
import java.io.File import java.io.File
import java.io.IOException import java.io.IOException
import javax.inject.Inject import javax.inject.Inject
@@ -23,19 +28,22 @@ import javax.inject.Inject
class SingleMangaImporter @Inject constructor( class SingleMangaImporter @Inject constructor(
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
private val storageManager: LocalStorageManager, private val storageManager: LocalStorageManager,
@LocalStorageChanges private val localStorageChanges: MutableSharedFlow<LocalManga?>,
) { ) {
private val contentResolver = context.contentResolver private val contentResolver = context.contentResolver
suspend fun import(uri: Uri, progressState: MutableStateFlow<Float>?): LocalManga { suspend fun import(uri: Uri): LocalManga {
return if (isDirectory(uri)) { val result = if (isDirectory(uri)) {
importDirectory(uri, progressState) importDirectory(uri)
} else { } else {
importFile(uri, progressState) importFile(uri)
} }
localStorageChanges.emit(result)
return result
} }
private suspend fun importFile(uri: Uri, progressState: MutableStateFlow<Float>?): LocalManga { private suspend fun importFile(uri: Uri): LocalManga = withContext(Dispatchers.IO) {
val contentResolver = storageManager.contentResolver val contentResolver = storageManager.contentResolver
val name = contentResolver.resolveName(uri) ?: throw IOException("Cannot fetch name from uri: $uri") val name = contentResolver.resolveName(uri) ?: throw IOException("Cannot fetch name from uri: $uri")
if (!CbzFilter.isFileSupported(name)) { if (!CbzFilter.isFileSupported(name)) {
@@ -45,14 +53,14 @@ class SingleMangaImporter @Inject constructor(
runInterruptible { runInterruptible {
contentResolver.openInputStream(uri) contentResolver.openInputStream(uri)
}?.use { source -> }?.use { source ->
dest.outputStream().use { output -> dest.sink().buffer().use { output ->
source.copyToSuspending(output, progressState = progressState) output.writeAllCancellable(source.source())
} }
} ?: throw IOException("Cannot open input stream: $uri") } ?: throw IOException("Cannot open input stream: $uri")
return LocalMangaInput.of(dest).getManga() LocalMangaInput.of(dest).getManga()
} }
private suspend fun importDirectory(uri: Uri, progressState: MutableStateFlow<Float>?): LocalManga { private suspend fun importDirectory(uri: Uri): LocalManga {
val root = requireNotNull(DocumentFile.fromTreeUri(context, uri)) { val root = requireNotNull(DocumentFile.fromTreeUri(context, uri)) {
"Provided uri $uri is not a tree" "Provided uri $uri is not a tree"
} }
@@ -75,9 +83,9 @@ class SingleMangaImporter @Inject constructor(
docFile.copyTo(subDir) docFile.copyTo(subDir)
} }
} else { } else {
inputStream().use { input -> inputStream().source().use { input ->
File(destDir, requireName()).outputStream().use { output -> File(destDir, requireName()).sink().buffer().use { output ->
input.copyToSuspending(output) output.writeAllCancellable(input)
} }
} }
} }

View File

@@ -31,7 +31,8 @@ sealed class LocalMangaInput(
} }
@JvmStatic @JvmStatic
protected fun zipUri(file: File, entryName: String) = "cbz://${file.path}#$entryName" protected fun zipUri(file: File, entryName: String): String =
Uri.fromParts("cbz", file.path, entryName).toString()
@JvmStatic @JvmStatic
protected fun Manga.copy2( protected fun Manga.copy2(

View File

@@ -54,13 +54,14 @@ class LocalMangaDirOutput(
runInterruptible(Dispatchers.IO) { runInterruptible(Dispatchers.IO) {
output.put(name, file) output.put(name, file)
} }
index.addChapter(chapter) index.addChapter(chapter, chapterFileName(chapter))
} }
override suspend fun flushChapter(chapter: MangaChapter) { override suspend fun flushChapter(chapter: MangaChapter): Boolean {
val output = chaptersOutput.remove(chapter) ?: return val output = chaptersOutput.remove(chapter) ?: return false
output.flushAndFinish() output.flushAndFinish()
flushIndex() flushIndex()
return true
} }
override suspend fun finish() { override suspend fun finish() {
@@ -104,7 +105,18 @@ class LocalMangaDirOutput(
} }
private fun chapterFileName(chapter: MangaChapter): String { private fun chapterFileName(chapter: MangaChapter): String {
return "${chapter.number}_${chapter.name.toFileNameSafe()}".take(18) + ".cbz" index.getChapterFileName(chapter.id)?.let {
return it
}
val baseName = "${chapter.number}_${chapter.name.toFileNameSafe()}".take(18)
var i = 0
while (true) {
val name = (if (i == 0) baseName else baseName + "_$i") + ".cbz"
if (!File(rootFile, name).exists()) {
return name
}
i++
}
} }
private suspend fun flushIndex() = runInterruptible(Dispatchers.IO) { private suspend fun flushIndex() = runInterruptible(Dispatchers.IO) {

View File

@@ -1,6 +1,11 @@
package org.koitharu.kotatsu.local.data.output package org.koitharu.kotatsu.local.data.output
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import okio.Closeable import okio.Closeable
import org.koitharu.kotatsu.local.data.input.LocalMangaInput
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.util.toFileNameSafe import org.koitharu.kotatsu.parsers.util.toFileNameSafe
@@ -16,7 +21,7 @@ sealed class LocalMangaOutput(
abstract suspend fun addPage(chapter: MangaChapter, file: File, pageNumber: Int, ext: String) abstract suspend fun addPage(chapter: MangaChapter, file: File, pageNumber: Int, ext: String)
abstract suspend fun flushChapter(chapter: MangaChapter) abstract suspend fun flushChapter(chapter: MangaChapter): Boolean
abstract suspend fun finish() abstract suspend fun finish()
@@ -26,25 +31,63 @@ sealed class LocalMangaOutput(
const val ENTRY_NAME_INDEX = "index.json" const val ENTRY_NAME_INDEX = "index.json"
const val SUFFIX_TMP = ".tmp" const val SUFFIX_TMP = ".tmp"
private val mutex = Mutex()
fun getOrCreate(root: File, manga: Manga): LocalMangaOutput { suspend fun getOrCreate(root: File, manga: Manga): LocalMangaOutput = withContext(Dispatchers.IO) {
return checkNotNull(getImpl(root, manga, onlyIfExists = false)) val preferSingleCbz = manga.chapters.let {
} it != null && it.size <= 3
fun get(root: File, manga: Manga): LocalMangaOutput? {
return getImpl(root, manga, onlyIfExists = true)
}
private fun getImpl(root: File, manga: Manga, onlyIfExists: Boolean): LocalMangaOutput? {
val fileName = manga.title.toFileNameSafe()
val dir = File(root, fileName)
val zip = File(root, "$fileName.cbz")
return when {
dir.isDirectory -> LocalMangaDirOutput(dir, manga)
zip.isFile -> LocalMangaZipOutput(zip, manga)
!onlyIfExists -> LocalMangaDirOutput(dir, manga)
else -> null
} }
checkNotNull(getImpl(root, manga, onlyIfExists = false, preferSingleCbz))
}
suspend fun get(root: File, manga: Manga): LocalMangaOutput? = withContext(Dispatchers.IO) {
getImpl(root, manga, onlyIfExists = true, preferSingleCbz = false)
}
private suspend fun getImpl(
root: File,
manga: Manga,
onlyIfExists: Boolean,
preferSingleCbz: Boolean,
): LocalMangaOutput? {
mutex.withLock {
var i = 0
val baseName = manga.title.toFileNameSafe()
while (true) {
val fileName = if (i == 0) baseName else baseName + "_$i"
val dir = File(root, fileName)
val zip = File(root, "$fileName.cbz")
i++
return when {
dir.isDirectory -> {
if (canWriteTo(dir, manga)) {
LocalMangaDirOutput(dir, manga)
} else {
continue
}
}
zip.isFile -> if (canWriteTo(zip, manga)) {
LocalMangaZipOutput(zip, manga)
} else {
continue
}
!onlyIfExists -> if (preferSingleCbz) {
LocalMangaZipOutput(zip, manga)
} else {
LocalMangaDirOutput(dir, manga)
}
else -> null
}
}
}
}
private suspend fun canWriteTo(file: File, manga: Manga): Boolean {
val info = LocalMangaInput.of(file).getMangaInfo() ?: return false
return info.id == manga.id
} }
} }
} }

View File

@@ -57,10 +57,10 @@ class LocalMangaZipOutput(
runInterruptible(Dispatchers.IO) { runInterruptible(Dispatchers.IO) {
output.put(name, file) output.put(name, file)
} }
index.addChapter(chapter) index.addChapter(chapter, null)
} }
override suspend fun flushChapter(chapter: MangaChapter) = Unit override suspend fun flushChapter(chapter: MangaChapter): Boolean = false
override suspend fun finish() { override suspend fun finish() {
runInterruptible(Dispatchers.IO) { runInterruptible(Dispatchers.IO) {
@@ -98,7 +98,7 @@ class LocalMangaZipOutput(
} }
otherIndex?.getMangaInfo()?.chapters?.let { chapters -> otherIndex?.getMangaInfo()?.chapters?.let { chapters ->
for (chapter in chapters) { for (chapter in chapters) {
index.addChapter(chapter) index.addChapter(chapter, null)
} }
} }
} }

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