Compare commits

...

75 Commits

Author SHA1 Message Date
Koitharu
1d78c64350 Move coroutines from UserDataSettingsFragment to ViewModel 2023-07-19 13:32:02 +03:00
Koitharu
321a9ecf62 Update parsers 2023-07-19 12:30:58 +03:00
Koitharu
439a01c43f Fix bookmark has direct url detection #424 2023-07-18 11:43:31 +03:00
Koitharu
3a9d0def7d Update parsers 2023-07-18 10:13:46 +03:00
Koitharu
e4c80b4443 Remove rubbish file 2023-07-17 14:14:23 +03:00
Koitharu
940d448e00 Fix local manga update on shelf 2023-07-17 14:13:16 +03:00
Koitharu
5ab48a7545 Fix scrobbling rating 2023-07-17 13:30:50 +03:00
Koitharu
cb2bdbdd9a Update parsers 2023-07-17 13:08:39 +03:00
Cookies
8fdaf92cc4 Translated using Weblate (Vietnamese)
Currently translated at 89.2% (399 of 447 strings)

Co-authored-by: Cookies <Nekop1845@proton.me>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/vi/
Translation: Kotatsu/Strings
2023-07-17 12:39:46 +03:00
Shubham Niraula
0416077964 Translated using Weblate (Nepali)
Currently translated at 51.9% (232 of 447 strings)

Co-authored-by: Shubham Niraula <niraulas018@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ne/
Translation: Kotatsu/Strings
2023-07-17 12:39:46 +03:00
Koitharu
7b60ed6bad Fix new sources dialog list 2023-07-13 13:12:21 +03:00
Cookies
619be69580 Translated using Weblate (Vietnamese)
Currently translated at 89.2% (399 of 447 strings)

Co-authored-by: Cookies <Nekop1845@proton.me>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/vi/
Translation: Kotatsu/Strings
2023-07-12 11:00:13 +03:00
Shubham Niraula
9f3c3f8985 Translated using Weblate (Nepali)
Currently translated at 51.2% (229 of 447 strings)

Co-authored-by: Shubham Niraula <niraulas018@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ne/
Translation: Kotatsu/Strings
2023-07-12 11:00:13 +03:00
Vítor Fernandes Almado
f345977858 Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (447 of 447 strings)

Co-authored-by: Vítor Fernandes Almado <vfalmado@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt_BR/
Translation: Kotatsu/Strings
2023-07-12 11:00:13 +03:00
Koitharu
9610caf002 Downloads scheduler fixes 2023-07-12 10:58:02 +03:00
Koitharu
b75220a1b7 Fix cover loading in details 2023-07-11 11:45:57 +03:00
Koitharu
ab2a6f5a17 Fix loading state 2023-07-11 11:34:47 +03:00
Koitharu
2aeefc607b Udpate dependencies 2023-07-11 11:09:45 +03:00
Shubham Niraula
9af769bc69 Translated using Weblate (Nepali)
Currently translated at 50.3% (225 of 447 strings)

