Compare commits

...

76 Commits

Author SHA1 Message Date
42d933ba83 Bump version 2023-09-01 13:17:07 +03:00
4df644e21f Fix branch prediction 2023-09-01 12:02:31 +03:00
ViAnh
e4ba738c00 Use WeakHashMap to store views 2023-08-31 19:35:53 +03:00
ViAnh
b7f09243aa Avoid unnecessary child layout in webtoon recycler 2023-08-31 19:35:53 +03:00
ViAnh
50d4c41855 Fix webtoon under scale 2023-08-31 19:35:53 +03:00
67adc8b681 Fix widgets in dark theme 2023-08-31 19:28:25 +03:00
34fb4af9fe Fix color scheme preference 2023-08-31 19:11:29 +03:00
05241f73d9 Improve categories managing 2023-08-31 19:11:29 +03:00
d666e4b967 Fix small webtoon pages 2023-08-31 19:11:29 +03:00
b4bf607d3a Merge pull request #470 from Isira-Seneviratne/Data_classes 2023-08-31 09:17:03 +03:00
Isira Seneviratne
a417d5aaa9 Apply requested changes 2023-08-30 19:27:34 +05:30
4b6b2c3e12 Fix favorites selector 2023-08-30 14:43:57 +03:00
51300e30bd Improve favicon loading 2023-08-30 14:41:44 +03:00
399ac07fb3 Fix storage usage calculation 2023-08-30 14:21:09 +03:00
Eryk Michalak
eeba161235 Translated using Weblate (Polish)
Currently translated at 100.0% (7 of 7 strings)

Translated using Weblate (Polish)

Currently translated at 95.5% (455 of 476 strings)

Co-authored-by: Eryk Michalak <gnu.ewm@protonmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/pl/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pl/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2023-08-30 12:45:08 +03:00
Nayuki
088a388812 Translated using Weblate (Thai)
Currently translated at 49.5% (236 of 476 strings)

Translated using Weblate (Thai)

Currently translated at 42.8% (204 of 476 strings)

