Compare commits

...

54 Commits

Author SHA1 Message Date
Koitharu
28dede0d3e Fix displaying long author name 2024-05-12 16:52:26 +03:00
Koitharu
d66e61f845 Update parsers 2024-05-12 16:19:32 +03:00
Koitharu
b246575486 Fix main navigation bar behavior 2024-05-11 18:05:29 +03:00
Koitharu
18dd205051 Hide widgets content when app protected 2024-05-11 17:53:45 +03:00
Koitharu
0e10fdaf36 Code cleanup and refactor 2024-05-11 11:51:59 +03:00
Koitharu
7c82b4effb Multiple sources selection 2024-05-10 17:39:00 +03:00
Koitharu
82684601b7 Code cleanup and refactor 2024-05-10 15:37:34 +03:00
Koitharu
77ad21bd7a Details activity ui fixes 2024-05-10 08:15:43 +03:00
Koitharu
e6c8591bf8 Fix crash if animations disabled 2024-05-08 16:38:16 +03:00
Koitharu
e330be5d13 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (636 of 636 strings)

Co-authored-by: Koitharu <nvasya95@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/uk/
Translation: Kotatsu/Strings
2024-05-08 14:34:40 +03:00
Ismail Özcan
6a4cd9643a Translated using Weblate (German)
Currently translated at 96.3% (613 of 636 strings)

Co-authored-by: Ismail Özcan <me+weblate@ismailoezcan.de>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/de/
Translation: Kotatsu/Strings
2024-05-08 14:34:40 +03:00
ngocanhtve
d98cb9a577 Translated using Weblate (Vietnamese)
Currently translated at 100.0% (636 of 636 strings)

Co-authored-by: ngocanhtve <ngocanh.tve@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/vi/
Translation: Kotatsu/Strings
2024-05-08 14:34:40 +03:00
abc0922001
ac455527ef Translated using Weblate (Chinese (Traditional))
Currently translated at 100.0% (636 of 636 strings)

Co-authored-by: abc0922001 <abc0922001@hotmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hant/
Translation: Kotatsu/Strings
2024-05-08 14:34:40 +03:00
Nayuki
7e37345dea Translated using Weblate (Thai)
Currently translated at 63.9% (407 of 636 strings)

Translated using Weblate (Thai)

Currently translated at 100.0% (9 of 9 strings)

Co-authored-by: Nayuki <me@nayuki.cyou>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/th/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/th/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2024-05-08 14:34:40 +03:00
Oğuz Ersen
6e810179a7 Translated using Weblate (Turkish)
Currently translated at 100.0% (636 of 636 strings)

Co-authored-by: Oğuz Ersen <oguz@ersen.moe>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2024-05-08 14:34:40 +03:00
Infy's Tagalog Translations
7715aff953 Translated using Weblate (Filipino)
Currently translated at 100.0% (636 of 636 strings)

Co-authored-by: Infy's Tagalog Translations <ced.paltep10@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fil/
Translation: Kotatsu/Strings
2024-05-08 14:34:40 +03:00
Anon
63e6b9f026 Translated using Weblate (Serbian)
Currently translated at 100.0% (636 of 636 strings)

Co-authored-by: Anon <anonymousprivate76@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/sr/
Translation: Kotatsu/Strings
2024-05-08 14:34:40 +03:00
Naga
b6f136fb71 Translated using Weblate (French)
Currently translated at 99.5% (633 of 636 strings)