Co-authored-by: Shubham Niraula <niraulas018@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ne/
Translation: Kotatsu/Strings
2023-07-11 09:54:06 +03:00
Pluto
46b78cfcd7 Translated using Weblate (Czech)
Currently translated at 100.0% (6 of 6 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (447 of 447 strings)

Added translation using Weblate (Czech)

Added translation using Weblate (Czech)

Co-authored-by: Pluto <notemailprotected@protonmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/cs/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/cs/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2023-07-11 09:54:06 +03:00
Nguyễn Mạnh Hùng
c24324de9a Translated using Weblate (Vietnamese)
Currently translated at 81.8% (366 of 447 strings)

Co-authored-by: Nguyễn Mạnh Hùng <hungmn13@tuta.io>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/vi/
Translation: Kotatsu/Strings
2023-07-11 09:54:06 +03:00
Hosted Weblate
48b9c1236d 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-07-11 09:54:06 +03:00
plum7x
c69d293caa Translated using Weblate (Chinese (Traditional))
Currently translated at 100.0% (447 of 447 strings)

Translated using Weblate (Chinese (Traditional))

Currently translated at 97.5% (436 of 447 strings)

Translated using Weblate (Chinese (Traditional))

Currently translated at 97.3% (435 of 447 strings)

Co-authored-by: plum7x <plumgift@hotmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hant/
Translation: Kotatsu/Strings
2023-07-11 09:54:06 +03:00
Clxff H3r4ld0
0f4cca0e07 Translated using Weblate (Indonesian)
Currently translated at 100.0% (447 of 447 strings)

Co-authored-by: Clxff H3r4ld0 <123844876+clxf12@users.noreply.github.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/id/
Translation: Kotatsu/Strings
2023-07-11 09:54:06 +03:00
Luiz-bro
d6500b8fec Translated using Weblate (Portuguese (Brazil))
Currently translated at 99.1% (443 of 447 strings)

Co-authored-by: Luiz-bro <luiznneto1@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt_BR/
Translation: Kotatsu/Strings
2023-07-11 09:54:06 +03:00
Detrimental God
86140cab1e Added translation using Weblate (Malayalam)
Co-authored-by: Detrimental God <judeliger1@gmail.com>
2023-07-11 09:54:06 +03:00
Koitharu
90dfc84119 Update dependencies 2023-07-01 12:54:44 +03:00
Koitharu
6a792f8ac3 Use CoroutineStart.ATOMIC in some cases 2023-06-30 14:04:22 +03:00
Koitharu
c81e8749b6 Update parsers and headers processing 2023-06-28 13:27:26 +03:00
ztimms73
5fa260a0c7 Update issue template 2023-06-28 03:14:30 +03:00
Koitharu
e0ba4e2686 Remove unused code 2023-06-27 12:52:41 +03:00
Koitharu
f188d1c0f3 Remove ongoing flag from background work notifications 2023-06-27 12:34:12 +03:00
CakesTwix
6de55afa27 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (447 of 447 strings)

Co-authored-by: CakesTwix <cakestwix1@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/uk/
Translation: Kotatsu/Strings
2023-06-27 12:30:56 +03:00
J. Lavoie
21dcb5b754 Translated using Weblate (French)
Currently translated at 100.0% (447 of 447 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-06-27 12:30:56 +03:00
gallegonovato
9b3ea57db1 Translated using Weblate (Spanish)
Currently translated at 100.0% (447 of 447 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2023-06-27 12:30:56 +03:00
kuragehime
032a8607ba Translated using Weblate (Japanese)
Currently translated at 100.0% (447 of 447 strings)

Co-authored-by: kuragehime <kuragehime641@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ja/
Translation: Kotatsu/Strings
2023-06-27 12:30:56 +03:00
Макар Разин
f7303c5957 Translated using Weblate (Serbian)
Currently translated at 29.3% (131 of 447 strings)

Translated using Weblate (Ukrainian)

Currently translated at 99.5% (445 of 447 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (447 of 447 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (447 of 447 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/sr/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/uk/
Translation: Kotatsu/Strings
2023-06-27 12:30:56 +03:00
Koitharu
d696606ef9 Misc fixes 2023-06-27 10:28:47 +03:00
Koitharu
0a6e106a1d Filter local manga files 2023-06-24 13:18:09 +03:00
Koitharu
de1a7f0ca8 Fix IndexOutOfBoundsException in RemoteViewsFactory 2023-06-24 09:38:13 +03:00
Koitharu
9d31e76cc7 Translated using Weblate (Russian)
Currently translated at 100.0% (443 of 443 strings)

Co-authored-by: Koitharu <nvasya95@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translation: Kotatsu/Strings
2023-06-23 16:23:37 +03:00
Cookies
20910ffb5d Translated using Weblate (Vietnamese)
Currently translated at 81.2% (360 of 443 strings)

Co-authored-by: Cookies <Nekop1845@proton.me>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/vi/
Translation: Kotatsu/Strings
2023-06-23 16:23:37 +03:00
Clxff H3r4ld0
7497ee6364 Translated using Weblate (Indonesian)
Currently translated at 100.0% (443 of 443 strings)

Co-authored-by: Clxff H3r4ld0 <123844876+clxf12@users.noreply.github.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/id/
Translation: Kotatsu/Strings
2023-06-23 16:23:37 +03:00
Abay Emes
0f2ed50e18 Translated using Weblate (Kazakh)
Currently translated at 48.8% (213 of 436 strings)

Co-authored-by: Abay Emes <abayemes@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/kk/
Translation: Kotatsu/Strings
2023-06-23 16:23:37 +03:00
BlackSpectrum
ba066b577b Translated using Weblate (Hindi)
Currently translated at 15.5% (68 of 436 strings)

Co-authored-by: BlackSpectrum <tittan5000@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/hi/
Translation: Kotatsu/Strings
2023-06-23 16:23:37 +03:00
CakesTwix
4496fe876f Translated using Weblate (Ukrainian)
Currently translated at 100.0% (436 of 436 strings)

Co-authored-by: CakesTwix <cakestwix1@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/uk/
Translation: Kotatsu/Strings
2023-06-23 16:23:37 +03:00
gallegonovato
a9f5abebf0 Translated using Weblate (Spanish)
Currently translated at 100.0% (443 of 443 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (436 of 436 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2023-06-23 16:23:37 +03:00
qrynill
bebee2ef27 Translated using Weblate (Norwegian Nynorsk)
Currently translated at 91.7% (399 of 435 strings)

Co-authored-by: qrynill <tryvseu@tuta.io>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/nn/
Translation: Kotatsu/Strings
2023-06-23 16:23:37 +03:00
Макар Разин
4ec2b0c8fe Translated using Weblate (Vietnamese)
Currently translated at 79.3% (345 of 435 strings)

Translated using Weblate (Polish)

Currently translated at 100.0% (435 of 435 strings)

Translated using Weblate (Korean)

Currently translated at 75.1% (327 of 435 strings)

Translated using Weblate (Greek)

Currently translated at 19.3% (84 of 435 strings)

Translated using Weblate (Serbian)

Currently translated at 28.2% (123 of 435 strings)

Translated using Weblate (Arabic)

Currently translated at 18.1% (79 of 435 strings)

Translated using Weblate (Portuguese)

Currently translated at 100.0% (435 of 435 strings)

Translated using Weblate (Italian)

Currently translated at 85.2% (371 of 435 strings)

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/ar/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/be/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/el/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/it/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ko/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pl/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/sr/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/vi/
Translation: Kotatsu/Strings
2023-06-23 16:23:37 +03:00
Koitharu
4a7be70898 Update queries in manifest 2023-06-23 16:14:41 +03:00
Koitharu
2bcba1eb21 Configure manga directories 2023-06-22 13:45:29 +03:00
Koitharu
feca7ba3fc Support for custom directories for manga 2023-06-22 10:11:11 +03:00
Koitharu
745b349e5e Ability to remove item from updates 2023-06-21 15:27:20 +03:00
Koitharu
13946783a5 Fix crashes 2023-06-21 15:06:01 +03:00
Koitharu
84e5400522 Download options dialog 2023-06-21 14:54:11 +03:00
Koitharu
02c9a933d2 Fix offline manga details 2023-06-20 17:06:18 +03:00
Koitharu
92af851d3b Option to clear single source cookies 2023-06-20 13:43:09 +03:00
Koitharu
009eb9fe44 Fix recursive sync 2023-06-17 18:34:08 +03:00
Koitharu
fc8a5ccd9f Fix Continue button in offline mode 2023-06-17 18:20:57 +03:00
Koitharu
91f46de547 Fix crashes 2023-06-17 18:11:09 +03:00
Koitharu
d548993e14 Move syncronization to main process 2023-06-17 17:36:12 +03:00
Koitharu
4f32664b33 Respect system PowerSave mode 2023-06-17 16:12:14 +03:00
Koitharu
71b14a3aa8 Refactor FilterOwner 2023-06-17 16:05:08 +03:00
Isira Seneviratne
183a61272e Use ParcelCompat methods. 2023-06-17 15:50:08 +03:00
Koitharu
f1f208ad15 Merge branch 'master' into devel 2023-06-16 10:45:12 +03:00
Koitharu
c6983d794c Fix tablet portrait layout 2023-06-16 10:42:19 +03:00
Koitharu
8228153c83 Fix tablet portrait layout 2023-06-16 10:41:32 +03:00
Koitharu
844bd13a07 Fix filter lifecycle 2023-06-16 10:23:40 +03:00
Koitharu
60a5620134 Schedule workers only on demand 2023-06-16 09:50:02 +03:00
qrynill
dd09a39077 Translated using Weblate (Norwegian Nynorsk)
Currently translated at 82.0% (357 of 435 strings)

Co-authored-by: qrynill <tryvseu@tuta.io>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/nn/
Translation: Kotatsu/Strings
2023-06-15 09:45:35 +03:00
MaSHiNiK
1511bd3279 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (435 of 435 strings)

Co-authored-by: MaSHiNiK <infinitymashinik456@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/uk/
Translation: Kotatsu/Strings
2023-06-15 09:45:35 +03:00
Макар Разин
259c335607 Translated using Weblate (Turkish)
Currently translated at 100.0% (435 of 435 strings)

Translated using Weblate (French)

Currently translated at 100.0% (435 of 435 strings)

Translated using Weblate (Portuguese)

Currently translated at 86.2% (375 of 435 strings)

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/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fr/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2023-06-15 09:45:07 +03:00
Nick New
86367b6d3b Added translation using Weblate (Thai)
Co-authored-by: Nick New <newblackseries@gmail.com>
2023-06-15 09:45:07 +03:00
Koitharu
19b893738d Update parsers 2023-06-15 09:44:35 +03:00
Koitharu
d817ae0394 Fix Cloudflare bypass 2023-06-15 09:43:14 +03:00
170 changed files with 6950 additions and 3886 deletions

View File

@@ -61,4 +61,6 @@ body:
label: Acknowledgements
options:
- label: I have searched the existing issues and this is a new ticket, **NOT** a duplicate or related to another open issue.
required: true
required: true
- label: If this is an issue with a parser, I should be opening an issue in the [parsers repository](https://github.com/KotatsuApp/kotatsu-parsers/issues/new/choose).
required: true

1
.idea/.gitignore generated vendored
View File

@@ -1,3 +1,4 @@
# Default ignored files
/shelf/
/workspace.xml
/migrations.xml

3
.idea/gradle.xml generated
View File

@@ -4,8 +4,6 @@
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
<option name="testRunner" value="GRADLE" />
<option name="distributionType" value="DEFAULT_WRAPPED" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="gradleJvm" value="jbr-17" />
<option name="modules">
@@ -14,6 +12,7 @@
<option value="$PROJECT_DIR$/app" />
</set>
</option>
<option name="resolveExternalAnnotations" value="false" />
</GradleProjectSettings>
</option>
</component>

View File

@@ -14,9 +14,11 @@ android {
defaultConfig {
applicationId 'org.koitharu.kotatsu'
minSdkVersion 21
//TODO: update as soon as sources becomes available
//noinspection OldTargetApi
targetSdkVersion 33
versionCode 553
versionName '5.2.1'
versionCode 564
versionName '5.3.7'
generatedDensities = []
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
@@ -79,12 +81,12 @@ afterEvaluate {
}
dependencies {
//noinspection GradleDependency
implementation('com.github.KotatsuApp:kotatsu-parsers:f732582d55') {
implementation('com.github.KotatsuApp:kotatsu-parsers:69e0a531df') {
exclude group: 'org.json', module: 'json'
}
implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.8.22'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.1'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.2'
implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'androidx.core:core-ktx:1.10.1'
@@ -104,55 +106,57 @@ dependencies {
//noinspection LifecycleAnnotationProcessorWithJava8
kapt 'androidx.lifecycle:lifecycle-compiler:2.6.1'
// TODO https://issuetracker.google.com/issues/254846063
implementation 'androidx.work:work-runtime-ktx:2.8.1'
//noinspection GradleDependency
implementation('com.google.guava:guava:32.0.0-android') {
implementation('com.google.guava:guava:32.0.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-ktx:2.5.1'
kapt 'androidx.room:room-compiler:2.5.1'
implementation 'androidx.room:room-runtime:2.5.2'
implementation 'androidx.room:room-ktx:2.5.2'
//noinspection KaptUsageInsteadOfKsp
kapt 'androidx.room:room-compiler:2.5.2'
implementation 'com.squareup.okhttp3:okhttp:4.11.0'
implementation 'com.squareup.okhttp3:okhttp-dnsoverhttps:4.11.0'
implementation 'com.squareup.okio:okio:3.3.0'
implementation 'com.squareup.okio:okio:3.4.0'
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.2'
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:4.3.2'
implementation 'com.google.dagger:hilt-android:2.46.1'
kapt 'com.google.dagger:hilt-compiler:2.46.1'
implementation 'com.google.dagger:hilt-android:2.47'
kapt 'com.google.dagger:hilt-compiler:2.47'
implementation 'androidx.hilt:hilt-work:1.0.0'
kapt 'androidx.hilt:hilt-compiler:1.0.0'
implementation 'io.coil-kt:coil-base:2.4.0'
implementation 'io.coil-kt:coil-svg:2.4.0'
implementation 'com.github.KotatsuApp:subsampling-scale-image-view:1b19231b2f'
implementation 'com.github.KotatsuApp:subsampling-scale-image-view:9b1d20be67'
implementation 'com.github.solkin:disk-lru-cache:1.4'
implementation 'io.noties.markwon:core:4.6.2'
implementation 'ch.acra:acra-http:5.9.7'
implementation 'ch.acra:acra-dialog:5.9.7'
implementation 'ch.acra:acra-http:5.10.1'
implementation 'ch.acra:acra-dialog:5.10.1'
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.11'
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.12'
testImplementation 'junit:junit:4.13.2'
testImplementation 'org.json:json:20230227'
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.1'
testImplementation 'org.json:json:20230618'
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.2'
androidTestImplementation 'androidx.test:runner:1.5.2'
androidTestImplementation 'androidx.test:rules:1.5.0'
androidTestImplementation 'androidx.test:core-ktx:1.5.0'
androidTestImplementation 'androidx.test.ext:junit-ktx:1.1.5'
androidTestImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.1'
androidTestImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.2'
androidTestImplementation 'androidx.room:room-testing:2.5.1'
androidTestImplementation 'androidx.room:room-testing:2.5.2'
androidTestImplementation 'com.squareup.moshi:moshi-kotlin:1.15.0'
androidTestImplementation 'com.google.dagger:hilt-android-testing:2.46.1'
kaptAndroidTest 'com.google.dagger:hilt-android-compiler:2.46.1'
androidTestImplementation 'com.google.dagger:hilt-android-testing:2.47'
kaptAndroidTest 'com.google.dagger:hilt-android-compiler:2.47'
}

View File

@@ -17,7 +17,7 @@ import java.util.EnumSet
class DummyParser(context: MangaLoaderContext) : MangaParser(context, MangaSource.DUMMY) {
override val configKeyDomain: ConfigKey.Domain
get() = ConfigKey.Domain()
get() = ConfigKey.Domain("")
override val sortOrders: Set<SortOrder>
get() = EnumSet.allOf(SortOrder::class.java)

View File

@@ -18,6 +18,22 @@
<uses-permission android:name="android.permission.READ_SYNC_SETTINGS" />
<uses-permission android:name="android.permission.WRITE_SYNC_SETTINGS" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="29" />
<uses-permission
android:name="android.permission.MANAGE_EXTERNAL_STORAGE"
tools:ignore="ScopedStorage" />
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT" />
<data android:mimeType="text/plain" />
</intent>
<intent>
<action android:name="android.speech.action.RECOGNIZE_SPEECH" />
</intent>
</queries>
<application
android:name="org.koitharu.kotatsu.KotatsuApp"
@@ -32,6 +48,7 @@
android:largeHeap="true"
android:localeConfig="@xml/locales"
android:networkSecurityConfig="@xml/network_security_config"
android:requestLegacyExternalStorage="true"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Kotatsu"
@@ -98,6 +115,9 @@
<data android:host="sync-settings" />
</intent-filter>
</activity>
<activity
android:name="org.koitharu.kotatsu.settings.storage.directories.MangaDirectoriesActivity"
android:label="@string/local_manga_directories" />
<activity
android:name="org.koitharu.kotatsu.browser.BrowserActivity"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"
@@ -188,8 +208,7 @@
<service
android:name="org.koitharu.kotatsu.sync.ui.favourites.FavouritesSyncService"
android:exported="false"
android:label="@string/favourites"
android:process=":sync">
android:label="@string/favourites">
<intent-filter>
<action android:name="android.content.SyncAdapter" />
</intent-filter>
@@ -200,8 +219,7 @@
<service
android:name="org.koitharu.kotatsu.sync.ui.history.HistorySyncService"
android:exported="false"
android:label="@string/history"
android:process=":sync">
android:label="@string/history">
<intent-filter>
<action android:name="android.content.SyncAdapter" />
</intent-filter>

View File

@@ -28,6 +28,7 @@ import org.koitharu.kotatsu.local.data.LocalMangaRepository
import org.koitharu.kotatsu.local.data.PagesCache
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.settings.work.WorkScheduleManager
import javax.inject.Inject
@HiltAndroidApp
@@ -51,6 +52,9 @@ class KotatsuApp : Application(), Configuration.Provider {
@Inject
lateinit var appValidator: AppValidator
@Inject
lateinit var workScheduleManager: WorkScheduleManager
override fun onCreate() {
super.onCreate()
ACRA.errorReporter.putCustomData("isOriginalApp", appValidator.isOriginalApp.toString())
@@ -63,6 +67,7 @@ class KotatsuApp : Application(), Configuration.Provider {
processLifecycleScope.launch(Dispatchers.Default) {
setupDatabaseObservers()
}
workScheduleManager.init()
WorkServiceStopHelper(applicationContext).setup()
}

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.bookmarks.domain
import org.koitharu.kotatsu.local.data.ImageFileFilter
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaPage
import java.util.Date
@@ -26,7 +27,8 @@ class Bookmark(
)
private fun isImageUrlDirect(): Boolean {
return imageUrl.substringAfterLast('.').length in 2..4
val extension = imageUrl.substringAfterLast('.')
return extension.isNotEmpty() && ImageFileFilter().isExtensionValid(extension)
}
override fun equals(other: Any?): Boolean {

View File

@@ -5,15 +5,19 @@ import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.core.ui.drawable.TextDrawable
import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver
import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.util.ext.decodeRegion
import org.koitharu.kotatsu.core.util.ext.disposeImageRequest
import org.koitharu.kotatsu.core.util.ext.enqueueWith
import org.koitharu.kotatsu.core.util.ext.getThemeResId
import org.koitharu.kotatsu.core.util.ext.newImageRequest
import org.koitharu.kotatsu.core.util.ext.source
import org.koitharu.kotatsu.databinding.ItemBookmarkBinding
import org.koitharu.kotatsu.parsers.util.format
import com.google.android.material.R as materialR
fun bookmarkListAD(
coil: ImageLoader,

View File

@@ -8,6 +8,7 @@ import android.net.Uri
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import android.webkit.CookieManager
import androidx.core.graphics.Insets
import androidx.core.view.isVisible
import androidx.core.view.updatePadding
@@ -16,6 +17,7 @@ import org.koitharu.kotatsu.core.network.CommonHeadersInterceptor
import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.util.ext.catchingWebViewUnavailability
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
import org.koitharu.kotatsu.parsers.network.UserAgents
import com.google.android.material.R as materialR
@SuppressLint("SetJavaScriptEnabled")
@@ -34,8 +36,9 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
}
with(viewBinding.webView.settings) {
javaScriptEnabled = true
userAgentString = CommonHeadersInterceptor.userAgentChrome
userAgentString = UserAgents.CHROME_MOBILE
}
CookieManager.getInstance().setAcceptThirdPartyCookies(viewBinding.webView, true)
viewBinding.webView.webViewClient = BrowserClient(this)
viewBinding.webView.webChromeClient = ProgressChromeClient(viewBinding.progressBar)
onBackPressedCallback = WebViewBackPressedCallback(viewBinding.webView)

View File

@@ -24,6 +24,7 @@ import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.util.TaggedActivityResult
import org.koitharu.kotatsu.core.util.ext.catchingWebViewUnavailability
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
import org.koitharu.kotatsu.parsers.network.UserAgents
import javax.inject.Inject
import com.google.android.material.R as materialR
@@ -49,10 +50,9 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
val url = intent?.dataString.orEmpty()
with(viewBinding.webView.settings) {
javaScriptEnabled = true
cacheMode = WebSettings.LOAD_DEFAULT
domStorageEnabled = true
databaseEnabled = true
userAgentString = intent?.getStringExtra(ARG_UA) ?: CommonHeadersInterceptor.userAgentFallback
userAgentString = intent?.getStringExtra(ARG_UA) ?: UserAgents.CHROME_MOBILE
}
viewBinding.webView.webViewClient = CloudFlareClient(cookieJar, this, url)
onBackPressedCallback = WebViewBackPressedCallback(viewBinding.webView).also {

View File

@@ -33,11 +33,21 @@ abstract class ErrorObserver(
return resolver != null && ExceptionResolver.canResolve(error)
}
private fun isAlive(): Boolean {
return when {
fragment != null -> fragment.view != null
activity != null -> !activity.isDestroyed
else -> true
}
}
protected fun resolve(error: Throwable) {
lifecycleScope.launch {
val isResolved = resolver?.resolve(error) ?: false
if (isActive) {
onResolved?.accept(isResolved)
if (isAlive()) {
lifecycleScope.launch {
val isResolved = resolver?.resolve(error) ?: false
if (isActive) {
onResolved?.accept(isResolved)
}
}
}
}

View File

@@ -0,0 +1,17 @@
package org.koitharu.kotatsu.core.exceptions.resolve
import android.view.View
import android.widget.Toast
import androidx.fragment.app.Fragment
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
class ToastErrorObserver(
host: View,
fragment: Fragment?,
) : ErrorObserver(host, fragment, null, null) {
override suspend fun emit(value: Throwable) {
val toast = Toast.makeText(host.context, value.getDisplayMessage(host.context.resources), Toast.LENGTH_SHORT)
toast.show()
}
}

View File

@@ -8,10 +8,14 @@ import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.mapToSet
@JvmName("mangaIds")
fun Collection<Manga>.ids() = mapToSet { it.id }
fun Collection<Manga>.distinctById() = distinctBy { it.id }
@JvmName("chaptersIds")
fun Collection<MangaChapter>.ids() = mapToSet { it.id }
fun Collection<ChapterListItem>.countChaptersByBranch(): Int {
if (size <= 1) {
return size

View File

@@ -13,13 +13,13 @@ private const val SERVER_CLOUDFLARE = "cloudflare"
class CloudFlareInterceptor : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
val response = chain.proceed(request)
val response = chain.proceed(chain.request())
if (response.code == HTTP_FORBIDDEN || response.code == HTTP_UNAVAILABLE) {
if (response.header(HEADER_SERVER)?.startsWith(SERVER_CLOUDFLARE) == true) {
val request = response.request
response.closeQuietly()
throw CloudFlareProtectedException(
url = response.request.url.toString(),
url = request.url.toString(),
headers = request.headers,
)
}

View File

@@ -1,6 +1,5 @@
package org.koitharu.kotatsu.core.network
import android.os.Build
import android.util.Log
import dagger.Lazy
import okhttp3.Headers
@@ -10,11 +9,11 @@ import okhttp3.Response
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.mergeWith
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.network.UserAgents
import org.koitharu.kotatsu.parsers.util.mergeWith
import java.net.IDN
import java.util.Locale
import javax.inject.Inject
import javax.inject.Singleton
@@ -39,7 +38,7 @@ class CommonHeadersInterceptor @Inject constructor(
headersBuilder.mergeWith(it, replaceExisting = false)
}
if (headersBuilder[CommonHeaders.USER_AGENT] == null) {
headersBuilder[CommonHeaders.USER_AGENT] = userAgentFallback
headersBuilder[CommonHeaders.USER_AGENT] = UserAgents.CHROME_MOBILE
}
if (headersBuilder[CommonHeaders.REFERER] == null && repository != null) {
val idn = IDN.toASCII(repository.domain)
@@ -62,26 +61,4 @@ class CommonHeadersInterceptor @Inject constructor(
override fun request(): Request = request
}
companion object {
val userAgentFallback
get() = "Kotatsu/%s (Android %s; %s; %s %s; %s)".format(
BuildConfig.VERSION_NAME,
Build.VERSION.RELEASE,
Build.MODEL,
Build.BRAND,
Build.DEVICE,
Locale.getDefault().language,
)
val userAgentChrome
get() = (
"Mozilla/5.0 (Linux; Android %s; %s) AppleWebKit/537.36 (KHTML, like Gecko) " +
"Chrome/100.0.4896.127 Mobile Safari/537.36"
).format(
Build.VERSION.RELEASE,
Build.MODEL,
)
}
}

View File

@@ -4,6 +4,7 @@ import android.webkit.CookieManager
import androidx.annotation.WorkerThread
import okhttp3.Cookie
import okhttp3.HttpUrl
import org.koitharu.kotatsu.core.util.ext.newBuilder
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
@@ -30,6 +31,21 @@ class AndroidCookieJar : MutableCookieJar {
}
}
override fun removeCookies(url: HttpUrl) {
val cookies = loadForRequest(url)
if (cookies.isEmpty()) {
return
}
val urlString = url.toString()
for (c in cookies) {
val nc = c.newBuilder()
.expiresAt(System.currentTimeMillis() - 100000)
.build()
cookieManager.setCookie(urlString, nc.toString())
}
check(loadForRequest(url).isEmpty())
}
override suspend fun clear() = suspendCoroutine<Boolean> { continuation ->
cookieManager.removeAllCookies(continuation::resume)
}

View File

@@ -13,5 +13,8 @@ interface MutableCookieJar : CookieJar {
@WorkerThread
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>)
@WorkerThread
fun removeCookies(url: HttpUrl)
suspend fun clear(): Boolean
}

View File

@@ -21,6 +21,7 @@ class PreferencesCookieJar(
private var isLoaded = false
@WorkerThread
@Synchronized
override fun loadForRequest(url: HttpUrl): List<Cookie> {
loadPersistent()
val expired = HashSet<String>()
@@ -40,6 +41,7 @@ class PreferencesCookieJar(
}
@WorkerThread
@Synchronized
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
val wrapped = cookies.map { CookieWrapper(it) }
prefs.edit(commit = true) {
@@ -53,6 +55,22 @@ class PreferencesCookieJar(
}
}
@Synchronized
@WorkerThread
override fun removeCookies(url: HttpUrl) {
loadPersistent()
val toRemove = HashSet<String>()
for ((key, cookie) in cache) {
if (cookie.isExpired() || cookie.cookie.matches(url)) {
toRemove += key
}
}
if (toRemove.isNotEmpty()) {
cache.removeAll(toRemove)
removePersistent(toRemove)
}
}
override suspend fun clear(): Boolean {
cache.clear()
withContext(Dispatchers.IO) {

View File

@@ -9,6 +9,6 @@ fun MangaParser(source: MangaSource, loaderContext: MangaLoaderContext): MangaPa
return if (source == MangaSource.DUMMY) {
DummyParser(loaderContext)
} else {
source.newParser(loaderContext)
loaderContext.newParserInstance(source)
}
}
}

View File

@@ -21,9 +21,11 @@ import org.koitharu.kotatsu.core.util.ext.filterToSet
import org.koitharu.kotatsu.core.util.ext.getEnumValue
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.putEnumValue
import org.koitharu.kotatsu.core.util.ext.takeIfReadable
import org.koitharu.kotatsu.core.util.ext.toUriOrNull
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.util.mapNotNullToSet
import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.shelf.domain.model.ShelfSection
import java.io.File
@@ -234,14 +236,28 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
if (key == null) ScreenshotsPolicy.ALLOW else ScreenshotsPolicy.valueOf(key)
}.getOrDefault(ScreenshotsPolicy.ALLOW)
var userSpecifiedMangaDirectories: Set<File>
get() {
val set = prefs.getStringSet(KEY_LOCAL_MANGA_DIRS, emptySet()).orEmpty()
return set.mapNotNullToSet { File(it).takeIfReadable() }
}
set(value) {
val set = value.mapToSet { it.absolutePath }
prefs.edit { putStringSet(KEY_LOCAL_MANGA_DIRS, set) }
}
var mangaStorageDir: File?
get() = prefs.getString(KEY_LOCAL_STORAGE, null)?.let {
File(it)
}?.takeIf { it.exists() }
}?.takeIf { it.exists() && it in userSpecifiedMangaDirectories }
set(value) = prefs.edit {
if (value == null) {
remove(KEY_LOCAL_STORAGE)
} else {
val userDirs = userSpecifiedMangaDirectories
if (value !in userDirs) {
userSpecifiedMangaDirectories = userDirs + value
}
putString(KEY_LOCAL_STORAGE, value.path)
}
}
@@ -461,6 +477,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_PROXY_LOGIN = "proxy_login"
const val KEY_PROXY_PASSWORD = "proxy_password"
const val KEY_IMAGES_PROXY = "images_proxy"
const val KEY_LOCAL_MANGA_DIRS = "local_manga_dirs"
// About
const val KEY_APP_UPDATE = "app_update"

View File

@@ -14,6 +14,7 @@ import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.view.ActionMode
import androidx.appcompat.widget.ActionBarContextView
import androidx.appcompat.widget.Toolbar
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.core.graphics.ColorUtils
import androidx.core.view.ViewCompat
@@ -103,8 +104,7 @@ abstract class BaseActivity<B : ViewBinding> :
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
if (BuildConfig.DEBUG && keyCode == KeyEvent.KEYCODE_VOLUME_UP) {
// ActivityCompat.recreate(this)
error("Test")
ActivityCompat.recreate(this)
return true
}
return super.onKeyDown(keyCode, event)

View File

@@ -34,7 +34,7 @@ abstract class BaseViewModel : ViewModel() {
val isLoading: StateFlow<Boolean>
get() = loadingCounter.map { it > 0 }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), loadingCounter.value > 0)
protected fun launchJob(
context: CoroutineContext = EmptyCoroutineContext,

View File

@@ -1,101 +0,0 @@
package org.koitharu.kotatsu.core.ui.dialog
import android.content.Context
import android.content.DialogInterface
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.BaseAdapter
import androidx.annotation.StringRes
import androidx.appcompat.app.AlertDialog
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlinx.coroutines.runBlocking
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.databinding.ItemStorageBinding
import org.koitharu.kotatsu.local.data.LocalStorageManager
import java.io.File
class StorageSelectDialog private constructor(private val delegate: AlertDialog) :
DialogInterface by delegate {
fun show() = delegate.show()
class Builder(context: Context, storageManager: LocalStorageManager, listener: OnStorageSelectListener) {
private val adapter = VolumesAdapter(storageManager)
private val delegate = MaterialAlertDialogBuilder(context)
init {
if (adapter.isEmpty) {
delegate.setMessage(R.string.cannot_find_available_storage)
} else {
val defaultValue = runBlocking {
storageManager.getDefaultWriteableDir()
}
adapter.selectedItemPosition = adapter.volumes.indexOfFirst {
it.first.canonicalPath == defaultValue?.canonicalPath
}
delegate.setAdapter(adapter) { d, i ->
listener.onStorageSelected(adapter.getItem(i).first)
d.dismiss()
}
}
}
fun setTitle(@StringRes titleResId: Int): Builder {
delegate.setTitle(titleResId)
return this
}
fun setTitle(title: CharSequence): Builder {
delegate.setTitle(title)
return this
}
fun setNegativeButton(@StringRes textId: Int): Builder {
delegate.setNegativeButton(textId, null)
return this
}
fun create() = StorageSelectDialog(delegate.create())
}
private class VolumesAdapter(storageManager: LocalStorageManager) : BaseAdapter() {
var selectedItemPosition: Int = -1
val volumes = getAvailableVolumes(storageManager)
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
val view = convertView ?: LayoutInflater.from(parent.context).inflate(R.layout.item_storage, parent, false)
val binding = (view.tag as? ItemStorageBinding) ?: ItemStorageBinding.bind(view).also {
view.tag = it
}
val item = volumes[position]
binding.imageViewIndicator.isChecked = selectedItemPosition == position
binding.textViewTitle.text = item.second
binding.textViewSubtitle.text = item.first.path
return view
}
override fun getItem(position: Int): Pair<File, String> = volumes[position]
override fun getItemId(position: Int) = position.toLong()
override fun getCount() = volumes.size
override fun hasStableIds() = true
private fun getAvailableVolumes(storageManager: LocalStorageManager): List<Pair<File, String>> {
return runBlocking {
storageManager.getWriteableDirs().map {
it to storageManager.getStorageDisplayName(it)
}
}
}
}
fun interface OnStorageSelectListener {
fun onStorageSelected(file: File)
}
}

View File

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

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.core.ui.util
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.launch
@@ -13,7 +14,7 @@ fun interface ReversibleHandle {
suspend fun reverse()
}
fun ReversibleHandle.reverseAsync() = processLifecycleScope.launch(Dispatchers.Default) {
fun ReversibleHandle.reverseAsync() = processLifecycleScope.launch(Dispatchers.Default, CoroutineStart.ATOMIC) {
runCatchingCancellable {
withContext(NonCancellable) {
reverse()

View File

@@ -24,6 +24,7 @@ import com.google.android.material.shape.MaterialShapeDrawable
import com.google.android.material.shape.ShapeAppearanceModel
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ext.resolveDp
import org.koitharu.kotatsu.core.util.ext.textAndVisible
import org.koitharu.kotatsu.databinding.ViewTwoLinesItemBinding
@SuppressLint("RestrictedApi")
@@ -35,6 +36,18 @@ class TwoLinesItemView @JvmOverloads constructor(
private val binding = ViewTwoLinesItemBinding.inflate(LayoutInflater.from(context), this)
var title: CharSequence?
get() = binding.title.text
set(value) {
binding.title.text = value
}
var subtitle: CharSequence?
get() = binding.subtitle.textAndVisible
set(value) {
binding.subtitle.textAndVisible = value
}
init {
var textColors: ColorStateList? = null
context.withStyledAttributes(
@@ -76,8 +89,7 @@ class TwoLinesItemView @JvmOverloads constructor(
}
fun setIconResource(@DrawableRes resId: Int) {
val icon = if (resId != 0) ContextCompat.getDrawable(context, resId) else null
binding.icon.setImageDrawable(icon)
binding.icon.setImageResource(resId)
}
private fun createShapeDrawable(ta: TypedArray): InsetDrawable {

View File

@@ -1,6 +0,0 @@
package org.koitharu.kotatsu.core.util
fun interface BufferedObserver<T> {
fun onChanged(t: T, previous: T?)
}

View File

@@ -61,6 +61,10 @@ class WorkManagerHelper(
return workManagerImpl.getWorkInfoById(id).await()
}
suspend fun getUniqueWorkInfoByName(name: String): List<WorkInfo> {
return workManagerImpl.getWorkInfosForUniqueWork(name).await().orEmpty()
}
suspend fun updateWork(request: WorkRequest): WorkManager.UpdateResult {
return workManagerImpl.updateWork(request).await()
}

View File

@@ -6,6 +6,7 @@ import android.app.ActivityManager.MemoryInfo
import android.app.ActivityOptions
import android.content.Context
import android.content.Context.ACTIVITY_SERVICE
import android.content.Context.POWER_SERVICE
import android.content.ContextWrapper
import android.content.OperationApplicationException
import android.content.SharedPreferences
@@ -17,6 +18,7 @@ import android.graphics.Color
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.PowerManager
import android.provider.Settings
import android.view.View
import android.view.ViewPropertyAnimator
@@ -51,6 +53,9 @@ import kotlin.math.roundToLong
val Context.activityManager: ActivityManager?
get() = getSystemService(ACTIVITY_SERVICE) as? ActivityManager
val Context.powerManager: PowerManager?
get() = getSystemService(POWER_SERVICE) as? PowerManager
fun String.toUriOrNull() = if (isEmpty()) null else Uri.parse(this)
suspend fun CoroutineWorker.trySetForeground(): Boolean = runCatchingCancellable {
@@ -141,6 +146,10 @@ fun Context.isLowRamDevice(): Boolean {
return activityManager?.isLowRamDevice ?: false
}
fun Context.isPowerSaveMode(): Boolean {
return powerManager?.isPowerSaveMode == true
}
val Context.ramAvailable: Long
get() {
val result = MemoryInfo()

View File

@@ -9,8 +9,8 @@ import android.os.Parcel
import android.os.Parcelable
import androidx.core.content.IntentCompat
import androidx.core.os.BundleCompat
import androidx.core.os.ParcelCompat
import androidx.lifecycle.SavedStateHandle
import org.koitharu.kotatsu.core.model.parcelable.ParcelableMangaTags
import java.io.Serializable
// https://issuetracker.google.com/issues/240585930
@@ -36,11 +36,11 @@ inline fun <reified T : Serializable> Bundle.getSerializableCompat(key: String):
}
inline fun <reified T : Parcelable> Parcel.readParcelableCompat(): T? {
return readParcelable(ParcelableMangaTags::class.java.classLoader) as T?
return ParcelCompat.readParcelable(this, T::class.java.classLoader, T::class.java)
}
inline fun <reified T : Serializable> Parcel.readSerializableCompat(): T? {
return readSerializable() as T?
return ParcelCompat.readSerializable(this, T::class.java.classLoader, T::class.java)
}
inline fun <reified T : Serializable> Bundle.requireSerializable(key: String): T {
@@ -49,12 +49,6 @@ inline fun <reified T : Serializable> Bundle.requireSerializable(key: String): T
}
}
inline fun <reified T : Parcelable> Bundle.requireParcelable(key: String): T {
return checkNotNull(getParcelableCompat(key)) {
"Parcelable of type \"${T::class.java.name}\" not found at \"$key\""
}
}
fun <T> SavedStateHandle.require(key: String): T {
return checkNotNull(get(key)) {
"Value $key not found in SavedStateHandle or has a wrong type"

View File

@@ -0,0 +1,90 @@
package org.koitharu.kotatsu.core.util.ext
import android.annotation.TargetApi
import android.content.Context
import android.net.Uri
import android.os.Build
import android.os.storage.StorageManager
import android.provider.DocumentsContract
import org.koitharu.kotatsu.parsers.util.removeSuffix
import java.io.File
import java.lang.reflect.Array as ArrayReflect
private const val PRIMARY_VOLUME_NAME = "primary"
fun Uri.resolveFile(context: Context): File? {
val volumeId = getVolumeIdFromTreeUri(this) ?: return null
val volumePath = getVolumePath(volumeId, context)?.removeSuffix(File.separatorChar) ?: return null
val documentPath = getDocumentPathFromTreeUri(this)?.removeSuffix(File.separatorChar) ?: return null
return File(
if (documentPath.isNotEmpty()) {
if (documentPath.startsWith(File.separator)) {
volumePath + documentPath
} else {
volumePath + File.separator + documentPath
}
} else {
volumePath
},
)
}
private fun getVolumePath(volumeId: String, context: Context): String? {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
getVolumePathForAndroid11AndAbove(volumeId, context)
} else {
getVolumePathBeforeAndroid11(volumeId, context)
}
}
private fun getVolumePathBeforeAndroid11(volumeId: String, context: Context): String? = runCatching {
val mStorageManager = context.getSystemService(Context.STORAGE_SERVICE) as StorageManager
val storageVolumeClazz = Class.forName("android.os.storage.StorageVolume")
val getVolumeList = mStorageManager.javaClass.getMethod("getVolumeList")
val getUuid = storageVolumeClazz.getMethod("getUuid")
val getPath = storageVolumeClazz.getMethod("getPath")
val isPrimary = storageVolumeClazz.getMethod("isPrimary")
val result = getVolumeList.invoke(mStorageManager)
val length = ArrayReflect.getLength(checkNotNull(result))
(0 until length).firstNotNullOfOrNull { i ->
val storageVolumeElement = ArrayReflect.get(result, i)
val uuid = getUuid.invoke(storageVolumeElement) as String
val primary = isPrimary.invoke(storageVolumeElement) as Boolean
when {
primary && volumeId == PRIMARY_VOLUME_NAME -> getPath.invoke(storageVolumeElement) as String
uuid == volumeId -> getPath.invoke(storageVolumeElement) as String
else -> null
}
}
}.onFailure {
it.printStackTraceDebug()
}.getOrNull()
@TargetApi(Build.VERSION_CODES.R)
private fun getVolumePathForAndroid11AndAbove(volumeId: String, context: Context): String? = runCatching {
val storageManager = context.getSystemService(Context.STORAGE_SERVICE) as StorageManager
storageManager.storageVolumes.firstNotNullOfOrNull { volume ->
if (volume.isPrimary && volumeId == PRIMARY_VOLUME_NAME) {
volume.directory?.path
} else {
val uuid = volume.uuid
if (uuid != null && uuid == volumeId) volume.directory?.path else null
}
}
}.onFailure {
it.printStackTraceDebug()
}.getOrNull()
private fun getVolumeIdFromTreeUri(treeUri: Uri): String? {
val docId = DocumentsContract.getTreeDocumentId(treeUri)
val split = docId.split(":".toRegex())
return split.firstOrNull()?.takeUnless { it.isEmpty() }
}
private fun getDocumentPathFromTreeUri(treeUri: Uri): String? {
val docId = DocumentsContract.getTreeDocumentId(treeUri)
val split: Array<String?> = docId.split(":".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()
return if (split.size >= 2 && split[1] != null) split[1] else File.separator
}

View File

@@ -4,6 +4,7 @@ import android.os.SystemClock
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onCompletion
@@ -62,3 +63,23 @@ fun <T> Flow<T>.zipWithPrevious(): Flow<Pair<T?, T>> = flow {
emit(result)
}
}
@Suppress("UNCHECKED_CAST")
fun <T1, T2, T3, T4, T5, T6, R> combine(
flow: Flow<T1>,
flow2: Flow<T2>,
flow3: Flow<T3>,
flow4: Flow<T4>,
flow5: Flow<T5>,
flow6: Flow<T6>,
transform: suspend (T1, T2, T3, T4, T5, T6) -> R
): Flow<R> = combine(flow, flow2, flow3, flow4, flow5, flow6) { args: Array<*> ->
transform(
args[0] as T1,
args[1] as T2,
args[2] as T3,
args[3] as T4,
args[4] as T5,
args[5] as T6,
)
}

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.core.util.ext
import okhttp3.Cookie
import okhttp3.HttpUrl
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody
@@ -38,3 +39,23 @@ fun Response.ensureSuccess() = apply {
throw IllegalStateException(message)
}
}
fun Cookie.newBuilder(): Cookie.Builder = Cookie.Builder().also { c ->
c.name(name)
c.value(value)
if (persistent) {
c.expiresAt(expiresAt)
}
if (hostOnly) {
c.hostOnlyDomain(domain)
} else {
c.domain(domain)
}
c.path(path)
if (secure) {
c.secure()
}
if (httpOnly) {
c.httpOnly()
}
}

View File

@@ -60,3 +60,10 @@ fun Context.getThemeColorStateList(
) = obtainStyledAttributes(intArrayOf(resId)).use {
it.getColorStateList(0)
}
fun Context.getThemeResId(
@AttrRes resId: Int,
fallback: Int
): Int = obtainStyledAttributes(intArrayOf(resId)).use {
it.getResourceId(0, fallback)
}

View File

@@ -34,6 +34,7 @@ fun Throwable.getDisplayMessage(resources: Resources): String = when (this) {
is UnsupportedFileException -> resources.getString(R.string.text_file_not_supported)
is FileNotFoundException -> resources.getString(R.string.file_not_found)
is AccessDeniedException -> resources.getString(R.string.no_access_to_file)
is EmptyHistoryException -> resources.getString(R.string.history_is_empty)
is SyncApiException,
is ContentUnavailableException,

View File

@@ -0,0 +1,14 @@
package org.koitharu.kotatsu.core.util.ext
import androidx.annotation.DrawableRes
import androidx.appcompat.widget.Toolbar
fun Toolbar.setNavigationIconSafe(@DrawableRes iconRes: Int, retry: Boolean = true) {
try {
setNavigationIcon(iconRes)
} catch (e: IllegalStateException) {
if (retry) {
post { setNavigationIconSafe(iconRes, retry = false) }
}
}
}

View File

@@ -10,12 +10,13 @@ import org.koitharu.kotatsu.core.model.parcelable.ParcelableMangaChapters
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.ui.CoroutineIntentService
import org.koitharu.kotatsu.core.util.ext.getParcelableExtraCompat
import org.koitharu.kotatsu.core.util.ext.isPowerSaveMode
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.history.data.HistoryRepository
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.runCatchingCancellable
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import javax.inject.Inject
@AndroidEntryPoint
@@ -115,6 +116,9 @@ class MangaPrefetchService : CoroutineIntentService() {
if (source == MangaSource.LOCAL) {
return false
}
if (context.isPowerSaveMode()) {
return false
}
val entryPoint = EntryPointAccessors.fromApplication(context, PrefetchCompanionEntryPoint::class.java)
return entryPoint.contentCache.isCachingEnabled && entryPoint.settings.isContentPrefetchEnabled
}

View File

@@ -30,6 +30,7 @@ class ChaptersBottomSheetMediator(
}
override fun onActionModeStarted(mode: ActionMode) {
behavior.state = BottomSheetBehavior.STATE_EXPANDED
lock()
}

View File

@@ -18,6 +18,7 @@ import org.koitharu.kotatsu.core.ui.list.ListSelectionController
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.util.RecyclerViewScrollCallback
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.scaleUpActivityOptionsOf
import org.koitharu.kotatsu.databinding.FragmentChaptersBinding
import org.koitharu.kotatsu.details.ui.adapter.ChaptersAdapter
@@ -66,6 +67,9 @@ class ChaptersFragment :
viewModel.isChaptersEmpty.observe(viewLifecycleOwner) {
binding.textViewHolder.isVisible = it
}
viewModel.onSelectChapter.observeEvent(viewLifecycleOwner) {
selectionController?.onItemLongClick(it)
}
}
override fun onDestroyView() {

View File

@@ -43,6 +43,7 @@ import org.koitharu.kotatsu.core.util.ext.measureHeight
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.setNavigationBarTransparentCompat
import org.koitharu.kotatsu.core.util.ext.setNavigationIconSafe
import org.koitharu.kotatsu.core.util.ext.textAndVisible
import org.koitharu.kotatsu.databinding.ActivityDetailsBinding
import org.koitharu.kotatsu.details.service.MangaPrefetchService
@@ -211,7 +212,7 @@ class DetailsActivity :
}
if (isExpanded) {
toolbar.addMenuProvider(chaptersMenuProvider)
toolbar.setNavigationIcon(materialR.drawable.abc_ic_clear_material)
toolbar.setNavigationIconSafe(materialR.drawable.abc_ic_clear_material)
} else {
toolbar.removeMenuProvider(chaptersMenuProvider)
toolbar.navigationIcon = null

View File

@@ -15,6 +15,7 @@ import androidx.core.view.updatePadding
import androidx.fragment.app.activityViewModels
import coil.ImageLoader
import coil.request.ImageRequest
import coil.request.SuccessResult
import coil.util.CoilUtils
import com.google.android.material.chip.Chip
import dagger.hilt.android.AndroidEntryPoint
@@ -296,7 +297,7 @@ class DetailsFragment :
private fun loadCover(manga: Manga) {
val imageUrl = manga.largeCoverUrl.ifNullOrEmpty { manga.coverUrl }
val lastResult = CoilUtils.result(requireViewBinding().imageViewCover)
if (lastResult?.request?.data == imageUrl) {
if (lastResult is SuccessResult && lastResult.request.data == imageUrl) {
return
}
val request = ImageRequest.Builder(context ?: return)

View File

@@ -16,12 +16,11 @@ import kotlinx.coroutines.launch
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.browser.BrowserActivity
import org.koitharu.kotatsu.core.os.AppShortcutManager
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.util.ShareHelper
import org.koitharu.kotatsu.details.ui.model.MangaBranch
import org.koitharu.kotatsu.download.ui.dialog.DownloadOption
import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteCategoriesSheet
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.mapNotNullToSet
import org.koitharu.kotatsu.scrobbling.common.ui.selector.ScrobblingSelectorSheet
import org.koitharu.kotatsu.search.ui.multi.MultiSearchActivity
@@ -30,7 +29,7 @@ class DetailsMenuProvider(
private val viewModel: DetailsViewModel,
private val snackbarHost: View,
private val appShortcutManager: AppShortcutManager,
) : MenuProvider {
) : MenuProvider, OnListItemClickListener<DownloadOption> {
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menuInflater.inflate(R.menu.opt_details, menu)
@@ -44,7 +43,7 @@ class DetailsMenuProvider(
menu.findItem(R.id.action_shortcut).isVisible = ShortcutManagerCompat.isRequestPinShortcutSupported(activity)
menu.findItem(R.id.action_scrobbling).isVisible = viewModel.isScrobblingAvailable
menu.findItem(R.id.action_favourite).setIcon(
if (viewModel.favouriteCategories.value == true) R.drawable.ic_heart else R.drawable.ic_heart_outline,
if (viewModel.favouriteCategories.value) R.drawable.ic_heart else R.drawable.ic_heart_outline,
)
}
@@ -80,15 +79,7 @@ class DetailsMenuProvider(
}
R.id.action_save -> {
viewModel.manga.value?.let {
val chaptersCount = it.chapters?.size ?: 0
val branches = viewModel.branches.value.orEmpty()
if (chaptersCount > 5 || branches.size > 1) {
showSaveConfirmation(it, chaptersCount, branches)
} else {
viewModel.download(null)
}
}
DownloadDialogHelper(snackbarHost, viewModel).show(this)
}
R.id.action_browser -> {
@@ -125,35 +116,16 @@ class DetailsMenuProvider(
return true
}
private fun showSaveConfirmation(manga: Manga, chaptersCount: Int, branches: List<MangaBranch>) {
val dialogBuilder = MaterialAlertDialogBuilder(activity)
.setTitle(R.string.save_manga)
.setNegativeButton(android.R.string.cancel, null)
if (branches.size > 1) {
val items = Array(branches.size) { i -> branches[i].name.orEmpty() }
val currentBranch = branches.indexOfFirst { it.isSelected }
val checkedIndices = BooleanArray(branches.size) { i -> i == currentBranch }
dialogBuilder.setMultiChoiceItems(items, checkedIndices) { _, i, checked ->
checkedIndices[i] = checked
}.setPositiveButton(R.string.save) { _, _ ->
val selectedBranches = branches.mapIndexedNotNullTo(HashSet()) { i, b ->
if (checkedIndices[i]) b.name else null
}
val chaptersIds = manga.chapters?.mapNotNullToSet { c ->
if (c.branch in selectedBranches) c.id else null
}
viewModel.download(chaptersIds)
}
} else {
dialogBuilder.setMessage(
activity.getString(
R.string.large_manga_save_confirm,
activity.resources.getQuantityString(R.plurals.chapters, chaptersCount, chaptersCount),
),
).setPositiveButton(R.string.save) { _, _ ->
viewModel.download(null)
override fun onItemClick(item: DownloadOption, view: View) {
val chaptersIds: Set<Long>? = when (item) {
is DownloadOption.WholeManga -> null
is DownloadOption.SelectionHint -> {
viewModel.startChaptersSelection()
return
}
else -> item.chaptersIds
}
dialogBuilder.show()
viewModel.download(chaptersIds)
}
}

View File

@@ -31,12 +31,14 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository
import org.koitharu.kotatsu.core.model.getPreferredBranch
import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.core.parser.MangaIntent
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.observeAsStateFlow
import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.core.util.ext.combine
import org.koitharu.kotatsu.core.util.ext.computeSize
import org.koitharu.kotatsu.core.util.ext.requireValue
import org.koitharu.kotatsu.core.util.ext.sanitize
@@ -72,6 +74,7 @@ class DetailsViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val deleteLocalMangaUseCase: DeleteLocalMangaUseCase,
private val doubleMangaLoadUseCase: DoubleMangaLoadUseCase,
networkState: NetworkState,
) : BaseViewModel() {
private val intent = MangaIntent(savedStateHandle)
@@ -81,6 +84,7 @@ class DetailsViewModel @Inject constructor(
val onShowToast = MutableEventFlow<Int>()
val onShowTip = MutableEventFlow<Unit>()
val onSelectChapter = MutableEventFlow<Long>()
val onDownloadStarted = MutableEventFlow<Unit>()
val manga = doubleManga.map { it?.any }
@@ -176,8 +180,9 @@ class DetailsViewModel @Inject constructor(
selectedBranch,
newChaptersCount,
bookmarks,
) { manga, history, branch, news, bookmarks ->
mapChapters(manga?.remote, manga?.local, history, news, branch, bookmarks)
networkState,
) { manga, history, branch, news, bookmarks, isOnline ->
mapChapters(manga?.remote?.takeIf { isOnline }, manga?.local, history, news, branch, bookmarks)
},
isChaptersReversed,
chaptersQuery,
@@ -286,6 +291,14 @@ class DetailsViewModel @Inject constructor(
}
}
fun startChaptersSelection() {
val chapters = chapters.value
val chapter = chapters.find {
it.isUnread && !it.isDownloaded
} ?: chapters.firstOrNull() ?: return
onSelectChapter.call(chapter.chapter.id)
}
fun onButtonTipClosed() {
settings.closeTip(DetailsActivity.TIP_BUTTON)
}

View File

@@ -0,0 +1,64 @@
package org.koitharu.kotatsu.details.ui
import android.content.DialogInterface
import android.view.View
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.ids
import org.koitharu.kotatsu.core.ui.dialog.RecyclerViewAlertDialog
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.download.ui.dialog.DownloadOption
import org.koitharu.kotatsu.download.ui.dialog.downloadOptionAD
class DownloadDialogHelper(
private val host: View,
private val viewModel: DetailsViewModel,
) {
fun show(callback: OnListItemClickListener<DownloadOption>) {
val branch = viewModel.selectedBranchValue
val allChapters = viewModel.manga.value?.chapters ?: return
val branchChapters = viewModel.manga.value?.getChapters(branch).orEmpty()
val history = viewModel.history.value
val options = buildList {
add(DownloadOption.WholeManga(allChapters.ids()))
if (branch != null && branchChapters.isNotEmpty()) {
add(DownloadOption.AllChapters(branch, branchChapters.ids()))
}
if (history != null) {
val unreadChapters = branchChapters.takeLastWhile { it.id != history.chapterId }
if (unreadChapters.isNotEmpty() && unreadChapters.size < branchChapters.size) {
add(DownloadOption.AllUnreadChapters(unreadChapters.ids(), branch))
if (unreadChapters.size > 5) {
add(DownloadOption.NextUnreadChapters(unreadChapters.take(5).ids()))
if (unreadChapters.size > 10) {
add(DownloadOption.NextUnreadChapters(unreadChapters.take(10).ids()))
}
}
}
} else {
if (branchChapters.size > 5) {
add(DownloadOption.FirstChapters(branchChapters.take(5).ids()))
if (branchChapters.size > 10) {
add(DownloadOption.FirstChapters(branchChapters.take(10).ids()))
}
}
}
add(DownloadOption.SelectionHint())
}
var dialog: DialogInterface? = null
val listener = OnListItemClickListener<DownloadOption> { item, _ ->
callback.onItemClick(item, host)
dialog?.dismiss()
}
dialog = RecyclerViewAlertDialog.Builder<DownloadOption>(host.context)
.addAdapterDelegate(downloadOptionAD(listener))
.setCancelable(true)
.setTitle(R.string.download)
.setNegativeButton(android.R.string.cancel)
.setItems(options)
.create()
.also { it.show() }
}
}

View File

@@ -1,16 +0,0 @@
package org.koitharu.kotatsu.details.ui
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
import androidx.viewpager2.adapter.FragmentStateAdapter
class MangaDetailsAdapter(activity: FragmentActivity) : FragmentStateAdapter(activity) {
override fun getItemCount() = 2
override fun createFragment(position: Int): Fragment = when (position) {
0 -> DetailsFragment()
1 -> ChaptersFragment()
else -> throw IndexOutOfBoundsException("No fragment for position $position")
}
}

View File

@@ -0,0 +1,99 @@
package org.koitharu.kotatsu.download.ui.dialog
import android.content.res.Resources
import androidx.annotation.DrawableRes
import org.koitharu.kotatsu.R
import java.util.Locale
import com.google.android.material.R as materialR
sealed interface DownloadOption {
val chaptersIds: Set<Long>
@get:DrawableRes
val iconResId: Int
val chaptersCount: Int
get() = chaptersIds.size
fun getLabel(resources: Resources): CharSequence
class AllChapters(
val branch: String,
override val chaptersIds: Set<Long>,
) : DownloadOption {
override val iconResId = R.drawable.ic_select_group
override fun getLabel(resources: Resources): CharSequence {
return resources.getString(R.string.download_option_all_chapters, branch)
}
}
class WholeManga(
override val chaptersIds: Set<Long>,
) : DownloadOption {
override val iconResId = materialR.drawable.abc_ic_menu_selectall_mtrl_alpha
override fun getLabel(resources: Resources): CharSequence {
return resources.getString(R.string.download_option_whole_manga)
}
}
class FirstChapters(
override val chaptersIds: Set<Long>,
) : DownloadOption {
override val iconResId = R.drawable.ic_list_start
override fun getLabel(resources: Resources): CharSequence {
return resources.getString(
R.string.download_option_first_n_chapters,
resources.getQuantityString(R.plurals.chapters, chaptersCount, chaptersCount)
.lowercase(Locale.getDefault()),
)
}
}
class AllUnreadChapters(
override val chaptersIds: Set<Long>,
val branch: String?,
) : DownloadOption {
override val iconResId = R.drawable.ic_list_end
override fun getLabel(resources: Resources): CharSequence {
return if (branch == null) {
resources.getString(R.string.download_option_all_unread)
} else {
resources.getString(R.string.download_option_all_unread_b, branch)
}
}
}
class NextUnreadChapters(
override val chaptersIds: Set<Long>,
) : DownloadOption {
override val iconResId = R.drawable.ic_list_next
override fun getLabel(resources: Resources): CharSequence {
return resources.getString(
R.string.download_option_next_unread_n_chapters,
resources.getQuantityString(R.plurals.chapters, chaptersCount, chaptersCount)
.lowercase(Locale.getDefault()),
)
}
}
class SelectionHint : DownloadOption {
override val chaptersIds: Set<Long> = emptySet()
override val iconResId = R.drawable.ic_tap
override fun getLabel(resources: Resources): CharSequence {
return resources.getString(R.string.download_option_manual_selection)
}
}
}

View File

@@ -0,0 +1,27 @@
package org.koitharu.kotatsu.download.ui.dialog
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.databinding.ItemDownloadOptionBinding
fun downloadOptionAD(
onClickListener: OnListItemClickListener<DownloadOption>,
) = adapterDelegateViewBinding<DownloadOption, DownloadOption, ItemDownloadOptionBinding>(
{ layoutInflater, parent -> ItemDownloadOptionBinding.inflate(layoutInflater, parent, false) },
) {
binding.root.setOnClickListener { v -> onClickListener.onItemClick(item, v) }
bind {
with(binding.root) {
title = item.getLabel(resources)
subtitle = if (item.chaptersCount == 0) null else resources.getQuantityString(
R.plurals.chapters,
item.chaptersCount,
item.chaptersCount,
)
setIconResource(item.iconResId)
}
}
}

View File

@@ -141,11 +141,13 @@ class DownloadsViewModel @Inject constructor(
fun remove(ids: Set<Long>) {
launchJob(Dispatchers.Default) {
val snapshot = works.value ?: return@launchJob
val uuids = HashSet<UUID>(ids.size)
for (work in snapshot) {
if (work.id.mostSignificantBits in ids) {
workScheduler.delete(work.id)
uuids.add(work.id)
}
}
workScheduler.delete(uuids)
onActionDone.call(ReversibleAction(R.string.downloads_removed, null))
}
}

View File

@@ -45,6 +45,7 @@ import org.koitharu.kotatsu.core.util.WorkManagerHelper
import org.koitharu.kotatsu.core.util.ext.deleteAwait
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.writeAllCancellable
import org.koitharu.kotatsu.core.util.progress.TimeLeftEstimator
import org.koitharu.kotatsu.download.domain.DownloadState
@@ -60,7 +61,6 @@ import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.await
import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import java.io.File
import java.util.UUID
import java.util.concurrent.TimeUnit
@@ -322,9 +322,9 @@ class DownloadWorker @AssistedInject constructor(
manga: Manga,
includedIds: LongArray?,
): List<MangaChapter> {
val chapters = checkNotNull(manga.chapters?.toMutableList()) {
val chapters = checkNotNull(manga.chapters) {
"Chapters list must not be null"
}
}.toMutableList()
if (includedIds != null) {
val chaptersIdsSet = includedIds.toMutableSet()
chapters.retainAll { x -> chaptersIdsSet.remove(x.id) }
@@ -399,6 +399,13 @@ class DownloadWorker @AssistedInject constructor(
WorkManagerHelper(workManager).deleteWork(id)
}
suspend fun delete(ids: Collection<UUID>) {
val wm = workManager
val helper = WorkManagerHelper(wm)
ids.forEach { id -> wm.cancelWorkById(id).await() }
helper.deleteWorks(ids)
}
suspend fun removeCompleted() {
val helper = WorkManagerHelper(workManager)
val finishedWorks = helper.getFinishedWorkInfosByTag(TAG)
@@ -406,10 +413,7 @@ class DownloadWorker @AssistedInject constructor(
}
suspend fun updateConstraints() {
val constraints = Constraints.Builder()
.setRequiresStorageNotLow(true)
.setRequiredNetworkType(if (settings.isDownloadsWiFiOnly) NetworkType.UNMETERED else NetworkType.CONNECTED)
.build()
val constraints = createConstraints()
val helper = WorkManagerHelper(workManager)
val works = helper.getWorkInfosByTag(TAG)
for (work in works) {
@@ -418,6 +422,7 @@ class DownloadWorker @AssistedInject constructor(
}
val request = OneTimeWorkRequestBuilder<DownloadWorker>()
.setConstraints(constraints)
.addTag(TAG)
.setId(work.id)
.build()
helper.updateWork(request)
@@ -425,15 +430,15 @@ class DownloadWorker @AssistedInject constructor(
}
private suspend fun scheduleImpl(data: Collection<Data>) {
val constraints = Constraints.Builder()
.setRequiresStorageNotLow(true)
.setRequiredNetworkType(if (settings.isDownloadsWiFiOnly) NetworkType.UNMETERED else NetworkType.CONNECTED)
.build()
if (data.isEmpty()) {
return
}
val constraints = createConstraints()
val requests = data.map { inputData ->
OneTimeWorkRequestBuilder<DownloadWorker>()
.setConstraints(constraints)
.addTag(TAG)
.keepResultsForAtLeast(7, TimeUnit.DAYS)
.keepResultsForAtLeast(30, TimeUnit.DAYS)
.setBackoffCriteria(BackoffPolicy.LINEAR, 10, TimeUnit.SECONDS)
.setInputData(inputData)
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
@@ -441,6 +446,10 @@ class DownloadWorker @AssistedInject constructor(
}
workManager.enqueue(requests).await()
}
private fun createConstraints() = Constraints.Builder()
.setRequiredNetworkType(if (settings.isDownloadsWiFiOnly) NetworkType.UNMETERED else NetworkType.CONNECTED)
.build()
}
private companion object {

View File

@@ -22,6 +22,7 @@ import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.ui.widgets.ChipsView
import org.koitharu.kotatsu.core.util.ext.lifecycleScope
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.require
import org.koitharu.kotatsu.filter.ui.model.FilterHeaderModel
import org.koitharu.kotatsu.filter.ui.model.FilterItem
@@ -35,7 +36,6 @@ import org.koitharu.kotatsu.parsers.util.SuspendLazy
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.remotelist.ui.RemoteListFragment
import org.koitharu.kotatsu.search.domain.MangaSearchRepository
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import java.text.Collator
import java.util.LinkedList
import java.util.Locale
@@ -49,7 +49,7 @@ class FilterCoordinator @Inject constructor(
dataRepository: MangaDataRepository,
private val searchRepository: MangaSearchRepository,
lifecycle: ViewModelLifecycle,
) : FilterOwner {
) : MangaFilter {
private val coroutineScope = lifecycle.lifecycleScope
private val repository = mangaRepositoryFactory.create(savedStateHandle.require(RemoteListFragment.ARG_SOURCE))

View File

@@ -19,9 +19,8 @@ import com.google.android.material.R as materialR
class FilterHeaderFragment : BaseFragment<FragmentFilterHeaderBinding>(), ChipsView.OnChipClickListener {
private val owner by lazy(LazyThreadSafetyMode.NONE) {
FilterOwner.from(requireActivity())
}
private val filter: MangaFilter
get() = (requireActivity() as FilterOwner).filter
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentFilterHeaderBinding {
return FragmentFilterHeaderBinding.inflate(inflater, container, false)
@@ -30,7 +29,7 @@ class FilterHeaderFragment : BaseFragment<FragmentFilterHeaderBinding>(), ChipsV
override fun onViewBindingCreated(binding: FragmentFilterHeaderBinding, savedInstanceState: Bundle?) {
super.onViewBindingCreated(binding, savedInstanceState)
binding.chipsTags.onChipClickListener = this
owner.header.observe(viewLifecycleOwner, ::onDataChanged)
filter.header.observe(viewLifecycleOwner, ::onDataChanged)
}
override fun onWindowInsetsChanged(insets: Insets) = Unit
@@ -40,7 +39,7 @@ class FilterHeaderFragment : BaseFragment<FragmentFilterHeaderBinding>(), ChipsV
if (tag == null) {
FilterSheetFragment.show(parentFragmentManager)
} else {
owner.onTagItemClick(FilterItem.Tag(tag, !chip.isChecked))
filter.onTagItemClick(FilterItem.Tag(tag, !chip.isChecked))
}
}

View File

@@ -1,32 +1,6 @@
package org.koitharu.kotatsu.filter.ui
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
import kotlinx.coroutines.flow.StateFlow
import org.koitharu.kotatsu.core.util.ext.values
import org.koitharu.kotatsu.filter.ui.model.FilterHeaderModel
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.model.MangaTag
interface FilterOwner {
interface FilterOwner : OnFilterChangedListener {
val filterItems: StateFlow<List<ListModel>>
val header: StateFlow<FilterHeaderModel>
fun applyFilter(tags: Set<MangaTag>)
companion object {
fun from(activity: FragmentActivity): FilterOwner {
for (f in activity.supportFragmentManager.fragments) {
return find(f) ?: continue
}
error("Cannot find FilterOwner")
}
fun find(fragment: Fragment): FilterOwner? {
return fragment.viewModelStore.values.firstNotNullOfOrNull { it as? FilterOwner }
}
}
val filter: MangaFilter
}

View File

@@ -21,20 +21,17 @@ class FilterSheetFragment :
AdaptiveSheetCallback,
AsyncListDiffer.ListListener<ListModel> {
private val owner by lazy(LazyThreadSafetyMode.NONE) {
FilterOwner.from(requireActivity())
}
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): SheetFilterBinding {
return SheetFilterBinding.inflate(inflater, container, false)
}
override fun onViewBindingCreated(binding: SheetFilterBinding, savedInstanceState: Bundle?) {
super.onViewBindingCreated(binding, savedInstanceState)
val filter = (requireActivity() as FilterOwner).filter
addSheetCallback(this)
val adapter = FilterAdapter(owner, this)
val adapter = FilterAdapter(filter, this)
binding.recyclerView.adapter = adapter
owner.filterItems.observe(viewLifecycleOwner, adapter::setItems)
filter.filterItems.observe(viewLifecycleOwner, adapter::setItems)
if (dialog == null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
binding.recyclerView.scrollIndicators = 0

View File

@@ -0,0 +1,15 @@
package org.koitharu.kotatsu.filter.ui
import kotlinx.coroutines.flow.StateFlow
import org.koitharu.kotatsu.filter.ui.model.FilterHeaderModel
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.model.MangaTag
interface MangaFilter : OnFilterChangedListener {
val filterItems: StateFlow<List<ListModel>>
val header: StateFlow<FilterHeaderModel>
fun applyFilter(tags: Set<MangaTag>)
}

View File

@@ -45,6 +45,13 @@ class HistoryRepository @Inject constructor(
return entity.manga.toManga(entity.tags.toMangaTags())
}
fun observeLast(): Flow<Manga?> {
return db.historyDao.observeAll(1).map {
val first = it.firstOrNull()
first?.manga?.toManga(first.tags.toMangaTags())
}
}
fun observeAll(): Flow<List<Manga>> {
return db.historyDao.observeAll().mapItems {
it.manga.toManga(it.tags.toMangaTags())

View File

@@ -1,13 +1,16 @@
package org.koitharu.kotatsu.history.domain
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
import org.koitharu.kotatsu.history.data.HistoryRepository
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.reader.ui.ReaderState
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import javax.inject.Inject
class HistoryUpdateUseCase @Inject constructor(
@@ -28,9 +31,11 @@ class HistoryUpdateUseCase @Inject constructor(
manga: Manga,
readerState: ReaderState,
percent: Float
) = processLifecycleScope.launch(Dispatchers.Default) {
) = processLifecycleScope.launch(Dispatchers.Default, CoroutineStart.ATOMIC) {
runCatchingCancellable {
invoke(manga, readerState, percent)
withContext(NonCancellable) {
invoke(manga, readerState, percent)
}
}.onFailure {
it.printStackTraceDebug()
}

View File

@@ -23,7 +23,7 @@ class ImageFileFilter : FilenameFilter, FileFilter {
return isExtensionValid(ext)
}
private fun isExtensionValid(ext: String): Boolean {
fun isExtensionValid(ext: String): Boolean {
return ext == "png" || ext == "jpg" || ext == "jpeg" || ext == "webp"
}
}

View File

@@ -29,6 +29,7 @@ import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import java.io.File
import java.io.FilenameFilter
import java.util.EnumSet
import javax.inject.Inject
import javax.inject.Singleton
@@ -192,7 +193,7 @@ class LocalMangaRepository @Inject constructor(
val dispatcher = Dispatchers.IO.limitedParallelism(MAX_PARALLELISM)
files.map { file ->
async(dispatcher) {
runCatchingCancellable { LocalMangaInput.of(file).getManga() }.getOrNull()
runCatchingCancellable { LocalMangaInput.ofOrNull(file)?.getManga() }.getOrNull()
}
}.awaitAll()
}.filterNotNullTo(ArrayList(files.size))

View File

@@ -2,8 +2,11 @@ package org.koitharu.kotatsu.local.data
import android.content.ContentResolver
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.StatFs
import androidx.annotation.WorkerThread
import androidx.core.net.toFile
import dagger.Reusable
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
@@ -13,11 +16,14 @@ import okhttp3.Cache
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.ext.computeSize
import org.koitharu.kotatsu.core.util.ext.getStorageName
import org.koitharu.kotatsu.core.util.ext.resolveFile
import org.koitharu.kotatsu.core.util.ext.toFileOrNull
import org.koitharu.kotatsu.parsers.util.mapToSet
import java.io.File
import javax.inject.Inject
private const val DIR_NAME = "manga"
private const val NOMEDIA = ".nomedia"
private const val CACHE_DISK_PERCENTAGE = 0.02
private const val CACHE_SIZE_MIN: Long = 10 * 1024 * 1024 // 10MB
private const val CACHE_SIZE_MAX: Long = 250 * 1024 * 1024 // 250MB
@@ -74,14 +80,42 @@ class LocalStorageManager @Inject constructor(
preferredDir ?: getFallbackStorageDir()?.takeIf { it.isWriteable() }
}
fun getStorageDisplayName(file: File) = file.getStorageName(context)
suspend fun getApplicationStorageDirs(): Set<File> = runInterruptible(Dispatchers.IO) {
getAvailableStorageDirs()
}
suspend fun resolveUri(uri: Uri): File? = runInterruptible(Dispatchers.IO) {
if (uri.scheme == "file") {
uri.toFile()
} else {
uri.resolveFile(context)
}
}
suspend fun setDirIsNoMedia(dir: File) = runInterruptible(Dispatchers.IO) {
File(dir, NOMEDIA).createNewFile()
}
fun takePermissions(uri: Uri) {
val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
contentResolver.takePersistableUriPermission(uri, flags)
}
suspend fun getDirectoryDisplayName(dir: File, isFullPath: Boolean): String = runInterruptible(Dispatchers.IO) {
val packageName = context.packageName
if (dir.absolutePath.contains(packageName)) {
dir.getStorageName(context)
} else if (isFullPath) {
dir.path
} else {
dir.name
}
}
@WorkerThread
private fun getConfiguredStorageDirs(): MutableSet<File> {
val set = getAvailableStorageDirs()
settings.mangaStorageDir?.let {
set.add(it)
}
set.addAll(settings.userSpecifiedMangaDirectories)
return set
}

View File

@@ -2,6 +2,7 @@ package org.koitharu.kotatsu.local.data.input
import android.net.Uri
import androidx.core.net.toFile
import org.koitharu.kotatsu.local.data.CbzFilter
import org.koitharu.kotatsu.local.domain.model.LocalManga
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
@@ -30,6 +31,12 @@ sealed class LocalMangaInput(
else -> LocalMangaZipInput(file)
}
fun ofOrNull(file: File): LocalMangaInput? = when {
file.isDirectory -> LocalMangaDirInput(file)
CbzFilter.isFileSupported(file.name) -> LocalMangaZipInput(file)
else -> null
}
@JvmStatic
protected fun zipUri(file: File, entryName: String): String =
Uri.fromParts("cbz", file.path, entryName).toString()

View File

@@ -35,18 +35,6 @@ class LocalMangaUtil(
}
}
suspend fun writeIndex(index: MangaIndex) {
newOutput().use { output ->
when (output) {
is LocalMangaDirOutput -> {
TODO()
}
is LocalMangaZipOutput -> TODO()
}
}
}
private suspend fun newOutput(): LocalMangaOutput = runInterruptible(Dispatchers.IO) {
val file = manga.url.toUri().toFile()
if (file.isDirectory) {

View File

@@ -85,7 +85,7 @@ class LocalChaptersRemoveService : CoroutineIntentService() {
.setProgress(0, 0, true)
.setSmallIcon(android.R.drawable.stat_notify_sync)
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_DEFERRED)
.setOngoing(true)
.setOngoing(false)
.build()
startForeground(NOTIFICATION_ID, notification)
}

View File

@@ -17,18 +17,23 @@ import org.koitharu.kotatsu.core.util.ext.addMenuProvider
import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.withArgs
import org.koitharu.kotatsu.databinding.FragmentListBinding
import org.koitharu.kotatsu.filter.ui.FilterOwner
import org.koitharu.kotatsu.filter.ui.FilterSheetFragment
import org.koitharu.kotatsu.filter.ui.MangaFilter
import org.koitharu.kotatsu.list.ui.MangaListFragment
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.remotelist.ui.RemoteListFragment
class LocalListFragment : MangaListFragment() {
class LocalListFragment : MangaListFragment(), FilterOwner {
override val viewModel by viewModels<LocalListViewModel>()
override val filter: MangaFilter
get() = viewModel
override fun onViewBindingCreated(binding: FragmentListBinding, savedInstanceState: Bundle?) {
super.onViewBindingCreated(binding, savedInstanceState)
addMenuProvider(LocalListMenuProvider(this::onEmptyActionClick))
addMenuProvider(LocalListMenuProvider(binding.root.context, this::onEmptyActionClick))
viewModel.onMangaRemoved.observeEvent(viewLifecycleOwner) { onItemRemoved() }
}
@@ -40,7 +45,7 @@ class LocalListFragment : MangaListFragment() {
FilterSheetFragment.show(childFragmentManager)
}
override fun onScrolledToEnd() = Unit
override fun onScrolledToEnd() = viewModel.loadNextPage()
override fun onCreateActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean {
mode.menuInflater.inflate(R.menu.mode_local, menu)

View File

@@ -1,12 +1,15 @@
package org.koitharu.kotatsu.local.ui
import android.content.Context
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import androidx.core.view.MenuProvider
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.settings.storage.directories.MangaDirectoriesActivity
class LocalListMenuProvider(
private val context: Context,
private val onImportClick: Function0<Unit>,
) : MenuProvider {
@@ -20,6 +23,12 @@ class LocalListMenuProvider(
onImportClick()
true
}
R.id.action_settings -> {
context.startActivity(MangaDirectoriesActivity.newIntent(context))
true
}
else -> false
}
}

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.local.ui
import android.content.SharedPreferences
import androidx.lifecycle.SavedStateHandle
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
@@ -24,7 +25,7 @@ class LocalListViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
mangaRepositoryFactory: MangaRepository.Factory,
filter: FilterCoordinator,
settings: AppSettings,
private val settings: AppSettings,
downloadScheduler: DownloadWorker.Scheduler,
listExtraProvider: ListExtraProvider,
private val deleteLocalMangaUseCase: DeleteLocalMangaUseCase,
@@ -36,7 +37,7 @@ class LocalListViewModel @Inject constructor(
settings,
listExtraProvider,
downloadScheduler,
) {
), SharedPreferences.OnSharedPreferenceChangeListener {
val onMangaRemoved = MutableEventFlow<Unit>()
@@ -47,6 +48,18 @@ class LocalListViewModel @Inject constructor(
loadList(filter.snapshot(), append = false).join()
}
}
settings.subscribe(this)
}
override fun onCleared() {
settings.unsubscribe(this)
super.onCleared()
}
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) {
if (key == AppSettings.KEY_LOCAL_MANGA_DIRS) {
onRefresh()
}
}
fun delete(ids: Set<Long>) {

View File

@@ -9,6 +9,7 @@ import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import androidx.work.await
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import org.koitharu.kotatsu.local.data.LocalMangaRepository
@@ -33,7 +34,7 @@ class LocalStorageCleanupWorker @AssistedInject constructor(
private const val TAG = "cleanup"
fun enqueue(context: Context) {
suspend fun enqueue(context: Context) {
val constraints = Constraints.Builder()
.setRequiresBatteryNotLow(true)
.build()
@@ -42,7 +43,7 @@ class LocalStorageCleanupWorker @AssistedInject constructor(
.addTag(TAG)
.setBackoffCriteria(BackoffPolicy.LINEAR, 1, TimeUnit.MINUTES)
.build()
WorkManager.getInstance(context).enqueueUniqueWork(TAG, ExistingWorkPolicy.KEEP, request)
WorkManager.getInstance(context).enqueueUniqueWork(TAG, ExistingWorkPolicy.KEEP, request).await()
}
}
}

View File

@@ -0,0 +1,31 @@
package org.koitharu.kotatsu.main.domain
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.observeAsFlow
import org.koitharu.kotatsu.history.data.HistoryRepository
import org.koitharu.kotatsu.parsers.model.MangaSource
import javax.inject.Inject
class ReadingResumeEnabledUseCase @Inject constructor(
private val networkState: NetworkState,
private val historyRepository: HistoryRepository,
private val settings: AppSettings,
) {
operator fun invoke(): Flow<Boolean> = settings.observeAsFlow(AppSettings.KEY_INCOGNITO_MODE) {
isIncognitoModeEnabled
}.flatMapLatest { incognito ->
if (incognito) {
flowOf(false)
} else {
combine(networkState, historyRepository.observeLast()) { isOnline, last ->
last != null && (isOnline || last.source == MangaSource.LOCAL)
}
}
}
}

View File

@@ -69,8 +69,6 @@ import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionViewModel
import org.koitharu.kotatsu.settings.newsources.NewSourcesDialogFragment
import org.koitharu.kotatsu.settings.onboard.OnboardDialogFragment
import org.koitharu.kotatsu.shelf.ui.ShelfFragment
import org.koitharu.kotatsu.suggestions.ui.SuggestionsWorker
import org.koitharu.kotatsu.tracker.work.TrackWorker
import javax.inject.Inject
import com.google.android.material.R as materialR
@@ -321,8 +319,6 @@ class MainActivity :
}
}
withContext(Dispatchers.Default) {
TrackWorker.setup(applicationContext)
SuggestionsWorker.setup(applicationContext)
LocalStorageCleanupWorker.enqueue(applicationContext)
}
withResumed {

View File

@@ -97,7 +97,7 @@ class MainNavigationDelegate(
}
private fun onNavigationItemSelected(@IdRes itemId: Int): Boolean {
setPrimaryFragment(
return setPrimaryFragment(
when (itemId) {
R.id.nav_shelf -> ShelfFragment.newInstance()
R.id.nav_explore -> ExploreFragment.newInstance()
@@ -106,7 +106,6 @@ class MainNavigationDelegate(
else -> return false
},
)
return true
}
private fun getItemId(fragment: Fragment) = when (fragment) {
@@ -117,13 +116,17 @@ class MainNavigationDelegate(
else -> 0
}
private fun setPrimaryFragment(fragment: Fragment) {
private fun setPrimaryFragment(fragment: Fragment): Boolean {
if (fragmentManager.isStateSaved) {
return false
}
fragmentManager.beginTransaction()
.setReorderingAllowed(true)
.replace(R.id.container, fragment, TAG_PRIMARY)
.setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE)
.commit()
onFragmentChanged(fragment, fromUser = true)
return true
}
private fun onFragmentChanged(fragment: Fragment, fromUser: Boolean) {

View File

@@ -13,12 +13,12 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.EmptyHistoryException
import org.koitharu.kotatsu.core.github.AppUpdateRepository
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.observeAsFlow
import org.koitharu.kotatsu.core.prefs.observeAsStateFlow
import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.history.data.HistoryRepository
import org.koitharu.kotatsu.main.domain.ReadingResumeEnabledUseCase
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import javax.inject.Inject
@@ -29,16 +29,12 @@ class MainViewModel @Inject constructor(
private val appUpdateRepository: AppUpdateRepository,
trackingRepository: TrackingRepository,
settings: AppSettings,
readingResumeEnabledUseCase: ReadingResumeEnabledUseCase,
) : BaseViewModel() {
val onOpenReader = MutableEventFlow<Manga>()
val isResumeEnabled = combine(
historyRepository.observeHasItems(),
settings.observeAsFlow(AppSettings.KEY_INCOGNITO_MODE) { isIncognitoModeEnabled },
) { hasItems, incognito ->
hasItems && !incognito
}.stateIn(
val isResumeEnabled = readingResumeEnabledUseCase().stateIn(
scope = viewModelScope + Dispatchers.Default,
started = SharingStarted.WhileSubscribed(5000),
initialValue = false,

View File

@@ -34,6 +34,8 @@ import org.koitharu.kotatsu.core.util.FileSize
import org.koitharu.kotatsu.core.util.RetainedLifecycleCoroutineScope
import org.koitharu.kotatsu.core.util.ext.ensureSuccess
import org.koitharu.kotatsu.core.util.ext.isNotEmpty
import org.koitharu.kotatsu.core.util.ext.isPowerSaveMode
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.ramAvailable
import org.koitharu.kotatsu.core.util.ext.withProgress
import org.koitharu.kotatsu.core.util.progress.ProgressDeferred
@@ -42,7 +44,6 @@ import org.koitharu.kotatsu.local.data.PagesCache
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import java.io.File
import java.util.LinkedList
import java.util.concurrent.atomic.AtomicInteger
@@ -83,7 +84,10 @@ class PageLoader @Inject constructor(
}
fun isPrefetchApplicable(): Boolean {
return repository is RemoteMangaRepository && settings.isPagesPreloadEnabled && !isLowRam()
return repository is RemoteMangaRepository
&& settings.isPagesPreloadEnabled
&& !context.isPowerSaveMode()
&& !isLowRam()
}
@AnyThread

View File

@@ -15,7 +15,9 @@ import org.koitharu.kotatsu.core.ui.list.ListSelectionController
import org.koitharu.kotatsu.core.util.ext.addMenuProvider
import org.koitharu.kotatsu.core.util.ext.withArgs
import org.koitharu.kotatsu.databinding.FragmentListBinding
import org.koitharu.kotatsu.filter.ui.FilterOwner
import org.koitharu.kotatsu.filter.ui.FilterSheetFragment
import org.koitharu.kotatsu.filter.ui.MangaFilter
import org.koitharu.kotatsu.list.ui.MangaListFragment
import org.koitharu.kotatsu.main.ui.owners.AppBarOwner
import org.koitharu.kotatsu.parsers.model.MangaSource
@@ -23,10 +25,13 @@ import org.koitharu.kotatsu.search.ui.SearchActivity
import org.koitharu.kotatsu.settings.SettingsActivity
@AndroidEntryPoint
class RemoteListFragment : MangaListFragment() {
class RemoteListFragment : MangaListFragment(), FilterOwner {
override val viewModel by viewModels<RemoteListViewModel>()
override val filter: MangaFilter
get() = viewModel
override fun onViewBindingCreated(binding: FragmentListBinding, savedInstanceState: Bundle?) {
super.onViewBindingCreated(binding, savedInstanceState)
addMenuProvider(RemoteListMenuProvider())

View File

@@ -20,10 +20,11 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.require
import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
import org.koitharu.kotatsu.filter.ui.FilterCoordinator
import org.koitharu.kotatsu.filter.ui.FilterOwner
import org.koitharu.kotatsu.filter.ui.MangaFilter
import org.koitharu.kotatsu.filter.ui.model.FilterState
import org.koitharu.kotatsu.list.domain.ListExtraProvider
import org.koitharu.kotatsu.list.ui.MangaListViewModel
@@ -36,7 +37,6 @@ import org.koitharu.kotatsu.list.ui.model.toUi
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import javax.inject.Inject
private const val FILTER_MIN_INTERVAL = 250L
@@ -49,7 +49,7 @@ open class RemoteListViewModel @Inject constructor(
settings: AppSettings,
listExtraProvider: ListExtraProvider,
downloadScheduler: DownloadWorker.Scheduler,
) : MangaListViewModel(settings, downloadScheduler), FilterOwner by filter {
) : MangaListViewModel(settings, downloadScheduler), MangaFilter by filter {
val source = savedStateHandle.require<MangaSource>(RemoteListFragment.ARG_SOURCE)
private val repository = mangaRepositoryFactory.create(source)

View File

@@ -13,7 +13,7 @@ enum class ScoreFormat {
POINT_5 -> score / 5f
POINT_3 -> score / 3f
}
}.coerceIn(0f, 1f)
companion object {

View File

@@ -173,7 +173,7 @@ class MALRepository @Inject constructor(
status = json.getString("status"),
chapter = json.getInt("num_chapters_read"),
comment = json.getString("comments"),
rating = json.getDouble("score").toFloat() / 10f,
rating = (json.getDouble("score").toFloat() / 10f).coerceIn(0f, 1f),
)
db.scrobblingDao.upsert(entity)
}

View File

@@ -190,7 +190,7 @@ class ShikimoriRepository @Inject constructor(
status = json.getString("status"),
chapter = json.getInt("chapters"),
comment = json.getString("text"),
rating = json.getDouble("score").toFloat() / 10f,
rating = (json.getDouble("score").toFloat() / 10f).coerceIn(0f, 1f),
)
db.scrobblingDao.upsert(entity)
}

View File

@@ -26,7 +26,7 @@ import org.koitharu.kotatsu.databinding.ActivityMangaListBinding
import org.koitharu.kotatsu.filter.ui.FilterHeaderFragment
import org.koitharu.kotatsu.filter.ui.FilterOwner
import org.koitharu.kotatsu.filter.ui.FilterSheetFragment
import org.koitharu.kotatsu.list.ui.MangaListFragment
import org.koitharu.kotatsu.filter.ui.MangaFilter
import org.koitharu.kotatsu.local.ui.LocalListFragment
import org.koitharu.kotatsu.main.ui.owners.AppBarOwner
import org.koitharu.kotatsu.parsers.model.MangaSource
@@ -36,11 +36,16 @@ import org.koitharu.kotatsu.remotelist.ui.RemoteListFragment
@AndroidEntryPoint
class MangaListActivity :
BaseActivity<ActivityMangaListBinding>(),
AppBarOwner, View.OnClickListener {
AppBarOwner, View.OnClickListener, FilterOwner {
override val appBar: AppBarLayout
get() = viewBinding.appbar
override val filter: MangaFilter
get() = checkNotNull(findFilterOwner()) {
"Cannot find FilterOwner fragment in ${supportFragmentManager.fragments}"
}.filter
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(ActivityMangaListBinding.inflate(layoutInflater))
@@ -74,7 +79,10 @@ class MangaListActivity :
private fun initList(source: MangaSource, tags: Set<MangaTag>?) {
val fm = supportFragmentManager
if (fm.findFragmentById(R.id.container) == null) {
val existingFragment = fm.findFragmentById(R.id.container)
if (existingFragment is FilterOwner) {
initFilter(existingFragment)
} else {
fm.commit {
setReorderingAllowed(true)
val fragment = if (source == MangaSource.LOCAL) {
@@ -83,17 +91,15 @@ class MangaListActivity :
RemoteListFragment.newInstance(source)
}
replace(R.id.container, fragment)
runOnCommit { initFilter() }
if (!tags.isNullOrEmpty() && fragment is RemoteListFragment) {
runOnCommit { initFilter(fragment) }
if (!tags.isNullOrEmpty()) {
runOnCommit(ApplyFilterRunnable(fragment, tags))
}
}
} else {
initFilter()
}
}
private fun initFilter() {
private fun initFilter(filterOwner: FilterOwner) {
if (viewBinding.containerFilter != null) {
if (supportFragmentManager.findFragmentById(R.id.container_filter) == null) {
supportFragmentManager.commit {
@@ -109,14 +115,14 @@ class MangaListActivity :
}
}
}
val filterOwner = FilterOwner.from(this)
val filter = filterOwner.filter
val chipSort = viewBinding.chipSort
if (chipSort != null) {
filterOwner.header.observe(this) {
filter.header.observe(this) {
chipSort.setTextAndVisible(it.sortOrder?.titleRes ?: 0)
}
} else {
filterOwner.header.map {
filter.header.map {
it.textSummary
}.flowOn(Dispatchers.Default)
.observe(this) {
@@ -125,15 +131,17 @@ class MangaListActivity :
}
}
private fun findFilterOwner(): FilterOwner? {
return supportFragmentManager.findFragmentById(R.id.container) as? FilterOwner
}
private class ApplyFilterRunnable(
private val fragment: MangaListFragment,
private val filterOwner: FilterOwner,
private val tags: Set<MangaTag>,
) : Runnable {
override fun run() {
checkNotNull(FilterOwner.find(fragment)) {
"Cannot find FilterOwner"
}.applyFilter(tags)
filterOwner.filter.applyFilter(tags)
}
}

View File

@@ -11,20 +11,18 @@ import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.BasePreferenceFragment
import org.koitharu.kotatsu.core.ui.dialog.StorageSelectDialog
import org.koitharu.kotatsu.core.util.ext.getStorageName
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.viewLifecycleScope
import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
import org.koitharu.kotatsu.local.data.LocalStorageManager
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import java.io.File
import org.koitharu.kotatsu.settings.storage.MangaDirectorySelectDialog
import org.koitharu.kotatsu.settings.storage.directories.MangaDirectoriesActivity
import javax.inject.Inject
@AndroidEntryPoint
class DownloadsSettingsFragment :
BasePreferenceFragment(R.string.downloads),
SharedPreferences.OnSharedPreferenceChangeListener,
StorageSelectDialog.OnStorageSelectListener {
SharedPreferences.OnSharedPreferenceChangeListener {
@Inject
lateinit var storageManager: LocalStorageManager
@@ -39,6 +37,7 @@ class DownloadsSettingsFragment :
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
findPreference<Preference>(AppSettings.KEY_LOCAL_STORAGE)?.bindStorageName()
findPreference<Preference>(AppSettings.KEY_LOCAL_MANGA_DIRS)?.bindDirectoriesCount()
settings.subscribe(this)
}
@@ -53,6 +52,10 @@ class DownloadsSettingsFragment :
findPreference<Preference>(key)?.bindStorageName()
}
AppSettings.KEY_LOCAL_MANGA_DIRS -> {
findPreference<Preference>(key)?.bindDirectoriesCount()
}
AppSettings.KEY_DOWNLOADS_WIFI -> {
updateDownloadsConstraints()
}
@@ -62,12 +65,12 @@ class DownloadsSettingsFragment :
override fun onPreferenceTreeClick(preference: Preference): Boolean {
return when (preference.key) {
AppSettings.KEY_LOCAL_STORAGE -> {
val ctx = context ?: return false
StorageSelectDialog.Builder(ctx, storageManager, this)
.setTitle(preference.title ?: "")
.setNegativeButton(android.R.string.cancel)
.create()
.show()
MangaDirectorySelectDialog.show(childFragmentManager)
true
}
AppSettings.KEY_LOCAL_MANGA_DIRS -> {
startActivity(MangaDirectoriesActivity.newIntent(preference.context))
true
}
@@ -75,14 +78,21 @@ class DownloadsSettingsFragment :
}
}
override fun onStorageSelected(file: File) {
settings.mangaStorageDir = file
}
private fun Preference.bindStorageName() {
viewLifecycleScope.launch {
val storage = storageManager.getDefaultWriteableDir()
summary = storage?.getStorageName(context) ?: getString(R.string.not_available)
summary = if (storage != null) {
storageManager.getDirectoryDisplayName(storage, isFullPath = true)
} else {
getString(R.string.not_available)
}
}
}
private fun Preference.bindDirectoriesCount() {
viewLifecycleScope.launch {
val dirs = storageManager.getReadableDirs().size
summary = resources.getQuantityString(R.plurals.items, dirs, dirs)
}
}

View File

@@ -8,34 +8,28 @@ import android.os.Bundle
import android.view.View
import androidx.activity.result.ActivityResultCallback
import androidx.activity.result.contract.ActivityResultContracts
import androidx.lifecycle.Lifecycle
import androidx.fragment.app.viewModels
import androidx.preference.Preference
import androidx.preference.TwoStatePreference
import androidx.preference.forEach
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.runInterruptible
import okhttp3.Cache
import kotlinx.coroutines.flow.StateFlow
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
import org.koitharu.kotatsu.core.os.AppShortcutManager
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.BasePreferenceFragment
import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver
import org.koitharu.kotatsu.core.util.FileSize
import org.koitharu.kotatsu.core.util.ext.awaitStateAtLeast
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.viewLifecycleScope
import org.koitharu.kotatsu.local.data.CacheDir
import org.koitharu.kotatsu.local.data.LocalStorageManager
import org.koitharu.kotatsu.search.domain.MangaSearchRepository
import org.koitharu.kotatsu.settings.backup.BackupDialogFragment
import org.koitharu.kotatsu.settings.backup.RestoreDialogFragment
import org.koitharu.kotatsu.settings.protect.ProtectSetupActivity
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import javax.inject.Inject
@AndroidEntryPoint
@@ -43,24 +37,11 @@ class UserDataSettingsFragment : BasePreferenceFragment(R.string.data_and_privac
SharedPreferences.OnSharedPreferenceChangeListener,
ActivityResultCallback<Uri?> {
@Inject
lateinit var trackerRepo: TrackingRepository
@Inject
lateinit var searchRepository: MangaSearchRepository
@Inject
lateinit var storageManager: LocalStorageManager
@Inject
lateinit var cookieJar: MutableCookieJar
@Inject
lateinit var cache: Cache
@Inject
lateinit var appShortcutManager: AppShortcutManager
private val viewModel: UserDataSettingsViewModel by viewModels()
private val backupSelectCall = registerForActivityResult(
ActivityResultContracts.OpenDocument(),
this,
@@ -76,23 +57,26 @@ class UserDataSettingsFragment : BasePreferenceFragment(R.string.data_and_privac
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
findPreference<Preference>(AppSettings.KEY_PAGES_CACHE_CLEAR)?.bindSummaryToCacheSize(CacheDir.PAGES)
findPreference<Preference>(AppSettings.KEY_THUMBS_CACHE_CLEAR)?.bindSummaryToCacheSize(CacheDir.THUMBS)
findPreference<Preference>(AppSettings.KEY_HTTP_CACHE_CLEAR)?.bindSummaryToHttpCacheSize()
findPreference<Preference>(AppSettings.KEY_PAGES_CACHE_CLEAR)?.bindBytesSizeSummary(checkNotNull(viewModel.cacheSizes[CacheDir.PAGES]))
findPreference<Preference>(AppSettings.KEY_THUMBS_CACHE_CLEAR)?.bindBytesSizeSummary(checkNotNull(viewModel.cacheSizes[CacheDir.THUMBS]))
findPreference<Preference>(AppSettings.KEY_HTTP_CACHE_CLEAR)?.bindBytesSizeSummary(viewModel.httpCacheSize)
findPreference<Preference>(AppSettings.KEY_SEARCH_HISTORY_CLEAR)?.let { pref ->
viewLifecycleScope.launch {
lifecycle.awaitStateAtLeast(Lifecycle.State.RESUMED)
val items = searchRepository.getSearchHistoryCount()
pref.summary = pref.context.resources.getQuantityString(R.plurals.items, items, items)
viewModel.searchHistoryCount.observe(viewLifecycleOwner) {
pref.summary = pref.context.resources.getQuantityString(R.plurals.items, it, it)
}
}
findPreference<Preference>(AppSettings.KEY_UPDATES_FEED_CLEAR)?.let { pref ->
viewLifecycleScope.launch {
lifecycle.awaitStateAtLeast(Lifecycle.State.RESUMED)
val items = trackerRepo.getLogsCount()
pref.summary = pref.context.resources.getQuantityString(R.plurals.items, items, items)
viewModel.feedItemsCount.observe(viewLifecycleOwner) {
pref.summary = pref.context.resources.getQuantityString(R.plurals.items, it, it)
}
}
viewModel.loadingKeys.observe(viewLifecycleOwner) { keys ->
preferenceScreen.forEach { pref ->
pref.isEnabled = pref.key !in keys
}
}
viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(listView, this))
viewModel.onActionDone.observeEvent(viewLifecycleOwner, ReversibleActionObserver(listView))
settings.subscribe(this)
}
@@ -104,12 +88,12 @@ class UserDataSettingsFragment : BasePreferenceFragment(R.string.data_and_privac
override fun onPreferenceTreeClick(preference: Preference): Boolean {
return when (preference.key) {
AppSettings.KEY_PAGES_CACHE_CLEAR -> {
clearCache(preference, CacheDir.PAGES)
viewModel.clearCache(preference.key, CacheDir.PAGES)
true
}
AppSettings.KEY_THUMBS_CACHE_CLEAR -> {
clearCache(preference, CacheDir.THUMBS)
viewModel.clearCache(preference.key, CacheDir.THUMBS)
true
}
@@ -119,26 +103,17 @@ class UserDataSettingsFragment : BasePreferenceFragment(R.string.data_and_privac
}
AppSettings.KEY_SEARCH_HISTORY_CLEAR -> {
clearSearchHistory(preference)
clearSearchHistory()
true
}
AppSettings.KEY_HTTP_CACHE_CLEAR -> {
clearHttpCache()
viewModel.clearHttpCache()
true
}
AppSettings.KEY_UPDATES_FEED_CLEAR -> {
viewLifecycleScope.launch {
trackerRepo.clearLogs()
preference.summary = preference.context.resources
.getQuantityString(R.plurals.items, 0, 0)
Snackbar.make(
view ?: return@launch,
R.string.updates_feed_cleared,
Snackbar.LENGTH_SHORT,
).show()
}
viewModel.clearUpdatesFeed()
true
}
@@ -189,71 +164,23 @@ class UserDataSettingsFragment : BasePreferenceFragment(R.string.data_and_privac
}
}
private fun clearCache(preference: Preference, cache: CacheDir) {
val ctx = preference.context.applicationContext
viewLifecycleScope.launch {
try {
preference.isEnabled = false
storageManager.clearCache(cache)
val size = storageManager.computeCacheSize(cache)
preference.summary = FileSize.BYTES.format(ctx, size)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
preference.summary = e.getDisplayMessage(ctx.resources)
} finally {
preference.isEnabled = true
private fun Preference.bindBytesSizeSummary(stateFlow: StateFlow<Long>) {
stateFlow.observe(viewLifecycleOwner) { size ->
summary = if (size < 0) {
context.getString(R.string.computing_)
} else {
FileSize.BYTES.format(context, size)
}
}
}
private fun Preference.bindSummaryToCacheSize(dir: CacheDir) = viewLifecycleScope.launch {
val size = storageManager.computeCacheSize(dir)
summary = FileSize.BYTES.format(context, size)
}
private fun Preference.bindSummaryToHttpCacheSize() = viewLifecycleScope.launch {
val size = runInterruptible(Dispatchers.IO) { cache.size() }
summary = FileSize.BYTES.format(context, size)
}
private fun clearHttpCache() {
val preference = findPreference<Preference>(AppSettings.KEY_HTTP_CACHE_CLEAR) ?: return
val ctx = preference.context.applicationContext
viewLifecycleScope.launch {
try {
preference.isEnabled = false
val size = runInterruptible(Dispatchers.IO) {
cache.evictAll()
cache.size()
}
preference.summary = FileSize.BYTES.format(ctx, size)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
preference.summary = e.getDisplayMessage(ctx.resources)
} finally {
preference.isEnabled = true
}
}
}
private fun clearSearchHistory(preference: Preference) {
private fun clearSearchHistory() {
MaterialAlertDialogBuilder(context ?: return)
.setTitle(R.string.clear_search_history)
.setMessage(R.string.text_clear_search_history_prompt)
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(R.string.clear) { _, _ ->
viewLifecycleScope.launch {
searchRepository.clearSearchHistory()
preference.summary = preference.context.resources
.getQuantityString(R.plurals.items, 0, 0)
Snackbar.make(
view ?: return@launch,
R.string.search_history_cleared,
Snackbar.LENGTH_SHORT,
).show()
}
viewModel.clearSearchHistory()
}.show()
}
@@ -263,14 +190,7 @@ class UserDataSettingsFragment : BasePreferenceFragment(R.string.data_and_privac
.setMessage(R.string.text_clear_cookies_prompt)
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(R.string.clear) { _, _ ->
viewLifecycleScope.launch {
cookieJar.clear()
Snackbar.make(
listView ?: return@launch,
R.string.cookies_cleared,
Snackbar.LENGTH_SHORT,
).show()
}
viewModel.clearCookies()
}.show()
}
}

View File

@@ -0,0 +1,109 @@
package org.koitharu.kotatsu.settings
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.runInterruptible
import okhttp3.Cache
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.ui.util.ReversibleAction
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.local.data.CacheDir
import org.koitharu.kotatsu.local.data.LocalStorageManager
import org.koitharu.kotatsu.search.domain.MangaSearchRepository
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import java.util.EnumMap
import javax.inject.Inject
@HiltViewModel
class UserDataSettingsViewModel @Inject constructor(
private val storageManager: LocalStorageManager,
private val httpCache: Cache,
private val searchRepository: MangaSearchRepository,
private val trackingRepository: TrackingRepository,
private val cookieJar: MutableCookieJar,
) : BaseViewModel() {
val onActionDone = MutableEventFlow<ReversibleAction>()
val loadingKeys = MutableStateFlow(emptySet<String>())
val searchHistoryCount = MutableStateFlow(-1)
val feedItemsCount = MutableStateFlow(-1)
val httpCacheSize = MutableStateFlow(-1L)
val cacheSizes = EnumMap<CacheDir, MutableStateFlow<Long>>(CacheDir::class.java)
init {
CacheDir.values().forEach {
cacheSizes[it] = MutableStateFlow(-1L)
}
launchJob(Dispatchers.Default) {
searchHistoryCount.value = searchRepository.getSearchHistoryCount()
}
launchJob(Dispatchers.Default) {
feedItemsCount.value = trackingRepository.getLogsCount()
}
CacheDir.values().forEach { cache ->
launchJob(Dispatchers.Default) {
checkNotNull(cacheSizes[cache]).value = storageManager.computeCacheSize(cache)
}
}
launchJob(Dispatchers.Default) {
httpCacheSize.value = runInterruptible { httpCache.size() }
}
}
fun clearCache(key: String, cache: CacheDir) {
launchJob(Dispatchers.Default) {
try {
loadingKeys.update { it + key }
storageManager.clearCache(cache)
checkNotNull(cacheSizes[cache]).value = storageManager.computeCacheSize(cache)
} finally {
loadingKeys.update { it - key }
}
}
}
fun clearHttpCache() {
launchJob(Dispatchers.Default) {
try {
loadingKeys.update { it + AppSettings.KEY_HTTP_CACHE_CLEAR }
val size = runInterruptible(Dispatchers.IO) {
httpCache.evictAll()
httpCache.size()
}
httpCacheSize.value = size
} finally {
loadingKeys.update { it - AppSettings.KEY_HTTP_CACHE_CLEAR }
}
}
}
fun clearSearchHistory() {
launchJob(Dispatchers.Default) {
searchRepository.clearSearchHistory()
searchHistoryCount.value = searchRepository.getSearchHistoryCount()
onActionDone.call(ReversibleAction(R.string.search_history_cleared, null))
}
}
fun clearCookies() {
launchJob {
cookieJar.clear()
onActionDone.call(ReversibleAction(R.string.cookies_cleared, null))
}
}
fun clearUpdatesFeed() {
launchJob(Dispatchers.Default) {
trackingRepository.clearLogs()
feedItemsCount.value = trackingRepository.getLogsCount()
onActionDone.call(ReversibleAction(R.string.updates_feed_cleared, null))
}
}
}

View File

@@ -4,6 +4,9 @@ import androidx.annotation.WorkerThread
import androidx.core.os.LocaleListCompat
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.MutableStateFlow
import org.koitharu.kotatsu.core.model.getLocaleTitle
import org.koitharu.kotatsu.core.prefs.AppSettings
@@ -19,18 +22,26 @@ class NewSourcesViewModel @Inject constructor(
private val initialList = settings.newSources
val sources = MutableStateFlow<List<SourceConfigItem>?>(null)
private var listUpdateJob: Job? = null
init {
launchJob(Dispatchers.Default) {
listUpdateJob = launchJob(Dispatchers.Default) {
sources.value = buildList()
}
}
fun onItemEnabledChanged(item: SourceConfigItem.SourceItem, isEnabled: Boolean) {
if (isEnabled) {
settings.hiddenSources -= item.source.name
} else {
settings.hiddenSources += item.source.name
val prevJob = listUpdateJob
listUpdateJob = launchJob(Dispatchers.Default) {
if (isEnabled) {
settings.hiddenSources -= item.source.name
} else {
settings.hiddenSources += item.source.name
}
prevJob?.cancelAndJoin()
val list = buildList()
ensureActive()
sources.value = list
}
}
@@ -61,3 +72,4 @@ class NewSourcesViewModel @Inject constructor(
}
}
}

View File

@@ -4,16 +4,15 @@ import android.os.Bundle
import android.view.View
import androidx.fragment.app.viewModels
import androidx.preference.Preference
import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.BasePreferenceFragment
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.viewLifecycleScope
import org.koitharu.kotatsu.core.util.ext.withArgs
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.settings.sources.auth.SourceAuthActivity
@@ -49,10 +48,18 @@ class SourceSettingsFragment : BasePreferenceFragment(0) {
getString(R.string.logged_in_as, it)
}
}
viewModel.onError.observeEvent(viewLifecycleOwner, ::onError)
viewModel.onError.observeEvent(
viewLifecycleOwner,
SnackbarErrorObserver(
listView,
this,
exceptionResolver,
) { viewModel.onResume() },
)
viewModel.isLoading.observe(viewLifecycleOwner) { isLoading ->
findPreference<Preference>(KEY_AUTH)?.isEnabled = !isLoading
}
viewModel.onActionDone.observeEvent(viewLifecycleOwner, ReversibleActionObserver(listView))
}
override fun onPreferenceTreeClick(preference: Preference): Boolean {
@@ -61,32 +68,15 @@ class SourceSettingsFragment : BasePreferenceFragment(0) {
startActivity(SourceAuthActivity.newIntent(preference.context, viewModel.source))
true
}
AppSettings.KEY_COOKIES_CLEAR -> {
viewModel.clearCookies()
true
}
else -> super.onPreferenceTreeClick(preference)
}
}
private fun onError(error: Throwable) {
val snackbar = Snackbar.make(
listView ?: return,
error.getDisplayMessage(resources),
Snackbar.LENGTH_INDEFINITE,
)
if (ExceptionResolver.canResolve(error)) {
snackbar.setAction(ExceptionResolver.getResolveStringId(error)) { resolveError(error) }
}
snackbar.show()
}
private fun resolveError(error: Throwable) {
view ?: return
viewLifecycleScope.launch {
if (exceptionResolver.resolve(error)) {
viewModel.onResume()
}
}
}
companion object {
private const val KEY_AUTH = "auth"

View File

@@ -5,9 +5,15 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import okhttp3.HttpUrl
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.ui.util.ReversibleAction
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.core.util.ext.require
import org.koitharu.kotatsu.parsers.exception.AuthRequiredException
import org.koitharu.kotatsu.parsers.model.MangaSource
@@ -17,11 +23,13 @@ import javax.inject.Inject
class SourceSettingsViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
mangaRepositoryFactory: MangaRepository.Factory,
private val cookieJar: MutableCookieJar,
) : BaseViewModel() {
val source = savedStateHandle.require<MangaSource>(SourceSettingsFragment.EXTRA_SOURCE)
val repository = mangaRepositoryFactory.create(source) as RemoteMangaRepository
val onActionDone = MutableEventFlow<ReversibleAction>()
val username = MutableStateFlow<String?>(null)
private var usernameLoadJob: Job? = null
@@ -35,6 +43,18 @@ class SourceSettingsViewModel @Inject constructor(
}
}
fun clearCookies() {
launchLoadingJob(Dispatchers.Default) {
val url = HttpUrl.Builder()
.scheme("https")
.host(repository.domain)
.build()
cookieJar.removeCookies(url)
onActionDone.call(ReversibleAction(R.string.cookies_cleared, null))
loadUsername()
}
}
private fun loadUsername() {
launchLoadingJob(Dispatchers.Default) {
try {

View File

@@ -6,6 +6,7 @@ import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.MenuItem
import android.webkit.CookieManager
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContract
import androidx.core.graphics.Insets
@@ -27,6 +28,7 @@ import org.koitharu.kotatsu.core.util.ext.getSerializableExtraCompat
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
import org.koitharu.kotatsu.parsers.MangaParserAuthProvider
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.network.UserAgents
import javax.inject.Inject
import com.google.android.material.R as materialR
@@ -66,8 +68,11 @@ class SourceAuthActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallba
}
with(viewBinding.webView.settings) {
javaScriptEnabled = true
userAgentString = CommonHeadersInterceptor.userAgentChrome
domStorageEnabled = true
databaseEnabled = true
userAgentString = UserAgents.CHROME_MOBILE
}
CookieManager.getInstance().setAcceptThirdPartyCookies(viewBinding.webView, true)
viewBinding.webView.webViewClient = BrowserClient(this)
viewBinding.webView.webChromeClient = ProgressChromeClient(viewBinding.progressBar)
onBackPressedCallback = WebViewBackPressedCallback(viewBinding.webView)

View File

@@ -0,0 +1,21 @@
package org.koitharu.kotatsu.settings.storage
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.util.ext.textAndVisible
import org.koitharu.kotatsu.databinding.ItemStorageBinding
fun directoryAD(
clickListener: OnListItemClickListener<DirectoryModel>,
) = adapterDelegateViewBinding<DirectoryModel, DirectoryModel, ItemStorageBinding>(
{ layoutInflater, parent -> ItemStorageBinding.inflate(layoutInflater, parent, false) },
) {
binding.root.setOnClickListener { v -> clickListener.onItemClick(item, v) }
bind {
binding.textViewTitle.text = item.title ?: getString(item.titleRes)
binding.textViewSubtitle.textAndVisible = item.file?.absolutePath
binding.imageViewIndicator.isChecked = item.isChecked
}
}

View File

@@ -0,0 +1,22 @@
package org.koitharu.kotatsu.settings.storage
import androidx.recyclerview.widget.DiffUtil.ItemCallback
class DirectoryDiffCallback : ItemCallback<DirectoryModel>() {
override fun areItemsTheSame(oldItem: DirectoryModel, newItem: DirectoryModel): Boolean {
return oldItem.file == newItem.file
}
override fun areContentsTheSame(oldItem: DirectoryModel, newItem: DirectoryModel): Boolean {
return oldItem == newItem
}
override fun getChangePayload(oldItem: DirectoryModel, newItem: DirectoryModel): Any? {
return if (oldItem.isChecked != newItem.isChecked) {
Unit
} else {
super.getChangePayload(oldItem, newItem)
}
}
}

View File

@@ -0,0 +1,36 @@
package org.koitharu.kotatsu.settings.storage
import androidx.annotation.StringRes
import org.koitharu.kotatsu.list.ui.model.ListModel
import java.io.File
class DirectoryModel(
val title: String?,
@StringRes val titleRes: Int,
val file: File?,
val isChecked: Boolean,
val isAvailable: Boolean,
) : ListModel {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as DirectoryModel
if (title != other.title) return false
if (titleRes != other.titleRes) return false
if (file != other.file) return false
if (isChecked != other.isChecked) return false
return isAvailable == other.isAvailable
}
override fun hashCode(): Int {
var result = title?.hashCode() ?: 0
result = 31 * result + titleRes
result = 31 * result + (file?.hashCode() ?: 0)
result = 31 * result + isChecked.hashCode()
result = 31 * result + isAvailable.hashCode()
return result
}
}

View File

@@ -0,0 +1,81 @@
package org.koitharu.kotatsu.settings.storage
import android.Manifest
import android.os.Build
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.activity.result.contract.ActivityResultContracts
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.viewModels
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.resolve.ToastErrorObserver
import org.koitharu.kotatsu.core.ui.AlertDialogFragment
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.showDistinct
import org.koitharu.kotatsu.databinding.DialogDirectorySelectBinding
@AndroidEntryPoint
class MangaDirectorySelectDialog : AlertDialogFragment<DialogDirectorySelectBinding>(),
OnListItemClickListener<DirectoryModel> {
private val viewModel: MangaDirectorySelectViewModel by viewModels()
private val pickFileTreeLauncher = registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) {
if (it != null) viewModel.onCustomDirectoryPicked(it)
}
private val permissionRequestLauncher = registerForActivityResult(
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
RequestStorageManagerPermissionContract()
} else {
ActivityResultContracts.RequestPermission()
},
) {
if (it) {
viewModel.refresh()
pickFileTreeLauncher.launch(null)
}
}
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): DialogDirectorySelectBinding {
return DialogDirectorySelectBinding.inflate(inflater, container, false)
}
override fun onViewBindingCreated(binding: DialogDirectorySelectBinding, savedInstanceState: Bundle?) {
super.onViewBindingCreated(binding, savedInstanceState)
val adapter = AsyncListDifferDelegationAdapter(DirectoryDiffCallback(), directoryAD(this))
binding.root.adapter = adapter
viewModel.items.observe(viewLifecycleOwner) { adapter.items = it }
viewModel.onDismissDialog.observeEvent(viewLifecycleOwner) { dismiss() }
viewModel.onPickDirectory.observeEvent(viewLifecycleOwner) { pickCustomDirectory() }
viewModel.onError.observeEvent(viewLifecycleOwner, ToastErrorObserver(binding.root, this))
}
override fun onBuildDialog(builder: MaterialAlertDialogBuilder): MaterialAlertDialogBuilder {
return super.onBuildDialog(builder)
.setCancelable(true)
.setTitle(R.string.manga_save_location)
.setNegativeButton(android.R.string.cancel, null)
}
override fun onItemClick(item: DirectoryModel, view: View) {
viewModel.onItemClick(item)
}
private fun pickCustomDirectory() {
permissionRequestLauncher.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE)
}
companion object {
private const val TAG = "MangaDirectorySelectDialog"
fun show(fm: FragmentManager) = MangaDirectorySelectDialog()
.showDistinct(fm, TAG)
}
}

View File

@@ -0,0 +1,80 @@
package org.koitharu.kotatsu.settings.storage
import android.net.Uri
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import okio.FileNotFoundException
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.local.data.LocalStorageManager
import javax.inject.Inject
@HiltViewModel
class MangaDirectorySelectViewModel @Inject constructor(
private val storageManager: LocalStorageManager,
private val settings: AppSettings,
) : BaseViewModel() {
val items = MutableStateFlow(emptyList<DirectoryModel>())
val onDismissDialog = MutableEventFlow<Unit>()
val onPickDirectory = MutableEventFlow<Unit>()
init {
refresh()
}
fun onItemClick(item: DirectoryModel) {
if (item.file != null) {
settings.mangaStorageDir = item.file
onDismissDialog.call(Unit)
} else {
onPickDirectory.call(Unit)
}
}
fun onCustomDirectoryPicked(uri: Uri) {
launchJob(Dispatchers.Default) {
storageManager.takePermissions(uri)
val dir = requireNotNull(storageManager.resolveUri(uri)) {
"Cannot resolve file name of \"$uri\""
}
if (!dir.canWrite()) {
throw AccessDeniedException(dir)
}
if (dir !in storageManager.getApplicationStorageDirs()) {
settings.mangaStorageDir = dir
storageManager.setDirIsNoMedia(dir)
}
onDismissDialog.call(Unit)
}
}
fun refresh() {
launchJob(Dispatchers.Default) {
val defaultValue = storageManager.getDefaultWriteableDir()
val available = storageManager.getWriteableDirs()
items.value = buildList(available.size + 1) {
available.mapTo(this) { dir ->
DirectoryModel(
title = storageManager.getDirectoryDisplayName(dir, isFullPath = false),
titleRes = 0,
file = dir,
isChecked = dir == defaultValue,
isAvailable = true,
)
}
this += DirectoryModel(
title = null,
titleRes = R.string.pick_custom_directory,
file = null,
isChecked = false,
isAvailable = true,
)
}
}
}
}

View File

@@ -0,0 +1,34 @@
package org.koitharu.kotatsu.settings.storage
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.Environment
import android.provider.Settings
import androidx.activity.result.contract.ActivityResultContract
import androidx.annotation.RequiresApi
import androidx.core.net.toUri
@RequiresApi(Build.VERSION_CODES.R)
class RequestStorageManagerPermissionContract : ActivityResultContract<String, Boolean>() {
override fun createIntent(context: Context, input: String): Intent {
val intent = Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION)
intent.addCategory("android.intent.category.DEFAULT")
intent.data = "package:${context.packageName}".toUri()
return intent
}
override fun parseResult(resultCode: Int, intent: Intent?): Boolean {
return Environment.isExternalStorageManager()
}
override fun getSynchronousResult(context: Context, input: String): SynchronousResult<Boolean>? {
return if (Environment.isExternalStorageManager()) {
SynchronousResult(true)
} else {
null
}
}
}

View File

@@ -0,0 +1,31 @@
package org.koitharu.kotatsu.settings.storage.directories
import androidx.core.content.ContextCompat
import androidx.core.view.isVisible
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.util.ext.drawableStart
import org.koitharu.kotatsu.core.util.ext.textAndVisible
import org.koitharu.kotatsu.databinding.ItemStorageConfigBinding
import org.koitharu.kotatsu.settings.storage.DirectoryModel
fun directoryConfigAD(
clickListener: OnListItemClickListener<DirectoryModel>,
) = adapterDelegateViewBinding<DirectoryModel, DirectoryModel, ItemStorageConfigBinding>(
{ layoutInflater, parent -> ItemStorageConfigBinding.inflate(layoutInflater, parent, false) },
) {
binding.imageViewRemove.setOnClickListener { v -> clickListener.onItemClick(item, v) }
bind {
binding.textViewTitle.text = item.title ?: getString(item.titleRes)
binding.textViewSubtitle.textAndVisible = item.file?.absolutePath
binding.imageViewRemove.isVisible = item.isChecked
binding.textViewTitle.drawableStart = if (item.isAvailable) {
null
} else {
ContextCompat.getDrawable(context, R.drawable.ic_alert_outline)
}
}
}

View File

@@ -0,0 +1,93 @@
package org.koitharu.kotatsu.settings.storage.directories
import android.Manifest
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.view.View
import android.view.ViewGroup
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.core.graphics.Insets
import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams
import androidx.core.view.updatePadding
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.databinding.ActivityMangaDirectoriesBinding
import org.koitharu.kotatsu.settings.storage.DirectoryDiffCallback
import org.koitharu.kotatsu.settings.storage.DirectoryModel
import org.koitharu.kotatsu.settings.storage.RequestStorageManagerPermissionContract
@AndroidEntryPoint
class MangaDirectoriesActivity : BaseActivity<ActivityMangaDirectoriesBinding>(),
OnListItemClickListener<DirectoryModel>, View.OnClickListener {
private val viewModel: MangaDirectoriesViewModel by viewModels()
private val pickFileTreeLauncher = registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) {
if (it != null) viewModel.onCustomDirectoryPicked(it)
}
private val permissionRequestLauncher = registerForActivityResult(
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
RequestStorageManagerPermissionContract()
} else {
ActivityResultContracts.RequestPermission()
},
) {
if (it) {
viewModel.updateList()
pickFileTreeLauncher.launch(null)
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(ActivityMangaDirectoriesBinding.inflate(layoutInflater))
supportActionBar?.setDisplayHomeAsUpEnabled(true)
val adapter = AsyncListDifferDelegationAdapter(DirectoryDiffCallback(), directoryConfigAD(this))
viewBinding.recyclerView.adapter = adapter
viewBinding.fabAdd.setOnClickListener(this)
viewModel.items.observe(this) { adapter.items = it }
viewModel.isLoading.observe(this) { viewBinding.progressBar.isVisible = it }
viewModel.onError.observeEvent(
this,
SnackbarErrorObserver(viewBinding.root, null, exceptionResolver) {
if (it) viewModel.updateList()
},
)
}
override fun onItemClick(item: DirectoryModel, view: View) {
viewModel.onRemoveClick(item.file ?: return)
}
override fun onClick(v: View?) {
permissionRequestLauncher.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE)
}
override fun onWindowInsetsChanged(insets: Insets) {
viewBinding.fabAdd.updateLayoutParams<ViewGroup.MarginLayoutParams> {
rightMargin = topMargin + insets.right
leftMargin = topMargin + insets.left
bottomMargin = topMargin + insets.bottom
}
viewBinding.root.updatePadding(
left = insets.left,
right = insets.right,
)
viewBinding.recyclerView.updatePadding(
bottom = insets.bottom,
)
}
companion object {
fun newIntent(context: Context) = Intent(context, MangaDirectoriesActivity::class.java)
}
}

View File

@@ -0,0 +1,87 @@
package org.koitharu.kotatsu.settings.storage.directories
import android.net.Uri
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.flow.MutableStateFlow
import okio.FileNotFoundException
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.local.data.LocalStorageManager
import org.koitharu.kotatsu.settings.storage.DirectoryModel
import java.io.File
import javax.inject.Inject
@HiltViewModel
class MangaDirectoriesViewModel @Inject constructor(
private val storageManager: LocalStorageManager,
private val settings: AppSettings,
) : BaseViewModel() {
val items = MutableStateFlow(emptyList<DirectoryModel>())
private var loadingJob: Job? = null
init {
loadList()
}
fun updateList() {
loadList()
}
fun onCustomDirectoryPicked(uri: Uri) {
launchLoadingJob(Dispatchers.Default) {
loadingJob?.cancelAndJoin()
storageManager.takePermissions(uri)
val dir = requireNotNull(storageManager.resolveUri(uri)) {
"Cannot resolve file name of \"$uri\""
}
if (!dir.canWrite()) {
throw AccessDeniedException(dir)
}
if (dir !in storageManager.getApplicationStorageDirs()) {
settings.userSpecifiedMangaDirectories += dir
loadList()
}
}
}
fun onRemoveClick(directory: File) {
settings.userSpecifiedMangaDirectories -= directory
if (settings.mangaStorageDir == directory) {
settings.mangaStorageDir = null
}
loadList()
}
private fun loadList() {
val prevJob = loadingJob
loadingJob = launchJob(Dispatchers.Default) {
prevJob?.cancelAndJoin()
val applicationDirs = storageManager.getApplicationStorageDirs()
val customDirs = settings.userSpecifiedMangaDirectories
items.value = buildList(applicationDirs.size + customDirs.size) {
applicationDirs.mapTo(this) { dir ->
DirectoryModel(
title = storageManager.getDirectoryDisplayName(dir, isFullPath = false),
titleRes = 0,
file = dir,
isChecked = false,
isAvailable = dir.canRead() && dir.canWrite(),
)
}
customDirs.mapTo(this) { dir ->
DirectoryModel(
title = storageManager.getDirectoryDisplayName(dir, isFullPath = false),
titleRes = 0,
file = dir,
isChecked = true,
isAvailable = dir.canRead() && dir.canWrite(),
)
}
}
}
}
}

View File

@@ -24,6 +24,7 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.BasePreferenceFragment
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.powerManager
import org.koitharu.kotatsu.settings.tracker.categories.TrackerCategoriesConfigSheet
import org.koitharu.kotatsu.settings.utils.MultiSummaryProvider
import org.koitharu.kotatsu.tracker.work.TrackerNotificationChannels
@@ -155,7 +156,7 @@ class TrackerSettingsFragment :
return
}
val packageName = context.packageName
val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager
val powerManager = context.powerManager ?: return
if (!powerManager.isIgnoringBatteryOptimizations(packageName)) {
try {
val intent = Intent(

View File

@@ -0,0 +1,12 @@
package org.koitharu.kotatsu.settings.work
import android.content.Context
interface PeriodicWorkScheduler {
suspend fun schedule(context: Context)
suspend fun unschedule(context: Context)
suspend fun isScheduled(context: Context): Boolean
}

View File

@@ -0,0 +1,49 @@
package org.koitharu.kotatsu.settings.work
import android.content.Context
import android.content.SharedPreferences
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
import org.koitharu.kotatsu.suggestions.ui.SuggestionsWorker
import org.koitharu.kotatsu.tracker.work.TrackWorker
import javax.inject.Inject
class WorkScheduleManager @Inject constructor(
@ApplicationContext private val context: Context,
private val settings: AppSettings,
) : SharedPreferences.OnSharedPreferenceChangeListener {
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) {
when (key) {
AppSettings.KEY_TRACKER_ENABLED -> updateWorker(TrackWorker, settings.isTrackerEnabled)
AppSettings.KEY_SUGGESTIONS -> updateWorker(SuggestionsWorker, settings.isSuggestionsEnabled)
}
}
fun init() {
settings.subscribe(this)
processLifecycleScope.launch(Dispatchers.Default) {
updateWorkerImpl(TrackWorker, settings.isTrackerEnabled)
updateWorkerImpl(SuggestionsWorker, settings.isSuggestionsEnabled)
}
}
private fun updateWorker(scheduler: PeriodicWorkScheduler, isEnabled: Boolean) {
processLifecycleScope.launch(Dispatchers.Default) {
updateWorkerImpl(scheduler, isEnabled)
}
}
private suspend fun updateWorkerImpl(scheduler: PeriodicWorkScheduler, isEnabled: Boolean) {
if (scheduler.isScheduled(context) != isEnabled) {
if (isEnabled) {
scheduler.schedule(context)
} else {
scheduler.unschedule(context)
}
}
}
}

View File

@@ -4,6 +4,7 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
@@ -11,6 +12,7 @@ import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.onStart
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity
import org.koitharu.kotatsu.favourites.data.toFavouriteCategory
import org.koitharu.kotatsu.favourites.data.toMangaList
@@ -32,6 +34,7 @@ class ShelfContentObserveUseCase @Inject constructor(
private val trackingRepository: TrackingRepository,
private val suggestionRepository: SuggestionRepository,
private val db: MangaDatabase,
private val settings: AppSettings,
@LocalStorageChanges private val localStorageChanges: SharedFlow<LocalManga?>,
) {
@@ -46,7 +49,10 @@ class ShelfContentObserveUseCase @Inject constructor(
}
private fun observeLocalManga(sortOrder: SortOrder, limit: Int): Flow<List<Manga>> {
return localStorageChanges
return combine<LocalManga?, String, Any?>(
localStorageChanges,
settings.observe().filter { it == AppSettings.KEY_LOCAL_MANGA_DIRS }.onStart { emit("") }
) { a, b -> a to b }
.onStart { emit(null) }
.mapLatest {
localMangaRepository.getList(0, null, sortOrder).take(limit)

View File

@@ -23,6 +23,7 @@ import androidx.work.OutOfQuotaPolicy
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import androidx.work.await
import androidx.work.workDataOf
import coil.ImageLoader
import coil.request.ImageRequest
@@ -38,6 +39,7 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.distinctById
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.WorkManagerHelper
import org.koitharu.kotatsu.core.util.ext.almostEquals
import org.koitharu.kotatsu.core.util.ext.asArrayList
import org.koitharu.kotatsu.core.util.ext.flatten
@@ -55,6 +57,7 @@ import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.reader.ui.ReaderActivity.IntentBuilder
import org.koitharu.kotatsu.settings.work.PeriodicWorkScheduler
import org.koitharu.kotatsu.suggestions.domain.MangaSuggestion
import org.koitharu.kotatsu.suggestions.domain.SuggestionRepository
import org.koitharu.kotatsu.suggestions.domain.TagsBlacklist
@@ -75,11 +78,11 @@ class SuggestionsWorker @AssistedInject constructor(
) : CoroutineWorker(appContext, params) {
override suspend fun doWork(): Result {
trySetForeground()
if (!appSettings.isSuggestionsEnabled) {
suggestionRepository.clear()
return Result.success()
}
trySetForeground()
val count = doWorkImpl()
val outputData = workDataOf(DATA_COUNT to count)
return Result.success(outputData)
@@ -106,7 +109,7 @@ class SuggestionsWorker @AssistedInject constructor(
.setPriority(NotificationCompat.PRIORITY_MIN)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
.setDefaults(0)
.setOngoing(true)
.setOngoing(false)
.setSilent(true)
.setProgress(0, 0, true)
.setSmallIcon(android.R.drawable.stat_notify_sync)
@@ -303,7 +306,7 @@ class SuggestionsWorker @AssistedInject constructor(
return -1
}
companion object {
companion object : PeriodicWorkScheduler {
private const val TAG = "suggestions"
private const val TAG_ONESHOT = "suggestions_oneshot"
@@ -324,7 +327,7 @@ class SuggestionsWorker @AssistedInject constructor(
SortOrder.RATING,
)
fun setup(context: Context) {
override suspend fun schedule(context: Context) {
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.UNMETERED)
.setRequiresBatteryNotLow(true)
@@ -336,6 +339,19 @@ class SuggestionsWorker @AssistedInject constructor(
.build()
WorkManager.getInstance(context)
.enqueueUniquePeriodicWork(TAG, ExistingPeriodicWorkPolicy.KEEP, request)
.await()
}
override suspend fun unschedule(context: Context) {
WorkManager.getInstance(context)
.cancelUniqueWork(TAG)
.await()
}
override suspend fun isScheduled(context: Context): Boolean {
return WorkManagerHelper(WorkManager.getInstance(context))
.getUniqueWorkInfoByName(TAG)
.any { !it.state.isFinished }
}
fun startNow(context: Context) {

View File

@@ -43,10 +43,12 @@ class SyncController @Inject constructor(
private val defaultGcPeriod = TimeUnit.DAYS.toMillis(2) // gc period if sync disabled
override fun onInvalidated(tables: Set<String>) {
requestSync(
favourites = TABLE_FAVOURITES in tables || TABLE_FAVOURITE_CATEGORIES in tables,
history = TABLE_HISTORY in tables,
)
val favourites = (TABLE_FAVOURITES in tables || TABLE_FAVOURITE_CATEGORIES in tables)
&& !isSyncActiveOrPending(authorityFavourites)
val history = TABLE_HISTORY in tables && !isSyncActiveOrPending(authorityHistory)
if (favourites || history) {
requestSync(favourites, history)
}
}
fun isEnabled(account: Account): Boolean {
@@ -126,6 +128,11 @@ class SyncController @Inject constructor(
}
}
private fun isSyncActiveOrPending(authority: String): Boolean {
val account = peekAccount() ?: return false
return ContentResolver.isSyncActive(account, authority) || ContentResolver.isSyncPending(account, authority)
}
companion object {
@JvmStatic

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