Co-authored-by: Nayuki <me@nayuki.cyou>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/th/
Translation: Kotatsu/Strings
2023-08-30 12:45:08 +03:00
gallegonovato
943bba3ee8 Translated using Weblate (Spanish)
Currently translated at 100.0% (476 of 476 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2023-08-30 12:45:08 +03:00
18c3229200 Handle TooManyRequestsException during downloading 2023-08-29 20:23:20 +03:00
9b6f511ac6 Do not discard image requests in onViewRecycled 2023-08-29 17:35:00 +03:00
Isira Seneviratne
ad3b5dde91 Convert more classes to data classes 2023-08-27 07:10:32 +05:30
74ca19a931 Improve widgets ui #457 2023-08-26 18:35:49 +03:00
2684a7384e Restore covers using interceptor 2023-08-26 16:44:09 +03:00
2c561824ef Fix default reader mode option #468 #466 2023-08-25 13:26:48 +03:00
61e9796269 Fix amoled theme 2023-08-24 14:43:49 +03:00
54597eb8f0 Udpate parsers 2023-08-24 14:43:49 +03:00
Dpper
e07ea0552f Translated using Weblate (Ukrainian)
Currently translated at 100.0% (476 of 476 strings)

Co-authored-by: Dpper <ruslan20020401@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/uk/
Translation: Kotatsu/Strings
2023-08-24 13:50:13 +03:00
b8e90719ce Translated using Weblate (Russian)
Currently translated at 100.0% (476 of 476 strings)

Co-authored-by: Koitharu <nvasya95@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translation: Kotatsu/Strings
2023-08-24 13:50:13 +03:00
Reza Almanda
2ec716973c Translated using Weblate (Indonesian)
Currently translated at 100.0% (476 of 476 strings)

Translated using Weblate (Indonesian)

Currently translated at 100.0% (475 of 475 strings)

Co-authored-by: Reza Almanda <rezaalmanda27@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/id/
Translation: Kotatsu/Strings
2023-08-24 13:50:13 +03:00
J. Lavoie
0000c97e6a Translated using Weblate (French)
Currently translated at 100.0% (473 of 473 strings)

Co-authored-by: J. Lavoie <j.lavoie@net-c.ca>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fr/
Translation: Kotatsu/Strings
2023-08-24 13:50:13 +03:00
Nayuki
70684de683 Translated using Weblate (Thai)
Currently translated at 42.9% (203 of 473 strings)

Co-authored-by: Nayuki <me@nayuki.cyou>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/th/
Translation: Kotatsu/Strings
2023-08-24 13:50:13 +03:00
gallegonovato
5a2288eb2d Translated using Weblate (Spanish)
Currently translated at 100.0% (473 of 473 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2023-08-24 13:50:13 +03:00
Макар Разин
6a023fa976 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (475 of 475 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (475 of 475 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (475 of 475 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (473 of 473 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (473 of 473 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
2023-08-24 13:50:13 +03:00
3cc0fbe7bc Update UI 2023-08-23 20:42:40 +03:00
b3b022807a Fix sources reordering 2023-08-23 17:07:14 +03:00
ba0ea5a9fc UI fixes 2023-08-22 16:58:31 +03:00
ca1380e2b1 Refactor application class 2023-08-22 16:18:34 +03:00
05dbd11fc1 Restore covers only once 2023-08-22 13:38:20 +03:00
aa650d44d3 Revert "Limit TrackWorker parallelistm when device in use"
This reverts commit 0778f34db7.
2023-08-22 13:28:38 +03:00
99b698ad12 Improve theme chooser 2023-08-22 13:13:01 +03:00
2f9c2d9ab6 Option to swap first two navigation items 2023-08-22 12:11:09 +03:00
ab753787b0 Fix page loading retry 2023-08-21 17:33:43 +03:00
478ca351eb Merge pull request #463 from thonsi/devel 2023-08-21 10:49:12 +03:00
Thonsi
559b2cfd64 update gradles to latest 2023-08-20 20:55:49 +05:30
a1554f81ff Fix cover restoration 2023-08-19 18:02:22 +03:00
4ce7f74b9d Update parsers and gradle 2023-08-19 17:44:16 +03:00
0778f34db7 Limit TrackWorker parallelistm when device in use 2023-08-19 15:59:58 +03:00
bc488a6878 Improve search suggestions 2023-08-19 15:11:22 +03:00
856524c9f8 Revert "Remove WorkManager bug workaround"
This reverts commit af654b8c40.
2023-08-19 13:30:30 +03:00
ppg00
c75160d83c Translated using Weblate (Arabic)
Currently translated at 16.9% (80 of 473 strings)

Translated using Weblate (Arabic)

Currently translated at 28.5% (2 of 7 strings)

Co-authored-by: ppg00 <vx2dsk@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/ar/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ar/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2023-08-18 16:59:59 +03:00
d9c826524f Translated using Weblate (Russian)
Currently translated at 100.0% (473 of 473 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (473 of 473 strings)

Co-authored-by: Koitharu <nvasya95@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translation: Kotatsu/Strings
2023-08-18 16:59:59 +03:00
Christian Elbrianno
40196205eb Translated using Weblate (Indonesian)
Currently translated at 100.0% (473 of 473 strings)

Translated using Weblate (Indonesian)

Currently translated at 100.0% (7 of 7 strings)

Co-authored-by: Christian Elbrianno <crse@protonmail.ch>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/id/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/id/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2023-08-18 16:59:59 +03:00
Dpper
681ac492d6 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (473 of 473 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (473 of 473 strings)

Co-authored-by: Dpper <ruslan20020401@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/uk/
Translation: Kotatsu/Strings
2023-08-18 16:59:59 +03:00
Nayuki
95f4606661 Translated using Weblate (Thai)
Currently translated at 26.6% (126 of 472 strings)

Translated using Weblate (Thai)

Currently translated at 100.0% (7 of 7 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
2023-08-18 16:59:59 +03:00
gallegonovato
b406834d4a Translated using Weblate (Spanish)
Currently translated at 100.0% (473 of 473 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (472 of 472 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2023-08-18 16:59:59 +03:00
InfinityDouki56
2693ec5335 Translated using Weblate (Filipino)
Currently translated at 90.6% (427 of 471 strings)

Co-authored-by: InfinityDouki56 <ced.paltep10@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fil/
Translation: Kotatsu/Strings
2023-08-18 16:59:59 +03:00
ead8a3d6df Restore broken covers and bookmark previews 2023-08-18 16:52:14 +03:00
df04dcc8a3 Show error on ImageActivity 2023-08-18 15:40:56 +03:00
c103773c19 Fix handling favicon loading errors 2023-08-18 15:19:08 +03:00
0a28f131ee Update strings 2023-08-18 15:12:49 +03:00
c8e4842b6e Unify list spacings 2023-08-18 14:33:30 +03:00
d54d489494 Fixes 2023-08-18 12:59:45 +03:00
c77cb4cb3c Fix sources migration 2023-08-18 10:13:56 +03:00
d951306a90 Fix MangaListActivity header scrim 2023-08-18 10:12:12 +03:00
c871757893 Fix colorBackground attribute 2023-08-18 09:43:26 +03:00
64bf671e8b Merge remote-tracking branch 'origin/devel' into devel 2023-08-18 07:50:00 +03:00
b00bc22ead Update UI 2023-08-18 07:49:29 +03:00
fdea2b47da Fix sources migration 2023-08-17 17:10:41 +03:00
91266183c2 Hide categories notification indicators if tracking disabled 2023-08-17 17:00:48 +03:00
20a7e5a6a8 Handle offline mode in history list 2023-08-15 17:02:39 +03:00
6fa99791b6 Handle manga links 2023-08-15 12:46:46 +03:00
4090c8ad6a Fix crash on bookmarks 2023-08-15 10:14:11 +03:00
c72ed7cc34 Update dependencies and Gradle 2023-08-15 10:04:27 +03:00
925c24471e Upgrade targetSdk to 34 2023-08-14 15:10:08 +03:00
7e31b1384e Option to disable related manga 2023-08-14 14:18:09 +03:00
e1efb5fb1e Fix tests 2023-08-14 13:44:55 +03:00
29b5655efb Update screenshots 2023-08-14 13:15:56 +03:00
293 changed files with 3142 additions and 1784 deletions

View File

@@ -8,17 +8,16 @@ plugins {
} }
android { android {
compileSdk = 33 compileSdk = 34
// compileSdkExtension = 4
buildToolsVersion = '34.0.0' buildToolsVersion = '34.0.0'
namespace = 'org.koitharu.kotatsu' namespace = 'org.koitharu.kotatsu'
defaultConfig { defaultConfig {
applicationId 'org.koitharu.kotatsu' applicationId 'org.koitharu.kotatsu'
minSdkVersion 21 minSdk = 21
targetSdkVersion 33 targetSdk = 34
versionCode 570 versionCode = 575
versionName '6.0-a1' versionName = '6.0.2'
generatedDensities = [] generatedDensities = []
testInstrumentationRunner "org.koitharu.kotatsu.HiltTestRunner" testInstrumentationRunner "org.koitharu.kotatsu.HiltTestRunner"
ksp { ksp {
@@ -82,11 +81,11 @@ afterEvaluate {
} }
dependencies { dependencies {
//noinspection GradleDependency //noinspection GradleDependency
implementation('com.github.KotatsuApp:kotatsu-parsers:407ef5b655') { implementation('com.github.KotatsuApp:kotatsu-parsers:3a76504380') {
exclude group: 'org.json', module: 'json' exclude group: 'org.json', module: 'json'
} }
implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.9.0' implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.9.10'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3' implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3'
implementation 'androidx.appcompat:appcompat:1.6.1' implementation 'androidx.appcompat:appcompat:1.6.1'
@@ -121,7 +120,7 @@ dependencies {
implementation 'com.squareup.okhttp3:okhttp:4.11.0' implementation 'com.squareup.okhttp3:okhttp:4.11.0'
implementation 'com.squareup.okhttp3:okhttp-dnsoverhttps:4.11.0' implementation 'com.squareup.okhttp3:okhttp-dnsoverhttps:4.11.0'
implementation 'com.squareup.okio:okio:3.4.0' implementation 'com.squareup.okio:okio:3.5.0'
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.2' implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.2'
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:4.3.2' implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:4.3.2'
@@ -137,8 +136,8 @@ dependencies {
implementation 'com.github.solkin:disk-lru-cache:1.4' implementation 'com.github.solkin:disk-lru-cache:1.4'
implementation 'io.noties.markwon:core:4.6.2' implementation 'io.noties.markwon:core:4.6.2'
implementation 'ch.acra:acra-http:5.11.0' implementation 'ch.acra:acra-http:5.11.1'
implementation 'ch.acra:acra-dialog:5.11.0' implementation 'ch.acra:acra-dialog:5.11.1'
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.12' debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.12'

View File

@@ -0,0 +1,45 @@
package org.koitharu.kotatsu
import android.content.Context
import android.os.StrictMode
import androidx.fragment.app.strictmode.FragmentStrictMode
import org.koitharu.kotatsu.core.BaseApp
import org.koitharu.kotatsu.local.data.LocalMangaRepository
import org.koitharu.kotatsu.local.data.PagesCache
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.reader.domain.PageLoader
class KotatsuApp : BaseApp() {
override fun attachBaseContext(base: Context?) {
super.attachBaseContext(base)
enableStrictMode()
}
private fun enableStrictMode() {
StrictMode.setThreadPolicy(
StrictMode.ThreadPolicy.Builder()
.detectAll()
.penaltyLog()
.build(),
)
StrictMode.setVmPolicy(
StrictMode.VmPolicy.Builder()
.detectAll()
.setClassInstanceLimit(LocalMangaRepository::class.java, 1)
.setClassInstanceLimit(PagesCache::class.java, 1)
.setClassInstanceLimit(MangaLoaderContext::class.java, 1)
.setClassInstanceLimit(PageLoader::class.java, 1)
.penaltyLog()
.build(),
)
FragmentStrictMode.defaultPolicy = FragmentStrictMode.Policy.Builder()
.penaltyDeath()
.detectFragmentReuse()
// .detectWrongFragmentContainer() FIXME: migrate to ViewPager2
.detectRetainInstanceUsage()
.detectSetUserVisibleHint()
.detectFragmentTagUsage()
.build()
}
}

View File

@@ -18,6 +18,7 @@
<uses-permission android:name="android.permission.READ_SYNC_SETTINGS" /> <uses-permission android:name="android.permission.READ_SYNC_SETTINGS" />
<uses-permission android:name="android.permission.WRITE_SYNC_SETTINGS" /> <uses-permission android:name="android.permission.WRITE_SYNC_SETTINGS" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission <uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="29" /> android:maxSdkVersion="29" />
@@ -70,6 +71,17 @@
<intent-filter> <intent-filter>
<action android:name="${applicationId}.action.VIEW_MANGA" /> <action android:name="${applicationId}.action.VIEW_MANGA" />
</intent-filter> </intent-filter>
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="http" />
<data android:scheme="https" />
<data android:host="kotatsu.app" />
<data android:path="/manga" />
</intent-filter>
</activity> </activity>
<activity <activity
android:name="org.koitharu.kotatsu.reader.ui.ReaderActivity" android:name="org.koitharu.kotatsu.reader.ui.ReaderActivity"
@@ -136,13 +148,21 @@
android:name="org.koitharu.kotatsu.favourites.ui.categories.FavouriteCategoriesActivity" android:name="org.koitharu.kotatsu.favourites.ui.categories.FavouriteCategoriesActivity"
android:label="@string/manage_categories" /> android:label="@string/manage_categories" />
<activity <activity
android:name="org.koitharu.kotatsu.widget.shelf.ShelfConfigActivity" android:name="org.koitharu.kotatsu.widget.shelf.ShelfWidgetConfigActivity"
android:exported="true" android:exported="true"
android:label="@string/manga_shelf"> android:label="@string/manga_shelf">
<intent-filter> <intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_CONFIGURE" /> <action android:name="android.appwidget.action.APPWIDGET_CONFIGURE" />
</intent-filter> </intent-filter>
</activity> </activity>
<activity
android:name="org.koitharu.kotatsu.widget.recent.RecentWidgetConfigActivity"
android:exported="true"
android:label="@string/recent_manga">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_CONFIGURE" />
</intent-filter>
</activity>
<activity <activity
android:name="org.koitharu.kotatsu.search.ui.multi.MultiSearchActivity" android:name="org.koitharu.kotatsu.search.ui.multi.MultiSearchActivity"
android:label="@string/search" /> android:label="@string/search" />
@@ -185,7 +205,13 @@
</activity> </activity>
<service android:name="org.koitharu.kotatsu.local.ui.LocalChaptersRemoveService" /> <service
android:name="androidx.work.impl.foreground.SystemForegroundService"
android:foregroundServiceType="dataSync"
tools:node="merge" />
<service
android:name="org.koitharu.kotatsu.local.ui.LocalChaptersRemoveService"
android:foregroundServiceType="dataSync" />
<service <service
android:name="org.koitharu.kotatsu.widget.shelf.ShelfWidgetService" android:name="org.koitharu.kotatsu.widget.shelf.ShelfWidgetService"
android:permission="android.permission.BIND_REMOTEVIEWS" /> android:permission="android.permission.BIND_REMOTEVIEWS" />
@@ -298,6 +324,660 @@
android:name="com.samsung.android.icon_container.has_icon_container" android:name="com.samsung.android.icon_container.has_icon_container"
android:value="@bool/com_samsung_android_icon_container_has_icon_container" /> android:value="@bool/com_samsung_android_icon_container_has_icon_container" />
<activity-alias
android:name="org.koitharu.kotatsu.details.ui.DetailsBYLinkActivity"
android:exported="true"
android:targetActivity="org.koitharu.kotatsu.details.ui.DetailsActivity">
<intent-filter android:autoVerify="false">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="http" />
<data android:scheme="https" />
<data android:host="1stkissmanga.me" />
<data android:host="3asq.org" />
<data android:host="18porncomic.com" />
<data android:host="212.32.226.234" />
<data android:host="247manga.com" />
<data android:host="365manga.com" />
<data android:host="2023.allhen.online" />
<data android:host="adultwebtoon.com" />
<data android:host="afroditscans.com" />
<data android:host="ainzscans.site" />
<data android:host="aiyumanga.com" />
<data android:host="alceascan.my.id" />
<data android:host="allporncomic.com" />
<data android:host="anibel.net" />
<data android:host="anigliscans.com" />
<data android:host="anikiga.com" />
<data android:host="animaregia.net" />
<data android:host="anisamanga.com" />
<data android:host="anshscans.org" />
<data android:host="apenasmaisumyaoi.com" />
<data android:host="apollcomics.com" />
<data android:host="aquamanga.com" />
<data android:host="arabtoons.net" />
<data android:host="araznovel.com" />
<data android:host="arcanescans.com" />
<data android:host="arenascans.net" />
<data android:host="arthurscan.xyz" />
<data android:host="astral-manga.fr" />
<data android:host="astrallibrary.net" />
<data android:host="astrumscans.xyz" />
<data android:host="asura.nacm.xyz" />
<data android:host="asurascanstr.com" />
<data android:host="athenafansub.com" />
<data android:host="ayatoon.com" />
<data android:host="azoranov.com" />
<data android:host="azuremanga.com" />
<data android:host="babeltoon.com" />
<data android:host="bakai.org" />
<data android:host="bakaman.net" />
<data android:host="bakamh.com" />
<data android:host="banana-scan.com" />
<data android:host="bato.to" />
<data android:host="batocomic.com" />
<data android:host="batocomic.net" />
<data android:host="batocomic.org" />
<data android:host="batotoo.com" />
<data android:host="batotwo.com" />
<data android:host="battwo.com" />
<data android:host="beast-scans.com" />
<data android:host="beehentai.com" />
<data android:host="bentomanga.com" />
<data android:host="bestmanga.club" />
<data android:host="bestmanhua.com" />
<data android:host="bibimanga.com" />
<data android:host="birdmanga.com" />
<data android:host="birdtoon.net" />
<data android:host="blogmanga.net" />
<data android:host="blogtruyenmoi.com" />
<data android:host="bokugents.com" />
<data android:host="boosei.net" />
<data android:host="boyslove.me" />
<data android:host="br.atlantisscan.com" />
<data android:host="br.ninemanga.com" />
<data android:host="cabaredowatame.site" />
<data android:host="cafecomyaoi.com.br" />
<data android:host="carteldemanhwas.com" />
<data android:host="cat300.com" />
<data android:host="cerisescans.com" />
<data android:host="chap.mangairo.com" />
<data android:host="chapmanganato.com" />
<data android:host="chapmanganato.com" />
<data android:host="cizgiromanarsivi.com" />
<data android:host="cmreader.info" />
<data android:host="cocorip.net" />
<data android:host="coffeemanga.io" />
<data android:host="coloredmanga.com" />
<data android:host="comick.app" />
<data android:host="comiko.net" />
<data android:host="comiko.org" />
<data android:host="copypastescan.xyz" />
<data android:host="cosmicscans.com" />
<data android:host="daprob.com" />
<data android:host="darkscans.com" />
<data android:host="de.ninemanga.com" />
<data android:host="desu.me" />
<data android:host="diamondfansub.com" />
<data android:host="dojing.net" />
<data android:host="dokkomanga.com" />
<data android:host="dokkomanga.com" />
<data android:host="doujin69.com" />
<data android:host="doujindesu.rip" />
<data android:host="doujinhentai.net" />
<data android:host="dragontea.ink" />
<data android:host="dragontranslation.net" />
<data android:host="drakescans.com" />
<data android:host="dto.to" />
<data android:host="duckmanga.com" />
<data android:host="duniakomik.id" />
<data android:host="dynasty-scans.com" />
<data android:host="e-hentai.org" />
<data android:host="elarcpage.com" />
<data android:host="en.leviatanscans.com" />
<data android:host="epsilonscan.fr" />
<data android:host="es.ninemanga.com" />
<data android:host="esomanga.com" />
<data android:host="exhentai.org" />
<data android:host="falconmanga.com" />
<data android:host="fbsquads.com" />
<data android:host="finalscans.com" />
<data android:host="flamescans.org" />
<data android:host="foxwhite.com.br" />
<data android:host="fr-scan.cc" />
<data android:host="fr.ninemanga.com" />
<data android:host="franxxmangas.net" />
<data android:host="freakscans.com" />
<data android:host="freemanga.me" />
<data android:host="freemangatop.com" />
<data android:host="freewebtooncoins.com" />
<data android:host="frscans.com" />
<data android:host="furyosociety.com" />
<data android:host="galaxymanga.org" />
<data android:host="gatemanga.com" />
<data android:host="gdscans.com" />
<data android:host="gekkou.com.br" />
<data android:host="glorymanga.com" />
<data android:host="goldenmanga.top" />
<data android:host="golgebahcesi.com" />
<data android:host="gooffansub.com" />
<data android:host="gourmetscans.net" />
<data android:host="grabber.zone" />
<data android:host="gremorymangas.com" />
<data android:host="guimah.com" />
<data android:host="guncelmanga.net" />
<data android:host="h.mangabat.com" />
<data android:host="hachiraw.com" />
<data android:host="harimanga.com" />
<data android:host="hayalistic.com" />
<data android:host="hensekai.com" />
<data android:host="hentai3z.cc" />
<data android:host="hentai3z.xyz" />
<data android:host="hentai4free.net" />
<data android:host="hentai20.io" />
<data android:host="hentai.gekkouscans.com.br" />
<data android:host="hentai.scantrad-vf.cc" />
<data android:host="hentaichan.live" />
<data android:host="hentaichan.pro" />
<data android:host="hentaicube.net" />
<data android:host="hentailib.me" />
<data android:host="hentaimanga.me" />
<data android:host="hentaiteca.net" />
<data android:host="hentaivn.autos" />
<data android:host="hentaivn.tv" />
<data android:host="hentaiwebtoon.com" />
<data android:host="hentaixcomic.com" />
<data android:host="hentaixdickgirl.com" />
<data android:host="hentaixyuri.com" />
<data android:host="hentaizone.xyz" />
<data android:host="herenscan.com" />
<data android:host="hhentai.fr" />
<data android:host="hikariscan.com.br" />
<data android:host="hipercool.xyz" />
<data android:host="hmanhwa.com" />
<data android:host="hni-scantrad.com" />
<data android:host="honey-manga.com.ua" />
<data android:host="hscans.com" />
<data android:host="hto.to" />
<data android:host="id.gourmetscans.net" />
<data android:host="illusionscan.com" />
<data android:host="immortalupdates.com" />
<data android:host="immortalupdates.id" />
<data android:host="imperiodabritannia.com" />
<data android:host="imperioscans.com.br" />
<data android:host="indo18h.com" />
<data android:host="infrafandub.xyz" />
<data android:host="isekaiscan.top" />
<data android:host="it.ninemanga.com" />
<data android:host="itsyourightmanhua.com" />
<data android:host="jaiminisbox.net" />
<data android:host="japscan.ws" />
<data android:host="jiangzaitoon.co" />
<data android:host="jimanga.com" />
<data android:host="jpmangas.xyz" />
<data android:host="kanzenin.xyz" />
<data android:host="karatcam-scans.fr" />
<data android:host="katakomik.online" />
<data android:host="kiryuu.id" />
<data android:host="kissmanga.in" />
<data android:host="klikmanga.id" />
<data android:host="klz9.com" />
<data android:host="koinoboriscan.com" />
<data android:host="kolmanga.com" />
<data android:host="komikav.com" />
<data android:host="komikcast.io" />
<data android:host="komikdewasa.cfd" />
<data android:host="komikgo.org" />
<data android:host="komikhentai.co" />
<data android:host="komikid.com" />
<data android:host="komikindo.co" />
<data android:host="komikindo.info" />
<data android:host="komiklab.com" />
<data android:host="komiklokal.cfd" />
<data android:host="komikmama.co" />
<data android:host="komikmanhwa.me" />
<data android:host="komikmirror.art" />
<data android:host="komiksan.link" />
<data android:host="komiksay.site" />
<data android:host="komikstation.co" />
<data android:host="komiktap.in" />
<data android:host="komiku.com" />
<data android:host="komikzoid.xyz" />
<data android:host="ksgroupscans.com" />
<data android:host="kumascans.com" />
<data android:host="kunmanga.com" />
<data android:host="ladymanga.com" />
<data android:host="lectortmo.com" />
<data android:host="lectorunitoon.com" />
<data android:host="legacy-scans.com" />
<data android:host="legionscans.com" />
<data android:host="leitor.kamisama.com.br" />
<data android:host="leitorizakaya.net" />
<data android:host="lelscanvf.cc" />
<data android:host="leryaoi.com" />
<data android:host="lilymanga.net" />
<data android:host="limascans.xyz/v2" />
<data android:host="lkscanlation.com" />
<data android:host="lolicon.mobi" />
<data android:host="lugnica-scans.com" />
<data android:host="lunarscan.org" />
<data android:host="luxmanga.net" />
<data android:host="lxmanga.net" />
<data android:host="lynxscans.com" />
<data android:host="m.isekaiscan.to" />
<data android:host="mafia-manga.com" />
<data android:host="maidscan.com.br" />
<data android:host="manga1st.online" />
<data android:host="manga3s.com" />
<data android:host="manga18.club" />
<data android:host="manga68.com" />
<data android:host="manga689.com" />
<data android:host="manga-chan.me" />
<data android:host="manga-crab.com" />
<data android:host="manga-diyari.com" />
<data android:host="manga-fast.com" />
<data android:host="manga-fr.me" />
<data android:host="manga-mate.org" />
<data android:host="manga-moons.net" />
<data android:host="manga-scan.co" />
<data android:host="manga-scantrad.io" />
<data android:host="manga-tx.com" />
<data android:host="manga-uptocats.com" />
<data android:host="manga.clone-army.org" />
<data android:host="manga.in.ua" />
<data android:host="manga.mundodrama.site" />
<data android:host="mangaaction.com" />
<data android:host="mangaatrend.net" />
<data android:host="mangabaz.net" />
<data android:host="mangabob.com" />
<data android:host="mangabuddy.com" />
<data android:host="mangacim.com" />
<data android:host="mangaclash.com" />
<data android:host="mangacultivator.com" />
<data android:host="mangacute.com" />
<data android:host="mangacv.com" />
<data android:host="mangadass.com" />
<data android:host="mangadeemak.com" />
<data android:host="mangadex.org" />
<data android:host="mangadistrict.com" />
<data android:host="mangadna.com" />
<data android:host="mangadoor.com" />
<data android:host="mangaeffect.com" />
<data android:host="mangaforest.me" />
<data android:host="mangaforfree.com" />
<data android:host="mangafoxfull.com" />
<data android:host="mangafreak.online" />
<data android:host="mangagalaxy.me" />
<data android:host="mangagg.com" />
<data android:host="mangagoyaoi.com" />
<data android:host="mangagreat.com" />
<data android:host="mangahentai.me" />
<data android:host="mangahub.fr" />
<data android:host="mangaid.click" />
<data android:host="mangaindo.me" />
<data android:host="mangak2.com" />
<data android:host="mangakakalot.com" />
<data android:host="mangakeyfi.net" />
<data android:host="mangaking.net" />
<data android:host="mangakio.me" />
<data android:host="mangakiss.org" />
<data android:host="mangakita.net" />
<data android:host="mangakomi.io" />
<data android:host="mangakyo.org" />
<data android:host="mangalek.com" />
<data android:host="mangaleks.com" />
<data android:host="mangaleveling.com" />
<data android:host="mangalib.me" />
<data android:host="mangalike.me" />
<data android:host="mangalink.online" />
<data android:host="mangalionz.com" />
<data android:host="mangamammy.ru" />
<data android:host="mangamanhua.online" />
<data android:host="mangamaniacs.org" />
<data android:host="manganato.com" />
<data android:host="mangaokutr.com" />
<data android:host="mangaonelove.site" />
<data android:host="mangaonlineteam.com" />
<data android:host="mangaowl.to" />
<data android:host="mangaprotm.com" />
<data android:host="mangapt.com" />
<data android:host="mangapuma.com" />
<data android:host="mangaread.co" />
<data android:host="mangareaderpro.com" />
<data android:host="mangareading.org" />
<data android:host="mangarockteam.com" />
<data android:host="mangarocky.com" />
<data android:host="mangarolls.net" />
<data android:host="mangarosie.in" />
<data android:host="mangas-origines.fr" />
<data android:host="mangas-origines.xyz" />
<data android:host="mangaschan.com" />
<data android:host="mangasehri.com" />
<data android:host="mangaspark.com" />
<data android:host="mangastarz.com" />
<data android:host="mangastic.cc" />
<data android:host="mangastic.cc" />
<data android:host="mangasushi.org" />
<data android:host="mangasusuku.xyz" />
<data android:host="mangatale.co" />
<data android:host="mangatone.com" />
<data android:host="mangatoto.com" />
<data android:host="mangatoto.net" />
<data android:host="mangatoto.org" />
<data android:host="mangatx.com" />
<data android:host="mangaus.xyz" />
<data android:host="mangavisa.com" />
<data android:host="mangaweebs.in" />
<data android:host="mangawt.com" />
<data android:host="mangax1.com" />
<data android:host="mangaxyz.com" />
<data android:host="mangayaro.net" />
<data android:host="mangazavr.ru" />
<data android:host="mangazodiac.com" />
<data android:host="manhatic.com" />
<data android:host="manhuaes.com" />
<data android:host="manhuafast.com" />
<data android:host="manhuafast.net" />
<data android:host="manhuaga.com" />
<data android:host="manhuahot.com" />
<data android:host="manhuamix.com" />
<data android:host="manhuaplus.com" />
<data android:host="manhuascan.us" />
<data android:host="manhuaus.com" />
<data android:host="manhuazone.net" />
<data android:host="manhwa18.app" />
<data android:host="manhwa18.com" />
<data android:host="manhwa18.net" />
<data android:host="manhwa18.org" />
<data android:host="manhwa68.com" />
<data android:host="manhwa-latino.com" />
<data android:host="manhwaclan.com" />
<data android:host="manhwadesu.top" />
<data android:host="manhwafull.com" />
<data android:host="manhwahentai.me" />
<data android:host="manhwaindo.icu" />
<data android:host="manhwaindo.id" />
<data android:host="manhwakool.com" />
<data android:host="manhwalist.xyz" />
<data android:host="manhwalover.com" />
<data android:host="manhwaplus.pro" />
<data android:host="manhwasco.net" />
<data android:host="manhwatop.com" />
<data android:host="manhwaworld.com" />
<data android:host="manhwax.org" />
<data android:host="manhwaz.com" />
<data android:host="mantrazscan.com" />
<data android:host="manwe.pro" />
<data android:host="manycomic.com" />
<data android:host="manytoon.com" />
<data android:host="manytoon.me" />
<data android:host="masterkomik.com" />
<data android:host="melokomik.xyz" />
<data android:host="mgkomik.com" />
<data android:host="miauscans.com" />
<data android:host="milftoon.xxx" />
<data android:host="mintmanga.com" />
<data android:host="mintmanga.live" />
<data android:host="mirrordesu.ink" />
<data android:host="mm-scans.org" />
<data android:host="momonohanascan.com" />
<data android:host="monarcamanga.com" />
<data android:host="moonloversscan.com.br" />
<data android:host="moonwitchinlovescan.com" />
<data android:host="mortalsgroove.com" />
<data android:host="mto.to" />
<data android:host="mundomangakun.com.br" />
<data android:host="mundomanhwa.com" />
<data android:host="murimscan.run" />
<data android:host="neatmangas.com" />
<data android:host="neoxscans.net" />
<data android:host="nettruyenin.com" />
<data android:host="nettruyento.com" />
<data android:host="neumanga.net" />
<data android:host="neumanga.xyz" />
<data android:host="nhattruyenmin.com" />
<data android:host="nhentai.net" />
<data android:host="nicovideo.jp" />
<data android:host="nightscans.org" />
<data android:host="niji-translations.com" />
<data android:host="ninjascan.site" />
<data android:host="niverafansub.com" />
<data android:host="nocsummer.com.br" />
<data android:host="noindexscan.com" />
<data android:host="nonbiri.space" />
<data android:host="novelcrow.com" />
<data android:host="novelmic.com" />
<data android:host="novelstown.cyou" />
<data android:host="nude-moon.net" />
<data android:host="nude-moon.org" />
<data android:host="nyxmanga.com" />
<data android:host="origami-orpheans.com.br" />
<data android:host="otsugami.id" />
<data android:host="oxapk.com" />
<data android:host="ozulmanga.com" />
<data android:host="painfulnightz.com" />
<data android:host="pantheon-scan.com" />
<data android:host="papscan.com" />
<data android:host="paragonscans.com" />
<data android:host="peacescans.com" />
<data android:host="phantomscans.com" />
<data android:host="phenixscans.fr" />
<data android:host="pianmanga.me" />
<data android:host="pirulitorosa.site" />
<data android:host="piscans.in" />
<data android:host="platinumscans.com" />
<data android:host="pojokmanga.net" />
<data android:host="popsmanga.com" />
<data android:host="portalyaoi.com" />
<data android:host="prismahentai.com" />
<data android:host="prismascans.net" />
<data android:host="projetoscanlator.com" />
<data android:host="psunicorn.com" />
<data android:host="queenscans.com" />
<data android:host="ragnarokscan.com" />
<data android:host="ragnarokscanlation.com" />
<data android:host="raijinscans.fr" />
<data android:host="raikiscan.com" />
<data android:host="rainbowfairyscan.com" />
<data android:host="randomscans.com" />
<data android:host="ravenscans.com" />
<data android:host="rawdex.net" />
<data android:host="rawkuma.com" />
<data android:host="read-nifteam.info" />
<data android:host="read.babelwuxia.com" />
<data android:host="readcomicsonline.ru" />
<data android:host="reader.deathtollscans.net" />
<data android:host="reader.decadencescans.com" />
<data android:host="reader.evilflowers.com" />
<data android:host="reader.mangatellers.gr" />
<data android:host="reader.onepiecenakama.pl" />
<data android:host="reader.powermanga.org" />
<data android:host="reader.silentsky-scans.net" />
<data android:host="readfreecomics.com" />
<data android:host="readkomik.com" />
<data android:host="readmanga.io" />
<data android:host="readmanga.live" />
<data android:host="readmanga.me" />
<data android:host="readmangabat.com" />
<data android:host="readmanhua.net" />
<data android:host="readtoto.com" />
<data android:host="readtoto.net" />
<data android:host="readtoto.org" />
<data android:host="realmscans.xyz" />
<data android:host="reaperscans.fr" />
<data android:host="remanga.org" />
<data android:host="rightdark-scan.com" />
<data android:host="rio2manga.com" />
<data android:host="rio2manga.net" />
<data android:host="rogmangas.com" />
<data android:host="romantikmanga.com" />
<data android:host="ru.ninemanga.com" />
<data android:host="s2manga.com" />
<data android:host="samuraiscan.com" />
<data android:host="sawamics.com" />
<data android:host="saytruyenhay.com" />
<data android:host="scambertraslator.com" />
<data android:host="scan.hentai.menu" />
<data android:host="scanmanga-vf.ws" />
<data android:host="scansmangas.me" />
<data android:host="scansraw.com" />
<data android:host="scantrad-union.com" />
<data android:host="scantrad-vf.co" />
<data android:host="sekaikomik.pro" />
<data android:host="sektedoujin.cc" />
<data android:host="sektekomik.xyz" />
<data android:host="selfmanga.live" />
<data android:host="senpaiediciones.com" />
<data android:host="shadowmangas.com" />
<data android:host="shadowtrad.net" />
<data android:host="sheakomik.com" />
<data android:host="shibamanga.com" />
<data android:host="shinigami.id" />
<data android:host="shirodoujin.com" />
<data android:host="shootingstarscans.com" />
<data android:host="silencescan.com.br" />
<data android:host="sinensisscans.com" />
<data android:host="skanlacje-feniksy.pl" />
<data android:host="skymanga.work" />
<data android:host="skymangas.com" />
<data android:host="sleepytranslations.com" />
<data android:host="soulscans.my.id" />
<data android:host="spartanmanga.com.tr" />
<data android:host="sssscanlator.com" />
<data android:host="summanga.com" />
<data android:host="suryascans.com" />
<data android:host="sushiscan.fr" />
<data android:host="sushiscan.net" />
<data android:host="swatop.club" />
<data android:host="tankouhentai.com" />
<data android:host="tatakaescan.com" />
<data android:host="tecnoscann.com" />
<data android:host="teenmanhua.com" />
<data android:host="tempestfansub.com" />
<data android:host="templescan.net" />
<data android:host="templescanesp.com" />
<data android:host="tenkaiscan.net" />
<data android:host="theguildscans.com" />
<data android:host="thesugarscan.com" />
<data android:host="timenaight.com" />
<data android:host="todaymic.com" />
<data android:host="tonizutoon.com" />
<data android:host="toonchill.com" />
<data android:host="toonfr.com" />
<data android:host="toonhunter.com" />
<data android:host="toonily.com" />
<data android:host="toonily.me" />
<data android:host="toonily.net" />
<data android:host="toonitube.com" />
<data android:host="tortuga-ceviri.com" />
<data android:host="traduccionesmoonlight.com" />
<data android:host="treemanga.com" />
<data android:host="tritinia.org" />
<data android:host="truemanga.com" />
<data android:host="truyentranhlh.net" />
<data android:host="tsundoku.com.br" />
<data android:host="tukangkomik.id" />
<data android:host="tumanhwas.club" />
<data android:host="turktoon.com" />
<data android:host="v2.comiz.net" />
<data android:host="valkyriescan.com" />
<data android:host="vercomicsporno.com" />
<data android:host="vermangasporno.com" />
<data android:host="vermanhwa.es" />
<data android:host="viyafansub.com" />
<data android:host="void-scans.com" />
<data android:host="w.mangairo.com" />
<data android:host="wakamics.net" />
<data android:host="webcomic.me" />
<data android:host="webtoon-tr.com" />
<data android:host="webtoon.uk" />
<data android:host="webtoonempire.org" />
<data android:host="webtoonhatti.com" />
<data android:host="webtoons.top" />
<data android:host="webtoonscan.com" />
<data android:host="weloma.art" />
<data android:host="welovemanga.one" />
<data android:host="westmanga.info" />
<data android:host="wickedwitchscan.com" />
<data android:host="winterscan.com" />
<data android:host="wonderlandscan.com" />
<data android:host="woopread.com" />
<data android:host="worldmanhwas.bar" />
<data android:host="wto.to" />
<data android:host="www1.bluesolo.org" />
<data android:host="www.areascans.net" />
<data android:host="www.bentomanga.com" />
<data android:host="www.eromiau.com" />
<data android:host="www.inu-manga.com" />
<data android:host="www.japscan.lol" />
<data android:host="www.kuroimanga.com" />
<data android:host="www.lami-manga.com" />
<data android:host="www.lelmanga.com" />
<data android:host="www.lianscans.my.id" />
<data android:host="www.maid.my.id" />
<data android:host="www.majorscans.com" />
<data android:host="www.mangadods.com" />
<data android:host="www.mangaread.org" />
<data android:host="www.mangascantrad.fr" />
<data android:host="www.mangatown.com" />
<data android:host="www.manhuabug.com" />
<data android:host="www.manhuakey.com" />
<data android:host="www.manhuasy.com" />
<data android:host="www.menudo-fansub.com" />
<data android:host="www.nettruyenmax.com" />
<data android:host="www.nettruyento.com" />
<data android:host="www.nightcomic.com" />
<data android:host="www.ninemanga.com" />
<data android:host="www.noblessetranslations.com" />
<data android:host="www.pantheon-scan.fr" />
<data android:host="www.paritehaber.com" />
<data android:host="www.peachscan.com" />
<data android:host="www.petrotechsociety.org" />
<data android:host="www.petrotechsociety.org" />
<data android:host="www.ramareader.it" />
<data android:host="www.rh2plusmanga.com" />
<data android:host="www.ruyamanga.com" />
<data android:host="www.scan-fr.org" />
<data android:host="www.scan-vf.net" />
<data android:host="www.thaimanga.net" />
<data android:host="www.topmanhua.com" />
<data android:host="www.vfscan.com" />
<data android:host="www.walpurgiscan.it" />
<data android:host="www.webtoon.xyz" />
<data android:host="www.witcomics.net" />
<data android:host="www.xn--l3c0azab5a2gta.com" />
<data android:host="www.yaoitoshokan.net" />
<data android:host="xbato.com" />
<data android:host="xbato.net" />
<data android:host="xbato.org" />
<data android:host="xoxocomics.net" />
<data android:host="xx.hentaichan.live" />
<data android:host="xxx.hentaichan.live" />
<data android:host="y.hentaichan.live" />
<data android:host="yaoi-chan.me" />
<data android:host="yaoi.mobi" />
<data android:host="yaoilib.me" />
<data android:host="yaoiscan.com" />
<data android:host="ycscan.com" />
<data android:host="yugenmangas.com.br" />
<data android:host="yuri.live" />
<data android:host="zahard.xyz" />
<data android:host="zandynofansub.aishiteru.org" />
<data android:host="zbato.com" />
<data android:host="zbato.net" />
<data android:host="zbato.org" />
<data android:host="zeroscan.com.br" />
<data android:host="zinmanga.com" />
<data android:host="zinmanhwa.com" />
<data android:host="zuttomanga.com" />
<data android:host="реманга.орг" />
</intent-filter>
</activity-alias>
</application> </application>
</manifest> </manifest>

View File

@@ -52,6 +52,13 @@ class BookmarksRepository @Inject constructor(
} }
} }
suspend fun updateBookmark(bookmark: Bookmark, imageUrl: String) {
val entity = bookmark.toEntity().copy(
imageUrl = imageUrl,
)
db.bookmarksDao.upsert(listOf(entity))
}
suspend fun removeBookmark(mangaId: Long, chapterId: Long, page: Int) { suspend fun removeBookmark(mangaId: Long, chapterId: Long, page: Int) {
check(db.bookmarksDao.delete(mangaId, chapterId, page) != 0) { check(db.bookmarksDao.delete(mangaId, chapterId, page) != 0) {
"Bookmark not found" "Bookmark not found"

View File

@@ -29,7 +29,6 @@ import org.koitharu.kotatsu.core.ui.util.ReversibleAction
import org.koitharu.kotatsu.core.ui.util.reverseAsync import org.koitharu.kotatsu.core.ui.util.reverseAsync
import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.scaleUpActivityOptionsOf
import org.koitharu.kotatsu.databinding.FragmentListSimpleBinding import org.koitharu.kotatsu.databinding.FragmentListSimpleBinding
import org.koitharu.kotatsu.details.ui.DetailsActivity import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.list.ui.MangaListSpanResolver import org.koitharu.kotatsu.list.ui.MangaListSpanResolver
@@ -84,7 +83,7 @@ class BookmarksFragment :
with(binding.recyclerView) { with(binding.recyclerView) {
setHasFixedSize(true) setHasFixedSize(true)
val spanResolver = MangaListSpanResolver(resources) val spanResolver = MangaListSpanResolver(resources)
addItemDecoration(TypedListSpacingDecoration(context)) addItemDecoration(TypedListSpacingDecoration(context, false))
adapter = bookmarksAdapter adapter = bookmarksAdapter
addOnLayoutChangeListener(spanResolver) addOnLayoutChangeListener(spanResolver)
spanResolver.setGridSize(settings.gridSize / 100f, this) spanResolver.setGridSize(settings.gridSize / 100f, this)
@@ -159,10 +158,11 @@ class BookmarksFragment :
} }
override fun onWindowInsetsChanged(insets: Insets) { override fun onWindowInsetsChanged(insets: Insets) {
requireViewBinding().recyclerView.updatePadding( val rv = requireViewBinding().recyclerView
bottom = insets.bottom, rv.updatePadding(
bottom = insets.bottom + rv.paddingTop,
) )
requireViewBinding().recyclerView.fastScroller.updateLayoutParams<ViewGroup.MarginLayoutParams> { rv.fastScroller.updateLayoutParams<ViewGroup.MarginLayoutParams> {
bottomMargin = insets.bottom bottomMargin = insets.bottom
} }
} }

View File

@@ -9,7 +9,6 @@ import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver
import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.util.ext.decodeRegion import org.koitharu.kotatsu.core.util.ext.decodeRegion
import org.koitharu.kotatsu.core.util.ext.disposeImageRequest
import org.koitharu.kotatsu.core.util.ext.enqueueWith import org.koitharu.kotatsu.core.util.ext.enqueueWith
import org.koitharu.kotatsu.core.util.ext.newImageRequest import org.koitharu.kotatsu.core.util.ext.newImageRequest
import org.koitharu.kotatsu.core.util.ext.source import org.koitharu.kotatsu.core.util.ext.source
@@ -34,13 +33,10 @@ fun bookmarkListAD(
fallback(R.drawable.ic_placeholder) fallback(R.drawable.ic_placeholder)
error(R.drawable.ic_error_placeholder) error(R.drawable.ic_error_placeholder)
allowRgb565(true) allowRgb565(true)
tag(item)
decodeRegion(item.scroll) decodeRegion(item.scroll)
source(item.manga.source) source(item.manga.source)
enqueueWith(coil) enqueueWith(coil)
} }
} }
onViewRecycled {
binding.imageViewThumb.disposeImageRequest()
}
} }

View File

@@ -5,11 +5,15 @@ import coil.ImageLoader
import org.koitharu.kotatsu.bookmarks.domain.Bookmark import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.core.ui.BaseListAdapter import org.koitharu.kotatsu.core.ui.BaseListAdapter
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
class BookmarksAdapter( class BookmarksAdapter(
coil: ImageLoader, coil: ImageLoader,
lifecycleOwner: LifecycleOwner, lifecycleOwner: LifecycleOwner,
clickListener: OnListItemClickListener<Bookmark>, clickListener: OnListItemClickListener<Bookmark>,
) : BaseListAdapter<Bookmark>( ) : BaseListAdapter<Bookmark>() {
bookmarkListAD(coil, lifecycleOwner, clickListener),
) init {
addDelegate(ListItemType.PAGE_THUMB, bookmarkListAD(coil, lifecycleOwner, clickListener))
}
}

View File

@@ -9,7 +9,6 @@ import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver
import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.util.ext.decodeRegion import org.koitharu.kotatsu.core.util.ext.decodeRegion
import org.koitharu.kotatsu.core.util.ext.disposeImageRequest
import org.koitharu.kotatsu.core.util.ext.enqueueWith import org.koitharu.kotatsu.core.util.ext.enqueueWith
import org.koitharu.kotatsu.core.util.ext.newImageRequest import org.koitharu.kotatsu.core.util.ext.newImageRequest
import org.koitharu.kotatsu.core.util.ext.source import org.koitharu.kotatsu.core.util.ext.source
@@ -35,14 +34,11 @@ fun bookmarkLargeAD(
fallback(R.drawable.ic_placeholder) fallback(R.drawable.ic_placeholder)
error(R.drawable.ic_error_placeholder) error(R.drawable.ic_error_placeholder)
allowRgb565(true) allowRgb565(true)
tag(item)
decodeRegion(item.scroll) decodeRegion(item.scroll)
source(item.manga.source) source(item.manga.source)
enqueueWith(coil) enqueueWith(coil)
} }
binding.progressView.percent = item.percent binding.progressView.percent = item.percent
} }
onViewRecycled {
binding.imageViewThumb.disposeImageRequest()
}
} }

View File

@@ -9,6 +9,7 @@ import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.ui.list.fastscroll.FastScroller import org.koitharu.kotatsu.core.ui.list.fastscroll.FastScroller
import org.koitharu.kotatsu.list.ui.adapter.ListHeaderClickListener import org.koitharu.kotatsu.list.ui.adapter.ListHeaderClickListener
import org.koitharu.kotatsu.list.ui.adapter.ListItemType 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.listHeaderAD
import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD
import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD
@@ -27,6 +28,7 @@ class BookmarksAdapter(
addDelegate(ListItemType.HEADER, listHeaderAD(headerClickListener)) addDelegate(ListItemType.HEADER, listHeaderAD(headerClickListener))
addDelegate(ListItemType.FOOTER_LOADING, loadingFooterAD()) addDelegate(ListItemType.FOOTER_LOADING, loadingFooterAD())
addDelegate(ListItemType.STATE_LOADING, loadingStateAD()) addDelegate(ListItemType.STATE_LOADING, loadingStateAD())
addDelegate(ListItemType.STATE_EMPTY, emptyStateListAD(coil, lifecycleOwner, null))
} }
override fun getSectionText(context: Context, position: Int): CharSequence? { override fun getSectionText(context: Context, position: Int): CharSequence? {

View File

@@ -75,7 +75,7 @@ class BookmarksSheet :
) )
viewBinding?.headerBar?.setTitle(R.string.bookmarks) viewBinding?.headerBar?.setTitle(R.string.bookmarks)
with(binding.recyclerView) { with(binding.recyclerView) {
addItemDecoration(TypedListSpacingDecoration(context)) addItemDecoration(TypedListSpacingDecoration(context, false))
adapter = bookmarksAdapter adapter = bookmarksAdapter
addOnLayoutChangeListener(spanResolver) addOnLayoutChangeListener(spanResolver)
spanResolver?.setGridSize(settings.gridSize / 100f, this) spanResolver?.setGridSize(settings.gridSize / 100f, this)

View File

@@ -1,28 +1,28 @@
package org.koitharu.kotatsu.browser.cloudflare package org.koitharu.kotatsu.browser.cloudflare
import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import androidx.core.app.NotificationChannelCompat import androidx.core.app.NotificationChannelCompat
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import androidx.core.app.PendingIntentCompat import androidx.core.app.PendingIntentCompat
import androidx.core.net.toUri import androidx.core.net.toUri
import coil.EventListener
import coil.request.ErrorResult import coil.request.ErrorResult
import coil.request.ImageRequest import coil.request.ImageRequest
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission
import org.koitharu.kotatsu.parsers.model.ContentType import org.koitharu.kotatsu.parsers.model.ContentType
class CaptchaNotifier( class CaptchaNotifier(
private val context: Context, private val context: Context,
) : ImageRequest.Listener { ) : EventListener {
@SuppressLint("MissingPermission")
fun notify(exception: CloudFlareProtectedException) { fun notify(exception: CloudFlareProtectedException) {
val manager = NotificationManagerCompat.from(context) if (!context.checkNotificationPermission()) {
if (!manager.areNotificationsEnabled()) {
return return
} }
val manager = NotificationManagerCompat.from(context)
val channel = NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_DEFAULT) val channel = NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_DEFAULT)
.setName(context.getString(R.string.captcha_required)) .setName(context.getString(R.string.captcha_required))
.setShowBadge(true) .setShowBadge(true)

View File

@@ -25,6 +25,7 @@ import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asSharedFlow
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import org.koitharu.kotatsu.BuildConfig 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.ContentCache
import org.koitharu.kotatsu.core.cache.MemoryContentCache import org.koitharu.kotatsu.core.cache.MemoryContentCache
import org.koitharu.kotatsu.core.cache.StubContentCache import org.koitharu.kotatsu.core.cache.StubContentCache
@@ -46,6 +47,7 @@ import org.koitharu.kotatsu.local.data.CacheDir
import org.koitharu.kotatsu.local.data.CbzFetcher import org.koitharu.kotatsu.local.data.CbzFetcher
import org.koitharu.kotatsu.local.data.LocalStorageChanges import org.koitharu.kotatsu.local.data.LocalStorageChanges
import org.koitharu.kotatsu.local.domain.model.LocalManga import org.koitharu.kotatsu.local.domain.model.LocalManga
import org.koitharu.kotatsu.main.domain.CoverRestoreInterceptor
import org.koitharu.kotatsu.main.ui.protect.AppProtectHelper import org.koitharu.kotatsu.main.ui.protect.AppProtectHelper
import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.reader.ui.thumbnails.MangaPageFetcher import org.koitharu.kotatsu.reader.ui.thumbnails.MangaPageFetcher
@@ -89,6 +91,7 @@ interface AppModule {
mangaRepositoryFactory: MangaRepository.Factory, mangaRepositoryFactory: MangaRepository.Factory,
imageProxyInterceptor: ImageProxyInterceptor, imageProxyInterceptor: ImageProxyInterceptor,
pageFetcherFactory: MangaPageFetcher.Factory, pageFetcherFactory: MangaPageFetcher.Factory,
coverRestoreInterceptor: CoverRestoreInterceptor,
): ImageLoader { ): ImageLoader {
val diskCacheFactory = { val diskCacheFactory = {
val rootDir = context.externalCacheDir ?: context.cacheDir val rootDir = context.externalCacheDir ?: context.cacheDir
@@ -105,6 +108,7 @@ interface AppModule {
.diskCache(diskCacheFactory) .diskCache(diskCacheFactory)
.logger(if (BuildConfig.DEBUG) DebugLogger() else null) .logger(if (BuildConfig.DEBUG) DebugLogger() else null)
.allowRgb565(context.isLowRamDevice()) .allowRgb565(context.isLowRamDevice())
.eventListener(CaptchaNotifier(context))
.components( .components(
ComponentRegistry.Builder() ComponentRegistry.Builder()
.add(SvgDecoder.Factory()) .add(SvgDecoder.Factory())
@@ -112,6 +116,7 @@ interface AppModule {
.add(FaviconFetcher.Factory(context, okHttpClient, mangaRepositoryFactory)) .add(FaviconFetcher.Factory(context, okHttpClient, mangaRepositoryFactory))
.add(pageFetcherFactory) .add(pageFetcherFactory)
.add(imageProxyInterceptor) .add(imageProxyInterceptor)
.add(coverRestoreInterceptor)
.build(), .build(),
).build() ).build()
} }

View File

@@ -1,11 +1,9 @@
package org.koitharu.kotatsu package org.koitharu.kotatsu.core
import android.app.Application import android.app.Application
import android.content.Context import android.content.Context
import android.os.StrictMode
import androidx.annotation.WorkerThread import androidx.annotation.WorkerThread
import androidx.appcompat.app.AppCompatDelegate import androidx.appcompat.app.AppCompatDelegate
import androidx.fragment.app.strictmode.FragmentStrictMode
import androidx.hilt.work.HiltWorkerFactory import androidx.hilt.work.HiltWorkerFactory
import androidx.room.InvalidationTracker import androidx.room.InvalidationTracker
import androidx.work.Configuration import androidx.work.Configuration
@@ -20,20 +18,19 @@ import org.acra.config.httpSender
import org.acra.data.StringFormat import org.acra.data.StringFormat
import org.acra.ktx.initAcra import org.acra.ktx.initAcra
import org.acra.sender.HttpSender import org.acra.sender.HttpSender
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.os.AppValidator import org.koitharu.kotatsu.core.os.AppValidator
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.WorkServiceStopHelper
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
import org.koitharu.kotatsu.local.data.LocalMangaRepository
import org.koitharu.kotatsu.local.data.PagesCache
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.settings.work.WorkScheduleManager import org.koitharu.kotatsu.settings.work.WorkScheduleManager
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Provider import javax.inject.Provider
@HiltAndroidApp @HiltAndroidApp
class KotatsuApp : Application(), Configuration.Provider { open class BaseApp : Application(), Configuration.Provider {
@Inject @Inject
lateinit var databaseObservers: Set<@JvmSuppressWildcards InvalidationTracker.Observer> lateinit var databaseObservers: Set<@JvmSuppressWildcards InvalidationTracker.Observer>
@@ -62,9 +59,6 @@ class KotatsuApp : Application(), Configuration.Provider {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
ACRA.errorReporter.putCustomData("isOriginalApp", appValidator.isOriginalApp.toString()) ACRA.errorReporter.putCustomData("isOriginalApp", appValidator.isOriginalApp.toString())
if (BuildConfig.DEBUG) {
enableStrictMode()
}
AppCompatDelegate.setDefaultNightMode(settings.theme) AppCompatDelegate.setDefaultNightMode(settings.theme)
AppCompatDelegate.setApplicationLocales(settings.appLocales) AppCompatDelegate.setApplicationLocales(settings.appLocales)
setupActivityLifecycleCallbacks() setupActivityLifecycleCallbacks()
@@ -72,6 +66,7 @@ class KotatsuApp : Application(), Configuration.Provider {
setupDatabaseObservers() setupDatabaseObservers()
} }
workScheduleManager.init() workScheduleManager.init()
WorkServiceStopHelper(workManagerProvider).setup()
} }
override fun attachBaseContext(base: Context?) { override fun attachBaseContext(base: Context?) {
@@ -134,31 +129,4 @@ class KotatsuApp : Application(), Configuration.Provider {
registerActivityLifecycleCallbacks(it) registerActivityLifecycleCallbacks(it)
} }
} }
private fun enableStrictMode() {
StrictMode.setThreadPolicy(
StrictMode.ThreadPolicy.Builder()
.detectAll()
.penaltyLog()
.build(),
)
StrictMode.setVmPolicy(
StrictMode.VmPolicy.Builder()
.detectAll()
.setClassInstanceLimit(LocalMangaRepository::class.java, 1)
.setClassInstanceLimit(PagesCache::class.java, 1)
.setClassInstanceLimit(MangaLoaderContext::class.java, 1)
.setClassInstanceLimit(PageLoader::class.java, 1)
.penaltyLog()
.build(),
)
FragmentStrictMode.defaultPolicy = FragmentStrictMode.Policy.Builder()
.penaltyDeath()
.detectFragmentReuse()
// .detectWrongFragmentContainer() FIXME: migrate to ViewPager2
.detectRetainInstanceUsage()
.detectSetUserVisibleHint()
.detectFragmentTagUsage()
.build()
}
} }

View File

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

View File

@@ -19,6 +19,10 @@ abstract class MangaDao {
@Query("SELECT * FROM manga WHERE manga_id = :id") @Query("SELECT * FROM manga WHERE manga_id = :id")
abstract suspend fun find(id: Long): MangaWithTags? abstract suspend fun find(id: Long): MangaWithTags?
@Transaction
@Query("SELECT * FROM manga WHERE public_url = :publicUrl")
abstract suspend fun findByPublicUrl(publicUrl: String): MangaWithTags?
@Transaction @Transaction
@Query("SELECT * FROM manga WHERE (title LIKE :query OR alt_title LIKE :query) AND manga_id IN (SELECT manga_id FROM favourites UNION SELECT manga_id FROM history) LIMIT :limit") @Query("SELECT * FROM manga WHERE (title LIKE :query OR alt_title LIKE :query) AND manga_id IN (SELECT manga_id FROM favourites UNION SELECT manga_id FROM history) LIMIT :limit")
abstract suspend fun searchByTitle(query: String, limit: Int): List<MangaWithTags> abstract suspend fun searchByTitle(query: String, limit: Int): List<MangaWithTags>

View File

@@ -4,7 +4,7 @@ import androidx.room.Embedded
import androidx.room.Junction import androidx.room.Junction
import androidx.room.Relation import androidx.room.Relation
class MangaWithTags( data class MangaWithTags(
@Embedded val manga: MangaEntity, @Embedded val manga: MangaEntity,
@Relation( @Relation(
parentColumn = "manga_id", parentColumn = "manga_id",
@@ -12,21 +12,4 @@ class MangaWithTags(
associateBy = Junction(MangaTagsEntity::class) associateBy = Junction(MangaTagsEntity::class)
) )
val tags: List<TagEntity>, val tags: List<TagEntity>,
) { )
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as MangaWithTags
if (manga != other.manga) return false
return tags == other.tags
}
override fun hashCode(): Int {
var result = manga.hashCode()
result = 31 * result + tags.hashCode()
return result
}
}

View File

@@ -21,13 +21,18 @@ class Migration16To17(context: Context) : Migration(16, 17) {
continue continue
} }
val name = source.name val name = source.name
val isHidden = name in hiddenSources
var sortKey = order.indexOf(name) var sortKey = order.indexOf(name)
if (sortKey == -1) { if (sortKey == -1) {
sortKey = order.size + source.ordinal if (isHidden) {
sortKey = order.size + source.ordinal
} else {
continue
}
} }
database.execSQL( database.execSQL(
"INSERT INTO `sources` (`source`, `enabled`, `sort_key`) VALUES (?, ?, ?)", "INSERT INTO `sources` (`source`, `enabled`, `sort_key`) VALUES (?, ?, ?)",
arrayOf(name, (name !in hiddenSources).toInt(), sortKey), arrayOf(name, (!isHidden).toInt(), sortKey),
) )
} }
} }

View File

@@ -6,4 +6,8 @@ import java.util.Date
class TooManyRequestExceptions( class TooManyRequestExceptions(
val url: String, val url: String,
val retryAt: Date?, val retryAt: Date?,
) : IOException() ) : IOException() {
val retryAfter: Long
get() = if (retryAt == null) 0 else (retryAt.time - System.currentTimeMillis()).coerceAtLeast(0)
}

View File

@@ -2,7 +2,7 @@ package org.koitharu.kotatsu.core.github
import java.util.* import java.util.*
class VersionId( data class VersionId(
val major: Int, val major: Int,
val minor: Int, val minor: Int,
val build: Int, val build: Int,
@@ -30,28 +30,6 @@ class VersionId(
return variantNumber.compareTo(other.variantNumber) return variantNumber.compareTo(other.variantNumber)
} }
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as VersionId
if (major != other.major) return false
if (minor != other.minor) return false
if (build != other.build) return false
if (variantType != other.variantType) return false
return variantNumber == other.variantNumber
}
override fun hashCode(): Int {
var result = major
result = 31 * result + minor
result = 31 * result + build
result = 31 * result + variantType.hashCode()
result = 31 * result + variantNumber
return result
}
private fun variantWeight(variantType: String) = when (variantType.lowercase(Locale.ROOT)) { private fun variantWeight(variantType: String) = when (variantType.lowercase(Locale.ROOT)) {
"a", "alpha" -> 1 "a", "alpha" -> 1
"b", "beta" -> 2 "b", "beta" -> 2

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.core.model package org.koitharu.kotatsu.core.model
import android.net.Uri
import androidx.core.os.LocaleListCompat import androidx.core.os.LocaleListCompat
import org.koitharu.kotatsu.core.util.ext.iterator import org.koitharu.kotatsu.core.util.ext.iterator
import org.koitharu.kotatsu.details.ui.model.ChapterListItem import org.koitharu.kotatsu.details.ui.model.ChapterListItem
@@ -47,10 +48,10 @@ fun Manga.getPreferredBranch(history: MangaHistory?): String? {
if (groups.size == 1) { if (groups.size == 1) {
return groups.keys.first() return groups.keys.first()
} }
val candidates = HashMap<String?, List<MangaChapter>>(groups.size)
for (locale in LocaleListCompat.getAdjustedDefault()) { for (locale in LocaleListCompat.getAdjustedDefault()) {
val displayLanguage = locale.getDisplayLanguage(locale) val displayLanguage = locale.getDisplayLanguage(locale)
val displayName = locale.getDisplayName(locale) val displayName = locale.getDisplayName(locale)
val candidates = HashMap<String?, List<MangaChapter>>(3)
for (branch in groups.keys) { for (branch in groups.keys) {
if (branch != null && ( if (branch != null && (
branch.contains(displayLanguage, ignoreCase = true) || branch.contains(displayLanguage, ignoreCase = true) ||
@@ -60,9 +61,19 @@ fun Manga.getPreferredBranch(history: MangaHistory?): String? {
candidates[branch] = groups[branch] ?: continue candidates[branch] = groups[branch] ?: continue
} }
} }
if (candidates.isNotEmpty()) {
return candidates.maxBy { it.value.size }.key
}
} }
return candidates.ifEmpty { groups }.maxByOrNull { it.value.size }?.key return groups.maxByOrNull { it.value.size }?.key
} }
val Manga.isLocal: Boolean val Manga.isLocal: Boolean
get() = source == MangaSource.LOCAL get() = source == MangaSource.LOCAL
val Manga.appUrl: Uri
get() = Uri.parse("https://kotatsu.app/manga").buildUpon()
.appendQueryParameter("source", source.name)
.appendQueryParameter("name", title)
.appendQueryParameter("url", url)
.build()

View File

@@ -8,7 +8,7 @@ import java.io.ObjectInputStream
import java.io.ObjectOutputStream import java.io.ObjectOutputStream
class CookieWrapper( data class CookieWrapper(
val cookie: Cookie, val cookie: Cookie,
) { ) {
@@ -66,17 +66,4 @@ class CookieWrapper(
fun key(): String { fun key(): String {
return (if (cookie.secure) "https" else "http") + "://" + cookie.domain + cookie.path + "|" + cookie.name return (if (cookie.secure) "https" else "http") + "://" + cookie.domain + cookie.path + "|" + cookie.name
} }
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as CookieWrapper
return cookie == other.cookie
}
override fun hashCode(): Int {
return cookie.hashCode()
}
} }

View File

@@ -0,0 +1,15 @@
package org.koitharu.kotatsu.core.os
import android.content.Intent
import android.os.Build
import android.provider.Settings
@Suppress("FunctionName")
fun NetworkManageIntent(): Intent {
val action = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
Settings.Panel.ACTION_INTERNET_CONNECTIVITY
} else {
Settings.ACTION_WIRELESS_SETTINGS
}
return Intent(action)
}

View File

@@ -17,10 +17,12 @@ import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.reader.domain.ReaderColorFilter import org.koitharu.kotatsu.reader.domain.ReaderColorFilter
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Provider
@Reusable @Reusable
class MangaDataRepository @Inject constructor( class MangaDataRepository @Inject constructor(
private val db: MangaDatabase, private val db: MangaDatabase,
private val resolverProvider: Provider<MangaLinkResolver>,
) { ) {
suspend fun saveReaderMode(manga: Manga, mode: ReaderMode) { suspend fun saveReaderMode(manga: Manga, mode: ReaderMode) {
@@ -63,10 +65,15 @@ class MangaDataRepository @Inject constructor(
return db.mangaDao.find(mangaId)?.toManga() return db.mangaDao.find(mangaId)?.toManga()
} }
suspend fun findMangaByPublicUrl(publicUrl: String): Manga? {
return db.mangaDao.findByPublicUrl(publicUrl)?.toManga()
}
suspend fun resolveIntent(intent: MangaIntent): Manga? = when { suspend fun resolveIntent(intent: MangaIntent): Manga? = when {
intent.manga != null -> intent.manga intent.manga != null -> intent.manga
intent.mangaId != 0L -> findMangaById(intent.mangaId) intent.mangaId != 0L -> findMangaById(intent.mangaId)
else -> null // TODO resolve uri intent.uri != null -> resolverProvider.get().resolve(intent.uri)
else -> null
} }
suspend fun storeManga(manga: Manga) { suspend fun storeManga(manga: Manga) {

View File

@@ -0,0 +1,119 @@
package org.koitharu.kotatsu.core.parser
import android.net.Uri
import dagger.Reusable
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
import org.koitharu.kotatsu.parsers.exception.NotFoundException
import org.koitharu.kotatsu.parsers.model.ContentType
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.almostEquals
import org.koitharu.kotatsu.parsers.util.levenshteinDistance
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.parsers.util.toRelativeUrl
import javax.inject.Inject
@Reusable
class MangaLinkResolver @Inject constructor(
private val repositoryFactory: MangaRepository.Factory,
private val sourcesRepository: MangaSourcesRepository,
private val dataRepository: MangaDataRepository,
) {
suspend fun resolve(uri: Uri): Manga {
return if (uri.host == "kotatsu.app") {
resolveAppLink(uri)
} else {
resolveExternalLink(uri)
} ?: throw NotFoundException("Manga not found", uri.toString())
}
suspend fun resolveAppLink(uri: Uri): Manga? {
require(uri.pathSegments.singleOrNull() == "manga") { "Invalid url" }
val sourceName = requireNotNull(uri.getQueryParameter("source")) { "Source is not specified" }
val source = MangaSource(sourceName)
require(source != MangaSource.DUMMY) { "Manga source $sourceName is not supported" }
val repo = repositoryFactory.create(source)
return repo.findExact(
url = uri.getQueryParameter("url"),
title = uri.getQueryParameter("name"),
)
}
suspend fun resolveExternalLink(uri: Uri): Manga? {
dataRepository.findMangaByPublicUrl(uri.toString())?.let {
return it
}
val host = uri.host ?: return null
val repo = sourcesRepository.allMangaSources.asSequence()
.map { source ->
repositoryFactory.create(source) as RemoteMangaRepository
}.find { repo ->
host in repo.domains
} ?: return null
return repo.findExact(uri.toString().toRelativeUrl(host), null)
}
private suspend fun MangaRepository.findExact(url: String?, title: String?): Manga? {
if (!title.isNullOrEmpty()) {
val list = getList(0, title)
if (url != null) {
list.find { it.url == url }?.let {
return it
}
}
list.minByOrNull { it.title.levenshteinDistance(title) }
?.takeIf { it.title.almostEquals(title, 0.2f) }
?.let { return it }
}
val seed = getDetailsNoCache(
getSeedManga(source, url ?: return null, title),
)
return runCatchingCancellable {
val seedTitle = seed.title.ifEmpty {
seed.altTitle
}.ifNullOrEmpty {
seed.author
} ?: return@runCatchingCancellable null
val seedList = getList(0, seedTitle)
seedList.first { x -> x.url == url }
}.getOrThrow()
}
private suspend fun MangaRepository.getDetailsNoCache(manga: Manga): Manga {
return if (this is RemoteMangaRepository) {
getDetails(manga, withCache = false)
} else {
getDetails(manga)
}
}
private fun getSeedManga(source: MangaSource, url: String, title: String?) = Manga(
id = run {
var h = 1125899906842597L
source.name.forEach { c ->
h = 31 * h + c.code
}
url.forEach { c ->
h = 31 * h + c.code
}
h
},
title = title.orEmpty(),
altTitle = null,
url = url,
publicUrl = "",
rating = 0.0f,
isNsfw = source.contentType == ContentType.HENTAI,
coverUrl = "",
tags = emptySet(),
state = null,
author = null,
largeCoverUrl = null,
description = null,
chapters = null,
source = source,
)
}

View File

@@ -51,6 +51,9 @@ class RemoteMangaRepository(
getConfig()[parser.configKeyDomain] = value getConfig()[parser.configKeyDomain] = value
} }
val domains: Array<out String>
get() = parser.configKeyDomain.presetValues
val headers: Headers val headers: Headers
get() = parser.headers get() = parser.headers
@@ -70,14 +73,7 @@ class RemoteMangaRepository(
return parser.getList(offset, tags, sortOrder) return parser.getList(offset, tags, sortOrder)
} }
override suspend fun getDetails(manga: Manga): Manga { override suspend fun getDetails(manga: Manga): Manga = getDetails(manga, withCache = true)
cache.getDetails(source, manga.url)?.let { return it }
val details = asyncSafe {
parser.getDetails(manga)
}
cache.putDetails(source, manga.url, details)
return details.await()
}
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> { override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
cache.getPages(source, chapter.url)?.let { return it } cache.getPages(source, chapter.url)?.let { return it }
@@ -103,6 +99,23 @@ class RemoteMangaRepository(
return related.await() return related.await()
} }
suspend fun getDetails(manga: Manga, withCache: Boolean): Manga {
if (!withCache) {
return parser.getDetails(manga)
}
cache.getDetails(source, manga.url)?.let { return it }
val details = asyncSafe {
parser.getDetails(manga)
}
cache.putDetails(source, manga.url, details)
return details.await()
}
suspend fun find(manga: Manga): Manga? {
val list = getList(0, manga.title)
return list.find { x -> x.id == manga.id }
}
fun getAuthProvider(): MangaParserAuthProvider? = parser as? MangaParserAuthProvider fun getAuthProvider(): MangaParserAuthProvider? = parser as? MangaParserAuthProvider
fun getConfigKeys(): List<ConfigKey<*>> = ArrayList<ConfigKey<*>>().also { fun getConfigKeys(): List<ConfigKey<*>> = ArrayList<ConfigKey<*>>().also {

View File

@@ -14,23 +14,25 @@ import coil.network.HttpException
import coil.request.Options import coil.request.Options
import coil.size.Size import coil.size.Size
import coil.size.pxOrElse import coil.size.pxOrElse
import kotlinx.coroutines.ensureActive
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.Response import okhttp3.Response
import okhttp3.ResponseBody import okhttp3.ResponseBody
import okhttp3.internal.closeQuietly import okhttp3.internal.closeQuietly
import okio.Closeable import okio.Closeable
import okio.IOException
import okio.buffer import okio.buffer
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.core.util.ext.writeAllCancellable
import org.koitharu.kotatsu.local.data.CacheDir import org.koitharu.kotatsu.local.data.CacheDir
import org.koitharu.kotatsu.local.data.util.withExtraCloseable import org.koitharu.kotatsu.local.data.util.withExtraCloseable
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.await import org.koitharu.kotatsu.parsers.util.await
import java.net.HttpURLConnection import java.net.HttpURLConnection
import kotlin.coroutines.coroutineContext
private const val FALLBACK_SIZE = 9999 // largest icon private const val FALLBACK_SIZE = 9999 // largest icon
@@ -56,13 +58,16 @@ class FaviconFetcher(
options.size.height.pxOrElse { FALLBACK_SIZE }, options.size.height.pxOrElse { FALLBACK_SIZE },
) )
var favicons = repo.getFavicons() var favicons = repo.getFavicons()
var lastError: Exception? = null
while (favicons.isNotEmpty()) { while (favicons.isNotEmpty()) {
val icon = favicons.find(sizePx) ?: throwNSEE() coroutineContext.ensureActive()
val icon = favicons.find(sizePx) ?: throwNSEE(lastError)
val response = try { val response = try {
loadIcon(icon.url, mangaSource) loadIcon(icon.url, mangaSource)
} catch (e: CloudFlareProtectedException) { } catch (e: CloudFlareProtectedException) {
throw e throw e
} catch (e: IOException) { } catch (e: HttpException) {
lastError = e
favicons -= icon favicons -= icon
continue continue
} }
@@ -76,7 +81,7 @@ class FaviconFetcher(
dataSource = response.toDataSource(), dataSource = response.toDataSource(),
) )
} }
throwNSEE() throwNSEE(lastError)
} }
private suspend fun loadIcon(url: String, source: MangaSource): Response { private suspend fun loadIcon(url: String, source: MangaSource): Response {
@@ -106,14 +111,14 @@ class FaviconFetcher(
) )
} }
private fun writeToDiskCache(body: ResponseBody): DiskCache.Snapshot? { private suspend fun writeToDiskCache(body: ResponseBody): DiskCache.Snapshot? {
if (!options.diskCachePolicy.writeEnabled || body.contentLength() == 0L) { if (!options.diskCachePolicy.writeEnabled || body.contentLength() == 0L) {
return null return null
} }
val editor = diskCache.value?.openEditor(diskCacheKey) ?: return null val editor = diskCache.value?.openEditor(diskCacheKey) ?: return null
try { try {
fileSystem.write(editor.data) { fileSystem.write(editor.data) {
body.source().readAll(this) writeAllCancellable(body.source())
} }
return editor.commitAndOpenSnapshot() return editor.commitAndOpenSnapshot()
} catch (e: Throwable) { } catch (e: Throwable) {
@@ -155,7 +160,13 @@ class FaviconFetcher(
append(height.toString()) append(height.toString())
} }
private fun throwNSEE(): Nothing = throw NoSuchElementException("No favicons found") private fun throwNSEE(lastError: Exception?): Nothing {
if (lastError != null) {
throw lastError
} else {
throw NoSuchElementException("No favicons found")
}
}
class Factory( class Factory(
context: Context, context: Context,

View File

@@ -51,6 +51,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val isAmoledTheme: Boolean val isAmoledTheme: Boolean
get() = prefs.getBoolean(KEY_THEME_AMOLED, false) get() = prefs.getBoolean(KEY_THEME_AMOLED, false)
val isFavoritesNavItemFirst: Boolean
get() = (prefs.getString(KEY_FIRST_NAV_ITEM, null)?.toIntOrNull() ?: 0) == 1
var gridSize: Int var gridSize: Int
get() = prefs.getInt(KEY_GRID_SIZE, 100) get() = prefs.getInt(KEY_GRID_SIZE, 100)
set(value) = prefs.edit { putInt(KEY_GRID_SIZE, value) } set(value) = prefs.edit { putInt(KEY_GRID_SIZE, value) }
@@ -280,6 +283,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
get() = prefs.getEnumValue(KEY_HISTORY_ORDER, HistoryOrder.UPDATED) get() = prefs.getEnumValue(KEY_HISTORY_ORDER, HistoryOrder.UPDATED)
set(value) = prefs.edit { putEnumValue(KEY_HISTORY_ORDER, value) } set(value) = prefs.edit { putEnumValue(KEY_HISTORY_ORDER, value) }
val isRelatedMangaEnabled: Boolean
get() = prefs.getBoolean(KEY_RELATED_MANGA, true)
val isWebtoonZoomEnable: Boolean val isWebtoonZoomEnable: Boolean
get() = prefs.getBoolean(KEY_WEBTOON_ZOOM, true) get() = prefs.getBoolean(KEY_WEBTOON_ZOOM, true)
@@ -448,6 +454,8 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_IMAGES_PROXY = "images_proxy" const val KEY_IMAGES_PROXY = "images_proxy"
const val KEY_LOCAL_MANGA_DIRS = "local_manga_dirs" const val KEY_LOCAL_MANGA_DIRS = "local_manga_dirs"
const val KEY_DISABLE_NSFW = "no_nsfw" const val KEY_DISABLE_NSFW = "no_nsfw"
const val KEY_RELATED_MANGA = "related_manga"
const val KEY_FIRST_NAV_ITEM = "nav_first"
// About // About
const val KEY_APP_UPDATE = "app_update" const val KEY_APP_UPDATE = "app_update"

View File

@@ -1,15 +1,38 @@
package org.koitharu.kotatsu.core.prefs package org.koitharu.kotatsu.core.prefs
import android.appwidget.AppWidgetProvider
import android.content.Context import android.content.Context
import android.os.Build
import androidx.core.content.edit import androidx.core.content.edit
private const val CATEGORY_ID = "cat_id" private const val CATEGORY_ID = "cat_id"
private const val BACKGROUND = "bg"
class AppWidgetConfig(context: Context, val widgetId: Int) { class AppWidgetConfig(
context: Context,
cls: Class<out AppWidgetProvider>,
val widgetId: Int,
) {
private val prefs = context.getSharedPreferences("appwidget_$widgetId", Context.MODE_PRIVATE) private val prefs = context.getSharedPreferences("appwidget_${cls.simpleName}_$widgetId", Context.MODE_PRIVATE)
var categoryId: Long var categoryId: Long
get() = prefs.getLong(CATEGORY_ID, 0L) get() = prefs.getLong(CATEGORY_ID, 0L)
set(value) = prefs.edit { putLong(CATEGORY_ID, value) } set(value) = prefs.edit { putLong(CATEGORY_ID, value) }
var hasBackground: Boolean
get() = prefs.getBoolean(BACKGROUND, Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
set(value) = prefs.edit { putBoolean(BACKGROUND, value) }
fun clear() {
prefs.edit { clear() }
}
fun copyFrom(other: AppWidgetConfig) {
prefs.edit {
clear()
putLong(CATEGORY_ID, other.categoryId)
putBoolean(BACKGROUND, other.hasBackground)
}
}
} }

View File

@@ -3,8 +3,8 @@ package org.koitharu.kotatsu.core.prefs
enum class ReaderMode(val id: Int) { enum class ReaderMode(val id: Int) {
STANDARD(1), STANDARD(1),
WEBTOON(2), REVERSED(3),
REVERSED(3); WEBTOON(2);
companion object { companion object {

View File

@@ -0,0 +1,51 @@
package org.koitharu.kotatsu.core.ui
import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProvider
import android.content.Context
import android.widget.RemoteViews
import androidx.annotation.CallSuper
import org.koitharu.kotatsu.core.prefs.AppWidgetConfig
abstract class BaseAppWidgetProvider : AppWidgetProvider() {
@CallSuper
override fun onUpdate(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetIds: IntArray
) {
appWidgetIds.forEach { id ->
val config = AppWidgetConfig(context, javaClass, id)
val views = onUpdateWidget(context, config)
appWidgetManager.updateAppWidget(id, views)
}
}
override fun onDeleted(context: Context, appWidgetIds: IntArray) {
super.onDeleted(context, appWidgetIds)
for (id in appWidgetIds) {
AppWidgetConfig(context, javaClass, id).clear()
}
}
override fun onRestored(context: Context, oldWidgetIds: IntArray, newWidgetIds: IntArray) {
super.onRestored(context, oldWidgetIds, newWidgetIds)
if (oldWidgetIds.size != newWidgetIds.size) {
return
}
for (i in oldWidgetIds.indices) {
val oldId = oldWidgetIds[i]
val newId = newWidgetIds[i]
val oldConfig = AppWidgetConfig(context, javaClass, oldId)
val newConfig = AppWidgetConfig(context, javaClass, newId)
newConfig.copyFrom(oldConfig)
oldConfig.clear()
}
}
protected abstract fun onUpdateWidget(
context: Context,
config: AppWidgetConfig,
): RemoteViews
}

View File

@@ -13,13 +13,10 @@ import org.koitharu.kotatsu.list.ui.adapter.ListItemType
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import kotlin.coroutines.suspendCoroutine import kotlin.coroutines.suspendCoroutine
open class BaseListAdapter<T : ListModel>( open class BaseListAdapter<T : ListModel> : AsyncListDifferDelegationAdapter<T>(
vararg delegates: AdapterDelegate<List<T>>,
) : AsyncListDifferDelegationAdapter<T>(
AsyncDifferConfig.Builder(ListModelDiffCallback<T>()) AsyncDifferConfig.Builder(ListModelDiffCallback<T>())
.setBackgroundThreadExecutor(Dispatchers.Default.limitedParallelism(2).asExecutor()) .setBackgroundThreadExecutor(Dispatchers.Default.limitedParallelism(2).asExecutor())
.build(), .build(),
*delegates,
), FlowCollector<List<T>?> { ), FlowCollector<List<T>?> {
override suspend fun emit(value: List<T>?) = suspendCoroutine { cont -> override suspend fun emit(value: List<T>?) = suspendCoroutine { cont ->

View File

@@ -106,12 +106,7 @@ class TrimTransformation(
} }
override fun equals(other: Any?): Boolean { override fun equals(other: Any?): Boolean {
if (this === other) return true return this === other || (other is TrimTransformation && other.tolerance == tolerance)
if (javaClass != other?.javaClass) return false
other as TrimTransformation
return tolerance == other.tolerance
} }
override fun hashCode(): Int { override fun hashCode(): Int {

View File

@@ -20,38 +20,19 @@ sealed class DateTimeAgo {
override fun equals(other: Any?): Boolean = other === JustNow override fun equals(other: Any?): Boolean = other === JustNow
} }
class MinutesAgo(val minutes: Int) : DateTimeAgo() { data class MinutesAgo(val minutes: Int) : DateTimeAgo() {
override fun format(resources: Resources): String { override fun format(resources: Resources): String {
return resources.getQuantityString(R.plurals.minutes_ago, minutes, minutes) return resources.getQuantityString(R.plurals.minutes_ago, minutes, minutes)
} }
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as MinutesAgo
return minutes == other.minutes
}
override fun hashCode(): Int = minutes
override fun toString() = "minutes_ago_$minutes" override fun toString() = "minutes_ago_$minutes"
} }
class HoursAgo(val hours: Int) : DateTimeAgo() { data class HoursAgo(val hours: Int) : DateTimeAgo() {
override fun format(resources: Resources): String { override fun format(resources: Resources): String {
return resources.getQuantityString(R.plurals.hours_ago, hours, hours) return resources.getQuantityString(R.plurals.hours_ago, hours, hours)
} }
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as HoursAgo
return hours == other.hours
}
override fun hashCode(): Int = hours
override fun toString() = "hours_ago_$hours" override fun toString() = "hours_ago_$hours"
} }
@@ -75,26 +56,15 @@ sealed class DateTimeAgo {
override fun equals(other: Any?): Boolean = other === Yesterday override fun equals(other: Any?): Boolean = other === Yesterday
} }
class DaysAgo(val days: Int) : DateTimeAgo() { data class DaysAgo(val days: Int) : DateTimeAgo() {
override fun format(resources: Resources): String { override fun format(resources: Resources): String {
return resources.getQuantityString(R.plurals.days_ago, days, days) return resources.getQuantityString(R.plurals.days_ago, days, days)
} }
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as DaysAgo
return days == other.days
}
override fun hashCode(): Int = days
override fun toString() = "days_ago_$days" override fun toString() = "days_ago_$days"
} }
class MonthsAgo(val months: Int) : DateTimeAgo() { data class MonthsAgo(val months: Int) : DateTimeAgo() {
override fun format(resources: Resources): String { override fun format(resources: Resources): String {
return if (months == 0) { return if (months == 0) {
resources.getString(R.string.this_month) resources.getString(R.string.this_month)
@@ -102,19 +72,6 @@ sealed class DateTimeAgo {
resources.getQuantityString(R.plurals.months_ago, months, months) resources.getQuantityString(R.plurals.months_ago, months, months)
} }
} }
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as MonthsAgo
return months == other.months
}
override fun hashCode(): Int {
return months
}
} }
class Absolute(private val date: Date) : DateTimeAgo() { class Absolute(private val date: Date) : DateTimeAgo() {

View File

@@ -25,4 +25,9 @@ class ActivityRecreationHandle @Inject constructor() : DefaultActivityLifecycleC
val snapshot = activities.keys.toList() val snapshot = activities.keys.toList()
snapshot.forEach { ActivityCompat.recreate(it) } snapshot.forEach { ActivityCompat.recreate(it) }
} }
fun recreate(cls: Class<out Activity>) {
val activity = activities.keys.find { x -> x.javaClass == cls } ?: return
ActivityCompat.recreate(activity)
}
} }

View File

@@ -139,39 +139,14 @@ class ChipsView @JvmOverloads constructor(
} }
} }
class ChipModel( data class ChipModel(
@ColorRes val tint: Int, @ColorRes val tint: Int,
val title: CharSequence, val title: CharSequence,
@DrawableRes val icon: Int, @DrawableRes val icon: Int,
val isCheckable: Boolean, val isCheckable: Boolean,
val isChecked: Boolean, val isChecked: Boolean,
val data: Any? = null, val data: Any? = null,
) { )
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as ChipModel
if (tint != other.tint) return false
if (title != other.title) return false
if (icon != other.icon) return false
if (isCheckable != other.isCheckable) return false
if (isChecked != other.isChecked) return false
return data == other.data
}
override fun hashCode(): Int {
var result = tint.hashCode()
result = 31 * result + title.hashCode()
result = 31 * result + icon.hashCode()
result = 31 * result + isCheckable.hashCode()
result = 31 * result + isChecked.hashCode()
result = 31 * result + (data?.hashCode() ?: 0)
return result
}
}
fun interface OnChipClickListener { fun interface OnChipClickListener {

View File

@@ -118,27 +118,10 @@ class SegmentedBarView @JvmOverloads constructor(
segmentsSizes.add(w) segmentsSizes.add(w)
} }
class Segment( data class Segment(
@FloatRange(from = 0.0, to = 1.0) val percent: Float, @FloatRange(from = 0.0, to = 1.0) val percent: Float,
@ColorInt val color: Int, @ColorInt val color: Int,
) { )
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as Segment
if (percent != other.percent) return false
return color == other.color
}
override fun hashCode(): Int {
var result = percent.hashCode()
result = 31 * result + color
return result
}
}
private class OutlineProvider : ViewOutlineProvider() { private class OutlineProvider : ViewOutlineProvider() {
override fun getOutline(view: View, outline: Outline) { override fun getOutline(view: View, outline: Outline) {

View File

@@ -76,7 +76,7 @@ class TipView @JvmOverloads constructor(
val shapeAppearanceModel = ShapeAppearanceModel.builder(context, attrs, defStyleAttr, 0).build() val shapeAppearanceModel = ShapeAppearanceModel.builder(context, attrs, defStyleAttr, 0).build()
background = MaterialShapeDrawable(shapeAppearanceModel).also { background = MaterialShapeDrawable(shapeAppearanceModel).also {
it.fillColor = getColorStateList(R.styleable.TipView_cardBackgroundColor) it.fillColor = getColorStateList(R.styleable.TipView_cardBackgroundColor)
?: context.getThemeColorStateList(materialR.attr.colorBackgroundFloating) ?: context.getThemeColorStateList(R.attr.m3ColorExploreButton)
it.strokeWidth = getDimension(R.styleable.TipView_strokeWidth, 0f) it.strokeWidth = getDimension(R.styleable.TipView_strokeWidth, 0f)
it.strokeColor = getColorStateList(R.styleable.TipView_strokeColor) it.strokeColor = getColorStateList(R.styleable.TipView_strokeColor)
it.elevation = getDimension(R.styleable.TipView_elevation, 0f) it.elevation = getDimension(R.styleable.TipView_elevation, 0f)

View File

@@ -0,0 +1,43 @@
package org.koitharu.kotatsu.core.util
import androidx.collection.ArrayMap
import kotlinx.coroutines.sync.Mutex
class CompositeMutex2<T : Any> : Set<T> {
private val delegates = ArrayMap<T, Mutex>()
override val size: Int
get() = delegates.size
override fun contains(element: T): Boolean {
return delegates.containsKey(element)
}
override fun containsAll(elements: Collection<T>): Boolean {
return elements.all { x -> delegates.containsKey(x) }
}
override fun isEmpty(): Boolean {
return delegates.isEmpty
}
override fun iterator(): Iterator<T> {
return delegates.keys.iterator()
}
suspend fun lock(element: T) {
val mutex = synchronized(delegates) {
delegates.getOrPut(element) {
Mutex()
}
}
mutex.lock()
}
fun unlock(element: T) {
synchronized(delegates) {
delegates.remove(element)?.unlock()
}
}
}

View File

@@ -8,6 +8,7 @@ import androidx.core.content.FileProvider
import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.logs.FileLogger import org.koitharu.kotatsu.core.logs.FileLogger
import org.koitharu.kotatsu.core.model.appUrl
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import java.io.File import java.io.File
@@ -22,6 +23,8 @@ class ShareHelper(private val context: Context) {
append(manga.title) append(manga.title)
append("\n \n") append("\n \n")
append(manga.publicUrl) append(manga.publicUrl)
append("\n \n")
append(manga.appUrl)
} }
ShareCompat.IntentBuilder(context) ShareCompat.IntentBuilder(context)
.setText(text) .setText(text)

View File

@@ -0,0 +1,48 @@
package org.koitharu.kotatsu.core.util
import android.annotation.SuppressLint
import androidx.lifecycle.asFlow
import androidx.work.WorkInfo
import androidx.work.WorkManager
import androidx.work.WorkQuery
import androidx.work.impl.foreground.SystemForegroundService
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
import javax.inject.Provider
/**
* Workaround for issue
* https://issuetracker.google.com/issues/270245927
* https://issuetracker.google.com/issues/280504155
*/
class WorkServiceStopHelper(
private val workManagerProvider: Provider<WorkManager>,
) {
fun setup() {
processLifecycleScope.launch(Dispatchers.Default) {
workManagerProvider.get()
.getWorkInfosLiveData(WorkQuery.fromStates(WorkInfo.State.RUNNING))
.asFlow()
.map { it.isEmpty() }
.distinctUntilChanged()
.collectLatest {
if (it) {
delay(1_000)
stopWorkerService()
}
}
}
}
@SuppressLint("RestrictedApi")
private fun stopWorkerService() {
SystemForegroundService.getInstance()?.stop()
}
}

View File

@@ -83,7 +83,7 @@ fun <I> ActivityResultLauncher<I>.tryLaunch(
e.printStackTraceDebug() e.printStackTraceDebug()
}.isSuccess }.isSuccess
fun SharedPreferences.observe() = callbackFlow<String> { fun SharedPreferences.observe() = callbackFlow<String?> {
val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key -> val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
trySendBlocking(key) trySendBlocking(key)
} }
@@ -102,7 +102,7 @@ fun <T> SharedPreferences.observe(key: String, valueProducer: suspend () -> T):
} }
}.distinctUntilChanged() }.distinctUntilChanged()
fun Lifecycle.postDelayed(runnable: Runnable, delay: Long) { fun Lifecycle.postDelayed(delay: Long, runnable: Runnable) {
coroutineScope.launch { coroutineScope.launch {
delay(delay) delay(delay)
runnable.run() runnable.run()

View File

@@ -12,7 +12,6 @@ import coil.request.SuccessResult
import coil.util.CoilUtils import coil.util.CoilUtils
import com.google.android.material.progressindicator.BaseProgressIndicator import com.google.android.material.progressindicator.BaseProgressIndicator
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.browser.cloudflare.CaptchaNotifier
import org.koitharu.kotatsu.core.ui.image.RegionBitmapDecoder import org.koitharu.kotatsu.core.ui.image.RegionBitmapDecoder
import org.koitharu.kotatsu.core.util.progress.ImageRequestIndicatorListener import org.koitharu.kotatsu.core.util.progress.ImageRequestIndicatorListener
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
@@ -29,7 +28,6 @@ fun ImageView.newImageRequest(lifecycleOwner: LifecycleOwner, data: Any?): Image
.data(data) .data(data)
.lifecycle(lifecycleOwner) .lifecycle(lifecycleOwner)
.crossfade(context) .crossfade(context)
.listener(CaptchaNotifier(context.applicationContext))
.target(this) .target(this)
} }
@@ -65,11 +63,11 @@ fun ImageResult.toBitmapOrNull() = when (this) {
} }
fun ImageRequest.Builder.indicator(indicator: BaseProgressIndicator<*>): ImageRequest.Builder { fun ImageRequest.Builder.indicator(indicator: BaseProgressIndicator<*>): ImageRequest.Builder {
return listener(ImageRequestIndicatorListener(listOf(indicator))) return addListener(ImageRequestIndicatorListener(listOf(indicator)))
} }
fun ImageRequest.Builder.indicator(indicators: List<BaseProgressIndicator<*>>): ImageRequest.Builder { fun ImageRequest.Builder.indicator(indicators: List<BaseProgressIndicator<*>>): ImageRequest.Builder {
return listener(ImageRequestIndicatorListener(indicators)) return addListener(ImageRequestIndicatorListener(indicators))
} }
fun ImageRequest.Builder.decodeRegion( fun ImageRequest.Builder.decodeRegion(
@@ -86,3 +84,30 @@ fun ImageRequest.Builder.crossfade(context: Context): ImageRequest.Builder {
fun ImageRequest.Builder.source(source: MangaSource?): ImageRequest.Builder { fun ImageRequest.Builder.source(source: MangaSource?): ImageRequest.Builder {
return tag(MangaSource::class.java, source) return tag(MangaSource::class.java, source)
} }
fun ImageRequest.Builder.addListener(listener: ImageRequest.Listener): ImageRequest.Builder {
val existing = build().listener
return listener(
when (existing) {
null -> listener
is CompositeImageRequestListener -> existing + listener
else -> CompositeImageRequestListener(arrayOf(existing, listener))
},
)
}
private class CompositeImageRequestListener(
private val delegates: Array<ImageRequest.Listener>,
) : ImageRequest.Listener {
override fun onCancel(request: ImageRequest) = delegates.forEach { it.onCancel(request) }
override fun onError(request: ImageRequest, result: ErrorResult) = delegates.forEach { it.onError(request, result) }
override fun onStart(request: ImageRequest) = delegates.forEach { it.onStart(request) }
override fun onSuccess(request: ImageRequest, result: SuccessResult) =
delegates.forEach { it.onSuccess(request, result) }
operator fun plus(other: ImageRequest.Listener) = CompositeImageRequestListener(delegates + other)
}

View File

@@ -10,6 +10,7 @@ import androidx.lifecycle.ProcessLifecycleOwner
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import dagger.hilt.android.lifecycle.RetainedLifecycle import dagger.hilt.android.lifecycle.RetainedLifecycle
import kotlinx.coroutines.CancellableContinuation import kotlinx.coroutines.CancellableContinuation
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.suspendCancellableCoroutine
import org.koitharu.kotatsu.core.util.RetainedLifecycleCoroutineScope import org.koitharu.kotatsu.core.util.RetainedLifecycleCoroutineScope
@@ -70,3 +71,11 @@ private fun Lifecycle.removeObserverFromAnyThread(observer: LifecycleObserver) {
removeObserver(observer) removeObserver(observer)
} }
} }
fun <T> Deferred<T>.getCompletionResultOrNull(): Result<T>? = if (isCompleted) {
getCompletionExceptionOrNull()?.let { error ->
Result.failure(error)
} ?: Result.success(getCompleted())
} else {
null
}

View File

@@ -4,7 +4,7 @@ import androidx.annotation.FloatRange
import org.koitharu.kotatsu.parsers.util.levenshteinDistance import org.koitharu.kotatsu.parsers.util.levenshteinDistance
import java.util.UUID import java.util.UUID
inline fun <C : CharSequence> C?.ifNullOrEmpty(defaultValue: () -> C): C { inline fun <C : CharSequence?> C?.ifNullOrEmpty(defaultValue: () -> C): C {
return if (this.isNullOrEmpty()) defaultValue() else this return if (this.isNullOrEmpty()) defaultValue() else this
} }

View File

@@ -3,7 +3,9 @@ package org.koitharu.kotatsu.core.util.ext
import android.content.ActivityNotFoundException import android.content.ActivityNotFoundException
import android.content.res.Resources import android.content.res.Resources
import android.util.AndroidRuntimeException import android.util.AndroidRuntimeException
import androidx.annotation.DrawableRes
import androidx.collection.arraySetOf import androidx.collection.arraySetOf
import coil.network.HttpException
import okio.FileNotFoundException import okio.FileNotFoundException
import okio.IOException import okio.IOException
import org.acra.ktx.sendWithAcra import org.acra.ktx.sendWithAcra
@@ -50,10 +52,8 @@ fun Throwable.getDisplayMessage(resources: Resources): String = when (this) {
is WrongPasswordException -> resources.getString(R.string.wrong_password) is WrongPasswordException -> resources.getString(R.string.wrong_password)
is NotFoundException -> resources.getString(R.string.not_found_404) is NotFoundException -> resources.getString(R.string.not_found_404)
is HttpStatusException -> when (statusCode) { is HttpException -> getHttpDisplayMessage(response.code, resources)
in 500..599 -> resources.getString(R.string.server_error, statusCode) is HttpStatusException -> getHttpDisplayMessage(statusCode, resources)
else -> localizedMessage
}
is IOException -> getDisplayMessage(message, resources) ?: localizedMessage is IOException -> getDisplayMessage(message, resources) ?: localizedMessage
else -> localizedMessage else -> localizedMessage
@@ -61,6 +61,23 @@ fun Throwable.getDisplayMessage(resources: Resources): String = when (this) {
resources.getString(R.string.error_occurred) resources.getString(R.string.error_occurred)
} }
@DrawableRes
fun Throwable.getDisplayIcon() = when (this) {
is AuthRequiredException -> R.drawable.ic_auth_key_large
is CloudFlareProtectedException -> R.drawable.ic_bot_large
is UnknownHostException,
is SocketTimeoutException,
-> R.drawable.ic_plug_large
else -> R.drawable.ic_error_large
}
private fun getHttpDisplayMessage(statusCode: Int, resources: Resources): String? = when (statusCode) {
404 -> resources.getString(R.string.not_found_404)
in 500..599 -> resources.getString(R.string.server_error, statusCode)
else -> null
}
private fun getDisplayMessage(msg: String?, resources: Resources): String? = when { private fun getDisplayMessage(msg: String?, resources: Resources): String? = when {
msg.isNullOrEmpty() -> null msg.isNullOrEmpty() -> null
msg.contains(MSG_NO_SPACE_LEFT) -> resources.getString(R.string.error_no_space_left) msg.contains(MSG_NO_SPACE_LEFT) -> resources.getString(R.string.error_no_space_left)

View File

@@ -8,6 +8,7 @@ import android.view.ViewGroup
import android.view.ViewParent import android.view.ViewParent
import android.view.inputmethod.InputMethodManager import android.view.inputmethod.InputMethodManager
import android.widget.Checkable import android.widget.Checkable
import android.widget.CompoundButton
import androidx.core.view.children import androidx.core.view.children
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import androidx.viewpager2.widget.ViewPager2 import androidx.viewpager2.widget.ViewPager2

View File

@@ -53,6 +53,7 @@ import org.koitharu.kotatsu.details.ui.scrobbling.ScrollingInfoAdapter
import org.koitharu.kotatsu.history.data.PROGRESS_NONE import org.koitharu.kotatsu.history.data.PROGRESS_NONE
import org.koitharu.kotatsu.image.ui.ImageActivity import org.koitharu.kotatsu.image.ui.ImageActivity
import org.koitharu.kotatsu.list.domain.ListExtraProvider import org.koitharu.kotatsu.list.domain.ListExtraProvider
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
import org.koitharu.kotatsu.list.ui.adapter.mangaGridItemAD import org.koitharu.kotatsu.list.ui.adapter.mangaGridItemAD
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.MangaItemModel import org.koitharu.kotatsu.list.ui.model.MangaItemModel
@@ -227,14 +228,16 @@ class DetailsFragment :
val rv = viewBinding?.recyclerViewRelated ?: return val rv = viewBinding?.recyclerViewRelated ?: return
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
val adapter = (rv.adapter as? BaseListAdapter<ListModel>) ?: BaseListAdapter( val adapter = (rv.adapter as? BaseListAdapter<ListModel>) ?: BaseListAdapter<ListModel>()
mangaGridItemAD( .addDelegate(
coil, viewLifecycleOwner, ListItemType.MANGA_GRID,
StaticItemSizeResolver(resources.getDimensionPixelSize(R.dimen.smaller_grid_width)), mangaGridItemAD(
) { item, view -> coil, viewLifecycleOwner,
startActivity(DetailsActivity.newIntent(view.context, item)) StaticItemSizeResolver(resources.getDimensionPixelSize(R.dimen.smaller_grid_width)),
}, ) { item, view ->
).also { rv.adapter = it } startActivity(DetailsActivity.newIntent(view.context, item))
},
).also { rv.adapter = it }
adapter.items = related adapter.items = related
requireViewBinding().groupRelated.isVisible = true requireViewBinding().groupRelated.isVisible = true
} }

View File

@@ -167,7 +167,7 @@ class DetailsViewModel @Inject constructor(
it?.remote it?.remote
}.distinctUntilChangedBy { it?.id } }.distinctUntilChangedBy { it?.id }
.mapLatest { .mapLatest {
if (it != null) { if (it != null && settings.isRelatedMangaEnabled) {
relatedMangaUseCase.invoke(it)?.toUi(ListMode.GRID, extraProvider).orEmpty() relatedMangaUseCase.invoke(it)?.toUi(ListMode.GRID, extraProvider).orEmpty()
} else { } else {
emptyList() emptyList()

View File

@@ -4,7 +4,7 @@ import android.text.format.DateUtils
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
class ChapterListItem( data class ChapterListItem(
val chapter: MangaChapter, val chapter: MangaChapter,
val flags: Int, val flags: Int,
private val uploadDateMs: Long, private val uploadDateMs: Long,
@@ -66,24 +66,6 @@ class ChapterListItem(
} }
} }
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as ChapterListItem
if (chapter != other.chapter) return false
if (flags != other.flags) return false
return uploadDateMs == other.uploadDateMs
}
override fun hashCode(): Int {
var result = chapter.hashCode()
result = 31 * result + flags
result = 31 * result + uploadDateMs.hashCode()
return result
}
companion object { companion object {
const val FLAG_UNREAD = 2 const val FLAG_UNREAD = 2

View File

@@ -3,35 +3,14 @@ package org.koitharu.kotatsu.details.ui.model
import org.koitharu.kotatsu.core.model.MangaHistory import org.koitharu.kotatsu.core.model.MangaHistory
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
class HistoryInfo( data class HistoryInfo(
val totalChapters: Int, val totalChapters: Int,
val currentChapter: Int, val currentChapter: Int,
val history: MangaHistory?, val history: MangaHistory?,
val isIncognitoMode: Boolean, val isIncognitoMode: Boolean,
) { ) {
val isValid: Boolean val isValid: Boolean
get() = totalChapters >= 0 get() = totalChapters >= 0
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as HistoryInfo
if (totalChapters != other.totalChapters) return false
if (currentChapter != other.currentChapter) return false
if (history != other.history) return false
return isIncognitoMode == other.isIncognitoMode
}
override fun hashCode(): Int {
var result = totalChapters
result = 31 * result + currentChapter
result = 31 * result + (history?.hashCode() ?: 0)
result = 31 * result + isIncognitoMode.hashCode()
return result
}
} }
fun HistoryInfo( fun HistoryInfo(

View File

@@ -3,7 +3,7 @@ package org.koitharu.kotatsu.details.ui.model
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
class MangaBranch( data class MangaBranch(
val name: String?, val name: String?,
val count: Int, val count: Int,
val isSelected: Boolean, val isSelected: Boolean,
@@ -21,24 +21,6 @@ class MangaBranch(
} }
} }
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as MangaBranch
if (name != other.name) return false
if (count != other.count) return false
return isSelected == other.isSelected
}
override fun hashCode(): Int {
var result = name.hashCode()
result = 31 * result + count
result = 31 * result + isSelected.hashCode()
return result
}
override fun toString(): String { override fun toString(): String {
return "$name: $count" return "$name: $count"
} }

View File

@@ -5,7 +5,6 @@ import androidx.lifecycle.LifecycleOwner
import coil.ImageLoader import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ext.disposeImageRequest
import org.koitharu.kotatsu.core.util.ext.enqueueWith import org.koitharu.kotatsu.core.util.ext.enqueueWith
import org.koitharu.kotatsu.core.util.ext.newImageRequest import org.koitharu.kotatsu.core.util.ext.newImageRequest
import org.koitharu.kotatsu.databinding.ItemScrobblingInfoBinding import org.koitharu.kotatsu.databinding.ItemScrobblingInfoBinding
@@ -37,8 +36,4 @@ fun scrobblingInfoAD(
context.resources.getStringArray(R.array.scrobbling_statuses).getOrNull(it.ordinal) context.resources.getStringArray(R.array.scrobbling_statuses).getOrNull(it.ordinal)
} }
} }
onViewRecycled {
binding.imageViewCover.disposeImageRequest()
}
} }

View File

@@ -8,7 +8,6 @@ import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.image.TrimTransformation import org.koitharu.kotatsu.core.ui.image.TrimTransformation
import org.koitharu.kotatsu.core.util.ext.disposeImageRequest
import org.koitharu.kotatsu.core.util.ext.enqueueWith import org.koitharu.kotatsu.core.util.ext.enqueueWith
import org.koitharu.kotatsu.core.util.ext.newImageRequest import org.koitharu.kotatsu.core.util.ext.newImageRequest
import org.koitharu.kotatsu.core.util.ext.source import org.koitharu.kotatsu.core.util.ext.source
@@ -135,8 +134,4 @@ fun downloadItemAD(
} }
} }
} }
onViewRecycled {
binding.imageViewCover.disposeImageRequest()
}
} }

View File

@@ -7,7 +7,7 @@ import org.koitharu.kotatsu.parsers.model.Manga
import java.util.Date import java.util.Date
import java.util.UUID import java.util.UUID
class DownloadItemModel( data class DownloadItemModel(
val id: UUID, val id: UUID,
val workState: WorkInfo.State, val workState: WorkInfo.State,
val isIndeterminate: Boolean, val isIndeterminate: Boolean,
@@ -64,38 +64,4 @@ class DownloadItemModel(
else -> super.getChangePayload(previousState) else -> super.getChangePayload(previousState)
} }
} }
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as DownloadItemModel
if (id != other.id) return false
if (workState != other.workState) return false
if (isIndeterminate != other.isIndeterminate) return false
if (isPaused != other.isPaused) return false
if (manga != other.manga) return false
if (error != other.error) return false
if (max != other.max) return false
if (totalChapters != other.totalChapters) return false
if (progress != other.progress) return false
if (eta != other.eta) return false
return timestamp == other.timestamp
}
override fun hashCode(): Int {
var result = id.hashCode()
result = 31 * result + workState.hashCode()
result = 31 * result + isIndeterminate.hashCode()
result = 31 * result + isPaused.hashCode()
result = 31 * result + manga.hashCode()
result = 31 * result + (error?.hashCode() ?: 0)
result = 31 * result + max
result = 31 * result + totalChapters
result = 31 * result + progress
result = 31 * result + eta.hashCode()
result = 31 * result + timestamp.hashCode()
return result
}
} }

View File

@@ -7,7 +7,6 @@ import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
import android.view.View import android.view.View
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.annotation.Px
import androidx.appcompat.view.ActionMode import androidx.appcompat.view.ActionMode
import androidx.core.graphics.Insets import androidx.core.graphics.Insets
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
@@ -37,16 +36,12 @@ class DownloadsActivity : BaseActivity<ActivityDownloadsBinding>(),
private val viewModel by viewModels<DownloadsViewModel>() private val viewModel by viewModels<DownloadsViewModel>()
private lateinit var selectionController: ListSelectionController private lateinit var selectionController: ListSelectionController
@Px
private var listSpacing = 0
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(ActivityDownloadsBinding.inflate(layoutInflater)) setContentView(ActivityDownloadsBinding.inflate(layoutInflater))
listSpacing = resources.getDimensionPixelOffset(R.dimen.list_spacing)
supportActionBar?.setDisplayHomeAsUpEnabled(true) supportActionBar?.setDisplayHomeAsUpEnabled(true)
val downloadsAdapter = DownloadsAdapter(this, coil, this) val downloadsAdapter = DownloadsAdapter(this, coil, this)
val decoration = TypedListSpacingDecoration(this) val decoration = TypedListSpacingDecoration(this, false)
selectionController = ListSelectionController( selectionController = ListSelectionController(
activity = this, activity = this,
decoration = DownloadsSelectionDecoration(this), decoration = DownloadsSelectionDecoration(this),
@@ -71,9 +66,10 @@ class DownloadsActivity : BaseActivity<ActivityDownloadsBinding>(),
} }
override fun onWindowInsetsChanged(insets: Insets) { override fun onWindowInsetsChanged(insets: Insets) {
viewBinding.recyclerView.updatePadding( val rv = viewBinding.recyclerView
left = insets.left + listSpacing, rv.updatePadding(
right = insets.right + listSpacing, left = insets.left + rv.paddingTop,
right = insets.right + rv.paddingTop,
bottom = insets.bottom, bottom = insets.bottom,
) )
viewBinding.toolbar.updatePadding( viewBinding.toolbar.updatePadding(
@@ -98,11 +94,11 @@ class DownloadsActivity : BaseActivity<ActivityDownloadsBinding>(),
} }
override fun onPauseClick(item: DownloadItemModel) { override fun onPauseClick(item: DownloadItemModel) {
sendBroadcast(PausingReceiver.getPauseIntent(item.id)) sendBroadcast(PausingReceiver.getPauseIntent(this, item.id))
} }
override fun onResumeClick(item: DownloadItemModel) { override fun onResumeClick(item: DownloadItemModel) {
sendBroadcast(PausingReceiver.getResumeIntent(item.id)) sendBroadcast(PausingReceiver.getResumeIntent(this, item.id))
} }
override fun onSelectionChanged(controller: ListSelectionController, count: Int) { override fun onSelectionChanged(controller: ListSelectionController, count: Int) {

View File

@@ -2,6 +2,8 @@ package org.koitharu.kotatsu.download.ui.worker
import android.app.NotificationManager import android.app.NotificationManager
import android.content.Context import android.content.Context
import android.content.pm.ServiceInfo
import android.os.Build
import android.webkit.MimeTypeMap import android.webkit.MimeTypeMap
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.hilt.work.HiltWorker import androidx.hilt.work.HiltWorker
@@ -35,6 +37,7 @@ import okio.IOException
import okio.buffer import okio.buffer
import okio.sink import okio.sink
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.TooManyRequestExceptions
import org.koitharu.kotatsu.core.network.CommonHeaders import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.core.network.MangaHttpClient import org.koitharu.kotatsu.core.network.MangaHttpClient
import org.koitharu.kotatsu.core.parser.MangaDataRepository import org.koitharu.kotatsu.core.parser.MangaDataRepository
@@ -130,10 +133,18 @@ class DownloadWorker @AssistedInject constructor(
} }
} }
override suspend fun getForegroundInfo() = ForegroundInfo( override suspend fun getForegroundInfo() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
id.hashCode(), ForegroundInfo(
notificationFactory.create(lastPublishedState), id.hashCode(),
) notificationFactory.create(lastPublishedState),
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC,
)
} else {
ForegroundInfo(
id.hashCode(),
notificationFactory.create(lastPublishedState),
)
}
private suspend fun downloadMangaImpl( private suspend fun downloadMangaImpl(
includedIds: LongArray?, includedIds: LongArray?,
@@ -267,7 +278,12 @@ class DownloadWorker @AssistedInject constructor(
publishState(currentState.copy(isPaused = false, error = null)) publishState(currentState.copy(isPaused = false, error = null))
} else { } else {
countDown-- countDown--
delay(DOWNLOAD_ERROR_DELAY) val retryDelay = if (e is TooManyRequestExceptions) {
e.retryAfter + DOWNLOAD_ERROR_DELAY
} else {
DOWNLOAD_ERROR_DELAY
}
delay(retryDelay)
} }
} }
} }
@@ -389,12 +405,12 @@ class DownloadWorker @AssistedInject constructor(
} }
fun pause(id: UUID) { fun pause(id: UUID) {
val intent = PausingReceiver.getPauseIntent(id) val intent = PausingReceiver.getPauseIntent(context, id)
context.sendBroadcast(intent) context.sendBroadcast(intent)
} }
fun resume(id: UUID) { fun resume(id: UUID) {
val intent = PausingReceiver.getResumeIntent(id) val intent = PausingReceiver.getResumeIntent(context, id)
context.sendBroadcast(intent) context.sendBroadcast(intent)
} }

View File

@@ -40,18 +40,20 @@ class PausingReceiver(
addDataPath(id.toString(), PatternMatcher.PATTERN_SIMPLE_GLOB) addDataPath(id.toString(), PatternMatcher.PATTERN_SIMPLE_GLOB)
} }
fun getPauseIntent(id: UUID) = Intent(ACTION_PAUSE) fun getPauseIntent(context: Context, id: UUID) = Intent(ACTION_PAUSE)
.setData(Uri.parse("$SCHEME://$id")) .setData(Uri.parse("$SCHEME://$id"))
.setPackage(context.packageName)
.putExtra(EXTRA_UUID, id.toString()) .putExtra(EXTRA_UUID, id.toString())
fun getResumeIntent(id: UUID) = Intent(ACTION_RESUME) fun getResumeIntent(context: Context, id: UUID) = Intent(ACTION_RESUME)
.setData(Uri.parse("$SCHEME://$id")) .setData(Uri.parse("$SCHEME://$id"))
.setPackage(context.packageName)
.putExtra(EXTRA_UUID, id.toString()) .putExtra(EXTRA_UUID, id.toString())
fun createPausePendingIntent(context: Context, id: UUID) = PendingIntentCompat.getBroadcast( fun createPausePendingIntent(context: Context, id: UUID) = PendingIntentCompat.getBroadcast(
context, context,
0, 0,
getPauseIntent(id), getPauseIntent(context, id),
0, 0,
false, false,
) )
@@ -59,7 +61,7 @@ class PausingReceiver(
fun createResumePendingIntent(context: Context, id: UUID) = PendingIntentCompat.getBroadcast( fun createResumePendingIntent(context: Context, id: UUID) = PendingIntentCompat.getBroadcast(
context, context,
0, 0,
getResumeIntent(id), getResumeIntent(context, id),
0, 0,
false, false,
) )

View File

@@ -68,6 +68,14 @@ class MangaSourcesRepository @Inject constructor(
} }
} }
suspend fun setSourcesEnabledExclusive(sources: Set<MangaSource>) {
db.withTransaction {
for (s in remoteSources) {
dao.setEnabled(s.name, s in sources)
}
}
}
suspend fun setSourcesEnabled(sources: Iterable<MangaSource>, isEnabled: Boolean) { suspend fun setSourcesEnabled(sources: Iterable<MangaSource>, isEnabled: Boolean) {
db.withTransaction { db.withTransaction {
for (s in sources) { for (s in sources) {
@@ -83,6 +91,14 @@ class MangaSourcesRepository @Inject constructor(
} }
} }
suspend fun setPositions(sources: List<MangaSource>) {
db.withTransaction {
for ((index, item) in sources.withIndex()) {
dao.setSortKey(item.name, index)
}
}
}
suspend fun setPosition(source: MangaSource, index: Int) { suspend fun setPosition(source: MangaSource, index: Int) {
db.withTransaction { db.withTransaction {
val all = dao.findAll().toMutableList() val all = dao.findAll().toMutableList()

View File

@@ -28,13 +28,13 @@ import org.koitharu.kotatsu.core.ui.widgets.TipView
import org.koitharu.kotatsu.core.util.ext.addMenuProvider import org.koitharu.kotatsu.core.util.ext.addMenuProvider
import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.scaleUpActivityOptionsOf
import org.koitharu.kotatsu.databinding.FragmentExploreBinding import org.koitharu.kotatsu.databinding.FragmentExploreBinding
import org.koitharu.kotatsu.details.ui.DetailsActivity import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.download.ui.list.DownloadsActivity import org.koitharu.kotatsu.download.ui.list.DownloadsActivity
import org.koitharu.kotatsu.explore.ui.adapter.ExploreAdapter import org.koitharu.kotatsu.explore.ui.adapter.ExploreAdapter
import org.koitharu.kotatsu.explore.ui.adapter.ExploreListEventListener import org.koitharu.kotatsu.explore.ui.adapter.ExploreListEventListener
import org.koitharu.kotatsu.explore.ui.model.MangaSourceItem import org.koitharu.kotatsu.explore.ui.model.MangaSourceItem
import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
import org.koitharu.kotatsu.list.ui.model.ListHeader import org.koitharu.kotatsu.list.ui.model.ListHeader
import org.koitharu.kotatsu.list.ui.model.TipModel import org.koitharu.kotatsu.list.ui.model.TipModel
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
@@ -57,7 +57,6 @@ class ExploreFragment :
private val viewModel by viewModels<ExploreViewModel>() private val viewModel by viewModels<ExploreViewModel>()
private var exploreAdapter: ExploreAdapter? = null private var exploreAdapter: ExploreAdapter? = null
private var paddingHorizontal = 0
override val recyclerView: RecyclerView override val recyclerView: RecyclerView
get() = requireViewBinding().recyclerView get() = requireViewBinding().recyclerView
@@ -75,8 +74,7 @@ class ExploreFragment :
adapter = exploreAdapter adapter = exploreAdapter
setHasFixedSize(true) setHasFixedSize(true)
SpanSizeResolver(this, resources.getDimensionPixelSize(R.dimen.explore_grid_width)).attach() SpanSizeResolver(this, resources.getDimensionPixelSize(R.dimen.explore_grid_width)).attach()
val spacing = resources.getDimensionPixelOffset(R.dimen.list_spacing) addItemDecoration(TypedListSpacingDecoration(context, false))
paddingHorizontal = spacing
} }
addMenuProvider(ExploreMenuProvider(binding.root.context, viewModel)) addMenuProvider(ExploreMenuProvider(binding.root.context, viewModel))
viewModel.content.observe(viewLifecycleOwner) { viewModel.content.observe(viewLifecycleOwner) {
@@ -97,8 +95,9 @@ class ExploreFragment :
} }
override fun onWindowInsetsChanged(insets: Insets) { override fun onWindowInsetsChanged(insets: Insets) {
requireViewBinding().recyclerView.updatePadding( val rv = requireViewBinding().recyclerView
bottom = insets.bottom, rv.updatePadding(
bottom = insets.bottom + rv.paddingTop,
) )
} }

View File

@@ -12,7 +12,6 @@ import org.koitharu.kotatsu.core.ui.image.FaviconDrawable
import org.koitharu.kotatsu.core.ui.image.TrimTransformation import org.koitharu.kotatsu.core.ui.image.TrimTransformation
import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.util.ext.disposeImageRequest
import org.koitharu.kotatsu.core.util.ext.enqueueWith import org.koitharu.kotatsu.core.util.ext.enqueueWith
import org.koitharu.kotatsu.core.util.ext.getThemeColor import org.koitharu.kotatsu.core.util.ext.getThemeColor
import org.koitharu.kotatsu.core.util.ext.newImageRequest import org.koitharu.kotatsu.core.util.ext.newImageRequest
@@ -82,10 +81,6 @@ fun exploreRecommendationItemAD(
enqueueWith(coil) enqueueWith(coil)
} }
} }
onViewRecycled {
binding.imageViewCover.disposeImageRequest()
}
} }
fun exploreSourceListItemAD( fun exploreSourceListItemAD(
@@ -113,10 +108,6 @@ fun exploreSourceListItemAD(
enqueueWith(coil) enqueueWith(coil)
} }
} }
onViewRecycled {
binding.imageViewIcon.disposeImageRequest()
}
} }
fun exploreSourceGridItemAD( fun exploreSourceGridItemAD(
@@ -144,8 +135,4 @@ fun exploreSourceGridItemAD(
enqueueWith(coil) enqueueWith(coil)
} }
} }
onViewRecycled {
binding.imageViewIcon.disposeImageRequest()
}
} }

