Compare commits

...

121 Commits

Author SHA1 Message Date
b37431b07c Revert "Use ConnectivityManagerCompat.getRestrictBackgroundStatus()"
This reverts commit bfad632b8c.
2023-08-02 14:53:34 +03:00
2949fdd2c6 Recover history if chapterId id changed 2023-08-02 11:03:38 +03:00
829ea01b18 Migrate some classes to data classes 2023-08-02 10:26:18 +03:00
08b173b94a Update AppShortcutManager 2023-08-02 10:08:46 +03:00
Isira Seneviratne
7b090c4ccd Add default constructor values 2023-08-02 10:06:27 +03:00
Isira Seneviratne
bf1b8e8b75 Convert ListHeader to a data class 2023-08-02 10:06:27 +03:00
Isira Seneviratne
bfad632b8c Use ConnectivityManagerCompat.getRestrictBackgroundStatus() 2023-08-02 10:06:08 +03:00
Isira Seneviratne
73e6f730e1 Convert lazy value to Size 2023-07-31 15:35:39 +03:00
Isira Seneviratne
62d8b848b2 Add uses of ShortcutManagerCompat methods 2023-07-31 15:35:39 +03:00
2793f6ce52 Update sources manage screen 2023-07-31 12:09:24 +03:00
b107801188 Update bookmarks list screen 2023-07-31 11:28:46 +03:00
97de27dfb3 Recover manga after NotFoundException 2023-07-31 10:08:59 +03:00
cd4317dec5 Show captcha notfication for images 2023-07-30 17:44:24 +03:00
50554c6936 Fix tags suggestions 2023-07-28 16:42:01 +03:00
694297f49b Update bottom navigation 2023-07-28 16:15:34 +03:00
3e48ce85fd Show notification if captcha required in background 2023-07-28 16:09:46 +03:00
0f7bceb268 Fix new sources tip 2023-07-28 14:01:21 +03:00
00187c0d17 Fix description scrolling 2023-07-28 12:22:17 +03:00
2378d104c3 Option to open random manga from source 2023-07-28 12:15:03 +03:00
f105f4b496 Mark nsfw sources 2023-07-28 12:00:36 +03:00
Isira Seneviratne
01e27ba91f Add uses of NotificationManagerCompat and related classes 2023-07-28 10:51:13 +03:00
2342594885 Unify list spacing approach 2023-07-26 16:40:10 +03:00
61a7f1c830 Improve ui 2023-07-26 15:27:43 +03:00
01c23bc3b8 Manga preview in list on tablet 2023-07-26 12:11:42 +03:00
7c7106a63c History sorting #428 2023-07-25 15:36:50 +03:00
ac1a919476 Fix instrumented tests 2023-07-25 12:44:07 +03:00
234f74aa0d Fix database migrations 2023-07-25 12:28:50 +03:00
1711ebe616 Merge branch 'devel' into next 2023-07-25 10:11:44 +03:00
07af79a6bd Update parsers 2023-07-25 09:42:55 +03:00
e4942b0d93 Fix new manga sources enabling 2023-07-24 20:21:57 +03:00
5ca22f1419 Fix crash with empty EnumSet 2023-07-24 16:57:24 +03:00
345a878d83 Merge branch 'devel' into next 2023-07-24 16:38:51 +03:00
42bb5a65ab Fix crash in ScrobblingInfoSheet 2023-07-24 16:08:25 +03:00
0c37265a5b Update parsers 2023-07-24 16:03:08 +03:00
plum7x
7a65ae3ea7 Translated using Weblate (Chinese (Traditional))
Currently translated at 100.0% (447 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-24 15:56:11 +03:00
376cee1859 Store manga sources in database #426 2023-07-24 15:47:52 +03:00
InfinityDouki56
ee027cd64f Translated using Weblate (Filipino)
Currently translated at 91.0% (407 of 447 strings)

Co-authored-by: InfinityDouki56 <ced.paltep10@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fil/
Translation: Kotatsu/Strings
2023-07-22 13:59:48 +03:00
Макар Разин
7b2bb5ea8f Translated using Weblate (Polish)
Currently translated at 100.0% (447 of 447 strings)

Translated using Weblate (Korean)

Currently translated at 79.6% (356 of 447 strings)

Co-authored-by: Макар Разин <makarrazin14@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ko/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pl/
Translation: Kotatsu/Strings
2023-07-22 13:59:48 +03:00
plum7x
eff2d6bcb6 Translated using Weblate (Chinese (Traditional))
Currently translated at 100.0% (447 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-22 13:59:48 +03:00
03b92c4898 Improve wrokers parallelism 2023-07-21 15:46:28 +03:00
6dcb537a9a Update search ui 2023-07-21 15:26:26 +03:00
052cfe26b1 Show chapters count in suggestion notification 2023-07-21 10:32:10 +03:00
45b2f2337a Favourites manage activity 2023-07-21 10:23:02 +03:00
5785a2d5d1 Remove shelf 2023-07-20 16:11:03 +03:00
bc273bfb8f Storage usage preference 2023-07-20 15:50:32 +03:00
513aa1a285 Code cleanup 2023-07-20 14:09:07 +03:00
82a3b93214 Expandable manga description 2023-07-20 13:33:48 +03:00
80149b1ce7 Related manga activity 2023-07-20 10:56:15 +03:00
297029a659 Options to run background workers only using wifi 2023-07-20 09:29:26 +03:00
08acf2d882 Fix crashes 2023-07-19 15:18:30 +03:00
dafca9e1e1 Update suggestions 2023-07-19 15:10:11 +03:00
e174bc68af Merge branch 'devel' into next 2023-07-19 13:48:28 +03:00
1d78c64350 Move coroutines from UserDataSettingsFragment to ViewModel 2023-07-19 13:32:02 +03:00
321a9ecf62 Update parsers 2023-07-19 12:30:58 +03:00
83cf6aa997 Temporarily replace ViewPager2 within ViewPager in favourites 2023-07-18 16:22:02 +03:00
e7bd74429e Cache related manga lists 2023-07-18 15:43:28 +03:00
9ba87640c0 Bookmarks bottom sheet 2023-07-18 15:34:56 +03:00
fff77cf208 Backup and restore bookmarks 2023-07-18 14:27:14 +03:00
967e8df7c9 Fix backup restoring 2023-07-18 12:28:02 +03:00
f86d873361 Merge branch 'feature/backup-settings' of github.com:javlonrahimov/Kotatsu into javlonrahimov-feature/backup-settings 2023-07-18 12:05:23 +03:00
2d5332d8df Merge branch 'devel' into next 2023-07-18 12:04:41 +03:00
439a01c43f Fix bookmark has direct url detection #424 2023-07-18 11:43:31 +03:00
3a9d0def7d Update parsers 2023-07-18 10:13:46 +03:00
javlon
33a45ac5b3 remove clutter code 2023-07-17 14:49:28 +02:00
e4c80b4443 Remove rubbish file 2023-07-17 14:14:23 +03:00
940d448e00 Fix local manga update on shelf 2023-07-17 14:13:16 +03:00
5ab48a7545 Fix scrobbling rating 2023-07-17 13:30:50 +03:00
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
javlon
a8176e6589 add settings backup 2023-07-15 21:22:16 +02:00
a2437dd27a Adjust tip view 2023-07-15 15:56:07 +03:00
9e56766e9e New sources tip 2023-07-15 14:59:54 +03:00
eec750789d Reader background option 2023-07-14 14:33:55 +03:00
44a2b6db11 Update explore fragment 2023-07-13 16:08:09 +03:00
55ca2b8d8d Description bottom sheet 2023-07-13 15:08:39 +03:00
2d670418c7 Application update indicator 2023-07-13 15:08:19 +03:00
4c201bf950 Merge branch 'devel' into next 2023-07-13 13:20:53 +03:00
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
9610caf002 Downloads scheduler fixes 2023-07-12 10:58:02 +03:00
b75220a1b7 Fix cover loading in details 2023-07-11 11:45:57 +03:00
ab2a6f5a17 Fix loading state 2023-07-11 11:34:47 +03:00
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
Isira Seneviratne
46ab5af905 Apply suggestions. 2023-07-11 09:53:42 +03:00
Isira Seneviratne
9a815f28fa Add Parceler implementations for Manga classes. 2023-07-11 09:53:42 +03:00
394479192b Refactor adapters 2023-07-06 06:53:23 +03:00
7908eb1441 Favourites container fixes 2023-07-04 13:44:01 +03:00
ed672feebe Show related manga 2023-07-04 09:09:56 +03:00
4739da2774 Reorganize navigation 2023-07-03 17:20:17 +03:00
fb674b6028 More M3 theming 2023-07-03 16:11:05 +03:00
df5f5ea737 Return rounded square for simple manga item 2023-07-03 14:57:57 +03:00
e6facd4e41 Material fade through preview 2023-07-03 14:39:37 +03:00
942d4fe5ab Refactor ListModel 2023-07-03 13:57:51 +03:00
80db817ff2 Single suggestion more button intent 2023-07-02 15:59:42 +03:00
b2817a2ce7 Recommendation item on explore screen (not finished) 2023-07-02 02:15:07 +03:00
e2835e3e95 Use FastOutSlowInInterpolator for segmented bar 2023-07-01 23:45:03 +03:00
91928d058b Pie chart test 2023-07-01 23:43:38 +03:00
425e8a49c4 Adjust two-line items to M3 guidelines 2023-07-01 22:54:45 +03:00
148dbfdf02 Set 16dp corners for tips 2023-07-01 22:54:00 +03:00
55fdd6b7b1 Update default favicon drawable 2023-07-01 17:44:36 +03:00
a726b4f499 Merge remote-tracking branch 'origin/next' into next 2023-07-01 17:07:26 +03:00
39cd199044 Tip view 2023-07-01 16:03:48 +03:00
98b8aa3f2d Update details fragment layout 2023-07-01 14:46:42 +03:00
f5b8d41a86 Merge branch 'devel' into next 2023-07-01 13:18:29 +03:00
90dfc84119 Update dependencies 2023-07-01 12:54:44 +03:00
f01fd18711 Main activity theming 2023-06-30 21:27:20 +03:00
0098bdd07e Initial starting of writing next major version 2023-06-30 17:01:29 +03:00
6a792f8ac3 Use CoroutineStart.ATOMIC in some cases 2023-06-30 14:04:22 +03:00
c81e8749b6 Update parsers and headers processing 2023-06-28 13:27:26 +03:00
5fa260a0c7 Update issue template 2023-06-28 03:14:30 +03:00
413 changed files with 9342 additions and 7266 deletions

View File

@@ -13,6 +13,7 @@ disabled_rules = no-wildcard-imports, no-unused-imports
[{*.ant,*.fxml,*.jhm,*.jnlp,*.jrxml,*.rng,*.tld,*.wsdl,*.xml,*.xsd,*.xsl,*.xslt,*.xul}]
ij_continuation_indent_size = 4
ij_xml_attribute_wrap = on_every_item
[{*.kt,*.kts}]
ij_kotlin_allow_trailing_comma_on_call_site = true

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

2
.idea/gradle.xml generated
View File

@@ -5,7 +5,6 @@
<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 +13,7 @@
<option value="$PROJECT_DIR$/app" />
</set>
</option>
<option name="resolveExternalAnnotations" value="false" />
</GradleProjectSettings>
</option>
</component>

View File

@@ -14,11 +14,13 @@ android {
defaultConfig {
applicationId 'org.koitharu.kotatsu'
minSdkVersion 21
//TODO: update as soon as sources becomes available
//noinspection OldTargetApi
targetSdkVersion 33
versionCode 558
versionName '5.3.1'
versionCode 567
versionName '5.3.10'
generatedDensities = []
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
testInstrumentationRunner "org.koitharu.kotatsu.HiltTestRunner"
kapt {
arguments {
@@ -79,24 +81,24 @@ afterEvaluate {
}
dependencies {
//noinspection GradleDependency
implementation('com.github.KotatsuApp:kotatsu-parsers:b8f0ea4690') {
implementation('com.github.KotatsuApp:kotatsu-parsers:03b4fc9f00') {
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.3'
implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'androidx.core:core-ktx:1.10.1'
implementation 'androidx.activity:activity-ktx:1.7.2'
implementation 'androidx.fragment:fragment-ktx:1.6.0'
implementation 'androidx.fragment:fragment-ktx:1.6.1'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.1'
implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.6.1'
implementation 'androidx.lifecycle:lifecycle-service:2.6.1'
implementation 'androidx.lifecycle:lifecycle-process:2.6.1'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
implementation 'androidx.recyclerview:recyclerview:1.3.0'
implementation 'androidx.recyclerview:recyclerview:1.3.1'
implementation 'androidx.viewpager2:viewpager2:1.1.0-beta02'
implementation 'androidx.preference:preference-ktx:1.2.0'
implementation 'androidx.biometric:biometric-ktx:1.2.0-alpha05'
@@ -107,7 +109,7 @@ dependencies {
// 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'
@@ -120,41 +122,41 @@ dependencies {
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.10.1'
implementation 'ch.acra:acra-dialog:5.10.1'
implementation 'ch.acra:acra-http:5.11.0'
implementation 'ch.acra:acra-dialog:5.11.0'
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:20230618'
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.1'
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3'
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.3'
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

@@ -0,0 +1,13 @@
package org.koitharu.kotatsu
import android.app.Application
import android.content.Context
import androidx.test.runner.AndroidJUnitRunner
import dagger.hilt.android.testing.HiltTestApplication
class HiltTestRunner : AndroidJUnitRunner() {
override fun newApplication(cl: ClassLoader?, name: String?, context: Context?): Application {
return super.newApplication(cl, HiltTestApplication::class.java.name, context)
}
}

View File

@@ -17,7 +17,7 @@ class MangaDatabaseTest {
MangaDatabase::class.java,
)
private val migrations = databaseMigrations
private val migrations = getDatabaseMigrations(InstrumentationRegistry.getInstrumentation().targetContext)
@Test
fun versions() {

View File

@@ -48,6 +48,7 @@ class AppShortcutManagerTest {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N_MR1) {
return@runTest
}
database.invalidationTracker.addObserver(appShortcutManager)
awaitUpdate()
assertTrue(getShortcuts().isEmpty())
historyRepository.addOrUpdate(

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

@@ -100,6 +100,9 @@
<activity
android:name="org.koitharu.kotatsu.suggestions.ui.SuggestionsActivity"
android:label="@string/suggestions" />
<activity
android:name="org.koitharu.kotatsu.details.ui.related.RelatedMangaActivity"
android:label="@string/related_manga" />
<activity
android:name="org.koitharu.kotatsu.settings.SettingsActivity"
android:exported="true"
@@ -132,8 +135,7 @@
android:windowSoftInputMode="adjustResize" />
<activity
android:name="org.koitharu.kotatsu.favourites.ui.categories.FavouriteCategoriesActivity"
android:label="@string/favourites"
android:windowSoftInputMode="stateAlwaysHidden" />
android:label="@string/manage_categories" />
<activity
android:name="org.koitharu.kotatsu.widget.shelf.ShelfConfigActivity"
android:exported="true"

View File

@@ -9,6 +9,7 @@ import androidx.fragment.app.strictmode.FragmentStrictMode
import androidx.hilt.work.HiltWorkerFactory
import androidx.room.InvalidationTracker
import androidx.work.Configuration
import androidx.work.WorkManager
import dagger.hilt.android.HiltAndroidApp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -30,6 +31,7 @@ import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.settings.work.WorkScheduleManager
import javax.inject.Inject
import javax.inject.Provider
@HiltAndroidApp
class KotatsuApp : Application(), Configuration.Provider {
@@ -55,6 +57,9 @@ class KotatsuApp : Application(), Configuration.Provider {
@Inject
lateinit var workScheduleManager: WorkScheduleManager
@Inject
lateinit var workManagerProvider: Provider<WorkManager>
override fun onCreate() {
super.onCreate()
ACRA.errorReporter.putCustomData("isOriginalApp", appValidator.isOriginalApp.toString())
@@ -68,7 +73,7 @@ class KotatsuApp : Application(), Configuration.Provider {
setupDatabaseObservers()
}
workScheduleManager.init()
WorkServiceStopHelper(applicationContext).setup()
WorkServiceStopHelper(workManagerProvider).setup()
}
override fun attachBaseContext(base: Context?) {
@@ -152,7 +157,7 @@ class KotatsuApp : Application(), Configuration.Provider {
FragmentStrictMode.defaultPolicy = FragmentStrictMode.Policy.Builder()
.penaltyDeath()
.detectFragmentReuse()
.detectWrongFragmentContainer()
// .detectWrongFragmentContainer() FIXME: migrate to ViewPager2
.detectRetainInstanceUsage()
.detectSetUserVisibleHint()
.detectFragmentTagUsage()

View File

@@ -5,6 +5,7 @@ import androidx.room.Delete
import androidx.room.Insert
import androidx.room.Query
import androidx.room.Transaction
import androidx.room.Upsert
import kotlinx.coroutines.flow.Flow
import org.koitharu.kotatsu.core.db.entity.MangaWithTags
@@ -14,15 +15,24 @@ abstract class BookmarksDao {
@Query("SELECT * FROM bookmarks WHERE manga_id = :mangaId AND page_id = :pageId")
abstract suspend fun find(mangaId: Long, pageId: Long): BookmarkEntity?
@Query("SELECT * FROM bookmarks WHERE manga_id = :mangaId AND chapter_id = :chapterId AND page = :page")
@Query("SELECT * FROM bookmarks WHERE page_id = :pageId")
abstract suspend fun find(pageId: Long): BookmarkEntity?
@Transaction
@Query(
"SELECT * FROM manga JOIN bookmarks ON bookmarks.manga_id = manga.manga_id ORDER BY percent",
)
abstract suspend fun findAll(): Map<MangaWithTags, List<BookmarkEntity>>
@Query("SELECT * FROM bookmarks WHERE manga_id = :mangaId AND chapter_id = :chapterId AND page = :page ORDER BY percent")
abstract fun observe(mangaId: Long, chapterId: Long, page: Int): Flow<BookmarkEntity?>
@Query("SELECT * FROM bookmarks WHERE manga_id = :mangaId ORDER BY created_at DESC")
@Query("SELECT * FROM bookmarks WHERE manga_id = :mangaId ORDER BY percent")
abstract fun observe(mangaId: Long): Flow<List<BookmarkEntity>>
@Transaction
@Query(
"SELECT * FROM manga JOIN bookmarks ON bookmarks.manga_id = manga.manga_id ORDER BY bookmarks.created_at",
"SELECT * FROM manga JOIN bookmarks ON bookmarks.manga_id = manga.manga_id ORDER BY percent",
)
abstract fun observe(): Flow<Map<MangaWithTags, List<BookmarkEntity>>>
@@ -35,6 +45,12 @@ abstract class BookmarksDao {
@Query("DELETE FROM bookmarks WHERE manga_id = :mangaId AND page_id = :pageId")
abstract suspend fun delete(mangaId: Long, pageId: Long): Int
@Query("DELETE FROM bookmarks WHERE page_id = :pageId")
abstract suspend fun delete(pageId: Long): Int
@Query("DELETE FROM bookmarks WHERE manga_id = :mangaId AND chapter_id = :chapterId AND page = :page")
abstract suspend fun delete(mangaId: Long, chapterId: Long, page: Int): Int
@Upsert
abstract suspend fun upsert(bookmarks: Collection<BookmarkEntity>)
}

View File

@@ -2,7 +2,7 @@ package org.koitharu.kotatsu.bookmarks.data
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.parsers.model.Manga
import java.util.*
import java.util.Date
fun BookmarkEntity.toBookmark(manga: Manga) = Bookmark(
manga = manga,
@@ -30,4 +30,5 @@ fun Collection<BookmarkEntity>.toBookmarks(manga: Manga) = map {
it.toBookmark(manga)
}
fun Collection<Bookmark>.ids() = map { it.pageId }
@JvmName("bookmarksIds")
fun Collection<Bookmark>.ids() = map { it.pageId }

View File

@@ -1,10 +1,12 @@
package org.koitharu.kotatsu.bookmarks.domain
import org.koitharu.kotatsu.list.ui.model.ListModel
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
class Bookmark(
data class Bookmark(
val manga: Manga,
val pageId: Long,
val chapterId: Long,
@@ -13,11 +15,21 @@ class Bookmark(
val imageUrl: String,
val createdAt: Date,
val percent: Float,
) {
) : ListModel {
val directImageUrl: String?
get() = if (isImageUrlDirect()) imageUrl else null
val imageLoadData: Any
get() = if (isImageUrlDirect()) imageUrl else toMangaPage()
override fun areItemsTheSame(other: ListModel): Boolean {
return other is Bookmark &&
manga.id == other.manga.id &&
chapterId == other.chapterId &&
page == other.page
}
fun toMangaPage() = MangaPage(
id = pageId,
url = imageUrl,
@@ -26,34 +38,7 @@ class Bookmark(
)
private fun isImageUrlDirect(): Boolean {
return imageUrl.substringAfterLast('.').length in 2..4
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as Bookmark
if (manga != other.manga) return false
if (pageId != other.pageId) return false
if (chapterId != other.chapterId) return false
if (page != other.page) return false
if (scroll != other.scroll) return false
if (imageUrl != other.imageUrl) return false
if (createdAt != other.createdAt) return false
return percent == other.percent
}
override fun hashCode(): Int {
var result = manga.hashCode()
result = 31 * result + pageId.hashCode()
result = 31 * result + chapterId.hashCode()
result = 31 * result + page
result = 31 * result + scroll
result = 31 * result + imageUrl.hashCode()
result = 31 * result + createdAt.hashCode()
result = 31 * result + percent.hashCode()
return result
val extension = imageUrl.substringAfterLast('.')
return extension.isNotEmpty() && ImageFileFilter().isExtensionValid(extension)
}
}

View File

@@ -62,18 +62,16 @@ class BookmarksRepository @Inject constructor(
removeBookmark(bookmark.manga.id, bookmark.chapterId, bookmark.page)
}
suspend fun removeBookmarks(ids: Map<Manga, Set<Long>>): ReversibleHandle {
suspend fun removeBookmarks(ids: Set<Long>): ReversibleHandle {
val entities = ArrayList<BookmarkEntity>(ids.size)
db.withTransaction {
val dao = db.bookmarksDao
for ((manga, idSet) in ids) {
for (pageId in idSet) {
val e = dao.find(manga.id, pageId)
if (e != null) {
entities.add(e)
}
dao.delete(manga.id, pageId)
for (pageId in ids) {
val e = dao.find(pageId)
if (e != null) {
entities.add(e)
}
dao.delete(pageId)
}
}
return BookmarksRestorer(entities)

View File

@@ -12,31 +12,32 @@ import androidx.core.graphics.Insets
import androidx.core.view.updateLayoutParams
import androidx.core.view.updatePadding
import androidx.fragment.app.viewModels
import androidx.recyclerview.widget.GridLayoutManager
import coil.ImageLoader
import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.bookmarks.data.ids
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.bookmarks.ui.adapter.BookmarksGroupAdapter
import org.koitharu.kotatsu.bookmarks.ui.model.BookmarksGroup
import org.koitharu.kotatsu.bookmarks.ui.sheet.BookmarksAdapter
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.BaseFragment
import org.koitharu.kotatsu.core.ui.list.ListSelectionController
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.ui.list.SectionedSelectionController
import org.koitharu.kotatsu.core.ui.list.decor.AbstractSelectionItemDecoration
import org.koitharu.kotatsu.core.ui.list.decor.SpacingItemDecoration
import org.koitharu.kotatsu.core.ui.list.fastscroll.FastScroller
import org.koitharu.kotatsu.core.ui.util.ReversibleAction
import org.koitharu.kotatsu.core.ui.util.reverseAsync
import org.koitharu.kotatsu.core.util.ext.invalidateNestedItemDecorations
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.FragmentListSimpleBinding
import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.list.ui.MangaListSpanResolver
import org.koitharu.kotatsu.list.ui.adapter.ListHeaderClickListener
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
import org.koitharu.kotatsu.list.ui.adapter.ListStateHolderListener
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
import org.koitharu.kotatsu.list.ui.model.ListHeader
import org.koitharu.kotatsu.main.ui.owners.AppBarOwner
import org.koitharu.kotatsu.main.ui.owners.SnackbarOwner
import org.koitharu.kotatsu.parsers.model.Manga
@@ -48,15 +49,18 @@ class BookmarksFragment :
BaseFragment<FragmentListSimpleBinding>(),
ListStateHolderListener,
OnListItemClickListener<Bookmark>,
SectionedSelectionController.Callback<Manga>,
FastScroller.FastScrollListener {
ListSelectionController.Callback2,
FastScroller.FastScrollListener, ListHeaderClickListener {
@Inject
lateinit var coil: ImageLoader
@Inject
lateinit var settings: AppSettings
private val viewModel by viewModels<BookmarksViewModel>()
private var adapter: BookmarksGroupAdapter? = null
private var selectionController: SectionedSelectionController<Manga>? = null
private var bookmarksAdapter: BookmarksAdapter? = null
private var selectionController: ListSelectionController? = null
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentListSimpleBinding {
return FragmentListSimpleBinding.inflate(inflater, container, false)
@@ -64,37 +68,46 @@ class BookmarksFragment :
override fun onViewBindingCreated(binding: FragmentListSimpleBinding, savedInstanceState: Bundle?) {
super.onViewBindingCreated(binding, savedInstanceState)
selectionController = SectionedSelectionController(
selectionController = ListSelectionController(
activity = requireActivity(),
owner = this,
decoration = BookmarksSelectionDecoration(binding.root.context),
registryOwner = this,
callback = this,
)
adapter = BookmarksGroupAdapter(
bookmarksAdapter = BookmarksAdapter(
lifecycleOwner = viewLifecycleOwner,
coil = coil,
listener = this,
selectionController = checkNotNull(selectionController),
bookmarkClickListener = this,
groupClickListener = OnGroupClickListener(),
clickListener = this,
headerClickListener = this,
)
binding.recyclerView.adapter = adapter
binding.recyclerView.setHasFixedSize(true)
val spacingDecoration = SpacingItemDecoration(resources.getDimensionPixelOffset(R.dimen.grid_spacing))
binding.recyclerView.addItemDecoration(spacingDecoration)
viewModel.content.observe(viewLifecycleOwner, ::onListChanged)
val spanSizeLookup = SpanSizeLookup()
with(binding.recyclerView) {
setHasFixedSize(true)
val spanResolver = MangaListSpanResolver(resources)
addItemDecoration(TypedListSpacingDecoration(context))
adapter = bookmarksAdapter
addOnLayoutChangeListener(spanResolver)
spanResolver.setGridSize(settings.gridSize / 100f, this)
val lm = GridLayoutManager(context, spanResolver.spanCount)
lm.spanSizeLookup = spanSizeLookup
layoutManager = lm
selectionController?.attachToRecyclerView(this)
}
viewModel.content.observe(viewLifecycleOwner) {
bookmarksAdapter?.setItems(it, spanSizeLookup)
}
viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(binding.recyclerView, this))
viewModel.onActionDone.observeEvent(viewLifecycleOwner, ::onActionDone)
}
override fun onDestroyView() {
super.onDestroyView()
adapter = null
bookmarksAdapter = null
selectionController = null
}
override fun onItemClick(item: Bookmark, view: View) {
if (selectionController?.onItemClick(item.manga, item.pageId) != true) {
if (selectionController?.onItemClick(item.pageId) != true) {
val intent = ReaderActivity.IntentBuilder(view.context)
.bookmark(item)
.incognito(true)
@@ -104,8 +117,13 @@ class BookmarksFragment :
}
}
override fun onListHeaderClick(item: ListHeader, view: View) {
val manga = item.payload as? Manga ?: return
startActivity(DetailsActivity.newIntent(view.context, manga))
}
override fun onItemLongClick(item: Bookmark, view: View): Boolean {
return selectionController?.onItemLongClick(item.manga, item.pageId) ?: false
return selectionController?.onItemLongClick(item.pageId) ?: false
}
override fun onRetryClick(error: Throwable) = Unit
@@ -118,24 +136,16 @@ class BookmarksFragment :
override fun onFastScrollStop(fastScroller: FastScroller) = Unit
override fun onSelectionChanged(controller: SectionedSelectionController<Manga>, count: Int) {
requireViewBinding().recyclerView.invalidateNestedItemDecorations()
override fun onSelectionChanged(controller: ListSelectionController, count: Int) {
requireViewBinding().recyclerView.invalidateItemDecorations()
}
override fun onCreateActionMode(
controller: SectionedSelectionController<Manga>,
mode: ActionMode,
menu: Menu,
): Boolean {
override fun onCreateActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean {
mode.menuInflater.inflate(R.menu.mode_bookmarks, menu)
return true
}
override fun onActionItemClicked(
controller: SectionedSelectionController<Manga>,
mode: ActionMode,
item: MenuItem,
): Boolean {
override fun onActionItemClicked(controller: ListSelectionController, mode: ActionMode, item: MenuItem): Boolean {
return when (item.itemId) {
R.id.action_remove -> {
val ids = selectionController?.snapshot() ?: return false
@@ -148,11 +158,6 @@ class BookmarksFragment :
}
}
override fun onCreateItemDecoration(
controller: SectionedSelectionController<Manga>,
section: Manga,
): AbstractSelectionItemDecoration = BookmarksSelectionDecoration(requireContext())
override fun onWindowInsetsChanged(insets: Insets) {
requireViewBinding().recyclerView.updatePadding(
bottom = insets.bottom,
@@ -162,10 +167,6 @@ class BookmarksFragment :
}
}
private fun onListChanged(list: List<ListModel>) {
adapter?.items = list
}
private fun onActionDone(action: ReversibleAction) {
val handle = action.handle
val length = if (handle == null) Snackbar.LENGTH_SHORT else Snackbar.LENGTH_LONG
@@ -176,24 +177,24 @@ class BookmarksFragment :
snackbar.show()
}
private inner class OnGroupClickListener : OnListItemClickListener<BookmarksGroup> {
private inner class SpanSizeLookup : GridLayoutManager.SpanSizeLookup(), Runnable {
override fun onItemClick(item: BookmarksGroup, view: View) {
val controller = selectionController
if (controller != null && controller.count > 0) {
if (controller.getSectionCount(item.manga) == item.bookmarks.size) {
controller.clearSelection(item.manga)
} else {
controller.addToSelection(item.manga, item.bookmarks.ids())
}
return
}
val intent = DetailsActivity.newIntent(view.context, item.manga)
startActivity(intent)
init {
isSpanIndexCacheEnabled = true
isSpanGroupIndexCacheEnabled = true
}
override fun onItemLongClick(item: BookmarksGroup, view: View): Boolean {
return selectionController?.addToSelection(item.manga, item.bookmarks.ids()) ?: false
override fun getSpanSize(position: Int): Int {
val total = (viewBinding?.recyclerView?.layoutManager as? GridLayoutManager)?.spanCount ?: return 1
return when (bookmarksAdapter?.getItemViewType(position)) {
ListItemType.PAGE_THUMB.ordinal -> 1
else -> total
}
}
override fun run() {
invalidateSpanGroupIndexCache()
invalidateSpanIndexCache()
}
}

View File

@@ -10,13 +10,14 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.plus
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository
import org.koitharu.kotatsu.bookmarks.ui.model.BookmarksGroup
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.list.ui.model.EmptyState
import org.koitharu.kotatsu.list.ui.model.ListHeader
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.LoadingState
import org.koitharu.kotatsu.list.ui.model.toErrorState
@@ -41,17 +42,26 @@ class BookmarksViewModel @Inject constructor(
actionStringRes = 0,
),
)
} else list.map { (manga, bookmarks) ->
BookmarksGroup(manga, bookmarks)
} else {
mapList(list)
}
}
.catch { e -> emit(listOf(e.toErrorState(canRetry = false))) }
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState))
fun removeBookmarks(ids: Map<Manga, Set<Long>>) {
fun removeBookmarks(ids: Set<Long>) {
launchJob(Dispatchers.Default) {
val handle = repository.removeBookmarks(ids)
onActionDone.call(ReversibleAction(R.string.bookmarks_removed, handle))
}
}
private fun mapList(data: Map<Manga, List<Bookmark>>): List<ListModel> {
val result = ArrayList<ListModel>(data.values.sumOf { it.size + 1 })
for ((manga, bookmarks) in data) {
result.add(ListHeader(manga.title, R.string.more, manga))
result.addAll(bookmarks)
}
return result
}
}

View File

@@ -28,8 +28,7 @@ fun bookmarkListAD(
binding.root.setOnLongClickListener(listener)
bind {
val data: Any = item.directImageUrl ?: item.toMangaPage()
binding.imageViewThumb.newImageRequest(lifecycleOwner, data)?.run {
binding.imageViewThumb.newImageRequest(lifecycleOwner, item.imageLoadData)?.run {
size(CoverSizeResolver(binding.imageViewThumb))
placeholder(R.drawable.ic_placeholder)
fallback(R.drawable.ic_placeholder)

View File

@@ -1,32 +1,15 @@
package org.koitharu.kotatsu.bookmarks.ui.adapter
import androidx.lifecycle.LifecycleOwner
import androidx.recyclerview.widget.DiffUtil
import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.core.ui.BaseListAdapter
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
class BookmarksAdapter(
coil: ImageLoader,
lifecycleOwner: LifecycleOwner,
clickListener: OnListItemClickListener<Bookmark>,
) : AsyncListDifferDelegationAdapter<Bookmark>(
DiffCallback(),
) : BaseListAdapter<Bookmark>(
bookmarkListAD(coil, lifecycleOwner, clickListener),
) {
private class DiffCallback : DiffUtil.ItemCallback<Bookmark>() {
override fun areItemsTheSame(oldItem: Bookmark, newItem: Bookmark): Boolean {
return oldItem.manga.id == newItem.manga.id &&
oldItem.chapterId == newItem.chapterId &&
oldItem.page == newItem.page
}
override fun areContentsTheSame(oldItem: Bookmark, newItem: Bookmark): Boolean {
return oldItem.imageUrl == newItem.imageUrl
}
}
}
)

View File

@@ -1,69 +0,0 @@
package org.koitharu.kotatsu.bookmarks.ui.adapter
import android.view.View
import androidx.lifecycle.LifecycleOwner
import androidx.recyclerview.widget.RecyclerView
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.bookmarks.ui.model.BookmarksGroup
import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.ui.list.SectionedSelectionController
import org.koitharu.kotatsu.core.ui.list.decor.SpacingItemDecoration
import org.koitharu.kotatsu.core.util.ext.clearItemDecorations
import org.koitharu.kotatsu.core.util.ext.disposeImageRequest
import org.koitharu.kotatsu.core.util.ext.enqueueWith
import org.koitharu.kotatsu.core.util.ext.newImageRequest
import org.koitharu.kotatsu.core.util.ext.source
import org.koitharu.kotatsu.databinding.ItemBookmarksGroupBinding
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.model.Manga
fun bookmarksGroupAD(
coil: ImageLoader,
lifecycleOwner: LifecycleOwner,
sharedPool: RecyclerView.RecycledViewPool,
selectionController: SectionedSelectionController<Manga>,
bookmarkClickListener: OnListItemClickListener<Bookmark>,
groupClickListener: OnListItemClickListener<BookmarksGroup>,
) = adapterDelegateViewBinding<BookmarksGroup, ListModel, ItemBookmarksGroupBinding>(
{ layoutInflater, parent -> ItemBookmarksGroupBinding.inflate(layoutInflater, parent, false) },
) {
val viewListenerAdapter = object : View.OnClickListener, View.OnLongClickListener {
override fun onClick(v: View) = groupClickListener.onItemClick(item, v)
override fun onLongClick(v: View) = groupClickListener.onItemLongClick(item, v)
}
val adapter = BookmarksAdapter(coil, lifecycleOwner, bookmarkClickListener)
binding.recyclerView.setRecycledViewPool(sharedPool)
binding.recyclerView.adapter = adapter
val spacingDecoration = SpacingItemDecoration(context.resources.getDimensionPixelOffset(R.dimen.grid_spacing))
binding.recyclerView.addItemDecoration(spacingDecoration)
binding.root.setOnClickListener(viewListenerAdapter)
binding.root.setOnLongClickListener(viewListenerAdapter)
bind { payloads ->
if (payloads.isEmpty()) {
binding.recyclerView.clearItemDecorations()
binding.recyclerView.addItemDecoration(spacingDecoration)
selectionController.attachToRecyclerView(item.manga, binding.recyclerView)
}
binding.imageViewCover.newImageRequest(lifecycleOwner, item.manga.coverUrl)?.run {
placeholder(R.drawable.ic_placeholder)
fallback(R.drawable.ic_placeholder)
error(R.drawable.ic_error_placeholder)
allowRgb565(true)
size(CoverSizeResolver(binding.imageViewCover))
source(item.manga.source)
enqueueWith(coil)
}
binding.textViewTitle.text = item.manga.title
adapter.items = item.bookmarks
}
onViewRecycled {
binding.imageViewCover.disposeImageRequest()
}
}

View File

@@ -1,77 +0,0 @@
package org.koitharu.kotatsu.bookmarks.ui.adapter
import androidx.lifecycle.LifecycleOwner
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.bookmarks.ui.model.BookmarksGroup
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.ui.list.SectionedSelectionController
import org.koitharu.kotatsu.list.ui.adapter.ListStateHolderListener
import org.koitharu.kotatsu.list.ui.adapter.emptyStateListAD
import org.koitharu.kotatsu.list.ui.adapter.errorStateListAD
import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD
import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.LoadingFooter
import org.koitharu.kotatsu.parsers.model.Manga
import kotlin.jvm.internal.Intrinsics
class BookmarksGroupAdapter(
coil: ImageLoader,
lifecycleOwner: LifecycleOwner,
selectionController: SectionedSelectionController<Manga>,
listener: ListStateHolderListener,
bookmarkClickListener: OnListItemClickListener<Bookmark>,
groupClickListener: OnListItemClickListener<BookmarksGroup>,
) : AsyncListDifferDelegationAdapter<ListModel>(DiffCallback()) {
init {
val pool = RecyclerView.RecycledViewPool()
delegatesManager
.addDelegate(
bookmarksGroupAD(
coil = coil,
lifecycleOwner = lifecycleOwner,
sharedPool = pool,
selectionController = selectionController,
bookmarkClickListener = bookmarkClickListener,
groupClickListener = groupClickListener,
),
)
.addDelegate(loadingStateAD())
.addDelegate(loadingFooterAD())
.addDelegate(emptyStateListAD(coil, lifecycleOwner, listener))
.addDelegate(errorStateListAD(listener))
}
private class DiffCallback : DiffUtil.ItemCallback<ListModel>() {
override fun areItemsTheSame(oldItem: ListModel, newItem: ListModel): Boolean {
return when {
oldItem is BookmarksGroup && newItem is BookmarksGroup -> {
oldItem.manga.id == newItem.manga.id
}
oldItem is LoadingFooter && newItem is LoadingFooter -> {
oldItem.key == newItem.key
}
else -> oldItem.javaClass == newItem.javaClass
}
}
override fun areContentsTheSame(oldItem: ListModel, newItem: ListModel): Boolean {
return Intrinsics.areEqual(oldItem, newItem)
}
override fun getChangePayload(oldItem: ListModel, newItem: ListModel): Any? {
return when {
oldItem is BookmarksGroup && newItem is BookmarksGroup -> Unit
else -> super.getChangePayload(oldItem, newItem)
}
}
}
}

View File

@@ -1,31 +0,0 @@
package org.koitharu.kotatsu.bookmarks.ui.model
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.areItemsEquals
class BookmarksGroup(
val manga: Manga,
val bookmarks: List<Bookmark>,
) : ListModel {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as BookmarksGroup
if (manga != other.manga) return false
return bookmarks.areItemsEquals(other.bookmarks) { a, b ->
a.imageUrl == b.imageUrl
}
}
override fun hashCode(): Int {
var result = manga.hashCode()
result = 31 * result + bookmarks.sumOf { it.imageUrl.hashCode() }
return result
}
}

View File

@@ -0,0 +1,48 @@
package org.koitharu.kotatsu.bookmarks.ui.sheet
import androidx.lifecycle.LifecycleOwner
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.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.newImageRequest
import org.koitharu.kotatsu.core.util.ext.source
import org.koitharu.kotatsu.databinding.ItemBookmarkLargeBinding
import org.koitharu.kotatsu.list.ui.model.ListModel
fun bookmarkLargeAD(
coil: ImageLoader,
lifecycleOwner: LifecycleOwner,
clickListener: OnListItemClickListener<Bookmark>,
) = adapterDelegateViewBinding<Bookmark, ListModel, ItemBookmarkLargeBinding>(
{ inflater, parent -> ItemBookmarkLargeBinding.inflate(inflater, parent, false) },
) {
val listener = AdapterDelegateClickListenerAdapter(this, clickListener)
binding.root.setOnClickListener(listener)
binding.root.setOnLongClickListener(listener)
bind {
binding.imageViewThumb.newImageRequest(lifecycleOwner, item.imageLoadData)?.run {
size(CoverSizeResolver(binding.imageViewThumb))
placeholder(R.drawable.ic_placeholder)
fallback(R.drawable.ic_placeholder)
error(R.drawable.ic_error_placeholder)
allowRgb565(true)
decodeRegion(item.scroll)
source(item.manga.source)
enqueueWith(coil)
}
binding.progressView.percent = item.percent
}
onViewRecycled {
binding.imageViewThumb.disposeImageRequest()
}
}

View File

@@ -0,0 +1,42 @@
package org.koitharu.kotatsu.bookmarks.ui.sheet
import android.content.Context
import androidx.lifecycle.LifecycleOwner
import coil.ImageLoader
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.core.ui.BaseListAdapter
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.ui.list.fastscroll.FastScroller
import org.koitharu.kotatsu.list.ui.adapter.ListHeaderClickListener
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
import org.koitharu.kotatsu.list.ui.adapter.listHeaderAD
import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD
import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD
import org.koitharu.kotatsu.list.ui.model.ListHeader
import org.koitharu.kotatsu.list.ui.model.ListModel
class BookmarksAdapter(
coil: ImageLoader,
lifecycleOwner: LifecycleOwner,
clickListener: OnListItemClickListener<Bookmark>,
headerClickListener: ListHeaderClickListener?,
) : BaseListAdapter<ListModel>(), FastScroller.SectionIndexer {
init {
addDelegate(ListItemType.PAGE_THUMB, bookmarkLargeAD(coil, lifecycleOwner, clickListener))
addDelegate(ListItemType.HEADER, listHeaderAD(headerClickListener))
addDelegate(ListItemType.FOOTER_LOADING, loadingFooterAD())
addDelegate(ListItemType.STATE_LOADING, loadingStateAD())
}
override fun getSectionText(context: Context, position: Int): CharSequence? {
val list = items
for (i in (0..position).reversed()) {
val item = list.getOrNull(i) ?: continue
if (item is ListHeader) {
return item.getText(context)
}
}
return null
}
}

View File

@@ -0,0 +1,170 @@
package org.koitharu.kotatsu.bookmarks.ui.sheet
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.viewModels
import androidx.recyclerview.widget.GridLayoutManager
import coil.ImageLoader
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.ui.sheet.AdaptiveSheetBehavior
import org.koitharu.kotatsu.core.ui.sheet.AdaptiveSheetCallback
import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet
import org.koitharu.kotatsu.core.util.RecyclerViewScrollCallback
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.plus
import org.koitharu.kotatsu.core.util.ext.scaleUpActivityOptionsOf
import org.koitharu.kotatsu.core.util.ext.showDistinct
import org.koitharu.kotatsu.core.util.ext.withArgs
import org.koitharu.kotatsu.databinding.SheetPagesBinding
import org.koitharu.kotatsu.list.ui.MangaListSpanResolver
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.reader.ui.ReaderActivity.IntentBuilder
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
import org.koitharu.kotatsu.reader.ui.thumbnails.OnPageSelectListener
import org.koitharu.kotatsu.reader.ui.thumbnails.PageThumbnail
import javax.inject.Inject
import kotlin.math.roundToInt
@AndroidEntryPoint
class BookmarksSheet :
BaseAdaptiveSheet<SheetPagesBinding>(),
AdaptiveSheetCallback,
OnListItemClickListener<Bookmark> {
private val viewModel by viewModels<BookmarksSheetViewModel>()
@Inject
lateinit var coil: ImageLoader
@Inject
lateinit var settings: AppSettings
private var bookmarksAdapter: BookmarksAdapter? = null
private var spanResolver: MangaListSpanResolver? = null
private val spanSizeLookup = SpanSizeLookup()
private val listCommitCallback = Runnable {
spanSizeLookup.invalidateCache()
}
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): SheetPagesBinding {
return SheetPagesBinding.inflate(inflater, container, false)
}
override fun onViewBindingCreated(binding: SheetPagesBinding, savedInstanceState: Bundle?) {
super.onViewBindingCreated(binding, savedInstanceState)
addSheetCallback(this)
spanResolver = MangaListSpanResolver(binding.root.resources)
bookmarksAdapter = BookmarksAdapter(
coil = coil,
lifecycleOwner = viewLifecycleOwner,
clickListener = this@BookmarksSheet,
headerClickListener = null,
)
viewBinding?.headerBar?.setTitle(R.string.bookmarks)
with(binding.recyclerView) {
addItemDecoration(TypedListSpacingDecoration(context))
adapter = bookmarksAdapter
addOnLayoutChangeListener(spanResolver)
spanResolver?.setGridSize(settings.gridSize / 100f, this)
(layoutManager as GridLayoutManager).spanSizeLookup = spanSizeLookup
}
viewModel.content.observe(viewLifecycleOwner, ::onThumbnailsChanged)
viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(binding.recyclerView, this))
}
override fun onDestroyView() {
spanResolver = null
bookmarksAdapter = null
spanSizeLookup.invalidateCache()
super.onDestroyView()
}
override fun onItemClick(item: Bookmark, view: View) {
val listener = (parentFragment as? OnPageSelectListener) ?: (activity as? OnPageSelectListener)
if (listener != null) {
listener.onPageSelected(ReaderPage(item.toMangaPage(), item.page, item.chapterId))
} else {
val intent = IntentBuilder(view.context)
.manga(viewModel.manga)
.bookmark(item)
.incognito(true)
.build()
startActivity(intent, scaleUpActivityOptionsOf(view))
}
dismiss()
}
override fun onStateChanged(sheet: View, newState: Int) {
viewBinding?.recyclerView?.isFastScrollerEnabled = newState == AdaptiveSheetBehavior.STATE_EXPANDED
}
private fun onThumbnailsChanged(list: List<ListModel>) {
val adapter = bookmarksAdapter ?: return
if (adapter.itemCount == 0) {
var position = list.indexOfFirst { it is PageThumbnail && it.isCurrent }
if (position > 0) {
val spanCount = spanResolver?.spanCount ?: 0
val offset = if (position > spanCount + 1) {
(resources.getDimensionPixelSize(R.dimen.manga_list_details_item_height) * 0.6).roundToInt()
} else {
position = 0
0
}
val scrollCallback = RecyclerViewScrollCallback(requireViewBinding().recyclerView, position, offset)
adapter.setItems(list, listCommitCallback + scrollCallback)
} else {
adapter.setItems(list, listCommitCallback)
}
} else {
adapter.setItems(list, listCommitCallback)
}
}
private inner class SpanSizeLookup : GridLayoutManager.SpanSizeLookup() {
init {
isSpanIndexCacheEnabled = true
isSpanGroupIndexCacheEnabled = true
}
override fun getSpanSize(position: Int): Int {
val total = (viewBinding?.recyclerView?.layoutManager as? GridLayoutManager)?.spanCount ?: return 1
return when (bookmarksAdapter?.getItemViewType(position)) {
ListItemType.PAGE_THUMB.ordinal -> 1
else -> total
}
}
fun invalidateCache() {
invalidateSpanGroupIndexCache()
invalidateSpanIndexCache()
}
}
companion object {
const val ARG_MANGA = "manga"
private const val TAG = "BookmarksSheet"
fun show(fm: FragmentManager, manga: Manga) {
BookmarksSheet().withArgs(1) {
putParcelable(ARG_MANGA, ParcelableManga(manga, withChapters = true))
}.showDistinct(fm, TAG)
}
}
}

View File

@@ -0,0 +1,54 @@
package org.koitharu.kotatsu.bookmarks.ui.sheet
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.plus
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.util.ext.require
import org.koitharu.kotatsu.list.ui.model.ListHeader
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.LoadingFooter
import org.koitharu.kotatsu.parsers.util.SuspendLazy
import javax.inject.Inject
@HiltViewModel
class BookmarksSheetViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
mangaRepositoryFactory: MangaRepository.Factory,
bookmarksRepository: BookmarksRepository,
) : BaseViewModel() {
val manga = savedStateHandle.require<ParcelableManga>(BookmarksSheet.ARG_MANGA).manga
private val chaptersLazy = SuspendLazy {
requireNotNull(manga.chapters ?: mangaRepositoryFactory.create(manga.source).getDetails(manga).chapters)
}
val content: StateFlow<List<ListModel>> = bookmarksRepository.observeBookmarks(manga)
.map { mapList(it) }
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, listOf(LoadingFooter()))
private suspend fun mapList(bookmarks: List<Bookmark>): List<ListModel> {
val chapters = chaptersLazy.get()
val bookmarksMap = bookmarks.groupBy { it.chapterId }
val result = ArrayList<ListModel>(bookmarks.size + bookmarksMap.size)
for (chapter in chapters) {
val b = bookmarksMap[chapter.id]
if (b.isNullOrEmpty()) {
continue
}
result += ListHeader(chapter.name)
result.addAll(b)
}
return result
}
}

View File

@@ -17,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")
@@ -35,7 +36,7 @@ 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)

View File

@@ -0,0 +1,74 @@
package org.koitharu.kotatsu.browser.cloudflare
import android.annotation.SuppressLint
import android.content.Context
import androidx.core.app.NotificationChannelCompat
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.PendingIntentCompat
import androidx.core.net.toUri
import coil.request.ErrorResult
import coil.request.ImageRequest
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
import org.koitharu.kotatsu.parsers.model.ContentType
class CaptchaNotifier(
private val context: Context,
) : ImageRequest.Listener {
@SuppressLint("MissingPermission")
fun notify(exception: CloudFlareProtectedException) {
val manager = NotificationManagerCompat.from(context)
if (!manager.areNotificationsEnabled()) {
return
}
val channel = NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_DEFAULT)
.setName(context.getString(R.string.captcha_required))
.setShowBadge(true)
.setVibrationEnabled(false)
.setSound(null, null)
.setLightsEnabled(false)
.build()
manager.createNotificationChannel(channel)
val intent = CloudFlareActivity.newIntent(context, exception.url, exception.headers)
.setData(exception.url.toUri())
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setContentTitle(channel.name)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setDefaults(NotificationCompat.DEFAULT_SOUND)
.setSmallIcon(android.R.drawable.stat_notify_error)
.setAutoCancel(true)
.setVisibility(
if (exception.source?.contentType == ContentType.HENTAI) {
NotificationCompat.VISIBILITY_SECRET
} else {
NotificationCompat.VISIBILITY_PUBLIC
},
)
.setContentText(
context.getString(
R.string.captcha_required_summary,
exception.source?.title ?: context.getString(R.string.app_name),
),
)
.setContentIntent(PendingIntentCompat.getActivity(context, 0, intent, 0, false))
.build()
manager.notify(TAG, exception.source.hashCode(), notification)
}
override fun onError(request: ImageRequest, result: ErrorResult) {
super.onError(request, result)
val e = result.throwable
if (e is CloudFlareProtectedException) {
notify(e)
}
}
private companion object {
private const val CHANNEL_ID = "captcha"
private const val TAG = CHANNEL_ID
}
}

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

@@ -6,6 +6,7 @@ import android.provider.SearchRecentSuggestions
import android.text.Html
import androidx.collection.arraySetOf
import androidx.room.InvalidationTracker
import androidx.work.WorkManager
import coil.ComponentRegistry
import coil.ImageLoader
import coil.decode.SvgDecoder
@@ -172,5 +173,10 @@ interface AppModule {
fun provideLocalStorageChangesFlow(
@LocalStorageChanges flow: MutableSharedFlow<LocalManga?>,
): SharedFlow<LocalManga?> = flow.asSharedFlow()
@Provides
fun provideWorkManager(
@ApplicationContext context: Context,
): WorkManager = WorkManager.getInstance(context)
}
}

View File

@@ -13,5 +13,7 @@ class BackupEntry(
const val HISTORY = "history"
const val CATEGORIES = "categories"
const val FAVOURITES = "favourites"
const val SETTINGS = "settings"
const val BOOKMARKS = "bookmarks"
}
}
}

View File

@@ -5,6 +5,7 @@ import org.json.JSONArray
import org.json.JSONObject
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.parsers.util.json.JSONIterator
import org.koitharu.kotatsu.parsers.util.json.mapJSON
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
@@ -12,7 +13,10 @@ import javax.inject.Inject
private const val PAGE_SIZE = 10
class BackupRepository @Inject constructor(private val db: MangaDatabase) {
class BackupRepository @Inject constructor(
private val db: MangaDatabase,
private val settings: AppSettings,
) {
suspend fun dumpHistory(): BackupEntry {
var offset = 0
@@ -67,6 +71,36 @@ class BackupRepository @Inject constructor(private val db: MangaDatabase) {
return entry
}
suspend fun dumpBookmarks(): BackupEntry {
val entry = BackupEntry(BackupEntry.BOOKMARKS, JSONArray())
val all = db.bookmarksDao.findAll()
for ((m, b) in all) {
val json = JSONObject()
val manga = JsonSerializer(m.manga).toJson()
json.put("manga", manga)
val tags = JSONArray()
m.tags.forEach { tags.put(JsonSerializer(it).toJson()) }
json.put("tags", tags)
val bookmarks = JSONArray()
b.forEach { bookmarks.put(JsonSerializer(it).toJson()) }
json.put("bookmarks", bookmarks)
entry.data.put(json)
}
return entry
}
fun dumpSettings(): BackupEntry {
val entry = BackupEntry(BackupEntry.SETTINGS, JSONArray())
val settingsDump = settings.getAllValues().toMutableMap()
settingsDump.remove(AppSettings.KEY_APP_PASSWORD)
settingsDump.remove(AppSettings.KEY_PROXY_PASSWORD)
settingsDump.remove(AppSettings.KEY_PROXY_LOGIN)
settingsDump.remove(AppSettings.KEY_INCOGNITO_MODE)
val json = JsonSerializer(settingsDump).toJson()
entry.data.put(json)
return entry
}
fun createIndex(): BackupEntry {
val entry = BackupEntry(BackupEntry.INDEX, JSONArray())
val json = JSONObject()
@@ -127,4 +161,36 @@ class BackupRepository @Inject constructor(private val db: MangaDatabase) {
}
return result
}
suspend fun restoreBookmarks(entry: BackupEntry): CompositeResult {
val result = CompositeResult()
for (item in entry.data.JSONIterator()) {
val mangaJson = item.getJSONObject("manga")
val manga = JsonDeserializer(mangaJson).toMangaEntity()
val tags = item.getJSONArray("tags").mapJSON {
JsonDeserializer(it).toTagEntity()
}
val bookmarks = item.getJSONArray("bookmarks").mapJSON {
JsonDeserializer(it).toBookmarkEntity()
}
result += runCatchingCancellable {
db.withTransaction {
db.tagsDao.upsert(tags)
db.mangaDao.upsert(manga, tags)
db.bookmarksDao.upsert(bookmarks)
}
}
}
return result
}
fun restoreSettings(entry: BackupEntry): CompositeResult {
val result = CompositeResult()
for (item in entry.data.JSONIterator()) {
result += runCatchingCancellable {
settings.upsertAll(JsonDeserializer(item).toMap())
}
}
return result
}
}

View File

@@ -11,8 +11,8 @@ class BackupZipInput(val file: File) : Closeable {
private val zipFile = ZipFile(file)
suspend fun getEntry(name: String): BackupEntry = runInterruptible(Dispatchers.IO) {
val entry = zipFile.getEntry(name)
suspend fun getEntry(name: String): BackupEntry? = runInterruptible(Dispatchers.IO) {
val entry = zipFile.getEntry(name) ?: return@runInterruptible null
val json = zipFile.getInputStream(entry).use {
JSONArray(it.bufferedReader().readText())
}
@@ -22,4 +22,4 @@ class BackupZipInput(val file: File) : Closeable {
override fun close() {
zipFile.close()
}
}
}

View File

@@ -11,6 +11,9 @@ class CompositeResult {
val failures: List<Throwable>
get() = errors.filterNotNull()
val isEmpty: Boolean
get() = errors.isEmpty() && successCount == 0
val isAllSuccess: Boolean
get() = errors.none { it != null }
@@ -36,4 +39,4 @@ class CompositeResult {
result.errors.addAll(other.errors)
return result
}
}
}

View File

@@ -1,6 +1,7 @@
package org.koitharu.kotatsu.core.backup
import org.json.JSONObject
import org.koitharu.kotatsu.bookmarks.data.BookmarkEntity
import org.koitharu.kotatsu.core.db.entity.MangaEntity
import org.koitharu.kotatsu.core.db.entity.TagEntity
import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity
@@ -34,14 +35,14 @@ class JsonDeserializer(private val json: JSONObject) {
largeCoverUrl = json.getStringOrNull("large_cover_url"),
state = json.getStringOrNull("state"),
author = json.getStringOrNull("author"),
source = json.getString("source")
source = json.getString("source"),
)
fun toTagEntity() = TagEntity(
id = json.getLong("id"),
title = json.getString("title"),
key = json.getString("key"),
source = json.getString("source")
source = json.getString("source"),
)
fun toHistoryEntity() = HistoryEntity(
@@ -65,4 +66,28 @@ class JsonDeserializer(private val json: JSONObject) {
isVisibleInLibrary = json.getBooleanOrDefault("show_in_lib", true),
deletedAt = 0L,
)
fun toBookmarkEntity() = BookmarkEntity(
mangaId = json.getLong("manga_id"),
pageId = json.getLong("page_id"),
chapterId = json.getLong("chapter_id"),
page = json.getInt("page"),
scroll = json.getInt("scroll"),
imageUrl = json.getString("image_url"),
createdAt = json.getLong("created_at"),
percent = json.getDouble("percent").toFloat(),
)
fun toMap(): Map<String, Any?> {
val map = mutableMapOf<String, Any?>()
val keys = json.keys()
while (keys.hasNext()) {
val key = keys.next()
val value = json.get(key)
map[key] = value
}
return map
}
}

View File

@@ -1,6 +1,7 @@
package org.koitharu.kotatsu.core.backup
import org.json.JSONObject
import org.koitharu.kotatsu.bookmarks.data.BookmarkEntity
import org.koitharu.kotatsu.core.db.entity.MangaEntity
import org.koitharu.kotatsu.core.db.entity.TagEntity
import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity
@@ -15,7 +16,7 @@ class JsonSerializer private constructor(private val json: JSONObject) {
put("category_id", e.categoryId)
put("sort_key", e.sortKey)
put("created_at", e.createdAt)
}
},
)
constructor(e: FavouriteCategoryEntity) : this(
@@ -27,7 +28,7 @@ class JsonSerializer private constructor(private val json: JSONObject) {
put("order", e.order)
put("track", e.track)
put("show_in_lib", e.isVisibleInLibrary)
}
},
)
constructor(e: HistoryEntity) : this(
@@ -39,7 +40,7 @@ class JsonSerializer private constructor(private val json: JSONObject) {
put("page", e.page)
put("scroll", e.scroll)
put("percent", e.percent)
}
},
)
constructor(e: TagEntity) : this(
@@ -48,7 +49,7 @@ class JsonSerializer private constructor(private val json: JSONObject) {
put("title", e.title)
put("key", e.key)
put("source", e.source)
}
},
)
constructor(e: MangaEntity) : this(
@@ -65,8 +66,25 @@ class JsonSerializer private constructor(private val json: JSONObject) {
put("state", e.state)
put("author", e.author)
put("source", e.source)
}
},
)
constructor(e: BookmarkEntity) : this(
JSONObject().apply {
put("manga_id", e.mangaId)
put("page_id", e.pageId)
put("chapter_id", e.chapterId)
put("page", e.page)
put("scroll", e.scroll)
put("image_url", e.imageUrl)
put("created_at", e.createdAt)
put("percent", e.percent)
},
)
constructor(m: Map<String, *>) : this(
JSONObject(m),
)
fun toJson(): JSONObject = json
}
}

View File

@@ -16,8 +16,29 @@ interface ContentCache {
fun putPages(source: MangaSource, url: String, pages: SafeDeferred<List<MangaPage>>)
data class Key(
suspend fun getRelatedManga(source: MangaSource, url: String): List<Manga>?
fun putRelatedManga(source: MangaSource, url: String, related: SafeDeferred<List<Manga>>)
class Key(
val source: MangaSource,
val url: String,
)
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as Key
if (source != other.source) return false
return url == other.url
}
override fun hashCode(): Int {
var result = source.hashCode()
result = 31 * result + url.hashCode()
return result
}
}
}

View File

@@ -16,6 +16,7 @@ class MemoryContentCache(application: Application) : ContentCache, ComponentCall
private val detailsCache = ExpiringLruCache<SafeDeferred<Manga>>(4, 5, TimeUnit.MINUTES)
private val pagesCache = ExpiringLruCache<SafeDeferred<List<MangaPage>>>(4, 10, TimeUnit.MINUTES)
private val relatedMangaCache = ExpiringLruCache<SafeDeferred<List<Manga>>>(4, 10, TimeUnit.MINUTES)
override val isCachingEnabled: Boolean = true
@@ -35,6 +36,14 @@ class MemoryContentCache(application: Application) : ContentCache, ComponentCall
pagesCache[ContentCache.Key(source, url)] = pages
}
override suspend fun getRelatedManga(source: MangaSource, url: String): List<Manga>? {
return relatedMangaCache[ContentCache.Key(source, url)]?.awaitOrNull()
}
override fun putRelatedManga(source: MangaSource, url: String, related: SafeDeferred<List<Manga>>) {
relatedMangaCache[ContentCache.Key(source, url)] = related
}
override fun onConfigurationChanged(newConfig: Configuration) = Unit
override fun onLowMemory() = Unit
@@ -42,6 +51,7 @@ class MemoryContentCache(application: Application) : ContentCache, ComponentCall
override fun onTrimMemory(level: Int) {
trimCache(detailsCache, level)
trimCache(pagesCache, level)
trimCache(relatedMangaCache, level)
}
private fun trimCache(cache: ExpiringLruCache<*>, level: Int) {

View File

@@ -15,4 +15,8 @@ class StubContentCache : ContentCache {
override suspend fun getPages(source: MangaSource, url: String): List<MangaPage>? = null
override fun putPages(source: MangaSource, url: String, pages: SafeDeferred<List<MangaPage>>) = Unit
override suspend fun getRelatedManga(source: MangaSource, url: String): List<Manga>? = null
override fun putRelatedManga(source: MangaSource, url: String, related: SafeDeferred<List<Manga>>) = Unit
}

View File

@@ -12,11 +12,13 @@ import kotlinx.coroutines.launch
import org.koitharu.kotatsu.bookmarks.data.BookmarkEntity
import org.koitharu.kotatsu.bookmarks.data.BookmarksDao
import org.koitharu.kotatsu.core.db.dao.MangaDao
import org.koitharu.kotatsu.core.db.dao.MangaSourcesDao
import org.koitharu.kotatsu.core.db.dao.PreferencesDao
import org.koitharu.kotatsu.core.db.dao.TagsDao
import org.koitharu.kotatsu.core.db.dao.TrackLogsDao
import org.koitharu.kotatsu.core.db.entity.MangaEntity
import org.koitharu.kotatsu.core.db.entity.MangaPrefsEntity
import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity
import org.koitharu.kotatsu.core.db.entity.MangaTagsEntity
import org.koitharu.kotatsu.core.db.entity.TagEntity
import org.koitharu.kotatsu.core.db.migrations.Migration10To11
@@ -25,6 +27,7 @@ import org.koitharu.kotatsu.core.db.migrations.Migration12To13
import org.koitharu.kotatsu.core.db.migrations.Migration13To14
import org.koitharu.kotatsu.core.db.migrations.Migration14To15
import org.koitharu.kotatsu.core.db.migrations.Migration15To16
import org.koitharu.kotatsu.core.db.migrations.Migration16To17
import org.koitharu.kotatsu.core.db.migrations.Migration1To2
import org.koitharu.kotatsu.core.db.migrations.Migration2To3
import org.koitharu.kotatsu.core.db.migrations.Migration3To4
@@ -49,14 +52,14 @@ import org.koitharu.kotatsu.tracker.data.TrackEntity
import org.koitharu.kotatsu.tracker.data.TrackLogEntity
import org.koitharu.kotatsu.tracker.data.TracksDao
const val DATABASE_VERSION = 16
const val DATABASE_VERSION = 17
@Database(
entities = [
MangaEntity::class, TagEntity::class, HistoryEntity::class, MangaTagsEntity::class,
FavouriteCategoryEntity::class, FavouriteEntity::class, MangaPrefsEntity::class,
TrackEntity::class, TrackLogEntity::class, SuggestionEntity::class, BookmarkEntity::class,
ScrobblingEntity::class,
ScrobblingEntity::class, MangaSourceEntity::class,
],
version = DATABASE_VERSION,
)
@@ -83,30 +86,32 @@ abstract class MangaDatabase : RoomDatabase() {
abstract val bookmarksDao: BookmarksDao
abstract val scrobblingDao: ScrobblingDao
abstract val sourcesDao: MangaSourcesDao
}
val databaseMigrations: Array<Migration>
get() = arrayOf(
Migration1To2(),
Migration2To3(),
Migration3To4(),
Migration4To5(),
Migration5To6(),
Migration6To7(),
Migration7To8(),
Migration8To9(),
Migration9To10(),
Migration10To11(),
Migration11To12(),
Migration12To13(),
Migration13To14(),
Migration14To15(),
Migration15To16(),
)
fun getDatabaseMigrations(context: Context): Array<Migration> = arrayOf(
Migration1To2(),
Migration2To3(),
Migration3To4(),
Migration4To5(),
Migration5To6(),
Migration6To7(),
Migration7To8(),
Migration8To9(),
Migration9To10(),
Migration10To11(),
Migration11To12(),
Migration12To13(),
Migration13To14(),
Migration14To15(),
Migration15To16(),
Migration16To17(context),
)
fun MangaDatabase(context: Context): MangaDatabase = Room
.databaseBuilder(context, MangaDatabase::class.java, "kotatsu-db")
.addMigrations(*databaseMigrations)
.addMigrations(*getDatabaseMigrations(context))
.addCallback(DatabasePrePopulateCallback(context.resources))
.build()

View File

@@ -1,6 +1,12 @@
package org.koitharu.kotatsu.core.db.dao
import androidx.room.*
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import androidx.room.Update
import androidx.room.Upsert
import org.koitharu.kotatsu.core.db.entity.MangaEntity
import org.koitharu.kotatsu.core.db.entity.MangaTagsEntity
import org.koitharu.kotatsu.core.db.entity.MangaWithTags
@@ -21,8 +27,8 @@ abstract class MangaDao {
@Query("SELECT * FROM manga WHERE (title LIKE :query OR alt_title LIKE :query) AND source = :source AND manga_id IN (SELECT manga_id FROM favourites UNION SELECT manga_id FROM history) LIMIT :limit")
abstract suspend fun searchByTitle(query: String, source: String, limit: Int): List<MangaWithTags>
@Insert(onConflict = OnConflictStrategy.IGNORE)
abstract suspend fun insert(manga: MangaEntity): Long
@Upsert
abstract suspend fun upsert(manga: MangaEntity)
@Update(onConflict = OnConflictStrategy.IGNORE)
abstract suspend fun update(manga: MangaEntity): Int
@@ -35,15 +41,13 @@ abstract class MangaDao {
@Transaction
open suspend fun upsert(manga: MangaEntity, tags: Iterable<TagEntity>? = null) {
if (update(manga) <= 0) {
insert(manga)
if (tags != null) {
clearTagRelation(manga.id)
tags.map {
MangaTagsEntity(manga.id, it.id)
}.forEach {
insertTagRelation(it)
}
upsert(manga)
if (tags != null) {
clearTagRelation(manga.id)
tags.map {
MangaTagsEntity(manga.id, it.id)
}.forEach {
insertTagRelation(it)
}
}
}

View File

@@ -0,0 +1,57 @@
package org.koitharu.kotatsu.core.db.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import androidx.room.Upsert
import kotlinx.coroutines.flow.Flow
import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity
@Dao
abstract class MangaSourcesDao {
@Query("SELECT * FROM sources ORDER BY sort_key")
abstract suspend fun findAll(): List<MangaSourceEntity>
@Query("SELECT * FROM sources WHERE enabled = 1 ORDER BY sort_key")
abstract suspend fun findAllEnabled(): List<MangaSourceEntity>
@Query("SELECT * FROM sources WHERE enabled = 1 ORDER BY sort_key")
abstract fun observeEnabled(): Flow<List<MangaSourceEntity>>
@Query("SELECT * FROM sources ORDER BY sort_key")
abstract fun observeAll(): Flow<List<MangaSourceEntity>>
@Query("SELECT IFNULL(MAX(sort_key),0) FROM sources")
abstract suspend fun getMaxSortKey(): Int
@Query("UPDATE sources SET enabled = 0")
abstract suspend fun disableAllSources()
@Query("UPDATE sources SET sort_key = :sortKey WHERE source = :source")
abstract suspend fun setSortKey(source: String, sortKey: Int)
@Insert(onConflict = OnConflictStrategy.IGNORE)
@Transaction
abstract suspend fun insertIfAbsent(entries: Collection<MangaSourceEntity>)
@Upsert
abstract suspend fun upsert(entry: MangaSourceEntity)
@Transaction
open suspend fun setEnabled(source: String, isEnabled: Boolean) {
if (updateIsEnabled(source, isEnabled) == 0) {
val entity = MangaSourceEntity(
source = source,
isEnabled = isEnabled,
sortKey = getMaxSortKey() + 1,
)
upsert(entity)
}
}
@Query("UPDATE sources SET enabled = :isEnabled WHERE source = :source")
protected abstract suspend fun updateIsEnabled(source: String, isEnabled: Boolean): Int
}

View File

@@ -1,6 +1,8 @@
package org.koitharu.kotatsu.core.db.dao
import androidx.room.*
import androidx.room.Dao
import androidx.room.Query
import androidx.room.Upsert
import org.koitharu.kotatsu.core.db.entity.TagEntity
@Dao
@@ -12,6 +14,7 @@ abstract class TagsDao {
@Query(
"""SELECT tags.* FROM tags
LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id
WHERE manga_tags.manga_id IN (SELECT manga_id FROM history UNION SELECT manga_id FROM favourites)
GROUP BY tags.title
ORDER BY COUNT(manga_id) DESC
LIMIT :limit""",
@@ -21,7 +24,7 @@ abstract class TagsDao {
@Query(
"""SELECT tags.* FROM tags
LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id
WHERE tags.source = :source
WHERE tags.source = :source
GROUP BY tags.title
ORDER BY COUNT(manga_id) DESC
LIMIT :limit""",
@@ -31,7 +34,7 @@ abstract class TagsDao {
@Query(
"""SELECT tags.* FROM tags
LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id
WHERE tags.source = :source AND title LIKE :query
WHERE tags.source = :source AND title LIKE :query
GROUP BY tags.title
ORDER BY COUNT(manga_id) DESC
LIMIT :limit""",
@@ -41,7 +44,7 @@ abstract class TagsDao {
@Query(
"""SELECT tags.* FROM tags
LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id
WHERE title LIKE :query
WHERE title LIKE :query AND manga_tags.manga_id IN (SELECT manga_id FROM history UNION SELECT manga_id FROM favourites)
GROUP BY tags.title
ORDER BY COUNT(manga_id) DESC
LIMIT :limit""",

View File

@@ -0,0 +1,16 @@
package org.koitharu.kotatsu.core.db.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(
tableName = "sources",
)
data class MangaSourceEntity(
@PrimaryKey(autoGenerate = false)
@ColumnInfo(name = "source")
val source: String,
@ColumnInfo(name = "enabled") val isEnabled: Boolean,
@ColumnInfo(name = "sort_key", index = true) val sortKey: Int,
)

View File

@@ -21,9 +21,7 @@ class MangaWithTags(
other as MangaWithTags
if (manga != other.manga) return false
if (tags != other.tags) return false
return true
return tags == other.tags
}
override fun hashCode(): Int {
@@ -31,4 +29,4 @@ class MangaWithTags(
result = 31 * result + tags.hashCode()
return result
}
}
}

View File

@@ -9,5 +9,7 @@ class Migration13To14 : Migration(13, 14) {
database.execSQL("ALTER TABLE favourite_categories ADD COLUMN `deleted_at` INTEGER NOT NULL DEFAULT 0")
database.execSQL("ALTER TABLE favourites ADD COLUMN `deleted_at` INTEGER NOT NULL DEFAULT 0")
database.execSQL("ALTER TABLE history ADD COLUMN `deleted_at` INTEGER NOT NULL DEFAULT 0")
database.execSQL("ALTER TABLE preferences ADD COLUMN `cf_brightness` REAL NOT NULL DEFAULT 0")
database.execSQL("ALTER TABLE preferences ADD COLUMN `cf_contrast` REAL NOT NULL DEFAULT 0")
}
}

View File

@@ -5,8 +5,5 @@ import androidx.sqlite.db.SupportSQLiteDatabase
class Migration14To15 : Migration(14, 15) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE preferences ADD COLUMN `cf_brightness` REAL NOT NULL DEFAULT 0")
database.execSQL("ALTER TABLE preferences ADD COLUMN `cf_contrast` REAL NOT NULL DEFAULT 0")
}
override fun migrate(database: SupportSQLiteDatabase) = Unit
}

View File

@@ -0,0 +1,36 @@
package org.koitharu.kotatsu.core.db.migrations
import android.content.Context
import androidx.preference.PreferenceManager
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import org.koitharu.kotatsu.parsers.model.MangaSource
class Migration16To17(context: Context) : Migration(16, 17) {
private val prefs = PreferenceManager.getDefaultSharedPreferences(context)
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("CREATE TABLE `sources` (`source` TEXT NOT NULL, `enabled` INTEGER NOT NULL, `sort_key` INTEGER NOT NULL, PRIMARY KEY(`source`))")
database.execSQL("CREATE INDEX `index_sources_sort_key` ON `sources` (`sort_key`)")
val hiddenSources = prefs.getStringSet("sources_hidden", null).orEmpty()
val order = prefs.getString("sources_order_2", null)?.split('|').orEmpty()
val sources = MangaSource.values()
for (source in sources) {
if (source == MangaSource.LOCAL) {
continue
}
val name = source.name
var sortKey = order.indexOf(name)
if (sortKey == -1) {
sortKey = order.size + source.ordinal
}
database.execSQL(
"INSERT INTO `sources` (`source`, `enabled`, `sort_key`) VALUES (?, ?, ?)",
arrayOf(name, (name !in hiddenSources).toInt(), sortKey),
)
}
}
private fun Boolean.toInt() = if (this) 1 else 0
}

View File

@@ -2,8 +2,10 @@ package org.koitharu.kotatsu.core.exceptions
import okhttp3.Headers
import okio.IOException
import org.koitharu.kotatsu.parsers.model.MangaSource
class CloudFlareProtectedException(
val url: String,
val source: MangaSource?,
@Transient val headers: Headers,
) : IOException("Protected by CloudFlare")

View File

@@ -40,9 +40,7 @@ class VersionId(
if (minor != other.minor) return false
if (build != other.build) return false
if (variantType != other.variantType) return false
if (variantNumber != other.variantNumber) return false
return true
return variantNumber == other.variantNumber
}
override fun hashCode(): Int {

View File

@@ -2,8 +2,10 @@ package org.koitharu.kotatsu.core.model
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.model.SortOrder
import java.util.*
import java.util.Date
@Parcelize
data class FavouriteCategory(
@@ -14,4 +16,20 @@ data class FavouriteCategory(
val createdAt: Date,
val isTrackingEnabled: Boolean,
val isVisibleInLibrary: Boolean,
) : Parcelable
) : Parcelable, ListModel {
override fun areItemsTheSame(other: ListModel): Boolean {
return other is FavouriteCategory && id == other.id
}
override fun getChangePayload(previousState: ListModel): Any? {
if (previousState !is FavouriteCategory) {
return null
}
return if (isTrackingEnabled != previousState.isTrackingEnabled || isVisibleInLibrary != previousState.isVisibleInLibrary) {
ListModelDiffCallback.PAYLOAD_CHECKED_CHANGED
} else {
null
}
}
}

View File

@@ -1,98 +0,0 @@
package org.koitharu.kotatsu.core.model.parcelable
import android.os.Parcel
import androidx.core.os.ParcelCompat
import org.koitharu.kotatsu.core.util.ext.readParcelableCompat
import org.koitharu.kotatsu.core.util.ext.readSerializableCompat
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaTag
fun Manga.writeToParcel(out: Parcel, flags: Int, withChapters: Boolean) {
out.writeLong(id)
out.writeString(title)
out.writeString(altTitle)
out.writeString(url)
out.writeString(publicUrl)
out.writeFloat(rating)
ParcelCompat.writeBoolean(out, isNsfw)
out.writeString(coverUrl)
out.writeString(largeCoverUrl)
out.writeString(description)
out.writeParcelable(ParcelableMangaTags(tags), flags)
out.writeSerializable(state)
out.writeString(author)
if (withChapters) {
out.writeParcelable(chapters?.let(::ParcelableMangaChapters), flags)
} else {
out.writeString(null)
}
out.writeSerializable(source)
}
fun Parcel.readManga() = Manga(
id = readLong(),
title = requireNotNull(readString()),
altTitle = readString(),
url = requireNotNull(readString()),
publicUrl = requireNotNull(readString()),
rating = readFloat(),
isNsfw = ParcelCompat.readBoolean(this),
coverUrl = requireNotNull(readString()),
largeCoverUrl = readString(),
description = readString(),
tags = requireNotNull(readParcelableCompat<ParcelableMangaTags>()).tags,
state = readSerializableCompat(),
author = readString(),
chapters = readParcelableCompat<ParcelableMangaChapters>()?.chapters,
source = checkNotNull(readSerializableCompat()),
)
fun MangaPage.writeToParcel(out: Parcel) {
out.writeLong(id)
out.writeString(url)
out.writeString(preview)
out.writeSerializable(source)
}
fun Parcel.readMangaPage() = MangaPage(
id = readLong(),
url = requireNotNull(readString()),
preview = readString(),
source = checkNotNull(readSerializableCompat()),
)
fun MangaChapter.writeToParcel(out: Parcel) {
out.writeLong(id)
out.writeString(name)
out.writeInt(number)
out.writeString(url)
out.writeString(scanlator)
out.writeLong(uploadDate)
out.writeString(branch)
out.writeSerializable(source)
}
fun Parcel.readMangaChapter() = MangaChapter(
id = readLong(),
name = requireNotNull(readString()),
number = readInt(),
url = requireNotNull(readString()),
scanlator = readString(),
uploadDate = readLong(),
branch = readString(),
source = checkNotNull(readSerializableCompat()),
)
fun MangaTag.writeToParcel(out: Parcel) {
out.writeString(title)
out.writeString(key)
out.writeSerializable(source)
}
fun Parcel.readMangaTag() = MangaTag(
title = requireNotNull(readString()),
key = requireNotNull(readString()),
source = checkNotNull(readSerializableCompat()),
)

View File

@@ -2,56 +2,83 @@ package org.koitharu.kotatsu.core.model.parcelable
import android.os.Parcel
import android.os.Parcelable
import androidx.core.os.ParcelCompat
import kotlinx.parcelize.Parceler
import kotlinx.parcelize.Parcelize
import org.koitharu.kotatsu.core.util.ext.readParcelableCompat
import org.koitharu.kotatsu.core.util.ext.readSerializableCompat
import org.koitharu.kotatsu.parsers.model.Manga
// Limits to avoid TransactionTooLargeException
private const val MAX_SAFE_SIZE = 1024 * 100 // Assume that 100 kb is safe parcel size
private const val MAX_SAFE_CHAPTERS_COUNT = 24 // this is 100% safe
class ParcelableManga(
@Parcelize
data class ParcelableManga(
val manga: Manga,
private val withChapters: Boolean,
) : Parcelable {
constructor(parcel: Parcel) : this(parcel.readManga(), true)
override fun writeToParcel(parcel: Parcel, flags: Int) {
val chapters = manga.chapters
if (!withChapters || chapters == null) {
manga.writeToParcel(parcel, flags, withChapters = false)
return
}
if (chapters.size <= MAX_SAFE_CHAPTERS_COUNT) {
// fast path
manga.writeToParcel(parcel, flags, withChapters = true)
return
}
val tempParcel = Parcel.obtain()
manga.writeToParcel(tempParcel, flags, withChapters = true)
val size = tempParcel.dataSize()
if (size < MAX_SAFE_SIZE) {
parcel.appendFrom(tempParcel, 0, size)
} else {
manga.writeToParcel(parcel, flags, withChapters = false)
}
tempParcel.recycle()
}
override fun describeContents(): Int {
return 0
}
override fun toString(): String {
return "ParcelableManga(manga=$manga, withChapters=$withChapters)"
}
companion object CREATOR : Parcelable.Creator<ParcelableManga> {
override fun createFromParcel(parcel: Parcel): ParcelableManga {
return ParcelableManga(parcel)
companion object : Parceler<ParcelableManga> {
private fun Manga.writeToParcel(out: Parcel, flags: Int, withChapters: Boolean) {
out.writeLong(id)
out.writeString(title)
out.writeString(altTitle)
out.writeString(url)
out.writeString(publicUrl)
out.writeFloat(rating)
ParcelCompat.writeBoolean(out, isNsfw)
out.writeString(coverUrl)
out.writeString(largeCoverUrl)
out.writeString(description)
out.writeParcelable(ParcelableMangaTags(tags), flags)
out.writeSerializable(state)
out.writeString(author)
val parcelableChapters = if (withChapters) null else chapters?.let(::ParcelableMangaChapters)
out.writeParcelable(parcelableChapters, flags)
out.writeSerializable(source)
}
override fun newArray(size: Int): Array<ParcelableManga?> {
return arrayOfNulls(size)
override fun ParcelableManga.write(parcel: Parcel, flags: Int) {
val chapters = manga.chapters
if (!withChapters || chapters == null) {
manga.writeToParcel(parcel, flags, withChapters = false)
return
}
if (chapters.size <= MAX_SAFE_CHAPTERS_COUNT) {
// fast path
manga.writeToParcel(parcel, flags, withChapters = true)
return
}
val tempParcel = Parcel.obtain()
manga.writeToParcel(tempParcel, flags, withChapters = true)
val size = tempParcel.dataSize()
if (size < MAX_SAFE_SIZE) {
parcel.appendFrom(tempParcel, 0, size)
} else {
manga.writeToParcel(parcel, flags, withChapters = false)
}
tempParcel.recycle()
}
override fun create(parcel: Parcel) = ParcelableManga(
Manga(
id = parcel.readLong(),
title = requireNotNull(parcel.readString()),
altTitle = parcel.readString(),
url = requireNotNull(parcel.readString()),
publicUrl = requireNotNull(parcel.readString()),
rating = parcel.readFloat(),
isNsfw = ParcelCompat.readBoolean(parcel),
coverUrl = requireNotNull(parcel.readString()),
largeCoverUrl = parcel.readString(),
description = parcel.readString(),
tags = requireNotNull(parcel.readParcelableCompat<ParcelableMangaTags>()).tags,
state = parcel.readSerializableCompat(),
author = parcel.readString(),
chapters = parcel.readParcelableCompat<ParcelableMangaChapters>()?.chapters,
source = requireNotNull(parcel.readSerializableCompat()),
),
withChapters = true
)
}
}

View File

@@ -2,38 +2,36 @@ package org.koitharu.kotatsu.core.model.parcelable
import android.os.Parcel
import android.os.Parcelable
import kotlinx.parcelize.Parceler
import kotlinx.parcelize.Parcelize
import kotlinx.parcelize.TypeParceler
import org.koitharu.kotatsu.core.util.ext.readSerializableCompat
import org.koitharu.kotatsu.parsers.model.MangaChapter
class ParcelableMangaChapters(
val chapters: List<MangaChapter>,
) : Parcelable {
constructor(parcel: Parcel) : this(
List(parcel.readInt()) { parcel.readMangaChapter() }
object MangaChapterParceler : Parceler<MangaChapter> {
override fun create(parcel: Parcel) = MangaChapter(
id = parcel.readLong(),
name = requireNotNull(parcel.readString()),
number = parcel.readInt(),
url = requireNotNull(parcel.readString()),
scanlator = parcel.readString(),
uploadDate = parcel.readLong(),
branch = parcel.readString(),
source = requireNotNull(parcel.readSerializableCompat()),
)
override fun writeToParcel(parcel: Parcel, flags: Int) {
parcel.writeInt(chapters.size)
for (chapter in chapters) {
chapter.writeToParcel(parcel)
}
}
override fun describeContents(): Int {
return 0
}
override fun toString(): String {
return "ParcelableMangaChapters(chapters=$chapters)"
}
companion object CREATOR : Parcelable.Creator<ParcelableMangaChapters> {
override fun createFromParcel(parcel: Parcel): ParcelableMangaChapters {
return ParcelableMangaChapters(parcel)
}
override fun newArray(size: Int): Array<ParcelableMangaChapters?> {
return arrayOfNulls(size)
}
override fun MangaChapter.write(parcel: Parcel, flags: Int) {
parcel.writeLong(id)
parcel.writeString(name)
parcel.writeInt(number)
parcel.writeString(url)
parcel.writeString(scanlator)
parcel.writeLong(uploadDate)
parcel.writeString(branch)
parcel.writeSerializable(source)
}
}
@Parcelize
@TypeParceler<MangaChapter, MangaChapterParceler>
data class ParcelableMangaChapters(val chapters: List<MangaChapter>) : Parcelable

View File

@@ -0,0 +1,29 @@
package org.koitharu.kotatsu.core.model.parcelable
import android.os.Parcel
import android.os.Parcelable
import kotlinx.parcelize.Parceler
import kotlinx.parcelize.Parcelize
import kotlinx.parcelize.TypeParceler
import org.koitharu.kotatsu.core.util.ext.readSerializableCompat
import org.koitharu.kotatsu.parsers.model.MangaPage
object MangaPageParceler : Parceler<MangaPage> {
override fun create(parcel: Parcel) = MangaPage(
id = parcel.readLong(),
url = requireNotNull(parcel.readString()),
preview = parcel.readString(),
source = requireNotNull(parcel.readSerializableCompat()),
)
override fun MangaPage.write(parcel: Parcel, flags: Int) {
parcel.writeLong(id)
parcel.writeString(url)
parcel.writeString(preview)
parcel.writeSerializable(source)
}
}
@Parcelize
@TypeParceler<MangaPage, MangaPageParceler>
class ParcelableMangaPage(val page: MangaPage) : Parcelable

View File

@@ -1,39 +0,0 @@
package org.koitharu.kotatsu.core.model.parcelable
import android.os.Parcel
import android.os.Parcelable
import org.koitharu.kotatsu.parsers.model.MangaPage
class ParcelableMangaPages(
val pages: List<MangaPage>,
) : Parcelable {
constructor(parcel: Parcel) : this(
List(parcel.readInt()) { parcel.readMangaPage() }
)
override fun writeToParcel(parcel: Parcel, flags: Int) {
parcel.writeInt(pages.size)
for (page in pages) {
page.writeToParcel(parcel)
}
}
override fun describeContents(): Int {
return 0
}
override fun toString(): String {
return "ParcelableMangaPages(pages=$pages)"
}
companion object CREATOR : Parcelable.Creator<ParcelableMangaPages> {
override fun createFromParcel(parcel: Parcel): ParcelableMangaPages {
return ParcelableMangaPages(parcel)
}
override fun newArray(size: Int): Array<ParcelableMangaPages?> {
return arrayOfNulls(size)
}
}
}

View File

@@ -2,39 +2,26 @@ package org.koitharu.kotatsu.core.model.parcelable
import android.os.Parcel
import android.os.Parcelable
import org.koitharu.kotatsu.core.util.ext.Set
import kotlinx.parcelize.Parceler
import kotlinx.parcelize.Parcelize
import kotlinx.parcelize.TypeParceler
import org.koitharu.kotatsu.core.util.ext.readSerializableCompat
import org.koitharu.kotatsu.parsers.model.MangaTag
class ParcelableMangaTags(
val tags: Set<MangaTag>,
) : Parcelable {
constructor(parcel: Parcel) : this(
Set(parcel.readInt()) { parcel.readMangaTag() },
object MangaTagParceler : Parceler<MangaTag> {
override fun create(parcel: Parcel) = MangaTag(
title = requireNotNull(parcel.readString()),
key = requireNotNull(parcel.readString()),
source = requireNotNull(parcel.readSerializableCompat()),
)
override fun writeToParcel(parcel: Parcel, flags: Int) {
parcel.writeInt(tags.size)
for (tag in tags) {
tag.writeToParcel(parcel)
}
}
override fun describeContents(): Int {
return 0
}
override fun toString(): String {
return "ParcelableMangaTags(tags=$tags)"
}
companion object CREATOR : Parcelable.Creator<ParcelableMangaTags> {
override fun createFromParcel(parcel: Parcel): ParcelableMangaTags {
return ParcelableMangaTags(parcel)
}
override fun newArray(size: Int): Array<ParcelableMangaTags?> {
return arrayOfNulls(size)
}
override fun MangaTag.write(parcel: Parcel, flags: Int) {
parcel.writeString(title)
parcel.writeString(key)
parcel.writeSerializable(source)
}
}
@Parcelize
@TypeParceler<MangaTag, MangaTagParceler>
data class ParcelableMangaTags(val tags: Set<MangaTag>) : Parcelable

View File

@@ -4,6 +4,7 @@ import okhttp3.Interceptor
import okhttp3.Response
import okhttp3.internal.closeQuietly
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
import org.koitharu.kotatsu.parsers.model.MangaSource
import java.net.HttpURLConnection.HTTP_FORBIDDEN
import java.net.HttpURLConnection.HTTP_UNAVAILABLE
@@ -20,6 +21,7 @@ class CloudFlareInterceptor : Interceptor {
response.closeQuietly()
throw CloudFlareProtectedException(
url = request.url.toString(),
source = request.tag(MangaSource::class.java),
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

@@ -73,9 +73,7 @@ class CookieWrapper(
other as CookieWrapper
if (cookie != other.cookie) return false
return true
return cookie == other.cookie
}
override fun hashCode(): Int {

View File

@@ -1,12 +1,9 @@
package org.koitharu.kotatsu.core.os
import android.app.ActivityManager
import android.content.Context
import android.content.SharedPreferences
import android.content.pm.ShortcutManager
import android.os.Build
import android.util.Size
import androidx.annotation.RequiresApi
import androidx.annotation.VisibleForTesting
import androidx.core.content.pm.ShortcutInfoCompat
import androidx.core.content.pm.ShortcutManagerCompat
@@ -16,6 +13,7 @@ import androidx.room.InvalidationTracker
import coil.ImageLoader
import coil.request.ImageRequest
import coil.size.Scale
import coil.size.Size
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -28,6 +26,7 @@ import org.koitharu.kotatsu.core.ui.image.ThumbnailTransformation
import org.koitharu.kotatsu.core.util.ext.getDrawableOrThrow
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
import org.koitharu.kotatsu.core.util.ext.source
import org.koitharu.kotatsu.history.data.HistoryRepository
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
@@ -44,7 +43,9 @@ class AppShortcutManager @Inject constructor(
private val settings: AppSettings,
) : InvalidationTracker.Observer(TABLE_HISTORY), SharedPreferences.OnSharedPreferenceChangeListener {
private val iconSize by lazy { getIconSize(context) }
private val iconSize by lazy {
Size(ShortcutManagerCompat.getIconMaxWidth(context), ShortcutManagerCompat.getIconMaxHeight(context))
}
private var shortcutsUpdateJob: Job? = null
init {
@@ -52,7 +53,7 @@ class AppShortcutManager @Inject constructor(
}
override fun onInvalidated(tables: Set<String>) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N_MR1 || !settings.isDynamicShortcutsEnabled) {
if (!settings.isDynamicShortcutsEnabled) {
return
}
val prevJob = shortcutsUpdateJob
@@ -63,7 +64,7 @@ class AppShortcutManager @Inject constructor(
}
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1 && key == AppSettings.KEY_SHORTCUTS) {
if (key == AppSettings.KEY_SHORTCUTS) {
if (settings.isDynamicShortcutsEnabled) {
onInvalidated(emptySet())
} else {
@@ -73,11 +74,7 @@ class AppShortcutManager @Inject constructor(
}
suspend fun requestPinShortcut(manga: Manga): Boolean {
return ShortcutManagerCompat.requestPinShortcut(
context,
buildShortcutInfo(manga).build(),
null,
)
return ShortcutManagerCompat.requestPinShortcut(context, buildShortcutInfo(manga), null)
}
@VisibleForTesting
@@ -85,49 +82,34 @@ class AppShortcutManager @Inject constructor(
return shortcutsUpdateJob?.join() != null
}
fun isDynamicShortcutsAvailable(): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N_MR1) {
return false
}
val manager = context.getSystemService(Context.SHORTCUT_SERVICE) as ShortcutManager
return manager.maxShortcutCountPerActivity > 0
}
fun notifyMangaOpened(mangaId: Long) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N_MR1) {
return
}
val manager = context.getSystemService(Context.SHORTCUT_SERVICE) as ShortcutManager
manager.reportShortcutUsed(mangaId.toString())
ShortcutManagerCompat.reportShortcutUsed(context, mangaId.toString())
}
@RequiresApi(Build.VERSION_CODES.N_MR1)
private suspend fun updateShortcutsImpl() = runCatchingCancellable {
val manager = context.getSystemService(Context.SHORTCUT_SERVICE) as ShortcutManager
val shortcuts = historyRepository.getList(0, manager.maxShortcutCountPerActivity)
val maxShortcuts = ShortcutManagerCompat.getMaxShortcutCountPerActivity(context).coerceAtLeast(5)
val shortcuts = historyRepository.getList(0, maxShortcuts)
.filter { x -> x.title.isNotEmpty() }
.map { buildShortcutInfo(it).build().toShortcutInfo() }
manager.dynamicShortcuts = shortcuts
.map { buildShortcutInfo(it) }
ShortcutManagerCompat.setDynamicShortcuts(context, shortcuts)
}.onFailure {
it.printStackTraceDebug()
}
@RequiresApi(Build.VERSION_CODES.N_MR1)
private fun clearShortcuts() {
val manager = context.getSystemService(Context.SHORTCUT_SERVICE) as ShortcutManager
try {
manager.removeAllDynamicShortcuts()
ShortcutManagerCompat.removeAllDynamicShortcuts(context)
} catch (_: IllegalStateException) {
}
}
private suspend fun buildShortcutInfo(manga: Manga): ShortcutInfoCompat.Builder {
private suspend fun buildShortcutInfo(manga: Manga): ShortcutInfoCompat {
val icon = runCatchingCancellable {
coil.execute(
ImageRequest.Builder(context)
.data(manga.coverUrl)
.size(iconSize.width, iconSize.height)
.tag(manga.source)
.size(iconSize)
.source(manga.source)
.scale(Scale.FILL)
.transformations(ThumbnailTransformation())
.build(),
@@ -141,22 +123,17 @@ class AppShortcutManager @Inject constructor(
.setShortLabel(manga.title)
.setLongLabel(manga.title)
.setIcon(icon)
.setLongLived(true)
.setIntent(
ReaderActivity.IntentBuilder(context)
.mangaId(manga.id)
.build(),
)
.build()
}
private fun getIconSize(context: Context): Size {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) {
(context.getSystemService(Context.SHORTCUT_SERVICE) as ShortcutManager).let {
Size(it.iconMaxWidth, it.iconMaxHeight)
}
} else {
(context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager).launcherLargeIconSize.let {
Size(it, it)
}
}
fun isDynamicShortcutsAvailable(): Boolean {
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1 &&
context.getSystemService(ShortcutManager::class.java).maxShortcutCountPerActivity > 0
}
}

View File

@@ -3,7 +3,6 @@ package org.koitharu.kotatsu.core.parser
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaParser
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.newParser
fun MangaParser(source: MangaSource, loaderContext: MangaLoaderContext): MangaParser {
return if (source == MangaSource.DUMMY) {

View File

@@ -36,6 +36,8 @@ interface MangaRepository {
suspend fun getTags(): Set<MangaTag>
suspend fun getRelated(seed: Manga): List<Manga>
@Singleton
class Factory @Inject constructor(
private val localMangaRepository: LocalMangaRepository,

View File

@@ -51,7 +51,7 @@ class RemoteMangaRepository(
getConfig()[parser.configKeyDomain] = value
}
val headers: Headers?
val headers: Headers
get() = parser.headers
override fun intercept(chain: Interceptor.Chain): Response {
@@ -94,6 +94,15 @@ class RemoteMangaRepository(
suspend fun getFavicons(): Favicons = parser.getFavicons()
override suspend fun getRelated(seed: Manga): List<Manga> {
cache.getRelatedManga(source, seed.url)?.let { return it }
val related = asyncSafe {
parser.getRelatedManga(seed).filterNot { it.id == seed.id }
}
cache.putRelatedManga(source, seed.url, related)
return related.await()
}
fun getAuthProvider(): MangaParserAuthProvider? = parser as? MangaParserAuthProvider
fun getConfigKeys(): List<ConfigKey<*>> = ArrayList<ConfigKey<*>>().also {

View File

@@ -8,30 +8,27 @@ import android.os.Build
import android.provider.Settings
import androidx.annotation.FloatRange
import androidx.appcompat.app.AppCompatDelegate
import androidx.collection.ArraySet
import androidx.collection.arraySetOf
import androidx.core.content.edit
import androidx.core.os.LocaleListCompat
import androidx.preference.PreferenceManager
import dagger.hilt.android.qualifiers.ApplicationContext
import org.koitharu.kotatsu.BuildConfig
import org.json.JSONArray
import org.koitharu.kotatsu.core.model.ZoomMode
import org.koitharu.kotatsu.core.network.DoHProvider
import org.koitharu.kotatsu.core.util.ext.connectivityManager
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.history.domain.model.HistoryOrder
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
import java.net.Proxy
import java.util.Collections
import java.util.EnumSet
import java.util.Locale
import javax.inject.Inject
import javax.inject.Singleton
@@ -42,32 +39,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
private val prefs = PreferenceManager.getDefaultSharedPreferences(context)
private val connectivityManager = context.connectivityManager
private val remoteSources = EnumSet.allOf(MangaSource::class.java).apply {
remove(MangaSource.LOCAL)
if (!BuildConfig.DEBUG) {
remove(MangaSource.DUMMY)
}
}
val remoteMangaSources: Set<MangaSource>
get() = Collections.unmodifiableSet(remoteSources)
var shelfSections: List<ShelfSection>
get() {
val raw = prefs.getString(KEY_SHELF_SECTIONS, null)
val values = enumValues<ShelfSection>()
if (raw.isNullOrEmpty()) {
return values.toList()
}
return raw.split('|')
.mapNotNull { values.getOrNull(it.toIntOrNull() ?: -1) }
.distinct()
}
set(value) {
val raw = value.joinToString("|") { it.ordinal.toString() }
prefs.edit { putString(KEY_SHELF_SECTIONS, raw) }
}
var listMode: ListMode
get() = prefs.getEnumValue(KEY_LIST_MODE, ListMode.GRID)
set(value) = prefs.edit { putEnumValue(KEY_LIST_MODE, value) }
@@ -113,6 +84,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val isTrackerEnabled: Boolean
get() = prefs.getBoolean(KEY_TRACKER_ENABLED, true)
val isTrackerWifiOnly: Boolean
get() = prefs.getBoolean(KEY_TRACKER_WIFI_ONLY, false)
val isTrackerNotificationsEnabled: Boolean
get() = prefs.getBoolean(KEY_TRACKER_NOTIFICATIONS, true)
@@ -130,6 +104,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val readerAnimation: Boolean
get() = prefs.getBoolean(KEY_READER_ANIMATION, false)
val readerBackground: ReaderBackground
get() = prefs.getEnumValue(KEY_READER_BACKGROUND, ReaderBackground.DEFAULT)
val defaultReaderMode: ReaderMode
get() = prefs.getEnumValue(KEY_READER_MODE, ReaderMode.STANDARD)
@@ -192,37 +169,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
return policy.isNetworkAllowed(connectivityManager)
}
var sourcesOrder: List<String>
get() = prefs.getString(KEY_SOURCES_ORDER, null)
?.split('|')
.orEmpty()
set(value) = prefs.edit {
putString(KEY_SOURCES_ORDER, value.joinToString("|"))
}
var hiddenSources: Set<String>
get() = prefs.getStringSet(KEY_SOURCES_HIDDEN, null)?.filterToSet { name ->
remoteSources.any { it.name == name }
}.orEmpty()
set(value) = prefs.edit { putStringSet(KEY_SOURCES_HIDDEN, value) }
val isSourcesSelected: Boolean
get() = KEY_SOURCES_HIDDEN in prefs
val newSources: Set<MangaSource>
get() {
val known = sourcesOrder.toSet()
val hidden = hiddenSources
return remoteMangaSources
.filterNotTo(EnumSet.noneOf(MangaSource::class.java)) { x ->
x.name in known || x.name in hidden
}
}
fun markKnownSources(sources: Collection<MangaSource>) {
sourcesOrder = (sourcesOrder + sources.map { it.name }).distinct()
}
var isSourcesGridMode: Boolean
get() = prefs.getBoolean(KEY_SOURCES_GRID, false)
set(value) = prefs.edit { putBoolean(KEY_SOURCES_GRID, value) }
@@ -272,11 +218,14 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
get() = prefs.getBoolean(KEY_SUGGESTIONS, false)
set(value) = prefs.edit { putBoolean(KEY_SUGGESTIONS, value) }
val isSuggestionsWiFiOnly: Boolean
get() = prefs.getBoolean(KEY_SUGGESTIONS_WIFI_ONLY, false)
val isSuggestionsExcludeNsfw: Boolean
get() = prefs.getBoolean(KEY_SUGGESTIONS_EXCLUDE_NSFW, false)
val isSuggestionsNotificationAvailable: Boolean
get() = prefs.getBoolean(KEY_SUGGESTIONS_NOTIFICATIONS, true)
get() = prefs.getBoolean(KEY_SUGGESTIONS_NOTIFICATIONS, false)
val suggestionsTagsBlacklist: Set<String>
get() {
@@ -324,6 +273,10 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
get() = prefs.getEnumValue(KEY_LOCAL_LIST_ORDER, SortOrder.NEWEST)
set(value) = prefs.edit { putEnumValue(KEY_LOCAL_LIST_ORDER, value) }
var historySortOrder: HistoryOrder
get() = prefs.getEnumValue(KEY_HISTORY_ORDER, HistoryOrder.UPDATED)
set(value) = prefs.edit { putEnumValue(KEY_HISTORY_ORDER, value) }
val isWebtoonZoomEnable: Boolean
get() = prefs.getBoolean(KEY_WEBTOON_ZOOM, true)
@@ -341,20 +294,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
return policy.isNetworkAllowed(connectivityManager)
}
fun getMangaSources(includeHidden: Boolean): List<MangaSource> {
val list = remoteSources.toMutableList()
val order = sourcesOrder
list.sortBy { x ->
val e = order.indexOf(x.name)
if (e == -1) order.size + x.ordinal else e
}
if (!includeHidden) {
val hidden = hiddenSources
list.removeAll { x -> x.name in hidden }
}
return list
}
fun isTipEnabled(tip: String): Boolean {
return prefs.getStringSet(KEY_TIPS_CLOSED, emptySet())?.contains(tip) != true
}
@@ -377,6 +316,23 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
fun observe() = prefs.observe()
fun getAllValues(): Map<String, *> = prefs.all
fun upsertAll(m: Map<String, *>) {
prefs.edit {
m.forEach { e ->
when (val v = e.value) {
is Boolean -> putBoolean(e.key, v)
is Int -> putInt(e.key, v)
is Long -> putLong(e.key, v)
is Float -> putFloat(e.key, v)
is String -> putString(e.key, v)
is JSONArray -> putStringSet(e.key, v.toStringSet())
}
}
}
}
private fun isBackgroundNetworkRestricted(): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
connectivityManager.restrictBackgroundStatus == ConnectivityManager.RESTRICT_BACKGROUND_STATUS_ENABLED
@@ -385,6 +341,15 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
}
}
private fun JSONArray.toStringSet(): Set<String> {
val len = length()
val result = ArraySet<String>(len)
for (i in 0 until len) {
result.add(getString(i))
}
return result
}
companion object {
const val PAGE_SWITCH_TAPS = "taps"
@@ -397,8 +362,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_THEME = "theme"
const val KEY_COLOR_THEME = "color_theme"
const val KEY_THEME_AMOLED = "amoled_theme"
const val KEY_SOURCES_ORDER = "sources_order_2"
const val KEY_SOURCES_HIDDEN = "sources_hidden"
const val KEY_TRAFFIC_WARNING = "traffic_warning"
const val KEY_PAGES_CACHE_CLEAR = "pages_cache_clear"
const val KEY_HTTP_CACHE_CLEAR = "http_cache_clear"
@@ -411,6 +374,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_LOCAL_STORAGE = "local_storage"
const val KEY_READER_SWITCHERS = "reader_switchers"
const val KEY_TRACKER_ENABLED = "tracker_enabled"
const val KEY_TRACKER_WIFI_ONLY = "tracker_wifi"
const val KEY_TRACK_SOURCES = "track_sources"
const val KEY_TRACK_CATEGORIES = "track_categories"
const val KEY_TRACK_WARNING = "track_warning"
@@ -438,6 +402,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_SCREENSHOTS_POLICY = "screenshots_policy"
const val KEY_PAGES_PRELOAD = "pages_preload"
const val KEY_SUGGESTIONS = "suggestions"
const val KEY_SUGGESTIONS_WIFI_ONLY = "suggestions_wifi"
const val KEY_SUGGESTIONS_EXCLUDE_NSFW = "suggestions_exclude_nsfw"
const val KEY_SUGGESTIONS_EXCLUDE_TAGS = "suggestions_exclude_tags"
const val KEY_SUGGESTIONS_NOTIFICATIONS = "suggestions_notifications"
@@ -454,11 +419,12 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_SYNC_SETTINGS = "sync_settings"
const val KEY_READER_BAR = "reader_bar"
const val KEY_READER_SLIDER = "reader_slider"
const val KEY_READER_BACKGROUND = "reader_background"
const val KEY_SHORTCUTS = "dynamic_shortcuts"
const val KEY_READER_TAPS_LTR = "reader_taps_ltr"
const val KEY_LOCAL_LIST_ORDER = "local_order"
const val KEY_HISTORY_ORDER = "history_order"
const val KEY_WEBTOON_ZOOM = "webtoon_zoom"
const val KEY_SHELF_SECTIONS = "shelf_sections_2"
const val KEY_PREFETCH_CONTENT = "prefetch_content"
const val KEY_APP_LOCALE = "app_locale"
const val KEY_LOGGING_ENABLED = "logging"

View File

@@ -0,0 +1,25 @@
package org.koitharu.kotatsu.core.prefs
import android.content.Context
import android.view.ContextThemeWrapper
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.toDrawable
import org.koitharu.kotatsu.core.util.ext.getThemeDrawable
import com.google.android.material.R as materialR
enum class ReaderBackground {
DEFAULT, LIGHT, DARK, WHITE, BLACK;
fun resolve(context: Context) = when (this) {
DEFAULT -> context.getThemeDrawable(android.R.attr.windowBackground)
LIGHT -> ContextThemeWrapper(context, materialR.style.ThemeOverlay_Material3_Light)
.getThemeDrawable(android.R.attr.windowBackground)
DARK -> ContextThemeWrapper(context, materialR.style.ThemeOverlay_Material3_Dark)
.getThemeDrawable(android.R.attr.windowBackground)
WHITE -> ContextCompat.getColor(context, android.R.color.white).toDrawable()
BLACK -> ContextCompat.getColor(context, android.R.color.black).toDrawable()
}
}

View File

@@ -1,106 +0,0 @@
package org.koitharu.kotatsu.core.ui
import android.app.Dialog
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.ViewGroup.LayoutParams
import androidx.activity.OnBackPressedDispatcher
import androidx.core.view.updateLayoutParams
import androidx.viewbinding.ViewBinding
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialog
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.dialog.AppBottomSheetDialog
import org.koitharu.kotatsu.core.util.ext.findActivity
import org.koitharu.kotatsu.core.util.ext.getDisplaySize
import com.google.android.material.R as materialR
@Deprecated(
"Use BaseAdaptiveSheet",
replaceWith = ReplaceWith("BaseAdaptiveSheet<B>", "org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet"),
)
abstract class BaseBottomSheet<B : ViewBinding> : BottomSheetDialogFragment() {
var viewBinding: B? = null
private set
@Deprecated("", ReplaceWith("requireViewBinding()"))
protected val binding: B
get() = requireViewBinding()
protected val behavior: BottomSheetBehavior<*>?
get() = (dialog as? BottomSheetDialog)?.behavior
val isExpanded: Boolean
get() = behavior?.state == BottomSheetBehavior.STATE_EXPANDED
val onBackPressedDispatcher: OnBackPressedDispatcher
get() = (requireDialog() as AppBottomSheetDialog).onBackPressedDispatcher
final override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?,
): View {
val binding = onCreateViewBinding(inflater, container)
viewBinding = binding
return binding.root
}
final override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val binding = requireViewBinding()
// Enforce max width for tablets
val width = resources.getDimensionPixelSize(R.dimen.bottom_sheet_width)
if (width > 0) {
behavior?.maxWidth = width
}
// Set peek height to 40% display height
binding.root.context.findActivity()?.getDisplaySize()?.let {
behavior?.peekHeight = (it.height() * 0.4).toInt()
}
onViewBindingCreated(binding, savedInstanceState)
}
override fun onDestroyView() {
viewBinding = null
super.onDestroyView()
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
return AppBottomSheetDialog(requireContext(), theme)
}
fun addBottomSheetCallback(callback: BottomSheetBehavior.BottomSheetCallback) {
val b = behavior ?: return
b.addBottomSheetCallback(callback)
val rootView = dialog?.findViewById<View>(materialR.id.design_bottom_sheet)
if (rootView != null) {
callback.onStateChanged(rootView, b.state)
}
}
protected abstract fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): B
protected open fun onViewBindingCreated(binding: B, savedInstanceState: Bundle?) = Unit
protected fun setExpanded(isExpanded: Boolean, isLocked: Boolean) {
val b = behavior ?: return
if (isExpanded) {
b.state = BottomSheetBehavior.STATE_EXPANDED
}
b.isFitToContents = !isExpanded
val rootView = dialog?.findViewById<View>(materialR.id.design_bottom_sheet)
rootView?.updateLayoutParams {
height = if (isExpanded) LayoutParams.MATCH_PARENT else LayoutParams.WRAP_CONTENT
}
b.isDraggable = !isLocked
}
fun requireViewBinding(): B = checkNotNull(viewBinding) {
"Fragment $this did not return a ViewBinding from onCreateView() or this was called before onCreateView()."
}
}

View File

@@ -0,0 +1,41 @@
package org.koitharu.kotatsu.core.ui
import androidx.recyclerview.widget.AsyncDifferConfig
import androidx.recyclerview.widget.AsyncListDiffer.ListListener
import com.hannesdorfmann.adapterdelegates4.AdapterDelegate
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.asExecutor
import kotlinx.coroutines.flow.FlowCollector
import org.koitharu.kotatsu.core.util.ContinuationResumeRunnable
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
import org.koitharu.kotatsu.list.ui.model.ListModel
import kotlin.coroutines.suspendCoroutine
open class BaseListAdapter<T : ListModel>(
vararg delegates: AdapterDelegate<List<T>>,
) : AsyncListDifferDelegationAdapter<T>(
AsyncDifferConfig.Builder(ListModelDiffCallback<T>())
.setBackgroundThreadExecutor(Dispatchers.Default.limitedParallelism(2).asExecutor())
.build(),
*delegates,
), FlowCollector<List<T>?> {
override suspend fun emit(value: List<T>?) = suspendCoroutine { cont ->
setItems(value.orEmpty(), ContinuationResumeRunnable(cont))
}
fun addDelegate(type: ListItemType, delegate: AdapterDelegate<List<T>>): BaseListAdapter<T> {
delegatesManager.addDelegate(type.ordinal, delegate)
return this
}
fun addListListener(listListener: ListListener<T>) {
differ.addListListener(listListener)
}
fun removeListListener(listListener: ListListener<T>) {
differ.removeListListener(listListener)
}
}

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

@@ -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

@@ -5,45 +5,67 @@ import android.graphics.Canvas
import android.graphics.Color
import android.graphics.ColorFilter
import android.graphics.Paint
import android.graphics.Path
import android.graphics.PixelFormat
import android.graphics.Rect
import android.graphics.RectF
import android.graphics.drawable.Drawable
import androidx.annotation.StyleRes
import androidx.core.content.withStyledAttributes
import androidx.core.graphics.ColorUtils
import androidx.core.graphics.withClip
import com.google.android.material.color.MaterialColors
import org.koitharu.kotatsu.R
import kotlin.math.absoluteValue
class FaviconFallbackDrawable(
class FaviconDrawable(
context: Context,
@StyleRes styleResId: Int,
name: String,
) : Drawable() {
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
private var colorBackground = Color.WHITE
private var colorStroke = Color.LTGRAY
private val letter = name.take(1).uppercase()
private val color = MaterialColors.harmonizeWithPrimary(context, colorOfString(name))
private var cornerSize = 0f
private var colorForeground = Color.DKGRAY
private val textBounds = Rect()
private val tempRect = Rect()
private val boundsF = RectF()
private val clipPath = Path()
init {
paint.style = Paint.Style.FILL
context.withStyledAttributes(styleResId, R.styleable.FaviconFallbackDrawable) {
colorBackground = getColor(R.styleable.FaviconFallbackDrawable_backgroundColor, colorBackground)
colorStroke = getColor(R.styleable.FaviconFallbackDrawable_strokeColor, colorStroke)
cornerSize = getDimension(R.styleable.FaviconFallbackDrawable_cornerSize, cornerSize)
paint.strokeWidth = getDimension(R.styleable.FaviconFallbackDrawable_strokeWidth, 0f) * 2f
}
paint.textAlign = Paint.Align.CENTER
paint.isFakeBoldText = true
colorForeground = MaterialColors.harmonize(colorOfString(name), colorBackground)
}
override fun draw(canvas: Canvas) {
val cx = bounds.exactCenterX()
paint.color = color
canvas.drawPaint(paint)
paint.color = Color.WHITE
val ty = bounds.height() / 2f + textBounds.height() / 2f - textBounds.bottom
canvas.drawText(letter, cx, ty, paint)
if (cornerSize > 0f) {
canvas.withClip(clipPath) {
doDraw(canvas)
}
} else {
doDraw(canvas)
}
}
override fun onBoundsChange(bounds: Rect) {
super.onBoundsChange(bounds)
boundsF.set(bounds)
val innerWidth = bounds.width() - (paint.strokeWidth * 2f)
paint.textSize = getTextSizeForWidth(innerWidth, letter) * 0.5f
paint.getTextBounds(letter, 0, letter.length, textBounds)
invalidateSelf()
clipPath.reset()
clipPath.addRoundRect(boundsF, cornerSize, cornerSize, Path.Direction.CW)
clipPath.close()
}
override fun setAlpha(alpha: Int) {
@@ -58,6 +80,24 @@ class FaviconFallbackDrawable(
@Deprecated("Deprecated in Java")
override fun getOpacity() = PixelFormat.TRANSPARENT
private fun doDraw(canvas: Canvas) {
// background
paint.color = colorBackground
paint.style = Paint.Style.FILL
canvas.drawPaint(paint)
// letter
paint.color = colorForeground
val cx = (boundsF.left + boundsF.right) * 0.6f
val ty = boundsF.bottom * 0.7f + textBounds.height() * 0.5f - textBounds.bottom
canvas.drawText(letter, cx, ty, paint)
if (paint.strokeWidth > 0f) {
// stroke
paint.color = colorStroke
paint.style = Paint.Style.STROKE
canvas.drawPath(clipPath, paint)
}
}
private fun getTextSizeForWidth(width: Float, text: String): Float {
val testTextSize = 48f
paint.textSize = testTextSize

View File

@@ -15,7 +15,7 @@ class TrimTransformation(
private val tolerance: Int = 20,
) : Transformation {
override val cacheKey: String = javaClass.name
override val cacheKey: String = "${javaClass.name}-$tolerance"
override suspend fun transform(input: Bitmap, size: Size): Bitmap {
var left = 0
@@ -98,14 +98,23 @@ class TrimTransformation(
}
}
override fun equals(other: Any?) = other is TrimTransformation
override fun hashCode() = javaClass.hashCode()
private fun isColorTheSame(@ColorInt a: Int, @ColorInt b: Int): Boolean {
return abs(a.red - b.red) <= tolerance &&
abs(a.green - b.green) <= tolerance &&
abs(a.blue - b.blue) <= tolerance &&
abs(a.alpha - b.alpha) <= tolerance
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as TrimTransformation
return tolerance == other.tolerance
}
override fun hashCode(): Int {
return tolerance
}
}

View File

@@ -1,35 +0,0 @@
package org.koitharu.kotatsu.core.ui.list.decor
import android.graphics.Rect
import android.util.SparseIntArray
import android.view.View
import androidx.core.util.getOrDefault
import androidx.core.util.set
import androidx.recyclerview.widget.RecyclerView
class TypedSpacingItemDecoration(
vararg spacingMapping: Pair<Int, Int>,
private val fallbackSpacing: Int = 0,
) : RecyclerView.ItemDecoration() {
private val mapping = SparseIntArray(spacingMapping.size)
init {
spacingMapping.forEach { (k, v) -> mapping[k] = v }
}
override fun getItemOffsets(
outRect: Rect,
view: View,
parent: RecyclerView,
state: RecyclerView.State
) {
val itemType = parent.getChildViewHolder(view)?.itemViewType
val spacing = if (itemType == null) {
fallbackSpacing
} else {
mapping.getOrDefault(itemType, fallbackSpacing)
}
outRect.set(spacing, spacing, spacing, spacing)
}
}

View File

@@ -4,10 +4,9 @@ import android.content.res.Resources
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ext.daysDiff
import org.koitharu.kotatsu.core.util.ext.format
import org.koitharu.kotatsu.list.ui.model.ListModel
import java.util.Date
sealed class DateTimeAgo : ListModel {
sealed class DateTimeAgo {
abstract fun format(resources: Resources): String
@@ -77,6 +76,7 @@ sealed class DateTimeAgo : ListModel {
}
class DaysAgo(val days: Int) : DateTimeAgo() {
override fun format(resources: Resources): String {
return resources.getQuantityString(R.plurals.days_ago, days, days)
}
@@ -93,6 +93,30 @@ sealed class DateTimeAgo : ListModel {
override fun toString() = "days_ago_$days"
}
class MonthsAgo(val months: Int) : DateTimeAgo() {
override fun format(resources: Resources): String {
return if (months == 0) {
resources.getString(R.string.this_month)
} else {
resources.getQuantityString(R.plurals.months_ago, months, months)
}
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as MonthsAgo
return months == other.months
}
override fun hashCode(): Int {
return months
}
}
class Absolute(private val date: Date) : DateTimeAgo() {
private val day = date.daysDiff(0)

View File

@@ -0,0 +1,13 @@
package org.koitharu.kotatsu.core.ui.util
import androidx.core.view.MenuHost
import kotlinx.coroutines.flow.FlowCollector
class MenuInvalidator(
private val host: MenuHost,
) : FlowCollector<Any?> {
override suspend fun emit(value: Any?) {
host.invalidateMenu()
}
}

View File

@@ -0,0 +1,38 @@
package org.koitharu.kotatsu.core.ui.util
import androidx.annotation.IdRes
import androidx.appcompat.widget.Toolbar
import com.google.android.material.badge.BadgeDrawable
import com.google.android.material.badge.BadgeUtils
import com.google.android.material.badge.ExperimentalBadgeUtils
@androidx.annotation.OptIn(ExperimentalBadgeUtils::class)
class OptionsMenuBadgeHelper(
private val toolbar: Toolbar,
@IdRes private val itemId: Int,
) {
private var badge: BadgeDrawable? = null
fun setBadgeVisible(isVisible: Boolean) {
if (isVisible) {
showBadge()
} else {
hideBadge()
}
}
private fun hideBadge() {
badge?.let {
BadgeUtils.detachBadgeDrawable(it, toolbar, itemId)
}
badge = null
}
private fun showBadge() {
val badgeDrawable = badge ?: BadgeDrawable.create(toolbar.context).also {
badge = it
}
BadgeUtils.attachBadgeDrawable(badgeDrawable, toolbar, itemId)
}
}

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

@@ -1,316 +0,0 @@
package org.koitharu.kotatsu.core.ui.widgets
import android.animation.LayoutTransition
import android.content.Context
import android.util.AttributeSet
import android.view.LayoutInflater
import android.view.Menu
import android.view.View
import android.view.ViewGroup
import android.view.WindowInsets
import androidx.annotation.AttrRes
import androidx.annotation.MenuRes
import androidx.annotation.StringRes
import androidx.appcompat.widget.Toolbar
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.content.withStyledAttributes
import androidx.core.view.*
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import com.google.android.material.appbar.AppBarLayout
import com.google.android.material.appbar.MaterialToolbar
import com.google.android.material.bottomsheet.BottomSheetBehavior
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ext.getAnimationDuration
import org.koitharu.kotatsu.core.util.ext.getThemeDrawable
import org.koitharu.kotatsu.core.util.ext.parents
import org.koitharu.kotatsu.databinding.LayoutSheetHeaderBinding
import java.util.*
import com.google.android.material.R as materialR
private const val THROTTLE_DELAY = 200L
@Deprecated("")
class BottomSheetHeaderBar @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
@AttrRes defStyleAttr: Int = materialR.attr.appBarLayoutStyle,
) : AppBarLayout(context, attrs, defStyleAttr), MenuHost {
private val binding = LayoutSheetHeaderBinding.inflate(LayoutInflater.from(context), this)
private val closeDrawable = context.getThemeDrawable(materialR.attr.actionModeCloseDrawable)
private val bottomSheetCallback = Callback()
private val adjustStateRunnable = Runnable { adjustState() }
private var bottomSheetBehavior: BottomSheetBehavior<*>? = null
private val locationBuffer = IntArray(2)
private val expansionListeners = LinkedList<OnExpansionChangeListener>()
private var fitStatusBar = false
private val minHandleHeight = context.resources.getDimensionPixelSize(R.dimen.bottom_sheet_handle_size_min)
private val maxHandleHeight = context.resources.getDimensionPixelSize(R.dimen.bottom_sheet_handle_size_max)
private var isLayoutSuppressedCompat = false
private var isLayoutCalledWhileSuppressed = false
private var isBsExpanded = false
private var stateAdjustedAt = 0L
@Deprecated("")
val toolbar: MaterialToolbar
get() = binding.toolbar
val menu: Menu
get() = binding.toolbar.menu
var title: CharSequence?
get() = binding.toolbar.title
set(value) {
binding.toolbar.title = value
}
var subtitle: CharSequence?
get() = binding.toolbar.subtitle
set(value) {
binding.toolbar.subtitle = value
}
val isExpanded: Boolean
get() = binding.dragHandle.isGone
init {
setBackgroundResource(R.drawable.sheet_toolbar_background)
layoutTransition = LayoutTransition().apply {
setDuration(context.getAnimationDuration(R.integer.config_tinyAnimTime))
}
context.withStyledAttributes(attrs, R.styleable.BottomSheetHeaderBar, defStyleAttr) {
binding.toolbar.title = getString(R.styleable.BottomSheetHeaderBar_title)
fitStatusBar = getBoolean(R.styleable.BottomSheetHeaderBar_fitStatusBar, fitStatusBar)
val menuResId = getResourceId(R.styleable.BottomSheetHeaderBar_menu, 0)
if (menuResId != 0) {
binding.toolbar.inflateMenu(menuResId)
}
}
binding.toolbar.setNavigationOnClickListener(bottomSheetCallback)
}
override fun onAttachedToWindow() {
super.onAttachedToWindow()
setBottomSheetBehavior(findParentBottomSheetBehavior())
}
override fun onDetachedFromWindow() {
setBottomSheetBehavior(null)
super.onDetachedFromWindow()
}
override fun addView(child: View?, index: Int) {
if (shouldAddView(child)) {
super.addView(child, index)
} else {
binding.toolbar.addView(child, index)
}
}
override fun addView(child: View?, width: Int, height: Int) {
if (shouldAddView(child)) {
super.addView(child, width, height)
} else {
binding.toolbar.addView(child, width, height)
}
}
override fun addView(child: View?, index: Int, params: ViewGroup.LayoutParams?) {
if (shouldAddView(child)) {
super.addView(child, index, params)
} else {
binding.toolbar.addView(child, index, convertLayoutParams(params))
}
}
override fun onApplyWindowInsets(insets: WindowInsets?): WindowInsets {
dispatchInsets(if (insets != null) WindowInsetsCompat.toWindowInsetsCompat(insets) else null)
return super.onApplyWindowInsets(insets)
}
override fun addMenuProvider(provider: MenuProvider) {
binding.toolbar.addMenuProvider(provider)
}
override fun addMenuProvider(provider: MenuProvider, owner: LifecycleOwner) {
binding.toolbar.addMenuProvider(provider, owner)
}
override fun addMenuProvider(provider: MenuProvider, owner: LifecycleOwner, state: Lifecycle.State) {
binding.toolbar.addMenuProvider(provider, owner, state)
}
override fun removeMenuProvider(provider: MenuProvider) {
binding.toolbar.removeMenuProvider(provider)
}
override fun invalidateMenu() {
binding.toolbar.invalidateMenu()
}
fun inflateMenu(@MenuRes resId: Int) {
binding.toolbar.inflateMenu(resId)
}
fun setNavigationOnClickListener(onClickListener: OnClickListener) {
binding.toolbar.setNavigationOnClickListener(onClickListener)
}
fun addOnExpansionChangeListener(listener: OnExpansionChangeListener) {
expansionListeners.add(listener)
}
fun removeOnExpansionChangeListener(listener: OnExpansionChangeListener) {
expansionListeners.remove(listener)
}
fun setTitle(@StringRes resId: Int) {
binding.toolbar.setTitle(resId)
}
fun setSubtitle(@StringRes resId: Int) {
binding.toolbar.setSubtitle(resId)
}
override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
if (isLayoutSuppressedCompat) {
isLayoutCalledWhileSuppressed = true
} else {
super.onLayout(changed, l, t, r, b)
}
}
private fun setBottomSheetBehavior(behavior: BottomSheetBehavior<*>?) {
bottomSheetBehavior?.removeBottomSheetCallback(bottomSheetCallback)
bottomSheetBehavior = behavior
if (behavior != null) {
onBottomSheetStateChanged(behavior.state)
behavior.addBottomSheetCallback(bottomSheetCallback)
}
}
private fun onBottomSheetStateChanged(newState: Int) {
val expanded = newState == BottomSheetBehavior.STATE_EXPANDED && isOnTopOfScreen()
if (isBsExpanded != expanded) {
isBsExpanded = expanded
postAdjustState()
}
}
private fun suppressLayoutCompat(suppress: Boolean) {
if (suppress == isLayoutSuppressedCompat) return
isLayoutSuppressedCompat = suppress
if (!suppress && isLayoutCalledWhileSuppressed) {
requestLayout()
}
isLayoutCalledWhileSuppressed = false
}
private fun dispatchInsets(insets: WindowInsetsCompat?) {
if (!fitStatusBar) {
return
}
val isExpanded = binding.dragHandle.isGone
val topInset = insets?.getInsets(WindowInsetsCompat.Type.systemBars())?.top ?: 0
if (isExpanded) {
updatePadding(top = topInset)
} else {
updatePadding(top = 0)
binding.dragHandle.updateLayoutParams {
height = topInset.coerceIn(minHandleHeight, maxHandleHeight)
}
}
}
private fun findParentBottomSheetBehavior(): BottomSheetBehavior<*>? {
for (p in parents) {
val layoutParams = (p as? View)?.layoutParams
if (layoutParams is CoordinatorLayout.LayoutParams) {
val behavior = layoutParams.behavior
if (behavior is BottomSheetBehavior<*>) {
return behavior
}
}
}
return null
}
private fun isOnTopOfScreen(): Boolean {
getLocationInWindow(locationBuffer)
val topInset = ViewCompat.getRootWindowInsets(this)
?.getInsets(WindowInsetsCompat.Type.systemBars())?.top ?: 0
val zeroTop = (layoutParams as? MarginLayoutParams)?.topMargin ?: 0
return (locationBuffer[1] - topInset) <= zeroTop
}
private fun dismissBottomSheet() {
val behavior = bottomSheetBehavior ?: return
if (behavior.isHideable) {
behavior.state = BottomSheetBehavior.STATE_HIDDEN
} else {
behavior.state = BottomSheetBehavior.STATE_COLLAPSED
}
}
private fun shouldAddView(child: View?): Boolean {
if (child == null) {
return true
}
val viewId = child.id
return viewId == R.id.dragHandle || viewId == R.id.toolbar
}
private fun convertLayoutParams(params: ViewGroup.LayoutParams?): Toolbar.LayoutParams? {
return when (params) {
null -> null
is MarginLayoutParams -> {
val lp = Toolbar.LayoutParams(params)
if (params is LayoutParams) {
lp.gravity = params.gravity
}
lp
}
else -> Toolbar.LayoutParams(params)
}
}
private fun postAdjustState() {
removeCallbacks(adjustStateRunnable)
val now = System.currentTimeMillis()
if (stateAdjustedAt + THROTTLE_DELAY < now) {
adjustState()
} else {
postDelayed(adjustStateRunnable, THROTTLE_DELAY)
}
}
private fun adjustState() {
suppressLayoutCompat(true)
binding.toolbar.navigationIcon = (if (isBsExpanded) closeDrawable else null)
binding.dragHandle.isGone = isBsExpanded
expansionListeners.forEach { it.onExpansionStateChanged(this, isBsExpanded) }
dispatchInsets(ViewCompat.getRootWindowInsets(this))
stateAdjustedAt = System.currentTimeMillis()
suppressLayoutCompat(false)
}
private inner class Callback : BottomSheetBehavior.BottomSheetCallback(), OnClickListener {
override fun onStateChanged(bottomSheet: View, newState: Int) {
onBottomSheetStateChanged(newState)
}
override fun onSlide(bottomSheet: View, slideOffset: Float) = Unit
override fun onClick(v: View?) {
dismissBottomSheet()
}
}
fun interface OnExpansionChangeListener {
fun onExpansionStateChanged(headerBar: BottomSheetHeaderBar, isExpanded: Boolean)
}
}

View File

@@ -0,0 +1,30 @@
package org.koitharu.kotatsu.core.ui.widgets
import android.annotation.SuppressLint
import android.content.Context
import android.util.AttributeSet
import android.view.MotionEvent
import androidx.viewpager.widget.ViewPager
@SuppressLint("ClickableViewAccessibility")
class EnhancedViewPager @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
) : ViewPager(context, attrs) {
var isUserInputEnabled: Boolean = true
set(value) {
field = value
if (!value) {
cancelPendingInputEvents()
}
}
override fun onTouchEvent(event: MotionEvent): Boolean {
return isUserInputEnabled && super.onTouchEvent(event)
}
override fun onInterceptTouchEvent(event: MotionEvent): Boolean {
return isUserInputEnabled && super.onInterceptTouchEvent(event)
}
}

View File

@@ -0,0 +1,397 @@
package org.koitharu.kotatsu.core.ui.widgets
import android.animation.ValueAnimator
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.CornerPathEffect
import android.graphics.Paint
import android.graphics.Rect
import android.graphics.RectF
import android.graphics.Typeface
import android.os.Build
import android.os.Parcelable
import android.text.Layout
import android.text.StaticLayout
import android.text.TextDirectionHeuristic
import android.text.TextDirectionHeuristics
import android.text.TextPaint
import android.util.AttributeSet
import android.view.View
import androidx.annotation.RequiresApi
import androidx.interpolator.view.animation.FastOutSlowInInterpolator
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ext.draw
import org.koitharu.kotatsu.core.util.ext.getAnimationDuration
import org.koitharu.kotatsu.core.util.ext.resolveDp
import org.koitharu.kotatsu.core.util.ext.resolveSp
class PieChart @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr), PieChartInterface {
private var marginTextFirst: Float = context.resources.resolveDp(DEFAULT_MARGIN_TEXT_1)
private var marginTextSecond: Float = context.resources.resolveDp(DEFAULT_MARGIN_TEXT_2)
private var marginTextThird: Float = context.resources.resolveDp(DEFAULT_MARGIN_TEXT_3)
private var marginSmallCircle: Float = context.resources.resolveDp(DEFAULT_MARGIN_SMALL_CIRCLE)
private val marginText: Float = marginTextFirst + marginTextSecond
private val circleRect = RectF()
private var circleStrokeWidth: Float = context.resources.resolveDp(6f)
private var circleRadius: Float = 0f
private var circlePadding: Float = context.resources.resolveDp(8f)
private var circlePaintRoundSize: Boolean = true
private var circleSectionSpace: Float = 3f
private var circleCenterX: Float = 0f
private var circleCenterY: Float = 0f
private var numberTextPaint: TextPaint = TextPaint()
private var descriptionTextPain: TextPaint = TextPaint()
private var amountTextPaint: TextPaint = TextPaint()
private var textStartX: Float = 0f
private var textStartY: Float = 0f
private var textHeight: Int = 0
private var textCircleRadius: Float = context.resources.resolveDp(4f)
private var textAmountStr: String = ""
private var textAmountY: Float = 0f
private var textAmountXNumber: Float = 0f
private var textAmountXDescription: Float = 0f
private var textAmountYDescription: Float = 0f
private var totalAmount: Int = 0
private var pieChartColors: List<String> = listOf()
private var percentageCircleList: List<PieChartModel> = listOf()
private var textRowList: MutableList<StaticLayout> = mutableListOf()
private var dataList: List<Pair<Int, String>> = listOf()
private var animationSweepAngle: Int = 0
init {
var textAmountSize: Float = context.resources.resolveSp(22f)
var textNumberSize: Float = context.resources.resolveSp(20f)
var textDescriptionSize: Float = context.resources.resolveSp(14f)
var textAmountColor: Int = Color.WHITE
var textNumberColor: Int = Color.WHITE
var textDescriptionColor: Int = Color.GRAY
if (attrs != null) {
val typeArray = context.obtainStyledAttributes(attrs, R.styleable.PieChart)
val colorResId = typeArray.getResourceId(R.styleable.PieChart_pieChartColors, 0)
pieChartColors = typeArray.resources.getStringArray(colorResId).toList()
marginTextFirst = typeArray.getDimension(R.styleable.PieChart_pieChartMarginTextFirst, marginTextFirst)
marginTextSecond = typeArray.getDimension(R.styleable.PieChart_pieChartMarginTextSecond, marginTextSecond)
marginTextThird = typeArray.getDimension(R.styleable.PieChart_pieChartMarginTextThird, marginTextThird)
marginSmallCircle =
typeArray.getDimension(R.styleable.PieChart_pieChartMarginSmallCircle, marginSmallCircle)
circleStrokeWidth =
typeArray.getDimension(R.styleable.PieChart_pieChartCircleStrokeWidth, circleStrokeWidth)
circlePadding = typeArray.getDimension(R.styleable.PieChart_pieChartCirclePadding, circlePadding)
circlePaintRoundSize =
typeArray.getBoolean(R.styleable.PieChart_pieChartCirclePaintRoundSize, circlePaintRoundSize)
circleSectionSpace = typeArray.getFloat(R.styleable.PieChart_pieChartCircleSectionSpace, circleSectionSpace)
textCircleRadius = typeArray.getDimension(R.styleable.PieChart_pieChartTextCircleRadius, textCircleRadius)
textAmountSize = typeArray.getDimension(R.styleable.PieChart_pieChartTextAmountSize, textAmountSize)
textNumberSize = typeArray.getDimension(R.styleable.PieChart_pieChartTextNumberSize, textNumberSize)
textDescriptionSize =
typeArray.getDimension(R.styleable.PieChart_pieChartTextDescriptionSize, textDescriptionSize)
textAmountColor = typeArray.getColor(R.styleable.PieChart_pieChartTextAmountColor, textAmountColor)
textNumberColor = typeArray.getColor(R.styleable.PieChart_pieChartTextNumberColor, textNumberColor)
textDescriptionColor =
typeArray.getColor(R.styleable.PieChart_pieChartTextDescriptionColor, textDescriptionColor)
textAmountStr = typeArray.getString(R.styleable.PieChart_pieChartTextAmount) ?: ""
typeArray.recycle()
}
circlePadding += circleStrokeWidth
// Инициализация кистей View
initPaints(amountTextPaint, textAmountSize, textAmountColor)
initPaints(numberTextPaint, textNumberSize, textNumberColor)
initPaints(descriptionTextPain, textDescriptionSize, textDescriptionColor, true)
}
@RequiresApi(Build.VERSION_CODES.M)
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
textRowList.clear()
val initSizeWidth = resolveDefaultSize(widthMeasureSpec, DEFAULT_VIEW_SIZE_WIDTH)
val textTextWidth = (initSizeWidth * TEXT_WIDTH_PERCENT)
val initSizeHeight = calculateViewHeight(heightMeasureSpec, textTextWidth.toInt())
textStartX = initSizeWidth - textTextWidth.toFloat()
textStartY = initSizeHeight.toFloat() / 2 - textHeight / 2
calculateCircleRadius(initSizeWidth, initSizeHeight)
setMeasuredDimension(initSizeWidth, initSizeHeight)
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
drawCircle(canvas)
drawText(canvas)
}
override fun onRestoreInstanceState(state: Parcelable?) {
val pieChartState = state as? PieChartState
super.onRestoreInstanceState(pieChartState?.superState ?: state)
dataList = pieChartState?.dataList ?: listOf()
}
override fun onSaveInstanceState(): Parcelable {
val superState = super.onSaveInstanceState()
return PieChartState(superState, dataList)
}
override fun setDataChart(list: List<Pair<Int, String>>) {
dataList = list
calculatePercentageOfData()
}
override fun startAnimation() {
val animator = ValueAnimator.ofInt(0, 360).apply {
duration = context.getAnimationDuration(android.R.integer.config_longAnimTime)
interpolator = FastOutSlowInInterpolator()
addUpdateListener { valueAnimator ->
animationSweepAngle = valueAnimator.animatedValue as Int
invalidate()
}
}
animator.start()
}
private fun drawCircle(canvas: Canvas) {
for (percent in percentageCircleList) {
if (animationSweepAngle > percent.percentToStartAt + percent.percentOfCircle) {
canvas.drawArc(circleRect, percent.percentToStartAt, percent.percentOfCircle, false, percent.paint)
} else if (animationSweepAngle > percent.percentToStartAt) {
canvas.drawArc(
circleRect,
percent.percentToStartAt,
animationSweepAngle - percent.percentToStartAt,
false,
percent.paint,
)
}
}
}
private fun drawText(canvas: Canvas) {
var textBuffY = textStartY
textRowList.forEachIndexed { index, staticLayout ->
if (index % 2 == 0) {
staticLayout.draw(canvas, textStartX + marginSmallCircle + textCircleRadius, textBuffY)
canvas.drawCircle(
textStartX + marginSmallCircle / 2,
textBuffY + staticLayout.height / 2 + textCircleRadius / 2,
textCircleRadius,
Paint().apply { color = Color.parseColor(pieChartColors[(index / 2) % pieChartColors.size]) },
)
textBuffY += staticLayout.height + marginTextFirst
} else {
staticLayout.draw(canvas, textStartX, textBuffY)
textBuffY += staticLayout.height + marginTextSecond
}
}
canvas.drawText(totalAmount.toString(), textAmountXNumber, textAmountY, amountTextPaint)
canvas.drawText(textAmountStr, textAmountXDescription, textAmountYDescription, descriptionTextPain)
}
private fun initPaints(textPaint: TextPaint, textSize: Float, textColor: Int, isDescription: Boolean = false) {
textPaint.color = textColor
textPaint.textSize = textSize
textPaint.isAntiAlias = true
if (!isDescription) textPaint.typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
}
private fun resolveDefaultSize(spec: Int, defValue: Int): Int {
return when (MeasureSpec.getMode(spec)) {
MeasureSpec.UNSPECIFIED -> resources.resolveDp(defValue)
else -> MeasureSpec.getSize(spec)
}
}
@RequiresApi(Build.VERSION_CODES.M)
private fun calculateViewHeight(heightMeasureSpec: Int, textWidth: Int): Int {
val initSizeHeight = resolveDefaultSize(heightMeasureSpec, DEFAULT_VIEW_SIZE_HEIGHT)
textHeight = (dataList.size * marginText + getTextViewHeight(textWidth)).toInt()
val textHeightWithPadding = textHeight + paddingTop + paddingBottom
return if (textHeightWithPadding > initSizeHeight) textHeightWithPadding else initSizeHeight
}
private fun calculateCircleRadius(width: Int, height: Int) {
val circleViewWidth = (width * CIRCLE_WIDTH_PERCENT)
circleRadius = if (circleViewWidth > height) {
(height.toFloat() - circlePadding) / 2
} else {
circleViewWidth.toFloat() / 2
}
with(circleRect) {
left = circlePadding
top = height / 2 - circleRadius
right = circleRadius * 2 + circlePadding
bottom = height / 2 + circleRadius
}
circleCenterX = (circleRadius * 2 + circlePadding + circlePadding) / 2
circleCenterY = (height / 2 + circleRadius + (height / 2 - circleRadius)) / 2
textAmountY = circleCenterY
val sizeTextAmountNumber = getWidthOfAmountText(
totalAmount.toString(),
amountTextPaint,
)
textAmountXNumber = circleCenterX - sizeTextAmountNumber.width() / 2
textAmountXDescription = circleCenterX - getWidthOfAmountText(textAmountStr, descriptionTextPain).width() / 2
textAmountYDescription = circleCenterY + sizeTextAmountNumber.height() + marginTextThird
}
@RequiresApi(Build.VERSION_CODES.M)
private fun getTextViewHeight(maxWidth: Int): Int {
var textHeight = 0
dataList.forEach {
val textLayoutNumber = getMultilineText(
text = it.first.toString(),
textPaint = numberTextPaint,
width = maxWidth,
)
val textLayoutDescription = getMultilineText(
text = it.second,
textPaint = descriptionTextPain,
width = maxWidth,
)
textRowList.apply {
add(textLayoutNumber)
add(textLayoutDescription)
}
textHeight += textLayoutNumber.height + textLayoutDescription.height
}
return textHeight
}
private fun calculatePercentageOfData() {
totalAmount = dataList.fold(0) { res, value -> res + value.first }
var startAt = circleSectionSpace
percentageCircleList = dataList.mapIndexed { index, pair ->
var percent = pair.first * 100 / totalAmount.toFloat() - circleSectionSpace
percent = if (percent < 0f) 0f else percent
val resultModel = PieChartModel(
percentOfCircle = percent,
percentToStartAt = startAt,
colorOfLine = Color.parseColor(pieChartColors[index % pieChartColors.size]),
stroke = circleStrokeWidth,
paintRound = circlePaintRoundSize,
)
if (percent != 0f) startAt += percent + circleSectionSpace
resultModel
}
}
private fun getWidthOfAmountText(text: String, textPaint: TextPaint): Rect {
val bounds = Rect()
textPaint.getTextBounds(text, 0, text.length, bounds)
return bounds
}
@RequiresApi(Build.VERSION_CODES.M)
private fun getMultilineText(
text: CharSequence,
textPaint: TextPaint,
width: Int,
start: Int = 0,
end: Int = text.length,
alignment: Layout.Alignment = Layout.Alignment.ALIGN_NORMAL,
textDir: TextDirectionHeuristic = TextDirectionHeuristics.LTR,
spacingMult: Float = 1f,
spacingAdd: Float = 0f
): StaticLayout {
return StaticLayout.Builder
.obtain(text, start, end, textPaint, width)
.setAlignment(alignment)
.setTextDirection(textDir)
.setLineSpacing(spacingAdd, spacingMult)
.build()
}
companion object {
private const val DEFAULT_MARGIN_TEXT_1 = 2f
private const val DEFAULT_MARGIN_TEXT_2 = 10f
private const val DEFAULT_MARGIN_TEXT_3 = 2f
private const val DEFAULT_MARGIN_SMALL_CIRCLE = 12f
private const val TEXT_WIDTH_PERCENT = 0.40
private const val CIRCLE_WIDTH_PERCENT = 0.50
const val DEFAULT_VIEW_SIZE_HEIGHT = 150
const val DEFAULT_VIEW_SIZE_WIDTH = 250
}
}
interface PieChartInterface {
fun setDataChart(list: List<Pair<Int, String>>)
fun startAnimation()
}
data class PieChartModel(
var percentOfCircle: Float = 0f,
var percentToStartAt: Float = 0f,
var colorOfLine: Int = 0,
var stroke: Float = 0f,
var paint: Paint = Paint(),
var paintRound: Boolean = true
) {
init {
if (percentOfCircle < 0 || percentOfCircle > 100) {
percentOfCircle = 100f
}
percentOfCircle = 360 * percentOfCircle / 100
if (percentToStartAt < 0 || percentToStartAt > 100) {
percentToStartAt = 0f
}
percentToStartAt = 360 * percentToStartAt / 100
if (colorOfLine == 0) {
colorOfLine = Color.parseColor("#000000")
}
paint = Paint()
paint.color = colorOfLine
paint.isAntiAlias = true
paint.style = Paint.Style.STROKE
paint.strokeWidth = stroke
paint.isDither = true
if (paintRound) {
paint.strokeJoin = Paint.Join.ROUND
paint.strokeCap = Paint.Cap.ROUND
paint.pathEffect = CornerPathEffect(8f)
}
}
}
class PieChartState(
superSavedState: Parcelable?,
val dataList: List<Pair<Int, String>>
) : View.BaseSavedState(superSavedState), Parcelable

View File

@@ -9,15 +9,13 @@ import android.graphics.Paint
import android.util.AttributeSet
import android.view.View
import android.view.ViewOutlineProvider
import android.view.animation.DecelerateInterpolator
import androidx.annotation.ColorInt
import androidx.annotation.FloatRange
import androidx.interpolator.view.animation.FastOutSlowInInterpolator
import org.koitharu.kotatsu.core.util.ext.getAnimationDuration
import org.koitharu.kotatsu.core.util.ext.getThemeColor
import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled
import org.koitharu.kotatsu.core.util.ext.resolveDp
import org.koitharu.kotatsu.parsers.util.replaceWith
import com.google.android.material.R as materialR
class SegmentedBarView @JvmOverloads constructor(
context: Context,
@@ -28,13 +26,12 @@ class SegmentedBarView @JvmOverloads constructor(
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
private val segmentsData = ArrayList<Segment>()
private val segmentsSizes = ArrayList<Float>()
private val outlineColor = context.getThemeColor(materialR.attr.colorOutline)
private var cornerSize = 0f
private var scaleFactor = 1f
private var scaleAnimator: ValueAnimator? = null
init {
paint.strokeWidth = context.resources.resolveDp(1f)
paint.strokeWidth = context.resources.resolveDp(0f)
outlineProvider = OutlineProvider()
clipToOutline = true
}
@@ -57,12 +54,10 @@ class SegmentedBarView @JvmOverloads constructor(
paint.style = Paint.Style.FILL
val segmentWidth = segmentsSizes[i]
canvas.drawRoundRect(0f, 0f, x + cornerSize, height.toFloat(), cornerSize, cornerSize, paint)
paint.color = outlineColor
paint.style = Paint.Style.STROKE
canvas.drawRoundRect(0f, 0f, x + cornerSize, height.toFloat(), cornerSize, cornerSize, paint)
x -= segmentWidth
}
paint.color = outlineColor
paint.style = Paint.Style.STROKE
canvas.drawRoundRect(0f, 0f, w, height.toFloat(), cornerSize, cornerSize, paint)
}
@@ -100,7 +95,7 @@ class SegmentedBarView @JvmOverloads constructor(
invalidate()
val animator = ValueAnimator.ofFloat(0f, 1f)
animator.duration = context.getAnimationDuration(android.R.integer.config_longAnimTime)
animator.interpolator = DecelerateInterpolator()
animator.interpolator = FastOutSlowInInterpolator()
animator.addUpdateListener(this@SegmentedBarView)
animator.addListener(this@SegmentedBarView)
scaleAnimator = animator

View File

@@ -26,4 +26,8 @@ class SelectableTextView @JvmOverloads constructor(
Selection.setSelection(spannableText, text.length)
}
}
override fun scrollTo(x: Int, y: Int) {
super.scrollTo(0, 0)
}
}

View File

@@ -0,0 +1,135 @@
package org.koitharu.kotatsu.core.ui.widgets
import android.content.Context
import android.graphics.Outline
import android.graphics.drawable.Drawable
import android.util.AttributeSet
import android.view.LayoutInflater
import android.view.View
import android.view.ViewOutlineProvider
import android.widget.LinearLayout
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.core.content.ContextCompat
import androidx.core.content.withStyledAttributes
import androidx.core.view.setPadding
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.drawableStart
import org.koitharu.kotatsu.core.util.ext.getDrawableCompat
import org.koitharu.kotatsu.core.util.ext.getThemeColorStateList
import org.koitharu.kotatsu.core.util.ext.setTextAndVisible
import org.koitharu.kotatsu.core.util.ext.textAndVisible
import org.koitharu.kotatsu.databinding.ViewTipBinding
import com.google.android.material.R as materialR
class TipView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = R.attr.tipViewStyle,
) : LinearLayout(context, attrs, defStyleAttr), View.OnClickListener {
private val binding = ViewTipBinding.inflate(LayoutInflater.from(context), this)
var title: CharSequence?
get() = binding.textViewTitle.text
set(value) {
binding.textViewTitle.text = value
}
var text: CharSequence?
get() = binding.textViewBody.text
set(value) {
binding.textViewBody.text = value
}
var icon: Drawable?
get() = binding.textViewTitle.drawableStart
set(value) {
binding.textViewTitle.drawableStart = value
}
var primaryButtonText: CharSequence?
get() = binding.buttonPrimary.textAndVisible
set(value) {
binding.buttonPrimary.textAndVisible = value
}
var secondaryButtonText: CharSequence?
get() = binding.buttonSecondary.textAndVisible
set(value) {
binding.buttonSecondary.textAndVisible = value
}
var onButtonClickListener: OnButtonClickListener? = null
init {
orientation = VERTICAL
setPadding(context.resources.getDimensionPixelOffset(R.dimen.margin_normal))
context.withStyledAttributes(attrs, R.styleable.TipView, defStyleAttr) {
title = getText(R.styleable.TipView_title)
text = getText(R.styleable.TipView_android_text)
icon = getDrawableCompat(context, R.styleable.TipView_icon)
primaryButtonText = getString(R.styleable.TipView_primaryButtonText)
secondaryButtonText = getString(R.styleable.TipView_secondaryButtonText)
val shapeAppearanceModel = ShapeAppearanceModel.builder(context, attrs, defStyleAttr, 0).build()
background = MaterialShapeDrawable(shapeAppearanceModel).also {
it.fillColor = getColorStateList(R.styleable.TipView_cardBackgroundColor)
?: context.getThemeColorStateList(materialR.attr.colorBackgroundFloating)
it.strokeWidth = getDimension(R.styleable.TipView_strokeWidth, 0f)
it.strokeColor = getColorStateList(R.styleable.TipView_strokeColor)
it.elevation = getDimension(R.styleable.TipView_elevation, 0f)
}
outlineProvider = OutlineProvider(shapeAppearanceModel)
}
binding.buttonPrimary.setOnClickListener(this)
binding.buttonSecondary.setOnClickListener(this)
}
override fun onClick(v: View) {
when (v.id) {
R.id.button_primary -> onButtonClickListener?.onPrimaryButtonClick(this)
R.id.button_secondary -> onButtonClickListener?.onSecondaryButtonClick(this)
}
}
fun setTitle(@StringRes resId: Int) {
binding.textViewTitle.setText(resId)
}
fun setText(@StringRes resId: Int) {
binding.textViewBody.setText(resId)
}
fun setPrimaryButtonText(@StringRes resId: Int) {
binding.buttonPrimary.setTextAndVisible(resId)
}
fun setSecondaryButtonText(@StringRes resId: Int) {
binding.buttonSecondary.setTextAndVisible(resId)
}
fun setIcon(@DrawableRes resId: Int) {
icon = ContextCompat.getDrawable(context, resId)
}
interface OnButtonClickListener {
fun onPrimaryButtonClick(tipView: TipView)
fun onSecondaryButtonClick(tipView: TipView)
}
private class OutlineProvider(
shapeAppearanceModel: ShapeAppearanceModel,
) : ViewOutlineProvider() {
private val shapeDrawable = MaterialShapeDrawable(shapeAppearanceModel)
override fun getOutline(view: View, outline: Outline) {
shapeDrawable.setBounds(0, 0, view.width, view.height)
shapeDrawable.getOutline(outline)
}
}
}

View File

@@ -0,0 +1,13 @@
package org.koitharu.kotatsu.core.util
import kotlin.coroutines.Continuation
import kotlin.coroutines.resume
class ContinuationResumeRunnable(
private val continuation: Continuation<Unit>,
) : Runnable {
override fun run() {
continuation.resume(Unit)
}
}

View File

@@ -1,71 +0,0 @@
package org.koitharu.kotatsu.core.util
import android.annotation.SuppressLint
import androidx.work.WorkInfo
import androidx.work.WorkManager
import androidx.work.WorkQuery
import androidx.work.WorkRequest
import androidx.work.await
import androidx.work.impl.WorkManagerImpl
import java.util.UUID
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine
@SuppressLint("RestrictedApi")
class WorkManagerHelper(
workManager: WorkManager,
) {
private val workManagerImpl = workManager as WorkManagerImpl
suspend fun deleteWork(id: UUID) = suspendCoroutine { cont ->
workManagerImpl.workTaskExecutor.executeOnTaskThread {
try {
workManagerImpl.workDatabase.workSpecDao().delete(id.toString())
cont.resume(Unit)
} catch (e: Exception) {
cont.resumeWithException(e)
}
}
}
suspend fun deleteWorks(ids: Collection<UUID>) = suspendCoroutine { cont ->
workManagerImpl.workTaskExecutor.executeOnTaskThread {
try {
val db = workManagerImpl.workDatabase
db.runInTransaction {
for (id in ids) {
db.workSpecDao().delete(id.toString())
}
}
cont.resume(Unit)
} catch (e: Exception) {
cont.resumeWithException(e)
}
}
}
suspend fun getWorkInfosByTag(tag: String): List<WorkInfo> {
return workManagerImpl.getWorkInfosByTag(tag).await()
}
suspend fun getFinishedWorkInfosByTag(tag: String): List<WorkInfo> {
val query = WorkQuery.Builder.fromTags(listOf(tag))
.addStates(listOf(WorkInfo.State.SUCCEEDED, WorkInfo.State.CANCELLED, WorkInfo.State.FAILED))
.build()
return workManagerImpl.getWorkInfos(query).await()
}
suspend fun getWorkInfoById(id: UUID): WorkInfo? {
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

@@ -1,7 +1,6 @@
package org.koitharu.kotatsu.core.util
import android.annotation.SuppressLint
import android.content.Context
import androidx.lifecycle.asFlow
import androidx.work.WorkInfo
import androidx.work.WorkManager
@@ -14,6 +13,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
import javax.inject.Provider
/**
* Workaround for issue
@@ -21,12 +21,12 @@ import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
* https://issuetracker.google.com/issues/280504155
*/
class WorkServiceStopHelper(
private val context: Context,
private val workManagerProvider: Provider<WorkManager>,
) {
fun setup() {
processLifecycleScope.launch(Dispatchers.Default) {
WorkManager.getInstance(context)
workManagerProvider.get()
.getWorkInfosLiveData(WorkQuery.fromStates(WorkInfo.State.RUNNING))
.asFlow()
.map { it.isEmpty() }

View File

@@ -69,11 +69,14 @@ fun <I> ActivityResultLauncher<I>.resolve(context: Context, input: I): ResolveIn
return pm.resolveActivity(intent, 0)
}
fun <I> ActivityResultLauncher<I>.tryLaunch(input: I, options: ActivityOptionsCompat? = null): Boolean {
return runCatching {
launch(input, options)
}.isSuccess
}
fun <I> ActivityResultLauncher<I>.tryLaunch(
input: I,
options: ActivityOptionsCompat? = null,
): Boolean = runCatching {
launch(input, options)
}.onFailure { e ->
e.printStackTraceDebug()
}.isSuccess
fun SharedPreferences.observe() = callbackFlow<String> {
val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key ->

View File

@@ -12,6 +12,7 @@ import coil.request.SuccessResult
import coil.util.CoilUtils
import com.google.android.material.progressindicator.BaseProgressIndicator
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.browser.cloudflare.CaptchaNotifier
import org.koitharu.kotatsu.core.ui.image.RegionBitmapDecoder
import org.koitharu.kotatsu.core.util.progress.ImageRequestIndicatorListener
import org.koitharu.kotatsu.parsers.model.MangaSource
@@ -28,6 +29,7 @@ fun ImageView.newImageRequest(lifecycleOwner: LifecycleOwner, data: Any?): Image
.data(data)
.lifecycle(lifecycleOwner)
.crossfade(context)
.listener(CaptchaNotifier(context.applicationContext))
.target(this)
}

View File

@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.core.util.ext
import androidx.collection.ArrayMap
import androidx.collection.ArraySet
import java.util.Collections
import java.util.EnumSet
@Deprecated("TODO: remove")
fun <T> MutableList<T>.move(sourceIndex: Int, targetIndex: Int) {
@@ -61,3 +62,9 @@ fun <T> List<T>.takeMostFrequent(limit: Int): List<T> {
}
}
}
inline fun <reified E : Enum<E>> Collection<E>.toEnumSet(): EnumSet<E> = if (isEmpty()) {
EnumSet.noneOf(E::class.java)
} else {
EnumSet.copyOf(this)
}

View File

@@ -30,7 +30,7 @@ fun DialogFragment.showAllowStateLoss(manager: FragmentManager, tag: String?) {
}
fun Fragment.addMenuProvider(provider: MenuProvider) {
requireActivity().addMenuProvider(provider, viewLifecycleOwner, Lifecycle.State.STARTED)
requireActivity().addMenuProvider(provider, viewLifecycleOwner, Lifecycle.State.RESUMED)
}
@MainThread

View File

@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.core.util.ext
import android.annotation.SuppressLint
import android.content.Context
import android.content.res.Resources
import android.util.TypedValue
import androidx.annotation.Px
import kotlin.math.roundToInt
@@ -12,6 +13,9 @@ fun Resources.resolveDp(dp: Int) = (dp * displayMetrics.density).roundToInt()
@Px
fun Resources.resolveDp(dp: Float) = dp * displayMetrics.density
@Px
fun Resources.resolveSp(sp: Float) = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, sp, displayMetrics)
@SuppressLint("DiscouragedApi")
fun Context.getSystemBoolean(resName: String, fallback: Boolean): Boolean {
val id = Resources.getSystem().getIdentifier(resName, "bool", "android")

View File

@@ -0,0 +1,11 @@
package org.koitharu.kotatsu.core.util.ext
import android.graphics.Canvas
import android.text.StaticLayout
import androidx.core.graphics.withTranslation
fun StaticLayout.draw(canvas: Canvas, x: Float, y: Float) {
canvas.withTranslation(x, y) {
draw(this)
}
}

View File

@@ -11,6 +11,7 @@ import androidx.core.content.res.use
import androidx.core.view.isGone
import androidx.core.widget.TextViewCompat
var TextView.textAndVisible: CharSequence?
get() = text?.takeIf { visibility == View.VISIBLE }
set(value) {
@@ -70,3 +71,13 @@ fun TextView.setThemeTextAppearance(@AttrRes resId: Int, @StyleRes fallback: Int
TextViewCompat.setTextAppearance(this, it.getResourceId(0, fallback))
}
}
val TextView.isTextTruncated: Boolean
get() {
val l = layout ?: return false
if (maxLines in 0 until l.lineCount) {
return true
}
val layoutLines = l.lineCount
return layoutLines > 0 && l.getEllipsisCount(layoutLines - 1) > 0
}

View File

@@ -1,11 +1,16 @@
package org.koitharu.kotatsu.core.util.ext
import android.content.Context
import android.content.res.TypedArray
import android.graphics.Color
import android.graphics.drawable.Drawable
import androidx.annotation.AttrRes
import androidx.annotation.ColorInt
import androidx.annotation.FloatRange
import androidx.annotation.Px
import androidx.core.content.ContextCompat
import androidx.core.content.res.ResourcesCompat
import androidx.core.content.res.TypedArrayUtils
import androidx.core.content.res.use
import androidx.core.graphics.ColorUtils
@@ -60,3 +65,15 @@ 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)
}
fun TypedArray.getDrawableCompat(context: Context, index: Int): Drawable? {
val resId = getResourceId(index, 0)
return if (resId != 0) ContextCompat.getDrawable(context, resId) else null
}

View File

@@ -12,6 +12,7 @@ import androidx.core.view.children
import androidx.recyclerview.widget.RecyclerView
import androidx.viewpager2.widget.ViewPager2
import com.google.android.material.slider.Slider
import com.google.android.material.tabs.TabLayout
import kotlin.math.roundToInt
fun View.hideKeyboard() {
@@ -147,3 +148,9 @@ var View.isRtl: Boolean
set(value) {
layoutDirection = if (value) View.LAYOUT_DIRECTION_RTL else View.LAYOUT_DIRECTION_LTR
}
fun TabLayout.setTabsEnabled(enabled: Boolean) {
for (i in 0 until tabCount) {
getTabAt(i)?.view?.isEnabled = enabled
}
}

View File

@@ -0,0 +1,73 @@
package org.koitharu.kotatsu.core.util.ext
import android.annotation.SuppressLint
import androidx.work.WorkInfo
import androidx.work.WorkManager
import androidx.work.WorkQuery
import androidx.work.WorkRequest
import androidx.work.await
import androidx.work.impl.WorkManagerImpl
import java.util.UUID
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine
@SuppressLint("RestrictedApi")
suspend fun WorkManager.deleteWork(id: UUID) = suspendCoroutine { cont ->
workManagerImpl.workTaskExecutor.executeOnTaskThread {
try {
workManagerImpl.workDatabase.workSpecDao().delete(id.toString())
cont.resume(Unit)
} catch (e: Exception) {
cont.resumeWithException(e)
}
}
}
@SuppressLint("RestrictedApi")
suspend fun WorkManager.deleteWorks(ids: Collection<UUID>) = suspendCoroutine { cont ->
workManagerImpl.workTaskExecutor.executeOnTaskThread {
try {
val db = workManagerImpl.workDatabase
db.runInTransaction {
for (id in ids) {
db.workSpecDao().delete(id.toString())
}
}
cont.resume(Unit)
} catch (e: Exception) {
cont.resumeWithException(e)
}
}
}
@SuppressLint("RestrictedApi")
suspend fun WorkManager.awaitWorkInfosByTag(tag: String): List<WorkInfo> {
return getWorkInfosByTag(tag).await()
}
@SuppressLint("RestrictedApi")
suspend fun WorkManager.awaitFinishedWorkInfosByTag(tag: String): List<WorkInfo> {
val query = WorkQuery.Builder.fromTags(listOf(tag))
.addStates(listOf(WorkInfo.State.SUCCEEDED, WorkInfo.State.CANCELLED, WorkInfo.State.FAILED))
.build()
return getWorkInfos(query).await()
}
@SuppressLint("RestrictedApi")
suspend fun WorkManager.awaitWorkInfoById(id: UUID): WorkInfo? {
return getWorkInfoById(id).await()
}
@SuppressLint("RestrictedApi")
suspend fun WorkManager.awaitUniqueWorkInfoByName(name: String): List<WorkInfo> {
return getWorkInfosForUniqueWork(name).await().orEmpty()
}
@SuppressLint("RestrictedApi")
suspend fun WorkManager.awaitUpdateWork(request: WorkRequest): WorkManager.UpdateResult {
return updateWork(request).await()
}
private val WorkManager.workManagerImpl
@SuppressLint("RestrictedApi") inline get() = this as WorkManagerImpl

View File

@@ -8,9 +8,11 @@ import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.parser.MangaIntent
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.details.domain.model.DoubleManga
import org.koitharu.kotatsu.explore.domain.RecoverMangaUseCase
import org.koitharu.kotatsu.local.data.LocalMangaRepository
import org.koitharu.kotatsu.parsers.exception.NotFoundException
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.recoverNotNull
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import javax.inject.Inject
@@ -18,6 +20,7 @@ class DoubleMangaLoadUseCase @Inject constructor(
private val mangaDataRepository: MangaDataRepository,
private val localMangaRepository: LocalMangaRepository,
private val mangaRepositoryFactory: MangaRepository.Factory,
private val recoverUseCase: RecoverMangaUseCase,
) {
suspend operator fun invoke(manga: Manga): DoubleManga = coroutineScope {
@@ -58,6 +61,12 @@ class DoubleMangaLoadUseCase @Inject constructor(
} ?: return null
val repository = mangaRepositoryFactory.create(seed.source)
repository.getDetails(seed)
}.recoverNotNull { e ->
if (e is NotFoundException) {
recoverUseCase(manga)
} else {
null
}
}
}

View File

@@ -0,0 +1,18 @@
package org.koitharu.kotatsu.details.domain
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import javax.inject.Inject
class RelatedMangaUseCase @Inject constructor(
private val mangaRepositoryFactory: MangaRepository.Factory,
) {
suspend operator fun invoke(seed: Manga) = runCatchingCancellable {
mangaRepositoryFactory.create(seed.source).getRelated(seed)
}.onFailure {
it.printStackTraceDebug()
}.getOrNull()
}

View File

@@ -35,6 +35,7 @@ import org.koitharu.kotatsu.core.parser.MangaIntent
import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.ui.dialog.RecyclerViewAlertDialog
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.ui.util.MenuInvalidator
import org.koitharu.kotatsu.core.util.ViewBadge
import org.koitharu.kotatsu.core.util.ext.doOnExpansionsChanged
import org.koitharu.kotatsu.core.util.ext.getAnimationDuration
@@ -131,12 +132,8 @@ class DetailsActivity :
viewBinding.toolbarChapters?.subtitle = it
viewBinding.textViewSubtitle?.textAndVisible = it
}
viewModel.isChaptersReversed.observe(this) {
viewBinding.toolbarChapters?.invalidateMenu() ?: invalidateOptionsMenu()
}
viewModel.favouriteCategories.observe(this) {
invalidateOptionsMenu()
}
viewModel.isChaptersReversed.observe(this, MenuInvalidator(viewBinding.toolbarChapters ?: this))
viewModel.favouriteCategories.observe(this, MenuInvalidator(this))
viewModel.branches.observe(this) {
viewBinding.buttonDropdown.isVisible = it.size > 1
}

View File

@@ -2,9 +2,11 @@ package org.koitharu.kotatsu.details.ui
import android.os.Bundle
import android.text.method.LinkMovementMethod
import android.transition.TransitionManager
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.ViewTreeObserver
import android.widget.Toast
import androidx.appcompat.widget.PopupMenu
import androidx.core.content.ContextCompat
@@ -15,6 +17,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
@@ -22,8 +25,10 @@ import kotlinx.coroutines.flow.filterNotNull
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.bookmarks.ui.adapter.BookmarksAdapter
import org.koitharu.kotatsu.bookmarks.ui.sheet.BookmarksSheet
import org.koitharu.kotatsu.core.model.countChaptersByBranch
import org.koitharu.kotatsu.core.ui.BaseFragment
import org.koitharu.kotatsu.core.ui.BaseListAdapter
import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.ui.list.decor.SpacingItemDecoration
@@ -33,18 +38,25 @@ import org.koitharu.kotatsu.core.util.ext.crossfade
import org.koitharu.kotatsu.core.util.ext.drawableTop
import org.koitharu.kotatsu.core.util.ext.enqueueWith
import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty
import org.koitharu.kotatsu.core.util.ext.isTextTruncated
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.parentView
import org.koitharu.kotatsu.core.util.ext.resolveDp
import org.koitharu.kotatsu.core.util.ext.scaleUpActivityOptionsOf
import org.koitharu.kotatsu.core.util.ext.textAndVisible
import org.koitharu.kotatsu.databinding.FragmentDetailsBinding
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.details.ui.model.HistoryInfo
import org.koitharu.kotatsu.details.ui.related.RelatedMangaActivity
import org.koitharu.kotatsu.details.ui.scrobbling.ScrobblingItemDecoration
import org.koitharu.kotatsu.details.ui.scrobbling.ScrollingInfoAdapter
import org.koitharu.kotatsu.history.data.PROGRESS_NONE
import org.koitharu.kotatsu.image.ui.ImageActivity
import org.koitharu.kotatsu.list.domain.ListExtraProvider
import org.koitharu.kotatsu.list.ui.adapter.mangaGridItemAD
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.MangaItemModel
import org.koitharu.kotatsu.list.ui.size.StaticItemSizeResolver
import org.koitharu.kotatsu.main.ui.owners.NoModalBottomSheetOwner
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
@@ -52,6 +64,7 @@ import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.reader.ui.ReaderActivity
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo
import org.koitharu.kotatsu.scrobbling.common.ui.selector.ScrobblingSelectorSheet
import org.koitharu.kotatsu.search.ui.MangaListActivity
import org.koitharu.kotatsu.search.ui.SearchActivity
import javax.inject.Inject
@@ -61,7 +74,7 @@ class DetailsFragment :
BaseFragment<FragmentDetailsBinding>(),
View.OnClickListener,
ChipsView.OnChipClickListener,
OnListItemClickListener<Bookmark> {
OnListItemClickListener<Bookmark>, ViewTreeObserver.OnDrawListener {
@Inject
lateinit var coil: ImageLoader
@@ -80,9 +93,17 @@ class DetailsFragment :
super.onViewBindingCreated(binding, savedInstanceState)
binding.textViewAuthor.setOnClickListener(this)
binding.imageViewCover.setOnClickListener(this)
binding.buttonDescriptionMore.setOnClickListener(this)
binding.buttonBookmarksMore.setOnClickListener(this)
binding.buttonScrobblingMore.setOnClickListener(this)
binding.buttonRelatedMore.setOnClickListener(this)
binding.infoLayout.textViewSource.setOnClickListener(this)
binding.textViewDescription.viewTreeObserver.addOnDrawListener(this)
binding.textViewDescription.movementMethod = LinkMovementMethod.getInstance()
binding.chipsTags.onChipClickListener = this
binding.recyclerViewRelated.addItemDecoration(
SpacingItemDecoration(resources.getDimensionPixelOffset(R.dimen.grid_spacing)),
)
TitleScrollCoordinator(binding.textViewTitle).attach(binding.scrollView)
viewModel.manga.filterNotNull().observe(viewLifecycleOwner, ::onMangaUpdated)
viewModel.isLoading.observe(viewLifecycleOwner, ::onLoadingStateChanged)
@@ -92,6 +113,7 @@ class DetailsFragment :
viewModel.description.observe(viewLifecycleOwner, ::onDescriptionChanged)
viewModel.chapters.observe(viewLifecycleOwner, ::onChaptersChanged)
viewModel.localSize.observe(viewLifecycleOwner, ::onLocalSizeChanged)
viewModel.relatedManga.observe(viewLifecycleOwner, ::onRelatedMangaChanged)
}
override fun onItemClick(item: Bookmark, view: View) {
@@ -115,6 +137,13 @@ class DetailsFragment :
return true
}
override fun onDraw() {
viewBinding?.run {
buttonDescriptionMore.isVisible = textViewDescription.maxLines == Int.MAX_VALUE ||
textViewDescription.isTextTruncated
}
}
private fun onMangaUpdated(manga: Manga) {
with(requireViewBinding()) {
// Main
@@ -172,11 +201,13 @@ class DetailsFragment :
}
private fun onDescriptionChanged(description: CharSequence?) {
val tv = requireViewBinding().textViewDescription
if (description.isNullOrBlank()) {
requireViewBinding().textViewDescription.setText(R.string.no_description)
tv.setText(R.string.no_description)
} else {
requireViewBinding().textViewDescription.text = description
tv.text = description
}
requireViewBinding().buttonDescriptionMore.isVisible = tv.isTextTruncated
}
private fun onLocalSizeChanged(size: Long) {
@@ -189,6 +220,26 @@ class DetailsFragment :
}
}
private fun onRelatedMangaChanged(related: List<MangaItemModel>) {
if (related.isEmpty()) {
requireViewBinding().groupRelated.isVisible = false
return
}
val rv = viewBinding?.recyclerViewRelated ?: return
@Suppress("UNCHECKED_CAST")
val adapter = (rv.adapter as? BaseListAdapter<ListModel>) ?: BaseListAdapter(
mangaGridItemAD(
coil, viewLifecycleOwner,
StaticItemSizeResolver(resources.getDimensionPixelSize(R.dimen.smaller_grid_width)),
) { item, view ->
startActivity(DetailsActivity.newIntent(view.context, item), scaleUpActivityOptionsOf(view))
},
).also { rv.adapter = it }
adapter.items = related
requireViewBinding().groupRelated.isVisible = true
}
private fun onHistoryChanged(history: HistoryInfo) {
requireViewBinding().progressView.setPercent(history.history?.percent ?: PROGRESS_NONE, animate = true)
}
@@ -217,7 +268,7 @@ class DetailsFragment :
private fun onScrobblingInfoChanged(scrobblings: List<ScrobblingInfo>) {
var adapter = requireViewBinding().recyclerViewScrobbling.adapter as? ScrollingInfoAdapter
requireViewBinding().recyclerViewScrobbling.isGone = scrobblings.isEmpty()
requireViewBinding().groupScrobbling.isGone = scrobblings.isEmpty()
if (adapter != null) {
adapter.items = scrobblings
} else {
@@ -260,6 +311,28 @@ class DetailsFragment :
scaleUpActivityOptionsOf(v),
)
}
R.id.button_description_more -> {
val tv = requireViewBinding().textViewDescription
TransitionManager.beginDelayedTransition(tv.parentView)
if (tv.maxLines in 1 until Integer.MAX_VALUE) {
tv.maxLines = Integer.MAX_VALUE
} else {
tv.maxLines = resources.getInteger(R.integer.details_description_lines)
}
}
R.id.button_scrobbling_more -> {
ScrobblingSelectorSheet.show(parentFragmentManager, manga, null)
}
R.id.button_bookmarks_more -> {
BookmarksSheet.show(parentFragmentManager, manga)
}
R.id.button_related_more -> {
startActivity(RelatedMangaActivity.newIntent(v.context, manga))
}
}
}
@@ -296,7 +369,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)

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