Compare commits

...

64 Commits

Author SHA1 Message Date
Koitharu
42bb5a65ab Fix crash in ScrobblingInfoSheet 2023-07-24 16:08:25 +03:00
Koitharu
0c37265a5b Update parsers 2023-07-24 16:03:08 +03:00
plum7x
7a65ae3ea7 Translated using Weblate (Chinese (Traditional))
Currently translated at 100.0% (447 of 447 strings)

Co-authored-by: plum7x <plumgift@hotmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hant/
Translation: Kotatsu/Strings
2023-07-24 15:56:11 +03:00
InfinityDouki56
ee027cd64f Translated using Weblate (Filipino)
Currently translated at 91.0% (407 of 447 strings)

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

Translated using Weblate (Korean)

Currently translated at 79.6% (356 of 447 strings)

Co-authored-by: Макар Разин <makarrazin14@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ko/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pl/
Translation: Kotatsu/Strings
2023-07-22 13:59:48 +03:00
plum7x
eff2d6bcb6 Translated using Weblate (Chinese (Traditional))
Currently translated at 100.0% (447 of 447 strings)

Co-authored-by: plum7x <plumgift@hotmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hant/
Translation: Kotatsu/Strings
2023-07-22 13:59:48 +03:00
Koitharu
08acf2d882 Fix crashes 2023-07-19 15:18:30 +03:00
Koitharu
1d78c64350 Move coroutines from UserDataSettingsFragment to ViewModel 2023-07-19 13:32:02 +03:00
Koitharu
321a9ecf62 Update parsers 2023-07-19 12:30:58 +03:00
Koitharu
439a01c43f Fix bookmark has direct url detection #424 2023-07-18 11:43:31 +03:00
Koitharu
3a9d0def7d Update parsers 2023-07-18 10:13:46 +03:00
Koitharu
e4c80b4443 Remove rubbish file 2023-07-17 14:14:23 +03:00
Koitharu
940d448e00 Fix local manga update on shelf 2023-07-17 14:13:16 +03:00
Koitharu
5ab48a7545 Fix scrobbling rating 2023-07-17 13:30:50 +03:00
Koitharu
cb2bdbdd9a Update parsers 2023-07-17 13:08:39 +03:00
Cookies
8fdaf92cc4 Translated using Weblate (Vietnamese)
Currently translated at 89.2% (399 of 447 strings)

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

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

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

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

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

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

Translated using Weblate (Czech)

Currently translated at 100.0% (447 of 447 strings)

Added translation using Weblate (Czech)

Added translation using Weblate (Czech)

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

Co-authored-by: Nguyễn Mạnh Hùng <hungmn13@tuta.io>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/vi/
Translation: Kotatsu/Strings
2023-07-11 09:54:06 +03:00
Hosted Weblate
48b9c1236d Update translation files
Updated by "Remove blank strings" hook in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/
Translation: Kotatsu/Strings
2023-07-11 09:54:06 +03:00
plum7x
c69d293caa Translated using Weblate (Chinese (Traditional))
Currently translated at 100.0% (447 of 447 strings)

Translated using Weblate (Chinese (Traditional))

Currently translated at 97.5% (436 of 447 strings)

Translated using Weblate (Chinese (Traditional))

Currently translated at 97.3% (435 of 447 strings)

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

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

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

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

Co-authored-by: J. Lavoie <j.lavoie@net-c.ca>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fr/
Translation: Kotatsu/Strings
2023-06-27 12:30:56 +03:00
gallegonovato
9b3ea57db1 Translated using Weblate (Spanish)
Currently translated at 100.0% (447 of 447 strings)

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

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

Translated using Weblate (Ukrainian)