View File

@@ -2,24 +2,11 @@ package org.koitharu.kotatsu.explore.ui.model
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
class ExploreButtons( data class ExploreButtons(
val isRandomLoading: Boolean, val isRandomLoading: Boolean,
) : ListModel { ) : ListModel {
override fun areItemsTheSame(other: ListModel): Boolean { override fun areItemsTheSame(other: ListModel): Boolean {
return other is ExploreButtons return other is ExploreButtons
} }
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as ExploreButtons
return isRandomLoading == other.isRandomLoading
}
override fun hashCode(): Int {
return isRandomLoading.hashCode()
}
} }

View File

@@ -3,7 +3,7 @@ package org.koitharu.kotatsu.explore.ui.model
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
class MangaSourceItem( data class MangaSourceItem(
val source: MangaSource, val source: MangaSource,
val isGrid: Boolean, val isGrid: Boolean,
) : ListModel { ) : ListModel {
@@ -11,20 +11,4 @@ class MangaSourceItem(
override fun areItemsTheSame(other: ListModel): Boolean { override fun areItemsTheSame(other: ListModel): Boolean {
return other is MangaSourceItem && other.source == source return other is MangaSourceItem && other.source == source
} }
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as MangaSourceItem
if (source != other.source) return false
return isGrid == other.isGrid
}
override fun hashCode(): Int {
var result = source.hashCode()
result = 31 * result + isGrid.hashCode()
return result
}
} }

