Compare commits

...

87 Commits
v4.0.4 ... v4.3

Author SHA1 Message Date
Koitharu
3e7a48d27a Fix NPE during PagesCache initialization 2023-01-22 09:21:24 +02:00
Koitharu
eeba959ba5 Replace fadingEdges with scrollIndicators 2023-01-22 09:09:45 +02:00
Zakhar Timoshenko
e7fa1036be Fading chips on detailed list 2023-01-21 20:55:54 +03:00
Zakhar Timoshenko
542a7e1141 Merge remote-tracking branch 'origin/devel' into devel 2023-01-21 20:48:25 +03:00
Zakhar Timoshenko
5951f4438a Fix dialog background on Android 5 2023-01-21 20:47:56 +03:00
Koitharu
1fbae6bd7b Translated using Weblate (Russian)
Currently translated at 100.0% (405 of 405 strings)

Co-authored-by: Koitharu <nvasya95@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translation: Kotatsu/Strings
2023-01-21 19:14:27 +02:00
Dan
b73924aea8 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (402 of 402 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (8 of 8 strings)

Co-authored-by: Dan <denqwerta@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/uk/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/uk/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2023-01-21 19:14:27 +02:00
Shippo
005443f4ae Translated using Weblate (Arabic)
Currently translated at 14.9% (60 of 401 strings)

Co-authored-by: Shippo <Shipox@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ar/
Translation: Kotatsu/Strings
2023-01-21 19:14:27 +02:00
J. Lavoie
abb55d4424 Translated using Weblate (Italian)
Currently translated at 99.2% (398 of 401 strings)

Translated using Weblate (French)

Currently translated at 100.0% (401 of 401 strings)

Translated using Weblate (Italian)

Currently translated at 80.7% (324 of 401 strings)

Translated using Weblate (German)

Currently translated at 97.5% (391 of 401 strings)

Co-authored-by: J. Lavoie <j.lavoie@net-c.ca>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/de/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fr/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/it/
Translation: Kotatsu/Strings
2023-01-21 19:14:27 +02:00
ssantos
e0538da079 Translated using Weblate (Portuguese)
Currently translated at 99.2% (398 of 401 strings)

Translated using Weblate (Portuguese)

Currently translated at 66.5% (267 of 401 strings)

Co-authored-by: ssantos <ssantos@web.de>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt/
Translation: Kotatsu/Strings
2023-01-21 19:14:27 +02:00
Eric
665bf5a034 Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (405 of 405 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (402 of 402 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (401 of 401 strings)

Co-authored-by: Eric <hamburger2048@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2023-01-21 19:14:27 +02:00
Evgeniy Khramov
dc7e1282c6 Translated using Weblate (Russian)
Currently translated at 100.0% (401 of 401 strings)

Co-authored-by: Evgeniy Khramov <thejenjagamertjg@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translation: Kotatsu/Strings
2023-01-21 19:14:27 +02:00
Oğuz Ersen
3a877d4f4a Translated using Weblate (Turkish)
Currently translated at 100.0% (405 of 405 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (402 of 402 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (401 of 401 strings)

Co-authored-by: Oğuz Ersen <oguz@ersen.moe>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2023-01-21 19:14:27 +02:00
gallegonovato
8a23c9a327 Translated using Weblate (Spanish)
Currently translated at 100.0% (405 of 405 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (402 of 402 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (401 of 401 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2023-01-21 19:14:27 +02:00
Koitharu
452c0edfc7 Fix Continue button behavior 2023-01-20 17:06:29 +02:00
Koitharu
2b9307aa17 Update dynamic colors 2023-01-20 15:48:54 +02:00
Koitharu
f91d5e1c29 Update gradle 2023-01-20 15:13:16 +02:00
Koitharu
2fbfd14252 Fix tracker cancellation errors 2023-01-20 14:28:59 +02:00
Koitharu
c09dd92cff Logger for debug logs 2023-01-20 11:32:29 +02:00
Koitharu
6b08074a70 Fix changelog formatting 2023-01-19 19:52:00 +02:00
Koitharu
9cb5971182 Option to change app language #282 2023-01-19 18:58:15 +02:00
Zakhar Timoshenko
6f37d95c24 Adjust alert dialogs to M3 guidelines 2023-01-19 07:37:00 +03:00
Zakhar Timoshenko
d290ba24b7 Use Markwon for pretty changelogs 2023-01-19 07:35:56 +03:00
Koitharu
f57d23026b Update room 2023-01-17 08:17:43 +02:00
Koitharu
1a70ccff55 Merge branch 'master' into devel 2023-01-17 07:41:09 +02:00
Koitharu
bd6a51e58d Fix crash on cold launch 2023-01-09 19:12:37 +02:00
Koitharu
a9c122b144 Option to mark chapter as current #56 2023-01-09 16:34:18 +02:00
Koitharu
ed56170809 Update okio 2023-01-09 15:25:47 +02:00
Koitharu
a36e5fce29 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (400 of 400 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (400 of 400 strings)

Co-authored-by: Koitharu <nvasya95@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/uk/
Translation: Kotatsu/Strings
2023-01-09 14:55:03 +02:00
gallegonovato
760bfaf4d7 Translated using Weblate (Spanish)
Currently translated at 100.0% (400 of 400 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2023-01-09 14:55:03 +02:00
Koitharu
24463720b1 Update tools fragment layout 2023-01-09 14:32:02 +02:00
Koitharu
516470e8ae Update manga details list item 2023-01-09 13:36:45 +02:00
Koitharu
7f530d0476 Fix list mode changing 2023-01-09 11:19:17 +02:00
Koitharu
8a2706d70b Hide prefetch option if not available 2023-01-09 10:49:25 +02:00
Koitharu
27f09480a0 Prefetch last manga 2023-01-09 10:49:25 +02:00
Koitharu
c03dcf6d2e Fix memory leaks 2023-01-09 10:49:25 +02:00
Koitharu
bdb2ae9c2f Manage prefetch cache memory 2023-01-09 10:49:25 +02:00
Koitharu
3413fe6943 Content prefetch settings 2023-01-09 10:49:25 +02:00
Koitharu
e6ae9e8bd6 Prefetch chapter 2023-01-09 10:49:25 +02:00
Koitharu
8d9426f257 Add option to change favourites sort order #290 2023-01-08 08:18:55 +02:00
Koitharu
30247e3def Fix horizontal autoscroll in shelf 2023-01-08 08:18:54 +02:00
Koitharu
084dc32d2d Circle chapters indicators 2023-01-08 08:18:54 +02:00
Koitharu
3393f1397b Show incognito indicator if manga will not be saved in history 2023-01-08 08:18:54 +02:00
Koitharu
61784bcfc4 Hide manga updates if tracker is disabled 2023-01-08 08:18:54 +02:00
Koitharu
bd692fc60c Update dependencies 2023-01-08 08:18:54 +02:00
Zakhar Timoshenko
738299e8d3 Merge pull request #287 from mitonik/devel
Translate to Polish
2023-01-04 18:46:36 +03:00
Koitharu
c8b6dc27b2 Fix branch selection in reader 2023-01-04 17:39:06 +02:00
Koitharu
1493aa39a3 CookieJar implementation for non-WebView environment 2023-01-04 15:59:31 +02:00
Koitharu
f115031846 Fix error handling in CoroutineIntentService 2023-01-04 14:39:36 +02:00
Koitharu
571b85dfd8 Fix fast scroller NPE 2023-01-04 13:26:18 +02:00
Michał Antonik
75cc9e9030 Translate to Polish 2023-01-02 23:00:46 +01:00
Koitharu
656a707b4c Bump version 2023-01-01 08:49:15 +02:00
Koitharu
04afe7a934 Bind ssiv with lifecycle 2023-01-01 08:17:07 +02:00
Koitharu
689670b3ff Show inconito mode indicator on Read button 2023-01-01 08:17:03 +02:00
Zakhar Timoshenko
6273a9decb Merge pull request #281 from weblate/weblate-kotatsu-strings
Translations update from Hosted Weblate
2022-12-31 22:37:57 +03:00
Eric
72336d4f71 Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (399 of 399 strings)

Co-authored-by: Eric <hamburger2048@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2022-12-31 20:33:30 +01:00
J. Lavoie
731e998eb2 Translated using Weblate (French)
Currently translated at 100.0% (399 of 399 strings)

Translated using Weblate (Italian)

Currently translated at 79.9% (319 of 399 strings)

Translated using Weblate (German)

Currently translated at 97.2% (388 of 399 strings)

Co-authored-by: J. Lavoie <j.lavoie@net-c.ca>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/de/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fr/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/it/
Translation: Kotatsu/Strings
2022-12-31 20:33:30 +01:00
gallegonovato
9bf53114de Translated using Weblate (Spanish)
Currently translated at 100.0% (399 of 399 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2022-12-31 20:33:30 +01:00
Zakhar Timoshenko
0e1b8db688 Update parsers, Happy New Year! 2022-12-31 22:32:35 +03:00
Zakhar Timoshenko
3a62e2e6c0 Add monochrome icon on One UI 5.0 2022-12-31 15:08:47 +03:00
Koitharu
08764cb3cb Translated using Weblate (Russian)
Currently translated at 100.0% (399 of 399 strings)

Co-authored-by: Koitharu <nvasya95@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translation: Kotatsu/Strings
2022-12-27 09:00:05 +02:00
Evgeniy Khramov
9c52545f63 Translated using Weblate (Russian)
Currently translated at 100.0% (398 of 398 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (8 of 8 strings)

Co-authored-by: Evgeniy Khramov <thejenjagamertjg@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/ru/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2022-12-27 09:00:05 +02:00
gallegonovato
a6c30d33d4 Translated using Weblate (Spanish)
Currently translated at 100.0% (398 of 398 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2022-12-27 09:00:05 +02:00
Neko Nekowazarashi
25974af229 Translated using Weblate (Indonesian)
Currently translated at 90.2% (359 of 398 strings)

Co-authored-by: Neko Nekowazarashi <kodra@nekoweb.my.id>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/id/
Translation: Kotatsu/Strings
2022-12-27 09:00:05 +02:00
Andy Hong
607dfc9be3 Translated using Weblate (Korean)
Currently translated at 26.8% (107 of 398 strings)

Added translation using Weblate (Korean)

Added translation using Weblate (Korean)

Co-authored-by: Andy Hong <andy963963@icloud.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ko/
Translation: Kotatsu/Strings
2022-12-27 09:00:05 +02:00
mondstern
560e669700 Translated using Weblate (German)
Currently translated at 97.2% (387 of 398 strings)

Co-authored-by: mondstern <mondstern@snopyta.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/de/
Translation: Kotatsu/Strings
2022-12-27 09:00:05 +02:00
J. Lavoie
ba403c9360 Translated using Weblate (French)
Currently translated at 100.0% (398 of 398 strings)

Translated using Weblate (German)

Currently translated at 96.2% (383 of 398 strings)

Co-authored-by: J. Lavoie <j.lavoie@net-c.ca>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/de/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fr/
Translation: Kotatsu/Strings
2022-12-27 09:00:05 +02:00
Eric
0f1c9ff05d Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (398 of 398 strings)

Co-authored-by: Eric <hamburger1024@duck.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2022-12-27 09:00:05 +02:00
kuragehime
662f08e115 Translated using Weblate (Japanese)
Currently translated at 100.0% (398 of 398 strings)

Co-authored-by: kuragehime <kuragehime641@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ja/
Translation: Kotatsu/Strings
2022-12-27 09:00:05 +02:00
Oğuz Ersen
d647a32e9f Translated using Weblate (Turkish)
Currently translated at 100.0% (399 of 399 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (398 of 398 strings)

Co-authored-by: Oğuz Ersen <oguz@ersen.moe>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2022-12-27 09:00:05 +02:00
Koitharu
375e72cb98 Pass language to voice search 2022-12-27 08:55:39 +02:00
Koitharu
34c7cafdfe Use AlphanumComparator for importing manga dir 2022-12-27 08:39:27 +02:00
NOTMASTER09
03e0eefe4d Fix DirMangaImporter 2022-12-27 08:36:18 +02:00
Koitharu
f41425f03d Popup menu on sources in Explore fragment 2022-12-26 20:02:02 +02:00
Koitharu
400b91278f Allow source login on error 2022-12-26 19:27:38 +02:00
Koitharu
9088f77ae5 Update dependencies 2022-12-26 19:15:27 +02:00
Koitharu
86da3217d1 Add A13 locale list 2022-12-26 19:15:17 +02:00
Koitharu
24908e52af Update network error message 2022-12-09 18:32:10 +02:00
Koitharu
1261a6790d Improve pages cache creation 2022-12-09 18:24:55 +02:00
Koitharu
59fa61864a Update parsers 2022-12-09 18:19:39 +02:00
Koitharu
1cbfe017ea Fix network state observer 2022-11-30 09:22:51 +02:00
Koitharu
f469369b14 Fix manga search suggestions #268 2022-11-30 09:08:40 +02:00
Koitharu
1ddcaed483 Update parsers 2022-11-30 08:29:36 +02:00
Cường Bá
7bb7736f18 Translated using Weblate (Vietnamese)
Currently translated at 87.5% (7 of 8 strings)

Co-authored-by: Cường Bá <cuongba956@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/vi/
Translation: Kotatsu/plurals
2022-11-30 08:11:39 +02:00
john d
d1e7e7a2a6 Translated using Weblate (Greek)
Currently translated at 20.8% (83 of 398 strings)

Translated using Weblate (Greek)

Currently translated at 2.2% (9 of 398 strings)

Translated using Weblate (Greek)

Currently translated at 87.5% (7 of 8 strings)

Added translation using Weblate (Greek)

Added translation using Weblate (Greek)

Co-authored-by: john d <rasengan1405@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/el/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/el/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2022-11-30 08:11:39 +02:00
Koitharu
0c4b7b0586 Replace ArrayMap with an AndroidX implementation 2022-11-28 19:59:32 +02:00
Koitharu
f320f22863 Improve network state observer 2022-11-28 19:40:01 +02:00
181 changed files with 3443 additions and 848 deletions

2
.idea/gradle.xml generated
View File

@@ -7,7 +7,7 @@
<option name="testRunner" value="GRADLE" /> <option name="testRunner" value="GRADLE" />
<option name="distributionType" value="DEFAULT_WRAPPED" /> <option name="distributionType" value="DEFAULT_WRAPPED" />
<option name="externalProjectPath" value="$PROJECT_DIR$" /> <option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="gradleJvm" value="Embedded JDK" /> <option name="gradleJvm" value="jbr-17" />
<option name="modules"> <option name="modules">
<set> <set>
<option value="$PROJECT_DIR$" /> <option value="$PROJECT_DIR$" />

2
.idea/kotlinc.xml generated
View File

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

View File

@@ -7,16 +7,16 @@ plugins {
} }
android { android {
compileSdkVersion 33 compileSdk = 33
buildToolsVersion '33.0.0' buildToolsVersion = '33.0.1'
namespace 'org.koitharu.kotatsu' namespace = 'org.koitharu.kotatsu'
defaultConfig { defaultConfig {
applicationId 'org.koitharu.kotatsu' applicationId 'org.koitharu.kotatsu'
minSdkVersion 21 minSdkVersion 21
targetSdkVersion 33 targetSdkVersion 33
versionCode 504 versionCode 511
versionName '4.0.4' versionName '4.3'
generatedDensities = [] generatedDensities = []
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
@@ -56,6 +56,7 @@ android {
kotlinOptions { kotlinOptions {
jvmTarget = JavaVersion.VERSION_1_8.toString() jvmTarget = JavaVersion.VERSION_1_8.toString()
freeCompilerArgs += [ freeCompilerArgs += [
'-opt-in=kotlin.ExperimentalStdlibApi',
'-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi', '-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi',
'-opt-in=kotlinx.coroutines.FlowPreview', '-opt-in=kotlinx.coroutines.FlowPreview',
'-opt-in=kotlin.contracts.ExperimentalContracts', '-opt-in=kotlin.contracts.ExperimentalContracts',
@@ -83,15 +84,17 @@ afterEvaluate {
} }
} }
dependencies { dependencies {
implementation('com.github.KotatsuApp:kotatsu-parsers:1e49d4095b') { implementation('com.github.KotatsuApp:kotatsu-parsers:cf00732023') {
exclude group: 'org.json', module: 'json' exclude group: 'org.json', module: 'json'
} }
implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.8.0'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4' implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4'
implementation "androidx.appcompat:appcompat:1.6.0"
implementation 'androidx.core:core-ktx:1.9.0' implementation 'androidx.core:core-ktx:1.9.0'
implementation 'androidx.activity:activity-ktx:1.6.1' implementation 'androidx.activity:activity-ktx:1.6.1'
implementation 'androidx.fragment:fragment-ktx:1.5.4' implementation 'androidx.fragment:fragment-ktx:1.5.5'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.1' implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.1'
implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.5.1' implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.5.1'
implementation 'androidx.lifecycle:lifecycle-service:2.5.1' implementation 'androidx.lifecycle:lifecycle-service:2.5.1'
@@ -103,50 +106,51 @@ dependencies {
implementation 'androidx.preference:preference-ktx:1.2.0' implementation 'androidx.preference:preference-ktx:1.2.0'
implementation 'androidx.work:work-runtime-ktx:2.7.1' implementation 'androidx.work:work-runtime-ktx:2.7.1'
implementation 'androidx.biometric:biometric-ktx:1.2.0-alpha05' implementation 'androidx.biometric:biometric-ktx:1.2.0-alpha05'
implementation 'com.google.android.material:material:1.7.0' implementation 'com.google.android.material:material:1.8.0-rc01'
//noinspection LifecycleAnnotationProcessorWithJava8 //noinspection LifecycleAnnotationProcessorWithJava8
kapt 'androidx.lifecycle:lifecycle-compiler:2.5.1' kapt 'androidx.lifecycle:lifecycle-compiler:2.5.1'
implementation 'androidx.room:room-runtime:2.4.3' implementation 'androidx.room:room-runtime:2.5.0'
implementation 'androidx.room:room-ktx:2.4.3' implementation 'androidx.room:room-ktx:2.5.0'
kapt 'androidx.room:room-compiler:2.4.3' kapt 'androidx.room:room-compiler:2.5.0'
implementation 'com.squareup.okhttp3:okhttp:4.10.0' implementation 'com.squareup.okhttp3:okhttp:4.10.0'
implementation 'com.squareup.okhttp3:okhttp-dnsoverhttps:4.9.3' implementation 'com.squareup.okhttp3:okhttp-dnsoverhttps:4.9.3'
implementation 'com.squareup.okio:okio:3.2.0' implementation 'com.squareup.okio:okio:3.3.0'
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.2' implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.2'
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:4.3.2' implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:4.3.2'
implementation "com.google.dagger:hilt-android:2.44" implementation 'com.google.dagger:hilt-android:2.44.2'
kapt "com.google.dagger:hilt-compiler:2.44" kapt 'com.google.dagger:hilt-compiler:2.44.2'
implementation 'androidx.hilt:hilt-work:1.0.0' implementation 'androidx.hilt:hilt-work:1.0.0'
kapt 'androidx.hilt:hilt-compiler:1.0.0' kapt 'androidx.hilt:hilt-compiler:1.0.0'
implementation 'io.coil-kt:coil-base:2.2.2' implementation 'io.coil-kt:coil-base:2.2.2'
implementation 'io.coil-kt:coil-svg:2.2.2' implementation 'io.coil-kt:coil-svg:2.2.2'
implementation 'com.github.KotatsuApp:subsampling-scale-image-view:f8a38b08fe' implementation 'com.github.KotatsuApp:subsampling-scale-image-view:1b19231b2f'
implementation 'com.github.solkin:disk-lru-cache:1.4' implementation 'com.github.solkin:disk-lru-cache:1.4'
implementation 'io.noties.markwon:core:4.6.2'
implementation 'ch.acra:acra-http:5.9.6' implementation 'ch.acra:acra-http:5.9.7'
implementation 'ch.acra:acra-dialog:5.9.6' implementation 'ch.acra:acra-dialog:5.9.7'
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.9.1' debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.10'
testImplementation 'junit:junit:4.13.2' testImplementation 'junit:junit:4.13.2'
testImplementation 'org.json:json:20220924' testImplementation 'org.json:json:20220924'
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.4' testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.4'
androidTestImplementation 'androidx.test:runner:1.5.1' androidTestImplementation 'androidx.test:runner:1.5.2'
androidTestImplementation 'androidx.test:rules:1.5.0' androidTestImplementation 'androidx.test:rules:1.5.0'
androidTestImplementation 'androidx.test:core-ktx:1.5.0' androidTestImplementation 'androidx.test:core-ktx:1.5.0'
androidTestImplementation 'androidx.test.ext:junit-ktx:1.1.4' androidTestImplementation 'androidx.test.ext:junit-ktx:1.1.5'
androidTestImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.4' androidTestImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.4'
androidTestImplementation 'androidx.room:room-testing:2.4.3' androidTestImplementation 'androidx.room:room-testing:2.5.0'
androidTestImplementation 'com.squareup.moshi:moshi-kotlin:1.14.0' androidTestImplementation 'com.squareup.moshi:moshi-kotlin:1.14.0'
androidTestImplementation 'com.google.dagger:hilt-android-testing:2.44' androidTestImplementation 'com.google.dagger:hilt-android-testing:2.44.2'
kaptAndroidTest 'com.google.dagger:hilt-android-compiler:2.44' kaptAndroidTest 'com.google.dagger:hilt-android-compiler:2.44.2'
} }

View File

@@ -14,3 +14,4 @@
-keep class org.koitharu.kotatsu.core.exceptions.* { *; } -keep class org.koitharu.kotatsu.core.exceptions.* { *; }
-keep class org.koitharu.kotatsu.settings.NotificationSettingsLegacyFragment -keep class org.koitharu.kotatsu.settings.NotificationSettingsLegacyFragment
-keep class org.koitharu.kotatsu.core.prefs.ScreenshotsPolicy { *; }

View File

@@ -29,6 +29,7 @@
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:label="@string/app_name" android:label="@string/app_name"
android:largeHeap="true" android:largeHeap="true"
android:localeConfig="@xml/locales"
android:networkSecurityConfig="@xml/network_security_config" android:networkSecurityConfig="@xml/network_security_config"
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
@@ -187,6 +188,9 @@
android:name="android.content.SyncAdapter" android:name="android.content.SyncAdapter"
android:resource="@xml/sync_history" /> android:resource="@xml/sync_history" />
</service> </service>
<service
android:name="org.koitharu.kotatsu.details.service.MangaPrefetchService"
android:exported="false" />
<provider <provider
android:name="org.koitharu.kotatsu.search.ui.MangaSuggestionsProvider" android:name="org.koitharu.kotatsu.search.ui.MangaSuggestionsProvider"
@@ -247,6 +251,9 @@
<meta-data <meta-data
android:name="android.webkit.WebView.MetricsOptOut" android:name="android.webkit.WebView.MetricsOptOut"
android:value="true" /> android:value="true" />
<meta-data
android:name="com.samsung.android.icon_container.has_icon_container"
android:value="@bool/com_samsung_android_icon_container_has_icon_container" />
</application> </application>

View File

@@ -50,6 +50,7 @@ class KotatsuApp : Application(), Configuration.Provider {
enableStrictMode() enableStrictMode()
} }
AppCompatDelegate.setDefaultNightMode(settings.theme) AppCompatDelegate.setDefaultNightMode(settings.theme)
AppCompatDelegate.setApplicationLocales(settings.appLocales)
setupActivityLifecycleCallbacks() setupActivityLifecycleCallbacks()
processLifecycleScope.launch(Dispatchers.Default) { processLifecycleScope.launch(Dispatchers.Default) {
setupDatabaseObservers() setupDatabaseObservers()

View File

@@ -31,6 +31,9 @@ abstract class CoroutineIntentService : BaseService() {
processIntent(startId, intent) processIntent(startId, intent)
} }
} }
} catch (e: Throwable) {
e.printStackTraceDebug()
onError(startId, e)
} finally { } finally {
stopSelf(startId) stopSelf(startId)
} }

View File

@@ -2,20 +2,20 @@ package org.koitharu.kotatsu.base.ui.list
import android.app.Activity import android.app.Activity
import android.os.Bundle import android.os.Bundle
import android.util.ArrayMap
import android.view.Menu import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.view.ActionMode import androidx.appcompat.view.ActionMode
import androidx.collection.ArrayMap
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import androidx.savedstate.SavedStateRegistry import androidx.savedstate.SavedStateRegistry
import androidx.savedstate.SavedStateRegistryOwner import androidx.savedstate.SavedStateRegistryOwner
import kotlin.coroutines.EmptyCoroutineContext
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import org.koitharu.kotatsu.base.ui.list.decor.AbstractSelectionItemDecoration import org.koitharu.kotatsu.base.ui.list.decor.AbstractSelectionItemDecoration
import kotlin.coroutines.EmptyCoroutineContext
private const val PROVIDER_NAME = "selection_decoration_sectioned" private const val PROVIDER_NAME = "selection_decoration_sectioned"

View File

@@ -516,6 +516,6 @@ class FastScroller @JvmOverloads constructor(
interface SectionIndexer { interface SectionIndexer {
fun getSectionText(context: Context, position: Int): CharSequence fun getSectionText(context: Context, position: Int): CharSequence?
} }
} }

View File

@@ -8,10 +8,12 @@ import androidx.coordinatorlayout.widget.CoordinatorLayout.Behavior
import androidx.core.view.ViewCompat import androidx.core.view.ViewCompat
import com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton import com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
class ShrinkOnScrollBehavior : Behavior<ExtendedFloatingActionButton> { open class ShrinkOnScrollBehavior : Behavior<ExtendedFloatingActionButton> {
@Suppress("unused") constructor() : super() @Suppress("unused")
@Suppress("unused") constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs) constructor() : super()
@Suppress("unused")
constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs)
override fun onStartNestedScroll( override fun onStartNestedScroll(
coordinatorLayout: CoordinatorLayout, coordinatorLayout: CoordinatorLayout,
@@ -45,4 +47,4 @@ class ShrinkOnScrollBehavior : Behavior<ExtendedFloatingActionButton> {
} }
} }
} }
} }

View File

@@ -4,12 +4,12 @@ import android.graphics.Bitmap
import android.webkit.WebView import android.webkit.WebView
import android.webkit.WebViewClient import android.webkit.WebViewClient
import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.HttpUrl.Companion.toHttpUrl
import org.koitharu.kotatsu.core.network.AndroidCookieJar import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
private const val CF_CLEARANCE = "cf_clearance" private const val CF_CLEARANCE = "cf_clearance"
class CloudFlareClient( class CloudFlareClient(
private val cookieJar: AndroidCookieJar, private val cookieJar: MutableCookieJar,
private val callback: CloudFlareCallback, private val callback: CloudFlareCallback,
private val targetUrl: String, private val targetUrl: String,
) : WebViewClient() { ) : WebViewClient() {
@@ -42,4 +42,4 @@ class CloudFlareClient(
return cookieJar.loadForRequest(targetUrl.toHttpUrl()) return cookieJar.loadForRequest(targetUrl.toHttpUrl())
.find { it.name == CF_CLEARANCE }?.value .find { it.name == CF_CLEARANCE }?.value
} }
} }

View File

@@ -12,13 +12,13 @@ import androidx.core.view.isInvisible
import androidx.fragment.app.setFragmentResult import androidx.fragment.app.setFragmentResult
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import org.koitharu.kotatsu.base.ui.AlertDialogFragment import org.koitharu.kotatsu.base.ui.AlertDialogFragment
import org.koitharu.kotatsu.core.network.AndroidCookieJar
import org.koitharu.kotatsu.core.network.UserAgentInterceptor import org.koitharu.kotatsu.core.network.UserAgentInterceptor
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
import org.koitharu.kotatsu.databinding.FragmentCloudflareBinding import org.koitharu.kotatsu.databinding.FragmentCloudflareBinding
import org.koitharu.kotatsu.utils.ext.stringArgument import org.koitharu.kotatsu.utils.ext.stringArgument
import org.koitharu.kotatsu.utils.ext.withArgs import org.koitharu.kotatsu.utils.ext.withArgs
import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class CloudFlareDialog : AlertDialogFragment<FragmentCloudflareBinding>(), CloudFlareCallback { class CloudFlareDialog : AlertDialogFragment<FragmentCloudflareBinding>(), CloudFlareCallback {
@@ -27,7 +27,7 @@ class CloudFlareDialog : AlertDialogFragment<FragmentCloudflareBinding>(), Cloud
private val pendingResult = Bundle(1) private val pendingResult = Bundle(1)
@Inject @Inject
lateinit var cookieJar: AndroidCookieJar lateinit var cookieJar: MutableCookieJar
override fun onInflateView( override fun onInflateView(
inflater: LayoutInflater, inflater: LayoutInflater,

View File

@@ -4,6 +4,7 @@ import android.app.Application
import android.content.Context import android.content.Context
import android.provider.SearchRecentSuggestions import android.provider.SearchRecentSuggestions
import android.text.Html import android.text.Html
import android.util.AndroidRuntimeException
import androidx.collection.arraySetOf import androidx.collection.arraySetOf
import androidx.room.InvalidationTracker import androidx.room.InvalidationTracker
import coil.ComponentRegistry import coil.ComponentRegistry
@@ -23,8 +24,15 @@ import okhttp3.CookieJar
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.base.ui.util.ActivityRecreationHandle import org.koitharu.kotatsu.base.ui.util.ActivityRecreationHandle
import org.koitharu.kotatsu.core.cache.ContentCache
import org.koitharu.kotatsu.core.cache.MemoryContentCache
import org.koitharu.kotatsu.core.cache.StubContentCache
import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.network.* import org.koitharu.kotatsu.core.network.*
import org.koitharu.kotatsu.core.network.cookies.AndroidCookieJar
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
import org.koitharu.kotatsu.core.network.cookies.PreferencesCookieJar
import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.core.os.ShortcutsUpdater import org.koitharu.kotatsu.core.os.ShortcutsUpdater
import org.koitharu.kotatsu.core.parser.MangaLoaderContextImpl import org.koitharu.kotatsu.core.parser.MangaLoaderContextImpl
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
@@ -39,6 +47,8 @@ import org.koitharu.kotatsu.search.ui.MangaSuggestionsProvider
import org.koitharu.kotatsu.settings.backup.BackupObserver import org.koitharu.kotatsu.settings.backup.BackupObserver
import org.koitharu.kotatsu.sync.domain.SyncController import org.koitharu.kotatsu.sync.domain.SyncController
import org.koitharu.kotatsu.utils.IncognitoModeIndicator import org.koitharu.kotatsu.utils.IncognitoModeIndicator
import org.koitharu.kotatsu.utils.ext.activityManager
import org.koitharu.kotatsu.utils.ext.connectivityManager
import org.koitharu.kotatsu.utils.ext.isLowRamDevice import org.koitharu.kotatsu.utils.ext.isLowRamDevice
import org.koitharu.kotatsu.utils.image.CoilImageGetter import org.koitharu.kotatsu.utils.image.CoilImageGetter
import org.koitharu.kotatsu.widget.WidgetUpdater import org.koitharu.kotatsu.widget.WidgetUpdater
@@ -50,7 +60,7 @@ import javax.inject.Singleton
interface AppModule { interface AppModule {
@Binds @Binds
fun bindCookieJar(androidCookieJar: AndroidCookieJar): CookieJar fun bindCookieJar(androidCookieJar: MutableCookieJar): CookieJar
@Binds @Binds
fun bindMangaLoaderContext(mangaLoaderContextImpl: MangaLoaderContextImpl): MangaLoaderContext fun bindMangaLoaderContext(mangaLoaderContextImpl: MangaLoaderContextImpl): MangaLoaderContext
@@ -60,6 +70,17 @@ interface AppModule {
companion object { companion object {
@Provides
@Singleton
fun provideCookieJar(
@ApplicationContext context: Context
): MutableCookieJar = try {
AndroidCookieJar()
} catch (e: AndroidRuntimeException) {
// WebView is not available
PreferencesCookieJar(context)
}
@Provides @Provides
@Singleton @Singleton
fun provideOkHttpClient( fun provideOkHttpClient(
@@ -81,6 +102,12 @@ interface AppModule {
}.build() }.build()
} }
@Provides
@Singleton
fun provideNetworkState(
@ApplicationContext context: Context
) = NetworkState(context.connectivityManager)
@Provides @Provides
@Singleton @Singleton
fun provideMangaDatabase( fun provideMangaDatabase(
@@ -159,5 +186,17 @@ interface AppModule {
activityRecreationHandle, activityRecreationHandle,
incognitoModeIndicator, incognitoModeIndicator,
) )
@Provides
@Singleton
fun provideContentCache(
application: Application,
): ContentCache {
return if (application.activityManager?.isLowRamDevice == true) {
StubContentCache()
} else {
MemoryContentCache(application)
}
}
} }
} }

View File

@@ -0,0 +1,23 @@
package org.koitharu.kotatsu.core.cache
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaSource
interface ContentCache {
val isCachingEnabled: Boolean
suspend fun getDetails(source: MangaSource, url: String): Manga?
fun putDetails(source: MangaSource, url: String, details: SafeDeferred<Manga>)
suspend fun getPages(source: MangaSource, url: String): List<MangaPage>?
fun putPages(source: MangaSource, url: String, pages: SafeDeferred<List<MangaPage>>)
data class Key(
val source: MangaSource,
val url: String,
)
}

View File

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

View File

@@ -0,0 +1,59 @@
package org.koitharu.kotatsu.core.cache
import android.app.Application
import android.content.ComponentCallbacks2
import android.content.res.Configuration
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaSource
class MemoryContentCache(application: Application) : ContentCache, ComponentCallbacks2 {
init {
application.registerComponentCallbacks(this)
}
private val detailsCache = DeferredLruCache<Manga>(4)
private val pagesCache = DeferredLruCache<List<MangaPage>>(4)
override val isCachingEnabled: Boolean = true
override suspend fun getDetails(source: MangaSource, url: String): Manga? {
return detailsCache[ContentCache.Key(source, url)]?.awaitOrNull()
}
override fun putDetails(source: MangaSource, url: String, details: SafeDeferred<Manga>) {
detailsCache.put(ContentCache.Key(source, url), details)
}
override suspend fun getPages(source: MangaSource, url: String): List<MangaPage>? {
return pagesCache[ContentCache.Key(source, url)]?.awaitOrNull()
}
override fun putPages(source: MangaSource, url: String, pages: SafeDeferred<List<MangaPage>>) {
pagesCache.put(ContentCache.Key(source, url), pages)
}
override fun onConfigurationChanged(newConfig: Configuration) = Unit
override fun onLowMemory() = Unit
override fun onTrimMemory(level: Int) {
trimCache(detailsCache, level)
trimCache(pagesCache, level)
}
private fun trimCache(cache: DeferredLruCache<*>, level: Int) {
when (level) {
ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL,
ComponentCallbacks2.TRIM_MEMORY_COMPLETE,
ComponentCallbacks2.TRIM_MEMORY_MODERATE -> cache.evictAll()
ComponentCallbacks2.TRIM_MEMORY_UI_HIDDEN,
ComponentCallbacks2.TRIM_MEMORY_RUNNING_LOW,
ComponentCallbacks2.TRIM_MEMORY_BACKGROUND -> cache.trimToSize(1)
else -> cache.trimToSize(cache.maxSize() / 2)
}
}
}

View File

@@ -0,0 +1,20 @@
package org.koitharu.kotatsu.core.cache
import kotlinx.coroutines.Deferred
class SafeDeferred<T>(
private val delegate: Deferred<Result<T>>,
) {
suspend fun await(): T {
return delegate.await().getOrThrow()
}
suspend fun awaitOrNull(): T? {
return delegate.await().getOrNull()
}
fun cancel() {
delegate.cancel()
}
}

View File

@@ -0,0 +1,18 @@
package org.koitharu.kotatsu.core.cache
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaSource
class StubContentCache : ContentCache {
override val isCachingEnabled: Boolean = false
override suspend fun getDetails(source: MangaSource, url: String): Manga? = null
override fun putDetails(source: MangaSource, url: String, details: SafeDeferred<Manga>) = Unit
override suspend fun getPages(source: MangaSource, url: String): List<MangaPage>? = null
override fun putPages(source: MangaSource, url: String, pages: SafeDeferred<List<MangaPage>>) = Unit
}

View File

@@ -14,11 +14,11 @@ abstract class MangaDao {
abstract suspend fun find(id: Long): MangaWithTags? abstract suspend fun find(id: Long): MangaWithTags?
@Transaction @Transaction
@Query("SELECT * FROM manga WHERE title LIKE :query OR alt_title LIKE :query LIMIT :limit") @Query("SELECT * FROM manga WHERE (title LIKE :query OR alt_title LIKE :query) AND manga_id IN (SELECT manga_id FROM favourites UNION SELECT manga_id FROM history) LIMIT :limit")
abstract suspend fun searchByTitle(query: String, limit: Int): List<MangaWithTags> abstract suspend fun searchByTitle(query: String, limit: Int): List<MangaWithTags>
@Transaction @Transaction
@Query("SELECT * FROM manga WHERE (title LIKE :query OR alt_title LIKE :query) AND source = :source LIMIT :limit") @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> abstract suspend fun searchByTitle(query: String, source: String, limit: Int): List<MangaWithTags>
@Insert(onConflict = OnConflictStrategy.IGNORE) @Insert(onConflict = OnConflictStrategy.IGNORE)
@@ -47,4 +47,4 @@ abstract class MangaDao {
} }
} }
} }
} }

View File

@@ -13,16 +13,6 @@ abstract class PreferencesDao {
@Query("SELECT * FROM preferences WHERE manga_id = :mangaId") @Query("SELECT * FROM preferences WHERE manga_id = :mangaId")
abstract fun observe(mangaId: Long): Flow<MangaPrefsEntity?> abstract fun observe(mangaId: Long): Flow<MangaPrefsEntity?>
@Insert(onConflict = OnConflictStrategy.IGNORE) @Upsert
abstract suspend fun insert(pref: MangaPrefsEntity): Long abstract suspend fun upsert(pref: MangaPrefsEntity)
@Update
abstract suspend fun update(pref: MangaPrefsEntity): Int
@Transaction
open suspend fun upsert(pref: MangaPrefsEntity) {
if (update(pref) == 0) {
insert(pref)
}
}
} }

View File

@@ -14,7 +14,7 @@ abstract class TagsDao {
LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id
GROUP BY tags.title GROUP BY tags.title
ORDER BY COUNT(manga_id) DESC ORDER BY COUNT(manga_id) DESC
LIMIT :limit""" LIMIT :limit""",
) )
abstract suspend fun findPopularTags(limit: Int): List<TagEntity> abstract suspend fun findPopularTags(limit: Int): List<TagEntity>
@@ -24,7 +24,7 @@ abstract class TagsDao {
WHERE tags.source = :source WHERE tags.source = :source
GROUP BY tags.title GROUP BY tags.title
ORDER BY COUNT(manga_id) DESC ORDER BY COUNT(manga_id) DESC
LIMIT :limit""" LIMIT :limit""",
) )
abstract suspend fun findPopularTags(source: String, limit: Int): List<TagEntity> abstract suspend fun findPopularTags(source: String, limit: Int): List<TagEntity>
@@ -34,7 +34,7 @@ abstract class TagsDao {
WHERE tags.source = :source AND title LIKE :query WHERE tags.source = :source AND title LIKE :query
GROUP BY tags.title GROUP BY tags.title
ORDER BY COUNT(manga_id) DESC ORDER BY COUNT(manga_id) DESC
LIMIT :limit""" LIMIT :limit""",
) )
abstract suspend fun findTags(source: String, query: String, limit: Int): List<TagEntity> abstract suspend fun findTags(source: String, query: String, limit: Int): List<TagEntity>
@@ -44,22 +44,10 @@ abstract class TagsDao {
WHERE title LIKE :query WHERE title LIKE :query
GROUP BY tags.title GROUP BY tags.title
ORDER BY COUNT(manga_id) DESC ORDER BY COUNT(manga_id) DESC
LIMIT :limit""" LIMIT :limit""",
) )
abstract suspend fun findTags(query: String, limit: Int): List<TagEntity> abstract suspend fun findTags(query: String, limit: Int): List<TagEntity>
@Insert(onConflict = OnConflictStrategy.IGNORE) @Upsert
abstract suspend fun insert(tag: TagEntity): Long abstract suspend fun upsert(tags: Iterable<TagEntity>)
}
@Update(onConflict = OnConflictStrategy.IGNORE)
abstract suspend fun update(tag: TagEntity): Int
@Transaction
open suspend fun upsert(tags: Iterable<TagEntity>) {
tags.forEach { tag ->
if (update(tag) <= 0) {
insert(tag)
}
}
}
}

View File

@@ -1,9 +1,9 @@
package org.koitharu.kotatsu.core.exceptions.resolve package org.koitharu.kotatsu.core.exceptions.resolve
import android.util.ArrayMap
import androidx.activity.result.ActivityResultCallback import androidx.activity.result.ActivityResultCallback
import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.ActivityResultLauncher
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.collection.ArrayMap
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity import androidx.fragment.app.FragmentActivity
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.suspendCancellableCoroutine

View File

@@ -80,6 +80,12 @@ class AppUpdateRepository @Inject constructor(
return BuildConfig.DEBUG || getCertificateSHA1Fingerprint() == CERT_SHA1 return BuildConfig.DEBUG || getCertificateSHA1Fingerprint() == CERT_SHA1
} }
suspend fun getCurrentVersionChangelog(): String? {
val currentVersion = VersionId(BuildConfig.VERSION_NAME)
val available = getAvailableVersions()
return available.find { x -> x.versionId == currentVersion }?.description
}
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
@SuppressLint("PackageManagerGetSignatures") @SuppressLint("PackageManagerGetSignatures")
private fun getCertificateSHA1Fingerprint(): String? = runCatching { private fun getCertificateSHA1Fingerprint(): String? = runCatching {

View File

@@ -0,0 +1,128 @@
package org.koitharu.kotatsu.core.logs
import android.content.Context
import androidx.annotation.WorkerThread
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import org.koitharu.kotatsu.utils.ext.processLifecycleScope
import org.koitharu.kotatsu.utils.ext.runCatchingCancellable
import org.koitharu.kotatsu.utils.ext.subdir
import java.io.File
import java.io.FileOutputStream
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.concurrent.ConcurrentLinkedQueue
private const val DIR = "logs"
private const val FLUSH_DELAY = 2_000L
private const val MAX_SIZE_BYTES = 1024 * 1024 // 1 MB
class FileLogger(
context: Context,
private val settings: AppSettings,
name: String,
) {
val file by lazy {
val dir = context.getExternalFilesDir(DIR) ?: context.filesDir.subdir(DIR)
File(dir, "$name.log")
}
val isEnabled: Boolean
get() = settings.isLoggingEnabled
private val dateFormat = SimpleDateFormat.getDateTimeInstance(
SimpleDateFormat.SHORT,
SimpleDateFormat.SHORT,
Locale.ROOT,
)
private val buffer = ConcurrentLinkedQueue<String>()
private val mutex = Mutex()
private var flushJob: Job? = null
fun log(message: String, e: Throwable? = null) {
if (!isEnabled) {
return
}
val text = buildString {
append(dateFormat.format(Date()))
append(": ")
if (e != null) {
append("E!")
}
append(message)
if (e != null) {
append(' ')
append(e.stackTraceToString())
appendLine()
}
}
buffer.add(text)
postFlush()
}
suspend fun flush() {
if (!isEnabled) {
return
}
flushJob?.cancelAndJoin()
flushImpl()
}
private fun postFlush() {
if (flushJob?.isActive == true) {
return
}
flushJob = processLifecycleScope.launch(Dispatchers.Default) {
delay(FLUSH_DELAY)
runCatchingCancellable {
flushImpl()
}.onFailure {
it.printStackTraceDebug()
}
}
}
private suspend fun flushImpl() {
mutex.withLock {
if (buffer.isEmpty()) {
return
}
runInterruptible(Dispatchers.IO) {
if (file.length() > MAX_SIZE_BYTES) {
rotate()
}
FileOutputStream(file, true).use {
while (true) {
val message = buffer.poll() ?: break
it.write(message.toByteArray())
it.write('\n'.code)
}
it.flush()
}
}
}
}
@WorkerThread
private fun rotate() {
val length = file.length()
val bakFile = File(file.parentFile, file.name + ".bak")
file.renameTo(bakFile)
bakFile.inputStream().use { input ->
input.skip(length - MAX_SIZE_BYTES / 2)
file.outputStream().use { output ->
input.copyTo(output)
output.flush()
}
}
bakFile.delete()
}
}

View File

@@ -0,0 +1,7 @@
package org.koitharu.kotatsu.core.logs
import javax.inject.Qualifier
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class TrackerLogger

View File

@@ -0,0 +1,31 @@
package org.koitharu.kotatsu.core.logs
import android.content.Context
import androidx.collection.arraySetOf
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.ElementsIntoSet
import org.koitharu.kotatsu.core.prefs.AppSettings
@Module
@InstallIn(SingletonComponent::class)
object LoggersModule {
@Provides
@TrackerLogger
fun provideTrackerLogger(
@ApplicationContext context: Context,
settings: AppSettings,
) = FileLogger(context, settings, "tracker")
@Provides
@ElementsIntoSet
fun provideAllLoggers(
@TrackerLogger trackerLogger: FileLogger,
): Set<@JvmSuppressWildcards FileLogger> = arraySetOf(
trackerLogger,
)
}

View File

@@ -1,19 +1,17 @@
package org.koitharu.kotatsu.core.network package org.koitharu.kotatsu.core.network.cookies
import android.webkit.CookieManager import android.webkit.CookieManager
import javax.inject.Inject import androidx.annotation.WorkerThread
import javax.inject.Singleton import okhttp3.Cookie
import okhttp3.HttpUrl
import kotlin.coroutines.resume import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine import kotlin.coroutines.suspendCoroutine
import okhttp3.Cookie
import okhttp3.CookieJar
import okhttp3.HttpUrl
@Singleton class AndroidCookieJar : MutableCookieJar {
class AndroidCookieJar @Inject constructor() : CookieJar {
private val cookieManager = CookieManager.getInstance() private val cookieManager = CookieManager.getInstance()
@WorkerThread
override fun loadForRequest(url: HttpUrl): List<Cookie> { override fun loadForRequest(url: HttpUrl): List<Cookie> {
val rawCookie = cookieManager.getCookie(url.toString()) ?: return emptyList() val rawCookie = cookieManager.getCookie(url.toString()) ?: return emptyList()
return rawCookie.split(';').mapNotNull { return rawCookie.split(';').mapNotNull {
@@ -21,6 +19,7 @@ class AndroidCookieJar @Inject constructor() : CookieJar {
} }
} }
@WorkerThread
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) { override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
if (cookies.isEmpty()) { if (cookies.isEmpty()) {
return return
@@ -31,7 +30,7 @@ class AndroidCookieJar @Inject constructor() : CookieJar {
} }
} }
suspend fun clear() = suspendCoroutine<Boolean> { continuation -> override suspend fun clear() = suspendCoroutine<Boolean> { continuation ->
cookieManager.removeAllCookies(continuation::resume) cookieManager.removeAllCookies(continuation::resume)
} }
} }

View File

@@ -0,0 +1,84 @@
package org.koitharu.kotatsu.core.network.cookies
import android.util.Base64
import okhttp3.Cookie
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.ObjectInputStream
import java.io.ObjectOutputStream
class CookieWrapper(
val cookie: Cookie,
) {
constructor(encodedString: String) : this(
ObjectInputStream(ByteArrayInputStream(Base64.decode(encodedString, Base64.NO_WRAP))).use {
val name = it.readUTF()
val value = it.readUTF()
val expiresAt = it.readLong()
val domain = it.readUTF()
val path = it.readUTF()
val secure = it.readBoolean()
val httpOnly = it.readBoolean()
val persistent = it.readBoolean()
val hostOnly = it.readBoolean()
Cookie.Builder().also { c ->
c.name(name)
c.value(value)
if (persistent) {
c.expiresAt(expiresAt)
}
if (hostOnly) {
c.hostOnlyDomain(domain)
} else {
c.domain(domain)
}
c.path(path)
if (secure) {
c.secure()
}
if (httpOnly) {
c.httpOnly()
}
}.build()
},
)
fun encode(): String {
val output = ByteArrayOutputStream()
ObjectOutputStream(output).use {
it.writeUTF(cookie.name)
it.writeUTF(cookie.value)
it.writeLong(cookie.expiresAt)
it.writeUTF(cookie.domain)
it.writeUTF(cookie.path)
it.writeBoolean(cookie.secure)
it.writeBoolean(cookie.httpOnly)
it.writeBoolean(cookie.persistent)
it.writeBoolean(cookie.hostOnly)
}
return Base64.encodeToString(output.toByteArray(), Base64.NO_WRAP)
}
fun isExpired() = cookie.expiresAt < System.currentTimeMillis()
fun key(): String {
return (if (cookie.secure) "https" else "http") + "://" + cookie.domain + cookie.path + "|" + cookie.name
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as CookieWrapper
if (cookie != other.cookie) return false
return true
}
override fun hashCode(): Int {
return cookie.hashCode()
}
}

View File

@@ -0,0 +1,17 @@
package org.koitharu.kotatsu.core.network.cookies
import androidx.annotation.WorkerThread
import okhttp3.Cookie
import okhttp3.CookieJar
import okhttp3.HttpUrl
interface MutableCookieJar : CookieJar {
@WorkerThread
override fun loadForRequest(url: HttpUrl): List<Cookie>
@WorkerThread
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>)
suspend fun clear(): Boolean
}

View File

@@ -0,0 +1,89 @@
package org.koitharu.kotatsu.core.network.cookies
import android.content.Context
import androidx.annotation.WorkerThread
import androidx.collection.ArrayMap
import androidx.core.content.edit
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.Cookie
import okhttp3.HttpUrl
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
private const val PREFS_NAME = "cookies"
class PreferencesCookieJar(
context: Context,
) : MutableCookieJar {
private val cache = ArrayMap<String, CookieWrapper>()
private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
private var isLoaded = false
@WorkerThread
override fun loadForRequest(url: HttpUrl): List<Cookie> {
loadPersistent()
val expired = HashSet<String>()
val result = ArrayList<Cookie>()
for ((key, cookie) in cache) {
if (cookie.isExpired()) {
expired += key
} else if (cookie.cookie.matches(url)) {
result += cookie.cookie
}
}
if (expired.isNotEmpty()) {
cache.removeAll(expired)
removePersistent(expired)
}
return result
}
@WorkerThread
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
val wrapped = cookies.map { CookieWrapper(it) }
prefs.edit(commit = true) {
for (cookie in wrapped) {
val key = cookie.key()
cache[key] = cookie
if (cookie.cookie.persistent) {
putString(key, cookie.encode())
}
}
}
}
override suspend fun clear(): Boolean {
cache.clear()
withContext(Dispatchers.IO) {
prefs.edit(commit = true) { clear() }
}
return true
}
@Synchronized
private fun loadPersistent() {
if (!isLoaded) {
val map = prefs.all
cache.ensureCapacity(map.size)
for ((k, v) in map) {
val cookie = try {
CookieWrapper(v as String)
} catch (e: Exception) {
e.printStackTraceDebug()
continue
}
cache[k] = cookie
}
isLoaded = true
}
}
private fun removePersistent(keys: Collection<String>) {
prefs.edit(commit = true) {
for (key in keys) {
remove(key)
}
}
}
}