Currently translated at 99.5% (445 of 447 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (447 of 447 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (447 of 447 strings)

Co-authored-by: Макар Разин <makarrazin14@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/be/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/sr/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/uk/
Translation: Kotatsu/Strings
2023-06-27 12:30:56 +03:00
Koitharu
d696606ef9 Misc fixes 2023-06-27 10:28:47 +03:00
Koitharu
0a6e106a1d Filter local manga files 2023-06-24 13:18:09 +03:00
Koitharu
de1a7f0ca8 Fix IndexOutOfBoundsException in RemoteViewsFactory 2023-06-24 09:38:13 +03:00
Koitharu
9d31e76cc7 Translated using Weblate (Russian)
Currently translated at 100.0% (443 of 443 strings)

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

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

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

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

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

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

Translated using Weblate (Spanish)

Currently translated at 100.0% (436 of 436 strings)

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

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

Translated using Weblate (Polish)

Currently translated at 100.0% (435 of 435 strings)

Translated using Weblate (Korean)

Currently translated at 75.1% (327 of 435 strings)

Translated using Weblate (Greek)

Currently translated at 19.3% (84 of 435 strings)

Translated using Weblate (Serbian)

Currently translated at 28.2% (123 of 435 strings)

Translated using Weblate (Arabic)

Currently translated at 18.1% (79 of 435 strings)

Translated using Weblate (Portuguese)

Currently translated at 100.0% (435 of 435 strings)

Translated using Weblate (Italian)

Currently translated at 85.2% (371 of 435 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (435 of 435 strings)

Co-authored-by: Макар Разин <makarrazin14@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ar/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/be/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/el/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/it/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ko/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pl/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/sr/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/vi/
Translation: Kotatsu/Strings
2023-06-23 16:23:37 +03:00
Koitharu
4a7be70898 Update queries in manifest 2023-06-23 16:14:41 +03:00
Koitharu
2bcba1eb21 Configure manga directories 2023-06-22 13:45:29 +03:00
Koitharu
feca7ba3fc Support for custom directories for manga 2023-06-22 10:11:11 +03:00
Koitharu
745b349e5e Ability to remove item from updates 2023-06-21 15:27:20 +03:00
Koitharu
13946783a5 Fix crashes 2023-06-21 15:06:01 +03:00
Koitharu
84e5400522 Download options dialog 2023-06-21 14:54:11 +03:00
Koitharu
02c9a933d2 Fix offline manga details 2023-06-20 17:06:18 +03:00
Koitharu
92af851d3b Option to clear single source cookies 2023-06-20 13:43:09 +03:00
128 changed files with 5894 additions and 2959 deletions

View File

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

1
.idea/.gitignore generated vendored
View File

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

3
.idea/gradle.xml generated
View File

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

View File

@@ -14,9 +14,11 @@ android {
defaultConfig {
applicationId 'org.koitharu.kotatsu'
minSdkVersion 21
//TODO: update as soon as sources becomes available
//noinspection OldTargetApi
targetSdkVersion 33
versionCode 555
versionName '5.2.3'
versionCode 566
versionName '5.3.9'
generatedDensities = []
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
@@ -79,12 +81,12 @@ afterEvaluate {
}
dependencies {
//noinspection GradleDependency
implementation('com.github.KotatsuApp:kotatsu-parsers:86a82970fc') {
implementation('com.github.KotatsuApp:kotatsu-parsers:42cc0430f8') {
exclude group: 'org.json', module: 'json'
}
implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.8.22'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.1'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.2'
implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'androidx.core:core-ktx:1.10.1'
@@ -107,53 +109,54 @@ dependencies {
// TODO https://issuetracker.google.com/issues/254846063
implementation 'androidx.work:work-runtime-ktx:2.8.1'
//noinspection GradleDependency
implementation('com.google.guava:guava:32.0.0-android') {
implementation('com.google.guava:guava:32.0.1-android') {
exclude group: 'com.google.guava', module: 'failureaccess'
exclude group: 'org.checkerframework', module: 'checker-qual'
exclude group: 'com.google.j2objc', module: 'j2objc-annotations'
}
implementation 'androidx.room:room-runtime:2.5.1'
implementation 'androidx.room:room-ktx:2.5.1'
kapt 'androidx.room:room-compiler:2.5.1'
implementation 'androidx.room:room-runtime:2.5.2'
implementation 'androidx.room:room-ktx:2.5.2'
//noinspection KaptUsageInsteadOfKsp
kapt 'androidx.room:room-compiler:2.5.2'
implementation 'com.squareup.okhttp3:okhttp:4.11.0'
implementation 'com.squareup.okhttp3:okhttp-dnsoverhttps:4.11.0'
implementation 'com.squareup.okio:okio:3.3.0'
implementation 'com.squareup.okio:okio:3.4.0'
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.2'
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:4.3.2'
implementation 'com.google.dagger:hilt-android:2.46.1'
kapt 'com.google.dagger:hilt-compiler:2.46.1'
implementation 'com.google.dagger:hilt-android:2.47'
kapt 'com.google.dagger:hilt-compiler:2.47'
implementation 'androidx.hilt:hilt-work:1.0.0'
kapt 'androidx.hilt:hilt-compiler:1.0.0'
implementation 'io.coil-kt:coil-base:2.4.0'
implementation 'io.coil-kt:coil-svg:2.4.0'
implementation 'com.github.KotatsuApp:subsampling-scale-image-view:1b19231b2f'
implementation 'com.github.KotatsuApp:subsampling-scale-image-view:9b1d20be67'
implementation 'com.github.solkin:disk-lru-cache:1.4'
implementation 'io.noties.markwon:core:4.6.2'
implementation 'ch.acra:acra-http:5.9.7'
implementation 'ch.acra:acra-dialog:5.9.7'
implementation 'ch.acra:acra-http:5.11.0'
implementation 'ch.acra:acra-dialog:5.11.0'
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.11'
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.12'
testImplementation 'junit:junit:4.13.2'
testImplementation 'org.json:json:20230227'
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.1'
testImplementation 'org.json:json:20230618'
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.2'
androidTestImplementation 'androidx.test:runner:1.5.2'
androidTestImplementation 'androidx.test:rules:1.5.0'
androidTestImplementation 'androidx.test:core-ktx:1.5.0'
androidTestImplementation 'androidx.test.ext:junit-ktx:1.1.5'
androidTestImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.1'
androidTestImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.2'
androidTestImplementation 'androidx.room:room-testing:2.5.1'
androidTestImplementation 'androidx.room:room-testing:2.5.2'
androidTestImplementation 'com.squareup.moshi:moshi-kotlin:1.15.0'
androidTestImplementation 'com.google.dagger:hilt-android-testing:2.46.1'
kaptAndroidTest 'com.google.dagger:hilt-android-compiler:2.46.1'
androidTestImplementation 'com.google.dagger:hilt-android-testing:2.47'
kaptAndroidTest 'com.google.dagger:hilt-android-compiler:2.47'
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -121,13 +121,14 @@ class ScrobblingInfoSheet :
dismissAllowingStateLoss()
return
}
requireViewBinding().textViewTitle.text = scrobbling.title
requireViewBinding().ratingBar.rating = scrobbling.rating * requireViewBinding().ratingBar.numStars
requireViewBinding().textViewDescription.text = scrobbling.description?.sanitize()
requireViewBinding().spinnerStatus.setSelection(scrobbling.status?.ordinal ?: -1)
requireViewBinding().imageViewLogo.contentDescription = getString(scrobbling.scrobbler.titleResId)
requireViewBinding().imageViewLogo.setImageResource(scrobbling.scrobbler.iconResId)
requireViewBinding().imageViewCover.newImageRequest(viewLifecycleOwner, scrobbling.coverUrl)?.apply {
val binding = viewBinding ?: return
binding.textViewTitle.text = scrobbling.title
binding.ratingBar.rating = scrobbling.rating * binding.ratingBar.numStars
binding.textViewDescription.text = scrobbling.description?.sanitize()
binding.spinnerStatus.setSelection(scrobbling.status?.ordinal ?: -1)
binding.imageViewLogo.contentDescription = getString(scrobbling.scrobbler.titleResId)
binding.imageViewLogo.setImageResource(scrobbling.scrobbler.iconResId)
binding.imageViewCover.newImageRequest(viewLifecycleOwner, scrobbling.coverUrl)?.apply {
placeholder(R.drawable.ic_placeholder)
fallback(R.drawable.ic_placeholder)
error(R.drawable.ic_error_placeholder)

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.history.domain
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.launch
@@ -30,7 +31,7 @@ class HistoryUpdateUseCase @Inject constructor(
manga: Manga,
readerState: ReaderState,
percent: Float
) = processLifecycleScope.launch(Dispatchers.Default) {
) = processLifecycleScope.launch(Dispatchers.Default, CoroutineStart.ATOMIC) {
runCatchingCancellable {
withContext(NonCancellable) {
invoke(manga, readerState, percent)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -33,7 +33,7 @@ class LocalListFragment : MangaListFragment(), FilterOwner {
override fun onViewBindingCreated(binding: FragmentListBinding, savedInstanceState: Bundle?) {
super.onViewBindingCreated(binding, savedInstanceState)
addMenuProvider(LocalListMenuProvider(this::onEmptyActionClick))
addMenuProvider(LocalListMenuProvider(binding.root.context, this::onEmptyActionClick))
viewModel.onMangaRemoved.observeEvent(viewLifecycleOwner) { onItemRemoved() }
}
@@ -45,7 +45,7 @@ class LocalListFragment : MangaListFragment(), FilterOwner {
FilterSheetFragment.show(childFragmentManager)
}
override fun onScrolledToEnd() = Unit
override fun onScrolledToEnd() = viewModel.loadNextPage()
override fun onCreateActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean {
mode.menuInflater.inflate(R.menu.mode_local, menu)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -13,8 +13,9 @@ class MultiSummaryProvider(@StringRes private val emptySummaryId: Int) :
return preference.context.getString(emptySummaryId)
} else {
values.joinToString(", ") {
preference.entries[preference.findIndexOfValue(it)]
preference.entries.getOrNull(preference.findIndexOfValue(it))
?: preference.context.getString(androidx.preference.R.string.not_set)
}
}
}
}
}

View File

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

View File

@@ -109,7 +109,7 @@ class SuggestionsWorker @AssistedInject constructor(
.setPriority(NotificationCompat.PRIORITY_MIN)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
.setDefaults(0)
.setOngoing(true)
.setOngoing(false)
.setSilent(true)
.setProgress(0, 0, true)
.setSmallIcon(android.R.drawable.stat_notify_sync)

View File

@@ -13,9 +13,9 @@ import org.koitharu.kotatsu.core.db.entity.MangaEntity
entity = MangaEntity::class,
parentColumns = ["manga_id"],
childColumns = ["manga_id"],
onDelete = ForeignKey.CASCADE
)
]
onDelete = ForeignKey.CASCADE,
),
],
)
class TrackEntity(
@PrimaryKey(autoGenerate = false)
@@ -27,4 +27,4 @@ class TrackEntity(
@ColumnInfo(name = "last_check") val lastCheck: Long,
@get:Deprecated(message = "Should not be used", level = DeprecationLevel.ERROR)
@ColumnInfo(name = "last_notified_id") val lastNotifiedChapterId: Long
)
)

View File

@@ -43,6 +43,9 @@ abstract class TracksDao {
@Query("UPDATE tracks SET chapters_new = 0")
abstract suspend fun clearCounters()
@Query("UPDATE tracks SET chapters_new = 0 WHERE manga_id = :mangaId")
abstract suspend fun clearCounter(mangaId: Long)
@Query("DELETE FROM tracks WHERE manga_id = :mangaId")
abstract suspend fun delete(mangaId: Long)

View File

@@ -126,6 +126,18 @@ class TrackingRepository @Inject constructor(
}
}
suspend fun clearUpdates(ids: Collection<Long>) {
when {
ids.isEmpty() -> return
ids.size == 1 -> db.tracksDao.clearCounter(ids.single())
else -> db.withTransaction {
for (id in ids) {
db.tracksDao.clearCounter(id)
}
}
}
}
suspend fun syncWithHistory(manga: Manga, chapterId: Long) {
val chapters = manga.chapters ?: return
val chapterIndex = chapters.indexOfFirst { x -> x.id == chapterId }

View File

@@ -1,7 +1,12 @@
package org.koitharu.kotatsu.tracker.ui.updates
import android.view.Menu
import android.view.MenuItem
import androidx.appcompat.view.ActionMode
import androidx.fragment.app.viewModels
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.list.ListSelectionController
import org.koitharu.kotatsu.list.ui.MangaListFragment
@AndroidEntryPoint
@@ -12,6 +17,22 @@ class UpdatesFragment : MangaListFragment() {
override fun onScrolledToEnd() = Unit
override fun onCreateActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean {
mode.menuInflater.inflate(R.menu.mode_updates, menu)
return super.onCreateActionMode(controller, mode, menu)
}
override fun onActionItemClicked(controller: ListSelectionController, mode: ActionMode, item: MenuItem): Boolean {
return when (item.itemId) {
R.id.action_remove -> {
viewModel.remove(controller.snapshot())
true
}
else -> super.onActionItemClicked(controller, mode, item)
}
}
companion object {
fun newInstance() = UpdatesFragment()

View File

@@ -59,4 +59,10 @@ class UpdatesViewModel @Inject constructor(
override fun onRefresh() = Unit
override fun onRetry() = Unit
fun remove(ids: Set<Long>) {
launchJob(Dispatchers.Default) {
repository.clearUpdates(ids)
}
}
}

View File

@@ -221,7 +221,7 @@ class TrackWorker @AssistedInject constructor(
.setPriority(NotificationCompat.PRIORITY_MIN)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
.setDefaults(0)
.setOngoing(true)
.setOngoing(false)
.setSilent(true)
.setProgress(0, 0, true)
.setSmallIcon(android.R.drawable.stat_notify_sync)

View File

@@ -37,7 +37,7 @@ class RecentListFactory(
override fun getLoadingView() = null
override fun getItemId(position: Int) = dataSet[position].id
override fun getItemId(position: Int) = dataSet.getOrNull(position)?.id ?: 0L
override fun onDataSetChanged() {
val data = runBlocking { historyRepository.getList(0, 10) }
@@ -48,7 +48,7 @@ class RecentListFactory(
override fun getViewAt(position: Int): RemoteViews {
val views = RemoteViews(context.packageName, R.layout.item_recent)
val item = dataSet[position]
val item = dataSet.getOrNull(position) ?: return views
runCatching {
coil.executeBlocking(
ImageRequest.Builder(context)

View File

@@ -40,7 +40,7 @@ class ShelfListFactory(
override fun getLoadingView() = null
override fun getItemId(position: Int) = dataSet[position].id
override fun getItemId(position: Int) = dataSet.getOrNull(position)?.id ?: 0L
override fun onDataSetChanged() {
val data = runBlocking {
@@ -58,7 +58,7 @@ class ShelfListFactory(
override fun getViewAt(position: Int): RemoteViews {
val views = RemoteViews(context.packageName, R.layout.item_shelf)
val item = dataSet[position]
val item = dataSet.getOrNull(position) ?: return views
views.setTextViewText(R.id.textView_title, item.title)
runCatching {
coil.executeBlocking(

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<vector
xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#000000"
android:pathData="M17,12A5,5 0 0,1 12,17A5,5 0 0,1 7,12C7,9.58 8.72,7.56 11,7.1V3H13V7.1C15.28,7.56 17,9.58 17,12M12,9A3,3 0 0,0 9,12A3,3 0 0,0 12,15A3,3 0 0,0 15,12A3,3 0 0,0 12,9Z" />
</vector>

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<vector
xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#000000"
android:pathData="M7,12A5,5 0 0,1 12,7A5,5 0 0,1 17,12C17,14.42 15.28,16.44 13,16.9V21H11V16.9C8.72,16.44 7,14.42 7,12M12,15A3,3 0 0,0 15,12A3,3 0 0,0 12,9A3,3 0 0,0 9,12A3,3 0 0,0 12,15M13,3V5H11V3H13Z" />
</vector>

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<vector
xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#000000"
android:pathData="M12,7A5,5 0 0,1 17,12C17,14.42 15.28,16.44 13,16.9V21H11V16.9C8.72,16.44 7,14.42 7,12A5,5 0 0,1 12,7M12,9A3,3 0 0,0 9,12A3,3 0 0,0 12,15A3,3 0 0,0 15,12A3,3 0 0,0 12,9Z" />
</vector>

View File

@@ -0,0 +1,11 @@
<vector
xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:pathData="M5 3A2 2 0 0 0 3 5H5M7 3V5H9V3M11 3V5H13V3M15 3V5H17V3M19 3V5H21A2 2 0 0 0 19 3M3 7V9H5V7M7 7V11H11V7M13 7V11H17V7M19 7V9H21V7M3 11V13H5V11M19 11V13H21V11M7 13V17H11V13M13 13V17H17V13M3 15V17H5V15M19 15V17H21V15M3 19A2 2 0 0 0 5 21V19M7 19V21H9V19M11 19V21H13V19M15 19V21H17V19M19 19V21A2 2 0 0 0 21 19Z" />
</vector>

View File

@@ -58,7 +58,6 @@
android:layout_height="wrap_content"
android:layout_margin="16dp"
android:contentDescription="@string/add_new_category"
android:src="@drawable/ic_add"
android:text="@string/create_category"
app:fabSize="normal"
app:icon="@drawable/ic_add"

View File

@@ -0,0 +1,57 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/appbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:fitsSystemWindows="true">
<com.google.android.material.appbar.MaterialToolbar
android:id="@id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
app:layout_scrollFlags="noScroll">
</com.google.android.material.appbar.MaterialToolbar>
</com.google.android.material.appbar.AppBarLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:orientation="vertical"
android:scrollbars="vertical"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
app:layout_behavior="com.google.android.material.appbar.AppBarLayout$ScrollingViewBehavior" />
<com.google.android.material.progressindicator.LinearProgressIndicator
android:id="@+id/progressBar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:indeterminate="true"
android:visibility="gone"
app:layout_anchor="@id/appbar"
app:layout_anchorGravity="bottom" />
<com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
android:id="@+id/fab_add"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="16dp"
android:contentDescription="@string/pick_custom_directory"
android:text="@string/add"
app:fabSize="normal"
app:icon="@drawable/ic_add"
app:layout_anchor="@id/recyclerView"
app:layout_anchorGravity="bottom|end"
app:layout_behavior="org.koitharu.kotatsu.core.ui.util.ShrinkOnScrollBehavior"
app:layout_dodgeInsetEdges="bottom" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View File

@@ -93,8 +93,7 @@
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:indeterminate="true"
app:trackCornerRadius="@dimen/mtrl_progress_indicator_full_rounded_corner_radius" />
android:indeterminate="true" />
<TextView
android:id="@+id/textView_loading"

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.recyclerview.widget.RecyclerView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:scrollIndicators="top|bottom"
android:scrollbars="vertical"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
tools:ignore="UnusedAttribute" />

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<org.koitharu.kotatsu.core.ui.widgets.TwoLinesItemView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/button_file"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:drawablePadding="?android:listPreferredItemPaddingStart"
android:minHeight="?android:listPreferredItemHeightSmall"
android:paddingStart="?android:listPreferredItemPaddingStart"
android:paddingEnd="?android:listPreferredItemPaddingEnd"
tools:subtitle="@string/chapters"
tools:title="@string/download_option_whole_manga" />

View File

@@ -1,48 +1,46 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?selectableItemBackground"
android:minHeight="?listPreferredItemHeightLarge"
android:gravity="center_vertical"
android:minHeight="?listPreferredItemHeight"
android:orientation="horizontal"
android:paddingVertical="12dp"
android:paddingStart="?listPreferredItemPaddingStart"
android:paddingTop="16dp"
android:paddingEnd="?listPreferredItemPaddingEnd"
android:paddingBottom="16dp">
android:paddingEnd="?listPreferredItemPaddingEnd">
<org.koitharu.kotatsu.core.ui.widgets.CheckableImageView
android:id="@+id/imageView_indicator"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentStart="true"
android:layout_centerVertical="true"
android:src="?android:listChoiceIndicatorSingle"
tools:ignore="TouchTargetSizeCheck" />
<TextView
android:id="@+id/textView_title"
android:layout_width="0dp"
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_alignParentEnd="true"
android:layout_marginStart="?listPreferredItemPaddingStart"
android:layout_toEndOf="@id/imageView_indicator"
android:ellipsize="end"
android:maxLines="1"
android:textAppearance="?attr/textAppearanceTitleSmall"
tools:text="@tools:sample/lorem[3]" />
android:orientation="vertical">
<TextView
android:id="@+id/textView_subtitle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_below="@id/textView_title"
android:layout_alignParentEnd="true"
android:layout_marginStart="?listPreferredItemPaddingStart"
android:layout_marginTop="6dp"
android:layout_toEndOf="@id/imageView_indicator"
android:ellipsize="end"
android:textAppearance="?attr/textAppearanceBodyMedium"
tools:text="@tools:sample/lorem[20]" />
<TextView
android:id="@+id/textView_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:singleLine="true"
android:textAppearance="?attr/textAppearanceTitleSmall"
tools:text="@tools:sample/lorem[3]" />
</RelativeLayout>
<TextView
android:id="@+id/textView_subtitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:textAppearance="?attr/textAppearanceBodyMedium"
tools:text="@tools:sample/lorem[20]" />
</LinearLayout>
</LinearLayout>

View File

@@ -0,0 +1,53 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?selectableItemBackground"
android:gravity="center_vertical"
android:minHeight="?listPreferredItemHeight"
android:orientation="horizontal"
android:paddingVertical="12dp"
android:paddingStart="?listPreferredItemPaddingStart">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/textView_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:drawablePadding="6dp"
android:ellipsize="end"
android:gravity="center_vertical"
android:singleLine="true"
android:textAppearance="?attr/textAppearanceTitleSmall"
app:drawableTint="@color/warning"
tools:drawableStart="@drawable/ic_alert_outline"
tools:text="@tools:sample/lorem[3]" />
<TextView
android:id="@+id/textView_subtitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:textAppearance="?attr/textAppearanceBodyMedium"
tools:text="@tools:sample/lorem[20]" />
</LinearLayout>
<ImageView
android:id="@+id/imageView_remove"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="?selectableItemBackgroundBorderless"
android:contentDescription="@string/remove"
android:padding="?listPreferredItemPaddingEnd"
app:srcCompat="@drawable/ic_delete" />
</LinearLayout>

View File

@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<menu
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/action_share"
android:icon="?actionModeShareDrawable"
android:title="@string/share"
app:showAsAction="ifRoom|withText" />
<item
android:id="@+id/action_remove"
android:icon="@drawable/ic_delete"
android:title="@string/delete"
app:showAsAction="ifRoom|withText" />
<item
android:id="@+id/action_save"
android:icon="@drawable/ic_save"
android:title="@string/save"
app:showAsAction="ifRoom|withText" />
<item
android:id="@+id/action_select_all"
android:icon="?actionModeSelectAllDrawable"
android:title="@android:string/selectAll"
app:showAsAction="ifRoom|withText" />
</menu>

View File

@@ -20,7 +20,7 @@
<item
android:id="@+id/action_save"
android:orderInCategory="40"
android:title="@string/save"
android:title="@string/download"
android:visible="false"
app:showAsAction="never" />

View File

@@ -9,4 +9,10 @@
android:title="@string/_import"
app:showAsAction="never" />
</menu>
<item
android:id="@+id/action_settings"
android:orderInCategory="100"
android:title="@string/settings"
app:showAsAction="never" />
</menu>

View File

@@ -1,77 +1,77 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="detailed_list">تفاصيل القائمة</string>
<string name="error_occurred">حدث خطأ</string>
<string name="details">تفاصيل</string>
<string name="grid">شبكة</string>
<string name="list_mode">وضع القائمة</string>
<string name="settings">إعدادات</string>
<string name="remote_sources">المصادر البعيدة</string>
<string name="chapters">فصول</string>
<string name="favourites">المفضلة</string>
<string name="network_error">‌خطاء في الشبكة</string>
<string name="loading_">جار التحميل…</string>
<string name="chapter_d_of_d">فصل %1$d في %2$d</string>
<string name="close">غلق</string>
<string name="try_again">حاول مجدداً</string>
<string name="computing_">جاري الحوسبة …</string>
<string name="local_storage">التخزين المحلي</string>
<string name="history">سجل</string>
<string name="list">قائمة</string>
<string name="clear_history">محو سجل</string>
<string name="add_to_favourites">ضع هذا في المفضلة</string>
<string name="add">أضف</string>
<string name="save">حفظ</string>
<string name="history_is_empty">لا سجل بعد</string>
<string name="downloads">التحميلات</string>
<string name="by_name">اسم</string>
<string name="newest">الأحدث</string>
<string name="by_rating">تقييم</string>
<string name="pages">صفحات</string>
<string name="read">اقرأ</string>
<string name="share">شارك</string>
<string name="nothing_found">لم يتم عثور على اي شيء</string>
<string name="you_have_not_favourites_yet">لا مفضلة بعد</string>
<string name="search">بحث</string>
<string name="search_manga">البحث في المانجا</string>
<string name="manga_downloading_">جاري التنزيل…</string>
<string name="create_shortcut">انشاء اختصار…</string>
<string name="theme">مظهر</string>
<string name="automatic">حسب النظام</string>
<string name="share_s">شارك %s</string>
<string name="processing_">في طور معالجة…</string>
<string name="updated">محدث</string>
<string name="filter">فلتر</string>
<string name="sort_order">ترتيب الفرز</string>
<string name="light">ضوء</string>
<string name="dark">داكن</string>
<string name="clear">أزل</string>
<string name="remove">ازالة</string>
<string name="popular">شائع</string>
<string name="add_new_category">قائمة جديدة</string>
<string name="download_complete">تم التنزيل</string>
<string name="text_clear_history_prompt">هل تريد محو سجل القراءة بالكامل بشكل دائم؟</string>
<string name="save_page">احفظ الصفحة</string>
<string name="page_saved">حفظت</string>
<string name="standard">اساسي</string>
<string name="no_description">لا يوجد وصف</string>
<string name="clear_pages_cache">مسح ذاكرة التخزين المؤقت للصفحة</string>
<string name="webtoon">ويبتون</string>
<string name="read_mode">وضع القراءة</string>
<string name="search_on_s">بحث على %s</string>
<string name="delete_manga">حذف المانغا</string>
<string name="text_delete_local_manga">حذف \"%s\" من الجهاز نهائيا؟</string>
<string name="reader_settings">إعدادات القراءة</string>
<string name="switch_pages">تغییر صفحات</string>
<string name="delete">حذف</string>
<string name="share_image">شارك الصورة</string>
<string name="text_file_not_supported">إما أن تختار ملف ZIP أو CBZ.</string>
<string name="_import">استورد</string>
<string name="operation_not_supported">هذا خيار غير مدعم</string>
<string name="grid_size">حجم الشبكة</string>
<string name="volume_buttons">أزرار الصوت</string>
<string name="taps_on_edges">النقر على حواف الشاشة</string>
<string name="_continue">يكمل</string>
<string name="error">خطاء</string>
<string name="clear_search_history">مسح تاريخ البحث</string>
</resources>
<string name="detailed_list">تفاصيل القائمة</string>
<string name="error_occurred">حدث خطأ</string>
<string name="details">تفاصيل</string>
<string name="grid">شبكة</string>
<string name="list_mode">وضع القائمة</string>
<string name="settings">إعدادات</string>
<string name="remote_sources">مصادر المانجا</string>
<string name="chapters">فصول</string>
<string name="favourites">المفضلة</string>
<string name="network_error">‌خطاء في الشبكة</string>
<string name="loading_">جار التحميل…</string>
<string name="chapter_d_of_d">فصل %1$d في %2$d</string>
<string name="close">غلق</string>
<string name="try_again">حاول مجدداً</string>
<string name="computing_">جاري الحوسبة …</string>
<string name="local_storage">التخزين المحلي</string>
<string name="history">سجل</string>
<string name="list">قائمة</string>
<string name="clear_history">محو سجل</string>
<string name="add_to_favourites">ضع هذا في المفضلة</string>
<string name="add">أضف</string>
<string name="save">حفظ</string>
<string name="history_is_empty">لا سجل بعد</string>
<string name="downloads">التحميلات</string>
<string name="by_name">اسم</string>
<string name="newest">الأحدث</string>
<string name="by_rating">تقييم</string>
<string name="pages">صفحات</string>
<string name="read">اقرأ</string>
<string name="share">شارك</string>
<string name="nothing_found">لم يتم عثور على اي شيء</string>
<string name="you_have_not_favourites_yet">لا مفضلة بعد</string>
<string name="search">بحث</string>
<string name="search_manga">البحث في المانجا</string>
<string name="manga_downloading_">جاري التنزيل…</string>
<string name="create_shortcut">انشاء اختصار…</string>
<string name="theme">مظهر</string>
<string name="automatic">حسب النظام</string>
<string name="share_s">شارك %s</string>
<string name="processing_">في طور معالجة…</string>
<string name="updated">محدث</string>
<string name="filter">فلتر</string>
<string name="sort_order">ترتيب الفرز</string>
<string name="light">ضوء</string>
<string name="dark">داكن</string>
<string name="clear">أزل</string>
<string name="remove">ازالة</string>
<string name="popular">شائع</string>
<string name="add_new_category">قائمة جديدة</string>
<string name="download_complete">تم التنزيل</string>
<string name="text_clear_history_prompt">هل تريد محو سجل القراءة بالكامل بشكل دائم؟</string>
<string name="save_page">احفظ الصفحة</string>
<string name="page_saved">حفظت</string>
<string name="standard">اساسي</string>
<string name="no_description">لا يوجد وصف</string>
<string name="clear_pages_cache">مسح ذاكرة التخزين المؤقت للصفحة</string>
<string name="webtoon">ويبتون</string>
<string name="read_mode">وضع القراءة</string>
<string name="search_on_s">بحث على %s</string>
<string name="delete_manga">حذف المانغا</string>
<string name="text_delete_local_manga">حذف \"%s\" من الجهاز نهائيا؟</string>
<string name="reader_settings">إعدادات القراءة</string>
<string name="switch_pages">تغییر صفحات</string>
<string name="delete">حذف</string>
<string name="share_image">شارك الصورة</string>
<string name="text_file_not_supported">إما أن تختار ملف ZIP أو CBZ.</string>
<string name="_import">استورد</string>
<string name="operation_not_supported">هذا خيار غير مدعم</string>
<string name="grid_size">حجم الشبكة</string>
<string name="volume_buttons">أزرار الصوت</string>
<string name="taps_on_edges">النقر على حواف الشاشة</string>
<string name="_continue">يكمل</string>
<string name="error">خطاء</string>
<string name="clear_search_history">مسح تاريخ البحث</string>
</resources>

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