View File

@@ -6,7 +6,6 @@ import org.koitharu.kotatsu.parsers.model.Manga
data class RecommendationsItem( data class RecommendationsItem(
val manga: Manga val manga: Manga
) : ListModel { ) : ListModel {
val summary: String = manga.tags.joinToString { it.title } val summary: String = manga.tags.joinToString { it.title }
override fun areItemsTheSame(other: ListModel): Boolean { override fun areItemsTheSame(other: ListModel): Boolean {

View File

@@ -3,31 +3,10 @@ package org.koitharu.kotatsu.favourites.domain.model
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.find import org.koitharu.kotatsu.parsers.util.find
class Cover( data class Cover(
val url: String, val url: String,
val source: String, val source: String,
) { ) {
val mangaSource: MangaSource? val mangaSource: MangaSource?
get() = if (source.isEmpty()) null else MangaSource.entries.find(source) get() = if (source.isEmpty()) null else MangaSource.entries.find(source)
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as Cover
if (url != other.url) return false
return source == other.source
}
override fun hashCode(): Int {
var result = url.hashCode()
result = 31 * result + source.hashCode()
return result
}
override fun toString(): String {
return "Cover(url='$url', source=$source)"
}
} }

View File

@@ -21,9 +21,11 @@ import org.koitharu.kotatsu.core.ui.list.ListSelectionController
import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.databinding.ActivityCategoriesBinding import org.koitharu.kotatsu.databinding.ActivityCategoriesBinding
import org.koitharu.kotatsu.favourites.ui.FavouritesActivity
import org.koitharu.kotatsu.favourites.ui.categories.adapter.CategoriesAdapter import org.koitharu.kotatsu.favourites.ui.categories.adapter.CategoriesAdapter
import org.koitharu.kotatsu.favourites.ui.categories.edit.FavouritesCategoryEditActivity import org.koitharu.kotatsu.favourites.ui.categories.edit.FavouritesCategoryEditActivity
import org.koitharu.kotatsu.list.ui.adapter.ListStateHolderListener import org.koitharu.kotatsu.list.ui.adapter.ListStateHolderListener
import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
import javax.inject.Inject import javax.inject.Inject
@@ -58,6 +60,7 @@ class FavouriteCategoriesActivity :
selectionController.attachToRecyclerView(viewBinding.recyclerView) selectionController.attachToRecyclerView(viewBinding.recyclerView)
viewBinding.recyclerView.setHasFixedSize(true) viewBinding.recyclerView.setHasFixedSize(true)
viewBinding.recyclerView.adapter = adapter viewBinding.recyclerView.adapter = adapter
viewBinding.recyclerView.addItemDecoration(TypedListSpacingDecoration(this, false))
viewBinding.fabAdd.setOnClickListener(this) viewBinding.fabAdd.setOnClickListener(this)
reorderHelper = ItemTouchHelper(ReorderHelperCallback()).apply { reorderHelper = ItemTouchHelper(ReorderHelperCallback()).apply {
@@ -75,6 +78,14 @@ class FavouriteCategoriesActivity :
} }
override fun onItemClick(item: FavouriteCategory, view: View) { override fun onItemClick(item: FavouriteCategory, view: View) {
if (selectionController.onItemClick(item.id)) {
return
}
val intent = FavouritesActivity.newIntent(view.context, item)
startActivity(intent)
}
override fun onEditClick(item: FavouriteCategory, view: View) {
if (selectionController.onItemClick(item.id)) { if (selectionController.onItemClick(item.id)) {
return return
} }
@@ -106,12 +117,12 @@ class FavouriteCategoriesActivity :
right = insets.right, right = insets.right,
) )
viewBinding.recyclerView.updatePadding( viewBinding.recyclerView.updatePadding(
bottom = insets.bottom, bottom = insets.bottom + viewBinding.recyclerView.paddingTop,
) )
} }
private fun onCategoriesChanged(categories: List<ListModel>) { private suspend fun onCategoriesChanged(categories: List<ListModel>) {
adapter.items = categories adapter.emit(categories)
invalidateOptionsMenu() invalidateOptionsMenu()
} }
@@ -126,7 +137,14 @@ class FavouriteCategoriesActivity :
recyclerView: RecyclerView, recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder, viewHolder: RecyclerView.ViewHolder,
target: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder,
): Boolean = viewHolder.itemViewType == target.itemViewType ): Boolean {
if (viewHolder.itemViewType != target.itemViewType) {
return false
}
val fromPos = viewHolder.bindingAdapterPosition
val toPos = target.bindingAdapterPosition
return fromPos != toPos && fromPos != RecyclerView.NO_POSITION && toPos != RecyclerView.NO_POSITION
}
override fun canDropOver( override fun canDropOver(
recyclerView: RecyclerView, recyclerView: RecyclerView,
@@ -151,7 +169,8 @@ class FavouriteCategoriesActivity :
override fun onSelectedChanged(viewHolder: RecyclerView.ViewHolder?, actionState: Int) { override fun onSelectedChanged(viewHolder: RecyclerView.ViewHolder?, actionState: Int) {
super.onSelectedChanged(viewHolder, actionState) super.onSelectedChanged(viewHolder, actionState)
viewBinding.recyclerView.isNestedScrollingEnabled = actionState == ItemTouchHelper.ACTION_STATE_IDLE viewBinding.recyclerView.isNestedScrollingEnabled =
actionState == ItemTouchHelper.ACTION_STATE_IDLE
} }
} }

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.favourites.ui.categories package org.koitharu.kotatsu.favourites.ui.categories
import android.view.View
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import org.koitharu.kotatsu.core.model.FavouriteCategory import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
@@ -7,4 +8,6 @@ import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
interface FavouriteCategoriesListListener : OnListItemClickListener<FavouriteCategory> { interface FavouriteCategoriesListListener : OnListItemClickListener<FavouriteCategory> {
fun onDragHandleTouch(holder: RecyclerView.ViewHolder): Boolean fun onDragHandleTouch(holder: RecyclerView.ViewHolder): Boolean
fun onEditClick(item: FavouriteCategory, view: View)
} }