View File

@@ -0,0 +1,46 @@
package org.koitharu.kotatsu.core.os
import android.net.ConnectivityManager
import android.net.ConnectivityManager.NetworkCallback
import android.net.Network
import android.net.NetworkRequest
import kotlinx.coroutines.flow.first
import org.koitharu.kotatsu.utils.MediatorStateFlow
import org.koitharu.kotatsu.utils.ext.isNetworkAvailable
class NetworkState(
private val connectivityManager: ConnectivityManager,
) : MediatorStateFlow<Boolean>(connectivityManager.isNetworkAvailable) {
private val callback = NetworkCallbackImpl()
override fun onActive() {
invalidate()
val request = NetworkRequest.Builder().build()
connectivityManager.registerNetworkCallback(request, callback)
}
override fun onInactive() {
connectivityManager.unregisterNetworkCallback(callback)
}
suspend fun awaitForConnection() {
if (value) {
return
}
first { it }
}
private fun invalidate() {
publishValue(connectivityManager.isNetworkAvailable)
}
private inner class NetworkCallbackImpl : NetworkCallback() {
override fun onAvailable(network: Network) = invalidate()
override fun onLost(network: Network) = invalidate()
override fun onUnavailable() = invalidate()
}
}

View File

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

View File

@@ -6,25 +6,25 @@ import android.util.Base64
import android.webkit.WebView import android.webkit.WebView
import androidx.core.os.LocaleListCompat import androidx.core.os.LocaleListCompat
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import java.util.*
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import org.koitharu.kotatsu.core.network.AndroidCookieJar import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
import org.koitharu.kotatsu.core.prefs.SourceSettings import org.koitharu.kotatsu.core.prefs.SourceSettings
import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.config.MangaSourceConfig import org.koitharu.kotatsu.parsers.config.MangaSourceConfig
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.utils.ext.toList import org.koitharu.kotatsu.utils.ext.toList
import java.util.*
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
@Singleton @Singleton
class MangaLoaderContextImpl @Inject constructor( class MangaLoaderContextImpl @Inject constructor(
override val httpClient: OkHttpClient, override val httpClient: OkHttpClient,
override val cookieJar: AndroidCookieJar, override val cookieJar: MutableCookieJar,
@ApplicationContext private val androidContext: Context, @ApplicationContext private val androidContext: Context,
) : MangaLoaderContext() { ) : MangaLoaderContext() {

View File

@@ -1,13 +1,19 @@
package org.koitharu.kotatsu.core.parser package org.koitharu.kotatsu.core.parser
import org.koitharu.kotatsu.core.cache.ContentCache
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder
import java.lang.ref.WeakReference import java.lang.ref.WeakReference
import java.util.* import java.util.EnumMap
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
import kotlin.collections.set import kotlin.collections.set
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.model.*
interface MangaRepository { interface MangaRepository {
@@ -31,6 +37,7 @@ interface MangaRepository {
class Factory @Inject constructor( class Factory @Inject constructor(
private val localMangaRepository: LocalMangaRepository, private val localMangaRepository: LocalMangaRepository,
private val loaderContext: MangaLoaderContext, private val loaderContext: MangaLoaderContext,
private val contentCache: ContentCache,
) { ) {
private val cache = EnumMap<MangaSource, WeakReference<RemoteMangaRepository>>(MangaSource::class.java) private val cache = EnumMap<MangaSource, WeakReference<RemoteMangaRepository>>(MangaSource::class.java)
@@ -42,7 +49,7 @@ interface MangaRepository {
cache[source]?.get()?.let { return it } cache[source]?.get()?.let { return it }
return synchronized(cache) { return synchronized(cache) {
cache[source]?.get()?.let { return it } cache[source]?.get()?.let { return it }
val repository = RemoteMangaRepository(MangaParser(source, loaderContext)) val repository = RemoteMangaRepository(MangaParser(source, loaderContext), contentCache)
cache[source] = WeakReference(repository) cache[source] = WeakReference(repository)
repository repository
} }

View File

@@ -1,12 +1,31 @@
package org.koitharu.kotatsu.core.parser package org.koitharu.kotatsu.core.parser
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.MainCoroutineDispatcher
import kotlinx.coroutines.async
import kotlinx.coroutines.currentCoroutineContext
import org.koitharu.kotatsu.core.cache.ContentCache
import org.koitharu.kotatsu.core.cache.SafeDeferred
import org.koitharu.kotatsu.core.prefs.SourceSettings import org.koitharu.kotatsu.core.prefs.SourceSettings
import org.koitharu.kotatsu.parsers.MangaParser import org.koitharu.kotatsu.parsers.MangaParser
import org.koitharu.kotatsu.parsers.MangaParserAuthProvider import org.koitharu.kotatsu.parsers.MangaParserAuthProvider
import org.koitharu.kotatsu.parsers.config.ConfigKey import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.model.* import org.koitharu.kotatsu.parsers.model.Favicons
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.utils.ext.processLifecycleScope
import org.koitharu.kotatsu.utils.ext.runCatchingCancellable
class RemoteMangaRepository(private val parser: MangaParser) : MangaRepository { class RemoteMangaRepository(
private val parser: MangaParser,
private val cache: ContentCache,
) : MangaRepository {
override val source: MangaSource override val source: MangaSource
get() = parser.source get() = parser.source
@@ -28,9 +47,23 @@ class RemoteMangaRepository(private val parser: MangaParser) : MangaRepository {
return parser.getList(offset, tags, sortOrder) return parser.getList(offset, tags, sortOrder)
} }
override suspend fun getDetails(manga: Manga): Manga = parser.getDetails(manga) override suspend fun getDetails(manga: Manga): Manga {
cache.getDetails(source, manga.url)?.let { return it }
val details = asyncSafe {
parser.getDetails(manga)
}
cache.putDetails(source, manga.url, details)
return details.await()
}
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> = parser.getPages(chapter) override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
cache.getPages(source, chapter.url)?.let { return it }
val pages = asyncSafe {
parser.getPages(chapter)
}
cache.putPages(source, chapter.url, pages)
return pages.await()
}
override suspend fun getPageUrl(page: MangaPage): String = parser.getPageUrl(page) override suspend fun getPageUrl(page: MangaPage): String = parser.getPageUrl(page)
@@ -45,4 +78,16 @@ class RemoteMangaRepository(private val parser: MangaParser) : MangaRepository {
} }
private fun getConfig() = parser.config as SourceSettings private fun getConfig() = parser.config as SourceSettings
}
private suspend fun <T> asyncSafe(block: suspend CoroutineScope.() -> T): SafeDeferred<T> {
var dispatcher = currentCoroutineContext()[CoroutineDispatcher.Key]
if (dispatcher == null || dispatcher is MainCoroutineDispatcher) {
dispatcher = Dispatchers.Default
}
return SafeDeferred(
processLifecycleScope.async(dispatcher) {
runCatchingCancellable { block() }
},
)
}
}

View File

@@ -2,12 +2,12 @@ package org.koitharu.kotatsu.core.prefs
import android.content.Context import android.content.Context
import android.content.SharedPreferences import android.content.SharedPreferences
import android.net.ConnectivityManager
import android.net.Uri import android.net.Uri
import android.provider.Settings import android.provider.Settings
import androidx.appcompat.app.AppCompatDelegate import androidx.appcompat.app.AppCompatDelegate
import androidx.collection.arraySetOf import androidx.collection.arraySetOf
import androidx.core.content.edit import androidx.core.content.edit
import androidx.core.os.LocaleListCompat
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import com.google.android.material.color.DynamicColors import com.google.android.material.color.DynamicColors
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
@@ -17,6 +17,7 @@ import org.koitharu.kotatsu.core.network.DoHProvider
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.shelf.domain.ShelfSection import org.koitharu.kotatsu.shelf.domain.ShelfSection
import org.koitharu.kotatsu.utils.ext.connectivityManager
import org.koitharu.kotatsu.utils.ext.getEnumValue import org.koitharu.kotatsu.utils.ext.getEnumValue
import org.koitharu.kotatsu.utils.ext.observe import org.koitharu.kotatsu.utils.ext.observe
import org.koitharu.kotatsu.utils.ext.putEnumValue import org.koitharu.kotatsu.utils.ext.putEnumValue
@@ -34,6 +35,7 @@ import javax.inject.Singleton
class AppSettings @Inject constructor(@ApplicationContext context: Context) { class AppSettings @Inject constructor(@ApplicationContext context: Context) {
private val prefs = PreferenceManager.getDefaultSharedPreferences(context) private val prefs = PreferenceManager.getDefaultSharedPreferences(context)
private val connectivityManager = context.connectivityManager
private val remoteSources = EnumSet.allOf(MangaSource::class.java).apply { private val remoteSources = EnumSet.allOf(MangaSource::class.java).apply {
remove(MangaSource.LOCAL) remove(MangaSource.LOCAL)
@@ -78,6 +80,17 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
get() = prefs.getInt(KEY_GRID_SIZE, 100) get() = prefs.getInt(KEY_GRID_SIZE, 100)
set(value) = prefs.edit { putInt(KEY_GRID_SIZE, value) } set(value) = prefs.edit { putInt(KEY_GRID_SIZE, value) }
var appLocales: LocaleListCompat
get() {
val raw = prefs.getString(KEY_APP_LOCALE, null)
return LocaleListCompat.forLanguageTags(raw)
}
set(value) {
prefs.edit {
putString(KEY_APP_LOCALE, value.toLanguageTags())
}
}
val readerPageSwitch: Set<String> val readerPageSwitch: Set<String>
get() = prefs.getStringSet(KEY_READER_SWITCHERS, null) ?: setOf(PAGE_SWITCH_TAPS) get() = prefs.getStringSet(KEY_READER_SWITCHERS, null) ?: setOf(PAGE_SWITCH_TAPS)
@@ -146,6 +159,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
get() = prefs.getString(KEY_APP_PASSWORD, null) get() = prefs.getString(KEY_APP_PASSWORD, null)
set(value) = prefs.edit { if (value != null) putString(KEY_APP_PASSWORD, value) else remove(KEY_APP_PASSWORD) } set(value) = prefs.edit { if (value != null) putString(KEY_APP_PASSWORD, value) else remove(KEY_APP_PASSWORD) }
val isLoggingEnabled: Boolean
get() = prefs.getBoolean(KEY_LOGGING_ENABLED, false)
var isBiometricProtectionEnabled: Boolean var isBiometricProtectionEnabled: Boolean
get() = prefs.getBoolean(KEY_PROTECT_APP_BIOMETRIC, true) get() = prefs.getBoolean(KEY_PROTECT_APP_BIOMETRIC, true)
set(value) = prefs.edit { putBoolean(KEY_PROTECT_APP_BIOMETRIC, value) } set(value) = prefs.edit { putBoolean(KEY_PROTECT_APP_BIOMETRIC, value) }
@@ -156,6 +172,11 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val isDynamicShortcutsEnabled: Boolean val isDynamicShortcutsEnabled: Boolean
get() = prefs.getBoolean(KEY_SHORTCUTS, true) get() = prefs.getBoolean(KEY_SHORTCUTS, true)
fun isContentPrefetchEnabled(): Boolean {
val policy = NetworkPolicy.from(prefs.getString(KEY_PREFETCH_CONTENT, null), NetworkPolicy.NEVER)
return policy.isNetworkAllowed(connectivityManager)
}
var sourcesOrder: List<String> var sourcesOrder: List<String>
get() = prefs.getString(KEY_SOURCES_ORDER, null) get() = prefs.getString(KEY_SOURCES_ORDER, null)
?.split('|') ?.split('|')
@@ -234,12 +255,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val isWebtoonZoomEnable: Boolean val isWebtoonZoomEnable: Boolean
get() = prefs.getBoolean(KEY_WEBTOON_ZOOM, true) get() = prefs.getBoolean(KEY_WEBTOON_ZOOM, true)
fun isPagesPreloadAllowed(cm: ConnectivityManager): Boolean { fun isPagesPreloadEnabled(): Boolean {
return when (prefs.getString(KEY_PAGES_PRELOAD, null)?.toIntOrNull()) { val policy = NetworkPolicy.from(prefs.getString(KEY_PAGES_PRELOAD, null), NetworkPolicy.NON_METERED)
NETWORK_ALWAYS -> true return policy.isNetworkAllowed(connectivityManager)
NETWORK_NEVER -> false
else -> cm.isActiveNetworkMetered
}
} }
fun getDateFormat(format: String = prefs.getString(KEY_DATE_FORMAT, "").orEmpty()): DateFormat = fun getDateFormat(format: String = prefs.getString(KEY_DATE_FORMAT, "").orEmpty()): DateFormat =
@@ -293,7 +311,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val TRACK_FAVOURITES = "favourites" const val TRACK_FAVOURITES = "favourites"
const val KEY_LIST_MODE = "list_mode_2" const val KEY_LIST_MODE = "list_mode_2"
const val KEY_APP_SECTION = "app_section_2"
const val KEY_THEME = "theme" const val KEY_THEME = "theme"
const val KEY_DYNAMIC_THEME = "dynamic_theme" const val KEY_DYNAMIC_THEME = "dynamic_theme"
const val KEY_THEME_AMOLED = "amoled_theme" const val KEY_THEME_AMOLED = "amoled_theme"
@@ -355,13 +372,13 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_LOCAL_LIST_ORDER = "local_order" const val KEY_LOCAL_LIST_ORDER = "local_order"
const val KEY_WEBTOON_ZOOM = "webtoon_zoom" const val KEY_WEBTOON_ZOOM = "webtoon_zoom"
const val KEY_SHELF_SECTIONS = "shelf_sections_2" 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"
const val KEY_LOGS_SHARE = "logs_share"
// About // About
const val KEY_APP_UPDATE = "app_update" const val KEY_APP_UPDATE = "app_update"
const val KEY_APP_TRANSLATION = "about_app_translation" const val KEY_APP_TRANSLATION = "about_app_translation"
private const val NETWORK_NEVER = 0
private const val NETWORK_ALWAYS = 1
private const val NETWORK_NON_METERED = 2
} }
} }

View File

@@ -0,0 +1,26 @@
package org.koitharu.kotatsu.core.prefs
import android.net.ConnectivityManager
enum class NetworkPolicy(
private val key: Int,
) {
NEVER(0),
ALWAYS(1),
NON_METERED(2);
fun isNetworkAllowed(cm: ConnectivityManager) = when (this) {
NEVER -> false
ALWAYS -> true
NON_METERED -> !cm.isActiveNetworkMetered
}
companion object {
fun from(key: String?, default: NetworkPolicy): NetworkPolicy {
val intKey = key?.toIntOrNull() ?: return default
return enumValues<NetworkPolicy>().find { it.key == intKey } ?: default
}
}
}

View File

@@ -52,10 +52,13 @@ class ZipOutput(
return if (entryNames.add(entry.name)) { return if (entryNames.add(entry.name)) {
val zipEntry = ZipEntry(entry.name) val zipEntry = ZipEntry(entry.name)
output.putNextEntry(zipEntry) output.putNextEntry(zipEntry)
other.getInputStream(entry).use { input -> try {
input.copyTo(output) other.getInputStream(entry).use { input ->
input.copyTo(output)
}
} finally {
output.closeEntry()
} }
output.closeEntry()
true true
} else { } else {
false false

View File

@@ -0,0 +1,116 @@
package org.koitharu.kotatsu.details.service
import android.content.Context
import android.content.Intent
import dagger.hilt.android.AndroidEntryPoint
import dagger.hilt.android.EntryPointAccessors
import org.koitharu.kotatsu.base.ui.CoroutineIntentService
import org.koitharu.kotatsu.core.cache.ContentCache
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.model.parcelable.ParcelableMangaChapters
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.history.domain.HistoryRepository
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.utils.ext.getParcelableExtraCompat
import org.koitharu.kotatsu.utils.ext.runCatchingCancellable
import javax.inject.Inject
@AndroidEntryPoint
class MangaPrefetchService : CoroutineIntentService() {
@Inject
lateinit var mangaRepositoryFactory: MangaRepository.Factory
@Inject
lateinit var cache: ContentCache
@Inject
lateinit var historyRepository: HistoryRepository
override suspend fun processIntent(startId: Int, intent: Intent) {
when (intent.action) {
ACTION_PREFETCH_DETAILS -> prefetchDetails(
manga = intent.getParcelableExtraCompat<ParcelableManga>(EXTRA_MANGA)?.manga ?: return,
)
ACTION_PREFETCH_PAGES -> prefetchPages(
chapter = intent.getParcelableExtraCompat<ParcelableMangaChapters>(EXTRA_CHAPTER)
?.chapters?.singleOrNull() ?: return,
)
ACTION_PREFETCH_LAST -> prefetchLast()
}
}
override fun onError(startId: Int, error: Throwable) = Unit
private suspend fun prefetchDetails(manga: Manga) {
val source = mangaRepositoryFactory.create(manga.source)
runCatchingCancellable { source.getDetails(manga) }
}
private suspend fun prefetchPages(chapter: MangaChapter) {
val source = mangaRepositoryFactory.create(chapter.source)
runCatchingCancellable { source.getPages(chapter) }
}
private suspend fun prefetchLast() {
val last = historyRepository.getLastOrNull() ?: return
if (last.source == MangaSource.LOCAL) return
val repo = mangaRepositoryFactory.create(last.source)
val details = runCatchingCancellable { repo.getDetails(last) }.getOrNull() ?: return
val chapters = details.chapters
if (chapters.isNullOrEmpty()) {
return
}
val history = historyRepository.getOne(last)
val chapter = if (history == null) {
chapters.firstOrNull()
} else {
chapters.find { x -> x.id == history.chapterId } ?: chapters.firstOrNull()
} ?: return
runCatchingCancellable { repo.getPages(chapter) }
}
companion object {
private const val EXTRA_MANGA = "manga"
private const val EXTRA_CHAPTER = "manga"
private const val ACTION_PREFETCH_DETAILS = "details"
private const val ACTION_PREFETCH_PAGES = "pages"
private const val ACTION_PREFETCH_LAST = "last"
fun prefetchDetails(context: Context, manga: Manga) {
if (!isPrefetchAvailable(context, manga.source)) return
val intent = Intent(context, MangaPrefetchService::class.java)
intent.action = ACTION_PREFETCH_DETAILS
intent.putExtra(EXTRA_MANGA, ParcelableManga(manga, withChapters = false))
context.startService(intent)
}
fun prefetchPages(context: Context, chapter: MangaChapter) {
if (!isPrefetchAvailable(context, chapter.source)) return
val intent = Intent(context, MangaPrefetchService::class.java)
intent.action = ACTION_PREFETCH_PAGES
intent.putExtra(EXTRA_CHAPTER, ParcelableMangaChapters(listOf(chapter)))
context.startService(intent)
}
fun prefetchLast(context: Context) {
if (!isPrefetchAvailable(context, null)) return
val intent = Intent(context, MangaPrefetchService::class.java)
intent.action = ACTION_PREFETCH_LAST
context.startService(intent)
}
private fun isPrefetchAvailable(context: Context, source: MangaSource?): Boolean {
if (source == MangaSource.LOCAL) {
return false
}
val entryPoint = EntryPointAccessors.fromApplication(context, PrefetchCompanionEntryPoint::class.java)
return entryPoint.contentCache.isCachingEnabled && entryPoint.settings.isContentPrefetchEnabled()
}
}
}

View File

@@ -0,0 +1,14 @@
package org.koitharu.kotatsu.details.service
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import org.koitharu.kotatsu.core.cache.ContentCache
import org.koitharu.kotatsu.core.prefs.AppSettings
@EntryPoint
@InstallIn(SingletonComponent::class)
interface PrefetchCompanionEntryPoint {
val settings: AppSettings
val contentCache: ContentCache
}

View File

@@ -1,15 +1,16 @@
package org.koitharu.kotatsu.details.ui package org.koitharu.kotatsu.details.ui
import android.os.Bundle import android.os.Bundle
import android.view.* import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.view.ActionMode import androidx.appcompat.view.ActionMode
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.graphics.Insets import androidx.core.graphics.Insets
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import kotlin.math.roundToInt
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseFragment import org.koitharu.kotatsu.base.ui.BaseFragment
import org.koitharu.kotatsu.base.ui.list.ListSelectionController import org.koitharu.kotatsu.base.ui.list.ListSelectionController
@@ -24,8 +25,8 @@ import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.reader.ui.ReaderActivity import org.koitharu.kotatsu.reader.ui.ReaderActivity
import org.koitharu.kotatsu.reader.ui.ReaderState import org.koitharu.kotatsu.reader.ui.ReaderState
import org.koitharu.kotatsu.utils.RecyclerViewScrollCallback import org.koitharu.kotatsu.utils.RecyclerViewScrollCallback
import org.koitharu.kotatsu.utils.ext.parents
import org.koitharu.kotatsu.utils.ext.scaleUpActivityOptionsOf import org.koitharu.kotatsu.utils.ext.scaleUpActivityOptionsOf
import kotlin.math.roundToInt
class ChaptersFragment : class ChaptersFragment :
BaseFragment<FragmentChaptersBinding>(), BaseFragment<FragmentChaptersBinding>(),
@@ -102,6 +103,7 @@ class ChaptersFragment :
mode.finish() mode.finish()
true true
} }
R.id.action_delete -> { R.id.action_delete -> {
val ids = selectionController?.peekCheckedIds() val ids = selectionController?.peekCheckedIds()
val manga = viewModel.manga.value val manga = viewModel.manga.value
@@ -120,6 +122,7 @@ class ChaptersFragment :
mode.finish() mode.finish()
true true
} }
R.id.action_select_range -> { R.id.action_select_range -> {
val items = chaptersAdapter?.items ?: return false val items = chaptersAdapter?.items ?: return false
val ids = HashSet(controller.peekCheckedIds()) val ids = HashSet(controller.peekCheckedIds())
@@ -139,11 +142,20 @@ class ChaptersFragment :
controller.addAll(ids) controller.addAll(ids)
true true
} }
R.id.action_select_all -> { R.id.action_select_all -> {
val ids = chaptersAdapter?.items?.map { it.chapter.id } ?: return false val ids = chaptersAdapter?.items?.map { it.chapter.id } ?: return false
selectionController?.addAll(ids) controller.addAll(ids)
true true
} }
R.id.action_mark_current -> {
val id = controller.peekCheckedIds().singleOrNull() ?: return false
viewModel.markChapterAsCurrent(id)
mode.finish()
true
}
else -> false else -> false
} }
} }
@@ -164,6 +176,7 @@ class ChaptersFragment :
x.chapter.source == MangaSource.LOCAL x.chapter.source == MangaSource.LOCAL
} }
menu.findItem(R.id.action_select_all).isVisible = items.size < allItems.size menu.findItem(R.id.action_select_all).isVisible = items.size < allItems.size
menu.findItem(R.id.action_mark_current).isVisible = items.size == 1
mode.title = items.size.toString() mode.title = items.size.toString()
var hasGap = false var hasGap = false
for (i in 0 until items.size - 1) { for (i in 0 until items.size - 1) {

View File

@@ -18,6 +18,7 @@ import androidx.core.graphics.Insets
import androidx.core.view.isGone import androidx.core.view.isGone
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
import androidx.lifecycle.Observer
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.BaseTransientBottomBar import com.google.android.material.snackbar.BaseTransientBottomBar
@@ -33,6 +34,8 @@ import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.os.ShortcutsUpdater import org.koitharu.kotatsu.core.os.ShortcutsUpdater
import org.koitharu.kotatsu.core.ui.MangaErrorDialog import org.koitharu.kotatsu.core.ui.MangaErrorDialog
import org.koitharu.kotatsu.databinding.ActivityDetailsBinding import org.koitharu.kotatsu.databinding.ActivityDetailsBinding
import org.koitharu.kotatsu.details.service.MangaPrefetchService
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.details.ui.model.HistoryInfo import org.koitharu.kotatsu.details.ui.model.HistoryInfo
import org.koitharu.kotatsu.download.ui.service.DownloadService import org.koitharu.kotatsu.download.ui.service.DownloadService
import org.koitharu.kotatsu.main.ui.owners.NoModalBottomSheetOwner import org.koitharu.kotatsu.main.ui.owners.NoModalBottomSheetOwner
@@ -120,6 +123,7 @@ class DetailsActivity :
viewModel.branches.observe(this) { viewModel.branches.observe(this) {
binding.buttonDropdown.isVisible = it.size > 1 binding.buttonDropdown.isVisible = it.size > 1
} }
viewModel.chapters.observe(this, PrefetchObserver(this))
registerReceiver(downloadReceiver, IntentFilter(DownloadService.ACTION_DOWNLOAD_COMPLETE)) registerReceiver(downloadReceiver, IntentFilter(DownloadService.ACTION_DOWNLOAD_COMPLETE))
addMenuProvider( addMenuProvider(
@@ -228,18 +232,18 @@ class DetailsActivity :
} }
} }
private fun onHistoryChanged(info: HistoryInfo?) { private fun onHistoryChanged(info: HistoryInfo) {
with(binding.buttonRead) { with(binding.buttonRead) {
if (info?.history != null) { if (info.history != null) {
setText(R.string._continue) setText(R.string._continue)
setIconResource(R.drawable.ic_play) setIconResource(if (info.isIncognitoMode) R.drawable.ic_incognito else R.drawable.ic_play)
} else { } else {
setText(R.string.read) setText(R.string.read)
setIconResource(R.drawable.ic_read) setIconResource(if (info.isIncognitoMode) R.drawable.ic_incognito else R.drawable.ic_play)
} }
} }
val text = when { val text = when {
info == null -> getString(R.string.loading_) !info.isValid -> getString(R.string.loading_)
info.currentChapter >= 0 -> getString(R.string.chapter_d_of_d, info.currentChapter + 1, info.totalChapters) info.currentChapter >= 0 -> getString(R.string.chapter_d_of_d, info.currentChapter + 1, info.totalChapters)
info.totalChapters == 0 -> getString(R.string.no_chapters) info.totalChapters == 0 -> getString(R.string.no_chapters)
else -> resources.getQuantityString(R.plurals.chapters, info.totalChapters, info.totalChapters) else -> resources.getQuantityString(R.plurals.chapters, info.totalChapters, info.totalChapters)
@@ -325,6 +329,24 @@ class DetailsActivity :
return sb return sb
} }
private class PrefetchObserver(
private val context: Context,
) : Observer<List<ChapterListItem>> {
private var isCalled = false
override fun onChanged(t: List<ChapterListItem>?) {
if (t.isNullOrEmpty()) {
return
}
if (!isCalled) {
isCalled = true
val item = t.find { it.hasFlag(ChapterListItem.FLAG_CURRENT) } ?: t.first()
MangaPrefetchService.prefetchPages(context, item.chapter)
}
}
}
companion object { companion object {
fun newIntent(context: Context, manga: Manga): Intent { fun newIntent(context: Context, manga: Manga): Intent {

View File

@@ -18,7 +18,6 @@ import coil.request.ImageRequest
import coil.util.CoilUtils import coil.util.CoilUtils
import com.google.android.material.chip.Chip import com.google.android.material.chip.Chip
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseFragment import org.koitharu.kotatsu.base.ui.BaseFragment
@@ -27,9 +26,9 @@ import org.koitharu.kotatsu.base.ui.list.decor.SpacingItemDecoration
import org.koitharu.kotatsu.base.ui.widgets.ChipsView import org.koitharu.kotatsu.base.ui.widgets.ChipsView
import org.koitharu.kotatsu.bookmarks.domain.Bookmark import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.bookmarks.ui.adapter.BookmarksAdapter import org.koitharu.kotatsu.bookmarks.ui.adapter.BookmarksAdapter
import org.koitharu.kotatsu.core.model.MangaHistory
import org.koitharu.kotatsu.databinding.FragmentDetailsBinding import org.koitharu.kotatsu.databinding.FragmentDetailsBinding
import org.koitharu.kotatsu.details.ui.model.ChapterListItem import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.details.ui.model.HistoryInfo
import org.koitharu.kotatsu.details.ui.scrobbling.ScrobblingItemDecoration import org.koitharu.kotatsu.details.ui.scrobbling.ScrobblingItemDecoration
import org.koitharu.kotatsu.details.ui.scrobbling.ScrollingInfoAdapter import org.koitharu.kotatsu.details.ui.scrobbling.ScrollingInfoAdapter
import org.koitharu.kotatsu.history.domain.PROGRESS_NONE import org.koitharu.kotatsu.history.domain.PROGRESS_NONE
@@ -45,8 +44,20 @@ import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblingInfo
import org.koitharu.kotatsu.search.ui.MangaListActivity import org.koitharu.kotatsu.search.ui.MangaListActivity
import org.koitharu.kotatsu.search.ui.SearchActivity import org.koitharu.kotatsu.search.ui.SearchActivity
import org.koitharu.kotatsu.utils.FileSize import org.koitharu.kotatsu.utils.FileSize
import org.koitharu.kotatsu.utils.ext.* import org.koitharu.kotatsu.utils.ext.computeSize
import org.koitharu.kotatsu.utils.ext.crossfade
import org.koitharu.kotatsu.utils.ext.drawableTop
import org.koitharu.kotatsu.utils.ext.enqueueWith
import org.koitharu.kotatsu.utils.ext.ifNullOrEmpty
import org.koitharu.kotatsu.utils.ext.measureHeight
import org.koitharu.kotatsu.utils.ext.referer
import org.koitharu.kotatsu.utils.ext.resolveDp
import org.koitharu.kotatsu.utils.ext.scaleUpActivityOptionsOf
import org.koitharu.kotatsu.utils.ext.textAndVisible
import org.koitharu.kotatsu.utils.ext.toFileOrNull
import org.koitharu.kotatsu.utils.ext.viewLifecycleScope
import org.koitharu.kotatsu.utils.image.CoverSizeResolver import org.koitharu.kotatsu.utils.image.CoverSizeResolver
import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class DetailsFragment : class DetailsFragment :
@@ -75,7 +86,7 @@ class DetailsFragment :
binding.chipsTags.onChipClickListener = this binding.chipsTags.onChipClickListener = this
viewModel.manga.observe(viewLifecycleOwner, ::onMangaUpdated) viewModel.manga.observe(viewLifecycleOwner, ::onMangaUpdated)
viewModel.isLoading.observe(viewLifecycleOwner, ::onLoadingStateChanged) viewModel.isLoading.observe(viewLifecycleOwner, ::onLoadingStateChanged)
viewModel.readingHistory.observe(viewLifecycleOwner, ::onHistoryChanged) viewModel.historyInfo.observe(viewLifecycleOwner, ::onHistoryChanged)
viewModel.bookmarks.observe(viewLifecycleOwner, ::onBookmarksChanged) viewModel.bookmarks.observe(viewLifecycleOwner, ::onBookmarksChanged)
viewModel.scrobblingInfo.observe(viewLifecycleOwner, ::onScrobblingInfoChanged) viewModel.scrobblingInfo.observe(viewLifecycleOwner, ::onScrobblingInfoChanged)
viewModel.description.observe(viewLifecycleOwner, ::onDescriptionChanged) viewModel.description.observe(viewLifecycleOwner, ::onDescriptionChanged)
@@ -123,12 +134,14 @@ class DetailsFragment :
drawableTop = ContextCompat.getDrawable(context, R.drawable.ic_state_finished) drawableTop = ContextCompat.getDrawable(context, R.drawable.ic_state_finished)
} }
} }
MangaState.ONGOING -> { MangaState.ONGOING -> {
infoLayout.textViewState.apply { infoLayout.textViewState.apply {
textAndVisible = resources.getString(R.string.state_ongoing) textAndVisible = resources.getString(R.string.state_ongoing)
drawableTop = ContextCompat.getDrawable(context, R.drawable.ic_state_ongoing) drawableTop = ContextCompat.getDrawable(context, R.drawable.ic_state_ongoing)
} }
} }
else -> infoLayout.textViewState.isVisible = false else -> infoLayout.textViewState.isVisible = false
} }
if (manga.source == MangaSource.LOCAL) { if (manga.source == MangaSource.LOCAL) {
@@ -178,8 +191,8 @@ class DetailsFragment :
} }
} }
private fun onHistoryChanged(history: MangaHistory?) { private fun onHistoryChanged(history: HistoryInfo) {
binding.progressView.setPercent(history?.percent ?: PROGRESS_NONE, animate = true) binding.progressView.setPercent(history.history?.percent ?: PROGRESS_NONE, animate = true)
} }
private fun onLoadingStateChanged(isLoading: Boolean) { private fun onLoadingStateChanged(isLoading: Boolean) {
@@ -229,6 +242,7 @@ class DetailsFragment :
), ),
) )
} }
R.id.textView_source -> { R.id.textView_source -> {
startActivity( startActivity(
MangaListActivity.newIntent( MangaListActivity.newIntent(
@@ -237,6 +251,7 @@ class DetailsFragment :
), ),
) )
} }
R.id.imageView_cover -> { R.id.imageView_cover -> {
startActivity( startActivity(
ImageActivity.newIntent(v.context, manga.largeCoverUrl.ifNullOrEmpty { manga.coverUrl }), ImageActivity.newIntent(v.context, manga.largeCoverUrl.ifNullOrEmpty { manga.coverUrl }),
@@ -249,7 +264,7 @@ class DetailsFragment :
override fun onLongClick(v: View): Boolean { override fun onLongClick(v: View): Boolean {
when (v.id) { when (v.id) {
R.id.button_read -> { R.id.button_read -> {
if (viewModel.readingHistory.value == null) { if (viewModel.historyInfo.value?.history == null) {
return false return false
} }
val menu = PopupMenu(v.context, v) val menu = PopupMenu(v.context, v)
@@ -271,12 +286,14 @@ class DetailsFragment :
) )
true true
} }
else -> false else -> false
} }
} }
menu.show() menu.show()
return true return true
} }
else -> return false else -> return false
} }
} }