Co-authored-by: Naga <yz2000.pro@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fr/
Translation: Kotatsu/Strings
2024-05-08 14:34:40 +03:00
Макар Разин
de0327a00a Translated using Weblate (Ukrainian)
Currently translated at 100.0% (636 of 636 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (636 of 636 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (636 of 636 strings)

Co-authored-by: Макар Разин <makarrazin14@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/be/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/uk/
Translation: Kotatsu/Strings
2024-05-08 14:34:40 +03:00
maryush
e5f09ae4c9 Translated using Weblate (Polish)
Currently translated at 100.0% (636 of 636 strings)

Co-authored-by: maryush <maryush@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pl/
Translation: Kotatsu/Strings
2024-05-08 14:34:40 +03:00
gekka
f10d9b54d8 Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (636 of 636 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (636 of 636 strings)

Co-authored-by: gekka <1778962971@qq.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2024-05-08 14:34:40 +03:00
gallegonovato
619d672e49 Translated using Weblate (Spanish)
Currently translated at 100.0% (636 of 636 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2024-05-08 14:34:40 +03:00
Koitharu
db519701bc Show single branch in details 2024-05-08 14:31:19 +03:00
Koitharu
e42aeb857f Fix details read/continue state 2024-05-08 14:23:04 +03:00
Koitharu
4f82495cfc Optimize app initialization 2024-05-08 13:58:25 +03:00
Koitharu
311c36b7c0 Builtin ssl certificates for old devices 2024-05-08 13:16:10 +03:00
Koitharu
002ce25d7e Notification settings actions in notifications 2024-05-08 08:46:21 +03:00
Koitharu
d9cf13d3fb Fix tracking and progress 2024-05-08 08:34:34 +03:00
Koitharu
ed5b1306b8 UI fixes 2024-05-07 12:10:39 +03:00
Koitharu
227fe86cf9 Allow to add readonly manga directories 2024-05-07 10:46:50 +03:00
Koitharu
1905482b06 UI fixes 2024-05-06 17:10:08 +03:00
Koitharu
46ded4af0d Bookmarks selection 2024-05-06 15:05:25 +03:00
Koitharu
6676ab82b4 Fix ChaptersPagesSheet nested scrolling 2024-05-06 14:47:55 +03:00
Koitharu
1a60df6d98 Fix images memory caching 2024-05-06 13:42:17 +03:00
Koitharu
5ef1b4ac9c Fix restoring bookmarks 2024-05-04 17:20:42 +03:00
Koitharu
17828ae755 Fix crashes 2024-05-04 17:06:28 +03:00
Koitharu
d8ac4d6738 Fix default ChaptersPagesSheet tab 2024-05-04 12:21:57 +03:00
Koitharu
0a10cb509c Details activity fixes 2024-05-03 10:01:47 +03:00
Koitharu
7a3fd20dfa Update dependencies 2024-05-03 08:57:48 +03:00
Koitharu
ab20e50dc1 Merge branch 'devel' of https://hosted.weblate.org/git/kotatsu/strings into devel 2024-04-29 19:51:35 +03:00
Scrambled777
f783ffef11 Translated using Weblate (Hindi)
Currently translated at 100.0% (636 of 636 strings)

Translation: Kotatsu/Strings
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/hi/
2024-04-29 18:09:46 +02:00
Nayuki
e01c485949 Translated using Weblate (Thai)
Currently translated at 63.7% (405 of 635 strings)

Co-authored-by: Nayuki <me@nayuki.cyou>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/th/
Translation: Kotatsu/Strings
2024-04-29 11:28:24 +02:00
maryush
3672c84e8f Translated using Weblate (Polish)
Currently translated at 100.0% (635 of 635 strings)

Co-authored-by: maryush <maryush@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pl/
Translation: Kotatsu/Strings
2024-04-29 11:28:23 +02:00
Anon
55c5a07c8b Translated using Weblate (Serbian)
Currently translated at 100.0% (635 of 635 strings)

Co-authored-by: Anon <anonymousprivate76@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/sr/
Translation: Kotatsu/Strings
2024-04-29 11:28:21 +02:00
Andrius
a3cf32aefb Translated using Weblate (Lithuanian)
Currently translated at 5.8% (37 of 635 strings)

Translated using Weblate (Lithuanian)

Currently translated at 100.0% (9 of 9 strings)

Added translation using Weblate (Lithuanian)

Added translation using Weblate (Lithuanian)

Co-authored-by: Andrius <sndriuss@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/lt/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/lt/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2024-04-29 11:28:20 +02:00
Infy's Tagalog Translations
c21bf30e91 Translated using Weblate (Filipino)
Currently translated at 100.0% (635 of 635 strings)

Co-authored-by: Infy's Tagalog Translations <ced.paltep10@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fil/
Translation: Kotatsu/Strings
2024-04-29 11:28:19 +02:00
Scrambled777
1719547ce0 Translated using Weblate (Hindi)
Currently translated at 100.0% (635 of 635 strings)

Co-authored-by: Scrambled777 <weblate.scrambled777@simplelogin.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/hi/
Translation: Kotatsu/Strings
2024-04-29 11:28:17 +02:00
gekka
22186825a0 Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (635 of 635 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (635 of 635 strings)

Co-authored-by: gekka <1778962971@qq.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2024-04-29 11:28:16 +02:00
Celysia
b9c83ad5cc Translated using Weblate (Indonesian)
Currently translated at 91.6% (582 of 635 strings)

Co-authored-by: Celysia <celysiasyantik@neko2.net>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/id/
Translation: Kotatsu/Strings
2024-04-29 11:28:14 +02:00
Макар Разин
1359689b23 Translated using Weblate (Polish)
Currently translated at 99.8% (634 of 635 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (635 of 635 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (635 of 635 strings)

Co-authored-by: Макар Разин <makarrazin14@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pl/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/uk/
Translation: Kotatsu/Strings
2024-04-29 11:28:13 +02:00
Koitharu
7bad6ad077 Translated using Weblate (Russian)
Currently translated at 100.0% (635 of 635 strings)

Co-authored-by: Koitharu <nvasya95@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translation: Kotatsu/Strings
2024-04-29 11:28:12 +02:00
Oğuz Ersen
b9097fa077 Translated using Weblate (Turkish)
Currently translated at 100.0% (635 of 635 strings)

Co-authored-by: Oğuz Ersen <oguz@ersen.moe>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2024-04-29 11:28:10 +02:00
gallegonovato
0b03806ccd Translated using Weblate (Spanish)
Currently translated at 100.0% (635 of 635 strings)

Translated using Weblate (Spanish)

Currently translated at 99.8% (634 of 635 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2024-04-29 11:28:09 +02:00
Макар Разин
db9c1279ac Translated using Weblate (Belarusian)
Currently translated at 100.0% (635 of 635 strings)

Co-authored-by: Макар Разин <makarrazin14@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/be/
Translation: Kotatsu/Strings
2024-04-29 11:28:07 +02:00
181 changed files with 2116 additions and 1907 deletions

1
.gitignore vendored
View File

@@ -24,3 +24,4 @@
/captures
.externalNativeBuild
.cxx
/.idea/deviceManager.xml

View File

@@ -16,8 +16,8 @@ android {
applicationId 'org.koitharu.kotatsu'
minSdk = 21
targetSdk = 34
versionCode = 636
versionName = '7.0-b2'
versionCode = 641
versionName = '7.0'
generatedDensities = []
testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner'
ksp {
@@ -82,18 +82,18 @@ afterEvaluate {
}
dependencies {
//noinspection GradleDependency
implementation('com.github.KotatsuApp:kotatsu-parsers:a245574dee') {
implementation('com.github.KotatsuApp:kotatsu-parsers:3e32a6280a') {
exclude group: 'org.json', module: 'json'
}
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.4'
implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.9.23'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.0'
implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.9.24'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1'
implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'androidx.core:core-ktx:1.13.0'
implementation 'androidx.core:core-ktx:1.13.1'
implementation 'androidx.activity:activity-ktx:1.9.0'
implementation 'androidx.fragment:fragment-ktx:1.6.2'
implementation 'androidx.fragment:fragment-ktx:1.7.0'
implementation 'androidx.collection:collection-ktx:1.4.0'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0'
implementation 'androidx.lifecycle:lifecycle-service:2.7.0'
@@ -101,12 +101,12 @@ dependencies {
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
implementation 'androidx.recyclerview:recyclerview:1.3.2'
implementation 'androidx.viewpager2:viewpager2:1.1.0-beta02'
implementation 'androidx.viewpager2:viewpager2:1.1.0-rc01'
implementation 'androidx.preference:preference-ktx:1.2.1'
implementation 'androidx.biometric:biometric-ktx:1.2.0-alpha05'
implementation 'com.google.android.material:material:1.12.0-rc01'
implementation 'com.google.android.material:material:1.12.0'
implementation 'androidx.lifecycle:lifecycle-common-java8:2.7.0'
implementation 'androidx.webkit:webkit:1.10.0'
implementation 'androidx.webkit:webkit:1.11.0'
implementation 'androidx.work:work-runtime:2.9.0'
//noinspection GradleDependency
@@ -121,6 +121,7 @@ dependencies {
ksp 'androidx.room:room-compiler:2.6.1'
implementation 'com.squareup.okhttp3:okhttp:4.12.0'
implementation 'com.squareup.okhttp3:okhttp-tls:4.12.0'
implementation 'com.squareup.okhttp3:okhttp-dnsoverhttps:4.12.0'
implementation 'com.squareup.okio:okio:3.9.0'
@@ -146,17 +147,18 @@ dependencies {
implementation 'org.conscrypt:conscrypt-android:2.5.2'
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.14'
debugImplementation 'com.github.Koitharu:WorkInspector:5778dd1747'
testImplementation 'junit:junit:4.13.2'
testImplementation 'org.json:json:20240303'
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.0'
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1'
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.8.0'
androidTestImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1'
androidTestImplementation 'androidx.room:room-testing:2.6.1'
androidTestImplementation 'com.squareup.moshi:moshi-kotlin:1.15.1'

View File

@@ -11,7 +11,7 @@ import org.koitharu.kotatsu.reader.domain.PageLoader
class KotatsuApp : BaseApp() {
override fun attachBaseContext(base: Context?) {
override fun attachBaseContext(base: Context) {
super.attachBaseContext(base)
enableStrictMode()
}

View File

@@ -0,0 +1,9 @@
package org.koitharu.kotatsu.core.util.ext
import android.os.Looper
fun Throwable.printStackTraceDebug() = printStackTrace()
fun assertNotInMainThread() = check(Looper.myLooper() != Looper.getMainLooper()) {
"Calling this from the main thread is prohibited"
}

View File

@@ -1,3 +0,0 @@
package org.koitharu.kotatsu.core.util.ext
fun Throwable.printStackTraceDebug() = printStackTrace()

View File

@@ -13,4 +13,9 @@
android:title="@string/check_for_new_chapters"
app:showAsAction="never" />
<item
android:id="@id/action_works"
android:title="Works"
app:showAsAction="never" />
</menu>

View File

@@ -1,4 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools">
<bool name="leak_canary_add_launcher_icon" tools:node="replace">false</bool>
<bool name="wi_launcher_icon_enabled" tools:node="replace">false</bool>
</resources>

View File

@@ -122,7 +122,7 @@
android:name="org.koitharu.kotatsu.favourites.ui.FavouritesActivity"
android:label="@string/favourites" />
<activity
android:name="org.koitharu.kotatsu.bookmarks.ui.BookmarksActivity"
android:name="org.koitharu.kotatsu.bookmarks.ui.AllBookmarksActivity"
android:label="@string/bookmarks" />
<activity
android:name="org.koitharu.kotatsu.suggestions.ui.SuggestionsActivity"

View File

@@ -0,0 +1,31 @@
-----BEGIN CERTIFICATE-----
MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAw
TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh
cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4
WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJu
ZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBY
MTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54rVygc
h77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+
0TM8ukj13Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7iS4+3mX6U
A5/TR5d8mUgjU+g4rk8Kb4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sW
T8KOEUt+zwvo/7V3LvSye0rgTBIlDHCNAymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyH
B5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ4Q7e2RCOFvu396j3x+UC
B5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf1b0SHzUv
KBds0pjBqAlkd25HN7rOrFleaJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWn
OlFuhjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTn
jh8BCNAw1FtxNrQHusEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbw
qHyGO0aoSCqI3Haadr8faqU9GY/rOPNk3sgrDQoo//fb4hVC1CLQJ13hef4Y53CI
rU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNV
HRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY9umbbjANBgkq
hkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZL
ubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ
3BebYhtF8GaV0nxvwuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KK
NFtY2PwByVS5uCbMiogziUwthDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5
ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJwTdwJx4nLCgdNbOhdjsnvzqvHu7Ur
TkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIuvtd7u+Nxe5AW0wdeRlN8NwdC
jNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZAJzVc
oyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq
4RgqsahDYVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPA
mRGunUHBcnWEvgJBQl9nJEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57d
emyPxgcYxn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc=
-----END CERTIFICATE-----

View File

@@ -12,6 +12,7 @@ import org.koitharu.kotatsu.history.data.toMangaHistory
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.tracker.data.TrackEntity
import javax.inject.Inject
class MigrateUseCase @Inject constructor(
@@ -56,6 +57,22 @@ class MigrateUseCase @Inject constructor(
historyDao.delete(oldDetails.id)
historyDao.upsert(newHistory)
}
// track
val tracksDao = database.getTracksDao()
val oldTrack = tracksDao.find(oldDetails.id)
if (oldTrack != null) {
val lastChapter = newDetails.chapters?.lastOrNull()
val newTrack = TrackEntity(
mangaId = newDetails.id,
lastChapterId = lastChapter?.id ?: 0L,
newChapters = 0,
lastCheckTime = System.currentTimeMillis(),
lastChapterDate = lastChapter?.uploadDate ?: 0L,
lastResult = TrackEntity.RESULT_EXTERNAL_MODIFICATION,
)
tracksDao.delete(oldDetails.id)
tracksDao.upsert(newTrack)
}
}
progressUpdateUseCase(newManga)
}

View File

@@ -12,9 +12,6 @@ import org.koitharu.kotatsu.core.db.entity.MangaWithTags
@Dao
abstract class BookmarksDao {
@Query("SELECT * FROM bookmarks WHERE manga_id = :mangaId AND page_id = :pageId")
abstract suspend fun find(mangaId: Long, pageId: Long): BookmarkEntity?
@Query("SELECT * FROM bookmarks WHERE page_id = :pageId")
abstract suspend fun find(pageId: Long): BookmarkEntity?
@@ -42,9 +39,6 @@ abstract class BookmarksDao {
@Delete
abstract suspend fun delete(entity: BookmarkEntity)
@Query("DELETE FROM bookmarks WHERE manga_id = :mangaId AND page_id = :pageId")
abstract suspend fun delete(mangaId: Long, pageId: Long): Int
@Query("DELETE FROM bookmarks WHERE page_id = :pageId")
abstract suspend fun delete(pageId: Long): Int

View File

@@ -16,7 +16,7 @@ import org.koitharu.kotatsu.main.ui.owners.AppBarOwner
import org.koitharu.kotatsu.main.ui.owners.SnackbarOwner
@AndroidEntryPoint
class BookmarksActivity :
class AllBookmarksActivity :
BaseActivity<ActivityContainerBinding>(),
AppBarOwner,
SnackbarOwner {
@@ -35,7 +35,7 @@ class BookmarksActivity :
if (fm.findFragmentById(R.id.container) == null) {
fm.commit {
setReorderingAllowed(true)
replace(R.id.container, BookmarksFragment::class.java, null)
replace(R.id.container, AllBookmarksFragment::class.java, null)
}
}
}
@@ -49,6 +49,6 @@ class BookmarksActivity :
companion object {
fun newIntent(context: Context) = Intent(context, BookmarksActivity::class.java)
fun newIntent(context: Context) = Intent(context, AllBookmarksActivity::class.java)
}
}

View File

@@ -17,7 +17,7 @@ import coil.ImageLoader
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.bookmarks.ui.sheet.BookmarksAdapter
import org.koitharu.kotatsu.bookmarks.ui.adapter.BookmarksAdapter
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.BaseFragment
@@ -42,7 +42,7 @@ import org.koitharu.kotatsu.reader.ui.ReaderActivity
import javax.inject.Inject
@AndroidEntryPoint
class BookmarksFragment :
class AllBookmarksFragment :
BaseFragment<FragmentListSimpleBinding>(),
ListStateHolderListener,
OnListItemClickListener<Bookmark>,
@@ -55,7 +55,7 @@ class BookmarksFragment :
@Inject
lateinit var settings: AppSettings
private val viewModel by viewModels<BookmarksViewModel>()
private val viewModel by viewModels<AllBookmarksViewModel>()
private var bookmarksAdapter: BookmarksAdapter? = null
private var selectionController: ListSelectionController? = null
@@ -213,6 +213,6 @@ class BookmarksFragment :
"org.koitharu.kotatsu.bookmarks.ui.BookmarksFragment",
),
)
fun newInstance() = BookmarksFragment()
fun newInstance() = AllBookmarksFragment()
}
}

View File

@@ -25,7 +25,7 @@ import org.koitharu.kotatsu.parsers.model.Manga
import javax.inject.Inject
@HiltViewModel
class BookmarksViewModel @Inject constructor(
class AllBookmarksViewModel @Inject constructor(
private val repository: BookmarksRepository,
) : BaseViewModel() {

View File

@@ -1,4 +1,4 @@
package org.koitharu.kotatsu.bookmarks.ui.sheet
package org.koitharu.kotatsu.bookmarks.ui.adapter
import androidx.lifecycle.LifecycleOwner
import coil.ImageLoader

View File

@@ -1,19 +1,36 @@
package org.koitharu.kotatsu.bookmarks.ui.adapter
import android.content.Context
import androidx.lifecycle.LifecycleOwner
import coil.ImageLoader
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.core.ui.BaseListAdapter
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.ui.list.fastscroll.FastScroller
import org.koitharu.kotatsu.list.ui.adapter.ListHeaderClickListener
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
import org.koitharu.kotatsu.list.ui.adapter.emptyStateListAD
import org.koitharu.kotatsu.list.ui.adapter.listHeaderAD
import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD
import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD
import org.koitharu.kotatsu.list.ui.model.ListModel
class BookmarksAdapter(
coil: ImageLoader,
lifecycleOwner: LifecycleOwner,
clickListener: OnListItemClickListener<Bookmark>,
) : BaseListAdapter<Bookmark>() {
headerClickListener: ListHeaderClickListener?,
) : BaseListAdapter<ListModel>(), FastScroller.SectionIndexer {
init {
addDelegate(ListItemType.PAGE_THUMB, bookmarkListAD(coil, lifecycleOwner, clickListener))
addDelegate(ListItemType.PAGE_THUMB, bookmarkLargeAD(coil, lifecycleOwner, clickListener))
addDelegate(ListItemType.HEADER, listHeaderAD(headerClickListener))
addDelegate(ListItemType.FOOTER_LOADING, loadingFooterAD())
addDelegate(ListItemType.STATE_LOADING, loadingStateAD())
addDelegate(ListItemType.STATE_EMPTY, emptyStateListAD(coil, lifecycleOwner, null))
}
override fun getSectionText(context: Context, position: Int): CharSequence? {
return findHeader(position)?.getText(context)
}
}

View File

@@ -1,36 +0,0 @@
package org.koitharu.kotatsu.bookmarks.ui.sheet
import android.content.Context
import androidx.lifecycle.LifecycleOwner
import coil.ImageLoader
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.core.ui.BaseListAdapter
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.ui.list.fastscroll.FastScroller
import org.koitharu.kotatsu.list.ui.adapter.ListHeaderClickListener
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
import org.koitharu.kotatsu.list.ui.adapter.emptyStateListAD
import org.koitharu.kotatsu.list.ui.adapter.listHeaderAD
import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD
import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD
import org.koitharu.kotatsu.list.ui.model.ListModel
class BookmarksAdapter(
coil: ImageLoader,
lifecycleOwner: LifecycleOwner,
clickListener: OnListItemClickListener<Bookmark>,
headerClickListener: ListHeaderClickListener?,
) : BaseListAdapter<ListModel>(), FastScroller.SectionIndexer {
init {
addDelegate(ListItemType.PAGE_THUMB, bookmarkLargeAD(coil, lifecycleOwner, clickListener))
addDelegate(ListItemType.HEADER, listHeaderAD(headerClickListener))
addDelegate(ListItemType.FOOTER_LOADING, loadingFooterAD())
addDelegate(ListItemType.STATE_LOADING, loadingStateAD())
addDelegate(ListItemType.STATE_EMPTY, emptyStateListAD(coil, lifecycleOwner, null))
}
override fun getSectionText(context: Context, position: Int): CharSequence? {
return findHeader(position)?.getText(context)
}
}

View File

@@ -1,6 +1,9 @@
package org.koitharu.kotatsu.browser.cloudflare
import android.content.Context
import android.content.Intent
import android.os.Build
import android.provider.Settings
import androidx.core.app.NotificationChannelCompat
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
@@ -33,7 +36,7 @@ class CaptchaNotifier(
.build()
manager.createNotificationChannel(channel)
val intent = CloudFlareActivity.newIntent(context, exception.url, exception.headers)
val intent = CloudFlareActivity.newIntent(context, exception)
.setData(exception.url.toUri())
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setContentTitle(channel.name)
@@ -56,8 +59,21 @@ class CaptchaNotifier(
),
)
.setContentIntent(PendingIntentCompat.getActivity(context, 0, intent, 0, false))
.build()
manager.notify(TAG, exception.source.hashCode(), notification)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val actionIntent = PendingIntentCompat.getActivity(
context, SETTINGS_ACTION_CODE,
Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS)
.putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
.putExtra(Settings.EXTRA_CHANNEL_ID, CHANNEL_ID),
0, false,
)
notification.addAction(
R.drawable.ic_settings,
context.getString(R.string.notifications_settings),
actionIntent,
)
}
manager.notify(TAG, exception.source.hashCode(), notification.build())
}
fun dismiss(source: MangaSource) {
@@ -84,5 +100,6 @@ class CaptchaNotifier(
private const val CHANNEL_ID = "captcha"
private const val TAG = CHANNEL_ID
private const val GROUP_CAPTCHA = "org.koitharu.kotatsu.CAPTCHA"
private const val SETTINGS_ACTION_CODE = 3
}
}

View File

@@ -23,12 +23,15 @@ import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.browser.WebViewBackPressedCallback
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.util.TaggedActivityResult
import org.koitharu.kotatsu.core.util.ext.configureForParser
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
import org.koitharu.kotatsu.parsers.model.MangaSource
import javax.inject.Inject
import com.google.android.material.R as materialR
@@ -137,6 +140,10 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
override fun onCheckPassed() {
pendingResult = RESULT_OK
val source = intent?.getStringExtra(ARG_SOURCE)
if (source != null) {
CaptchaNotifier(this).dismiss(MangaSource(source))
}
finishAfterTransition()
}
@@ -174,9 +181,9 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
}
}
class Contract : ActivityResultContract<Pair<String, Headers?>, TaggedActivityResult>() {
override fun createIntent(context: Context, input: Pair<String, Headers?>): Intent {
return newIntent(context, input.first, input.second)
class Contract : ActivityResultContract<CloudFlareProtectedException, TaggedActivityResult>() {
override fun createIntent(context: Context, input: CloudFlareProtectedException): Intent {
return newIntent(context, input)
}
override fun parseResult(resultCode: Int, intent: Intent?): TaggedActivityResult {
@@ -188,13 +195,23 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
const val TAG = "CloudFlareActivity"
private const val ARG_UA = "ua"
private const val ARG_SOURCE = "_source"
fun newIntent(
fun newIntent(context: Context, exception: CloudFlareProtectedException) = newIntent(
context = context,
url = exception.url,
source = exception.source,
headers = exception.headers,
)
private fun newIntent(
context: Context,
url: String,
source: MangaSource?,
headers: Headers?,
) = Intent(context, CloudFlareActivity::class.java).apply {
data = url.toUri()
putExtra(ARG_SOURCE, source?.name)
headers?.get(CommonHeaders.USER_AGENT)?.let {
putExtra(ARG_UA, it)
}

View File

@@ -26,9 +26,6 @@ import kotlinx.coroutines.flow.asSharedFlow
import okhttp3.OkHttpClient
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.browser.cloudflare.CaptchaNotifier
import org.koitharu.kotatsu.core.cache.ContentCache
import org.koitharu.kotatsu.core.cache.MemoryContentCache
import org.koitharu.kotatsu.core.cache.StubContentCache
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.network.ImageProxyInterceptor
import org.koitharu.kotatsu.core.network.MangaHttpClient
@@ -43,6 +40,7 @@ import org.koitharu.kotatsu.core.util.AcraScreenLogger
import org.koitharu.kotatsu.core.util.ext.connectivityManager
import org.koitharu.kotatsu.core.util.ext.isLowRamDevice
import org.koitharu.kotatsu.details.ui.pager.pages.MangaPageFetcher
import org.koitharu.kotatsu.details.ui.pager.pages.MangaPageKeyer
import org.koitharu.kotatsu.local.data.CacheDir
import org.koitharu.kotatsu.local.data.CbzFetcher
import org.koitharu.kotatsu.local.data.LocalStorageChanges
@@ -54,6 +52,7 @@ import org.koitharu.kotatsu.search.ui.MangaSuggestionsProvider
import org.koitharu.kotatsu.settings.backup.BackupObserver
import org.koitharu.kotatsu.sync.domain.SyncController
import org.koitharu.kotatsu.widget.WidgetUpdater
import javax.inject.Provider
import javax.inject.Singleton
@Module
@@ -86,7 +85,7 @@ interface AppModule {
@Singleton
fun provideCoil(
@ApplicationContext context: Context,
@MangaHttpClient okHttpClient: OkHttpClient,
@MangaHttpClient okHttpClientProvider: Provider<OkHttpClient>,
mangaRepositoryFactory: MangaRepository.Factory,
imageProxyInterceptor: ImageProxyInterceptor,
pageFetcherFactory: MangaPageFetcher.Factory,
@@ -98,11 +97,14 @@ interface AppModule {
.directory(rootDir.resolve(CacheDir.THUMBS.dir))
.build()
}
val okHttpClientLazy = lazy {
okHttpClientProvider.get().newBuilder().cache(null).build()
}
return ImageLoader.Builder(context)
.okHttpClient(okHttpClient.newBuilder().cache(null).build())
.okHttpClient { okHttpClientLazy.value }
.interceptorDispatcher(Dispatchers.Default)
.fetcherDispatcher(Dispatchers.IO)
.decoderDispatcher(Dispatchers.Default)
.fetcherDispatcher(Dispatchers.Default)
.decoderDispatcher(Dispatchers.IO)
.transformationDispatcher(Dispatchers.Default)
.diskCache(diskCacheFactory)
.logger(if (BuildConfig.DEBUG) DebugLogger() else null)
@@ -112,7 +114,8 @@ interface AppModule {
ComponentRegistry.Builder()
.add(SvgDecoder.Factory())
.add(CbzFetcher.Factory())
.add(FaviconFetcher.Factory(context, okHttpClient, mangaRepositoryFactory))
.add(FaviconFetcher.Factory(context, okHttpClientLazy, mangaRepositoryFactory))
.add(MangaPageKeyer())
.add(pageFetcherFactory)
.add(imageProxyInterceptor)
.add(coverRestoreInterceptor)
@@ -153,18 +156,6 @@ interface AppModule {
acraScreenLogger,
)
@Provides
@Singleton
fun provideContentCache(
application: Application,
): ContentCache {
return if (application.isLowRamDevice()) {
StubContentCache()
} else {
MemoryContentCache(application)
}
}
@Provides
@Singleton
@LocalStorageChanges

View File

@@ -37,7 +37,7 @@ import javax.inject.Provider
open class BaseApp : Application(), Configuration.Provider {
@Inject
lateinit var databaseObservers: Set<@JvmSuppressWildcards InvalidationTracker.Observer>
lateinit var databaseObserversProvider: Provider<Set<@JvmSuppressWildcards InvalidationTracker.Observer>>
@Inject
lateinit var activityLifecycleCallbacks: Set<@JvmSuppressWildcards ActivityLifecycleCallbacks>
@@ -87,7 +87,7 @@ open class BaseApp : Application(), Configuration.Provider {
WorkServiceStopHelper(workManagerProvider).setup()
}
override fun attachBaseContext(base: Context?) {
override fun attachBaseContext(base: Context) {
super.attachBaseContext(base)
initAcra {
buildConfigClass = BuildConfig::class.java
@@ -123,7 +123,7 @@ open class BaseApp : Application(), Configuration.Provider {
@WorkerThread
private fun setupDatabaseObservers() {
val tracker = database.get().invalidationTracker
databaseObservers.forEach {
databaseObserversProvider.get().forEach {
tracker.addObserver(it)
}
}

View File

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

View File

@@ -2,18 +2,19 @@ package org.koitharu.kotatsu.core.cache
import androidx.collection.LruCache
import java.util.concurrent.TimeUnit
import org.koitharu.kotatsu.core.cache.MemoryContentCache.Key as CacheKey
class ExpiringLruCache<T>(
val maxSize: Int,
private val lifetime: Long,
private val timeUnit: TimeUnit,
) : Iterable<ContentCache.Key> {
) : Iterable<CacheKey> {
private val cache = LruCache<ContentCache.Key, ExpiringValue<T>>(maxSize)
private val cache = LruCache<CacheKey, ExpiringValue<T>>(maxSize)
override fun iterator(): Iterator<ContentCache.Key> = cache.snapshot().keys.iterator()
override fun iterator(): Iterator<CacheKey> = cache.snapshot().keys.iterator()
operator fun get(key: ContentCache.Key): T? {
operator fun get(key: CacheKey): T? {
val value = cache[key] ?: return null
if (value.isExpired) {
cache.remove(key)
@@ -21,7 +22,7 @@ class ExpiringLruCache<T>(
return value.get()
}
operator fun set(key: ContentCache.Key, value: T) {
operator fun set(key: CacheKey, value: T) {
cache.put(key, ExpiringValue(value, lifetime, timeUnit))
}
@@ -33,7 +34,7 @@ class ExpiringLruCache<T>(
cache.trimToSize(size)
}
fun remove(key: ContentCache.Key) {
fun remove(key: CacheKey) {
cache.remove(key)
}
}

View File

@@ -3,48 +3,54 @@ package org.koitharu.kotatsu.core.cache
import android.app.Application
import android.content.ComponentCallbacks2
import android.content.res.Configuration
import org.koitharu.kotatsu.core.util.ext.isLowRamDevice
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaSource
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Singleton
class MemoryContentCache(application: Application) : ContentCache, ComponentCallbacks2 {
@Singleton
class MemoryContentCache @Inject constructor(application: Application) : ComponentCallbacks2 {
private val isLowRam = application.isLowRamDevice()
init {
application.registerComponentCallbacks(this)
}
private val detailsCache = ExpiringLruCache<SafeDeferred<Manga>>(4, 5, TimeUnit.MINUTES)
private val pagesCache = ExpiringLruCache<SafeDeferred<List<MangaPage>>>(4, 10, TimeUnit.MINUTES)
private val relatedMangaCache = ExpiringLruCache<SafeDeferred<List<Manga>>>(4, 10, TimeUnit.MINUTES)
private val detailsCache = ExpiringLruCache<SafeDeferred<Manga>>(if (isLowRam) 1 else 4, 5, TimeUnit.MINUTES)
private val pagesCache =
ExpiringLruCache<SafeDeferred<List<MangaPage>>>(if (isLowRam) 1 else 4, 10, TimeUnit.MINUTES)
private val relatedMangaCache =
ExpiringLruCache<SafeDeferred<List<Manga>>>(if (isLowRam) 1 else 3, 10, TimeUnit.MINUTES)
override val isCachingEnabled: Boolean = true
override suspend fun getDetails(source: MangaSource, url: String): Manga? {
return detailsCache[ContentCache.Key(source, url)]?.awaitOrNull()
suspend fun getDetails(source: MangaSource, url: String): Manga? {
return detailsCache[Key(source, url)]?.awaitOrNull()
}
override fun putDetails(source: MangaSource, url: String, details: SafeDeferred<Manga>) {
detailsCache[ContentCache.Key(source, url)] = details
fun putDetails(source: MangaSource, url: String, details: SafeDeferred<Manga>) {
detailsCache[Key(source, url)] = details
}
override suspend fun getPages(source: MangaSource, url: String): List<MangaPage>? {
return pagesCache[ContentCache.Key(source, url)]?.awaitOrNull()
suspend fun getPages(source: MangaSource, url: String): List<MangaPage>? {
return pagesCache[Key(source, url)]?.awaitOrNull()
}
override fun putPages(source: MangaSource, url: String, pages: SafeDeferred<List<MangaPage>>) {
pagesCache[ContentCache.Key(source, url)] = pages
fun putPages(source: MangaSource, url: String, pages: SafeDeferred<List<MangaPage>>) {
pagesCache[Key(source, url)] = pages
}
override suspend fun getRelatedManga(source: MangaSource, url: String): List<Manga>? {
return relatedMangaCache[ContentCache.Key(source, url)]?.awaitOrNull()
suspend fun getRelatedManga(source: MangaSource, url: String): List<Manga>? {
return relatedMangaCache[Key(source, url)]?.awaitOrNull()
}
override fun putRelatedManga(source: MangaSource, url: String, related: SafeDeferred<List<Manga>>) {
relatedMangaCache[ContentCache.Key(source, url)] = related
fun putRelatedManga(source: MangaSource, url: String, related: SafeDeferred<List<Manga>>) {
relatedMangaCache[Key(source, url)] = related
}
override fun clear(source: MangaSource) {
fun clear(source: MangaSource) {
clearCache(detailsCache, source)
clearCache(pagesCache, source)
clearCache(relatedMangaCache, source)
@@ -81,4 +87,9 @@ class MemoryContentCache(application: Application) : ContentCache, ComponentCall
}
}
}
data class Key(
val source: MangaSource,
val url: String,
)
}

View File

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

View File

@@ -40,7 +40,7 @@ abstract class MangaDao {
abstract suspend fun searchByTitle(query: String, source: String, limit: Int): List<MangaWithTags>
@Upsert
abstract suspend fun upsert(manga: MangaEntity)
protected abstract suspend fun upsert(manga: MangaEntity)
@Update(onConflict = OnConflictStrategy.IGNORE)
abstract suspend fun update(manga: MangaEntity): Int

View File

@@ -20,11 +20,8 @@ abstract class MangaSourcesDao {
@Query("SELECT * FROM sources ORDER BY sort_key")
abstract suspend fun findAll(): List<MangaSourceEntity>
@Query("SELECT * FROM sources WHERE enabled = 0 ORDER BY sort_key")
abstract suspend fun findAllDisabled(): List<MangaSourceEntity>
@Query("SELECT * FROM sources WHERE enabled = 0")
abstract fun observeDisabled(): Flow<List<MangaSourceEntity>>
@Query("SELECT source FROM sources WHERE enabled = 1")
abstract suspend fun findAllEnabledNames(): List<String>
@Query("SELECT * FROM sources ORDER BY sort_key")
abstract fun observeAll(): Flow<List<MangaSourceEntity>>

View File

@@ -28,9 +28,6 @@ interface TrackLogsDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(entity: TrackLogEntity): Long
@Query("DELETE FROM track_logs WHERE manga_id = :mangaId")
suspend fun removeAll(mangaId: Long)
@Query("DELETE FROM track_logs WHERE manga_id NOT IN (SELECT manga_id FROM tracks)")
suspend fun gc()

View File

@@ -6,7 +6,6 @@ import androidx.annotation.StringRes
import androidx.collection.ArrayMap
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
import okhttp3.Headers
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.alternatives.ui.AlternativesActivity
import org.koitharu.kotatsu.browser.BrowserActivity
@@ -30,7 +29,7 @@ class ExceptionResolver : ActivityResultCallback<TaggedActivityResult> {
private val activity: FragmentActivity?
private val fragment: Fragment?
private val sourceAuthContract: ActivityResultLauncher<MangaSource>
private val cloudflareContract: ActivityResultLauncher<Pair<String, Headers?>>
private val cloudflareContract: ActivityResultLauncher<CloudFlareProtectedException>
constructor(activity: FragmentActivity) {
this.activity = activity
@@ -55,7 +54,7 @@ class ExceptionResolver : ActivityResultCallback<TaggedActivityResult> {
}
suspend fun resolve(e: Throwable): Boolean = when (e) {
is CloudFlareProtectedException -> resolveCF(e.url, e.headers)
is CloudFlareProtectedException -> resolveCF(e)
is AuthRequiredException -> resolveAuthException(e.source)
is NotFoundException -> {
openInBrowser(e.url)
@@ -70,9 +69,9 @@ class ExceptionResolver : ActivityResultCallback<TaggedActivityResult> {
else -> false
}
private suspend fun resolveCF(url: String, headers: Headers): Boolean = suspendCoroutine { cont ->
private suspend fun resolveCF(e: CloudFlareProtectedException): Boolean = suspendCoroutine { cont ->
continuations[CloudFlareActivity.TAG] = cont
cloudflareContract.launch(url to headers)
cloudflareContract.launch(e)
}
private suspend fun resolveAuthException(source: MangaSource): Boolean = suspendCoroutine { cont ->

View File

@@ -16,8 +16,10 @@ import org.koitharu.kotatsu.core.network.cookies.AndroidCookieJar
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
import org.koitharu.kotatsu.core.network.cookies.PreferencesCookieJar
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.ext.assertNotInMainThread
import org.koitharu.kotatsu.local.data.LocalStorageManager
import java.util.concurrent.TimeUnit
import javax.inject.Provider
import javax.inject.Singleton
@Module
@@ -50,10 +52,12 @@ interface NetworkModule {
@Singleton
@BaseHttpClient
fun provideBaseHttpClient(
@ApplicationContext contextProvider: Provider<Context>,
cache: Cache,
cookieJar: CookieJar,
settings: AppSettings,
): OkHttpClient = OkHttpClient.Builder().apply {
assertNotInMainThread()
connectTimeout(20, TimeUnit.SECONDS)
readTimeout(60, TimeUnit.SECONDS)
writeTimeout(20, TimeUnit.SECONDS)
@@ -62,7 +66,9 @@ interface NetworkModule {
proxyAuthenticator(ProxyAuthenticator(settings))
dns(DoHManager(cache, settings))
if (settings.isSSLBypassEnabled) {
bypassSSLErrors()
disableCertificateVerification()
} else {
installExtraCertsificates(contextProvider.get())
}
cache(cache)
addInterceptor(GZipInterceptor())

View File

@@ -1,30 +0,0 @@
package org.koitharu.kotatsu.core.network
import android.annotation.SuppressLint
import okhttp3.OkHttpClient
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import java.security.SecureRandom
import java.security.cert.X509Certificate
import javax.net.ssl.SSLContext
import javax.net.ssl.SSLSocketFactory
import javax.net.ssl.X509TrustManager
@SuppressLint("CustomX509TrustManager")
fun OkHttpClient.Builder.bypassSSLErrors() = also { builder ->
runCatching {
val trustAllCerts = object : X509TrustManager {
override fun checkClientTrusted(chain: Array<X509Certificate>, authType: String) = Unit
override fun checkServerTrusted(chain: Array<X509Certificate>, authType: String) = Unit
override fun getAcceptedIssuers(): Array<X509Certificate> = emptyArray()
}
val sslContext = SSLContext.getInstance("SSL")
sslContext.init(null, arrayOf(trustAllCerts), SecureRandom())
val sslSocketFactory: SSLSocketFactory = sslContext.socketFactory
builder.sslSocketFactory(sslSocketFactory, trustAllCerts)
builder.hostnameVerifier { _, _ -> true }
}.onFailure {
it.printStackTraceDebug()
}
}

View File

@@ -0,0 +1,63 @@
package org.koitharu.kotatsu.core.network
import android.annotation.SuppressLint
import android.content.Context
import android.content.res.AssetManager
import android.util.Log
import okhttp3.OkHttpClient
import okhttp3.tls.HandshakeCertificates
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import java.security.SecureRandom
import java.security.cert.CertificateFactory
import java.security.cert.X509Certificate
import javax.net.ssl.SSLContext
import javax.net.ssl.SSLSocketFactory
import javax.net.ssl.X509TrustManager
@SuppressLint("CustomX509TrustManager")
fun OkHttpClient.Builder.disableCertificateVerification() = also { builder ->
runCatching {
val trustAllCerts = object : X509TrustManager {
override fun checkClientTrusted(chain: Array<X509Certificate>, authType: String) = Unit
override fun checkServerTrusted(chain: Array<X509Certificate>, authType: String) = Unit
override fun getAcceptedIssuers(): Array<X509Certificate> = emptyArray()
}
val sslContext = SSLContext.getInstance("SSL")
sslContext.init(null, arrayOf(trustAllCerts), SecureRandom())
val sslSocketFactory: SSLSocketFactory = sslContext.socketFactory
builder.sslSocketFactory(sslSocketFactory, trustAllCerts)
builder.hostnameVerifier { _, _ -> true }
}.onFailure {
it.printStackTraceDebug()
}
}
fun OkHttpClient.Builder.installExtraCertsificates(context: Context) = also { builder ->
val certificatesBuilder = HandshakeCertificates.Builder()
.addPlatformTrustedCertificates()
val assets = context.assets.list("").orEmpty()
for (path in assets) {
if (path.endsWith(".pem")) {
val cert = loadCert(context, path) ?: continue
certificatesBuilder.addTrustedCertificate(cert)
}
}
val certificates = certificatesBuilder.build()
builder.sslSocketFactory(certificates.sslSocketFactory(), certificates.trustManager)
}
private fun loadCert(context: Context, path: String): X509Certificate? = runCatching {
val cf = CertificateFactory.getInstance("X.509")
context.assets.open(path, AssetManager.ACCESS_STREAMING).use {
cf.generateCertificate(it)
} as X509Certificate
}.onFailure { e ->
e.printStackTraceDebug()
}.onSuccess {
if (BuildConfig.DEBUG) {
Log.i("ExtraCerts", "Loaded cert $path")
}
}.getOrNull()

View File

@@ -18,6 +18,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.db.TABLE_HISTORY
import org.koitharu.kotatsu.core.parser.MangaDataRepository
@@ -31,6 +32,7 @@ import org.koitharu.kotatsu.core.util.ext.source
import org.koitharu.kotatsu.history.data.HistoryRepository
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.mapNotNullToSet
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.reader.ui.ReaderActivity
import org.koitharu.kotatsu.search.ui.MangaListActivity
@@ -90,6 +92,14 @@ class AppShortcutManager @Inject constructor(
false
}
fun getMangaShortcuts(): Set<Long> {
val shortcuts = ShortcutManagerCompat.getShortcuts(
context,
ShortcutManagerCompat.FLAG_MATCH_CACHED or ShortcutManagerCompat.FLAG_MATCH_PINNED or ShortcutManagerCompat.FLAG_MATCH_DYNAMIC,
)
return shortcuts.mapNotNullToSet { it.id.toLongOrNull() }
}
@VisibleForTesting
suspend fun await(): Boolean {
return shortcutsUpdateJob?.join() != null
@@ -150,7 +160,7 @@ class AppShortcutManager @Inject constructor(
.build()
}
private suspend fun buildShortcutInfo(source: MangaSource): ShortcutInfoCompat {
private suspend fun buildShortcutInfo(source: MangaSource): ShortcutInfoCompat = withContext(Dispatchers.Default) {
val icon = runCatchingCancellable {
coil.execute(
ImageRequest.Builder(context)
@@ -163,7 +173,7 @@ class AppShortcutManager @Inject constructor(
onSuccess = { IconCompat.createWithAdaptiveBitmap(it) },
onFailure = { IconCompat.createWithResource(context, R.drawable.ic_shortcut_default) },
)
return ShortcutInfoCompat.Builder(context, source.name)
ShortcutInfoCompat.Builder(context, source.name)
.setShortLabel(source.title)
.setLongLabel(source.title)
.setIcon(icon)

View File

@@ -22,11 +22,11 @@ import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.config.MangaSourceConfig
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.network.UserAgents
import org.koitharu.kotatsu.parsers.util.SuspendLazy
import java.lang.ref.WeakReference
import java.util.Locale
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.coroutines.EmptyCoroutineContext
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
@@ -38,15 +38,10 @@ class MangaLoaderContextImpl @Inject constructor(
) : MangaLoaderContext() {
private var webViewCached: WeakReference<WebView>? = null
private val userAgentLazy = SuspendLazy {
withContext(Dispatchers.Main) {
obtainWebView().settings.userAgentString
}.sanitizeHeaderValue()
}
private val webViewUserAgent by lazy { obtainWebViewUserAgent() }
@SuppressLint("SetJavaScriptEnabled")
override suspend fun evaluateJs(script: String): String? = withContext(Dispatchers.Main) {
override suspend fun evaluateJs(script: String): String? = withContext(Dispatchers.Main.immediate) {
val webView = obtainWebView()
suspendCoroutine { cont ->
webView.evaluateJavascript(script) { result ->
@@ -55,13 +50,7 @@ class MangaLoaderContextImpl @Inject constructor(
}
}
override fun getDefaultUserAgent(): String = runCatching {
runBlocking {
userAgentLazy.get()
}
}.onFailure { e ->
e.printStackTraceDebug()
}.getOrDefault(UserAgents.FIREFOX_MOBILE)
override fun getDefaultUserAgent(): String = webViewUserAgent
override fun getConfig(source: MangaSource): MangaSourceConfig {
return SourceSettings(androidContext, source)
@@ -86,4 +75,22 @@ class MangaLoaderContextImpl @Inject constructor(
webViewCached = WeakReference(it)
}
}
private fun obtainWebViewUserAgent(): String {
val mainDispatcher = Dispatchers.Main.immediate
return if (!mainDispatcher.isDispatchNeeded(EmptyCoroutineContext)) {
obtainWebViewUserAgentImpl()
} else {
runBlocking(mainDispatcher) {
obtainWebViewUserAgentImpl()
}
}
}
@MainThread
private fun obtainWebViewUserAgentImpl() = runCatching {
obtainWebView().settings.userAgentString.sanitizeHeaderValue()
}.onFailure { e ->
e.printStackTraceDebug()
}.getOrDefault(UserAgents.FIREFOX_MOBILE)
}

View File

@@ -1,7 +1,7 @@
package org.koitharu.kotatsu.core.parser
import androidx.annotation.AnyThread
import org.koitharu.kotatsu.core.cache.ContentCache
import org.koitharu.kotatsu.core.cache.MemoryContentCache
import org.koitharu.kotatsu.core.network.MirrorSwitchInterceptor
import org.koitharu.kotatsu.local.data.LocalMangaRepository
import org.koitharu.kotatsu.parsers.MangaLoaderContext
@@ -57,7 +57,7 @@ interface MangaRepository {
class Factory @Inject constructor(
private val localMangaRepository: LocalMangaRepository,
private val loaderContext: MangaLoaderContext,
private val contentCache: ContentCache,
private val contentCache: MemoryContentCache,
private val mirrorSwitchInterceptor: MirrorSwitchInterceptor,
) {

View File

@@ -13,15 +13,15 @@ import okhttp3.Headers
import okhttp3.Interceptor
import okhttp3.Response
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.cache.ContentCache
import org.koitharu.kotatsu.core.cache.MemoryContentCache
import org.koitharu.kotatsu.core.cache.SafeDeferred
import org.koitharu.kotatsu.core.network.MirrorSwitchInterceptor
import org.koitharu.kotatsu.core.prefs.SourceSettings
import org.koitharu.kotatsu.core.util.MultiMutex
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
import org.koitharu.kotatsu.parsers.MangaParser
import org.koitharu.kotatsu.parsers.MangaParserAuthProvider
import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.exception.ParseException
import org.koitharu.kotatsu.parsers.model.ContentRating
import org.koitharu.kotatsu.parsers.model.Favicons
import org.koitharu.kotatsu.parsers.model.Manga
@@ -38,10 +38,14 @@ import java.util.Locale
class RemoteMangaRepository(
private val parser: MangaParser,
private val cache: ContentCache,
private val cache: MemoryContentCache,
private val mirrorSwitchInterceptor: MirrorSwitchInterceptor,
) : MangaRepository, Interceptor {
private val detailsMutex = MultiMutex<Long>()
private val relatedMangaMutex = MultiMutex<Long>()
private val pagesMutex = MultiMutex<Long>()
override val source: MangaSource
get() = parser.source
@@ -97,7 +101,7 @@ class RemoteMangaRepository(
override suspend fun getDetails(manga: Manga): Manga = getDetails(manga, CachePolicy.ENABLED)
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> = pagesMutex.withLock(chapter.id) {
cache.getPages(source, chapter.url)?.let { return it }
val pages = asyncSafe {
mirrorSwitchInterceptor.withMirrorSwitching {
@@ -105,8 +109,8 @@ class RemoteMangaRepository(
}
}
cache.putPages(source, chapter.url, pages)
return pages.await()
}
pages
}.await()
override suspend fun getPageUrl(page: MangaPage): String = mirrorSwitchInterceptor.withMirrorSwitching {
parser.getPageUrl(page)
@@ -124,16 +128,16 @@ class RemoteMangaRepository(
parser.getFavicons()
}
override suspend fun getRelated(seed: Manga): List<Manga> {
override suspend fun getRelated(seed: Manga): List<Manga> = relatedMangaMutex.withLock(seed.id) {
cache.getRelatedManga(source, seed.url)?.let { return it }
val related = asyncSafe {
parser.getRelatedManga(seed).filterNot { it.id == seed.id }
}
cache.putRelatedManga(source, seed.url, related)
return related.await()
}
related
}.await()
suspend fun getDetails(manga: Manga, cachePolicy: CachePolicy): Manga {
suspend fun getDetails(manga: Manga, cachePolicy: CachePolicy): Manga = detailsMutex.withLock(manga.id) {
if (cachePolicy.readEnabled) {
cache.getDetails(source, manga.url)?.let { return it }
}
@@ -145,8 +149,8 @@ class RemoteMangaRepository(
if (cachePolicy.writeEnabled) {
cache.putDetails(source, manga.url, details)
}
return details.await()
}
details
}.await()
suspend fun peekDetails(manga: Manga): Manga? {
return cache.getDetails(source, manga.url)

View File

@@ -170,10 +170,11 @@ class FaviconFetcher(
class Factory(
context: Context,
private val okHttpClient: OkHttpClient,
okHttpClientLazy: Lazy<OkHttpClient>,
private val mangaRepositoryFactory: MangaRepository.Factory,
) : Fetcher.Factory<Uri> {
private val okHttpClient by okHttpClientLazy
private val diskCache = lazy {
val rootDir = context.externalCacheDir ?: context.cacheDir
DiskCache.Builder()

View File

@@ -252,7 +252,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val defaultDetailsTab: Int
get() = if (isPagesTabEnabled) {
val raw = prefs.getString(KEY_DETAILS_TAB, null)?.toIntOrNull() ?: 0
val raw = prefs.getString(KEY_DETAILS_TAB, null)?.toIntOrNull() ?: -1
if (raw == -1) {
lastDetailsTab
} else {
@@ -281,7 +281,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
set(value) = prefs.edit { putEnumValue(KEY_SOURCES_ORDER, value) }
var isSourcesGridMode: Boolean
get() = prefs.getBoolean(KEY_SOURCES_GRID, false)
get() = prefs.getBoolean(KEY_SOURCES_GRID, true)
set(value) = prefs.edit { putBoolean(KEY_SOURCES_GRID, value) }
val isNewSourcesTipEnabled: Boolean

View File

@@ -3,33 +3,27 @@ package org.koitharu.kotatsu.core.ui
import android.content.Intent
import android.content.res.Configuration
import android.graphics.Color
import android.os.Build
import android.os.Bundle
import android.view.KeyEvent
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.annotation.CallSuper
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
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.updateLayoutParams
import androidx.viewbinding.ViewBinding
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.components.SingletonComponent
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.util.ActionModeDelegate
import org.koitharu.kotatsu.core.ui.util.BaseActivityEntryPoint
import org.koitharu.kotatsu.core.ui.util.WindowInsetsDelegate
import org.koitharu.kotatsu.core.util.ext.getThemeColor
import org.koitharu.kotatsu.core.util.ext.isWebViewUnavailable
@Suppress("LeakingThis")
@@ -98,6 +92,10 @@ abstract class BaseActivity<B : ViewBinding> :
}
override fun onSupportNavigateUp(): Boolean {
if (supportFragmentManager.backStackEntryCount > 0) {
supportFragmentManager.popBackStack()
return false
}
dispatchNavigateUp()
return true
}
@@ -123,32 +121,13 @@ abstract class BaseActivity<B : ViewBinding> :
@CallSuper
override fun onSupportActionModeStarted(mode: ActionMode) {
super.onSupportActionModeStarted(mode)
actionModeDelegate.onSupportActionModeStarted(mode)
val actionModeColor = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
ColorUtils.compositeColors(
ContextCompat.getColor(this, com.google.android.material.R.color.m3_appbar_overlay_color),
getThemeColor(com.google.android.material.R.attr.colorSurface),
)
} else {
ContextCompat.getColor(this, R.color.kotatsu_background)
}
defaultStatusBarColor = window.statusBarColor
window.statusBarColor = actionModeColor
val insets = ViewCompat.getRootWindowInsets(viewBinding.root)
?.getInsets(WindowInsetsCompat.Type.systemBars()) ?: return
findViewById<ActionBarContextView?>(androidx.appcompat.R.id.action_mode_bar).apply {
setBackgroundColor(actionModeColor)
updateLayoutParams<ViewGroup.MarginLayoutParams> {
topMargin = insets.top
}
}
actionModeDelegate.onSupportActionModeStarted(mode, window)
}
@CallSuper
override fun onSupportActionModeFinished(mode: ActionMode) {
super.onSupportActionModeFinished(mode)
actionModeDelegate.onSupportActionModeFinished(mode)
window.statusBarColor = defaultStatusBarColor
actionModeDelegate.onSupportActionModeFinished(mode, window)
}
protected open fun dispatchNavigateUp() {
@@ -181,6 +160,12 @@ abstract class BaseActivity<B : ViewBinding> :
}
}
@EntryPoint
@InstallIn(SingletonComponent::class)
interface BaseActivityEntryPoint {
val settings: AppSettings
}
companion object {
const val EXTRA_DATA = "data"

View File

@@ -19,7 +19,7 @@ abstract class BaseFullscreenActivity<B : ViewBinding> :
with(window) {
systemUiController = SystemUiController(this)
statusBarColor = Color.TRANSPARENT
navigationBarColor = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
navigationBarColor = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O_MR1) {
ContextCompat.getColor(this@BaseFullscreenActivity, R.color.dim)
} else {
Color.TRANSPARENT

View File

@@ -7,23 +7,21 @@ import android.graphics.ColorFilter
import android.graphics.PixelFormat
import android.graphics.drawable.Animatable
import android.graphics.drawable.Drawable
import android.view.animation.AccelerateDecelerateInterpolator
import androidx.core.graphics.ColorUtils
import androidx.interpolator.view.animation.FastOutSlowInInterpolator
import com.google.android.material.animation.ArgbEvaluatorCompat
import org.koitharu.kotatsu.core.util.ext.animatorDurationScale
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ext.getAnimationDuration
import org.koitharu.kotatsu.core.util.ext.getThemeColor
import kotlin.math.abs
import com.google.android.material.R as materialR
class AnimatedPlaceholderDrawable(context: Context) : Drawable(), Animatable, TimeAnimator.TimeListener {
private val colorLow = context.getThemeColor(materialR.attr.colorBackgroundFloating)
private val colorHigh = context.getThemeColor(materialR.attr.colorSurfaceContainer)
private val colorLow = context.getThemeColor(materialR.attr.colorSurfaceContainerLowest)
private val colorHigh = context.getThemeColor(materialR.attr.colorSurfaceContainerHighest)
private var currentColor: Int = colorLow
private var alpha: Int = 255
private val interpolator = FastOutSlowInInterpolator()
private val period = 2000 * context.animatorDurationScale
private val period = context.getAnimationDuration(R.integer.config_longAnimTime) * 2
private val timeAnimator = TimeAnimator()
init {
@@ -32,7 +30,7 @@ class AnimatedPlaceholderDrawable(context: Context) : Drawable(), Animatable, Ti
}
override fun draw(canvas: Canvas) {
if (!isRunning) {
if (!isRunning && period > 0) {
updateColor()
start()
}
@@ -40,23 +38,22 @@ class AnimatedPlaceholderDrawable(context: Context) : Drawable(), Animatable, Ti
}
override fun setAlpha(alpha: Int) {
this.alpha = alpha
// this.alpha = alpha FIXME coil's crossfade
}
override fun setColorFilter(colorFilter: ColorFilter?) {
throw UnsupportedOperationException("ColorFilter is not supported by PlaceholderDrawable")
}
override fun setColorFilter(colorFilter: ColorFilter?) = Unit
@Suppress("DeprecatedCallableAddReplaceWith")
@Deprecated("Deprecated in Java")
override fun getOpacity(): Int = PixelFormat.OPAQUE
override fun getAlpha(): Int = alpha
override fun getAlpha(): Int = 255
override fun onTimeUpdate(animation: TimeAnimator?, totalTime: Long, deltaTime: Long) {
if (callback != null) {
callback?.also {
updateColor()
invalidateSelf()
}
it.invalidateDrawable(this)
} ?: stop()
}
override fun start() {
@@ -64,19 +61,18 @@ class AnimatedPlaceholderDrawable(context: Context) : Drawable(), Animatable, Ti
}
override fun stop() {
timeAnimator.cancel()
timeAnimator.end()
}
override fun isRunning(): Boolean = timeAnimator.isStarted
private fun updateColor() {
if (period <= 0f) {
return
}
val ph = period / 2
val fraction = abs((System.currentTimeMillis() % period) - ph) / ph.toFloat()
var color = ArgbEvaluatorCompat.getInstance()
currentColor = ArgbEvaluatorCompat.getInstance()
.evaluate(interpolator.getInterpolation(fraction), colorLow, colorHigh)
if (alpha != 255) {
color = ColorUtils.setAlphaComponent(color, alpha)
}
currentColor = color
}
}

View File

@@ -1,162 +0,0 @@
package org.koitharu.kotatsu.core.ui.image
import android.content.Context
import android.content.res.ColorStateList
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.ColorFilter
import android.graphics.Outline
import android.graphics.Paint
import android.graphics.Path
import android.graphics.PixelFormat
import android.graphics.Rect
import android.graphics.RectF
import android.graphics.drawable.Drawable
import android.graphics.drawable.LayerDrawable
import android.os.Build
import androidx.annotation.ReturnThis
import org.koitharu.kotatsu.core.util.ext.getThemeColorStateList
import org.koitharu.kotatsu.core.util.ext.resolveDp
import org.koitharu.kotatsu.parsers.util.toIntUp
import com.google.android.material.R as materialR
class CardDrawable(
context: Context,
private var corners: Int,
) : Drawable() {
private val cornerSize = context.resources.resolveDp(12f)
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
private val cornersF = FloatArray(8)
private val boundsF = RectF()
private val color: ColorStateList
private val path = Path()
private var alpha = 255
private var state: IntArray? = null
private var horizontalInset: Int = 0
init {
paint.style = Paint.Style.FILL
color = context.getThemeColorStateList(materialR.attr.colorSurfaceContainerHighest)
?: ColorStateList.valueOf(Color.TRANSPARENT)
setCorners(corners)
updateColor()
}
override fun draw(canvas: Canvas) {
canvas.drawPath(path, paint)
}
override fun setAlpha(alpha: Int) {
this.alpha = alpha
updateColor()
}
override fun setColorFilter(colorFilter: ColorFilter?) {
paint.colorFilter = colorFilter
}
override fun getColorFilter(): ColorFilter? = paint.colorFilter
override fun getOutline(outline: Outline) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
outline.setPath(path)
} else if (path.isConvex) {
outline.setConvexPath(path)
}
outline.alpha = 1f
}
override fun getPadding(padding: Rect): Boolean {
padding.set(
horizontalInset,
0,
horizontalInset,
0,
)
if (corners or TOP != 0) {
padding.top += cornerSize.toIntUp()
}
if (corners or BOTTOM != 0) {
padding.bottom += cornerSize.toIntUp()
}
return horizontalInset != 0
}
override fun onStateChange(state: IntArray): Boolean {
this.state = state
if (color.isStateful) {
updateColor()
return true
} else {
return false
}
}
@Deprecated("Deprecated in Java")
override fun getOpacity(): Int = PixelFormat.TRANSPARENT
override fun onBoundsChange(bounds: Rect) {
super.onBoundsChange(bounds)
boundsF.set(bounds)
boundsF.inset(horizontalInset.toFloat(), 0f)
path.reset()
path.addRoundRect(boundsF, cornersF, Path.Direction.CW)
path.close()
}
@ReturnThis
fun setCorners(corners: Int): CardDrawable {
this.corners = corners
val topLeft = if (corners and TOP_LEFT == TOP_LEFT) cornerSize else 0f
val topRight = if (corners and TOP_RIGHT == TOP_RIGHT) cornerSize else 0f
val bottomRight = if (corners and BOTTOM_RIGHT == BOTTOM_RIGHT) cornerSize else 0f
val bottomLeft = if (corners and BOTTOM_LEFT == BOTTOM_LEFT) cornerSize else 0f
cornersF[0] = topLeft
cornersF[1] = topLeft
cornersF[2] = topRight
cornersF[3] = topRight
cornersF[4] = bottomRight
cornersF[5] = bottomRight
cornersF[6] = bottomLeft
cornersF[7] = bottomLeft
invalidateSelf()
return this
}
fun setHorizontalInset(inset: Int) {
horizontalInset = inset
invalidateSelf()
}
private fun updateColor() {
paint.color = color.getColorForState(state, color.defaultColor)
paint.alpha = alpha
}
companion object {
const val TOP_LEFT = 1
const val TOP_RIGHT = 2
const val BOTTOM_LEFT = 4
const val BOTTOM_RIGHT = 8
const val LEFT = TOP_LEFT or BOTTOM_LEFT
const val TOP = TOP_LEFT or TOP_RIGHT
const val RIGHT = TOP_RIGHT or BOTTOM_RIGHT
const val BOTTOM = BOTTOM_LEFT or BOTTOM_RIGHT
const val NONE = 0
const val ALL = TOP_LEFT or TOP_RIGHT or BOTTOM_RIGHT or BOTTOM_LEFT
fun from(d: Drawable?): CardDrawable? = when (d) {
null -> null
is CardDrawable -> d
is LayerDrawable -> (0 until d.numberOfLayers).firstNotNullOfOrNull { i ->
from(d.getDrawable(i))
}
else -> null
}
}
}

View File

@@ -6,7 +6,7 @@ import android.view.ViewGroup
import android.widget.ImageView
import coil.size.Dimension
import coil.size.Size
import coil.size.SizeResolver
import coil.size.ViewSizeResolver
import kotlinx.coroutines.CancellableContinuation
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlin.coroutines.resume
@@ -16,24 +16,24 @@ private const val ASPECT_RATIO_HEIGHT = 18f
private const val ASPECT_RATIO_WIDTH = 13f
class CoverSizeResolver(
private val imageView: ImageView,
) : SizeResolver {
override val view: ImageView,
) : ViewSizeResolver<ImageView> {
override suspend fun size(): Size {
getSize()?.let { return it }
return suspendCancellableCoroutine { cont ->
val layoutListener = LayoutListener(cont)
imageView.addOnLayoutChangeListener(layoutListener)
view.addOnLayoutChangeListener(layoutListener)
cont.invokeOnCancellation {
imageView.removeOnLayoutChangeListener(layoutListener)
view.removeOnLayoutChangeListener(layoutListener)
}
}
}
private fun getSize(): Size? {
val lp = imageView.layoutParams
var width = getDimension(lp.width, imageView.width, imageView.paddingLeft + imageView.paddingRight)
var height = getDimension(lp.height, imageView.height, imageView.paddingTop + imageView.paddingBottom)
val lp = view.layoutParams
var width = getDimension(lp.width, view.width, view.paddingLeft + view.paddingRight)
var height = getDimension(lp.height, view.height, view.paddingTop + view.paddingBottom)
if (width == null && height == null) {
return null
}

View File

@@ -67,7 +67,7 @@ class FastScroller @JvmOverloads constructor(
private var hideScrollbar = true
private var showBubble = true
private var showBubbleAlways = false
private var bubbleSize = BubbleSize.NORMAL
private var bubbleSize = BubbleSize.SMALL
private var bubbleImage: Drawable? = null
private var handleImage: Drawable? = null
private var trackImage: Drawable? = null
@@ -91,7 +91,7 @@ class FastScroller @JvmOverloads constructor(
if (showBubbleAlways) {
val targetPos = getRecyclerViewTargetPosition(y)
sectionIndexer?.let { binding.bubble.text = it.getSectionText(recyclerView.context, targetPos) }
sectionIndexer?.let { bindBubble(it.getSectionText(recyclerView.context, targetPos)) }
}
}
}
@@ -145,7 +145,7 @@ class FastScroller @JvmOverloads constructor(
showBubble = getBoolean(R.styleable.FastScrollRecyclerView_showBubble, showBubble)
showBubbleAlways = getBoolean(R.styleable.FastScrollRecyclerView_showBubbleAlways, showBubbleAlways)
showTrack = getBoolean(R.styleable.FastScrollRecyclerView_showTrack, showTrack)
bubbleSize = getBubbleSize(R.styleable.FastScrollRecyclerView_bubbleSize, BubbleSize.NORMAL)
bubbleSize = getBubbleSize(R.styleable.FastScrollRecyclerView_bubbleSize, bubbleSize)
val textSize = getDimension(R.styleable.FastScrollRecyclerView_bubbleTextSize, bubbleSize.textSize)
binding.bubble.setTextSize(TypedValue.COMPLEX_UNIT_PX, textSize)
offset = getDimensionPixelOffset(R.styleable.FastScrollRecyclerView_scrollerOffset, offset)
@@ -473,7 +473,7 @@ class FastScroller @JvmOverloads constructor(
val layoutManager = recyclerView?.layoutManager ?: return
val targetPos = getRecyclerViewTargetPosition(y)
layoutManager.scrollToPosition(targetPos)
if (showBubble) sectionIndexer?.let { binding.bubble.text = it.getSectionText(context, targetPos) }
if (showBubble) sectionIndexer?.let { bindBubble(it.getSectionText(context, targetPos)) }
}
private fun setViewPositions(y: Float) {
@@ -535,6 +535,11 @@ class FastScroller @JvmOverloads constructor(
}
}
private fun bindBubble(text: CharSequence?) {
binding.bubble.text = text
binding.bubble.alpha = if (text.isNullOrEmpty()) 0f else 1f
}
private val BubbleSize.textSize
@Px get() = resources.getDimension(textSizeId)

View File

@@ -2,8 +2,6 @@ package org.koitharu.kotatsu.core.ui.sheet
import android.app.Dialog
import android.content.Context
import android.graphics.Color
import android.os.Build
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
@@ -16,15 +14,8 @@ import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.app.AppCompatDialog
import androidx.appcompat.app.AppCompatDialogFragment
import androidx.appcompat.view.ActionMode
import androidx.appcompat.widget.ActionBarContextView
import androidx.core.content.ContextCompat
import androidx.core.graphics.ColorUtils
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.updateLayoutParams
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.viewbinding.ViewBinding
import com.google.android.material.bottomsheet.BottomSheetBehavior
@@ -33,14 +24,12 @@ import com.google.android.material.sidesheet.SideSheetDialog
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.ui.util.ActionModeDelegate
import org.koitharu.kotatsu.core.util.ext.getThemeColor
import com.google.android.material.R as materialR
abstract class BaseAdaptiveSheet<B : ViewBinding> : AppCompatDialogFragment() {
private var waitingForDismissAllowingStateLoss = false
private var isFitToContentsDisabled = false
private var defaultStatusBarColor = Color.TRANSPARENT
var viewBinding: B? = null
private set
@@ -105,40 +94,18 @@ abstract class BaseAdaptiveSheet<B : ViewBinding> : AppCompatDialogFragment() {
@CallSuper
protected open fun dispatchSupportActionModeStarted(mode: ActionMode) {
actionModeDelegate?.onSupportActionModeStarted(mode)
val ctx = requireContext()
val actionModeColor = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
ColorUtils.compositeColors(
ContextCompat.getColor(ctx, com.google.android.material.R.color.m3_appbar_overlay_color),
ctx.getThemeColor(com.google.android.material.R.attr.colorSurface),
)
} else {
ContextCompat.getColor(ctx, R.color.kotatsu_surface)
}
dialog?.window?.let {
defaultStatusBarColor = it.statusBarColor
it.statusBarColor = actionModeColor
}
val insets = ViewCompat.getRootWindowInsets(requireView())
?.getInsets(WindowInsetsCompat.Type.systemBars()) ?: return
dialog?.window?.decorView?.findViewById<ActionBarContextView?>(androidx.appcompat.R.id.action_mode_bar)?.apply {
setBackgroundColor(actionModeColor)
updateLayoutParams<ViewGroup.MarginLayoutParams> {
topMargin = insets.top
}
}
actionModeDelegate?.onSupportActionModeStarted(mode, dialog?.window)
}
@CallSuper
protected open fun dispatchSupportActionModeFinished(mode: ActionMode) {
actionModeDelegate?.onSupportActionModeFinished(mode)
dialog?.window?.statusBarColor = defaultStatusBarColor
actionModeDelegate?.onSupportActionModeFinished(mode, dialog?.window)
}
fun addSheetCallback(callback: AdaptiveSheetCallback, lifecycleOwner: LifecycleOwner): Boolean {
val b = behavior ?: return false
b.addCallback(callback)
val rootView = dialog?.findViewById<View>(materialR.id.design_bottom_sheet)
val rootView = dialog?.findViewById(materialR.id.design_bottom_sheet)
?: dialog?.findViewById(materialR.id.coordinator)
?: view
if (rootView != null) {

View File

@@ -1,4 +1,4 @@
package org.koitharu.kotatsu.core.ui.util
package org.koitharu.kotatsu.core.ui.sheet
import android.view.View
import androidx.activity.OnBackPressedCallback
@@ -6,9 +6,8 @@ import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_COLLAPSED
import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_EXPANDED
import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_HALF_EXPANDED
import org.koitharu.kotatsu.core.util.ext.doOnExpansionsChanged
class BottomSheetClollapseCallback(
class BottomSheetCollapseCallback(
private val behavior: BottomSheetBehavior<*>,
) : OnBackPressedCallback(behavior.state == STATE_EXPANDED) {

View File

@@ -1,14 +1,28 @@
package org.koitharu.kotatsu.core.ui.util
import android.graphics.Color
import android.os.Build
import android.view.ViewGroup
import android.view.Window
import androidx.activity.OnBackPressedCallback
import androidx.appcompat.view.ActionMode
import androidx.appcompat.widget.ActionBarContextView
import androidx.core.content.ContextCompat
import androidx.core.graphics.ColorUtils
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.updateLayoutParams
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ext.getThemeColor
import com.google.android.material.R as materialR
class ActionModeDelegate : OnBackPressedCallback(false) {
private var activeActionMode: ActionMode? = null
private var listeners: MutableList<ActionModeListener>? = null
private var defaultStatusBarColor = Color.TRANSPARENT
val isActionModeStarted: Boolean
get() = activeActionMode != null
@@ -17,16 +31,40 @@ class ActionModeDelegate : OnBackPressedCallback(false) {
finishActionMode()
}
fun onSupportActionModeStarted(mode: ActionMode) {
fun onSupportActionModeStarted(mode: ActionMode, window: Window?) {
activeActionMode = mode
isEnabled = true
listeners?.forEach { it.onActionModeStarted(mode) }
if (window != null) {
val ctx = window.context
val actionModeColor = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
ColorUtils.compositeColors(
ContextCompat.getColor(ctx, materialR.color.m3_appbar_overlay_color),
ctx.getThemeColor(materialR.attr.colorSurface),
)
} else {
ContextCompat.getColor(ctx, R.color.kotatsu_surface)
}
defaultStatusBarColor = window.statusBarColor
window.statusBarColor = actionModeColor
val insets = ViewCompat.getRootWindowInsets(window.decorView)
?.getInsets(WindowInsetsCompat.Type.systemBars()) ?: return
window.decorView.findViewById<ActionBarContextView?>(androidx.appcompat.R.id.action_mode_bar)?.apply {
setBackgroundColor(actionModeColor)
updateLayoutParams<ViewGroup.MarginLayoutParams> {
topMargin = insets.top
}
}
}
}
fun onSupportActionModeFinished(mode: ActionMode) {
fun onSupportActionModeFinished(mode: ActionMode, window: Window?) {
activeActionMode = null
isEnabled = false
listeners?.forEach { it.onActionModeFinished(mode) }
if (window != null) {
window.statusBarColor = defaultStatusBarColor
}
}
fun addListener(listener: ActionModeListener) {

View File

@@ -1,12 +0,0 @@
package org.koitharu.kotatsu.core.ui.util
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import org.koitharu.kotatsu.core.prefs.AppSettings
@EntryPoint
@InstallIn(SingletonComponent::class)
interface BaseActivityEntryPoint {
val settings: AppSettings
}

View File

@@ -0,0 +1,50 @@
package org.koitharu.kotatsu.core.ui.util
import android.view.View
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.view.ancestors
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.Lifecycle.State.RESUMED
import androidx.lifecycle.LifecycleOwner
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.bottomsheet.BottomSheetBehavior
class PagerNestedScrollHelper(
private val recyclerView: RecyclerView,
) : DefaultLifecycleObserver {
fun bind(lifecycleOwner: LifecycleOwner) {
lifecycleOwner.lifecycle.addObserver(this)
recyclerView.isNestedScrollingEnabled = lifecycleOwner.lifecycle.currentState.isAtLeast(RESUMED)
}
override fun onPause(owner: LifecycleOwner) {
recyclerView.isNestedScrollingEnabled = false
invalidateBottomSheetScrollTarget()
}
override fun onResume(owner: LifecycleOwner) {
recyclerView.isNestedScrollingEnabled = true
}
override fun onDestroy(owner: LifecycleOwner) {
owner.lifecycle.removeObserver(this)
}
/**
* Here we need to invalidate the `nestedScrollingChildRef` of the [BottomSheetBehavior]
*/
private fun invalidateBottomSheetScrollTarget() {
var handleCoordinator = false
for (parent in recyclerView.ancestors) {
if (handleCoordinator && parent is CoordinatorLayout) {
parent.requestLayout()
break
}
val lp = (parent as? View)?.layoutParams ?: continue
if (lp is CoordinatorLayout.LayoutParams && lp.behavior is BottomSheetBehavior<*>) {
handleCoordinator = true
}
}
}
}

View File

@@ -1,42 +0,0 @@
package org.koitharu.kotatsu.core.ui.util
import android.animation.ValueAnimator
import android.view.animation.AccelerateDecelerateInterpolator
import com.google.android.material.appbar.AppBarLayout
import com.google.android.material.shape.MaterialShapeDrawable
import org.koitharu.kotatsu.core.util.ext.getAnimationDuration
import com.google.android.material.R as materialR
class StatusBarDimHelper : AppBarLayout.OnOffsetChangedListener {
private var animator: ValueAnimator? = null
private val interpolator = AccelerateDecelerateInterpolator()
override fun onOffsetChanged(appBarLayout: AppBarLayout, verticalOffset: Int) {
val foreground = appBarLayout.statusBarForeground ?: return
val start = foreground.alpha
val collapsed = verticalOffset != 0
val end = if (collapsed) 255 else 0
animator?.cancel()
if (start == end) {
animator = null
return
}
animator = ValueAnimator.ofInt(start, end).apply {
duration = appBarLayout.context.getAnimationDuration(materialR.integer.app_bar_elevation_anim_duration)
interpolator = this@StatusBarDimHelper.interpolator
addUpdateListener {
foreground.alpha = it.animatedValue as Int
}
start()
}
}
fun attachToAppBar(appBarLayout: AppBarLayout) {
appBarLayout.addOnOffsetChangedListener(this)
appBarLayout.statusBarForeground =
MaterialShapeDrawable.createWithElevationOverlay(appBarLayout.context).apply {
alpha = 0
}
}
}

View File

@@ -33,23 +33,30 @@ sealed class SystemUiController(
private class LegacyImpl(window: Window) : SystemUiController(window) {
override fun setSystemUiVisible(value: Boolean) {
val flags = window.decorView.systemUiVisibility
window.decorView.systemUiVisibility = if (value) {
View.SYSTEM_UI_FLAG_LAYOUT_STABLE or
View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or
View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
(flags and LEGACY_FLAGS_HIDDEN.inv()) or LEGACY_FLAGS_VISIBLE
} else {
View.SYSTEM_UI_FLAG_LAYOUT_STABLE or
View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or
View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or
View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or
View.SYSTEM_UI_FLAG_FULLSCREEN or
View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
(flags and LEGACY_FLAGS_VISIBLE.inv()) or LEGACY_FLAGS_HIDDEN
}
}
}
companion object {
@Suppress("DEPRECATION")
private const val LEGACY_FLAGS_VISIBLE = View.SYSTEM_UI_FLAG_LAYOUT_STABLE or
View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or
View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
@Suppress("DEPRECATION")
private const val LEGACY_FLAGS_HIDDEN = View.SYSTEM_UI_FLAG_LAYOUT_STABLE or
View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or
View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or
View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or
View.SYSTEM_UI_FLAG_FULLSCREEN or
View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
operator fun invoke(window: Window): SystemUiController =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
Api30Impl(window)

View File

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

View File

@@ -0,0 +1,19 @@
package org.koitharu.kotatsu.core.ui.widgets
import android.content.Context
import android.util.AttributeSet
import androidx.annotation.AttrRes
import com.google.android.material.textview.MaterialTextView
class MultilineEllipsizeTextView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
@AttrRes defStyleAttr: Int = android.R.attr.textViewStyle,
) : MaterialTextView(context, attrs, defStyleAttr) {
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
val lh = lineHeight
maxLines = if (lh > 0) h / lh else 1
}
}

View File

@@ -16,11 +16,13 @@ import android.widget.TextView
import androidx.annotation.StringRes
import androidx.appcompat.widget.LinearLayoutCompat
import androidx.core.content.withStyledAttributes
import androidx.core.graphics.ColorUtils
import androidx.core.view.children
import androidx.core.widget.TextViewCompat
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ext.getAnimationDuration
import org.koitharu.kotatsu.core.util.ext.getThemeColorStateList
import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled
import org.koitharu.kotatsu.core.util.ext.resolveDp
import org.koitharu.kotatsu.core.util.ext.setTextAndVisible
import org.koitharu.kotatsu.core.util.ext.textAndVisible
@@ -37,10 +39,14 @@ class ProgressButton @JvmOverloads constructor(
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
private var progress = 0f
private var targetProgress = 0f
private var colorBase: ColorStateList = ColorStateList.valueOf(Color.TRANSPARENT)
private var colorProgress: ColorStateList = ColorStateList.valueOf(Color.TRANSPARENT)
private var progressAnimator: ValueAnimator? = null
private var colorBaseCurrent = colorProgress.defaultColor
private var colorProgressCurrent = colorProgress.defaultColor
var title: CharSequence?
get() = textViewTitle.textAndVisible
set(value) {
@@ -97,10 +103,19 @@ class ProgressButton @JvmOverloads constructor(
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
canvas.drawColor(colorBase.getColorForState(drawableState, colorBase.defaultColor))
paint.color = colorProgress.getColorForState(drawableState, colorProgress.defaultColor)
paint.alpha = 84 // 255 * 0.33F
canvas.drawRect(0f, 0f, width * progress, height.toFloat(), paint)
canvas.drawColor(colorBaseCurrent)
if (progress > 0f) {
canvas.drawRect(0f, 0f, width * progress, height.toFloat(), paint)
}
}
override fun drawableStateChanged() {
super.drawableStateChanged()
val state = drawableState
colorBaseCurrent = colorBase.getColorForState(state, colorBase.defaultColor)
colorProgressCurrent = colorProgress.getColorForState(state, colorProgress.defaultColor)
colorProgressCurrent = ColorUtils.setAlphaComponent(colorProgressCurrent, 84 /* 255 * 0.33F */)
paint.color = colorProgressCurrent
}
override fun setGravity(gravity: Int) {
@@ -116,8 +131,10 @@ class ProgressButton @JvmOverloads constructor(
}
override fun onAnimationUpdate(animation: ValueAnimator) {
progress = animation.animatedValue as Float
invalidate()
if (animation === progressAnimator) {
progress = animation.animatedValue as Float
invalidate()
}
}
fun setTitle(@StringRes titleResId: Int) {
@@ -129,19 +146,25 @@ class ProgressButton @JvmOverloads constructor(
}
fun setProgress(value: Float, animate: Boolean) {
progressAnimator?.cancel()
if (animate) {
val prevAnimator = progressAnimator
if (animate && context.isAnimationsEnabled) {
if (value == targetProgress) {
return
}
targetProgress = value
progressAnimator = ValueAnimator.ofFloat(progress, value).apply {
duration = context.getAnimationDuration(android.R.integer.config_shortAnimTime)
duration = context.getAnimationDuration(android.R.integer.config_mediumAnimTime)
interpolator = AccelerateDecelerateInterpolator()
addUpdateListener(this@ProgressButton)
start()
}
progressAnimator?.start()
} else {
progressAnimator = null
progress = value
targetProgress = value
invalidate()
}
prevAnimator?.cancel()
}
private fun applyGravity() {

View File

@@ -11,6 +11,7 @@ import android.view.ViewPropertyAnimator
import androidx.annotation.AttrRes
import androidx.annotation.StyleRes
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.view.isVisible
import androidx.customview.view.AbsSavedState
import androidx.interpolator.view.animation.FastOutLinearInInterpolator
import androidx.interpolator.view.animation.LinearOutSlowInInterpolator
@@ -47,6 +48,9 @@ class SlidingBottomNavigationView @JvmOverloads constructor(
}
}
val isShownOrShowing: Boolean
get() = isVisible && currentState == STATE_UP
override fun getBehavior(): CoordinatorLayout.Behavior<*> {
return behavior
}

View File

@@ -2,6 +2,8 @@ package org.koitharu.kotatsu.core.util
import androidx.collection.ArrayMap
import kotlinx.coroutines.sync.Mutex
import kotlin.contracts.InvocationKind
import kotlin.contracts.contract
class MultiMutex<T : Any> : Set<T> {
@@ -10,12 +12,12 @@ class MultiMutex<T : Any> : Set<T> {
override val size: Int
get() = delegates.size
override fun contains(element: T): Boolean {
return delegates.containsKey(element)
override fun contains(element: T): Boolean = synchronized(delegates) {
delegates.containsKey(element)
}
override fun containsAll(elements: Collection<T>): Boolean {
return elements.all { x -> delegates.containsKey(x) }
override fun containsAll(elements: Collection<T>): Boolean = synchronized(delegates) {
elements.all { x -> delegates.containsKey(x) }
}
override fun isEmpty(): Boolean {
@@ -40,4 +42,16 @@ class MultiMutex<T : Any> : Set<T> {
delegates.remove(element)?.unlock()
}
}
suspend inline fun <R> withLock(element: T, block: () -> R): R {
contract {
callsInPlace(block, InvocationKind.EXACTLY_ONCE)
}
return try {
lock(element)
block()
} finally {
unlock(element)
}
}
}

View File

@@ -37,11 +37,14 @@ import androidx.appcompat.app.AppCompatDialog
import androidx.core.app.ActivityOptionsCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import androidx.core.graphics.ColorUtils
import androidx.core.os.LocaleListCompat
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.Fragment
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.coroutineScope
import androidx.webkit.WebViewCompat
import androidx.webkit.WebViewFeature
import androidx.work.CoroutineWorker
import com.google.android.material.elevation.ElevationOverlayProvider
import kotlinx.coroutines.Dispatchers
@@ -59,7 +62,6 @@ import okio.use
import org.json.JSONException
import org.jsoup.internal.StringUtil.StringJoiner
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.xmlpull.v1.XmlPullParser
import org.xmlpull.v1.XmlPullParserException
@@ -139,6 +141,9 @@ fun Window.setNavigationBarTransparentCompat(context: Context, elevation: Float,
!context.getSystemBoolean("config_navBarNeedsScrim", true)
) {
Color.TRANSPARENT
} else if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O_MR1) {
val baseColor = context.getThemeColor(android.R.attr.navigationBarColor)
ColorUtils.setAlphaComponent(baseColor, (Color.alpha(baseColor) * alphaFactor).toInt())
} else {
// Set navbar scrim 70% of navigationBarColor
ElevationOverlayProvider(context).compositeOverlayIfNeeded(
@@ -263,6 +268,9 @@ fun WebView.configureForParser(userAgentOverride: String?) = with(settings) {
javaScriptEnabled = true
domStorageEnabled = true
mediaPlaybackRequiresUserGesture = false
if (WebViewFeature.isFeatureSupported(WebViewFeature.MUTE_AUDIO)) {
WebViewCompat.setAudioMuted(this@configureForParser, true)
}
databaseEnabled = true
if (userAgentOverride != null) {
userAgentString = userAgentOverride

View File

@@ -47,15 +47,6 @@ fun ImageResult.getDrawableOrThrow() = when (this) {
is ErrorResult -> throw throwable
}
@Deprecated(
"",
ReplaceWith(
"getDrawableOrThrow().toBitmap()",
"androidx.core.graphics.drawable.toBitmap",
),
)
fun ImageResult.requireBitmap() = getDrawableOrThrow().toBitmap()
fun ImageResult.toBitmapOrNull() = when (this) {
is SuccessResult -> try {
drawable.toBitmap()

View File

@@ -1,25 +0,0 @@
package org.koitharu.kotatsu.core.util.ext
import android.app.Activity
import android.graphics.Rect
import android.os.Build
import android.util.DisplayMetrics
import android.view.Display
@Suppress("DEPRECATION")
val Activity.displayCompat: Display
get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
display ?: windowManager.defaultDisplay
} else {
windowManager.defaultDisplay
}
fun Activity.getDisplaySize(): Rect {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
windowManager.currentWindowMetrics.bounds
} else {
val dm = DisplayMetrics()
displayCompat.getRealMetrics(dm)
Rect(0, 0, dm.widthPixels, dm.heightPixels)
}
}

View File

@@ -1,6 +1,5 @@
package org.koitharu.kotatsu.core.util.ext
import android.util.Log
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
@@ -28,11 +27,16 @@ fun <T> Flow<T>.observe(owner: LifecycleOwner, minState: Lifecycle.State, collec
}
fun <T> Flow<Event<T>?>.observeEvent(owner: LifecycleOwner, collector: FlowCollector<T>) {
observeEvent(owner, Lifecycle.State.STARTED, collector)
}
fun <T> Flow<Event<T>?>.observeEvent(owner: LifecycleOwner, minState: Lifecycle.State, collector: FlowCollector<T>) {
owner.lifecycleScope.launch {
owner.repeatOnLifecycle(Lifecycle.State.STARTED) {
owner.repeatOnLifecycle(minState) {
collect {
it?.consume(collector)
}
}
}
}

View File

@@ -54,7 +54,7 @@ class DetailsLoadUseCase @Inject constructor(
send(MangaDetails(manga, null, null, false))
try {
val details = getDetails(manga)
launch { updateTracker(manga) }
launch { updateTracker(details) }
send(MangaDetails(details, local?.peek(), details.description?.parseAsHtml(withImages = false), false))
send(MangaDetails(details, local?.await(), details.description?.parseAsHtml(withImages = true), true))
} catch (e: IOException) {

View File

@@ -4,7 +4,7 @@ import android.content.Context
import android.content.Intent
import dagger.hilt.android.AndroidEntryPoint
import dagger.hilt.android.EntryPointAccessors
import org.koitharu.kotatsu.core.cache.ContentCache
import org.koitharu.kotatsu.core.cache.MemoryContentCache
import org.koitharu.kotatsu.core.model.findById
import org.koitharu.kotatsu.core.model.parcelable.ParcelableChapter
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
@@ -27,7 +27,7 @@ class MangaPrefetchService : CoroutineIntentService() {
lateinit var mangaRepositoryFactory: MangaRepository.Factory
@Inject
lateinit var cache: ContentCache
lateinit var cache: MemoryContentCache
@Inject
lateinit var historyRepository: HistoryRepository
@@ -110,17 +110,14 @@ class MangaPrefetchService : CoroutineIntentService() {
}
private fun isPrefetchAvailable(context: Context, source: MangaSource?): Boolean {
if (source == MangaSource.LOCAL) {
return false
}
if (context.isPowerSaveMode()) {
if (source == MangaSource.LOCAL || context.isPowerSaveMode()) {
return false
}
val entryPoint = EntryPointAccessors.fromApplication(
context,
PrefetchCompanionEntryPoint::class.java,
)
return entryPoint.contentCache.isCachingEnabled && entryPoint.settings.isContentPrefetchEnabled
return entryPoint.settings.isContentPrefetchEnabled
}
private fun tryStart(context: Context, intent: Intent) {

View File

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

View File

@@ -72,8 +72,7 @@ fun MangaDetails.mapChapters(
fun List<ChapterListItem>.withVolumeHeaders(context: Context): List<ListModel> {
var prevVolume = 0
val result = ArrayList<ListModel>((size * 1.4).toInt())
var groupPos: Byte = 0
for ((index, item) in this.withIndex()) {
for (item in this) {
val chapter = item.chapter
if (chapter.volume != prevVolume) {
val text = if (chapter.volume == 0) {
@@ -83,19 +82,8 @@ fun List<ChapterListItem>.withVolumeHeaders(context: Context): List<ListModel> {
}
result.add(ListHeader(text))
prevVolume = chapter.volume
groupPos = ChapterListItem.GROUP_START
} else if (groupPos == ChapterListItem.GROUP_START) {
groupPos = ChapterListItem.GROUP_MIDDLE
}
if (groupPos != 0.toByte()) {
val next = this.getOrNull(index + 1)
if (next == null || next.chapter.volume != prevVolume) {
groupPos = ChapterListItem.GROUP_END
}
result.add(item.copy(groupPosition = groupPos))
} else {
result.add(item)
}
result.add(item)
}
return result
}

View File

@@ -53,12 +53,11 @@ import org.koitharu.kotatsu.core.ui.BaseListAdapter
import org.koitharu.kotatsu.core.ui.image.ChipIconTarget
import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.ui.util.BottomSheetClollapseCallback
import org.koitharu.kotatsu.core.ui.sheet.BottomSheetCollapseCallback
import org.koitharu.kotatsu.core.ui.util.MenuInvalidator
import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver
import org.koitharu.kotatsu.core.ui.widgets.ChipsView
import org.koitharu.kotatsu.core.util.FileSize
import org.koitharu.kotatsu.core.util.ViewBadge
import org.koitharu.kotatsu.core.util.ext.crossfade
import org.koitharu.kotatsu.core.util.ext.defaultPlaceholders
import org.koitharu.kotatsu.core.util.ext.enqueueWith
@@ -124,7 +123,6 @@ class DetailsActivity :
private val viewModel: DetailsViewModel by viewModels()
private lateinit var chaptersBadge: ViewBadge
private lateinit var menuProvider: DetailsMenuProvider
override fun onCreate(savedInstanceState: Bundle?) {
@@ -156,13 +154,11 @@ class DetailsActivity :
viewBinding.chipsTags.onChipClickListener = this
TitleScrollCoordinator(viewBinding.textViewTitle).attach(viewBinding.scrollView)
viewBinding.containerBottomSheet?.let { BottomSheetBehavior.from(it) }?.let { behavior ->
onBackPressedDispatcher.addCallback(BottomSheetClollapseCallback(behavior))
onBackPressedDispatcher.addCallback(BottomSheetCollapseCallback(behavior))
}
chaptersBadge = ViewBadge(viewBinding.buttonRead, this)
viewModel.details.filterNotNull().observe(this, ::onMangaUpdated)
viewModel.onMangaRemoved.observeEvent(this, ::onMangaRemoved)
viewModel.newChaptersCount.observe(this, ::onNewChaptersChanged)
viewModel.onError
.filterNot { ChaptersPagesSheet.isShown(supportFragmentManager) }
.observeEvent(this, DetailsErrorObserver(this, viewModel, exceptionResolver))
@@ -185,7 +181,8 @@ class DetailsActivity :
viewModel.isStatsAvailable.observe(this, menuInvalidator)
viewModel.remoteManga.observe(this, menuInvalidator)
viewModel.branches.observe(this) {
viewBinding.infoLayout.chipBranch.isVisible = it.size > 1
viewBinding.infoLayout.chipBranch.isVisible = it.size > 1 || !it.firstOrNull()?.name.isNullOrEmpty()
viewBinding.infoLayout.chipBranch.isCloseIconVisible = it.size > 1
}
viewModel.chapters.observe(this, PrefetchObserver(this))
viewModel.onDownloadStarted
@@ -379,15 +376,6 @@ class DetailsActivity :
chip.textAndVisible = time?.formatShort(chip.resources)
}
private fun onDescriptionChanged(description: CharSequence?) {
val tv = viewBinding.textViewDescription
if (description.isNullOrBlank()) {
tv.setText(R.string.no_description)
} else {
tv.text = description
}
}
private fun onLocalSizeChanged(size: Long) {
val chip = viewBinding.infoLayout.chipSize
if (size == 0L) {
@@ -455,7 +443,7 @@ class DetailsActivity :
loadCover(manga)
textViewTitle.text = manga.title
textViewSubtitle.textAndVisible = manga.altTitle
infoLayout.chipAuthor.textAndVisible = manga.author
infoLayout.chipAuthor.textAndVisible = manga.author?.ellipsize(AUTHOR_LABEL_LIMIT)
if (manga.hasRating) {
ratingBar.rating = manga.rating * ratingBar.numStars
ratingBar.isVisible = true
@@ -545,18 +533,19 @@ class DetailsActivity :
info.totalChapters == -1 -> getString(R.string.error_occurred)
else -> resources.getQuantityString(R.plurals.chapters, info.totalChapters, info.totalChapters)
}
buttonRead.setProgress(info.history?.percent?.coerceIn(0f, 1f) ?: 0f, true)
val isFirstCall = buttonRead.tag == null
buttonRead.tag = Unit
buttonRead.setProgress(info.history?.percent?.coerceIn(0f, 1f) ?: 0f, !isFirstCall)
buttonDownload?.isEnabled = info.isValid && info.canDownload
buttonRead.isEnabled = info.isValid
}
private fun onNewChaptersChanged(count: Int) {
chaptersBadge.counter = count
}
private fun showBranchPopupMenu(v: View) {
val menu = PopupMenu(v.context, v)
val branches = viewModel.branches.value
if (branches.size <= 1) {
return
}
val menu = PopupMenu(v.context, v)
for ((i, branch) in branches.withIndex()) {
val title = buildSpannedString {
if (branch.isCurrent) {
@@ -600,8 +589,7 @@ class DetailsActivity :
private fun openReader(isIncognitoMode: Boolean) {
val manga = viewModel.manga.value ?: return
val chapterId = viewModel.historyInfo.value.history?.chapterId
if (chapterId != null && manga.chapters?.none { x -> x.id == chapterId } == true) {
if (viewModel.historyInfo.value.isChapterMissing) {
Snackbar.make(viewBinding.scrollView, R.string.chapter_is_missing, Snackbar.LENGTH_SHORT)
.show()
} else {
@@ -680,6 +668,7 @@ class DetailsActivity :
companion object {
private const val FAV_LABEL_LIMIT = 10
private const val AUTHOR_LABEL_LIMIT = 16
fun newIntent(context: Context, manga: Manga): Intent {
return Intent(context, DetailsActivity::class.java)

View File

@@ -19,7 +19,6 @@ 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.pager.ChaptersPagesSheet
import org.koitharu.kotatsu.download.ui.dialog.DownloadOption
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.scrobbling.common.ui.selector.ScrobblingSelectorSheet
@@ -135,7 +134,6 @@ class DetailsMenuProvider(
is DownloadOption.WholeManga -> null
is DownloadOption.SelectionHint -> {
viewModel.startChaptersSelection()
ChaptersPagesSheet.show(activity.supportFragmentManager, ChaptersPagesSheet.TAB_CHAPTERS)
return
}

View File

@@ -88,7 +88,6 @@ class DetailsViewModel @Inject constructor(
val mangaId = intent.mangaId
val onActionDone = MutableEventFlow<ReversibleAction>()
val onShowTip = MutableEventFlow<Unit>()
val onSelectChapter = MutableEventFlow<Long>()
val onDownloadStarted = MutableEventFlow<Unit>()
@@ -161,11 +160,6 @@ class DetailsViewModel @Inject constructor(
}
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.WhileSubscribed(5000), 0L)
@Deprecated("")
val description = details
.map { it?.description }
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, null)
val onMangaRemoved = MutableEventFlow<Manga>()
val isScrobblingAvailable: Boolean
get() = scrobblers.any { it.isAvailable }
@@ -179,7 +173,7 @@ class DetailsViewModel @Inject constructor(
} else {
emptyList()
}
}.stateIn(viewModelScope, SharingStarted.Lazily, emptyList())
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, emptyList())
val branches: StateFlow<List<MangaBranch>> = combine(
details,
@@ -226,7 +220,7 @@ class DetailsViewModel @Inject constructor(
chaptersQuery,
) { list, reversed, query ->
(if (reversed) list.asReversed() else list).filterSearch(query)
}.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList())
val readingTime = combine(
details,
@@ -234,7 +228,7 @@ class DetailsViewModel @Inject constructor(
history,
) { m, b, h ->
readingTimeUseCase.invoke(m, b, h)
}.stateIn(viewModelScope, SharingStarted.Lazily, null)
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, null)
val selectedBranchValue: String?
get() = selectedBranch.value

View File

@@ -26,18 +26,9 @@ fun chapterListItemAD(
itemView.setOnClickListener(eventListener)
itemView.setOnLongClickListener(eventListener)
bind { payloads ->
bind {
binding.textViewTitle.text = item.chapter.name
binding.textViewDescription.textAndVisible = item.description
itemView.setBackgroundResource(
when {
item.isGroupStart && item.isGroupEnd -> R.drawable.bg_card_full
item.isGroupStart -> R.drawable.bg_card_top
item.isGroupMiddle -> R.drawable.bg_card_none
item.isGroupEnd -> R.drawable.bg_card_bottom
else -> R.drawable.list_selector
},
)
when {
item.isCurrent -> {
binding.textViewTitle.drawableStart = ContextCompat.getDrawable(context, R.drawable.ic_current_chapter)

View File

@@ -1,25 +1,39 @@
package org.koitharu.kotatsu.details.ui.adapter
import android.content.Context
import org.koitharu.kotatsu.core.model.formatNumber
import org.koitharu.kotatsu.core.ui.BaseListAdapter
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.ui.list.fastscroll.FastScroller
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
import org.koitharu.kotatsu.list.ui.adapter.listHeaderAD
import org.koitharu.kotatsu.list.ui.model.ListHeader
import org.koitharu.kotatsu.list.ui.model.ListModel
class ChaptersAdapter(
private val onItemClickListener: OnListItemClickListener<ChapterListItem>,
) : BaseListAdapter<ListModel>(), FastScroller.SectionIndexer {
private var hasVolumes = false
init {
addDelegate(ListItemType.HEADER, listHeaderAD(null))
addDelegate(ListItemType.CHAPTER_LIST, chapterListItemAD(onItemClickListener))
addDelegate(ListItemType.CHAPTER_GRID, chapterGridItemAD(onItemClickListener))
}
override suspend fun emit(value: List<ListModel>?) {
super.emit(value)
hasVolumes = value != null && value.any { it is ListHeader }
}
override fun getSectionText(context: Context, position: Int): CharSequence? {
return findHeader(position)?.getText(context)
return if (hasVolumes) {
findHeader(position)?.getText(context)
} else {
val chapter = (items.getOrNull(position) as? ChapterListItem)?.chapter ?: return null
if (chapter.number > 0) chapter.formatNumber() else null
}
}
}

View File

@@ -7,6 +7,7 @@ import android.graphics.Paint
import android.graphics.RectF
import android.view.View
import androidx.cardview.widget.CardView
import androidx.core.content.ContextCompat
import androidx.core.graphics.ColorUtils
import androidx.recyclerview.widget.RecyclerView
import org.koitharu.kotatsu.R
@@ -19,7 +20,10 @@ import com.google.android.material.R as materialR
class ChaptersSelectionDecoration(context: Context) : AbstractSelectionItemDecoration() {
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
private val defaultRadius = context.resources.getDimension(materialR.dimen.abc_control_corner_material)
private val radius = context.resources.getDimension(materialR.dimen.abc_control_corner_material)
private val checkIcon = ContextCompat.getDrawable(context, materialR.drawable.ic_mtrl_checked_circle)
private val iconOffset = context.resources.getDimensionPixelOffset(R.dimen.chapter_check_offset)
private val iconSize = context.resources.getDimensionPixelOffset(R.dimen.chapter_check_size)
private val strokeColor = context.getThemeColor(materialR.attr.colorPrimary, Color.RED)
private val fillColor = ColorUtils.setAlphaComponent(
ColorUtils.blendARGB(strokeColor, context.getThemeColor(materialR.attr.colorSurface), 0.8f),
@@ -32,11 +36,12 @@ class ChaptersSelectionDecoration(context: Context) : AbstractSelectionItemDecor
98,
)
paint.style = Paint.Style.FILL
hasBackground = false
hasBackground = true
hasForeground = true
isIncludeDecorAndMargins = false
paint.strokeWidth = context.resources.getDimension(R.dimen.selection_stroke_width)
checkIcon?.setTint(strokeColor)
}
override fun getItemId(parent: RecyclerView, child: View): Long {
@@ -45,6 +50,19 @@ class ChaptersSelectionDecoration(context: Context) : AbstractSelectionItemDecor
return item.chapter.id
}
override fun onDrawBackground(
canvas: Canvas,
parent: RecyclerView,
child: View,
bounds: RectF,
state: RecyclerView.State,
) {
if (child is CardView) {
return
}
canvas.drawRoundRect(bounds, radius, radius, paint)
}
override fun onDrawForeground(
canvas: Canvas,
parent: RecyclerView,
@@ -52,16 +70,24 @@ class ChaptersSelectionDecoration(context: Context) : AbstractSelectionItemDecor
bounds: RectF,
state: RecyclerView.State
) {
val radius = if (child is CardView) {
child.radius
} else {
defaultRadius
if (child !is CardView) {
return
}
val radius = child.radius
paint.color = fillColor
paint.style = Paint.Style.FILL
canvas.drawRoundRect(bounds, radius, radius, paint)
paint.color = strokeColor
paint.style = Paint.Style.STROKE
canvas.drawRoundRect(bounds, radius, radius, paint)
checkIcon?.run {
setBounds(
(bounds.right - iconSize - iconOffset).toInt(),
(bounds.top + iconOffset).toInt(),
(bounds.right - iconOffset).toInt(),
(bounds.top + iconOffset + iconSize).toInt(),
)
draw(canvas)
}
}
}

View File

@@ -10,8 +10,6 @@ import kotlin.experimental.and
data class ChapterListItem(
val chapter: MangaChapter,
val flags: Byte,
private val uploadDateMs: Long,
private val groupPosition: Byte,
) : ListModel {
var description: String? = null
@@ -26,9 +24,9 @@ data class ChapterListItem(
private set
get() {
if (field != null) return field
if (uploadDateMs == 0L) return null
if (chapter.uploadDate == 0L) return null
field = DateUtils.getRelativeTimeSpanString(
uploadDateMs,
chapter.uploadDate,
System.currentTimeMillis(),
DateUtils.DAY_IN_MILLIS,
)
@@ -53,15 +51,6 @@ data class ChapterListItem(
val isGrid: Boolean
get() = hasFlag(FLAG_GRID)
val isGroupStart: Boolean
get() = (groupPosition and GROUP_START) == GROUP_START
val isGroupMiddle: Boolean
get() = (groupPosition and GROUP_MIDDLE) == GROUP_MIDDLE
val isGroupEnd: Boolean
get() = (groupPosition and GROUP_END) == GROUP_END
private fun buildDescription(): String {
val joiner = StringJoiner("")
chapter.formatNumber()?.let {
@@ -105,9 +94,5 @@ data class ChapterListItem(
const val FLAG_BOOKMARKED: Byte = 16
const val FLAG_DOWNLOADED: Byte = 32
const val FLAG_GRID: Byte = 64
const val GROUP_START: Byte = 2
const val GROUP_MIDDLE: Byte = 4
const val GROUP_END: Byte = 8
}
}

View File

@@ -1,10 +1,7 @@
package org.koitharu.kotatsu.details.ui.model
import org.koitharu.kotatsu.core.model.MangaHistory
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.model.isLocal
import org.koitharu.kotatsu.details.data.MangaDetails
import org.koitharu.kotatsu.parsers.model.Manga
data class HistoryInfo(
val totalChapters: Int,
@@ -17,8 +14,8 @@ data class HistoryInfo(
val isValid: Boolean
get() = totalChapters >= 0
val canContinue: Boolean
get() = history != null && !isChapterMissing
val canContinue
get() = currentChapter >= 0
}
fun HistoryInfo(
@@ -38,7 +35,7 @@ fun HistoryInfo(
currentChapter = currentChapter,
history = history,
isIncognitoMode = isIncognitoMode,
isChapterMissing = currentChapter == -1,
isChapterMissing = history != null && manga?.isLoaded == true && manga.allChapters.none { it.id == history.chapterId },
canDownload = manga?.isLocal == false,
)
}

View File

@@ -27,7 +27,5 @@ fun MangaChapter.toListItem(
return ChapterListItem(
chapter = this,
flags = flags,
uploadDateMs = uploadDate,
groupPosition = 0,
)
}

View File

@@ -5,7 +5,7 @@ import androidx.viewpager2.adapter.FragmentStateAdapter
import com.google.android.material.tabs.TabLayout
import com.google.android.material.tabs.TabLayoutMediator
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.details.ui.pager.bookmarks.MangaBookmarksFragment
import org.koitharu.kotatsu.details.ui.pager.bookmarks.BookmarksFragment
import org.koitharu.kotatsu.details.ui.pager.chapters.ChaptersFragment
import org.koitharu.kotatsu.details.ui.pager.pages.PagesFragment
@@ -19,8 +19,8 @@ class ChaptersPagesAdapter(
override fun createFragment(position: Int): Fragment = when (position) {
0 -> ChaptersFragment()
1 -> if (isPagesTabEnabled) PagesFragment() else MangaBookmarksFragment()
2 -> MangaBookmarksFragment()
1 -> if (isPagesTabEnabled) PagesFragment() else BookmarksFragment()
2 -> BookmarksFragment()
else -> throw IllegalArgumentException("Invalid position $position")
}

View File

@@ -8,12 +8,10 @@ import androidx.appcompat.view.ActionMode
import androidx.core.view.isVisible
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.Lifecycle
import com.google.android.material.tabs.TabLayoutMediator
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.sheet.AdaptiveSheetBehavior
import org.koitharu.kotatsu.core.ui.sheet.AdaptiveSheetBehavior.Companion.STATE_COLLAPSED
import org.koitharu.kotatsu.core.ui.sheet.AdaptiveSheetBehavior.Companion.STATE_DRAGGING
import org.koitharu.kotatsu.core.ui.sheet.AdaptiveSheetBehavior.Companion.STATE_EXPANDED
@@ -21,11 +19,13 @@ import org.koitharu.kotatsu.core.ui.sheet.AdaptiveSheetBehavior.Companion.STATE_
import org.koitharu.kotatsu.core.ui.sheet.AdaptiveSheetCallback
import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet
import org.koitharu.kotatsu.core.ui.util.ActionModeListener
import org.koitharu.kotatsu.core.ui.util.MenuInvalidator
import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver
import org.koitharu.kotatsu.core.util.ext.doOnPageChanged
import org.koitharu.kotatsu.core.util.ext.menuView
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.recyclerView
import org.koitharu.kotatsu.core.util.ext.setTabsEnabled
import org.koitharu.kotatsu.core.util.ext.showDistinct
import org.koitharu.kotatsu.core.util.ext.withArgs
@@ -51,9 +51,13 @@ class ChaptersPagesSheet : BaseAdaptiveSheet<SheetChaptersPagesBinding>(), Actio
disableFitToContents()
val args = arguments ?: Bundle.EMPTY
val defaultTab = args.getInt(ARG_TAB, settings.defaultDetailsTab)
val adapter = ChaptersPagesAdapter(this, settings.isPagesTabEnabled || defaultTab == TAB_PAGES)
var defaultTab = args.getInt(ARG_TAB, settings.defaultDetailsTab)
val adapter = ChaptersPagesAdapter(this, settings.isPagesTabEnabled)
if (!adapter.isPagesTabEnabled) {
defaultTab = (defaultTab - 1).coerceAtLeast(TAB_CHAPTERS)
}
binding.pager.offscreenPageLimit = adapter.itemCount
binding.pager.recyclerView?.isNestedScrollingEnabled = false
binding.pager.adapter = adapter
binding.pager.doOnPageChanged(::onPageChanged)
TabLayoutMediator(binding.tabs, binding.pager, adapter).attach()
@@ -64,21 +68,28 @@ class ChaptersPagesSheet : BaseAdaptiveSheet<SheetChaptersPagesBinding>(), Actio
onBackPressedDispatcher.addCallback(viewLifecycleOwner, menuProvider)
binding.toolbar.addMenuProvider(menuProvider)
val menuInvalidator = MenuInvalidator(binding.toolbar)
viewModel.isChaptersReversed.observe(viewLifecycleOwner, menuInvalidator)
viewModel.isChaptersInGridView.observe(viewLifecycleOwner, menuInvalidator)
actionModeDelegate?.addListener(this, viewLifecycleOwner)
addSheetCallback(this, viewLifecycleOwner)
viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(binding.pager, this))
viewModel.onActionDone.observeEvent(viewLifecycleOwner, ReversibleActionObserver(binding.pager, null))
viewModel.onDownloadStarted.observeEvent(viewLifecycleOwner, DownloadStartedObserver(binding.pager))
viewModel.newChaptersCount.observe(viewLifecycleOwner, ::onNewChaptersChanged)
if (dialog != null) {
viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(binding.pager, this))
viewModel.onActionDone.observeEvent(viewLifecycleOwner, ReversibleActionObserver(binding.pager, null))
viewModel.onDownloadStarted.observeEvent(viewLifecycleOwner, DownloadStartedObserver(binding.pager))
}
}
override fun onStateChanged(sheet: View, newState: Int) {
if (newState == STATE_DRAGGING || newState == STATE_SETTLING) {
return
}
val binding = viewBinding ?: return
val isActionModeStarted = actionModeDelegate?.isActionModeStarted == true
viewBinding?.toolbar?.menuView?.isVisible = newState != STATE_COLLAPSED && !isActionModeStarted
binding.toolbar.menuView?.isVisible = newState != STATE_COLLAPSED && !isActionModeStarted
}
override fun onActionModeStarted(mode: ActionMode) {
@@ -130,9 +141,6 @@ class ChaptersPagesSheet : BaseAdaptiveSheet<SheetChaptersPagesBinding>(), Actio
const val TAB_PAGES = 1
const val TAB_BOOKMARKS = 2
private const val ARG_TAB = "tag"
@Deprecated("")
private const val ARG_SHOW_PAGES = "pages"
private const val TAG = "ChaptersPagesSheet"
fun show(fm: FragmentManager) {
@@ -147,7 +155,7 @@ class ChaptersPagesSheet : BaseAdaptiveSheet<SheetChaptersPagesBinding>(), Actio
fun isShown(fm: FragmentManager): Boolean {
val sheet = fm.findFragmentByTag(TAG) as? ChaptersPagesSheet
return sheet != null && sheet.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)
return sheet?.dialog?.isShowing == true
}
}
}

View File

@@ -2,22 +2,33 @@ package org.koitharu.kotatsu.details.ui.pager.bookmarks
import android.os.Bundle
import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.view.ActionMode
import androidx.core.graphics.Insets
import androidx.fragment.app.activityViewModels
import androidx.fragment.app.viewModels
import androidx.recyclerview.widget.GridLayoutManager
import coil.ImageLoader
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.bookmarks.ui.sheet.BookmarksAdapter
import org.koitharu.kotatsu.bookmarks.ui.BookmarksSelectionDecoration
import org.koitharu.kotatsu.bookmarks.ui.adapter.BookmarksAdapter
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.BaseFragment
import org.koitharu.kotatsu.core.ui.list.ListSelectionController
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.ui.util.PagerNestedScrollHelper
import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver
import org.koitharu.kotatsu.core.util.ext.dismissParentDialog
import org.koitharu.kotatsu.core.util.ext.findAppCompatDelegate
import org.koitharu.kotatsu.core.util.ext.findParentCallback
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.databinding.FragmentMangaBookmarksBinding
import org.koitharu.kotatsu.details.ui.DetailsViewModel
import org.koitharu.kotatsu.list.ui.GridSpanResolver
@@ -28,11 +39,11 @@ import org.koitharu.kotatsu.reader.ui.ReaderNavigationCallback
import javax.inject.Inject
@AndroidEntryPoint
class MangaBookmarksFragment : BaseFragment<FragmentMangaBookmarksBinding>(),
OnListItemClickListener<Bookmark> {
class BookmarksFragment : BaseFragment<FragmentMangaBookmarksBinding>(),
OnListItemClickListener<Bookmark>, ListSelectionController.Callback2 {
private val activityViewModel by activityViewModels<DetailsViewModel>()
private val viewModel by viewModels<MangaBookmarksViewModel>()
private val viewModel by viewModels<BookmarksViewModel>()
@Inject
lateinit var coil: ImageLoader
@@ -42,6 +53,7 @@ class MangaBookmarksFragment : BaseFragment<FragmentMangaBookmarksBinding>(),
private var bookmarksAdapter: BookmarksAdapter? = null
private var spanResolver: GridSpanResolver? = null
private var selectionController: ListSelectionController? = null
private val spanSizeLookup = SpanSizeLookup()
private val listCommitCallback = Runnable {
@@ -60,46 +72,54 @@ class MangaBookmarksFragment : BaseFragment<FragmentMangaBookmarksBinding>(),
override fun onViewBindingCreated(binding: FragmentMangaBookmarksBinding, savedInstanceState: Bundle?) {
super.onViewBindingCreated(binding, savedInstanceState)
spanResolver = GridSpanResolver(binding.root.resources)
selectionController = ListSelectionController(
appCompatDelegate = checkNotNull(findAppCompatDelegate()),
decoration = BookmarksSelectionDecoration(binding.root.context),
registryOwner = this,
callback = this,
)
bookmarksAdapter = BookmarksAdapter(
coil = coil,
lifecycleOwner = viewLifecycleOwner,
clickListener = this@MangaBookmarksFragment,
clickListener = this@BookmarksFragment,
headerClickListener = null,
)
viewModel.gridScale.observe(viewLifecycleOwner, ::onGridScaleChanged) // before rv initialization
with(binding.recyclerView) {
addItemDecoration(TypedListSpacingDecoration(context, false))
setHasFixedSize(true)
PagerNestedScrollHelper(this).bind(viewLifecycleOwner)
adapter = bookmarksAdapter
addOnLayoutChangeListener(spanResolver)
(layoutManager as GridLayoutManager).let {
it.spanSizeLookup = spanSizeLookup
it.spanCount = checkNotNull(spanResolver).spanCount
}
selectionController?.attachToRecyclerView(this)
}
viewModel.content.observe(viewLifecycleOwner, checkNotNull(bookmarksAdapter))
viewModel.content.observe(viewLifecycleOwner) { bookmarksAdapter?.setItems(it, listCommitCallback) }
viewModel.onError.observeEvent(
viewLifecycleOwner,
SnackbarErrorObserver(binding.recyclerView, this),
)
viewModel.onActionDone.observeEvent(viewLifecycleOwner, ReversibleActionObserver(binding.recyclerView))
}
override fun onDestroyView() {
spanResolver = null
bookmarksAdapter = null
selectionController = null
spanSizeLookup.invalidateCache()
super.onDestroyView()
}
override fun onPause() {
// required for BottomSheetBehavior
requireViewBinding().recyclerView.isNestedScrollingEnabled = false
super.onPause()
}
override fun onResume() {
requireViewBinding().recyclerView.isNestedScrollingEnabled = true
super.onResume()
}
override fun onWindowInsetsChanged(insets: Insets) = Unit
override fun onItemClick(item: Bookmark, view: View) {
if (selectionController?.onItemClick(item.pageId) == true) {
return
}
val listener = findParentCallback(ReaderNavigationCallback::class.java)
if (listener != null && listener.onBookmarkSelected(item)) {
dismissParentDialog()
@@ -113,6 +133,40 @@ class MangaBookmarksFragment : BaseFragment<FragmentMangaBookmarksBinding>(),
}
}
override fun onItemLongClick(item: Bookmark, view: View): Boolean {
return selectionController?.onItemLongClick(item.pageId) ?: false
}
override fun onSelectionChanged(controller: ListSelectionController, count: Int) {
requireViewBinding().recyclerView.invalidateItemDecorations()
}
override fun onCreateActionMode(
controller: ListSelectionController,
mode: ActionMode,
menu: Menu,
): Boolean {
mode.menuInflater.inflate(R.menu.mode_bookmarks, menu)
return true
}
override fun onActionItemClicked(
controller: ListSelectionController,
mode: ActionMode,
item: MenuItem,
): Boolean {
return when (item.itemId) {
R.id.action_remove -> {
val ids = selectionController?.snapshot() ?: return false
viewModel.removeBookmarks(ids)
mode.finish()
true
}
else -> false
}
}
private fun onGridScaleChanged(scale: Float) {
spanSizeLookup.invalidateCache()
spanResolver?.setGridSize(scale, requireViewBinding().recyclerView)

View File

@@ -18,6 +18,9 @@ import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository
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.ui.util.ReversibleAction
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.list.ui.model.EmptyState
import org.koitharu.kotatsu.list.ui.model.ListHeader
import org.koitharu.kotatsu.list.ui.model.ListModel
@@ -26,12 +29,13 @@ import org.koitharu.kotatsu.parsers.model.Manga
import javax.inject.Inject
@HiltViewModel
class MangaBookmarksViewModel @Inject constructor(
bookmarksRepository: BookmarksRepository,
class BookmarksViewModel @Inject constructor(
private val bookmarksRepository: BookmarksRepository,
settings: AppSettings,
) : BaseViewModel(), FlowCollector<Manga?> {
private val manga = MutableStateFlow<Manga?>(null)
val onActionDone = MutableEventFlow<ReversibleAction>()
val gridScale = settings.observeAsStateFlow(
scope = viewModelScope + Dispatchers.Default,
@@ -50,7 +54,14 @@ class MangaBookmarksViewModel @Inject constructor(
manga.value = value
}
private suspend fun mapList(manga: Manga, bookmarks: List<Bookmark>): List<ListModel>? {
fun removeBookmarks(ids: Set<Long>) {
launchJob(Dispatchers.Default) {
val handle = bookmarksRepository.removeBookmarks(ids)
onActionDone.call(ReversibleAction(R.string.bookmarks_removed, handle))
}
}
private fun mapList(manga: Manga, bookmarks: List<Bookmark>): List<ListModel>? {
val chapters = manga.chapters ?: return null
val bookmarksMap = bookmarks.groupBy { it.chapterId }
val result = ArrayList<ListModel>(bookmarks.size + bookmarksMap.size)

View File

@@ -8,19 +8,23 @@ import android.view.View
import android.view.ViewGroup
import androidx.appcompat.view.ActionMode
import androidx.core.graphics.Insets
import androidx.core.view.ancestors
import androidx.core.view.isVisible
import androidx.fragment.app.activityViewModels
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.viewpager2.widget.ViewPager2
import com.google.android.material.snackbar.Snackbar
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.BaseFragment
import org.koitharu.kotatsu.core.ui.list.ListSelectionController
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.ui.util.PagerNestedScrollHelper
import org.koitharu.kotatsu.core.util.RecyclerViewScrollCallback
import org.koitharu.kotatsu.core.util.ext.dismissParentDialog
import org.koitharu.kotatsu.core.util.ext.findAppCompatDelegate
@@ -79,7 +83,7 @@ class ChaptersFragment :
addItemDecoration(TypedListSpacingDecoration(context, true))
checkNotNull(selectionController).attachToRecyclerView(this)
setHasFixedSize(true)
isNestedScrollingEnabled = false
PagerNestedScrollHelper(this).bind(viewLifecycleOwner)
adapter = chaptersAdapter
ChapterGridSpanHelper.attach(this)
}
@@ -91,12 +95,7 @@ class ChaptersFragment :
viewModel.isChaptersEmpty.observe(viewLifecycleOwner) {
binding.textViewHolder.isVisible = it
}
viewModel.onSelectChapter.observeEvent(viewLifecycleOwner) { chapterId ->
chaptersAdapter?.observeItems()?.firstOrNull { items ->
items.any { x -> x is ChapterListItem && x.chapter.id == chapterId }
}
selectionController?.onItemLongClick(chapterId)
}
viewModel.onSelectChapter.observeEvent(viewLifecycleOwner, ::onSelectChapter)
}
override fun onDestroyView() {
@@ -105,17 +104,6 @@ class ChaptersFragment :
super.onDestroyView()
}
override fun onPause() {
// required for BottomSheetBehavior
requireViewBinding().recyclerViewChapters.isNestedScrollingEnabled = false
super.onPause()
}
override fun onResume() {
requireViewBinding().recyclerViewChapters.isNestedScrollingEnabled = true
super.onResume()
}
override fun onItemClick(item: ChapterListItem, view: View) {
if (selectionController?.onItemClick(item.chapter.id) == true) {
return
@@ -273,6 +261,25 @@ class ChaptersFragment :
}
}
private suspend fun onSelectChapter(chapterId: Long) {
if (!isResumed) {
view?.ancestors?.firstNotNullOfOrNull { it as? ViewPager2 }?.setCurrentItem(0, true)
}
val position = withContext(Dispatchers.Default) {
val predicate: (ListModel) -> Boolean = { x -> x is ChapterListItem && x.chapter.id == chapterId }
val items = chaptersAdapter?.observeItems()?.firstOrNull { it.any(predicate) }
items?.indexOfFirst(predicate) ?: -1
}
if (position >= 0) {
selectionController?.onItemLongClick(chapterId)
val lm = (viewBinding?.recyclerViewChapters?.layoutManager as? LinearLayoutManager)
if (lm != null) {
val offset = resources.getDimensionPixelOffset(R.dimen.chapter_list_item_height)
lm.scrollToPositionWithOffset(position, offset)
}
}
}
private fun onLoadingStateChanged(isLoading: Boolean) {
requireViewBinding().progressBar.isVisible = isLoading
}

View File

@@ -10,6 +10,7 @@ import coil.decode.ImageSource
import coil.fetch.FetchResult
import coil.fetch.Fetcher
import coil.fetch.SourceResult
import coil.network.HttpException
import coil.request.Options
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
@@ -44,15 +45,17 @@ class MangaPageFetcher(
override suspend fun fetch(): FetchResult {
val repo = mangaRepositoryFactory.create(page.source)
val pageUrl = repo.getPageUrl(page)
pagesCache.get(pageUrl)?.let { file ->
return SourceResult(
source = ImageSource(
file = file.toOkioPath(),
metadata = MangaPageMetadata(page),
),
mimeType = null,
dataSource = DataSource.DISK,
)
if (options.diskCachePolicy.readEnabled) {
pagesCache.get(pageUrl)?.let { file ->
return SourceResult(
source = ImageSource(
file = file.toOkioPath(),
metadata = MangaPageMetadata(page),
),
mimeType = null,
dataSource = DataSource.DISK,
)
}
}
return loadPage(pageUrl)
}
@@ -91,8 +94,8 @@ class MangaPageFetcher(
else -> {
val request = PageLoader.createPageRequest(page, pageUrl)
imageProxyInterceptor.interceptPageRequest(request, okHttpClient).use { response ->
check(response.isSuccessful) {
"Invalid response: ${response.code} ${response.message} at $pageUrl"
if (!response.isSuccessful) {
throw HttpException(response)
}
val body = checkNotNull(response.body) {
"Null response"
@@ -122,17 +125,15 @@ class MangaPageFetcher(
private val imageProxyInterceptor: ImageProxyInterceptor,
) : Fetcher.Factory<MangaPage> {
override fun create(data: MangaPage, options: Options, imageLoader: ImageLoader): Fetcher {
return MangaPageFetcher(
okHttpClient = okHttpClient,
pagesCache = pagesCache,
options = options,
page = data,
context = context,
mangaRepositoryFactory = mangaRepositoryFactory,
imageProxyInterceptor = imageProxyInterceptor,
)
}
override fun create(data: MangaPage, options: Options, imageLoader: ImageLoader) = MangaPageFetcher(
okHttpClient = okHttpClient,
pagesCache = pagesCache,
options = options,
page = data,
context = context,
mangaRepositoryFactory = mangaRepositoryFactory,
imageProxyInterceptor = imageProxyInterceptor,
)
}
class MangaPageMetadata(val page: MangaPage) : ImageSource.Metadata()

View File

@@ -0,0 +1,10 @@
package org.koitharu.kotatsu.details.ui.pager.pages
import coil.key.Keyer
import coil.request.Options
import org.koitharu.kotatsu.parsers.model.MangaPage
class MangaPageKeyer : Keyer<MangaPage> {
override fun key(data: MangaPage, options: Options) = data.url
}

View File

@@ -22,6 +22,7 @@ import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.BaseFragment
import org.koitharu.kotatsu.core.ui.list.BoundsScrollListener
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.ui.util.PagerNestedScrollHelper
import org.koitharu.kotatsu.core.util.RecyclerViewScrollCallback
import org.koitharu.kotatsu.core.util.ext.dismissParentDialog
import org.koitharu.kotatsu.core.util.ext.findParentCallback
@@ -93,7 +94,7 @@ class PagesFragment :
addItemDecoration(TypedListSpacingDecoration(context, false))
adapter = thumbnailsAdapter
setHasFixedSize(true)
isNestedScrollingEnabled = false
PagerNestedScrollHelper(this).bind(viewLifecycleOwner)
addOnLayoutChangeListener(spanResolver)
addOnScrollListener(ScrollListener().also { scrollListener = it })
(layoutManager as GridLayoutManager).let {
@@ -117,17 +118,6 @@ class PagesFragment :
super.onDestroyView()
}
override fun onPause() {
// required for BottomSheetBehavior
requireViewBinding().recyclerView.isNestedScrollingEnabled = false
super.onPause()
}
override fun onResume() {
requireViewBinding().recyclerView.isNestedScrollingEnabled = true
super.onResume()
}
override fun onWindowInsetsChanged(insets: Insets) = Unit
override fun onItemClick(item: PageThumbnail, view: View) {

View File

@@ -5,9 +5,7 @@ import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.work.WorkInfo
import coil.ImageLoader
import coil.request.SuccessResult
@@ -62,7 +60,6 @@ fun downloadItemAD(
val chaptersAdapter = BaseListAdapter<DownloadChapter>()
.addDelegate(ListItemType.CHAPTER_LIST, downloadChapterAD())
binding.recyclerViewChapters.addItemDecoration(DividerItemDecoration(context, RecyclerView.VERTICAL))
binding.recyclerViewChapters.adapter = chaptersAdapter
binding.buttonCancel.setOnClickListener(clickListener)
binding.buttonPause.setOnClickListener(clickListener)

View File

@@ -322,7 +322,7 @@ class DownloadsViewModel @Inject constructor(
emit(mapChapters())
}
}
}.stateIn(viewModelScope, SharingStarted.Eagerly, null)
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)
private suspend fun tryLoad(manga: Manga) = runCatchingCancellable {
(mangaRepositoryFactory.create(manga.source) as RemoteMangaRepository).getDetails(manga)

View File

@@ -17,7 +17,6 @@ import org.koitharu.kotatsu.core.model.isNsfw
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.observeAsFlow
import org.koitharu.kotatsu.core.ui.util.ReversibleHandle
import org.koitharu.kotatsu.parsers.model.ContentType
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.mapToSet
import java.util.Collections
@@ -48,8 +47,17 @@ class MangaSourcesRepository @Inject constructor(
return dao.findAllEnabled(order).toSources(settings.isNsfwContentDisabled, order)
}
suspend fun getDisabledSources(): List<MangaSource> {
return dao.findAllDisabled().toSources(settings.isNsfwContentDisabled, null)
suspend fun getDisabledSources(): Set<MangaSource> {
val result = EnumSet.copyOf(remoteSources)
val enabled = dao.findAllEnabledNames()
for (name in enabled) {
val source = MangaSource(name)
result.remove(source)
}
if (settings.isNsfwContentDisabled) {
result.removeAll { it.isNsfw() }
}
return result
}
fun observeIsEnabled(source: MangaSource): Flow<Boolean> {
@@ -97,10 +105,10 @@ class MangaSourcesRepository @Inject constructor(
result
}
suspend fun setSourceEnabled(source: MangaSource, isEnabled: Boolean): ReversibleHandle {
dao.setEnabled(source.name, isEnabled)
suspend fun setSourcesEnabled(sources: Collection<MangaSource>, isEnabled: Boolean): ReversibleHandle {
setSourcesEnabledImpl(sources, isEnabled)
return ReversibleHandle {
dao.setEnabled(source.name, !isEnabled)
setSourcesEnabledImpl(sources, !isEnabled)
}
}
@@ -143,6 +151,7 @@ class MangaSourcesRepository @Inject constructor(
result
}.distinctUntilChanged()
} else {
assimilateNewSources()
flowOf(emptySet())
}
}
@@ -171,6 +180,18 @@ class MangaSourcesRepository @Inject constructor(
return dao.findAll().isEmpty()
}
private suspend fun setSourcesEnabledImpl(sources: Collection<MangaSource>, isEnabled: Boolean) {
if (sources.size == 1) { // fast path
dao.setEnabled(sources.first().name, isEnabled)
return
}
db.withTransaction {
for (source in sources) {
dao.setEnabled(source.name, isEnabled)
}
}
}
private suspend fun getNewSources(): MutableSet<MangaSource> {
val entities = dao.findAll()
val result = EnumSet.copyOf(remoteSources)
@@ -187,7 +208,7 @@ class MangaSourcesRepository @Inject constructor(
val result = ArrayList<MangaSource>(size)
for (entity in this) {
val source = MangaSource(entity.source)
if (skipNsfwSources && source.contentType == ContentType.HENTAI) {
if (skipNsfwSources && source.isNsfw()) {
continue
}
if (source in remoteSources) {

View File

@@ -4,11 +4,11 @@ import android.content.DialogInterface
import android.content.Intent
import android.os.Bundle
import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.widget.PopupMenu
import androidx.core.content.pm.ShortcutManagerCompat
import androidx.appcompat.view.ActionMode
import androidx.core.graphics.Insets
import androidx.core.view.updatePadding
import androidx.fragment.app.viewModels
@@ -17,22 +17,21 @@ import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import coil.ImageLoader
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.bookmarks.ui.BookmarksActivity
import org.koitharu.kotatsu.bookmarks.ui.AllBookmarksActivity
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
import org.koitharu.kotatsu.core.os.AppShortcutManager
import org.koitharu.kotatsu.core.ui.BaseFragment
import org.koitharu.kotatsu.core.ui.dialog.TwoButtonsAlertDialog
import org.koitharu.kotatsu.core.ui.list.ListSelectionController
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.ui.util.RecyclerViewOwner
import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver
import org.koitharu.kotatsu.core.ui.util.SpanSizeResolver
import org.koitharu.kotatsu.core.ui.widgets.TipView
import org.koitharu.kotatsu.core.util.ext.addMenuProvider
import org.koitharu.kotatsu.core.util.ext.findAppCompatDelegate
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.databinding.FragmentExploreBinding
import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.download.ui.list.DownloadsActivity
@@ -44,6 +43,7 @@ import org.koitharu.kotatsu.list.ui.model.ListHeader
import org.koitharu.kotatsu.list.ui.model.TipModel
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.search.ui.MangaListActivity
import org.koitharu.kotatsu.settings.SettingsActivity
import org.koitharu.kotatsu.settings.newsources.NewSourcesDialogFragment
@@ -56,16 +56,14 @@ class ExploreFragment :
BaseFragment<FragmentExploreBinding>(),
RecyclerViewOwner,
ExploreListEventListener,
OnListItemClickListener<MangaSourceItem>, TipView.OnButtonClickListener {
OnListItemClickListener<MangaSourceItem>, TipView.OnButtonClickListener, ListSelectionController.Callback2 {
@Inject
lateinit var coil: ImageLoader
@Inject
lateinit var shortcutManager: AppShortcutManager
private val viewModel by viewModels<ExploreViewModel>()
private var exploreAdapter: ExploreAdapter? = null
private var sourceSelectionController: ListSelectionController? = null
override val recyclerView: RecyclerView
get() = requireViewBinding().recyclerView
@@ -79,11 +77,18 @@ class ExploreFragment :
exploreAdapter = ExploreAdapter(coil, viewLifecycleOwner, this, this, this) { manga, view ->
startActivity(DetailsActivity.newIntent(view.context, manga))
}
sourceSelectionController = ListSelectionController(
appCompatDelegate = checkNotNull(findAppCompatDelegate()),
decoration = SourceSelectionDecoration(binding.root.context),
registryOwner = this,
callback = this,
)
with(binding.recyclerView) {
adapter = exploreAdapter
setHasFixedSize(true)
SpanSizeResolver(this, resources.getDimensionPixelSize(R.dimen.explore_grid_width)).attach()
addItemDecoration(TypedListSpacingDecoration(context, false))
checkNotNull(sourceSelectionController).attachToRecyclerView(this)
}
addMenuProvider(ExploreMenuProvider(binding.root.context))
viewModel.content.observe(viewLifecycleOwner) {
@@ -100,6 +105,7 @@ class ExploreFragment :
override fun onDestroyView() {
super.onDestroyView()
sourceSelectionController = null
exploreAdapter = null
}
@@ -133,7 +139,7 @@ class ExploreFragment :
override fun onClick(v: View) {
val intent = when (v.id) {
R.id.button_local -> MangaListActivity.newIntent(v.context, MangaSource.LOCAL)
R.id.button_bookmarks -> BookmarksActivity.newIntent(v.context)
R.id.button_bookmarks -> AllBookmarksActivity.newIntent(v.context)
R.id.button_more -> SuggestionsActivity.newIntent(v.context)
R.id.button_downloads -> DownloadsActivity.newIntent(v.context)
R.id.button_random -> {
@@ -147,18 +153,15 @@ class ExploreFragment :
}
override fun onItemClick(item: MangaSourceItem, view: View) {
if (sourceSelectionController?.onItemClick(item.id) == true) {
return
}
val intent = MangaListActivity.newIntent(view.context, item.source)
startActivity(intent)
}
override fun onItemLongClick(item: MangaSourceItem, view: View): Boolean {
val menu = PopupMenu(view.context, view)
menu.inflate(R.menu.popup_source)
menu.menu.findItem(R.id.action_shortcut)
?.isVisible = ShortcutManagerCompat.isRequestPinShortcutSupported(view.context)
menu.setOnMenuItemClickListener(SourceMenuListener(item))
menu.show()
return true
return sourceSelectionController?.onItemLongClick(item.id) ?: false
}
override fun onRetryClick(error: Throwable) = Unit
@@ -167,6 +170,52 @@ class ExploreFragment :
startActivity(Intent(context ?: return, SourcesCatalogActivity::class.java))
}
override fun onSelectionChanged(controller: ListSelectionController, count: Int) {
viewBinding?.recyclerView?.invalidateItemDecorations()
}
override fun onCreateActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean {
mode.menuInflater.inflate(R.menu.mode_source, menu)
return true
}
override fun onPrepareActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean {
val isSingleSelection = controller.count == 1
menu.findItem(R.id.action_settings).isVisible = isSingleSelection
menu.findItem(R.id.action_shortcut).isVisible = isSingleSelection
return super.onPrepareActionMode(controller, mode, menu)
}
override fun onActionItemClicked(controller: ListSelectionController, mode: ActionMode, item: MenuItem): Boolean {
val selectedSources = controller.peekCheckedIds().mapNotNullToSet { id ->
MangaSource.entries.getOrNull(id.toInt())
}
if (selectedSources.isEmpty()) {
return false
}
when (item.itemId) {
R.id.action_settings -> {
val source = selectedSources.singleOrNull() ?: return false
startActivity(SettingsActivity.newSourceSettingsIntent(requireContext(), source))
mode.finish()
}
R.id.action_disable -> {
viewModel.disableSources(selectedSources)
mode.finish()
}
R.id.action_shortcut -> {
val source = selectedSources.singleOrNull() ?: return false
viewModel.requestPinShortcut(source)
mode.finish()
}
else -> return false
}
return true
}
private fun onOpenManga(manga: Manga) {
val intent = DetailsActivity.newIntent(context ?: return, manga)
startActivity(intent)
@@ -194,30 +243,4 @@ class ExploreFragment :
.create()
.show()
}
private inner class SourceMenuListener(
private val sourceItem: MangaSourceItem,
) : PopupMenu.OnMenuItemClickListener {
override fun onMenuItemClick(item: MenuItem): Boolean {
when (item.itemId) {
R.id.action_settings -> {
startActivity(SettingsActivity.newSourceSettingsIntent(requireContext(), sourceItem.source))
}
R.id.action_hide -> {
viewModel.hideSource(sourceItem.source)
}
R.id.action_shortcut -> {
viewLifecycleScope.launch {
shortcutManager.requestPinShortcut(sourceItem.source)
}
}
else -> return false
}
return true
}
}
}

View File

@@ -13,6 +13,7 @@ import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.plus
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.os.AppShortcutManager
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.observeAsFlow
import org.koitharu.kotatsu.core.prefs.observeAsStateFlow
@@ -43,6 +44,7 @@ class ExploreViewModel @Inject constructor(
private val suggestionRepository: SuggestionRepository,
private val exploreRepository: ExploreRepository,
private val sourcesRepository: MangaSourcesRepository,
private val shortcutManager: AppShortcutManager,
) : BaseViewModel() {
val isGrid = settings.observeAsStateFlow(
@@ -92,10 +94,11 @@ class ExploreViewModel @Inject constructor(
}
}
fun hideSource(source: MangaSource) {
fun disableSources(sources: Collection<MangaSource>) {
launchJob(Dispatchers.Default) {
val rollback = sourcesRepository.setSourceEnabled(source, isEnabled = false)
onActionDone.call(ReversibleAction(R.string.source_disabled, rollback))
val rollback = sourcesRepository.setSourcesEnabled(sources, isEnabled = false)
val message = if (sources.size == 1) R.string.source_disabled else R.string.sources_disabled
onActionDone.call(ReversibleAction(message, rollback))
}
}
@@ -105,6 +108,12 @@ class ExploreViewModel @Inject constructor(
}
}
fun requestPinShortcut(source: MangaSource) {
launchLoadingJob(Dispatchers.Default) {
shortcutManager.requestPinShortcut(source)
}
}
fun respondSuggestionTip(isAccepted: Boolean) {
settings.isSuggestionsEnabled = isAccepted
settings.closeTip(TIP_SUGGESTIONS)

View File

@@ -0,0 +1,56 @@
package org.koitharu.kotatsu.explore.ui
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.RectF
import android.view.View
import androidx.core.graphics.ColorUtils
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.NO_ID
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.list.decor.AbstractSelectionItemDecoration
import org.koitharu.kotatsu.core.util.ext.getItem
import org.koitharu.kotatsu.core.util.ext.getThemeColor
import org.koitharu.kotatsu.explore.ui.model.MangaSourceItem
import com.google.android.material.R as materialR
class SourceSelectionDecoration(context: Context) : AbstractSelectionItemDecoration() {
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
private val strokeColor = context.getThemeColor(materialR.attr.colorPrimary, Color.RED)
private val fillColor = ColorUtils.setAlphaComponent(
ColorUtils.blendARGB(strokeColor, context.getThemeColor(materialR.attr.colorSurface), 0.8f),
0x74,
)
private val defaultRadius = context.resources.getDimension(R.dimen.list_selector_corner)
init {
hasBackground = false
hasForeground = true
isIncludeDecorAndMargins = false
paint.strokeWidth = context.resources.getDimension(R.dimen.selection_stroke_width)
}
override fun getItemId(parent: RecyclerView, child: View): Long {
val holder = parent.getChildViewHolder(child) ?: return NO_ID
val item = holder.getItem(MangaSourceItem::class.java) ?: return NO_ID
return item.id
}
override fun onDrawForeground(
canvas: Canvas,
parent: RecyclerView,
child: View,
bounds: RectF,
state: RecyclerView.State,
) {
paint.color = fillColor
paint.style = Paint.Style.FILL
canvas.drawRoundRect(bounds, defaultRadius, defaultRadius, paint)
paint.color = strokeColor
paint.style = Paint.Style.STROKE
canvas.drawRoundRect(bounds, defaultRadius, defaultRadius, paint)
}
}

View File

@@ -85,7 +85,6 @@ fun recommendationMangaItemAD(
binding.root.setOnClickListener { v ->
itemClickListener.onItemClick(item.manga, v)
}
bind {
binding.textViewTitle.text = item.manga.title
binding.textViewSubtitle.textAndVisible = item.subtitle

View File

@@ -8,6 +8,9 @@ data class MangaSourceItem(
val isGrid: Boolean,
) : ListModel {
val id: Long
get() = source.ordinal.toLong()
override fun areItemsTheSame(other: ListModel): Boolean {
return other is MangaSourceItem && other.source == source
}

View File

@@ -20,7 +20,7 @@ abstract class FavouriteCategoriesDao {
abstract fun observeAll(): Flow<List<FavouriteCategoryEntity>>
@Query("SELECT * FROM favourite_categories WHERE deleted_at = 0 AND show_in_lib = 1 ORDER BY sort_key")
abstract fun observeAllForLibrary(): Flow<List<FavouriteCategoryEntity>>
abstract fun observeAllVisible(): Flow<List<FavouriteCategoryEntity>>
@Query("SELECT * FROM favourite_categories WHERE category_id = :id AND deleted_at = 0")
abstract fun observe(id: Long): Flow<FavouriteCategoryEntity?>
@@ -40,7 +40,7 @@ abstract class FavouriteCategoriesDao {
abstract suspend fun updateTracking(id: Long, isEnabled: Boolean)
@Query("UPDATE favourite_categories SET `show_in_lib` = :isEnabled WHERE category_id = :id")
abstract suspend fun updateLibVisibility(id: Long, isEnabled: Boolean)
abstract suspend fun updateVisibility(id: Long, isEnabled: Boolean)
@Query("UPDATE favourite_categories SET sort_key = :sortKey WHERE category_id = :id")
abstract suspend fun updateSortKey(id: Long, sortKey: Int)

View File

@@ -11,7 +11,6 @@ import androidx.sqlite.db.SimpleSQLiteQuery
import androidx.sqlite.db.SupportSQLiteQuery
import kotlinx.coroutines.flow.Flow
import org.intellij.lang.annotations.Language
import org.koitharu.kotatsu.core.db.entity.MangaEntity
import org.koitharu.kotatsu.favourites.domain.model.Cover
import org.koitharu.kotatsu.list.domain.ListSortOrder
@@ -39,13 +38,6 @@ abstract class FavouritesDao {
return observeAllImpl(query)
}
@Transaction
@Query(
"SELECT * FROM favourites WHERE deleted_at = 0 " +
"GROUP BY manga_id ORDER BY created_at DESC LIMIT :limit OFFSET :offset",
)
abstract suspend fun findAll(offset: Int, limit: Int): List<FavouriteManga>
@Transaction
@Query("SELECT * FROM favourites WHERE deleted_at = 0 ORDER BY created_at DESC LIMIT :limit OFFSET :offset")
abstract suspend fun findAllRaw(offset: Int, limit: Int): List<FavouriteManga>
@@ -72,19 +64,6 @@ abstract class FavouritesDao {
return observeAllImpl(query)
}
@Transaction
@Query(
"SELECT * FROM favourites WHERE category_id = :categoryId AND deleted_at = 0 " +
"GROUP BY manga_id ORDER BY created_at DESC LIMIT :limit OFFSET :offset",
)
abstract suspend fun findAll(categoryId: Long, offset: Int, limit: Int): List<FavouriteManga>
@Query(
"SELECT * FROM manga WHERE manga_id IN " +
"(SELECT manga_id FROM favourites WHERE category_id = :categoryId AND deleted_at = 0)",
)
abstract suspend fun findAllManga(categoryId: Int): List<MangaEntity>
suspend fun findCovers(categoryId: Long, order: ListSortOrder): List<Cover> {
val orderBy = getOrderBy(order)
@@ -114,21 +93,9 @@ abstract class FavouritesDao {
@Query("SELECT COUNT(DISTINCT manga_id) FROM favourites WHERE deleted_at = 0")
abstract fun observeMangaCount(): Flow<Int>
@Query("SELECT * FROM manga WHERE manga_id IN (SELECT manga_id FROM favourites WHERE deleted_at = 0)")
abstract suspend fun findAllManga(): List<MangaEntity>
@Transaction
@Query("SELECT * FROM favourites WHERE manga_id = :id AND deleted_at = 0 GROUP BY manga_id")
abstract suspend fun find(id: Long): FavouriteManga?
@Query("SELECT * FROM favourites WHERE manga_id = :mangaId AND deleted_at = 0")
abstract suspend fun findAllRaw(mangaId: Long): List<FavouriteEntity>
@Transaction
@Deprecated("Ignores order")
@Query("SELECT * FROM favourites WHERE manga_id = :id AND deleted_at = 0 GROUP BY manga_id")
abstract fun observe(id: Long): Flow<FavouriteManga?>
@Query("SELECT DISTINCT category_id FROM favourites WHERE manga_id = :id AND deleted_at = 0")
abstract fun observeIds(id: Long): Flow<List<Long>>
@@ -138,9 +105,6 @@ abstract class FavouritesDao {
@Query("SELECT DISTINCT category_id FROM favourites WHERE manga_id IN (:mangaIds) AND deleted_at = 0 ORDER BY favourites.created_at ASC")
abstract suspend fun findCategoriesIds(mangaIds: Collection<Long>): List<Long>
@Query("SELECT DISTINCT favourite_categories.category_id FROM favourites LEFT JOIN favourite_categories ON favourites.category_id = favourite_categories.category_id WHERE manga_id = :mangaId AND favourites.deleted_at = 0 AND favourite_categories.deleted_at = 0 AND favourite_categories.track = 1")
abstract suspend fun findCategoriesIdsWithTrack(mangaId: Long): List<Long>
/** INSERT **/
@Insert(onConflict = OnConflictStrategy.REPLACE)
@@ -194,7 +158,7 @@ abstract class FavouritesDao {
protected abstract suspend fun setDeletedAt(mangaId: Long, deletedAt: Long)
@Query("UPDATE favourites SET deleted_at = :deletedAt WHERE manga_id = :mangaId AND category_id = :categoryId")
abstract suspend fun setDeletedAt(categoryId: Long, mangaId: Long, deletedAt: Long)
protected abstract suspend fun setDeletedAt(categoryId: Long, mangaId: Long, deletedAt: Long)
@Query("UPDATE favourites SET deleted_at = :deletedAt WHERE category_id = :categoryId AND deleted_at = 0")
protected abstract suspend fun setDeletedAtAll(categoryId: Long, deletedAt: Long)

View File

@@ -76,7 +76,7 @@ class FavouritesRepository @Inject constructor(
}
fun observeCategoriesForLibrary(): Flow<List<FavouriteCategory>> {
return db.getFavouriteCategoriesDao().observeAllForLibrary().mapItems {
return db.getFavouriteCategoriesDao().observeAllVisible().mapItems {
it.toFavouriteCategory()
}.distinctUntilChanged()
}
@@ -157,7 +157,7 @@ class FavouritesRepository @Inject constructor(
}
suspend fun updateCategory(id: Long, isVisibleInLibrary: Boolean) {
db.getFavouriteCategoriesDao().updateLibVisibility(id, isVisibleInLibrary)
db.getFavouriteCategoriesDao().updateVisibility(id, isVisibleInLibrary)
}
suspend fun updateCategoryTracking(id: Long, isTrackingEnabled: Boolean) {

View File

@@ -1,7 +1,5 @@
package org.koitharu.kotatsu.favourites.ui.categories
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.View
import android.view.ViewGroup
@@ -177,10 +175,4 @@ class FavouriteCategoriesActivity :
viewModel.saveOrder(adapter.items ?: return)
}
}
@Deprecated("")
companion object {
fun newIntent(context: Context) = Intent(context, FavouriteCategoriesActivity::class.java)
}
}

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.favourites.ui.categories.select.adapter
import android.content.Intent
import android.view.View
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R
@@ -16,7 +17,7 @@ fun categoriesHeaderAD() = adapterDelegateViewBinding<CategoriesHeaderItem, List
val onClickListener = View.OnClickListener { v ->
val intent = when (v.id) {
R.id.chip_create -> FavouritesCategoryEditActivity.newIntent(v.context)
R.id.chip_manage -> FavouriteCategoriesActivity.newIntent(v.context)
R.id.chip_manage -> Intent(v.context, FavouriteCategoriesActivity::class.java)
else -> return@OnClickListener
}
v.context.startActivity(intent)

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.favourites.ui.container
import android.content.Intent
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
@@ -102,7 +103,7 @@ class FavouritesContainerFragment : BaseFragment<FragmentFavouritesContainerBind
override fun onClick(v: View) {
when (v.id) {
R.id.button_retry -> startActivity(
FavouriteCategoriesActivity.newIntent(v.context),
Intent(v.context, FavouriteCategoriesActivity::class.java),
)
}
}

View File

@@ -1,6 +1,7 @@
package org.koitharu.kotatsu.favourites.ui.container
import android.content.Context
import android.content.Intent
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
@@ -19,7 +20,7 @@ class FavouritesContainerMenuProvider(
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
when (menuItem.itemId) {
R.id.action_manage -> {
context.startActivity(FavouriteCategoriesActivity.newIntent(context))
context.startActivity(Intent(context, FavouriteCategoriesActivity::class.java))
}
else -> return false

View File

@@ -10,7 +10,6 @@ import androidx.sqlite.db.SimpleSQLiteQuery
import androidx.sqlite.db.SupportSQLiteQuery
import kotlinx.coroutines.flow.Flow
import org.intellij.lang.annotations.Language
import org.koitharu.kotatsu.core.db.entity.MangaEntity
import org.koitharu.kotatsu.core.db.entity.TagEntity
import org.koitharu.kotatsu.list.domain.ListSortOrder
@@ -21,10 +20,6 @@ abstract class HistoryDao {
@Query("SELECT * FROM history WHERE deleted_at = 0 ORDER BY updated_at DESC LIMIT :limit OFFSET :offset")
abstract suspend fun findAll(offset: Int, limit: Int): List<HistoryWithManga>
@Transaction
@Query("SELECT * FROM history WHERE deleted_at = 0 AND manga_id IN (:ids)")
abstract suspend fun findAll(ids: Collection<Long>): List<HistoryEntity>
@Transaction
@Query("SELECT * FROM history WHERE deleted_at = 0 ORDER BY updated_at DESC")
abstract fun observeAll(): Flow<List<HistoryWithManga>>
@@ -33,6 +28,7 @@ abstract class HistoryDao {
@Query("SELECT * FROM history WHERE deleted_at = 0 ORDER BY updated_at DESC LIMIT :limit")
abstract fun observeAll(limit: Int): Flow<List<HistoryWithManga>>
// TODO pagination
fun observeAll(order: ListSortOrder): Flow<List<HistoryWithManga>> {
val orderBy = when (order) {
ListSortOrder.LAST_READ -> "history.updated_at DESC"
@@ -56,9 +52,6 @@ abstract class HistoryDao {
return observeAllImpl(query)
}
@Query("SELECT * FROM manga WHERE manga_id IN (SELECT manga_id FROM history WHERE deleted_at = 0)")
abstract suspend fun findAllManga(): List<MangaEntity>
@Query("SELECT manga_id FROM history WHERE deleted_at = 0")
abstract suspend fun findAllIds(): LongArray

View File

@@ -4,14 +4,14 @@ import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import com.google.android.material.badge.BadgeDrawable
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.databinding.ItemHeaderButtonBinding
import org.koitharu.kotatsu.databinding.ItemHeaderBinding
import org.koitharu.kotatsu.list.ui.model.ListHeader
import org.koitharu.kotatsu.list.ui.model.ListModel
fun listHeaderAD(
listener: ListHeaderClickListener?,
) = adapterDelegateViewBinding<ListHeader, ListModel, ItemHeaderButtonBinding>(
{ inflater, parent -> ItemHeaderButtonBinding.inflate(inflater, parent, false) },
) = adapterDelegateViewBinding<ListHeader, ListModel, ItemHeaderBinding>(
{ inflater, parent -> ItemHeaderBinding.inflate(inflater, parent, false) },
) {
var badge: BadgeDrawable? = null

View File

@@ -82,7 +82,7 @@ class ListConfigViewModel @Inject constructor(
ListConfigSection.General -> null
ListConfigSection.Updated -> null
ListConfigSection.History -> settings.historySortOrder
ListConfigSection.Suggestions -> ListSortOrder.RELEVANCE // TODO
ListConfigSection.Suggestions -> ListSortOrder.RELEVANCE
}
fun setSortOrder(position: Int) {

View File

@@ -150,9 +150,9 @@ class LocalMangaRepository @Inject constructor(
return channelFlow {
for (file in files) {
launch {
val mangaInput = LocalMangaInput.of(file)
val mangaInput = LocalMangaInput.ofOrNull(file)
runCatchingCancellable {
val mangaInfo = mangaInput.getMangaInfo()
val mangaInfo = mangaInput?.getMangaInfo()
if (mangaInfo != null && mangaInfo.id == remoteManga.id) {
send(mangaInput)
}

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