View File

@@ -17,7 +17,7 @@ import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.favourites.ui.categories.adapter.CategoryListModel import org.koitharu.kotatsu.favourites.ui.categories.adapter.CategoryListModel
import org.koitharu.kotatsu.list.ui.model.EmptyState import org.koitharu.kotatsu.list.ui.model.EmptyState
import org.koitharu.kotatsu.list.ui.model.LoadingState import org.koitharu.kotatsu.list.ui.model.LoadingState
import java.util.Collections import org.koitharu.kotatsu.parsers.util.move
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
@@ -35,6 +35,7 @@ class FavouritesCategoriesViewModel @Inject constructor(
mangaCount = covers.size, mangaCount = covers.size,
covers = covers.take(3), covers = covers.take(3),
category = category, category = category,
isTrackerEnabled = settings.isTrackerEnabled && AppSettings.TRACK_FAVOURITES in settings.trackSources,
) )
}.ifEmpty { }.ifEmpty {
listOf( listOf(
@@ -64,12 +65,11 @@ class FavouritesCategoriesViewModel @Inject constructor(
val prevJob = reorderJob val prevJob = reorderJob
reorderJob = launchJob(Dispatchers.Default) { reorderJob = launchJob(Dispatchers.Default) {
prevJob?.join() prevJob?.join()
val items = categories.requireValue() val snapshot = categories.requireValue().toMutableList()
val ids = items.mapNotNullTo(ArrayList(items.size)) { snapshot.move(oldPos, newPos)
val ids = snapshot.mapNotNullTo(ArrayList(snapshot.size)) {
(it as? CategoryListModel)?.category?.id (it as? CategoryListModel)?.category?.id
} }
Collections.swap(ids, oldPos, newPos)
ids.remove(0L)
repository.reorderCategories(ids) repository.reorderCategories(ids)
} }
} }

View File

@@ -4,6 +4,7 @@ import androidx.lifecycle.LifecycleOwner
import coil.ImageLoader import coil.ImageLoader
import org.koitharu.kotatsu.core.ui.BaseListAdapter import org.koitharu.kotatsu.core.ui.BaseListAdapter
import org.koitharu.kotatsu.favourites.ui.categories.FavouriteCategoriesListListener import org.koitharu.kotatsu.favourites.ui.categories.FavouriteCategoriesListListener
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
import org.koitharu.kotatsu.list.ui.adapter.ListStateHolderListener import org.koitharu.kotatsu.list.ui.adapter.ListStateHolderListener
import org.koitharu.kotatsu.list.ui.adapter.emptyStateListAD import org.koitharu.kotatsu.list.ui.adapter.emptyStateListAD
import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD
@@ -17,8 +18,8 @@ class CategoriesAdapter(
) : BaseListAdapter<ListModel>() { ) : BaseListAdapter<ListModel>() {
init { init {
delegatesManager.addDelegate(categoryAD(coil, lifecycleOwner, onItemClickListener)) addDelegate(ListItemType.CATEGORY_LARGE, categoryAD(coil, lifecycleOwner, onItemClickListener))
.addDelegate(emptyStateListAD(coil, lifecycleOwner, listListener)) addDelegate(ListItemType.STATE_EMPTY, emptyStateListAD(coil, lifecycleOwner, listListener))
.addDelegate(loadingStateAD()) addDelegate(ListItemType.STATE_LOADING, loadingStateAD())
} }
} }

View File

@@ -16,7 +16,6 @@ import androidx.lifecycle.LifecycleOwner
import coil.ImageLoader import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ext.disposeImageRequest
import org.koitharu.kotatsu.core.util.ext.enqueueWith import org.koitharu.kotatsu.core.util.ext.enqueueWith
import org.koitharu.kotatsu.core.util.ext.getAnimationDuration import org.koitharu.kotatsu.core.util.ext.getAnimationDuration
import org.koitharu.kotatsu.core.util.ext.getThemeColor import org.koitharu.kotatsu.core.util.ext.getThemeColor
@@ -35,8 +34,13 @@ fun categoryAD(
{ inflater, parent -> ItemCategoryBinding.inflate(inflater, parent, false) }, { inflater, parent -> ItemCategoryBinding.inflate(inflater, parent, false) },
) { ) {
val eventListener = object : OnClickListener, OnLongClickListener, OnTouchListener { val eventListener = object : OnClickListener, OnLongClickListener, OnTouchListener {
override fun onClick(v: View) = clickListener.onItemClick(item.category, itemView) override fun onClick(v: View) = if (v.id == R.id.imageView_edit) {
override fun onLongClick(v: View) = clickListener.onItemLongClick(item.category, itemView) clickListener.onEditClick(item.category, v)
} else {
clickListener.onItemClick(item.category, v)
}
override fun onLongClick(v: View) = clickListener.onItemLongClick(item.category, v)
override fun onTouch(v: View?, event: MotionEvent): Boolean = event.actionMasked == MotionEvent.ACTION_DOWN && override fun onTouch(v: View?, event: MotionEvent): Boolean = event.actionMasked == MotionEvent.ACTION_DOWN &&
clickListener.onDragHandleTouch(this@adapterDelegateViewBinding) clickListener.onDragHandleTouch(this@adapterDelegateViewBinding)
} }
@@ -58,6 +62,7 @@ fun categoryAD(
val crossFadeDuration = context.getAnimationDuration(R.integer.config_defaultAnimTime).toInt() val crossFadeDuration = context.getAnimationDuration(R.integer.config_defaultAnimTime).toInt()
itemView.setOnClickListener(eventListener) itemView.setOnClickListener(eventListener)
itemView.setOnLongClickListener(eventListener) itemView.setOnLongClickListener(eventListener)
binding.imageViewEdit.setOnClickListener(eventListener)
binding.imageViewHandle.setOnTouchListener(eventListener) binding.imageViewHandle.setOnTouchListener(eventListener)
bind { payloads -> bind { payloads ->
@@ -89,10 +94,4 @@ fun categoryAD(
} }
} }
} }
onViewRecycled {
coverViews.forEach {
it.disposeImageRequest()
}
}
} }