View File

@@ -13,10 +13,18 @@ import androidx.lifecycle.viewModelScope
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import java.io.IOException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.transformLatest
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.plus import kotlinx.coroutines.plus
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
@@ -46,6 +54,7 @@ import org.koitharu.kotatsu.utils.asFlowLiveData
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import org.koitharu.kotatsu.utils.ext.runCatchingCancellable import org.koitharu.kotatsu.utils.ext.runCatchingCancellable
import java.io.IOException
class DetailsViewModel @AssistedInject constructor( class DetailsViewModel @AssistedInject constructor(
@Assisted intent: MangaIntent, @Assisted intent: MangaIntent,
@@ -80,8 +89,14 @@ class DetailsViewModel @AssistedInject constructor(
private val favourite = favouritesRepository.observeCategoriesIds(delegate.mangaId).map { it.isNotEmpty() } private val favourite = favouritesRepository.observeCategoriesIds(delegate.mangaId).map { it.isNotEmpty() }
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, false) .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, false)
private val newChapters = trackingRepository.observeNewChaptersCount(delegate.mangaId) private val newChapters = settings.observeAsFlow(AppSettings.KEY_TRACKER_ENABLED) { isTrackerEnabled }
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, 0) .flatMapLatest { isEnabled ->
if (isEnabled) {
trackingRepository.observeNewChaptersCount(delegate.mangaId)
} else {
flowOf(0)
}
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, 0)
private val chaptersQuery = MutableStateFlow("") private val chaptersQuery = MutableStateFlow("")
@@ -91,17 +106,18 @@ class DetailsViewModel @AssistedInject constructor(
val manga = delegate.manga.filterNotNull().asLiveData(viewModelScope.coroutineContext) val manga = delegate.manga.filterNotNull().asLiveData(viewModelScope.coroutineContext)
val favouriteCategories = favourite.asLiveData(viewModelScope.coroutineContext) val favouriteCategories = favourite.asLiveData(viewModelScope.coroutineContext)
val newChaptersCount = newChapters.asLiveData(viewModelScope.coroutineContext) val newChaptersCount = newChapters.asLiveData(viewModelScope.coroutineContext)
@Deprecated("")
val readingHistory = history.asLiveData(viewModelScope.coroutineContext)
val isChaptersReversed = chaptersReversed.asLiveData(viewModelScope.coroutineContext) val isChaptersReversed = chaptersReversed.asLiveData(viewModelScope.coroutineContext)
val historyInfo = combine( val historyInfo: LiveData<HistoryInfo> = combine(
delegate.manga, delegate.manga,
history, history,
) { m, h -> historyRepository.observeShouldSkip(delegate.manga),
HistoryInfo(m, h) ) { m, h, im ->
}.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, null) HistoryInfo(m, h, im)
}.asFlowLiveData(
context = viewModelScope.coroutineContext + Dispatchers.Default,
defaultValue = HistoryInfo(null, null, false),
)
val bookmarks = delegate.manga.flatMapLatest { val bookmarks = delegate.manga.flatMapLatest {
if (it != null) bookmarksRepository.observeBookmarks(it) else flowOf(emptyList()) if (it != null) bookmarksRepository.observeBookmarks(it) else flowOf(emptyList())
@@ -265,6 +281,17 @@ class DetailsViewModel @AssistedInject constructor(
} }
} }
fun markChapterAsCurrent(chapterId: Long) {
launchJob(Dispatchers.Default) {
val manga = checkNotNull(delegate.manga.value)
val chapters = checkNotNull(manga.chapters)
val chapterIndex = chapters.indexOfFirst { it.id == chapterId }
check(chapterIndex in chapters.indices) { "Chapter not found" }
val percent = chapterIndex / chapters.size.toFloat()
historyRepository.addOrUpdate(manga = manga, chapterId = chapterId, page = 0, scroll = 0, percent = percent)
}
}
private fun doLoad() = launchLoadingJob(Dispatchers.Default) { private fun doLoad() = launchLoadingJob(Dispatchers.Default) {
delegate.doLoad() delegate.doLoad()
} }