View File

@@ -8,6 +8,7 @@ class CategoryListModel(
val mangaCount: Int, val mangaCount: Int,
val covers: List<Cover>, val covers: List<Cover>,
val category: FavouriteCategory, val category: FavouriteCategory,
val isTrackerEnabled: Boolean,
) : ListModel { ) : ListModel {
override fun areItemsTheSame(other: ListModel): Boolean { override fun areItemsTheSame(other: ListModel): Boolean {
@@ -21,6 +22,7 @@ class CategoryListModel(
other as CategoryListModel other as CategoryListModel
if (mangaCount != other.mangaCount) return false if (mangaCount != other.mangaCount) return false
if (isTrackerEnabled != other.isTrackerEnabled) return false
if (covers != other.covers) return false if (covers != other.covers) return false
if (category.id != other.category.id) return false if (category.id != other.category.id) return false
if (category.title != other.category.title) return false if (category.title != other.category.title) return false
@@ -33,6 +35,7 @@ class CategoryListModel(
override fun hashCode(): Int { override fun hashCode(): Int {
var result = mangaCount var result = mangaCount
result = 31 * result + isTrackerEnabled.hashCode()
result = 31 * result + covers.hashCode() result = 31 * result + covers.hashCode()
result = 31 * result + category.id.hashCode() result = 31 * result + category.id.hashCode()
result = 31 * result + category.title.hashCode() result = 31 * result + category.title.hashCode()
@@ -43,4 +46,8 @@ class CategoryListModel(
result = 31 * result + category.isVisibleInLibrary.hashCode() result = 31 * result + category.isVisibleInLibrary.hashCode()
return result return result
} }
override fun toString(): String {
return "CategoryListModel(categoryId=${category.id})"
}
} }

View File

@@ -11,6 +11,7 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.plus import kotlinx.coroutines.plus
import org.koitharu.kotatsu.core.model.ids import org.koitharu.kotatsu.core.model.ids
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.util.ext.require import org.koitharu.kotatsu.core.util.ext.require
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
@@ -24,6 +25,7 @@ import javax.inject.Inject
class MangaCategoriesViewModel @Inject constructor( class MangaCategoriesViewModel @Inject constructor(
savedStateHandle: SavedStateHandle, savedStateHandle: SavedStateHandle,
private val favouritesRepository: FavouritesRepository, private val favouritesRepository: FavouritesRepository,
settings: AppSettings,
) : BaseViewModel() { ) : BaseViewModel() {
private val manga = savedStateHandle.require<List<ParcelableManga>>(KEY_MANGA_LIST).map { it.manga } private val manga = savedStateHandle.require<List<ParcelableManga>>(KEY_MANGA_LIST).map { it.manga }
@@ -39,6 +41,7 @@ class MangaCategoriesViewModel @Inject constructor(
MangaCategoryItem( MangaCategoryItem(
category = it, category = it,
isChecked = it.id in checked, isChecked = it.id in checked,
isTrackerEnabled = settings.isTrackerEnabled && AppSettings.TRACK_FAVOURITES in settings.trackSources,
) )
} }
} }

View File

@@ -22,7 +22,7 @@ fun mangaCategoryAD(
bind { payloads -> bind { payloads ->
binding.checkableImageView.setChecked(item.isChecked, ListModelDiffCallback.PAYLOAD_CHECKED_CHANGED !in payloads) binding.checkableImageView.setChecked(item.isChecked, ListModelDiffCallback.PAYLOAD_CHECKED_CHANGED !in payloads)
binding.textViewTitle.text = item.category.title binding.textViewTitle.text = item.category.title
binding.imageViewTracker.isVisible = item.category.isTrackingEnabled binding.imageViewTracker.isVisible = item.category.isTrackingEnabled && item.isTrackerEnabled
binding.imageViewVisible.isVisible = item.category.isVisibleInLibrary binding.imageViewVisible.isVisible = item.category.isVisibleInLibrary
} }
} }

View File

@@ -7,6 +7,7 @@ import org.koitharu.kotatsu.list.ui.model.ListModel
data class MangaCategoryItem( data class MangaCategoryItem(
val category: FavouriteCategory, val category: FavouriteCategory,
val isChecked: Boolean, val isChecked: Boolean,
val isTrackerEnabled: Boolean,
) : ListModel { ) : ListModel {
override fun areItemsTheSame(other: ListModel): Boolean { override fun areItemsTheSame(other: ListModel): Boolean {

View File

@@ -2,7 +2,7 @@ package org.koitharu.kotatsu.favourites.ui.container
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
class FavouriteTabModel( data class FavouriteTabModel(
val id: Long, val id: Long,
val title: String, val title: String,
) : ListModel { ) : ListModel {
@@ -10,20 +10,4 @@ class FavouriteTabModel(
override fun areItemsTheSame(other: ListModel): Boolean { override fun areItemsTheSame(other: ListModel): Boolean {
return other is FavouriteTabModel && other.id == id return other is FavouriteTabModel && other.id == id
} }
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as FavouriteTabModel
if (id != other.id) return false
return title == other.title
}
override fun hashCode(): Int {
var result = id.hashCode()
result = 31 * result + title.hashCode()
return result
}
} }

View File

@@ -263,30 +263,11 @@ class FilterCoordinator @Inject constructor(
return result return result
} }
private class TagsWrapper( private data class TagsWrapper(
val tags: Set<MangaTag>, val tags: Set<MangaTag>,
val isLoading: Boolean, val isLoading: Boolean,
val isError: Boolean, val isError: Boolean,
) { )
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as TagsWrapper
if (tags != other.tags) return false
if (isLoading != other.isLoading) return false
return isError == other.isError
}
override fun hashCode(): Int {
var result = tags.hashCode()
result = 31 * result + isLoading.hashCode()
result = 31 * result + isError.hashCode()
return result
}
}
private class TagTitleComparator(lc: String?) : Comparator<MangaTag> { private class TagTitleComparator(lc: String?) : Comparator<MangaTag> {

View File

@@ -33,7 +33,7 @@ class FilterSheetFragment :
val adapter = FilterAdapter(filter, this) val adapter = FilterAdapter(filter, this)
binding.recyclerView.adapter = adapter binding.recyclerView.adapter = adapter
filter.filterItems.observe(viewLifecycleOwner, adapter) filter.filterItems.observe(viewLifecycleOwner, adapter)
binding.recyclerView.addItemDecoration(TypedListSpacingDecoration(binding.root.context)) binding.recyclerView.addItemDecoration(TypedListSpacingDecoration(binding.root.context, true))
if (dialog == null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { if (dialog == null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
binding.recyclerView.scrollIndicators = 0 binding.recyclerView.scrollIndicators = 0

View File

@@ -8,7 +8,7 @@ import org.koitharu.kotatsu.parsers.model.SortOrder
sealed interface FilterItem : ListModel { sealed interface FilterItem : ListModel {
class Sort( data class Sort(
val order: SortOrder, val order: SortOrder,
val isSelected: Boolean, val isSelected: Boolean,
) : FilterItem { ) : FilterItem {
@@ -24,25 +24,9 @@ sealed interface FilterItem : ListModel {
super.getChangePayload(previousState) super.getChangePayload(previousState)
} }
} }
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as Sort
if (order != other.order) return false
return isSelected == other.isSelected
}
override fun hashCode(): Int {
var result = order.hashCode()
result = 31 * result + isSelected.hashCode()
return result
}
} }
class Tag( data class Tag(
val tag: MangaTag, val tag: MangaTag,
val isChecked: Boolean, val isChecked: Boolean,
) : FilterItem { ) : FilterItem {
@@ -58,43 +42,14 @@ sealed interface FilterItem : ListModel {
super.getChangePayload(previousState) super.getChangePayload(previousState)
} }
} }
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as Tag
if (tag != other.tag) return false
return isChecked == other.isChecked
}
override fun hashCode(): Int {
var result = tag.hashCode()
result = 31 * result + isChecked.hashCode()
return result
}
} }
class Error( data class Error(
@StringRes val textResId: Int, @StringRes val textResId: Int,
) : FilterItem { ) : FilterItem {
override fun areItemsTheSame(other: ListModel): Boolean { override fun areItemsTheSame(other: ListModel): Boolean {
return other is Error && textResId == other.textResId return other is Error && textResId == other.textResId
} }
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as Error
return textResId == other.textResId
}
override fun hashCode(): Int {
return textResId
}
} }
} }