View File

@@ -42,7 +42,8 @@ class ChaptersAdapter(
} }
} }
override fun getSectionText(context: Context, position: Int): CharSequence { override fun getSectionText(context: Context, position: Int): CharSequence? {
return items[position].chapter.number.toString() val item = items.getOrNull(position) ?: return null
return item.chapter.number.toString()
} }
} }

View File

@@ -7,8 +7,12 @@ class HistoryInfo(
val totalChapters: Int, val totalChapters: Int,
val currentChapter: Int, val currentChapter: Int,
val history: MangaHistory?, val history: MangaHistory?,
val isIncognitoMode: Boolean,
) { ) {
val isValid: Boolean
get() = totalChapters >= 0
override fun equals(other: Any?): Boolean { override fun equals(other: Any?): Boolean {
if (this === other) return true if (this === other) return true
if (javaClass != other?.javaClass) return false if (javaClass != other?.javaClass) return false
@@ -18,6 +22,7 @@ class HistoryInfo(
if (totalChapters != other.totalChapters) return false if (totalChapters != other.totalChapters) return false
if (currentChapter != other.currentChapter) return false if (currentChapter != other.currentChapter) return false
if (history != other.history) return false if (history != other.history) return false
if (isIncognitoMode != other.isIncognitoMode) return false
return true return true
} }
@@ -26,20 +31,21 @@ class HistoryInfo(
var result = totalChapters var result = totalChapters
result = 31 * result + currentChapter result = 31 * result + currentChapter
result = 31 * result + (history?.hashCode() ?: 0) result = 31 * result + (history?.hashCode() ?: 0)
result = 31 * result + isIncognitoMode.hashCode()
return result return result
} }
} }
@Suppress("FunctionName") fun HistoryInfo(manga: Manga?, history: MangaHistory?, isIncognitoMode: Boolean): HistoryInfo {
fun HistoryInfo(manga: Manga?, history: MangaHistory?): HistoryInfo? { val chapters = manga?.chapters
val chapters = manga?.chapters ?: return null
return HistoryInfo( return HistoryInfo(
totalChapters = chapters.size, totalChapters = chapters?.size ?: -1,
currentChapter = if (history != null) { currentChapter = if (history != null && !chapters.isNullOrEmpty()) {
chapters.indexOfFirst { it.id == history.chapterId } chapters.indexOfFirst { it.id == history.chapterId }
} else { } else {
-1 -1
}, },
history = history, history = history,
isIncognitoMode = isIncognitoMode,
) )
} }

View File