View File

@@ -3,24 +3,7 @@ package org.koitharu.kotatsu.filter.ui.model
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
class FilterState( data class FilterState(
val sortOrder: SortOrder?, val sortOrder: SortOrder?,
val tags: Set<MangaTag>, val tags: Set<MangaTag>,
) { )
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as FilterState
if (sortOrder != other.sortOrder) return false
return tags == other.tags
}
override fun hashCode(): Int {
var result = sortOrder?.hashCode() ?: 0
result = 31 * result + tags.hashCode()
return result
}
}

View File

@@ -7,6 +7,7 @@ import androidx.appcompat.view.ActionMode
import androidx.fragment.app.viewModels import androidx.fragment.app.viewModels
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.os.NetworkManageIntent
import org.koitharu.kotatsu.core.ui.list.ListSelectionController import org.koitharu.kotatsu.core.ui.list.ListSelectionController
import org.koitharu.kotatsu.core.ui.util.MenuInvalidator import org.koitharu.kotatsu.core.ui.util.MenuInvalidator
import org.koitharu.kotatsu.core.util.ext.addMenuProvider import org.koitharu.kotatsu.core.util.ext.addMenuProvider
@@ -32,6 +33,10 @@ class HistoryListFragment : MangaListFragment() {
override fun onScrolledToEnd() = Unit override fun onScrolledToEnd() = Unit
override fun onEmptyActionClick() {
startActivity(NetworkManageIntent())
}
override fun onCreateActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean { override fun onCreateActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean {
mode.menuInflater.inflate(R.menu.mode_history, menu) mode.menuInflater.inflate(R.menu.mode_history, menu)
return super.onCreateActionMode(controller, mode, menu) return super.onCreateActionMode(controller, mode, menu)

View File

@@ -13,6 +13,8 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.plus import kotlinx.coroutines.plus
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.MangaHistory import org.koitharu.kotatsu.core.model.MangaHistory
import org.koitharu.kotatsu.core.model.isLocal
import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.ListMode import org.koitharu.kotatsu.core.prefs.ListMode
import org.koitharu.kotatsu.core.prefs.observeAsFlow import org.koitharu.kotatsu.core.prefs.observeAsFlow
@@ -28,6 +30,7 @@ import org.koitharu.kotatsu.history.domain.model.HistoryOrder
import org.koitharu.kotatsu.history.domain.model.MangaWithHistory import org.koitharu.kotatsu.history.domain.model.MangaWithHistory
import org.koitharu.kotatsu.list.domain.ListExtraProvider import org.koitharu.kotatsu.list.domain.ListExtraProvider
import org.koitharu.kotatsu.list.ui.MangaListViewModel import org.koitharu.kotatsu.list.ui.MangaListViewModel
import org.koitharu.kotatsu.list.ui.model.EmptyHint
import org.koitharu.kotatsu.list.ui.model.EmptyState import org.koitharu.kotatsu.list.ui.model.EmptyState
import org.koitharu.kotatsu.list.ui.model.ListHeader import org.koitharu.kotatsu.list.ui.model.ListHeader
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
@@ -36,6 +39,7 @@ import org.koitharu.kotatsu.list.ui.model.toErrorState
import org.koitharu.kotatsu.list.ui.model.toGridModel import org.koitharu.kotatsu.list.ui.model.toGridModel
import org.koitharu.kotatsu.list.ui.model.toListDetailedModel import org.koitharu.kotatsu.list.ui.model.toListDetailedModel
import org.koitharu.kotatsu.list.ui.model.toListModel import org.koitharu.kotatsu.list.ui.model.toListModel
import org.koitharu.kotatsu.local.data.LocalMangaRepository
import java.util.Date import java.util.Date
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import javax.inject.Inject import javax.inject.Inject
@@ -45,6 +49,8 @@ class HistoryListViewModel @Inject constructor(
private val repository: HistoryRepository, private val repository: HistoryRepository,
private val settings: AppSettings, private val settings: AppSettings,
private val extraProvider: ListExtraProvider, private val extraProvider: ListExtraProvider,
private val localMangaRepository: LocalMangaRepository,
networkState: NetworkState,
downloadScheduler: DownloadWorker.Scheduler, downloadScheduler: DownloadWorker.Scheduler,
) : MangaListViewModel(settings, downloadScheduler) { ) : MangaListViewModel(settings, downloadScheduler) {
@@ -69,7 +75,8 @@ class HistoryListViewModel @Inject constructor(
sortOrder.flatMapLatest { repository.observeAllWithHistory(it) }, sortOrder.flatMapLatest { repository.observeAllWithHistory(it) },
isGroupingEnabled, isGroupingEnabled,
listMode, listMode,
) { list, grouped, mode -> networkState,
) { list, grouped, mode, online ->
when { when {
list.isEmpty() -> listOf( list.isEmpty() -> listOf(
EmptyState( EmptyState(
@@ -80,7 +87,7 @@ class HistoryListViewModel @Inject constructor(
), ),
) )
else -> mapList(list, grouped, mode) else -> mapList(list, grouped, mode, online)
} }
}.onStart { }.onStart {
loadingCounter.increment() loadingCounter.increment()
@@ -129,11 +136,25 @@ class HistoryListViewModel @Inject constructor(
list: List<MangaWithHistory>, list: List<MangaWithHistory>,
grouped: Boolean, grouped: Boolean,
mode: ListMode, mode: ListMode,
isOnline: Boolean,
): List<ListModel> { ): List<ListModel> {
val result = ArrayList<ListModel>(if (grouped) (list.size * 1.4).toInt() else list.size + 1) val result = ArrayList<ListModel>(if (grouped) (list.size * 1.4).toInt() else list.size + 1)
val order = sortOrder.value val order = sortOrder.value
var prevHeader: ListHeader? = null var prevHeader: ListHeader? = null
for ((manga, history) in list) { if (!isOnline) {
result += EmptyHint(
icon = R.drawable.ic_empty_common,
textPrimary = R.string.network_unavailable,
textSecondary = R.string.network_unavailable_hint,
actionStringRes = R.string.manage,
)
}
for ((m, history) in list) {
val manga = if (!isOnline && !m.isLocal) {
localMangaRepository.findSavedManga(m)?.manga ?: continue
} else {
m
}
if (grouped) { if (grouped) {
val header = history.header(order) val header = history.header(order)
if (header != prevHeader) { if (header != prevHeader) {

View File

@@ -5,32 +5,40 @@ import android.content.Intent
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import android.net.Uri import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.core.graphics.Insets import androidx.core.graphics.Insets
import androidx.core.graphics.drawable.toBitmap import androidx.core.graphics.drawable.toBitmap
import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams import androidx.core.view.updateLayoutParams
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
import coil.ImageLoader import coil.ImageLoader
import coil.request.CachePolicy import coil.request.CachePolicy
import coil.request.ErrorResult
import coil.request.ImageRequest import coil.request.ImageRequest
import coil.request.SuccessResult
import coil.target.ViewTarget import coil.target.ViewTarget
import com.davemorrissey.labs.subscaleview.ImageSource import com.davemorrissey.labs.subscaleview.ImageSource
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.core.ui.BaseActivity import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.util.ext.enqueueWith import org.koitharu.kotatsu.core.util.ext.enqueueWith
import org.koitharu.kotatsu.core.util.ext.getDisplayIcon
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
import org.koitharu.kotatsu.core.util.ext.getSerializableExtraCompat import org.koitharu.kotatsu.core.util.ext.getSerializableExtraCompat
import org.koitharu.kotatsu.core.util.ext.indicator
import org.koitharu.kotatsu.databinding.ActivityImageBinding import org.koitharu.kotatsu.databinding.ActivityImageBinding
import org.koitharu.kotatsu.databinding.ItemErrorStateBinding
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import javax.inject.Inject import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class ImageActivity : BaseActivity<ActivityImageBinding>() { class ImageActivity : BaseActivity<ActivityImageBinding>(), ImageRequest.Listener, View.OnClickListener {
@Inject @Inject
lateinit var coil: ImageLoader lateinit var coil: ImageLoader
private var errorBinding: ItemErrorStateBinding? = null
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(ActivityImageBinding.inflate(layoutInflater)) setContentView(ActivityImageBinding.inflate(layoutInflater))
@@ -53,14 +61,40 @@ class ImageActivity : BaseActivity<ActivityImageBinding>() {
} }
} }
override fun onClick(v: View?) {
loadImage(intent.data)
}
override fun onError(request: ImageRequest, result: ErrorResult) {
viewBinding.progressBar.hide()
with(errorBinding ?: ItemErrorStateBinding.bind(viewBinding.stubError.inflate())) {
errorBinding = this
root.isVisible = true
textViewError.text = result.throwable.getDisplayMessage(resources)
textViewError.setCompoundDrawablesWithIntrinsicBounds(0, result.throwable.getDisplayIcon(), 0, 0)
buttonRetry.isVisible = true
buttonRetry.setOnClickListener(this@ImageActivity)
}
}
override fun onStart(request: ImageRequest) {
viewBinding.progressBar.show()
(errorBinding?.root ?: viewBinding.stubError).isVisible = false
}
override fun onSuccess(request: ImageRequest, result: SuccessResult) {
viewBinding.progressBar.hide()
(errorBinding?.root ?: viewBinding.stubError).isVisible = false
}
private fun loadImage(url: Uri?) { private fun loadImage(url: Uri?) {
ImageRequest.Builder(this) ImageRequest.Builder(this)
.data(url) .data(url)
.memoryCachePolicy(CachePolicy.DISABLED) .memoryCachePolicy(CachePolicy.DISABLED)
.lifecycle(this) .lifecycle(this)
.listener(this)
.tag(intent.getSerializableExtraCompat<MangaSource>(EXTRA_SOURCE)) .tag(intent.getSerializableExtraCompat<MangaSource>(EXTRA_SOURCE))
.target(SsivTarget(viewBinding.ssiv)) .target(SsivTarget(viewBinding.ssiv))
.indicator(viewBinding.progressBar)
.enqueueWith(coil) .enqueueWith(coil)
} }

View File

@@ -37,7 +37,6 @@ import org.koitharu.kotatsu.core.util.ext.measureHeight
import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.resolveDp import org.koitharu.kotatsu.core.util.ext.resolveDp
import org.koitharu.kotatsu.core.util.ext.scaleUpActivityOptionsOf
import org.koitharu.kotatsu.core.util.ext.viewLifecycleScope import org.koitharu.kotatsu.core.util.ext.viewLifecycleScope
import org.koitharu.kotatsu.databinding.FragmentListBinding import org.koitharu.kotatsu.databinding.FragmentListBinding
import org.koitharu.kotatsu.details.ui.DetailsActivity import org.koitharu.kotatsu.details.ui.DetailsActivity
@@ -109,7 +108,7 @@ abstract class MangaListFragment :
setHasFixedSize(true) setHasFixedSize(true)
adapter = listAdapter adapter = listAdapter
checkNotNull(selectionController).attachToRecyclerView(binding.recyclerView) checkNotNull(selectionController).attachToRecyclerView(binding.recyclerView)
addItemDecoration(TypedListSpacingDecoration(context)) addItemDecoration(TypedListSpacingDecoration(context, false))
addOnScrollListener(paginationListener!!) addOnScrollListener(paginationListener!!)
fastScroller.setFastScrollListener(this@MangaListFragment) fastScroller.setFastScrollListener(this@MangaListFragment)
} }
@@ -200,15 +199,16 @@ abstract class MangaListFragment :
coil = coil, coil = coil,
lifecycleOwner = viewLifecycleOwner, lifecycleOwner = viewLifecycleOwner,
listener = this, listener = this,
sizeResolver = DynamicItemSizeResolver(resources, settings, adjustWidth = false) sizeResolver = DynamicItemSizeResolver(resources, settings, adjustWidth = false),
) )
} }
override fun onWindowInsetsChanged(insets: Insets) { override fun onWindowInsetsChanged(insets: Insets) {
requireViewBinding().recyclerView.updatePadding( val rv = requireViewBinding().recyclerView
bottom = insets.bottom, rv.updatePadding(
bottom = insets.bottom + rv.paddingTop,
) )
requireViewBinding().recyclerView.fastScroller.updateLayoutParams<MarginLayoutParams> { rv.fastScroller.updateLayoutParams<MarginLayoutParams> {
bottomMargin = insets.bottom bottomMargin = insets.bottom
} }
if (activity is MainActivity) { if (activity is MainActivity) {
@@ -247,21 +247,16 @@ abstract class MangaListFragment :
when (mode) { when (mode) {
ListMode.LIST -> { ListMode.LIST -> {
layoutManager = FitHeightLinearLayoutManager(context) layoutManager = FitHeightLinearLayoutManager(context)
updatePadding(left = 0, right = 0)
} }
ListMode.DETAILED_LIST -> { ListMode.DETAILED_LIST -> {
layoutManager = FitHeightLinearLayoutManager(context) layoutManager = FitHeightLinearLayoutManager(context)
val spacing = resources.getDimensionPixelOffset(R.dimen.list_spacing)
updatePadding(left = spacing, right = spacing)
} }
ListMode.GRID -> { ListMode.GRID -> {
layoutManager = FitHeightGridLayoutManager(context, checkNotNull(spanResolver).spanCount).also { layoutManager = FitHeightGridLayoutManager(context, checkNotNull(spanResolver).spanCount).also {
it.spanSizeLookup = spanSizeLookup it.spanSizeLookup = spanSizeLookup
} }
val spacing = resources.getDimensionPixelOffset(R.dimen.grid_spacing)
updatePadding(left = spacing, right = spacing)
addOnLayoutChangeListener(spanResolver) addOnLayoutChangeListener(spanResolver)
} }
} }

View File

@@ -3,7 +3,6 @@ package org.koitharu.kotatsu.list.ui.adapter
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import coil.ImageLoader import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.core.util.ext.disposeImageRequest
import org.koitharu.kotatsu.core.util.ext.enqueueWith import org.koitharu.kotatsu.core.util.ext.enqueueWith
import org.koitharu.kotatsu.core.util.ext.newImageRequest import org.koitharu.kotatsu.core.util.ext.newImageRequest
import org.koitharu.kotatsu.core.util.ext.setTextAndVisible import org.koitharu.kotatsu.core.util.ext.setTextAndVisible
@@ -27,8 +26,4 @@ fun emptyHintAD(
binding.textSecondary.setTextAndVisible(item.textSecondary) binding.textSecondary.setTextAndVisible(item.textSecondary)
binding.buttonRetry.setTextAndVisible(item.actionStringRes) binding.buttonRetry.setTextAndVisible(item.actionStringRes)
} }
onViewRecycled {
binding.icon.disposeImageRequest()
}
} }

View File

@@ -3,7 +3,6 @@ package org.koitharu.kotatsu.list.ui.adapter
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import coil.ImageLoader import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.core.util.ext.disposeImageRequest
import org.koitharu.kotatsu.core.util.ext.enqueueWith import org.koitharu.kotatsu.core.util.ext.enqueueWith
import org.koitharu.kotatsu.core.util.ext.newImageRequest import org.koitharu.kotatsu.core.util.ext.newImageRequest
import org.koitharu.kotatsu.core.util.ext.setTextAndVisible import org.koitharu.kotatsu.core.util.ext.setTextAndVisible
@@ -31,8 +30,4 @@ fun emptyStateListAD(
binding.buttonRetry.setTextAndVisible(item.actionStringRes) binding.buttonRetry.setTextAndVisible(item.actionStringRes)
} }
} }
onViewRecycled {
binding.icon.disposeImageRequest()
}
} }

View File

@@ -23,4 +23,6 @@ enum class ListItemType {
PAGE_THUMB, PAGE_THUMB,
FEED, FEED,
DOWNLOAD, DOWNLOAD,
CATEGORY_LARGE,
MANGA_SCROBBLING,
} }

View File

@@ -8,12 +8,10 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver
import org.koitharu.kotatsu.core.ui.image.TrimTransformation import org.koitharu.kotatsu.core.ui.image.TrimTransformation
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.util.ext.disposeImageRequest
import org.koitharu.kotatsu.core.util.ext.enqueueWith import org.koitharu.kotatsu.core.util.ext.enqueueWith
import org.koitharu.kotatsu.core.util.ext.newImageRequest import org.koitharu.kotatsu.core.util.ext.newImageRequest
import org.koitharu.kotatsu.core.util.ext.source import org.koitharu.kotatsu.core.util.ext.source
import org.koitharu.kotatsu.databinding.ItemMangaGridBinding import org.koitharu.kotatsu.databinding.ItemMangaGridBinding
import org.koitharu.kotatsu.history.data.PROGRESS_NONE
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.MangaGridModel import org.koitharu.kotatsu.list.ui.model.MangaGridModel
@@ -48,16 +46,10 @@ fun mangaGridItemAD(
error(R.drawable.ic_error_placeholder) error(R.drawable.ic_error_placeholder)
transformations(TrimTransformation()) transformations(TrimTransformation())
allowRgb565(true) allowRgb565(true)
tag(item.manga)
source(item.source) source(item.source)
enqueueWith(coil) enqueueWith(coil)
} }
badge = itemView.bindBadge(badge, item.counter) badge = itemView.bindBadge(badge, item.counter)
} }
onViewRecycled {
itemView.clearBadge(badge)
binding.progressView.percent = PROGRESS_NONE
badge = null
binding.imageViewCover.disposeImageRequest()
}
} }