@@ -2,8 +2,10 @@ package org.koitharu.kotatsu.explore.ui
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.MenuItem
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.appcompat.widget.PopupMenu
import androidx.core.graphics.Insets import androidx.core.graphics.Insets
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
import androidx.fragment.app.viewModels import androidx.fragment.app.viewModels
@@ -11,11 +13,12 @@ import androidx.recyclerview.widget.RecyclerView
import coil.ImageLoader import coil.ImageLoader
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.domain.reverseAsync
import org.koitharu.kotatsu.base.ui.BaseFragment import org.koitharu.kotatsu.base.ui.BaseFragment
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.base.ui.util.RecyclerViewOwner import org.koitharu.kotatsu.base.ui.util.RecyclerViewOwner
import org.koitharu.kotatsu.base.ui.util.ReversibleAction
import org.koitharu.kotatsu.bookmarks.ui.BookmarksActivity import org.koitharu.kotatsu.bookmarks.ui.BookmarksActivity
import org.koitharu.kotatsu.databinding.FragmentExploreBinding import org.koitharu.kotatsu.databinding.FragmentExploreBinding
import org.koitharu.kotatsu.details.ui.DetailsActivity import org.koitharu.kotatsu.details.ui.DetailsActivity
@@ -31,6 +34,7 @@ import org.koitharu.kotatsu.search.ui.MangaListActivity
import org.koitharu.kotatsu.settings.SettingsActivity import org.koitharu.kotatsu.settings.SettingsActivity
import org.koitharu.kotatsu.suggestions.ui.SuggestionsActivity import org.koitharu.kotatsu.suggestions.ui.SuggestionsActivity
import org.koitharu.kotatsu.utils.ext.getDisplayMessage import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class ExploreFragment : class ExploreFragment :
@@ -67,6 +71,7 @@ class ExploreFragment :
} }
viewModel.onError.observe(viewLifecycleOwner, ::onError) viewModel.onError.observe(viewLifecycleOwner, ::onError)
viewModel.onOpenManga.observe(viewLifecycleOwner, ::onOpenManga) viewModel.onOpenManga.observe(viewLifecycleOwner, ::onOpenManga)
viewModel.onActionDone.observe(viewLifecycleOwner, ::onActionDone)
} }
override fun onDestroyView() { override fun onDestroyView() {
@@ -95,6 +100,7 @@ class ExploreFragment :
viewModel.openRandom() viewModel.openRandom()
return return
} }
else -> return else -> return
} }
startActivity(intent) startActivity(intent)
@@ -105,6 +111,14 @@ class ExploreFragment :
startActivity(intent) startActivity(intent)
} }
override fun onItemLongClick(item: ExploreItem.Source, view: View): Boolean {
val menu = PopupMenu(view.context, view)
menu.inflate(R.menu.popup_source)
menu.setOnMenuItemClickListener(SourceMenuListener(item))
menu.show()
return true
}
override fun onRetryClick(error: Throwable) = Unit override fun onRetryClick(error: Throwable) = Unit
override fun onEmptyActionClick() = onManageClick(requireView()) override fun onEmptyActionClick() = onManageClick(requireView())
@@ -124,6 +138,37 @@ class ExploreFragment :
startActivity(intent) startActivity(intent)
} }
private fun onActionDone(action: ReversibleAction) {
val handle = action.handle
val length = if (handle == null) Snackbar.LENGTH_SHORT else Snackbar.LENGTH_LONG
val snackbar = Snackbar.make(binding.recyclerView, action.stringResId, length)
if (handle != null) {
snackbar.setAction(R.string.undo) { handle.reverseAsync() }
}
snackbar.anchorView = (activity as? BottomNavOwner)?.bottomNav
snackbar.show()
}
private inner class SourceMenuListener(
private val sourceItem: ExploreItem.Source,
) : PopupMenu.OnMenuItemClickListener {
override fun onMenuItemClick(item: MenuItem): Boolean {
when (item.itemId) {
R.id.action_settings -> {
startActivity(SettingsActivity.newSourceSettingsIntent(requireContext(), sourceItem.source))
}
R.id.action_hide -> {
viewModel.hideSource(sourceItem.source)
}
else -> return false
}
return true
}
}
companion object { companion object {
fun newInstance() = ExploreFragment() fun newInstance() = ExploreFragment()

View File

@@ -4,11 +4,17 @@ import androidx.lifecycle.LiveData
import androidx.lifecycle.asFlow import androidx.lifecycle.asFlow
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.domain.ReversibleHandle
import org.koitharu.kotatsu.base.ui.BaseViewModel import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.base.ui.util.ReversibleAction
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.explore.domain.ExploreRepository import org.koitharu.kotatsu.explore.domain.ExploreRepository
import org.koitharu.kotatsu.explore.ui.model.ExploreItem import org.koitharu.kotatsu.explore.ui.model.ExploreItem
@@ -16,6 +22,7 @@ import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.utils.SingleLiveEvent import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
import javax.inject.Inject
@HiltViewModel @HiltViewModel
class ExploreViewModel @Inject constructor( class ExploreViewModel @Inject constructor(
@@ -24,6 +31,7 @@ class ExploreViewModel @Inject constructor(
) : BaseViewModel() { ) : BaseViewModel() {
val onOpenManga = SingleLiveEvent<Manga>() val onOpenManga = SingleLiveEvent<Manga>()
val onActionDone = SingleLiveEvent<ReversibleAction>()
val content: LiveData<List<ExploreItem>> = isLoading.asFlow().flatMapLatest { loading -> val content: LiveData<List<ExploreItem>> = isLoading.asFlow().flatMapLatest { loading ->
if (loading) { if (loading) {
@@ -40,6 +48,16 @@ class ExploreViewModel @Inject constructor(
} }
} }
fun hideSource(source: MangaSource) {
launchJob(Dispatchers.Default) {
settings.hiddenSources += source.name
val rollback = ReversibleHandle {
settings.hiddenSources -= source.name
}
onActionDone.postCall(ReversibleAction(R.string.source_disabled, rollback))
}
}
private fun createContentFlow() = settings.observe() private fun createContentFlow() = settings.observe()
.filter { .filter {
it == AppSettings.KEY_SOURCES_HIDDEN || it == AppSettings.KEY_SOURCES_HIDDEN ||

View File

@@ -21,9 +21,6 @@ abstract class FavouriteCategoriesDao {
@Insert(onConflict = OnConflictStrategy.ABORT) @Insert(onConflict = OnConflictStrategy.ABORT)
abstract suspend fun insert(category: FavouriteCategoryEntity): Long abstract suspend fun insert(category: FavouriteCategoryEntity): Long
@Update
abstract suspend fun update(category: FavouriteCategoryEntity): Int
suspend fun delete(id: Long) = setDeletedAt(id, System.currentTimeMillis()) suspend fun delete(id: Long) = setDeletedAt(id, System.currentTimeMillis())
@Query("UPDATE favourite_categories SET title = :title, `order` = :order, `track` = :tracker WHERE category_id = :id") @Query("UPDATE favourite_categories SET title = :title, `order` = :order, `track` = :tracker WHERE category_id = :id")
@@ -51,12 +48,8 @@ abstract class FavouriteCategoriesDao {
return (getMaxSortKey() ?: 0) + 1 return (getMaxSortKey() ?: 0) + 1
} }
@Transaction @Upsert
open suspend fun upsert(entity: FavouriteCategoryEntity) { abstract suspend fun upsert(entity: FavouriteCategoryEntity)
if (update(entity) == 0) {
insert(entity)
}
}
@Query("UPDATE favourite_categories SET deleted_at = :deletedAt WHERE category_id = :id") @Query("UPDATE favourite_categories SET deleted_at = :deletedAt WHERE category_id = :id")
protected abstract suspend fun setDeletedAt(id: Long, deletedAt: Long) protected abstract suspend fun setDeletedAt(id: Long, deletedAt: Long)

View File

@@ -99,11 +99,6 @@ abstract class FavouritesDao {
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE)
abstract suspend fun insert(favourite: FavouriteEntity) abstract suspend fun insert(favourite: FavouriteEntity)
/** UPDATE **/
@Update
abstract suspend fun update(favourite: FavouriteEntity): Int
/** DELETE **/ /** DELETE **/
suspend fun delete(mangaId: Long) = setDeletedAt( suspend fun delete(mangaId: Long) = setDeletedAt(
@@ -138,12 +133,8 @@ abstract class FavouritesDao {
/** TOOLS **/ /** TOOLS **/
@Transaction @Upsert
open suspend fun upsert(entity: FavouriteEntity) { abstract suspend fun upsert(entity: FavouriteEntity)
if (update(entity) == 0) {
insert(entity)
}
}
@Transaction @Transaction
@RawQuery(observedEntities = [FavouriteEntity::class]) @RawQuery(observedEntities = [FavouriteEntity::class])
@@ -166,6 +157,7 @@ abstract class FavouritesDao {
SortOrder.NEWEST, SortOrder.NEWEST,
SortOrder.UPDATED, SortOrder.UPDATED,
-> "created_at DESC" -> "created_at DESC"
SortOrder.ALPHABETICAL -> "title ASC" SortOrder.ALPHABETICAL -> "title ASC"
else -> throw IllegalArgumentException("Sort order $sortOrder is not supported") else -> throw IllegalArgumentException("Sort order $sortOrder is not supported")
} }

View File

@@ -7,15 +7,16 @@ import android.view.View
import androidx.appcompat.view.ActionMode import androidx.appcompat.view.ActionMode
import androidx.appcompat.widget.PopupMenu import androidx.appcompat.widget.PopupMenu
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.list.ListSelectionController import org.koitharu.kotatsu.base.ui.list.ListSelectionController
import org.koitharu.kotatsu.core.ui.titleRes import org.koitharu.kotatsu.core.ui.titleRes
import org.koitharu.kotatsu.favourites.ui.categories.FavouriteCategoriesActivity import org.koitharu.kotatsu.favourites.ui.categories.FavouriteCategoriesActivity
import org.koitharu.kotatsu.list.ui.MangaListFragment import org.koitharu.kotatsu.list.ui.MangaListFragment
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.utils.ext.addMenuProvider
import org.koitharu.kotatsu.utils.ext.assistedViewModels import org.koitharu.kotatsu.utils.ext.assistedViewModels
import org.koitharu.kotatsu.utils.ext.withArgs import org.koitharu.kotatsu.utils.ext.withArgs
import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class FavouritesListFragment : MangaListFragment(), PopupMenu.OnMenuItemClickListener { class FavouritesListFragment : MangaListFragment(), PopupMenu.OnMenuItemClickListener {
@@ -23,9 +24,7 @@ class FavouritesListFragment : MangaListFragment(), PopupMenu.OnMenuItemClickLis
@Inject @Inject
lateinit var viewModelFactory: FavouritesListViewModel.Factory lateinit var viewModelFactory: FavouritesListViewModel.Factory
override val viewModel by assistedViewModels<FavouritesListViewModel> { override val viewModel by assistedViewModels { viewModelFactory.create(categoryId) }
viewModelFactory.create(categoryId)
}
private val categoryId: Long private val categoryId: Long
get() = arguments?.getLong(ARG_CATEGORY_ID) ?: NO_ID get() = arguments?.getLong(ARG_CATEGORY_ID) ?: NO_ID
@@ -34,6 +33,9 @@ class FavouritesListFragment : MangaListFragment(), PopupMenu.OnMenuItemClickLis
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
if (viewModel.categoryId != NO_ID) {
addMenuProvider(FavouritesListMenuProvider(view.context, viewModel))
}
viewModel.sortOrder.observe(viewLifecycleOwner) { activity?.invalidateOptionsMenu() } viewModel.sortOrder.observe(viewLifecycleOwner) { activity?.invalidateOptionsMenu() }
} }
@@ -73,6 +75,7 @@ class FavouritesListFragment : MangaListFragment(), PopupMenu.OnMenuItemClickLis
mode.finish() mode.finish()
true true
} }
else -> super.onActionItemClicked(controller, mode, item) else -> super.onActionItemClicked(controller, mode, item)
} }
} }

View File

@@ -0,0 +1,56 @@
package org.koitharu.kotatsu.favourites.ui.list
import android.content.Context
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import androidx.core.view.MenuProvider
import androidx.core.view.forEach
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.titleRes
import org.koitharu.kotatsu.favourites.ui.categories.FavouriteCategoriesActivity
import org.koitharu.kotatsu.favourites.ui.categories.edit.FavouritesCategoryEditActivity
import org.koitharu.kotatsu.parsers.model.SortOrder
class FavouritesListMenuProvider(
private val context: Context,
private val viewModel: FavouritesListViewModel,
) : MenuProvider {
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menuInflater.inflate(R.menu.opt_favourites, menu)
val subMenu = menu.findItem(R.id.action_order)?.subMenu ?: return
for (order in FavouriteCategoriesActivity.SORT_ORDERS) {
subMenu.add(R.id.group_order, Menu.NONE, order.ordinal, order.titleRes)
}
subMenu.setGroupCheckable(R.id.group_order, true, true)
}
override fun onPrepareMenu(menu: Menu) {
super.onPrepareMenu(menu)
val order = viewModel.sortOrder.value ?: return
menu.findItem(R.id.action_order)?.subMenu?.forEach { item ->
if (item.order == order.ordinal) {
item.isChecked = true
}
}
}
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
if (menuItem.groupId == R.id.group_order) {
val order = enumValues<SortOrder>()[menuItem.order]
viewModel.setSortOrder(order)
return true
}
return when (menuItem.itemId) {
R.id.action_edit -> {
context.startActivity(
FavouritesCategoryEditActivity.newIntent(context, viewModel.categoryId),
)
true
}
else -> false
}
}
}

View File

@@ -31,7 +31,7 @@ import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
import org.koitharu.kotatsu.utils.ext.runCatchingCancellable import org.koitharu.kotatsu.utils.ext.runCatchingCancellable
class FavouritesListViewModel @AssistedInject constructor( class FavouritesListViewModel @AssistedInject constructor(
@Assisted private val categoryId: Long, @Assisted val categoryId: Long,
private val repository: FavouritesRepository, private val repository: FavouritesRepository,
private val trackingRepository: TrackingRepository, private val trackingRepository: TrackingRepository,
private val historyRepository: HistoryRepository, private val historyRepository: HistoryRepository,
@@ -55,7 +55,7 @@ class FavouritesListViewModel @AssistedInject constructor(
} else { } else {
repository.observeAll(categoryId) repository.observeAll(categoryId)
}, },
createListModeFlow(), listModeFlow,
) { list, mode -> ) { list, mode ->
when { when {
list.isEmpty() -> listOf( list.isEmpty() -> listOf(
@@ -117,7 +117,11 @@ class FavouritesListViewModel @AssistedInject constructor(
} }
override suspend fun getCounter(mangaId: Long): Int { override suspend fun getCounter(mangaId: Long): Int {
return trackingRepository.getNewChaptersCount(mangaId) return if (settings.isTrackerEnabled) {
trackingRepository.getNewChaptersCount(mangaId)
} else {
0
}
} }
override suspend fun getProgress(mangaId: Long): Float { override suspend fun getProgress(mangaId: Long): Float {

View File

@@ -1,15 +1,23 @@
package org.koitharu.kotatsu.history.domain package org.koitharu.kotatsu.history.domain
import androidx.room.withTransaction import androidx.room.withTransaction
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import org.koitharu.kotatsu.base.domain.ReversibleHandle import org.koitharu.kotatsu.base.domain.ReversibleHandle
import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.entity.* import org.koitharu.kotatsu.core.db.entity.toEntities
import org.koitharu.kotatsu.core.db.entity.toEntity
import org.koitharu.kotatsu.core.db.entity.toManga
import org.koitharu.kotatsu.core.db.entity.toMangaTag
import org.koitharu.kotatsu.core.db.entity.toMangaTags
import org.koitharu.kotatsu.core.model.MangaHistory import org.koitharu.kotatsu.core.model.MangaHistory
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.observeAsFlow
import org.koitharu.kotatsu.history.data.HistoryEntity import org.koitharu.kotatsu.history.data.HistoryEntity
import org.koitharu.kotatsu.history.data.toMangaHistory import org.koitharu.kotatsu.history.data.toMangaHistory
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
@@ -18,6 +26,7 @@ import org.koitharu.kotatsu.scrobbling.domain.Scrobbler
import org.koitharu.kotatsu.scrobbling.domain.tryScrobble import org.koitharu.kotatsu.scrobbling.domain.tryScrobble
import org.koitharu.kotatsu.tracker.domain.TrackingRepository import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.utils.ext.mapItems import org.koitharu.kotatsu.utils.ext.mapItems
import javax.inject.Inject
const val PROGRESS_NONE = -1f const val PROGRESS_NONE = -1f
@@ -66,7 +75,7 @@ class HistoryRepository @Inject constructor(
} }
suspend fun addOrUpdate(manga: Manga, chapterId: Long, page: Int, scroll: Int, percent: Float) { suspend fun addOrUpdate(manga: Manga, chapterId: Long, page: Int, scroll: Int, percent: Float) {
if (manga.isNsfw && settings.isHistoryExcludeNsfw || settings.isIncognitoModeEnabled) { if (shouldSkip(manga)) {
return return
} }
val tags = manga.tags.toEntities() val tags = manga.tags.toEntities()
@@ -138,6 +147,30 @@ class HistoryRepository @Inject constructor(
return db.historyDao.findPopularTags(limit).map { x -> x.toMangaTag() } return db.historyDao.findPopularTags(limit).map { x -> x.toMangaTag() }
} }
fun shouldSkip(manga: Manga): Boolean {
return manga.isNsfw && settings.isHistoryExcludeNsfw || settings.isIncognitoModeEnabled
}
fun observeShouldSkip(manga: Manga): Flow<Boolean> {
return settings.observe()
.filter { key -> key == AppSettings.KEY_INCOGNITO_MODE || key == AppSettings.KEY_HISTORY_EXCLUDE_NSFW }
.onStart { emit("") }
.map { shouldSkip(manga) }
.distinctUntilChanged()
}
fun observeShouldSkip(mangaFlow: Flow<Manga?>): Flow<Boolean> {
return mangaFlow
.distinctUntilChangedBy { it?.isNsfw }
.flatMapLatest { m ->
if (m != null) {
observeShouldSkip(m)
} else {
settings.observeAsFlow(AppSettings.KEY_INCOGNITO_MODE) { isIncognitoModeEnabled }
}
}
}
private suspend fun recover(ids: Collection<Long>) { private suspend fun recover(ids: Collection<Long>) {
db.withTransaction { db.withTransaction {
for (id in ids) { for (id in ids) {

View File

@@ -14,14 +14,14 @@ class HistoryListAdapter(
listener: MangaListListener listener: MangaListListener
) : MangaListAdapter(coil, lifecycleOwner, listener), FastScroller.SectionIndexer { ) : MangaListAdapter(coil, lifecycleOwner, listener), FastScroller.SectionIndexer {
override fun getSectionText(context: Context, position: Int): CharSequence { override fun getSectionText(context: Context, position: Int): CharSequence? {
val list = items val list = items
for (i in (0..position).reversed()) { for (i in (0..position).reversed()) {
val item = list[i] val item = list.getOrNull(i) ?: continue
if (item is DateTimeAgo) { if (item is DateTimeAgo) {
return item.format(context.resources) return item.format(context.resources)
} }
} }
return "" return null
} }
} }

View File

@@ -3,9 +3,6 @@ package org.koitharu.kotatsu.history.ui
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import java.util.*
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
@@ -21,11 +18,20 @@ import org.koitharu.kotatsu.history.domain.HistoryRepository
import org.koitharu.kotatsu.history.domain.MangaWithHistory import org.koitharu.kotatsu.history.domain.MangaWithHistory
import org.koitharu.kotatsu.history.domain.PROGRESS_NONE import org.koitharu.kotatsu.history.domain.PROGRESS_NONE
import org.koitharu.kotatsu.list.ui.MangaListViewModel import org.koitharu.kotatsu.list.ui.MangaListViewModel
import org.koitharu.kotatsu.list.ui.model.* import org.koitharu.kotatsu.list.ui.model.EmptyState
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.LoadingState
import org.koitharu.kotatsu.list.ui.model.toErrorState
import org.koitharu.kotatsu.list.ui.model.toGridModel
import org.koitharu.kotatsu.list.ui.model.toListDetailedModel
import org.koitharu.kotatsu.list.ui.model.toListModel
import org.koitharu.kotatsu.tracker.domain.TrackingRepository import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.utils.asFlowLiveData import org.koitharu.kotatsu.utils.asFlowLiveData
import org.koitharu.kotatsu.utils.ext.daysDiff import org.koitharu.kotatsu.utils.ext.daysDiff
import org.koitharu.kotatsu.utils.ext.onFirst import org.koitharu.kotatsu.utils.ext.onFirst
import java.util.Date
import java.util.concurrent.TimeUnit
import javax.inject.Inject
@HiltViewModel @HiltViewModel
class HistoryListViewModel @Inject constructor( class HistoryListViewModel @Inject constructor(
@@ -42,7 +48,7 @@ class HistoryListViewModel @Inject constructor(
override val content = combine( override val content = combine(
repository.observeAllWithHistory(), repository.observeAllWithHistory(),
historyGrouping, historyGrouping,
createListModeFlow(), listModeFlow,
) { list, grouped, mode -> ) { list, grouped, mode ->
when { when {
list.isEmpty() -> listOf( list.isEmpty() -> listOf(
@@ -53,6 +59,7 @@ class HistoryListViewModel @Inject constructor(
actionStringRes = 0, actionStringRes = 0,
), ),
) )
else -> mapList(list, grouped, mode) else -> mapList(list, grouped, mode)
} }
}.onStart { }.onStart {
@@ -103,7 +110,11 @@ class HistoryListViewModel @Inject constructor(
} }
prevDate = date prevDate = date
} }
val counter = trackingRepository.getNewChaptersCount(manga.id) val counter = if (settings.isTrackerEnabled) {
trackingRepository.getNewChaptersCount(manga.id)
} else {
0
}
val percent = if (showPercent) history.percent else PROGRESS_NONE val percent = if (showPercent) history.percent else PROGRESS_NONE
result += when (mode) { result += when (mode) {
ListMode.LIST -> manga.toListModel(counter, percent) ListMode.LIST -> manga.toListModel(counter, percent)

View File

@@ -49,6 +49,9 @@ class ListModeBottomSheet :
} }
override fun onButtonChecked(group: MaterialButtonToggleGroup?, checkedId: Int, isChecked: Boolean) { override fun onButtonChecked(group: MaterialButtonToggleGroup?, checkedId: Int, isChecked: Boolean) {
if (!isChecked) {
return
}
val mode = when (checkedId) { val mode = when (checkedId) {
R.id.button_list -> ListMode.LIST R.id.button_list -> ListMode.LIST
R.id.button_list_detailed -> ListMode.DETAILED_LIST R.id.button_list_detailed -> ListMode.DETAILED_LIST

View File

@@ -1,7 +1,11 @@
package org.koitharu.kotatsu.list.ui package org.koitharu.kotatsu.list.ui
import android.os.Bundle import android.os.Bundle
import android.view.* import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.view.ViewGroup.MarginLayoutParams import android.view.ViewGroup.MarginLayoutParams
import androidx.annotation.CallSuper import androidx.annotation.CallSuper
import androidx.appcompat.view.ActionMode import androidx.appcompat.view.ActionMode
@@ -15,7 +19,6 @@ import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import coil.ImageLoader import coil.ImageLoader
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.domain.reverseAsync import org.koitharu.kotatsu.base.domain.reverseAsync
@@ -42,13 +45,23 @@ import org.koitharu.kotatsu.list.ui.adapter.MangaListListener
import org.koitharu.kotatsu.list.ui.model.ListHeader import org.koitharu.kotatsu.list.ui.model.ListHeader
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.MangaItemModel import org.koitharu.kotatsu.list.ui.model.MangaItemModel
import org.koitharu.kotatsu.main.ui.MainActivity
import org.koitharu.kotatsu.main.ui.owners.AppBarOwner import org.koitharu.kotatsu.main.ui.owners.AppBarOwner
import org.koitharu.kotatsu.main.ui.owners.BottomNavOwner import org.koitharu.kotatsu.main.ui.owners.BottomNavOwner
import org.koitharu.kotatsu.main.ui.MainActivity
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.reader.ui.ReaderActivity
import org.koitharu.kotatsu.search.ui.MangaListActivity
import org.koitharu.kotatsu.utils.ShareHelper import org.koitharu.kotatsu.utils.ShareHelper
import org.koitharu.kotatsu.utils.ext.* import org.koitharu.kotatsu.utils.ext.addMenuProvider
import org.koitharu.kotatsu.utils.ext.clearItemDecorations
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.getThemeColor
import org.koitharu.kotatsu.utils.ext.measureHeight
import org.koitharu.kotatsu.utils.ext.resolveDp
import org.koitharu.kotatsu.utils.ext.scaleUpActivityOptionsOf
import org.koitharu.kotatsu.utils.ext.viewLifecycleScope
import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
abstract class MangaListFragment : abstract class MangaListFragment :
@@ -138,6 +151,20 @@ abstract class MangaListFragment :
return selectionController?.onItemLongClick(item.id) ?: false return selectionController?.onItemLongClick(item.id) ?: false
} }
override fun onReadClick(manga: Manga, view: View) {
if (selectionController?.onItemClick(manga.id) != true) {
val intent = ReaderActivity.newIntent(context ?: return, manga)
startActivity(intent, scaleUpActivityOptionsOf(view).toBundle())
}
}
override fun onTagClick(manga: Manga, tag: MangaTag, view: View) {
if (selectionController?.onItemClick(manga.id) != true) {
val intent = MangaListActivity.newIntent(context ?: return, setOf(tag))
startActivity(intent)
}
}
@CallSuper @CallSuper
override fun onRefresh() { override fun onRefresh() {
binding.swipeRefreshLayout.isRefreshing = true binding.swipeRefreshLayout.isRefreshing = true
@@ -251,12 +278,14 @@ abstract class MangaListFragment :
) )
addItemDecoration(decoration) addItemDecoration(decoration)
} }
ListMode.DETAILED_LIST -> { ListMode.DETAILED_LIST -> {
layoutManager = FitHeightLinearLayoutManager(context) layoutManager = FitHeightLinearLayoutManager(context)
val spacing = resources.getDimensionPixelOffset(R.dimen.list_spacing) val spacing = resources.getDimensionPixelOffset(R.dimen.list_spacing)
updatePadding(left = spacing, right = spacing) updatePadding(left = spacing, right = spacing)
addItemDecoration(SpacingItemDecoration(spacing)) addItemDecoration(SpacingItemDecoration(spacing))
} }
ListMode.GRID -> { ListMode.GRID -> {
layoutManager = FitHeightGridLayoutManager(context, checkNotNull(spanResolver).spanCount).also { layoutManager = FitHeightGridLayoutManager(context, checkNotNull(spanResolver).spanCount).also {
it.spanSizeLookup = spanSizeLookup it.spanSizeLookup = spanSizeLookup
@@ -284,21 +313,25 @@ abstract class MangaListFragment :
selectionController?.addAll(ids) selectionController?.addAll(ids)
true true
} }
R.id.action_share -> { R.id.action_share -> {
ShareHelper(requireContext()).shareMangaLinks(selectedItems) ShareHelper(requireContext()).shareMangaLinks(selectedItems)
mode.finish() mode.finish()
true true
} }
R.id.action_favourite -> { R.id.action_favourite -> {
FavouriteCategoriesBottomSheet.show(childFragmentManager, selectedItems) FavouriteCategoriesBottomSheet.show(childFragmentManager, selectedItems)
mode.finish() mode.finish()
true true
} }
R.id.action_save -> { R.id.action_save -> {
DownloadService.confirmAndStart(requireContext(), selectedItems) DownloadService.confirmAndStart(requireContext(), selectedItems)
mode.finish() mode.finish()
true true
} }
else -> false else -> false
} }
} }

View File

@@ -1,26 +1,29 @@
package org.koitharu.kotatsu.list.ui package org.koitharu.kotatsu.list.ui
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.plus
import org.koitharu.kotatsu.base.ui.BaseViewModel import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.base.ui.util.ReversibleAction import org.koitharu.kotatsu.base.ui.util.ReversibleAction
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.ListMode
import org.koitharu.kotatsu.core.prefs.observeAsFlow import org.koitharu.kotatsu.core.prefs.observeAsFlow
import org.koitharu.kotatsu.core.prefs.observeAsLiveData import org.koitharu.kotatsu.core.prefs.observeAsLiveData
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.utils.SingleLiveEvent import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.asFlowLiveData
abstract class MangaListViewModel( abstract class MangaListViewModel(
private val settings: AppSettings, private val settings: AppSettings,
) : BaseViewModel() { ) : BaseViewModel() {
abstract val content: LiveData<List<ListModel>> abstract val content: LiveData<List<ListModel>>
val listMode = MutableLiveData<ListMode>() protected val listModeFlow = settings.observeAsFlow(AppSettings.KEY_LIST_MODE) { listMode }
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, settings.listMode)
val listMode = listModeFlow.asFlowLiveData(viewModelScope.coroutineContext)
val onActionDone = SingleLiveEvent<ReversibleAction>() val onActionDone = SingleLiveEvent<ReversibleAction>()
val gridScale = settings.observeAsLiveData( val gridScale = settings.observeAsLiveData(
context = viewModelScope.coroutineContext + Dispatchers.Default, context = viewModelScope.coroutineContext + Dispatchers.Default,
@@ -30,13 +33,6 @@ abstract class MangaListViewModel(
open fun onUpdateFilter(tags: Set<MangaTag>) = Unit open fun onUpdateFilter(tags: Set<MangaTag>) = Unit
protected fun createListModeFlow() = settings.observeAsFlow(AppSettings.KEY_LIST_MODE) { listMode }
.onEach {
if (listMode.value != it) {
listMode.postValue(it)
}
}
abstract fun onRefresh() abstract fun onRefresh()
abstract fun onRetry() abstract fun onRetry()

View File

@@ -22,11 +22,12 @@ open class MangaSelectionDecoration(context: Context) : AbstractSelectionItemDec
protected val paint = Paint(Paint.ANTI_ALIAS_FLAG) protected val paint = Paint(Paint.ANTI_ALIAS_FLAG)
protected val checkIcon = ContextCompat.getDrawable(context, materialR.drawable.ic_mtrl_checked_circle) protected val checkIcon = ContextCompat.getDrawable(context, materialR.drawable.ic_mtrl_checked_circle)
protected val iconOffset = context.resources.getDimensionPixelOffset(R.dimen.grid_spacing_outer) protected val iconOffset = context.resources.getDimensionPixelOffset(R.dimen.card_indicator_offset)
protected val iconSize = context.resources.getDimensionPixelOffset(R.dimen.card_indicator_size)
protected val strokeColor = context.getThemeColor(materialR.attr.colorPrimary, Color.RED) protected val strokeColor = context.getThemeColor(materialR.attr.colorPrimary, Color.RED)
protected val fillColor = ColorUtils.setAlphaComponent( protected val fillColor = ColorUtils.setAlphaComponent(
ColorUtils.blendARGB(strokeColor, context.getThemeColor(materialR.attr.colorSurface), 0.8f), ColorUtils.blendARGB(strokeColor, context.getThemeColor(materialR.attr.colorSurface), 0.8f),
0x74 0x74,
) )
protected val defaultRadius = context.resources.getDimension(R.dimen.list_selector_corner) protected val defaultRadius = context.resources.getDimension(R.dimen.list_selector_corner)
@@ -65,11 +66,11 @@ open class MangaSelectionDecoration(context: Context) : AbstractSelectionItemDec
setBounds( setBounds(
(bounds.left + iconOffset).toInt(), (bounds.left + iconOffset).toInt(),
(bounds.top + iconOffset).toInt(), (bounds.top + iconOffset).toInt(),
(bounds.left + iconOffset + intrinsicWidth).toInt(), (bounds.left + iconOffset + iconSize).toInt(),
(bounds.top + iconOffset + intrinsicHeight).toInt(), (bounds.top + iconOffset + iconSize).toInt(),
) )
draw(canvas) draw(canvas)
} }
} }
} }
} }

View File

@@ -0,0 +1,13 @@
package org.koitharu.kotatsu.list.ui.adapter
import android.view.View
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaTag
interface MangaDetailsClickListener : OnListItemClickListener<Manga> {
fun onReadClick(manga: Manga, view: View)
fun onTagClick(manga: Manga, tag: MangaTag, view: View)
}

View File

@@ -1,34 +1,52 @@
package org.koitharu.kotatsu.list.ui.adapter package org.koitharu.kotatsu.list.ui.adapter
import android.view.View
import androidx.core.view.isVisible
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import coil.ImageLoader import coil.ImageLoader
import com.google.android.material.badge.BadgeDrawable import com.google.android.material.badge.BadgeDrawable
import com.google.android.material.chip.Chip
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener import org.koitharu.kotatsu.base.ui.widgets.ChipsView
import org.koitharu.kotatsu.databinding.ItemMangaListDetailsBinding import org.koitharu.kotatsu.databinding.ItemMangaListDetailsBinding
import org.koitharu.kotatsu.history.domain.PROGRESS_NONE import org.koitharu.kotatsu.history.domain.PROGRESS_NONE
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.MangaListDetailedModel import org.koitharu.kotatsu.list.ui.model.MangaListDetailedModel
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.utils.ext.* import org.koitharu.kotatsu.utils.ext.disposeImageRequest
import org.koitharu.kotatsu.utils.ext.enqueueWith
import org.koitharu.kotatsu.utils.ext.newImageRequest
import org.koitharu.kotatsu.utils.ext.referer
import org.koitharu.kotatsu.utils.ext.textAndVisible
import org.koitharu.kotatsu.utils.image.CoverSizeResolver import org.koitharu.kotatsu.utils.image.CoverSizeResolver
fun mangaListDetailedItemAD( fun mangaListDetailedItemAD(
coil: ImageLoader, coil: ImageLoader,
lifecycleOwner: LifecycleOwner, lifecycleOwner: LifecycleOwner,
clickListener: OnListItemClickListener<Manga>, clickListener: MangaDetailsClickListener,
) = adapterDelegateViewBinding<MangaListDetailedModel, ListModel, ItemMangaListDetailsBinding>( ) = adapterDelegateViewBinding<MangaListDetailedModel, ListModel, ItemMangaListDetailsBinding>(
{ inflater, parent -> ItemMangaListDetailsBinding.inflate(inflater, parent, false) }, { inflater, parent -> ItemMangaListDetailsBinding.inflate(inflater, parent, false) },
) { ) {
var badge: BadgeDrawable? = null var badge: BadgeDrawable? = null
itemView.setOnClickListener { val listenerAdapter = object : View.OnClickListener, View.OnLongClickListener, ChipsView.OnChipClickListener {
clickListener.onItemClick(item.manga, it) override fun onClick(v: View) = when (v.id) {
} R.id.button_read -> clickListener.onReadClick(item.manga, v)
itemView.setOnLongClickListener { else -> clickListener.onItemClick(item.manga, v)
clickListener.onItemLongClick(item.manga, it) }
override fun onLongClick(v: View): Boolean = clickListener.onItemLongClick(item.manga, v)
override fun onChipClick(chip: Chip, data: Any?) {
val tag = data as? MangaTag ?: return
clickListener.onTagClick(item.manga, tag, chip)
}
} }
itemView.setOnClickListener(listenerAdapter)
itemView.setOnLongClickListener(listenerAdapter)
binding.buttonRead.setOnClickListener(listenerAdapter)
binding.chipsTags.onChipClickListener = listenerAdapter
bind { payloads -> bind { payloads ->
binding.textViewTitle.text = item.title binding.textViewTitle.text = item.title
@@ -44,8 +62,9 @@ fun mangaListDetailedItemAD(
lifecycle(lifecycleOwner) lifecycle(lifecycleOwner)
enqueueWith(coil) enqueueWith(coil)
} }
binding.textViewRating.textAndVisible = item.rating binding.chipsTags.setChips(item.tags)
binding.textViewTags.text = item.tags binding.ratingBar.isVisible = item.manga.hasRating
binding.ratingBar.rating = binding.ratingBar.numStars * item.manga.rating
badge = itemView.bindBadge(badge, item.counter) badge = itemView.bindBadge(badge, item.counter)
} }

View File

@@ -1,11 +1,9 @@
package org.koitharu.kotatsu.list.ui.adapter package org.koitharu.kotatsu.list.ui.adapter
import android.view.View import android.view.View
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
interface MangaListListener : OnListItemClickListener<Manga>, ListStateHolderListener, ListHeaderClickListener { interface MangaListListener : MangaDetailsClickListener, ListStateHolderListener, ListHeaderClickListener {
fun onUpdateFilter(tags: Set<MangaTag>) fun onUpdateFilter(tags: Set<MangaTag>)

View File

@@ -1,8 +1,7 @@
package org.koitharu.kotatsu.list.ui.model package org.koitharu.kotatsu.list.ui.model
import java.net.SocketTimeoutException
import java.net.UnknownHostException
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.widgets.ChipsView
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.prefs.ListMode import org.koitharu.kotatsu.core.prefs.ListMode
@@ -11,6 +10,8 @@ import org.koitharu.kotatsu.list.domain.ListExtraProvider
import org.koitharu.kotatsu.parsers.exception.AuthRequiredException import org.koitharu.kotatsu.parsers.exception.AuthRequiredException
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.utils.ext.ifZero import org.koitharu.kotatsu.utils.ext.ifZero
import java.net.SocketTimeoutException
import java.net.UnknownHostException
fun Manga.toListModel(counter: Int, progress: Float) = MangaListModel( fun Manga.toListModel(counter: Int, progress: Float) = MangaListModel(
id = id, id = id,
@@ -26,12 +27,11 @@ fun Manga.toListDetailedModel(counter: Int, progress: Float) = MangaListDetailed
id = id, id = id,
title = title, title = title,
subtitle = altTitle, subtitle = altTitle,
rating = if (hasRating) String.format("%.1f", rating * 5) else null,
tags = tags.joinToString(", ") { it.title },
coverUrl = coverUrl, coverUrl = coverUrl,
manga = this, manga = this,
counter = counter, counter = counter,
progress = progress, progress = progress,
tags = tags.map { ChipsView.ChipModel(0, it.title, false, false, it) },
) )
fun Manga.toGridModel(counter: Int, progress: Float) = MangaGridModel( fun Manga.toGridModel(counter: Int, progress: Float) = MangaGridModel(
@@ -69,9 +69,11 @@ suspend fun <C : MutableCollection<in MangaItemModel>> List<Manga>.toUi(
ListMode.LIST -> mapTo(destination) { ListMode.LIST -> mapTo(destination) {
it.toListModel(extraProvider.getCounter(it.id), extraProvider.getProgress(it.id)) it.toListModel(extraProvider.getCounter(it.id), extraProvider.getProgress(it.id))
} }
ListMode.DETAILED_LIST -> mapTo(destination) { ListMode.DETAILED_LIST -> mapTo(destination) {
it.toListDetailedModel(extraProvider.getCounter(it.id), extraProvider.getProgress(it.id)) it.toListDetailedModel(extraProvider.getCounter(it.id), extraProvider.getProgress(it.id))
} }
ListMode.GRID -> mapTo(destination) { ListMode.GRID -> mapTo(destination) {
it.toGridModel(extraProvider.getCounter(it.id), extraProvider.getProgress(it.id)) it.toGridModel(extraProvider.getCounter(it.id), extraProvider.getProgress(it.id))
} }
@@ -95,5 +97,6 @@ private fun getErrorIcon(error: Throwable) = when (error) {
is UnknownHostException, is UnknownHostException,
is SocketTimeoutException, is SocketTimeoutException,
-> R.drawable.ic_plug_large -> R.drawable.ic_plug_large
else -> R.drawable.ic_error_large else -> R.drawable.ic_error_large
} }

View File

@@ -1,15 +1,15 @@
package org.koitharu.kotatsu.list.ui.model package org.koitharu.kotatsu.list.ui.model
import org.koitharu.kotatsu.base.ui.widgets.ChipsView
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
data class MangaListDetailedModel( data class MangaListDetailedModel(
override val id: Long, override val id: Long,
override val title: String, override val title: String,
val subtitle: String?, val subtitle: String?,
val tags: String,
override val coverUrl: String, override val coverUrl: String,
val rating: String?,
override val manga: Manga, override val manga: Manga,
override val counter: Int, override val counter: Int,
override val progress: Float, override val progress: Float,
) : MangaItemModel val tags: List<ChipsView.ChipModel>,
) : MangaItemModel

View File

@@ -23,10 +23,7 @@ class CbzFetcher(
val zip = ZipFile(uri.schemeSpecificPart) val zip = ZipFile(uri.schemeSpecificPart)
val entry = zip.getEntry(uri.fragment) val entry = zip.getEntry(uri.fragment)
val ext = MimeTypeMap.getFileExtensionFromUrl(entry.name) val ext = MimeTypeMap.getFileExtensionFromUrl(entry.name)
val bufferedSource = ExtraCloseableBufferedSource( val bufferedSource = zip.getInputStream(entry).source().withExtraCloseable(zip).buffer()
zip.getInputStream(entry).source().buffer(),
zip,
)
SourceResult( SourceResult(
source = ImageSource( source = ImageSource(
source = bufferedSource, source = bufferedSource,
@@ -50,4 +47,4 @@ class CbzFetcher(
} }
class CbzMetadata(val uri: Uri) : ImageSource.Metadata() class CbzMetadata(val uri: Uri) : ImageSource.Metadata()
} }

View File

@@ -1,18 +0,0 @@
package org.koitharu.kotatsu.local.data
import okhttp3.internal.closeQuietly
import okio.BufferedSource
import okio.Closeable
class ExtraCloseableBufferedSource(
private val delegate: BufferedSource,
vararg closeable: Closeable,
) : BufferedSource by delegate {
private val extraCloseable = closeable
override fun close() {
delegate.close()
extraCloseable.forEach { x -> x.closeQuietly() }
}
}

View File

@@ -0,0 +1,21 @@
package org.koitharu.kotatsu.local.data
import okhttp3.internal.closeQuietly
import okio.Closeable
import okio.Source
private class ExtraCloseableSource(
private val delegate: Source,
private val extraCloseable: Closeable,
) : Source by delegate {
override fun close() {
try {
delegate.close()
} finally {
extraCloseable.closeQuietly()
}
}
}
fun Source.withExtraCloseable(closeable: Closeable): Source = ExtraCloseableSource(this, closeable)

View File

@@ -10,6 +10,7 @@ import org.koitharu.kotatsu.utils.ext.copyToSuspending
import org.koitharu.kotatsu.utils.ext.longHashCode import org.koitharu.kotatsu.utils.ext.longHashCode
import org.koitharu.kotatsu.utils.ext.subdir import org.koitharu.kotatsu.utils.ext.subdir
import org.koitharu.kotatsu.utils.ext.takeIfReadable import org.koitharu.kotatsu.utils.ext.takeIfReadable
import org.koitharu.kotatsu.utils.ext.takeIfWriteable
import java.io.File import java.io.File
import java.io.InputStream import java.io.InputStream
import javax.inject.Inject import javax.inject.Inject
@@ -18,9 +19,14 @@ import javax.inject.Singleton
@Singleton @Singleton
class PagesCache @Inject constructor(@ApplicationContext context: Context) { class PagesCache @Inject constructor(@ApplicationContext context: Context) {
private val cacheDir = context.externalCacheDir ?: context.cacheDir private val cacheDir = checkNotNull(findSuitableDir(context)) {
val dirs = (context.externalCacheDirs + context.cacheDir).joinToString(";") {
it?.absolutePath.toString()
}
"Cannot find any suitable directory for PagesCache: [$dirs]"
}
private val lruCache = createDiskLruCacheSafe( private val lruCache = createDiskLruCacheSafe(
dir = cacheDir.subdir(CacheDir.PAGES.dir), dir = cacheDir,
size = FileSize.MEGABYTES.convert(200, FileSize.BYTES), size = FileSize.MEGABYTES.convert(200, FileSize.BYTES),
) )
@@ -29,7 +35,7 @@ class PagesCache @Inject constructor(@ApplicationContext context: Context) {
} }
suspend fun put(url: String, inputStream: InputStream): File = withContext(Dispatchers.IO) { suspend fun put(url: String, inputStream: InputStream): File = withContext(Dispatchers.IO) {
val file = File(cacheDir, url.longHashCode().toString()) val file = File(cacheDir.parentFile, url.longHashCode().toString())
try { try {
file.outputStream().use { out -> file.outputStream().use { out ->
inputStream.copyToSuspending(out) inputStream.copyToSuspending(out)
@@ -50,3 +56,10 @@ private fun createDiskLruCacheSafe(dir: File, size: Long): DiskLruCache {
DiskLruCache.create(dir, size) DiskLruCache.create(dir, size)
} }
} }
private fun findSuitableDir(context: Context): File? {
val dirs = context.externalCacheDirs + context.cacheDir
return dirs.firstNotNullOfOrNull {
it?.subdir(CacheDir.PAGES.dir)?.takeIfWriteable()
}
}

View File

@@ -13,6 +13,7 @@ import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.RATING_UNKNOWN import org.koitharu.kotatsu.parsers.model.RATING_UNKNOWN
import org.koitharu.kotatsu.utils.AlphanumComparator
import org.koitharu.kotatsu.utils.ext.copyToSuspending import org.koitharu.kotatsu.utils.ext.copyToSuspending
import org.koitharu.kotatsu.utils.ext.deleteAwait import org.koitharu.kotatsu.utils.ext.deleteAwait
import org.koitharu.kotatsu.utils.ext.longOf import org.koitharu.kotatsu.utils.ext.longOf
@@ -58,7 +59,7 @@ class DirMangaImporter(
private suspend fun addPages(output: CbzMangaOutput, root: DocumentFile, path: String, state: State) { private suspend fun addPages(output: CbzMangaOutput, root: DocumentFile, path: String, state: State) {
var number = 0 var number = 0
for (file in root.listFiles()) { for (file in root.listFiles().sortedWith(compareBy(AlphanumComparator()) { it.name.orEmpty() })) {
when { when {
file.isDirectory -> { file.isDirectory -> {
addPages(output, file, path + "/" + file.name, state) addPages(output, file, path + "/" + file.name, state)

View File

@@ -4,14 +4,16 @@ import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.asFlow import androidx.lifecycle.asFlow
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import java.io.IOException import kotlinx.coroutines.CancellationException
import java.util.* import kotlinx.coroutines.Dispatchers
import javax.inject.Inject import kotlinx.coroutines.Job
import kotlinx.coroutines.* import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.widgets.ChipsView import org.koitharu.kotatsu.base.ui.widgets.ChipsView
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
@@ -20,7 +22,11 @@ import org.koitharu.kotatsu.history.domain.HistoryRepository
import org.koitharu.kotatsu.history.domain.PROGRESS_NONE import org.koitharu.kotatsu.history.domain.PROGRESS_NONE
import org.koitharu.kotatsu.list.domain.ListExtraProvider import org.koitharu.kotatsu.list.domain.ListExtraProvider
import org.koitharu.kotatsu.list.ui.MangaListViewModel import org.koitharu.kotatsu.list.ui.MangaListViewModel
import org.koitharu.kotatsu.list.ui.model.* import org.koitharu.kotatsu.list.ui.model.EmptyState
import org.koitharu.kotatsu.list.ui.model.ListHeader2
import org.koitharu.kotatsu.list.ui.model.LoadingState
import org.koitharu.kotatsu.list.ui.model.toErrorState
import org.koitharu.kotatsu.list.ui.model.toUi
import org.koitharu.kotatsu.local.domain.LocalMangaRepository import org.koitharu.kotatsu.local.domain.LocalMangaRepository
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
@@ -30,6 +36,9 @@ import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import org.koitharu.kotatsu.utils.ext.runCatchingCancellable import org.koitharu.kotatsu.utils.ext.runCatchingCancellable
import java.io.IOException
import java.util.LinkedList
import javax.inject.Inject
@HiltViewModel @HiltViewModel
class LocalListViewModel @Inject constructor( class LocalListViewModel @Inject constructor(
@@ -48,7 +57,7 @@ class LocalListViewModel @Inject constructor(
override val content = combine( override val content = combine(
mangaList, mangaList,
createListModeFlow(), listModeFlow,
sortOrder.asFlow(), sortOrder.asFlow(),
selectedTags, selectedTags,
listError, listError,
@@ -181,7 +190,11 @@ class LocalListViewModel @Inject constructor(
} }
override suspend fun getCounter(mangaId: Long): Int { override suspend fun getCounter(mangaId: Long): Int {
return trackingRepository.getNewChaptersCount(mangaId) return if (settings.isTrackerEnabled) {
trackingRepository.getNewChaptersCount(mangaId)
} else {
0
}
} }
override suspend fun getProgress(mangaId: Long): Float { override suspend fun getProgress(mangaId: Long): Float {

View File

@@ -0,0 +1,39 @@
package org.koitharu.kotatsu.main.ui
import android.content.Context
import android.util.AttributeSet
import android.view.View
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.view.ViewCompat
import com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
import org.koitharu.kotatsu.base.ui.util.ShrinkOnScrollBehavior
import org.koitharu.kotatsu.base.ui.widgets.SlidingBottomNavigationView
class MainActionButtonBehavior : ShrinkOnScrollBehavior {
constructor() : super()
constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs)
override fun layoutDependsOn(
parent: CoordinatorLayout,
child: ExtendedFloatingActionButton,
dependency: View
): Boolean {
return dependency is SlidingBottomNavigationView || super.layoutDependsOn(parent, child, dependency)
}
override fun onDependentViewChanged(
parent: CoordinatorLayout,
child: ExtendedFloatingActionButton,
dependency: View
): Boolean {
val bottom = child.bottom
val bottomLine = parent.height
return if (bottom > bottomLine) {
ViewCompat.offsetTopAndBottom(child, bottomLine - bottom)
true
} else {
false
}
}
}

View File

@@ -41,6 +41,7 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseActivity import org.koitharu.kotatsu.base.ui.BaseActivity
import org.koitharu.kotatsu.base.ui.widgets.SlidingBottomNavigationView import org.koitharu.kotatsu.base.ui.widgets.SlidingBottomNavigationView
import org.koitharu.kotatsu.databinding.ActivityMainBinding import org.koitharu.kotatsu.databinding.ActivityMainBinding
import org.koitharu.kotatsu.details.service.MangaPrefetchService
import org.koitharu.kotatsu.details.ui.DetailsActivity import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.main.ui.owners.AppBarOwner import org.koitharu.kotatsu.main.ui.owners.AppBarOwner
import org.koitharu.kotatsu.main.ui.owners.BottomNavOwner import org.koitharu.kotatsu.main.ui.owners.BottomNavOwner
@@ -334,6 +335,7 @@ class MainActivity :
TrackWorker.setup(applicationContext) TrackWorker.setup(applicationContext)
SuggestionsWorker.setup(applicationContext) SuggestionsWorker.setup(applicationContext)
} }
MangaPrefetchService.prefetchLast(this@MainActivity)
requestNotificationsPermission() requestNotificationsPermission()
} }
} }

View File

@@ -38,7 +38,10 @@ class MainNavigationDelegate(
} }
override fun onNavigationItemReselected(item: MenuItem) { override fun onNavigationItemReselected(item: MenuItem) {
val fragment = fragmentManager.findFragmentByTag(TAG_PRIMARY) as? RecyclerViewOwner ?: return val fragment = fragmentManager.findFragmentByTag(TAG_PRIMARY)
if (fragment == null || fragment !is RecyclerViewOwner || fragment.view == null) {
return
}
val recyclerView = fragment.recyclerView val recyclerView = fragment.recyclerView
recyclerView.smoothScrollToPosition(0) recyclerView.smoothScrollToPosition(0)
} }
@@ -46,6 +49,10 @@ class MainNavigationDelegate(
fun onCreate(savedInstanceState: Bundle?) { fun onCreate(savedInstanceState: Bundle?) {
primaryFragment?.let { primaryFragment?.let {
onFragmentChanged(it, fromUser = false) onFragmentChanged(it, fromUser = false)
val itemId = getItemId(it)
if (navBar.selectedItemId != itemId) {
navBar.selectedItemId = itemId
}
} ?: onNavigationItemSelected(navBar.selectedItemId) } ?: onNavigationItemSelected(navBar.selectedItemId)
} }
@@ -92,6 +99,14 @@ class MainNavigationDelegate(
return true return true
} }
private fun getItemId(fragment: Fragment) = when (fragment) {
is ShelfFragment -> R.id.nav_shelf
is ExploreFragment -> R.id.nav_explore
is FeedFragment -> R.id.nav_feed
is ToolsFragment -> R.id.nav_tools
else -> 0
}
private fun setPrimaryFragment(fragment: Fragment) { private fun setPrimaryFragment(fragment: Fragment) {
fragmentManager.beginTransaction() fragmentManager.beginTransaction()
.setReorderingAllowed(true) .setReorderingAllowed(true)

View File

@@ -12,6 +12,7 @@ import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.exceptions.EmptyHistoryException import org.koitharu.kotatsu.core.exceptions.EmptyHistoryException
import org.koitharu.kotatsu.core.github.AppUpdateRepository import org.koitharu.kotatsu.core.github.AppUpdateRepository
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.observeAsFlow
import org.koitharu.kotatsu.core.prefs.observeAsLiveData import org.koitharu.kotatsu.core.prefs.observeAsLiveData
import org.koitharu.kotatsu.history.domain.HistoryRepository import org.koitharu.kotatsu.history.domain.HistoryRepository
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
@@ -34,9 +35,12 @@ class MainViewModel @Inject constructor(
val onOpenReader = SingleLiveEvent<Manga>() val onOpenReader = SingleLiveEvent<Manga>()
val isResumeEnabled = historyRepository val isResumeEnabled = combine(
.observeHasItems() historyRepository.observeHasItems(),
.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, false) settings.observeAsFlow(AppSettings.KEY_INCOGNITO_MODE) { isIncognitoModeEnabled },
) { hasItems, incognito ->
hasItems && !incognito
}.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, false)
val isFeedAvailable = settings.observeAsLiveData( val isFeedAvailable = settings.observeAsLiveData(
context = viewModelScope.coroutineContext + Dispatchers.Default, context = viewModelScope.coroutineContext + Dispatchers.Default,

View File

@@ -1,12 +1,10 @@
package org.koitharu.kotatsu.reader.domain package org.koitharu.kotatsu.reader.domain
import android.content.Context
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.net.Uri import android.net.Uri
import androidx.collection.LongSparseArray import androidx.collection.LongSparseArray
import androidx.collection.set import androidx.collection.set
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@@ -31,7 +29,6 @@ import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.await import org.koitharu.kotatsu.parsers.util.await
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
import org.koitharu.kotatsu.utils.ext.connectivityManager
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import org.koitharu.kotatsu.utils.ext.withProgress import org.koitharu.kotatsu.utils.ext.withProgress
import org.koitharu.kotatsu.utils.progress.ProgressDeferred import org.koitharu.kotatsu.utils.progress.ProgressDeferred
@@ -50,13 +47,11 @@ class PageLoader @Inject constructor(
private val okHttp: OkHttpClient, private val okHttp: OkHttpClient,
private val cache: PagesCache, private val cache: PagesCache,
private val settings: AppSettings, private val settings: AppSettings,
@ApplicationContext context: Context,
private val mangaRepositoryFactory: MangaRepository.Factory, private val mangaRepositoryFactory: MangaRepository.Factory,
) : Closeable { ) : Closeable {
val loaderScope = CoroutineScope(SupervisorJob() + InternalErrorHandler() + Dispatchers.Default) val loaderScope = CoroutineScope(SupervisorJob() + InternalErrorHandler() + Dispatchers.Default)
private val connectivityManager = context.connectivityManager
private val tasks = LongSparseArray<ProgressDeferred<File, Float>>() private val tasks = LongSparseArray<ProgressDeferred<File, Float>>()
private val convertLock = Mutex() private val convertLock = Mutex()
private var repository: MangaRepository? = null private var repository: MangaRepository? = null
@@ -73,7 +68,7 @@ class PageLoader @Inject constructor(
} }
fun isPrefetchApplicable(): Boolean { fun isPrefetchApplicable(): Boolean {
return repository is RemoteMangaRepository && settings.isPagesPreloadAllowed(connectivityManager) return repository is RemoteMangaRepository && settings.isPagesPreloadEnabled()
} }
fun prefetch(pages: List<ReaderPage>) { fun prefetch(pages: List<ReaderPage>) {

View File

@@ -313,7 +313,7 @@ class ReaderViewModel @AssistedInject constructor(
} ?: ReaderState(manga, preselectedBranch) } ?: ReaderState(manga, preselectedBranch)
} }
val branch = chapters[currentState.value?.chapterId ?: 0L].branch val branch = chapters[currentState.value?.chapterId ?: 0L]?.branch
mangaData.value = manga.filterChapters(branch) mangaData.value = manga.filterChapters(branch)
readerMode.postValue(mode) readerMode.postValue(mode)

View File

@@ -5,7 +5,7 @@ import androidx.annotation.CallSuper
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import androidx.viewbinding.ViewBinding import androidx.viewbinding.ViewBinding
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.os.NetworkStateObserver import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.databinding.LayoutPageInfoBinding import org.koitharu.kotatsu.databinding.LayoutPageInfoBinding
import org.koitharu.kotatsu.reader.domain.PageLoader import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.reader.ui.config.ReaderSettings import org.koitharu.kotatsu.reader.ui.config.ReaderSettings
@@ -14,12 +14,12 @@ abstract class BasePageHolder<B : ViewBinding>(
protected val binding: B, protected val binding: B,
loader: PageLoader, loader: PageLoader,
settings: ReaderSettings, settings: ReaderSettings,
networkStateObserver: NetworkStateObserver, networkState: NetworkState,
exceptionResolver: ExceptionResolver, exceptionResolver: ExceptionResolver,
) : RecyclerView.ViewHolder(binding.root), PageHolderDelegate.Callback { ) : RecyclerView.ViewHolder(binding.root), PageHolderDelegate.Callback {
@Suppress("LeakingThis") @Suppress("LeakingThis")
protected val delegate = PageHolderDelegate(loader, settings, this, networkStateObserver, exceptionResolver) protected val delegate = PageHolderDelegate(loader, settings, this, networkState, exceptionResolver)
protected val bindingInfo = LayoutPageInfoBinding.bind(binding.root) protected val bindingInfo = LayoutPageInfoBinding.bind(binding.root)
val context: Context val context: Context

View File

@@ -5,7 +5,7 @@ import androidx.recyclerview.widget.AsyncListDiffer
import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.os.NetworkStateObserver import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.reader.domain.PageLoader import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.reader.ui.config.ReaderSettings import org.koitharu.kotatsu.reader.ui.config.ReaderSettings
import org.koitharu.kotatsu.utils.ext.resetTransformations import org.koitharu.kotatsu.utils.ext.resetTransformations
@@ -16,7 +16,7 @@ import kotlin.coroutines.suspendCoroutine
abstract class BaseReaderAdapter<H : BasePageHolder<*>>( abstract class BaseReaderAdapter<H : BasePageHolder<*>>(
private val loader: PageLoader, private val loader: PageLoader,
private val readerSettings: ReaderSettings, private val readerSettings: ReaderSettings,
private val networkState: NetworkStateObserver, private val networkState: NetworkState,
private val exceptionResolver: ExceptionResolver, private val exceptionResolver: ExceptionResolver,
) : RecyclerView.Adapter<H>() { ) : RecyclerView.Adapter<H>() {
@@ -70,7 +70,7 @@ abstract class BaseReaderAdapter<H : BasePageHolder<*>>(
parent: ViewGroup, parent: ViewGroup,
loader: PageLoader, loader: PageLoader,
settings: ReaderSettings, settings: ReaderSettings,
networkState: NetworkStateObserver, networkState: NetworkState,
exceptionResolver: ExceptionResolver, exceptionResolver: ExceptionResolver,
): H ): H

View File

@@ -17,10 +17,11 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.plus import kotlinx.coroutines.plus
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.os.NetworkStateObserver import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.reader.domain.PageLoader import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.reader.ui.config.ReaderSettings import org.koitharu.kotatsu.reader.ui.config.ReaderSettings
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import java.io.File import java.io.File
import java.io.IOException import java.io.IOException
@@ -28,7 +29,7 @@ class PageHolderDelegate(
private val loader: PageLoader, private val loader: PageLoader,
private val readerSettings: ReaderSettings, private val readerSettings: ReaderSettings,
private val callback: Callback, private val callback: Callback,
private val networkState: NetworkStateObserver, private val networkState: NetworkState,
private val exceptionResolver: ExceptionResolver, private val exceptionResolver: ExceptionResolver,
) : DefaultOnImageEventListener, Observer<ReaderSettings> { ) : DefaultOnImageEventListener, Observer<ReaderSettings> {
@@ -138,6 +139,7 @@ class PageHolderDelegate(
} catch (e: CancellationException) { } catch (e: CancellationException) {
throw e throw e
} catch (e: Throwable) { } catch (e: Throwable) {
e.printStackTraceDebug()
state = State.ERROR state = State.ERROR
error = e error = e
callback.onError(e) callback.onError(e)

View File

@@ -3,22 +3,24 @@ package org.koitharu.kotatsu.reader.ui.pager.reversed
import android.graphics.PointF import android.graphics.PointF
import android.view.Gravity import android.view.Gravity
import android.widget.FrameLayout import android.widget.FrameLayout
import androidx.lifecycle.LifecycleOwner
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.model.ZoomMode import org.koitharu.kotatsu.core.model.ZoomMode
import org.koitharu.kotatsu.core.os.NetworkStateObserver import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.databinding.ItemPageBinding import org.koitharu.kotatsu.databinding.ItemPageBinding
import org.koitharu.kotatsu.reader.domain.PageLoader import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.reader.ui.config.ReaderSettings import org.koitharu.kotatsu.reader.ui.config.ReaderSettings
import org.koitharu.kotatsu.reader.ui.pager.standard.PageHolder import org.koitharu.kotatsu.reader.ui.pager.standard.PageHolder
class ReversedPageHolder( class ReversedPageHolder(
owner: LifecycleOwner,
binding: ItemPageBinding, binding: ItemPageBinding,
loader: PageLoader, loader: PageLoader,
settings: ReaderSettings, settings: ReaderSettings,
networkState: NetworkStateObserver, networkState: NetworkState,
exceptionResolver: ExceptionResolver, exceptionResolver: ExceptionResolver,
) : PageHolder(binding, loader, settings, networkState, exceptionResolver) { ) : PageHolder(owner, binding, loader, settings, networkState, exceptionResolver) {
init { init {
(binding.textViewNumber.layoutParams as FrameLayout.LayoutParams) (binding.textViewNumber.layoutParams as FrameLayout.LayoutParams)

View File

@@ -2,17 +2,19 @@ package org.koitharu.kotatsu.reader.ui.pager.reversed
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.ViewGroup import android.view.ViewGroup
import androidx.lifecycle.LifecycleOwner
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.os.NetworkStateObserver import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.databinding.ItemPageBinding import org.koitharu.kotatsu.databinding.ItemPageBinding
import org.koitharu.kotatsu.reader.domain.PageLoader import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.reader.ui.config.ReaderSettings import org.koitharu.kotatsu.reader.ui.config.ReaderSettings
import org.koitharu.kotatsu.reader.ui.pager.BaseReaderAdapter import org.koitharu.kotatsu.reader.ui.pager.BaseReaderAdapter
class ReversedPagesAdapter( class ReversedPagesAdapter(
private val lifecycleOwner: LifecycleOwner,
loader: PageLoader, loader: PageLoader,
settings: ReaderSettings, settings: ReaderSettings,
networkState: NetworkStateObserver, networkState: NetworkState,
exceptionResolver: ExceptionResolver, exceptionResolver: ExceptionResolver,
) : BaseReaderAdapter<ReversedPageHolder>(loader, settings, networkState, exceptionResolver) { ) : BaseReaderAdapter<ReversedPageHolder>(loader, settings, networkState, exceptionResolver) {
@@ -20,9 +22,10 @@ class ReversedPagesAdapter(
parent: ViewGroup, parent: ViewGroup,
loader: PageLoader, loader: PageLoader,
settings: ReaderSettings, settings: ReaderSettings,
networkState: NetworkStateObserver, networkState: NetworkState,
exceptionResolver: ExceptionResolver, exceptionResolver: ExceptionResolver,
) = ReversedPageHolder( ) = ReversedPageHolder(
owner = lifecycleOwner,
binding = ItemPageBinding.inflate(LayoutInflater.from(parent.context), parent, false), binding = ItemPageBinding.inflate(LayoutInflater.from(parent.context), parent, false),
loader = loader, loader = loader,
settings = settings, settings = settings,

View File

@@ -8,7 +8,7 @@ import android.view.ViewGroup
import androidx.core.view.children import androidx.core.view.children
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.async import kotlinx.coroutines.async
import org.koitharu.kotatsu.core.os.NetworkStateObserver import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.databinding.FragmentReaderStandardBinding import org.koitharu.kotatsu.databinding.FragmentReaderStandardBinding
import org.koitharu.kotatsu.reader.ui.ReaderState import org.koitharu.kotatsu.reader.ui.ReaderState
import org.koitharu.kotatsu.reader.ui.pager.BaseReader import org.koitharu.kotatsu.reader.ui.pager.BaseReader
@@ -26,7 +26,7 @@ import kotlin.math.absoluteValue
class ReversedReaderFragment : BaseReader<FragmentReaderStandardBinding>() { class ReversedReaderFragment : BaseReader<FragmentReaderStandardBinding>() {
@Inject @Inject
lateinit var networkStateObserver: NetworkStateObserver lateinit var networkState: NetworkState
private var pagerAdapter: ReversedPagesAdapter? = null private var pagerAdapter: ReversedPagesAdapter? = null
@@ -39,10 +39,11 @@ class ReversedReaderFragment : BaseReader<FragmentReaderStandardBinding>() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
pagerAdapter = ReversedPagesAdapter( pagerAdapter = ReversedPagesAdapter(
viewModel.pageLoader, lifecycleOwner = viewLifecycleOwner,
viewModel.readerSettings, loader = viewModel.pageLoader,
networkStateObserver, settings = viewModel.readerSettings,
exceptionResolver, networkState = networkState,
exceptionResolver = exceptionResolver,
) )
with(binding.pager) { with(binding.pager) {
adapter = pagerAdapter adapter = pagerAdapter

View File

@@ -5,12 +5,13 @@ import android.graphics.PointF
import android.net.Uri import android.net.Uri
import android.view.View import android.view.View
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.lifecycle.LifecycleOwner
import com.davemorrissey.labs.subscaleview.ImageSource import com.davemorrissey.labs.subscaleview.ImageSource
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.model.ZoomMode import org.koitharu.kotatsu.core.model.ZoomMode
import org.koitharu.kotatsu.core.os.NetworkStateObserver import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.databinding.ItemPageBinding import org.koitharu.kotatsu.databinding.ItemPageBinding
import org.koitharu.kotatsu.reader.domain.PageLoader import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.reader.ui.config.ReaderSettings import org.koitharu.kotatsu.reader.ui.config.ReaderSettings
@@ -19,15 +20,17 @@ import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
import org.koitharu.kotatsu.utils.ext.* import org.koitharu.kotatsu.utils.ext.*
open class PageHolder( open class PageHolder(
owner: LifecycleOwner,
binding: ItemPageBinding, binding: ItemPageBinding,
loader: PageLoader, loader: PageLoader,
settings: ReaderSettings, settings: ReaderSettings,
networkState: NetworkStateObserver, networkState: NetworkState,
exceptionResolver: ExceptionResolver, exceptionResolver: ExceptionResolver,
) : BasePageHolder<ItemPageBinding>(binding, loader, settings, networkState, exceptionResolver), ) : BasePageHolder<ItemPageBinding>(binding, loader, settings, networkState, exceptionResolver),
View.OnClickListener { View.OnClickListener {
init { init {
binding.ssiv.bindToLifecycle(owner)
binding.ssiv.isEagerLoadingEnabled = !isLowRamDevice(context) binding.ssiv.isEagerLoadingEnabled = !isLowRamDevice(context)
binding.ssiv.addOnImageEventListener(delegate) binding.ssiv.addOnImageEventListener(delegate)
@Suppress("LeakingThis") @Suppress("LeakingThis")

View File

@@ -8,7 +8,7 @@ import android.view.ViewGroup
import androidx.core.view.children import androidx.core.view.children
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.async import kotlinx.coroutines.async
import org.koitharu.kotatsu.core.os.NetworkStateObserver import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.databinding.FragmentReaderStandardBinding import org.koitharu.kotatsu.databinding.FragmentReaderStandardBinding
import org.koitharu.kotatsu.reader.ui.ReaderState import org.koitharu.kotatsu.reader.ui.ReaderState
import org.koitharu.kotatsu.reader.ui.pager.BaseReader import org.koitharu.kotatsu.reader.ui.pager.BaseReader
@@ -25,7 +25,7 @@ import kotlin.math.absoluteValue
class PagerReaderFragment : BaseReader<FragmentReaderStandardBinding>() { class PagerReaderFragment : BaseReader<FragmentReaderStandardBinding>() {
@Inject @Inject
lateinit var networkStateObserver: NetworkStateObserver lateinit var networkState: NetworkState
private var pagesAdapter: PagesAdapter? = null private var pagesAdapter: PagesAdapter? = null
@@ -38,10 +38,11 @@ class PagerReaderFragment : BaseReader<FragmentReaderStandardBinding>() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
pagesAdapter = PagesAdapter( pagesAdapter = PagesAdapter(
viewModel.pageLoader, lifecycleOwner = viewLifecycleOwner,
viewModel.readerSettings, loader = viewModel.pageLoader,
networkStateObserver, settings = viewModel.readerSettings,
exceptionResolver, networkState = networkState,
exceptionResolver = exceptionResolver,
) )
with(binding.pager) { with(binding.pager) {
adapter = pagesAdapter adapter = pagesAdapter

View File

@@ -2,27 +2,30 @@ package org.koitharu.kotatsu.reader.ui.pager.standard
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.ViewGroup import android.view.ViewGroup
import androidx.lifecycle.LifecycleOwner
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.os.NetworkStateObserver import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.databinding.ItemPageBinding import org.koitharu.kotatsu.databinding.ItemPageBinding
import org.koitharu.kotatsu.reader.domain.PageLoader import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.reader.ui.config.ReaderSettings import org.koitharu.kotatsu.reader.ui.config.ReaderSettings
import org.koitharu.kotatsu.reader.ui.pager.BaseReaderAdapter import org.koitharu.kotatsu.reader.ui.pager.BaseReaderAdapter
class PagesAdapter( class PagesAdapter(
private val lifecycleOwner: LifecycleOwner,
loader: PageLoader, loader: PageLoader,
settings: ReaderSettings, settings: ReaderSettings,
networkStateObserver: NetworkStateObserver, networkState: NetworkState,
exceptionResolver: ExceptionResolver, exceptionResolver: ExceptionResolver,
) : BaseReaderAdapter<PageHolder>(loader, settings, networkStateObserver, exceptionResolver) { ) : BaseReaderAdapter<PageHolder>(loader, settings, networkState, exceptionResolver) {
override fun onCreateViewHolder( override fun onCreateViewHolder(
parent: ViewGroup, parent: ViewGroup,
loader: PageLoader, loader: PageLoader,
settings: ReaderSettings, settings: ReaderSettings,
networkState: NetworkStateObserver, networkState: NetworkState,
exceptionResolver: ExceptionResolver, exceptionResolver: ExceptionResolver,
) = PageHolder( ) = PageHolder(
owner = lifecycleOwner,
binding = ItemPageBinding.inflate(LayoutInflater.from(parent.context), parent, false), binding = ItemPageBinding.inflate(LayoutInflater.from(parent.context), parent, false),
loader = loader, loader = loader,
settings = settings, settings = settings,

View File

@@ -2,17 +2,19 @@ package org.koitharu.kotatsu.reader.ui.pager.webtoon
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.ViewGroup import android.view.ViewGroup
import androidx.lifecycle.LifecycleOwner
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.os.NetworkStateObserver import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.databinding.ItemPageWebtoonBinding import org.koitharu.kotatsu.databinding.ItemPageWebtoonBinding
import org.koitharu.kotatsu.reader.domain.PageLoader import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.reader.ui.config.ReaderSettings import org.koitharu.kotatsu.reader.ui.config.ReaderSettings
import org.koitharu.kotatsu.reader.ui.pager.BaseReaderAdapter import org.koitharu.kotatsu.reader.ui.pager.BaseReaderAdapter
class WebtoonAdapter( class WebtoonAdapter(
private val lifecycleOwner: LifecycleOwner,
loader: PageLoader, loader: PageLoader,
settings: ReaderSettings, settings: ReaderSettings,
networkState: NetworkStateObserver, networkState: NetworkState,
exceptionResolver: ExceptionResolver, exceptionResolver: ExceptionResolver,
) : BaseReaderAdapter<WebtoonHolder>(loader, settings, networkState, exceptionResolver) { ) : BaseReaderAdapter<WebtoonHolder>(loader, settings, networkState, exceptionResolver) {
@@ -20,9 +22,10 @@ class WebtoonAdapter(
parent: ViewGroup, parent: ViewGroup,
loader: PageLoader, loader: PageLoader,
settings: ReaderSettings, settings: ReaderSettings,
networkState: NetworkStateObserver, networkState: NetworkState,
exceptionResolver: ExceptionResolver, exceptionResolver: ExceptionResolver,
) = WebtoonHolder( ) = WebtoonHolder(
owner = lifecycleOwner,
binding = ItemPageWebtoonBinding.inflate( binding = ItemPageWebtoonBinding.inflate(
LayoutInflater.from(parent.context), LayoutInflater.from(parent.context),
parent, parent,

View File

@@ -3,12 +3,13 @@ package org.koitharu.kotatsu.reader.ui.pager.webtoon
import android.net.Uri import android.net.Uri
import android.view.View import android.view.View
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.lifecycle.LifecycleOwner
import com.davemorrissey.labs.subscaleview.ImageSource import com.davemorrissey.labs.subscaleview.ImageSource
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
import com.davemorrissey.labs.subscaleview.decoder.SkiaPooledImageRegionDecoder import com.davemorrissey.labs.subscaleview.decoder.SkiaPooledImageRegionDecoder
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.os.NetworkStateObserver import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.databinding.ItemPageWebtoonBinding import org.koitharu.kotatsu.databinding.ItemPageWebtoonBinding
import org.koitharu.kotatsu.reader.domain.PageLoader import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.reader.ui.config.ReaderSettings import org.koitharu.kotatsu.reader.ui.config.ReaderSettings
@@ -22,10 +23,11 @@ import org.koitharu.kotatsu.utils.ext.setProgressCompat
import org.koitharu.kotatsu.utils.ext.showCompat import org.koitharu.kotatsu.utils.ext.showCompat
class WebtoonHolder( class WebtoonHolder(
owner: LifecycleOwner,
binding: ItemPageWebtoonBinding, binding: ItemPageWebtoonBinding,
loader: PageLoader, loader: PageLoader,
settings: ReaderSettings, settings: ReaderSettings,
networkState: NetworkStateObserver, networkState: NetworkState,
exceptionResolver: ExceptionResolver, exceptionResolver: ExceptionResolver,
) : BasePageHolder<ItemPageWebtoonBinding>(binding, loader, settings, networkState, exceptionResolver), ) : BasePageHolder<ItemPageWebtoonBinding>(binding, loader, settings, networkState, exceptionResolver),
View.OnClickListener { View.OnClickListener {
@@ -34,6 +36,7 @@ class WebtoonHolder(
private val goneOnInvisibleListener = GoneOnInvisibleListener(bindingInfo.progressBar) private val goneOnInvisibleListener = GoneOnInvisibleListener(bindingInfo.progressBar)
init { init {
binding.ssiv.bindToLifecycle(owner)
binding.ssiv.regionDecoderFactory = SkiaPooledImageRegionDecoder.Factory() binding.ssiv.regionDecoderFactory = SkiaPooledImageRegionDecoder.Factory()
binding.ssiv.addOnImageEventListener(delegate) binding.ssiv.addOnImageEventListener(delegate)
bindingInfo.buttonRetry.setOnClickListener(this) bindingInfo.buttonRetry.setOnClickListener(this)

View File

@@ -7,7 +7,7 @@ import android.view.ViewGroup
import android.view.animation.AccelerateDecelerateInterpolator import android.view.animation.AccelerateDecelerateInterpolator
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.async import kotlinx.coroutines.async
import org.koitharu.kotatsu.core.os.NetworkStateObserver import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.databinding.FragmentReaderWebtoonBinding import org.koitharu.kotatsu.databinding.FragmentReaderWebtoonBinding
import org.koitharu.kotatsu.reader.ui.ReaderState import org.koitharu.kotatsu.reader.ui.ReaderState
import org.koitharu.kotatsu.reader.ui.pager.BaseReader import org.koitharu.kotatsu.reader.ui.pager.BaseReader
@@ -22,7 +22,7 @@ import javax.inject.Inject
class WebtoonReaderFragment : BaseReader<FragmentReaderWebtoonBinding>() { class WebtoonReaderFragment : BaseReader<FragmentReaderWebtoonBinding>() {
@Inject @Inject
lateinit var networkStateObserver: NetworkStateObserver lateinit var networkState: NetworkState
private val scrollInterpolator = AccelerateDecelerateInterpolator() private val scrollInterpolator = AccelerateDecelerateInterpolator()
private var webtoonAdapter: WebtoonAdapter? = null private var webtoonAdapter: WebtoonAdapter? = null
@@ -35,10 +35,11 @@ class WebtoonReaderFragment : BaseReader<FragmentReaderWebtoonBinding>() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
webtoonAdapter = WebtoonAdapter( webtoonAdapter = WebtoonAdapter(
viewModel.pageLoader, lifecycleOwner = viewLifecycleOwner,
viewModel.readerSettings, loader = viewModel.pageLoader,
networkStateObserver, settings = viewModel.readerSettings,
exceptionResolver, networkState = networkState,
exceptionResolver = exceptionResolver,
) )
with(binding.recyclerView) { with(binding.recyclerView) {
setHasFixedSize(true) setHasFixedSize(true)

View File

@@ -63,7 +63,7 @@ class RemoteListViewModel @AssistedInject constructor(
override val content = combine( override val content = combine(
mangaList, mangaList,
createListModeFlow(), listModeFlow,
createHeaderFlow(), createHeaderFlow(),
listError, listError,
hasNextPage, hasNextPage,

View File

@@ -39,7 +39,7 @@ class SearchViewModel @AssistedInject constructor(
override val content = combine( override val content = combine(
mangaList, mangaList,
createListModeFlow(), listModeFlow,
listError, listError,
hasNextPage, hasNextPage,
) { list, mode, error, hasNext -> ) { list, mode, error, hasNext ->

View File

@@ -12,7 +12,6 @@ import androidx.core.graphics.Insets
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
import coil.ImageLoader import coil.ImageLoader
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseActivity import org.koitharu.kotatsu.base.ui.BaseActivity
import org.koitharu.kotatsu.base.ui.list.ListSelectionController import org.koitharu.kotatsu.base.ui.list.ListSelectionController
@@ -27,11 +26,15 @@ import org.koitharu.kotatsu.list.ui.adapter.MangaListListener
import org.koitharu.kotatsu.list.ui.model.ListHeader import org.koitharu.kotatsu.list.ui.model.ListHeader
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.reader.ui.ReaderActivity
import org.koitharu.kotatsu.search.ui.MangaListActivity
import org.koitharu.kotatsu.search.ui.SearchActivity import org.koitharu.kotatsu.search.ui.SearchActivity
import org.koitharu.kotatsu.search.ui.multi.adapter.MultiSearchAdapter import org.koitharu.kotatsu.search.ui.multi.adapter.MultiSearchAdapter
import org.koitharu.kotatsu.utils.ShareHelper import org.koitharu.kotatsu.utils.ShareHelper
import org.koitharu.kotatsu.utils.ext.assistedViewModels import org.koitharu.kotatsu.utils.ext.assistedViewModels
import org.koitharu.kotatsu.utils.ext.invalidateNestedItemDecorations import org.koitharu.kotatsu.utils.ext.invalidateNestedItemDecorations
import org.koitharu.kotatsu.utils.ext.scaleUpActivityOptionsOf
import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class MultiSearchActivity : class MultiSearchActivity :
@@ -110,6 +113,20 @@ class MultiSearchActivity :
return selectionController.onItemLongClick(item.id) return selectionController.onItemLongClick(item.id)
} }
override fun onReadClick(manga: Manga, view: View) {
if (!selectionController.onItemClick(manga.id)) {
val intent = ReaderActivity.newIntent(this, manga)
startActivity(intent, scaleUpActivityOptionsOf(view).toBundle())
}
}
override fun onTagClick(manga: Manga, tag: MangaTag, view: View) {
if (!selectionController.onItemClick(manga.id)) {
val intent = MangaListActivity.newIntent(this, setOf(tag))
startActivity(intent)
}
}
override fun onRetryClick(error: Throwable) { override fun onRetryClick(error: Throwable) {
viewModel.doSearch(viewModel.query.value.orEmpty()) viewModel.doSearch(viewModel.query.value.orEmpty())
} }
@@ -139,16 +156,19 @@ class MultiSearchActivity :
mode.finish() mode.finish()
true true
} }
R.id.action_favourite -> { R.id.action_favourite -> {
FavouriteCategoriesBottomSheet.show(supportFragmentManager, collectSelectedItems()) FavouriteCategoriesBottomSheet.show(supportFragmentManager, collectSelectedItems())
mode.finish() mode.finish()
true true
} }
R.id.action_save -> { R.id.action_save -> {
DownloadService.confirmAndStart(this, collectSelectedItems()) DownloadService.confirmAndStart(this, collectSelectedItems())
mode.finish() mode.finish()
true true
} }
else -> false else -> false
} }
} }

View File

@@ -1,27 +1,38 @@
package org.koitharu.kotatsu.settings package org.koitharu.kotatsu.settings
import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.SharedPreferences import android.content.SharedPreferences
import android.net.Uri
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.provider.Settings
import android.view.View import android.view.View
import androidx.appcompat.app.AppCompatDelegate import androidx.appcompat.app.AppCompatDelegate
import androidx.core.app.LocaleManagerCompat
import androidx.core.view.postDelayed import androidx.core.view.postDelayed
import androidx.preference.ListPreference import androidx.preference.ListPreference
import androidx.preference.Preference import androidx.preference.Preference
import androidx.preference.TwoStatePreference import androidx.preference.TwoStatePreference
import com.google.android.material.color.DynamicColors import com.google.android.material.color.DynamicColors
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import java.util.*
import javax.inject.Inject
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BasePreferenceFragment import org.koitharu.kotatsu.base.ui.BasePreferenceFragment
import org.koitharu.kotatsu.base.ui.util.ActivityRecreationHandle import org.koitharu.kotatsu.base.ui.util.ActivityRecreationHandle
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.ListMode import org.koitharu.kotatsu.core.prefs.ListMode
import org.koitharu.kotatsu.parsers.util.names import org.koitharu.kotatsu.parsers.util.names
import org.koitharu.kotatsu.parsers.util.toTitleCase
import org.koitharu.kotatsu.settings.protect.ProtectSetupActivity import org.koitharu.kotatsu.settings.protect.ProtectSetupActivity
import org.koitharu.kotatsu.settings.utils.ActivityListPreference
import org.koitharu.kotatsu.settings.utils.SliderPreference import org.koitharu.kotatsu.settings.utils.SliderPreference
import org.koitharu.kotatsu.utils.ext.getLocalesConfig
import org.koitharu.kotatsu.utils.ext.map
import org.koitharu.kotatsu.utils.ext.setDefaultValueCompat import org.koitharu.kotatsu.utils.ext.setDefaultValueCompat
import org.koitharu.kotatsu.utils.ext.toList
import java.util.Date
import java.util.Locale
import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class AppearanceSettingsFragment : class AppearanceSettingsFragment :
@@ -52,7 +63,7 @@ class AppearanceSettingsFragment :
entries = entryValues.map { value -> entries = entryValues.map { value ->
val formattedDate = settings.getDateFormat(value.toString()).format(now) val formattedDate = settings.getDateFormat(value.toString()).format(now)
if (value == "") { if (value == "") {
"${context.getString(R.string.system_default)} ($formattedDate)" getString(R.string.default_s, formattedDate)
} else { } else {
formattedDate formattedDate
} }
@@ -62,6 +73,20 @@ class AppearanceSettingsFragment :
} }
findPreference<TwoStatePreference>(AppSettings.KEY_PROTECT_APP) findPreference<TwoStatePreference>(AppSettings.KEY_PROTECT_APP)
?.isChecked = !settings.appPassword.isNullOrEmpty() ?.isChecked = !settings.appPassword.isNullOrEmpty()
findPreference<ActivityListPreference>(AppSettings.KEY_APP_LOCALE)?.run {
initLocalePicker(this)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
activityIntent = Intent(
Settings.ACTION_APP_LOCALE_SETTINGS,
Uri.fromParts("package", context.packageName, null),
)
}
summaryProvider = Preference.SummaryProvider<ActivityListPreference> {
val locale = AppCompatDelegate.getApplicationLocales().get(0)
locale?.getDisplayName(locale)?.toTitleCase(locale) ?: getString(R.string.automatic)
}
setDefaultValueCompat("")
}
} }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
@@ -79,16 +104,23 @@ class AppearanceSettingsFragment :
AppSettings.KEY_THEME -> { AppSettings.KEY_THEME -> {
AppCompatDelegate.setDefaultNightMode(settings.theme) AppCompatDelegate.setDefaultNightMode(settings.theme)
} }
AppSettings.KEY_DYNAMIC_THEME -> { AppSettings.KEY_DYNAMIC_THEME -> {
postRestart() postRestart()
} }
AppSettings.KEY_THEME_AMOLED -> { AppSettings.KEY_THEME_AMOLED -> {
postRestart() postRestart()
} }
AppSettings.KEY_APP_PASSWORD -> { AppSettings.KEY_APP_PASSWORD -> {
findPreference<TwoStatePreference>(AppSettings.KEY_PROTECT_APP) findPreference<TwoStatePreference>(AppSettings.KEY_PROTECT_APP)
?.isChecked = !settings.appPassword.isNullOrEmpty() ?.isChecked = !settings.appPassword.isNullOrEmpty()
} }
AppSettings.KEY_APP_LOCALE -> {
AppCompatDelegate.setApplicationLocales(settings.appLocales)
}
} }
} }
@@ -104,6 +136,7 @@ class AppearanceSettingsFragment :
} }
true true
} }
else -> super.onPreferenceTreeClick(preference) else -> super.onPreferenceTreeClick(preference)
} }
} }
@@ -113,4 +146,45 @@ class AppearanceSettingsFragment :
activityRecreationHandle.recreateAll() activityRecreationHandle.recreateAll()
} }
} }
private fun initLocalePicker(preference: ListPreference) {
val locales = resources.getLocalesConfig()
.toList()
.sortedWith(LocaleComparator(preference.context))
preference.entries = Array(locales.size + 1) { i ->
if (i == 0) {
getString(R.string.automatic)
} else {
val lc = locales[i - 1]
lc.getDisplayName(lc).toTitleCase(lc)
}
}
preference.entryValues = Array(locales.size + 1) { i ->
if (i == 0) {
""
} else {
locales[i - 1].toLanguageTag()
}
}
}
private class LocaleComparator(context: Context) : Comparator<Locale> {
private val deviceLocales = LocaleManagerCompat.getSystemLocales(context)
.map { it.language }
override fun compare(a: Locale, b: Locale): Int {
return if (a === b) {
0
} else {
val indexA = deviceLocales.indexOf(a.language)
val indexB = deviceLocales.indexOf(b.language)
if (indexA == -1 && indexB == -1) {
compareValues(a.language, b.language)
} else {
-2 - (indexA - indexB)
}
}
}
}
} }

View File

@@ -9,14 +9,13 @@ import androidx.preference.ListPreference
import androidx.preference.Preference import androidx.preference.Preference
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import java.io.File
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BasePreferenceFragment import org.koitharu.kotatsu.base.ui.BasePreferenceFragment
import org.koitharu.kotatsu.base.ui.dialog.StorageSelectDialog import org.koitharu.kotatsu.base.ui.dialog.StorageSelectDialog
import org.koitharu.kotatsu.core.cache.ContentCache
import org.koitharu.kotatsu.core.network.DoHProvider import org.koitharu.kotatsu.core.network.DoHProvider
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.local.data.LocalStorageManager import org.koitharu.kotatsu.local.data.LocalStorageManager
@@ -26,6 +25,8 @@ import org.koitharu.kotatsu.sync.ui.SyncSettingsIntent
import org.koitharu.kotatsu.utils.ext.getStorageName import org.koitharu.kotatsu.utils.ext.getStorageName
import org.koitharu.kotatsu.utils.ext.setDefaultValueCompat import org.koitharu.kotatsu.utils.ext.setDefaultValueCompat
import org.koitharu.kotatsu.utils.ext.viewLifecycleScope import org.koitharu.kotatsu.utils.ext.viewLifecycleScope
import java.io.File
import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class ContentSettingsFragment : class ContentSettingsFragment :
@@ -36,9 +37,12 @@ class ContentSettingsFragment :
@Inject @Inject
lateinit var storageManager: LocalStorageManager lateinit var storageManager: LocalStorageManager
@Inject
lateinit var contentCache: ContentCache
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
addPreferencesFromResource(R.xml.pref_content) addPreferencesFromResource(R.xml.pref_content)
findPreference<Preference>(AppSettings.KEY_PREFETCH_CONTENT)?.isVisible = contentCache.isCachingEnabled
findPreference<SliderPreference>(AppSettings.KEY_DOWNLOADS_PARALLELISM)?.run { findPreference<SliderPreference>(AppSettings.KEY_DOWNLOADS_PARALLELISM)?.run {
summary = value.toString() summary = value.toString()
setOnPreferenceChangeListener { preference, newValue -> setOnPreferenceChangeListener { preference, newValue ->
@@ -82,11 +86,13 @@ class ContentSettingsFragment :
AppSettings.KEY_LOCAL_STORAGE -> { AppSettings.KEY_LOCAL_STORAGE -> {
findPreference<Preference>(key)?.bindStorageName() findPreference<Preference>(key)?.bindStorageName()
} }
AppSettings.KEY_SUGGESTIONS -> { AppSettings.KEY_SUGGESTIONS -> {
findPreference<Preference>(AppSettings.KEY_SUGGESTIONS)?.setSummary( findPreference<Preference>(AppSettings.KEY_SUGGESTIONS)?.setSummary(
if (settings.isSuggestionsEnabled) R.string.enabled else R.string.disabled, if (settings.isSuggestionsEnabled) R.string.enabled else R.string.disabled,
) )
} }
AppSettings.KEY_SOURCES_HIDDEN -> { AppSettings.KEY_SOURCES_HIDDEN -> {
bindRemoteSourcesSummary() bindRemoteSourcesSummary()
} }
@@ -104,6 +110,7 @@ class ContentSettingsFragment :
.show() .show()
true true
} }
AppSettings.KEY_SYNC -> { AppSettings.KEY_SYNC -> {
val am = AccountManager.get(requireContext()) val am = AccountManager.get(requireContext())
val accountType = getString(R.string.account_type_sync) val accountType = getString(R.string.account_type_sync)
@@ -119,6 +126,7 @@ class ContentSettingsFragment :
} }
true true
} }
else -> super.onPreferenceTreeClick(preference) else -> super.onPreferenceTreeClick(preference)
} }
} }

View File

@@ -12,7 +12,7 @@ import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BasePreferenceFragment import org.koitharu.kotatsu.base.ui.BasePreferenceFragment
import org.koitharu.kotatsu.core.network.AndroidCookieJar import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
import org.koitharu.kotatsu.core.os.ShortcutsUpdater import org.koitharu.kotatsu.core.os.ShortcutsUpdater
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.local.data.CacheDir import org.koitharu.kotatsu.local.data.CacheDir
@@ -41,7 +41,7 @@ class HistorySettingsFragment : BasePreferenceFragment(R.string.history_and_cach
lateinit var shikimoriRepository: ShikimoriRepository lateinit var shikimoriRepository: ShikimoriRepository
@Inject @Inject
lateinit var cookieJar: AndroidCookieJar lateinit var cookieJar: MutableCookieJar
@Inject @Inject
lateinit var shortcutsUpdater: ShortcutsUpdater lateinit var shortcutsUpdater: ShortcutsUpdater

View File

@@ -78,6 +78,7 @@ class SettingsActivity :
startActivity(intent) startActivity(intent)
true true
} }
else -> super.onOptionsItemSelected(item) else -> super.onOptionsItemSelected(item)
} }
@@ -132,6 +133,7 @@ class SettingsActivity :
ACTION_SOURCE -> SourceSettingsFragment.newInstance( ACTION_SOURCE -> SourceSettingsFragment.newInstance(
intent.getSerializableExtra(EXTRA_SOURCE) as? MangaSource ?: MangaSource.LOCAL, intent.getSerializableExtra(EXTRA_SOURCE) as? MangaSource ?: MangaSource.LOCAL,
) )
ACTION_MANAGE_SOURCES -> SourcesSettingsFragment() ACTION_MANAGE_SOURCES -> SourcesSettingsFragment()
else -> SettingsHeadersFragment() else -> SettingsHeadersFragment()
} }

View File

@@ -86,7 +86,6 @@ class SourceSettingsFragment : BasePreferenceFragment(0) {
}.onSuccess { username -> }.onSuccess { username ->
preference.title = getString(R.string.logged_in_as, username) preference.title = getString(R.string.logged_in_as, username)
}.onFailure { error -> }.onFailure { error ->
preference.isEnabled = error is AuthRequiredException
when { when {
error is AuthRequiredException -> Unit error is AuthRequiredException -> Unit
ExceptionResolver.canResolve(error) -> { ExceptionResolver.canResolve(error) -> {

View File

@@ -7,16 +7,24 @@ import androidx.core.net.toUri
import androidx.fragment.app.viewModels import androidx.fragment.app.viewModels
import androidx.preference.Preference import androidx.preference.Preference
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BasePreferenceFragment import org.koitharu.kotatsu.base.ui.BasePreferenceFragment
import org.koitharu.kotatsu.core.github.AppVersion import org.koitharu.kotatsu.core.github.AppVersion
import org.koitharu.kotatsu.core.logs.FileLogger
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.utils.ShareHelper
import javax.inject.Inject
@AndroidEntryPoint
class AboutSettingsFragment : BasePreferenceFragment(R.string.about) { class AboutSettingsFragment : BasePreferenceFragment(R.string.about) {
private val viewModel by viewModels<AboutSettingsViewModel>() private val viewModel by viewModels<AboutSettingsViewModel>()
@Inject
lateinit var loggers: Set<@JvmSuppressWildcards FileLogger>
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
addPreferencesFromResource(R.xml.pref_about) addPreferencesFromResource(R.xml.pref_about)
findPreference<Preference>(AppSettings.KEY_APP_VERSION)?.run { findPreference<Preference>(AppSettings.KEY_APP_VERSION)?.run {
@@ -39,10 +47,17 @@ class AboutSettingsFragment : BasePreferenceFragment(R.string.about) {
viewModel.checkForUpdates() viewModel.checkForUpdates()
true true
} }
AppSettings.KEY_APP_TRANSLATION -> { AppSettings.KEY_APP_TRANSLATION -> {
openLink(getString(R.string.url_weblate), preference.title) openLink(getString(R.string.url_weblate), preference.title)
true true
} }
AppSettings.KEY_LOGS_SHARE -> {
ShareHelper(preference.context).shareLogs(loggers)
true
}
else -> super.onPreferenceTreeClick(preference) else -> super.onPreferenceTreeClick(preference)
} }
} }

View File

@@ -3,22 +3,24 @@ package org.koitharu.kotatsu.settings.about
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import androidx.core.net.toUri import androidx.core.net.toUri
import com.google.android.material.R as materialR import androidx.core.text.buildSpannedString
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import io.noties.markwon.Markwon
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.github.AppVersion import org.koitharu.kotatsu.core.github.AppVersion
import org.koitharu.kotatsu.utils.FileSize import org.koitharu.kotatsu.utils.FileSize
import com.google.android.material.R as materialR
class AppUpdateDialog(private val context: Context) { class AppUpdateDialog(private val context: Context) {
fun show(version: AppVersion) { fun show(version: AppVersion) {
val message = buildString { val message = buildSpannedString {
append(context.getString(R.string.new_version_s, version.name)) append(context.getString(R.string.new_version_s, version.name))
appendLine() appendLine()
append(context.getString(R.string.size_s, FileSize.BYTES.format(context, version.apkSize))) append(context.getString(R.string.size_s, FileSize.BYTES.format(context, version.apkSize)))
appendLine() appendLine()
appendLine() appendLine()
append(version.description) append(Markwon.create(context).toMarkdown(version.description))
} }
MaterialAlertDialogBuilder( MaterialAlertDialogBuilder(
context, context,

View File

@@ -4,11 +4,11 @@ import android.app.backup.BackupManager
import android.content.Context import android.content.Context
import androidx.room.InvalidationTracker import androidx.room.InvalidationTracker
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
import org.koitharu.kotatsu.core.db.TABLE_FAVOURITES import org.koitharu.kotatsu.core.db.TABLE_FAVOURITES
import org.koitharu.kotatsu.core.db.TABLE_FAVOURITE_CATEGORIES import org.koitharu.kotatsu.core.db.TABLE_FAVOURITE_CATEGORIES
import org.koitharu.kotatsu.core.db.TABLE_HISTORY import org.koitharu.kotatsu.core.db.TABLE_HISTORY
import javax.inject.Inject
import javax.inject.Singleton
@Singleton @Singleton
class BackupObserver @Inject constructor( class BackupObserver @Inject constructor(
@@ -17,7 +17,7 @@ class BackupObserver @Inject constructor(
private val backupManager = BackupManager(context) private val backupManager = BackupManager(context)
override fun onInvalidated(tables: MutableSet<String>) { override fun onInvalidated(tables: Set<String>) {
backupManager.dataChanged() backupManager.dataChanged()
} }
} }

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