View File

@@ -22,6 +22,7 @@ open class MangaListAdapter(
addDelegate(ListItemType.STATE_ERROR, errorStateListAD(listener)) addDelegate(ListItemType.STATE_ERROR, errorStateListAD(listener))
addDelegate(ListItemType.FOOTER_ERROR, errorFooterAD(listener)) addDelegate(ListItemType.FOOTER_ERROR, errorFooterAD(listener))
addDelegate(ListItemType.STATE_EMPTY, emptyStateListAD(coil, lifecycleOwner, listener)) addDelegate(ListItemType.STATE_EMPTY, emptyStateListAD(coil, lifecycleOwner, listener))
addDelegate(ListItemType.HINT_EMPTY, emptyHintAD(coil, lifecycleOwner, listener))
addDelegate(ListItemType.HEADER, listHeaderAD(listener)) addDelegate(ListItemType.HEADER, listHeaderAD(listener))
} }
} }

View File

@@ -11,13 +11,11 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver
import org.koitharu.kotatsu.core.ui.image.TrimTransformation import org.koitharu.kotatsu.core.ui.image.TrimTransformation
import org.koitharu.kotatsu.core.ui.widgets.ChipsView import org.koitharu.kotatsu.core.ui.widgets.ChipsView
import org.koitharu.kotatsu.core.util.ext.disposeImageRequest
import org.koitharu.kotatsu.core.util.ext.enqueueWith import org.koitharu.kotatsu.core.util.ext.enqueueWith
import org.koitharu.kotatsu.core.util.ext.newImageRequest import org.koitharu.kotatsu.core.util.ext.newImageRequest
import org.koitharu.kotatsu.core.util.ext.source import org.koitharu.kotatsu.core.util.ext.source
import org.koitharu.kotatsu.core.util.ext.textAndVisible import org.koitharu.kotatsu.core.util.ext.textAndVisible
import org.koitharu.kotatsu.databinding.ItemMangaListDetailsBinding import org.koitharu.kotatsu.databinding.ItemMangaListDetailsBinding
import org.koitharu.kotatsu.history.data.PROGRESS_NONE
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.MangaListDetailedModel import org.koitharu.kotatsu.list.ui.model.MangaListDetailedModel
@@ -61,6 +59,7 @@ fun mangaListDetailedItemAD(
error(R.drawable.ic_error_placeholder) error(R.drawable.ic_error_placeholder)
transformations(TrimTransformation()) transformations(TrimTransformation())
allowRgb565(true) allowRgb565(true)
tag(item.manga)
source(item.source) source(item.source)
enqueueWith(coil) enqueueWith(coil)
} }
@@ -72,11 +71,4 @@ fun mangaListDetailedItemAD(
binding.ratingBar.rating = binding.ratingBar.numStars * item.manga.rating binding.ratingBar.rating = binding.ratingBar.numStars * item.manga.rating
badge = itemView.bindBadge(badge, item.counter) badge = itemView.bindBadge(badge, item.counter)
} }
onViewRecycled {
itemView.clearBadge(badge)
binding.progressView.percent = PROGRESS_NONE
badge = null
binding.imageViewCover.disposeImageRequest()
}
} }

View File

@@ -7,7 +7,6 @@ import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.image.TrimTransformation import org.koitharu.kotatsu.core.ui.image.TrimTransformation
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.util.ext.disposeImageRequest
import org.koitharu.kotatsu.core.util.ext.enqueueWith import org.koitharu.kotatsu.core.util.ext.enqueueWith
import org.koitharu.kotatsu.core.util.ext.newImageRequest import org.koitharu.kotatsu.core.util.ext.newImageRequest
import org.koitharu.kotatsu.core.util.ext.source import org.koitharu.kotatsu.core.util.ext.source
@@ -42,15 +41,10 @@ fun mangaListItemAD(
error(R.drawable.ic_error_placeholder) error(R.drawable.ic_error_placeholder)
allowRgb565(true) allowRgb565(true)
transformations(TrimTransformation()) transformations(TrimTransformation())
tag(item.manga)
source(item.source) source(item.source)
enqueueWith(coil) enqueueWith(coil)
} }
badge = itemView.bindBadge(badge, item.counter) badge = itemView.bindBadge(badge, item.counter)
} }
onViewRecycled {
itemView.clearBadge(badge)
badge = null
binding.imageViewCover.disposeImageRequest()
}
} }

View File

@@ -9,10 +9,12 @@ import org.koitharu.kotatsu.R
class TypedListSpacingDecoration( class TypedListSpacingDecoration(
context: Context, context: Context,
private val addHorizontalPadding: Boolean,
) : ItemDecoration() { ) : ItemDecoration() {
private val spacingList = context.resources.getDimensionPixelOffset(R.dimen.list_spacing) private val spacingSmall = context.resources.getDimensionPixelOffset(R.dimen.list_spacing_small)
private val spacingGrid = context.resources.getDimensionPixelOffset(R.dimen.grid_spacing) private val spacingNormal = context.resources.getDimensionPixelOffset(R.dimen.list_spacing_normal)
private val spacingLarge = context.resources.getDimensionPixelOffset(R.dimen.list_spacing_large)
override fun getItemOffsets( override fun getItemOffsets(
outRect: Rect, outRect: Rect,
@@ -27,31 +29,47 @@ class TypedListSpacingDecoration(
ListItemType.FILTER_SORT, ListItemType.FILTER_SORT,
ListItemType.FILTER_TAG -> outRect.set(0) ListItemType.FILTER_TAG -> outRect.set(0)
ListItemType.HEADER -> outRect.set(spacingList, 0, spacingList, 0) ListItemType.HEADER,
ListItemType.FEED,
ListItemType.EXPLORE_SOURCE_LIST,
ListItemType.MANGA_SCROBBLING,
ListItemType.MANGA_LIST -> outRect.set(0) ListItemType.MANGA_LIST -> outRect.set(0)
ListItemType.DOWNLOAD, ListItemType.DOWNLOAD,
ListItemType.MANGA_LIST_DETAILED -> outRect.set(spacingList) ListItemType.HINT_EMPTY,
ListItemType.MANGA_LIST_DETAILED -> outRect.set(spacingNormal)
ListItemType.PAGE_THUMB, ListItemType.PAGE_THUMB,
ListItemType.MANGA_GRID -> outRect.set(spacingGrid) ListItemType.MANGA_GRID -> outRect.set(spacingNormal)
ListItemType.EXPLORE_BUTTONS -> outRect.set(spacingNormal)
ListItemType.FOOTER_LOADING, ListItemType.FOOTER_LOADING,
ListItemType.FOOTER_ERROR, ListItemType.FOOTER_ERROR,
ListItemType.STATE_LOADING, ListItemType.STATE_LOADING,
ListItemType.STATE_ERROR, ListItemType.STATE_ERROR,
ListItemType.STATE_EMPTY, ListItemType.STATE_EMPTY,
ListItemType.EXPLORE_BUTTONS,
ListItemType.EXPLORE_SOURCE_GRID, ListItemType.EXPLORE_SOURCE_GRID,
ListItemType.EXPLORE_SOURCE_LIST,
ListItemType.EXPLORE_SUGGESTION, ListItemType.EXPLORE_SUGGESTION,
ListItemType.MANGA_NESTED_GROUP, ListItemType.MANGA_NESTED_GROUP,
ListItemType.CATEGORY_LARGE,
null -> outRect.set(0) null -> outRect.set(0)
ListItemType.TIP -> outRect.set(0) // TODO ListItemType.TIP -> outRect.set(0) // TODO
ListItemType.HINT_EMPTY -> outRect.set(0) // TODO }
ListItemType.FEED -> outRect.set(spacingList, 0, spacingList, 0) if (addHorizontalPadding && !itemType.isEdgeToEdge()) {
outRect.set(
outRect.left + spacingNormal,
outRect.top,
outRect.right + spacingNormal,
outRect.bottom,
)
} }
} }
private fun Rect.set(spacing: Int) = set(spacing, spacing, spacing, spacing) private fun Rect.set(spacing: Int) = set(spacing, spacing, spacing, spacing)
private fun ListItemType?.isEdgeToEdge() = this == ListItemType.MANGA_NESTED_GROUP
|| this == ListItemType.FILTER_SORT
|| this == ListItemType.FILTER_TAG
} }

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