Compare commits

...

56 Commits

Author SHA1 Message Date
68bdd22634 Remove deprecated preferences 2023-08-14 10:19:25 +03:00
2e7867f60c Merge branch 'devel' 2023-08-14 10:09:56 +03:00
e4c2972cae Update parsers 2023-08-14 09:59:00 +03:00
af654b8c40 Remove WorkManager bug workaround 2023-08-14 09:44:56 +03:00
9eace89d4f Update error messages 2023-08-14 09:44:02 +03:00
7dbec8eb8f Update bottom nav item animations 2023-08-13 16:09:43 +03:00
111f816f18 Update themes 2023-08-13 15:59:05 +03:00
d4589716aa Improve tablet layout 2023-08-12 18:20:15 +03:00
1c4bd6da28 Retry tracker after errors 2023-08-12 13:55:13 +03:00
c83538f66d Merge pull request #450 from weblate/weblate-kotatsu-strings 2023-08-12 12:59:38 +03:00
gallegonovato
885cae7635 Translated using Weblate (Arabic)
Currently translated at 16.9% (80 of 471 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (471 of 471 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ar/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2023-08-12 06:39:19 +02:00
InfinityDouki56
c66d36abf9 Translated using Weblate (Filipino)
Currently translated at 91.0% (428 of 470 strings)

Co-authored-by: InfinityDouki56 <ced.paltep10@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fil/
Translation: Kotatsu/Strings
2023-08-12 06:39:19 +02:00
Макар Разин
4bd9c6df81 Translated using Weblate (Chinese (Simplified))
Currently translated at 91.4% (430 of 470 strings)

Co-authored-by: Макар Разин <makarrazin14@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2023-08-12 06:39:18 +02:00
Nayuki
b835cb98b7 Translated using Weblate (Thai)
Currently translated at 25.5% (120 of 470 strings)

Translated using Weblate (Thai)

Currently translated at 20.8% (98 of 470 strings)

Co-authored-by: Nayuki <me@nayuki.cyou>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/th/
Translation: Kotatsu/Strings
2023-08-12 06:39:18 +02:00
Balog Ferenc József
7cb1f90155 Translated using Weblate (Hungarian)
Currently translated at 28.5% (2 of 7 strings)

Added translation using Weblate (Hungarian)

Co-authored-by: Balog Ferenc József <ferencb2412@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/hu/
Translation: Kotatsu/plurals
2023-08-12 06:39:17 +02:00
96bac81b84 Fix captcha detection 2023-08-11 15:13:25 +03:00
b59f933031 Fix strings 2023-08-11 14:51:56 +03:00
caebca36de Option to hide nsfw content 2023-08-11 14:50:56 +03:00
03cb458d92 Remove custom activity animation 2023-08-11 14:09:21 +03:00
0788f5f05e Adjust cells content to grid size 2023-08-11 13:58:26 +03:00
0271ed2ba9 Show updated manga on top of feed 2023-08-11 13:18:23 +03:00
788c7b862a Fallback to another favicon on error 2023-08-11 12:30:47 +03:00
f4f84099cc Update parsers 2023-08-11 12:23:08 +03:00
3bea94bf1f Handle 429 TooManyRequests error 2023-08-11 11:21:25 +03:00
746eed698f Refactor tracker 2023-08-11 10:36:58 +03:00
fa0289eb27 Show search results once received 2023-08-10 09:09:04 +03:00
c874d73c04 Fix warnings and enable locales-config auto-generating 2023-08-09 16:17:59 +03:00
edb91c46d4 Option to disable pages animation #406 2023-08-09 14:50:52 +03:00
4b9f4f9af2 GC manga updates 2023-08-09 13:17:13 +03:00
J. Lavoie
a07117087a Translated using Weblate (French)
Currently translated at 100.0% (470 of 470 strings)

Translated using Weblate (Italian)

Currently translated at 78.9% (371 of 470 strings)

Translated using Weblate (German)

Currently translated at 99.3% (467 of 470 strings)

Co-authored-by: J. Lavoie <j.lavoie@net-c.ca>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/de/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fr/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/it/
Translation: Kotatsu/Strings
2023-08-09 12:27:20 +03:00
kuragehime
ce0ffca197 Translated using Weblate (Japanese)
Currently translated at 100.0% (470 of 470 strings)

Co-authored-by: kuragehime <kuragehime641@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ja/
Translation: Kotatsu/Strings
2023-08-09 12:27:20 +03:00
TzurS11
eece4d8f00 Added translation using Weblate (Hebrew)
Co-authored-by: TzurS11 <tzurshafriri11@gmail.com>
2023-08-09 12:27:20 +03:00
Nayuki
419e2e578b Translated using Weblate (Thai)
Currently translated at 11.0% (52 of 470 strings)

Translated using Weblate (Thai)

Currently translated at 71.4% (5 of 7 strings)

Added translation using Weblate (Thai)

Co-authored-by: Nayuki <notkungz.suphakorn@gmail.com>
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-09 12:27:20 +03:00
Cookies
0a7387c22e Translated using Weblate (Vietnamese)
Currently translated at 85.2% (399 of 468 strings)

Co-authored-by: Cookies <Nekop1845@proton.me>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/vi/
Translation: Kotatsu/Strings
2023-08-09 12:27:20 +03:00
InfinityDouki56
2a23c3b3b3 Translated using Weblate (Filipino)
Currently translated at 91.0% (428 of 470 strings)

Translated using Weblate (Filipino)

Currently translated at 91.4% (428 of 468 strings)

Co-authored-by: InfinityDouki56 <ced.paltep10@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fil/
Translation: Kotatsu/Strings
2023-08-09 12:27:20 +03:00
Masowick
0566aa4e6a Translated using Weblate (German)
Currently translated at 98.9% (465 of 470 strings)

Translated using Weblate (German)

Currently translated at 93.8% (441 of 470 strings)

Translated using Weblate (German)

Currently translated at 100.0% (7 of 7 strings)

Co-authored-by: Masowick <Demian@gmx.co.uk>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/de/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/de/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2023-08-09 12:27:20 +03:00
Макар Разин
3f27acf1aa Translated using Weblate (Ukrainian)
Currently translated at 100.0% (470 of 470 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (470 of 470 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (470 of 470 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (468 of 468 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (468 of 468 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (468 of 468 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/ja/
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-09 12:27:20 +03:00
JA Sunny
d48c3fbe1b Translated using Weblate (Bengali)
Currently translated at 18.4% (86 of 467 strings)

Translated using Weblate (Bengali)

Currently translated at 100.0% (7 of 7 strings)

Co-authored-by: JA Sunny <ayasbinshams2003@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/bn/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/bn/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2023-08-09 12:27:20 +03:00
kuragehime
6720474667 Translated using Weblate (Japanese)
Currently translated at 100.0% (7 of 7 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (467 of 467 strings)

Co-authored-by: kuragehime <kuragehime641@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/ja/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ja/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2023-08-09 12:27:20 +03:00
Eric
ad42ca5085 Translated using Weblate (Chinese (Simplified))
Currently translated at 92.0% (429 of 466 strings)

Co-authored-by: Eric <hamburger2048@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2023-08-09 12:27:20 +03:00
gallegonovato
d5e40d79ec Translated using Weblate (Spanish)
Currently translated at 100.0% (470 of 470 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (468 of 468 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (7 of 7 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/es/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2023-08-09 12:27:20 +03:00
Макар Разин
7853b9a73e Translated using Weblate (Belarusian)
Currently translated at 100.0% (468 of 468 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (467 of 467 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (467 of 467 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (467 of 467 strings)

Translated using Weblate (Korean)

Currently translated at 76.3% (356 of 466 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (466 of 466 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (7 of 7 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (7 of 7 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (7 of 7 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (466 of 466 strings)

Translated using Weblate (Japanese)

Currently translated at 96.3% (449 of 466 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (466 of 466 strings)

Co-authored-by: Макар Разин <makarrazin14@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/be/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/ru/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/uk/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/be/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ja/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ko/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/uk/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2023-08-09 12:27:20 +03:00
return_null
cb71a24d81 Translated using Weblate (Chinese (Simplified))
Currently translated at 95.5% (427 of 447 strings)

Co-authored-by: return_null <demolang@dismail.de>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2023-08-09 12:27:20 +03:00
a5d99db105 Use nio for File.listFiles() #449 2023-08-09 12:22:52 +03:00
6cc13784e4 Remove invalid track logs 2023-08-07 17:59:26 +03:00
cf9aab9afe Update parsers 2023-08-06 09:22:47 +03:00
83a919f9f6 Add contribution guidelines 2023-08-06 09:12:51 +03:00
70bf80ad8f Update parsers 2023-08-06 09:11:30 +03:00
05a724eae0 Add contribution guidelines 2023-08-04 17:08:50 +03:00
496f3637c4 Update gradle and kotlin 2023-08-04 16:32:08 +03:00
c5c907c8dc UI improvements 2023-08-02 16:45:01 +03:00
af4845a770 Revert "Use ConnectivityManagerCompat.getRestrictBackgroundStatus()"
This reverts commit bfad632b8c.
2023-08-02 14:55:22 +03:00
511f9af991 Limit pages loading parallelism 2023-08-02 14:55:15 +03:00
baf6f6d2c9 Re-use ZipFile instances in PageLoader 2023-08-02 14:35:55 +03:00
8b6a0a8c87 Improve downloads list 2023-08-02 12:42:57 +03:00
e7ee261680 Grouping history by progress 2023-08-02 12:08:28 +03:00
187 changed files with 3238 additions and 2414 deletions

11
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,11 @@
## Kotatsu contribution guidelines
- If you want to fix bug or implement a new feature, that already mention in the [issues](https://github.com/KotatsuApp/Kotatsu/issues), please, assign this issue to you and/or comment about it.
- Whether you have to implement new feature, please, open an issue or discussion regarding it to ensure it will be accepted.
- Translations have to be managed using the [Weblate](https://hosted.weblate.org/engage/kotatsu/) platform.
- In case you want to add a new manga source, refer to the [parsers repository](https://github.com/KotatsuApp/kotatsu-parsers).
Refactoring or some dev-faces improvements are also might be accepted, however please stick to the following principles:
- Performance matters. In the case of choosing between source code beauty and performance, performance should be a priority.
- Please, do not modify readme and other information files (except for typos).
- Avoid adding new dependencies unless required. APK size is important.

View File

@@ -39,6 +39,10 @@ Kotatsu is a free and open source manga reader for Android.
Kotatsu is localized in a number of different languages, if you would like to help improve these or add new languages,
please head over to the [Weblate project page](https://hosted.weblate.org/engage/kotatsu/)
### Contributing
See [CONTRIBUTING.md](./CONTRIBUTING.md) for the guidelines.
### License
[![GNU GPLv3 Image](https://www.gnu.org/graphics/gplv3-127x51.png)](http://www.gnu.org/licenses/gpl-3.0.en.html)

View File

@@ -2,30 +2,30 @@ plugins {
id 'com.android.application'
id 'kotlin-android'
id 'kotlin-kapt'
id 'com.google.devtools.ksp'
id 'kotlin-parcelize'
id 'dagger.hilt.android.plugin'
}
android {
compileSdk = 33
buildToolsVersion = '33.0.2'
// compileSdkExtension = 4
buildToolsVersion = '34.0.0'
namespace = 'org.koitharu.kotatsu'
defaultConfig {
applicationId 'org.koitharu.kotatsu'
minSdkVersion 21
//TODO: update as soon as sources becomes available
//noinspection OldTargetApi
targetSdkVersion 33
versionCode 567
versionName '5.3.10'
versionCode 570
versionName '6.0-a1'
generatedDensities = []
testInstrumentationRunner "org.koitharu.kotatsu.HiltTestRunner"
kapt {
arguments {
arg 'room.schemaLocation', "$projectDir/schemas".toString()
}
ksp {
arg("room.schemaLocation", "$projectDir/schemas")
}
androidResources {
generateLocaleConfig true
}
}
buildTypes {
@@ -41,6 +41,7 @@ android {
}
buildFeatures {
viewBinding true
buildConfig true
}
sourceSets {
androidTest.assets.srcDirs += files("$projectDir/schemas".toString())
@@ -81,11 +82,11 @@ afterEvaluate {
}
dependencies {
//noinspection GradleDependency
implementation('com.github.KotatsuApp:kotatsu-parsers:03b4fc9f00') {
implementation('com.github.KotatsuApp:kotatsu-parsers:407ef5b655') {
exclude group: 'org.json', module: 'json'
}
implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.8.22'
implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.9.0'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3'
implementation 'androidx.appcompat:appcompat:1.6.1'
@@ -100,11 +101,10 @@ dependencies {
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
implementation 'androidx.recyclerview:recyclerview:1.3.1'
implementation 'androidx.viewpager2:viewpager2:1.1.0-beta02'
implementation 'androidx.preference:preference-ktx:1.2.0'
implementation 'androidx.preference:preference-ktx:1.2.1'
implementation 'androidx.biometric:biometric-ktx:1.2.0-alpha05'
implementation 'com.google.android.material:material:1.9.0'
//noinspection LifecycleAnnotationProcessorWithJava8
kapt 'androidx.lifecycle:lifecycle-compiler:2.6.1'
implementation 'androidx.lifecycle:lifecycle-common-java8:2.6.1'
// TODO https://issuetracker.google.com/issues/254846063
implementation 'androidx.work:work-runtime-ktx:2.8.1'
@@ -117,8 +117,7 @@ dependencies {
implementation 'androidx.room:room-runtime:2.5.2'
implementation 'androidx.room:room-ktx:2.5.2'
//noinspection KaptUsageInsteadOfKsp
kapt 'androidx.room:room-compiler:2.5.2'
ksp 'androidx.room:room-compiler:2.5.2'
implementation 'com.squareup.okhttp3:okhttp:4.11.0'
implementation 'com.squareup.okhttp3:okhttp-dnsoverhttps:4.11.0'

View File

@@ -1,37 +0,0 @@
package org.koitharu.kotatsu.core.util
import android.util.Log
import androidx.recyclerview.widget.RecyclerView.AdapterDataObserver
class LoggingAdapterDataObserver(
private val tag: String,
) : AdapterDataObserver() {
override fun onChanged() {
Log.d(tag, "onChanged()")
}
override fun onItemRangeChanged(positionStart: Int, itemCount: Int) {
Log.d(tag, "onItemRangeChanged(positionStart=$positionStart, itemCount=$itemCount)")
}
override fun onItemRangeChanged(positionStart: Int, itemCount: Int, payload: Any?) {
Log.d(tag, "onItemRangeChanged(positionStart=$positionStart, itemCount=$itemCount, payload=$payload)")
}
override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
Log.d(tag, "onItemRangeInserted(positionStart=$positionStart, itemCount=$itemCount)")
}
override fun onItemRangeRemoved(positionStart: Int, itemCount: Int) {
Log.d(tag, "onItemRangeRemoved(positionStart=$positionStart, itemCount=$itemCount)")
}
override fun onItemRangeMoved(fromPosition: Int, toPosition: Int, itemCount: Int) {
Log.d(tag, "onItemRangeMoved(fromPosition=$fromPosition, toPosition=$toPosition, itemCount=$itemCount)")
}
override fun onStateRestorationPolicyChanged() {
Log.d(tag, "onStateRestorationPolicyChanged()")
}
}

View File

@@ -46,7 +46,6 @@
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:largeHeap="true"
android:localeConfig="@xml/locales"
android:networkSecurityConfig="@xml/network_security_config"
android:requestLegacyExternalStorage="true"
android:roundIcon="@mipmap/ic_launcher_round"
@@ -166,9 +165,6 @@
<activity
android:name="org.koitharu.kotatsu.reader.ui.colorfilter.ColorFilterConfigActivity"
android:label="@string/color_correction" />
<activity
android:name="org.koitharu.kotatsu.shelf.ui.config.ShelfSettingsActivity"
android:label="@string/settings" />
<activity
android:name="org.koitharu.kotatsu.scrobbling.common.ui.config.ScrobblerConfigActivity"
android:exported="true"

View File

@@ -23,7 +23,6 @@ import org.acra.sender.HttpSender
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.os.AppValidator
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.local.data.LocalMangaRepository
import org.koitharu.kotatsu.local.data.PagesCache
@@ -73,7 +72,6 @@ class KotatsuApp : Application(), Configuration.Provider {
setupDatabaseObservers()
}
workScheduleManager.init()
WorkServiceStopHelper(workManagerProvider).setup()
}
override fun attachBaseContext(base: Context?) {

View File

@@ -112,7 +112,7 @@ class BookmarksFragment :
.bookmark(item)
.incognito(true)
.build()
startActivity(intent, scaleUpActivityOptionsOf(view))
startActivity(intent)
Toast.makeText(view.context, R.string.incognito_mode, Toast.LENGTH_SHORT).show()
}
}

View File

@@ -22,7 +22,6 @@ import org.koitharu.kotatsu.core.util.RecyclerViewScrollCallback
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.plus
import org.koitharu.kotatsu.core.util.ext.scaleUpActivityOptionsOf
import org.koitharu.kotatsu.core.util.ext.showDistinct
import org.koitharu.kotatsu.core.util.ext.withArgs
import org.koitharu.kotatsu.databinding.SheetPagesBinding
@@ -103,7 +102,7 @@ class BookmarksSheet :
.bookmark(item)
.incognito(true)
.build()
startActivity(intent, scaleUpActivityOptionsOf(view))
startActivity(intent)
}
dismiss()
}

View File

@@ -13,7 +13,6 @@ import androidx.core.graphics.Insets
import androidx.core.view.isVisible
import androidx.core.view.updatePadding
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.network.CommonHeadersInterceptor
import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.util.ext.catchingWebViewUnavailability
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding

View File

@@ -5,7 +5,6 @@ import android.content.Intent
import android.os.Bundle
import android.view.MenuItem
import android.webkit.CookieManager
import android.webkit.WebSettings
import androidx.activity.result.contract.ActivityResultContract
import androidx.core.graphics.Insets
import androidx.core.net.toUri
@@ -18,7 +17,6 @@ import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.browser.WebViewBackPressedCallback
import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.core.network.CommonHeadersInterceptor
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.util.TaggedActivityResult

View File

@@ -15,7 +15,7 @@ class Migration16To17(context: Context) : Migration(16, 17) {
database.execSQL("CREATE INDEX `index_sources_sort_key` ON `sources` (`sort_key`)")
val hiddenSources = prefs.getStringSet("sources_hidden", null).orEmpty()
val order = prefs.getString("sources_order_2", null)?.split('|').orEmpty()
val sources = MangaSource.values()
val sources = MangaSource.entries
for (source in sources) {
if (source == MangaSource.LOCAL) {
continue

View File

@@ -0,0 +1,9 @@
package org.koitharu.kotatsu.core.exceptions
import okio.IOException
import java.util.Date
class TooManyRequestExceptions(
val url: String,
val retryAt: Date?,
) : IOException()

View File

@@ -0,0 +1,20 @@
package org.koitharu.kotatsu.core.fs
import android.os.Build
import org.koitharu.kotatsu.core.util.iterator.CloseableIterator
import org.koitharu.kotatsu.core.util.iterator.MappingIterator
import java.io.File
import java.nio.file.Files
import java.nio.file.Path
class FileSequence(private val dir: File) : Sequence<File> {
override fun iterator(): Iterator<File> {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val stream = Files.newDirectoryStream(dir.toPath())
CloseableIterator(MappingIterator(stream.iterator(), Path::toFile), stream)
} else {
dir.listFiles().orEmpty().iterator()
}
}
}

View File

@@ -10,7 +10,7 @@ fun MangaSource.getLocaleTitle(): String? {
}
fun MangaSource(name: String): MangaSource {
MangaSource.values().forEach {
MangaSource.entries.forEach {
if (it.name == name) return it
}
return MangaSource.DUMMY

View File

@@ -3,20 +3,21 @@ package org.koitharu.kotatsu.core.network
import okhttp3.Interceptor
import okhttp3.Response
import okhttp3.internal.closeQuietly
import org.jsoup.Jsoup
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
import org.koitharu.kotatsu.parsers.model.MangaSource
import java.net.HttpURLConnection.HTTP_FORBIDDEN
import java.net.HttpURLConnection.HTTP_UNAVAILABLE
private const val HEADER_SERVER = "Server"
private const val SERVER_CLOUDFLARE = "cloudflare"
class CloudFlareInterceptor : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val response = chain.proceed(chain.request())
if (response.code == HTTP_FORBIDDEN || response.code == HTTP_UNAVAILABLE) {
if (response.header(HEADER_SERVER)?.startsWith(SERVER_CLOUDFLARE) == true) {
val content = response.body?.source()?.peek()?.use {
Jsoup.parse(it.inputStream(), Charsets.UTF_8.name(), response.request.url.toString())
} ?: return response
if (content.getElementById("challenge-error-title") != null) {
val request = response.request
response.closeQuietly()
throw CloudFlareProtectedException(

View File

@@ -15,6 +15,7 @@ object CommonHeaders {
const val AUTHORIZATION = "Authorization"
const val CACHE_CONTROL = "Cache-Control"
const val PROXY_AUTHORIZATION = "Proxy-Authorization"
const val RETRY_AFTER = "Retry-After"
val CACHE_CONTROL_NO_STORE: CacheControl
get() = CacheControl.Builder().noStore().build()

View File

@@ -67,6 +67,7 @@ interface NetworkModule {
cache(cache)
addInterceptor(GZipInterceptor())
addInterceptor(CloudFlareInterceptor())
addInterceptor(RateLimitInterceptor())
if (BuildConfig.DEBUG) {
addInterceptor(CurlLoggingInterceptor())
}

View File

@@ -0,0 +1,36 @@
package org.koitharu.kotatsu.core.network
import okhttp3.Interceptor
import okhttp3.Response
import okhttp3.internal.closeQuietly
import org.koitharu.kotatsu.core.exceptions.TooManyRequestExceptions
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.concurrent.TimeUnit
class RateLimitInterceptor : Interceptor {
private val dateFormat = SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss ZZZ", Locale.ENGLISH)
override fun intercept(chain: Interceptor.Chain): Response {
val response = chain.proceed(chain.request())
if (response.code == 429) {
val retryDate = response.header(CommonHeaders.RETRY_AFTER)?.parseRetryDate()
val request = response.request
response.closeQuietly()
throw TooManyRequestExceptions(
url = request.url.toString(),
retryAt = retryDate,
)
}
return response
}
private fun String.parseRetryDate(): Date? {
toIntOrNull()?.let {
return Date(System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(it.toLong()))
}
return dateFormat.parse(this)
}
}

View File

@@ -20,7 +20,9 @@ import okhttp3.Response
import okhttp3.ResponseBody
import okhttp3.internal.closeQuietly
import okio.Closeable
import okio.IOException
import okio.buffer
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
@@ -49,22 +51,32 @@ class FaviconFetcher(
override suspend fun fetch(): FetchResult {
getCached(options)?.let { return it }
val repo = mangaRepositoryFactory.create(mangaSource) as RemoteMangaRepository
val favicons = repo.getFavicons()
val sizePx = maxOf(
options.size.width.pxOrElse { FALLBACK_SIZE },
options.size.height.pxOrElse { FALLBACK_SIZE },
)
val icon = checkNotNull(favicons.find(sizePx)) { "No favicons found" }
val response = loadIcon(icon.url, mangaSource)
val responseBody = response.requireBody()
val source = writeToDiskCache(responseBody)?.toImageSource()?.also {
response.closeQuietly()
} ?: responseBody.toImageSource(response)
return SourceResult(
source = source,
mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(icon.type),
dataSource = response.toDataSource(),
)
var favicons = repo.getFavicons()
while (favicons.isNotEmpty()) {
val icon = favicons.find(sizePx) ?: throwNSEE()
val response = try {
loadIcon(icon.url, mangaSource)
} catch (e: CloudFlareProtectedException) {
throw e
} catch (e: IOException) {
favicons -= icon
continue
}
val responseBody = response.requireBody()
val source = writeToDiskCache(responseBody)?.toImageSource()?.also {
response.closeQuietly()
} ?: responseBody.toImageSource(response)
return SourceResult(
source = source,
mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(icon.type),
dataSource = response.toDataSource(),
)
}
throwNSEE()
}
private suspend fun loadIcon(url: String, source: MangaSource): Response {
@@ -143,6 +155,8 @@ class FaviconFetcher(
append(height.toString())
}
private fun throwNSEE(): Nothing = throw NoSuchElementException("No favicons found")
class Factory(
context: Context,
private val okHttpClient: OkHttpClient,

View File

@@ -2,14 +2,14 @@ package org.koitharu.kotatsu.core.prefs
import android.content.Context
import android.content.SharedPreferences
import android.net.ConnectivityManager
import android.net.Uri
import android.os.Build
import android.provider.Settings
import androidx.annotation.FloatRange
import androidx.appcompat.app.AppCompatDelegate
import androidx.collection.ArraySet
import androidx.collection.arraySetOf
import androidx.core.content.edit
import androidx.core.net.ConnectivityManagerCompat
import androidx.core.os.LocaleListCompat
import androidx.preference.PreferenceManager
import dagger.hilt.android.qualifiers.ApplicationContext
@@ -55,6 +55,10 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
get() = prefs.getInt(KEY_GRID_SIZE, 100)
set(value) = prefs.edit { putInt(KEY_GRID_SIZE, value) }
var isNsfwContentDisabled: Boolean
get() = prefs.getBoolean(KEY_DISABLE_NSFW, false)
set(value) = prefs.edit { putBoolean(KEY_DISABLE_NSFW, value) }
var appLocales: LocaleListCompat
get() {
val raw = prefs.getString(KEY_APP_LOCALE, null)
@@ -100,8 +104,8 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val notificationLight: Boolean
get() = prefs.getBoolean(KEY_NOTIFICATIONS_LIGHT, true)
val readerAnimation: Boolean
get() = prefs.getBoolean(KEY_READER_ANIMATION, false)
val readerAnimation: ReaderAnimation
get() = prefs.getEnumValue(KEY_READER_ANIMATION, ReaderAnimation.DEFAULT)
val readerBackground: ReaderBackground
get() = prefs.getEnumValue(KEY_READER_BACKGROUND, ReaderBackground.DEFAULT)
@@ -134,7 +138,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
get() = prefs.getEnumValue(KEY_ZOOM_MODE, ZoomMode.FIT_CENTER)
val trackSources: Set<String>
get() = prefs.getStringSet(KEY_TRACK_SOURCES, null) ?: arraySetOf(TRACK_FAVOURITES, TRACK_HISTORY)
get() = prefs.getStringSet(KEY_TRACK_SOURCES, null) ?: setOf(TRACK_FAVOURITES)
var appPassword: String?
get() = prefs.getString(KEY_APP_PASSWORD, null)
@@ -333,8 +337,11 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
}
private fun isBackgroundNetworkRestricted(): Boolean {
return ConnectivityManagerCompat.getRestrictBackgroundStatus(connectivityManager) ==
ConnectivityManagerCompat.RESTRICT_BACKGROUND_STATUS_ENABLED
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
connectivityManager.restrictBackgroundStatus == ConnectivityManager.RESTRICT_BACKGROUND_STATUS_ENABLED
} else {
false
}
}
private fun JSONArray.toStringSet(): Set<String> {
@@ -380,7 +387,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_NOTIFICATIONS_VIBRATE = "notifications_vibrate"
const val KEY_NOTIFICATIONS_LIGHT = "notifications_light"
const val KEY_NOTIFICATIONS_INFO = "tracker_notifications_info"
const val KEY_READER_ANIMATION = "reader_animation"
const val KEY_READER_ANIMATION = "reader_animation2"
const val KEY_READER_MODE = "reader_mode"
const val KEY_READER_MODE_DETECT = "reader_mode_detect"
const val KEY_APP_PASSWORD = "app_password"
@@ -440,6 +447,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_PROXY_PASSWORD = "proxy_password"
const val KEY_IMAGES_PROXY = "images_proxy"
const val KEY_LOCAL_MANGA_DIRS = "local_manga_dirs"
const val KEY_DISABLE_NSFW = "no_nsfw"
// About
const val KEY_APP_UPDATE = "app_update"

View File

@@ -4,6 +4,7 @@ import androidx.annotation.StringRes
import androidx.annotation.StyleRes
import com.google.android.material.color.DynamicColors
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.parsers.util.find
enum class ColorScheme(
@StyleRes val styleResId: Int,
@@ -31,7 +32,7 @@ enum class ColorScheme(
}
fun getAvailableList(): List<ColorScheme> {
val list = enumValues<ColorScheme>().toMutableList()
val list = ColorScheme.entries.toMutableList()
if (!DynamicColors.isDynamicColorAvailable()) {
list.remove(MONET)
}
@@ -39,7 +40,7 @@ enum class ColorScheme(
}
fun safeValueOf(name: String): ColorScheme? {
return enumValues<ColorScheme>().find { it.name == name }
return ColorScheme.entries.find(name)
}
}
}

View File

@@ -20,7 +20,7 @@ enum class NetworkPolicy(
fun from(key: String?, default: NetworkPolicy): NetworkPolicy {
val intKey = key?.toIntOrNull() ?: return default
return enumValues<NetworkPolicy>().find { it.key == intKey } ?: default
return NetworkPolicy.entries.find { it.key == intKey } ?: default
}
}
}

View File

@@ -0,0 +1,7 @@
package org.koitharu.kotatsu.core.prefs
enum class ReaderAnimation {
// Do not rename this
NONE, DEFAULT, ADVANCED;
}

View File

@@ -8,6 +8,6 @@ enum class ReaderMode(val id: Int) {
companion object {
fun valueOf(id: Int) = values().firstOrNull { it.id == id }
fun valueOf(id: Int) = entries.firstOrNull { it.id == id }
}
}
}

View File

@@ -97,7 +97,6 @@ abstract class BaseActivity<B : ViewBinding> :
}
override fun onOptionsItemSelected(item: MenuItem) = if (item.itemId == android.R.id.home) {
@Suppress("DEPRECATION")
onBackPressed()
true
} else super.onOptionsItemSelected(item)

View File

@@ -13,6 +13,7 @@ import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.util.RecyclerViewOwner
import org.koitharu.kotatsu.core.ui.util.WindowInsetsDelegate
import org.koitharu.kotatsu.core.util.ext.getThemeColor
import org.koitharu.kotatsu.core.util.ext.parentView
import org.koitharu.kotatsu.settings.SettingsActivity
import javax.inject.Inject
@@ -33,7 +34,8 @@ abstract class BasePreferenceFragment(@StringRes private val titleId: Int) :
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
view.setBackgroundColor(view.context.getThemeColor(android.R.attr.colorBackground))
val themedContext = (view.parentView ?: view).context
view.setBackgroundColor(themedContext.getThemeColor(android.R.attr.colorBackground))
listView.clipToPadding = false
insetsDelegate.onViewCreated(view)
insetsDelegate.addInsetsListener(this)

View File

@@ -7,10 +7,14 @@ import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
@@ -32,9 +36,8 @@ abstract class BaseViewModel : ViewModel() {
val onError: EventFlow<Throwable>
get() = errorEvent
val isLoading: StateFlow<Boolean>
get() = loadingCounter.map { it > 0 }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), loadingCounter.value > 0)
val isLoading: StateFlow<Boolean> = loadingCounter.map { it > 0 }
.stateIn(viewModelScope, SharingStarted.Lazily, loadingCounter.value > 0)
protected fun launchJob(
context: CoroutineContext = EmptyCoroutineContext,
@@ -55,14 +58,24 @@ abstract class BaseViewModel : ViewModel() {
}
}
protected fun <T> Flow<T>.withLoading() = onStart {
loadingCounter.increment()
}.onCompletion {
loadingCounter.decrement()
}
protected fun <T> Flow<T>.withErrorHandling() = catch { error ->
errorEvent.call(error)
}
protected fun MutableStateFlow<Int>.increment() = update { it + 1 }
protected fun MutableStateFlow<Int>.decrement() = update { it - 1 }
private fun createErrorHandler() = CoroutineExceptionHandler { _, throwable ->
throwable.printStackTraceDebug()
if (throwable !is CancellationException) {
errorEvent.call(throwable)
}
}
protected fun MutableStateFlow<Int>.increment() = update { it + 1 }
protected fun MutableStateFlow<Int>.decrement() = update { it - 1 }
}

View File

@@ -1,29 +0,0 @@
package org.koitharu.kotatsu.core.ui.dialog
import android.content.Context
import android.graphics.Color
import android.view.View
import com.google.android.material.bottomsheet.BottomSheetDialog
class AppBottomSheetDialog(context: Context, theme: Int) : BottomSheetDialog(context, theme) {
/**
* https://github.com/material-components/material-components-android/issues/2582
*/
@Suppress("DEPRECATION")
override fun onAttachedToWindow() {
val window = window
val initialSystemUiVisibility = window?.decorView?.systemUiVisibility ?: 0
super.onAttachedToWindow()
if (window != null) {
// If the navigation bar is translucent at all, the BottomSheet should be edge to edge
val drawEdgeToEdge = edgeToEdgeEnabled && Color.alpha(window.navigationBarColor) < 0xFF
if (drawEdgeToEdge) {
// Copied from super.onAttachedToWindow:
val edgeToEdgeFlags = View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_LAYOUT_STABLE
// Fix super-class's window flag bug by respecting the initial system UI visibility:
window.decorView.systemUiVisibility = edgeToEdgeFlags or initialSystemUiVisibility
}
}
}
}

View File

@@ -1,87 +0,0 @@
package org.koitharu.kotatsu.core.ui.list.decor
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.Rect
import android.view.View
import androidx.core.content.res.getColorOrThrow
import androidx.core.view.children
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.R as materialR
@SuppressLint("PrivateResource")
abstract class AbstractDividerItemDecoration(context: Context) : RecyclerView.ItemDecoration() {
private val bounds = Rect()
private val thickness: Int
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
init {
paint.style = Paint.Style.FILL
val ta = context.obtainStyledAttributes(
null,
materialR.styleable.MaterialDivider,
materialR.attr.materialDividerStyle,
materialR.style.Widget_Material3_MaterialDivider,
)
paint.color = ta.getColorOrThrow(materialR.styleable.MaterialDivider_dividerColor)
thickness = ta.getDimensionPixelSize(
materialR.styleable.MaterialDivider_dividerThickness,
context.resources.getDimensionPixelSize(materialR.dimen.material_divider_thickness),
)
ta.recycle()
}
override fun getItemOffsets(
outRect: Rect,
view: View,
parent: RecyclerView,
state: RecyclerView.State,
) {
outRect.set(0, thickness, 0, 0)
}
// TODO implement for horizontal lists on demand
override fun onDraw(canvas: Canvas, parent: RecyclerView, s: RecyclerView.State) {
if (parent.layoutManager == null || thickness == 0) {
return
}
canvas.save()
val left: Float
val right: Float
if (parent.clipToPadding) {
left = parent.paddingLeft.toFloat()
right = (parent.width - parent.paddingRight).toFloat()
canvas.clipRect(
left,
parent.paddingTop.toFloat(),
right,
(parent.height - parent.paddingBottom).toFloat(),
)
} else {
left = 0f
right = parent.width.toFloat()
}
var previous: RecyclerView.ViewHolder? = null
for (child in parent.children) {
val holder = parent.getChildViewHolder(child)
if (previous != null && shouldDrawDivider(previous, holder)) {
parent.getDecoratedBoundsWithMargins(child, bounds)
val top: Float = bounds.top + child.translationY
val bottom: Float = top + thickness
canvas.drawRect(left, top, right, bottom, paint)
}
previous = holder
}
canvas.restore()
}
protected abstract fun shouldDrawDivider(
above: RecyclerView.ViewHolder,
below: RecyclerView.ViewHolder,
): Boolean
}

View File

@@ -519,7 +519,7 @@ class FastScroller @JvmOverloads constructor(
private fun TypedArray.getBubbleSize(@StyleableRes index: Int, defaultValue: BubbleSize): BubbleSize {
val ordinal = getInt(index, -1)
return BubbleSize.values().getOrNull(ordinal) ?: defaultValue
return BubbleSize.entries.getOrNull(ordinal) ?: defaultValue
}
private fun findValidParent(view: View): ViewGroup? = view.parents.firstNotNullOfOrNull { p ->

View File

@@ -1,48 +0,0 @@
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

@@ -1,9 +1,12 @@
package org.koitharu.kotatsu.core.util.ext
import android.Manifest
import android.annotation.SuppressLint
import android.app.Activity
import android.app.ActivityManager
import android.app.ActivityManager.MemoryInfo
import android.app.ActivityOptions
import android.app.LocaleConfig
import android.content.Context
import android.content.Context.ACTIVITY_SERVICE
import android.content.Context.POWER_SERVICE
@@ -11,8 +14,8 @@ import android.content.ContextWrapper
import android.content.OperationApplicationException
import android.content.SharedPreferences
import android.content.SyncResult
import android.content.pm.PackageManager
import android.content.pm.ResolveInfo
import android.content.res.Resources
import android.database.SQLException
import android.graphics.Color
import android.net.Uri
@@ -27,6 +30,8 @@ import android.widget.Toast
import androidx.activity.result.ActivityResultLauncher
import androidx.annotation.IntegerRes
import androidx.core.app.ActivityOptionsCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import androidx.core.os.LocaleListCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.coroutineScope
@@ -172,10 +177,17 @@ fun scaleUpActivityOptionsOf(view: View): Bundle? = if (view.context.isAnimation
null
}
fun Resources.getLocalesConfig(): LocaleListCompat {
@SuppressLint("DiscouragedApi")
fun Context.getLocalesConfig(): LocaleListCompat {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
LocaleConfig(this).supportedLocales?.let {
return LocaleListCompat.wrap(it)
}
}
val tagsList = StringJoiner(",")
try {
val xpp: XmlPullParser = getXml(R.xml.locales)
val resId = resources.getIdentifier("_generated_res_locale_config", "xml", packageName)
val xpp: XmlPullParser = resources.getXml(resId)
while (xpp.eventType != XmlPullParser.END_DOCUMENT) {
if (xpp.eventType == XmlPullParser.START_TAG) {
if (xpp.name == "locale") {
@@ -212,3 +224,9 @@ inline fun Activity.catchingWebViewUnavailability(block: () -> Unit): Boolean {
}
}
}
fun Context.checkNotificationPermission(): Boolean = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED
} else {
NotificationManagerCompat.from(this).areNotificationsEnabled()
}

View File

@@ -5,15 +5,6 @@ import androidx.collection.ArraySet
import java.util.Collections
import java.util.EnumSet
@Deprecated("TODO: remove")
fun <T> MutableList<T>.move(sourceIndex: Int, targetIndex: Int) {
if (sourceIndex <= targetIndex) {
Collections.rotate(subList(sourceIndex, targetIndex + 1), -1)
} else {
Collections.rotate(subList(targetIndex, sourceIndex + 1), 1)
}
}
inline fun <T> MutableSet(size: Int, init: (index: Int) -> T): MutableSet<T> {
val set = ArraySet<T>(size)
repeat(size) { index -> set.add(init(index)) }
@@ -41,10 +32,6 @@ fun <K, V> Map<K, V>.findKeyByValue(value: V): K? {
return null
}
inline fun <T> Collection<T>.filterToSet(predicate: (T) -> Boolean): Set<T> {
return filterTo(ArraySet(size), predicate)
}
fun <T> Sequence<T>.toListSorted(comparator: Comparator<T>): List<T> {
return toMutableList().apply { sortWith(comparator) }
}

View File

@@ -13,6 +13,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.fs.FileSequence
import java.io.File
import java.io.FileFilter
import java.util.zip.ZipEntry
@@ -73,11 +74,10 @@ suspend fun File.computeSize(): Long = runInterruptible(Dispatchers.IO) {
@WorkerThread
private fun computeSizeInternal(file: File): Long {
if (file.isDirectory) {
val files = file.listFiles() ?: return 0L
return files.sumOf { computeSizeInternal(it) }
return if (file.isDirectory) {
file.children().sumOf { computeSizeInternal(it) }
} else {
return file.length()
file.length()
}
}
@@ -86,9 +86,8 @@ fun File.listFilesRecursive(filter: FileFilter? = null): Sequence<File> = sequen
}
private suspend fun SequenceScope<File>.listFilesRecursiveImpl(root: File, filter: FileFilter?) {
val ss = root.list() ?: return
for (s in ss) {
val f = File(root, s)
val ss = root.children()
for (f in ss) {
if (f.isDirectory) {
listFilesRecursiveImpl(f, filter)
} else if (filter == null || filter.accept(f)) {
@@ -96,3 +95,7 @@ private suspend fun SequenceScope<File>.listFilesRecursiveImpl(root: File, filte
}
}
}
fun File.children() = FileSequence(this)
fun Sequence<File>.filterWith(filter: FileFilter): Sequence<File> = filter { f -> filter.accept(f) }

View File

@@ -9,8 +9,6 @@ import androidx.annotation.ColorInt
import androidx.annotation.FloatRange
import androidx.annotation.Px
import androidx.core.content.ContextCompat
import androidx.core.content.res.ResourcesCompat
import androidx.core.content.res.TypedArrayUtils
import androidx.core.content.res.use
import androidx.core.graphics.ColorUtils

View File

@@ -14,6 +14,7 @@ import org.koitharu.kotatsu.core.exceptions.CaughtException
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
import org.koitharu.kotatsu.core.exceptions.EmptyHistoryException
import org.koitharu.kotatsu.core.exceptions.SyncApiException
import org.koitharu.kotatsu.core.exceptions.TooManyRequestExceptions
import org.koitharu.kotatsu.core.exceptions.UnsupportedFileException
import org.koitharu.kotatsu.core.exceptions.WrongPasswordException
import org.koitharu.kotatsu.parsers.exception.AuthRequiredException
@@ -32,6 +33,7 @@ fun Throwable.getDisplayMessage(resources: Resources): String = when (this) {
is UnsupportedOperationException,
-> resources.getString(R.string.operation_not_supported)
is TooManyRequestExceptions -> resources.getString(R.string.too_many_requests_message)
is UnsupportedFileException -> resources.getString(R.string.text_file_not_supported)
is FileNotFoundException -> resources.getString(R.string.file_not_found)
is AccessDeniedException -> resources.getString(R.string.no_access_to_file)

View File

@@ -0,0 +1,36 @@
package org.koitharu.kotatsu.core.util.iterator
import okhttp3.internal.closeQuietly
import okio.Closeable
class CloseableIterator<T>(
private val upstream: Iterator<T>,
private val closeable: Closeable,
) : Iterator<T>, Closeable {
private var isClosed = false
override fun hasNext(): Boolean {
val result = upstream.hasNext()
if (!result) {
close()
}
return result
}
override fun next(): T {
try {
return upstream.next()
} catch (e: NoSuchElementException) {
close()
throw e
}
}
override fun close() {
if (!isClosed) {
closeable.closeQuietly()
isClosed = true
}
}
}

View File

@@ -0,0 +1,13 @@
package org.koitharu.kotatsu.core.util.iterator
import org.koitharu.kotatsu.R
class MappingIterator<T, R>(
private val upstream: Iterator<T>,
private val mapper: (T) -> R,
) : Iterator<R> {
override fun hasNext(): Boolean = upstream.hasNext()
override fun next(): R = mapper(upstream.next())
}

View File

@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.core.zip
import androidx.annotation.WorkerThread
import androidx.collection.ArraySet
import okio.Closeable
import org.koitharu.kotatsu.core.util.ext.children
import java.io.File
import java.io.FileInputStream
import java.util.zip.Deflater
@@ -90,7 +91,7 @@ class ZipOutput(
}
putNextEntry(entry)
closeEntry()
fileToZip.listFiles()?.forEach { childFile ->
fileToZip.children().forEach { childFile ->
appendFile(childFile, "$name/${childFile.name}")
}
} else {

View File

@@ -0,0 +1,32 @@
package org.koitharu.kotatsu.core.zip
import android.net.Uri
import androidx.annotation.WorkerThread
import androidx.collection.LruCache
import okhttp3.internal.closeQuietly
import okio.Source
import okio.source
import java.io.File
import java.util.zip.ZipFile
class ZipPool(maxSize: Int) : LruCache<String, ZipFile>(maxSize) {
override fun entryRemoved(evicted: Boolean, key: String, oldValue: ZipFile, newValue: ZipFile?) {
super.entryRemoved(evicted, key, oldValue, newValue)
oldValue.closeQuietly()
}
override fun create(key: String): ZipFile {
return ZipFile(File(key), ZipFile.OPEN_READ)
}
@Synchronized
@WorkerThread
operator fun get(uri: Uri): Source {
val zip = requireNotNull(get(uri.schemeSpecificPart)) {
"Cannot obtain zip by \"$uri\""
}
val entry = zip.getEntry(uri.fragment)
return zip.getInputStream(entry).source()
}
}

View File

@@ -87,7 +87,6 @@ class ChaptersFragment :
.manga(viewModel.manga.value ?: return)
.state(ReaderState(item.chapter.id, 0, 0))
.build(),
scaleUpActivityOptionsOf(view),
)
}

View File

@@ -1,13 +1,16 @@
package org.koitharu.kotatsu.details.ui
import android.content.Context
import android.content.DialogInterface
import android.content.Intent
import android.graphics.Color
import android.os.Bundle
import android.text.style.ForegroundColorSpan
import android.text.style.RelativeSizeSpan
import android.transition.AutoTransition
import android.transition.Slide
import android.transition.TransitionManager
import android.view.Gravity
import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
@@ -17,6 +20,8 @@ import android.widget.Toast
import androidx.activity.viewModels
import androidx.appcompat.widget.PopupMenu
import androidx.core.graphics.Insets
import androidx.core.text.buildSpannedString
import androidx.core.text.inSpans
import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams
@@ -33,12 +38,11 @@ import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.os.AppShortcutManager
import org.koitharu.kotatsu.core.parser.MangaIntent
import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.ui.dialog.RecyclerViewAlertDialog
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.ui.util.MenuInvalidator
import org.koitharu.kotatsu.core.util.ViewBadge
import org.koitharu.kotatsu.core.util.ext.doOnExpansionsChanged
import org.koitharu.kotatsu.core.util.ext.getAnimationDuration
import org.koitharu.kotatsu.core.util.ext.getThemeColor
import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled
import org.koitharu.kotatsu.core.util.ext.measureHeight
import org.koitharu.kotatsu.core.util.ext.observe
@@ -48,10 +52,8 @@ import org.koitharu.kotatsu.core.util.ext.setNavigationIconSafe
import org.koitharu.kotatsu.core.util.ext.textAndVisible
import org.koitharu.kotatsu.databinding.ActivityDetailsBinding
import org.koitharu.kotatsu.details.service.MangaPrefetchService
import org.koitharu.kotatsu.details.ui.adapter.branchAD
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.details.ui.model.HistoryInfo
import org.koitharu.kotatsu.details.ui.model.MangaBranch
import org.koitharu.kotatsu.download.ui.worker.DownloadStartedObserver
import org.koitharu.kotatsu.main.ui.owners.NoModalBottomSheetOwner
import org.koitharu.kotatsu.parsers.model.Manga
@@ -157,7 +159,7 @@ class DetailsActivity :
override fun onClick(v: View) {
when (v.id) {
R.id.button_read -> openReader(isIncognitoMode = false)
R.id.button_dropdown -> showBranchPopupMenu()
R.id.button_dropdown -> showBranchPopupMenu(v)
}
}
@@ -275,20 +277,28 @@ class DetailsActivity :
viewBadge.counter = newChapters
}
private fun showBranchPopupMenu() {
var dialog: DialogInterface? = null
val listener = OnListItemClickListener<MangaBranch> { item, _ ->
viewModel.setSelectedBranch(item.name)
dialog?.dismiss()
private fun showBranchPopupMenu(v: View) {
val menu = PopupMenu(v.context, v)
val branches = viewModel.branches.value
for ((i, branch) in branches.withIndex()) {
val title = buildSpannedString {
append(branch.name ?: getString(R.string.system_default))
append(' ')
append(' ')
inSpans(
ForegroundColorSpan(v.context.getThemeColor(android.R.attr.textColorSecondary, Color.LTGRAY)),
RelativeSizeSpan(0.74f),
) {
append(branch.count.toString())
}
}
menu.menu.add(Menu.NONE, Menu.NONE, i, title)
}
dialog = RecyclerViewAlertDialog.Builder<MangaBranch>(this)
.addAdapterDelegate(branchAD(listener))
.setCancelable(true)
.setNegativeButton(android.R.string.cancel, null)
.setTitle(R.string.translations)
.setItems(viewModel.branches.value)
.create()
.also { it.show() }
menu.setOnMenuItemClickListener {
viewModel.setSelectedBranch(branches.getOrNull(it.order)?.name)
true
}
menu.show()
}
private fun openReader(isIncognitoMode: Boolean) {

View File

@@ -119,7 +119,6 @@ class DetailsFragment :
override fun onItemClick(item: Bookmark, view: View) {
startActivity(
ReaderActivity.IntentBuilder(view.context).bookmark(item).incognito(true).build(),
scaleUpActivityOptionsOf(view),
)
Toast.makeText(view.context, R.string.incognito_mode, Toast.LENGTH_SHORT).show()
}
@@ -233,7 +232,7 @@ class DetailsFragment :
coil, viewLifecycleOwner,
StaticItemSizeResolver(resources.getDimensionPixelSize(R.dimen.smaller_grid_width)),
) { item, view ->
startActivity(DetailsActivity.newIntent(view.context, item), scaleUpActivityOptionsOf(view))
startActivity(DetailsActivity.newIntent(view.context, item))
},
).also { rv.adapter = it }
adapter.items = related

View File

@@ -19,7 +19,7 @@ import org.koitharu.kotatsu.core.os.AppShortcutManager
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.util.ShareHelper
import org.koitharu.kotatsu.download.ui.dialog.DownloadOption
import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteCategoriesSheet
import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteSheet
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.scrobbling.common.ui.selector.ScrobblingSelectorSheet
import org.koitharu.kotatsu.search.ui.multi.MultiSearchActivity
@@ -62,7 +62,7 @@ class DetailsMenuProvider(
R.id.action_favourite -> {
viewModel.manga.value?.let {
FavouriteCategoriesSheet.show(activity.supportFragmentManager, it)
FavouriteSheet.show(activity.supportFragmentManager, it)
}
}

View File

@@ -1,16 +0,0 @@
package org.koitharu.kotatsu.details.ui.adapter
import com.hannesdorfmann.adapterdelegates4.ListDelegationAdapter
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.details.ui.model.MangaBranch
class BranchesAdapter(
list: List<MangaBranch>,
listener: OnListItemClickListener<MangaBranch>,
) : ListDelegationAdapter<List<MangaBranch>>() {
init {
delegatesManager.addDelegate(branchAD(listener))
items = list
}
}

View File

@@ -88,7 +88,7 @@ class ScrobblingInfoSheet :
viewModel.updateScrobbling(
index = scrobblerIndex,
rating = requireViewBinding().ratingBar.rating / requireViewBinding().ratingBar.numStars,
status = enumValues<ScrobblingStatus>().getOrNull(position),
status = ScrobblingStatus.entries.getOrNull(position),
)
}
@@ -99,7 +99,7 @@ class ScrobblingInfoSheet :
viewModel.updateScrobbling(
index = scrobblerIndex,
rating = rating / ratingBar.numStars,
status = enumValues<ScrobblingStatus>().getOrNull(requireViewBinding().spinnerStatus.selectedItemPosition),
status = ScrobblingStatus.entries.getOrNull(requireViewBinding().spinnerStatus.selectedItemPosition),
)
}
}

View File

@@ -7,6 +7,7 @@ import androidx.work.WorkInfo
import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R
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.newImageRequest
@@ -53,6 +54,7 @@ fun downloadItemAD(
fallback(R.drawable.ic_placeholder)
error(R.drawable.ic_error_placeholder)
allowRgb565(true)
transformations(TrimTransformation())
source(item.manga.source)
enqueueWith(coil)
}

View File

@@ -32,6 +32,7 @@ import org.koitharu.kotatsu.list.ui.model.LoadingState
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.mapToSet
import java.util.Date
import java.util.LinkedList
import java.util.UUID
import java.util.concurrent.TimeUnit
import javax.inject.Inject
@@ -181,16 +182,34 @@ class DownloadsViewModel @Inject constructor(
if (isEmpty()) {
return emptyStateList()
}
val destination = ArrayList<ListModel>((size * 1.4).toInt())
val queued = LinkedList<ListModel>()
val running = LinkedList<ListModel>()
val destination = ArrayDeque<ListModel>((size * 1.4).toInt())
var prevDate: DateTimeAgo? = null
for (item in this) {
val date = timeAgo(item.timestamp)
if (prevDate != date) {
destination += ListHeader(date)
when (item.workState) {
WorkInfo.State.RUNNING -> running += item
WorkInfo.State.BLOCKED,
WorkInfo.State.ENQUEUED -> queued += item
else -> {
val date = timeAgo(item.timestamp)
if (prevDate != date) {
destination += ListHeader(date)
}
prevDate = date
destination += item
}
}
prevDate = date
destination += item
}
if (running.isNotEmpty()) {
running.addFirst(ListHeader(R.string.in_progress))
}
destination.addAll(0, running)
if (queued.isNotEmpty()) {
queued.addFirst(ListHeader(R.string.queued))
}
destination.addAll(0, queued)
return destination
}

View File

@@ -4,13 +4,17 @@ import androidx.room.withTransaction
import dagger.Reusable
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.dao.MangaSourcesDao
import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.observeAsFlow
import org.koitharu.kotatsu.core.ui.util.ReversibleHandle
import org.koitharu.kotatsu.parsers.model.ContentType
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.move
import java.util.Collections
@@ -20,6 +24,7 @@ import javax.inject.Inject
@Reusable
class MangaSourcesRepository @Inject constructor(
private val db: MangaDatabase,
private val settings: AppSettings,
) {
private val dao: MangaSourcesDao
@@ -36,11 +41,13 @@ class MangaSourcesRepository @Inject constructor(
get() = Collections.unmodifiableSet(remoteSources)
suspend fun getEnabledSources(): List<MangaSource> {
return dao.findAllEnabled().toSources()
return dao.findAllEnabled().toSources(settings.isNsfwContentDisabled)
}
fun observeEnabledSources(): Flow<List<MangaSource>> = dao.observeEnabled().map {
it.toSources()
fun observeEnabledSources(): Flow<List<MangaSource>> = observeIsNsfwDisabled().flatMapLatest { skipNsfw ->
dao.observeEnabled().map {
it.toSources(skipNsfw)
}
}
fun observeAll(): Flow<List<Pair<MangaSource, Boolean>>> = dao.observeAll().map { entities ->
@@ -137,14 +144,21 @@ class MangaSourcesRepository @Inject constructor(
return dao.findAll().isEmpty()
}
private fun List<MangaSourceEntity>.toSources(): List<MangaSource> {
private fun List<MangaSourceEntity>.toSources(skipNsfwSources: Boolean): List<MangaSource> {
val result = ArrayList<MangaSource>(size)
for (entity in this) {
val source = MangaSource(entity.source)
if (skipNsfwSources && source.contentType == ContentType.HENTAI) {
continue
}
if (source in remoteSources) {
result.add(source)
}
}
return result
}
private fun observeIsNsfwDisabled() = settings.observeAsFlow(AppSettings.KEY_DISABLE_NSFW) {
isNsfwContentDisabled
}
}

View File

@@ -69,7 +69,7 @@ class ExploreFragment :
override fun onViewBindingCreated(binding: FragmentExploreBinding, savedInstanceState: Bundle?) {
super.onViewBindingCreated(binding, savedInstanceState)
exploreAdapter = ExploreAdapter(coil, viewLifecycleOwner, this, this, this) { manga, view ->
startActivity(DetailsActivity.newIntent(view.context, manga), scaleUpActivityOptionsOf(view))
startActivity(DetailsActivity.newIntent(view.context, manga))
}
with(binding.recyclerView) {
adapter = exploreAdapter

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.parsers.model.Manga
class RecommendationsItem(
data class RecommendationsItem(
val manga: Manga
) : ListModel {
@@ -12,18 +12,4 @@ class RecommendationsItem(
override fun areItemsTheSame(other: ListModel): Boolean {
return other is RecommendationsItem
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as RecommendationsItem
return manga == other.manga
}
override fun hashCode(): Int {
return 31 * manga.hashCode()
}
}

View File

@@ -1,6 +1,7 @@
package org.koitharu.kotatsu.favourites.domain.model
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.find
class Cover(
val url: String,
@@ -8,7 +9,7 @@ class Cover(
) {
val mangaSource: MangaSource?
get() = if (source.isEmpty()) null else MangaSource.values().find { it.name == source }
get() = if (source.isEmpty()) null else MangaSource.entries.find(source)
override fun equals(other: Any?): Boolean {
if (this === other) return true

View File

@@ -23,7 +23,7 @@ import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.model.Manga
@AndroidEntryPoint
class FavouriteCategoriesSheet :
class FavouriteSheet :
BaseAdaptiveSheet<SheetFavoriteCategoriesBinding>(),
OnListItemClickListener<MangaCategoryItem> {
@@ -53,7 +53,7 @@ class FavouriteCategoriesSheet :
}
override fun onItemClick(item: MangaCategoryItem, view: View) {
viewModel.setChecked(item.id, !item.isChecked)
viewModel.setChecked(item.category.id, !item.isChecked)
}
private fun onContentChanged(categories: List<ListModel>) {
@@ -72,7 +72,7 @@ class FavouriteCategoriesSheet :
fun show(fm: FragmentManager, manga: Manga) = Companion.show(fm, listOf(manga))
fun show(fm: FragmentManager, manga: Collection<Manga>) =
FavouriteCategoriesSheet().withArgs(1) {
FavouriteSheet().withArgs(1) {
putParcelableArrayList(
KEY_MANGA_LIST,
manga.mapTo(ArrayList(manga.size)) {

View File

@@ -14,7 +14,7 @@ import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.util.ext.require
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteCategoriesSheet.Companion.KEY_MANGA_LIST
import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteSheet.Companion.KEY_MANGA_LIST
import org.koitharu.kotatsu.favourites.ui.categories.select.model.CategoriesHeaderItem
import org.koitharu.kotatsu.favourites.ui.categories.select.model.MangaCategoryItem
import org.koitharu.kotatsu.list.ui.model.ListModel
@@ -37,8 +37,7 @@ class MangaCategoriesViewModel @Inject constructor(
add(header)
all.mapTo(this) {
MangaCategoryItem(
id = it.id,
name = it.title,
category = it,
isChecked = it.id in checked,
)
}

View File

@@ -1,17 +1,18 @@
package org.koitharu.kotatsu.favourites.ui.categories.select.adapter
import androidx.core.view.isVisible
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.util.ext.setChecked
import org.koitharu.kotatsu.databinding.ItemCheckableNewBinding
import org.koitharu.kotatsu.databinding.ItemCategoryCheckableBinding
import org.koitharu.kotatsu.favourites.ui.categories.select.model.MangaCategoryItem
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
import org.koitharu.kotatsu.list.ui.model.ListModel
fun mangaCategoryAD(
clickListener: OnListItemClickListener<MangaCategoryItem>,
) = adapterDelegateViewBinding<MangaCategoryItem, ListModel, ItemCheckableNewBinding>(
{ inflater, parent -> ItemCheckableNewBinding.inflate(inflater, parent, false) },
) = adapterDelegateViewBinding<MangaCategoryItem, ListModel, ItemCategoryCheckableBinding>(
{ inflater, parent -> ItemCategoryCheckableBinding.inflate(inflater, parent, false) },
) {
itemView.setOnClickListener {
@@ -19,9 +20,9 @@ fun mangaCategoryAD(
}
bind { payloads ->
with(binding.root) {
text = item.name
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.imageViewTracker.isVisible = item.category.isTrackingEnabled
binding.imageViewVisible.isVisible = item.category.isVisibleInLibrary
}
}

View File

@@ -1,16 +1,16 @@
package org.koitharu.kotatsu.favourites.ui.categories.select.model
import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
import org.koitharu.kotatsu.list.ui.model.ListModel
class MangaCategoryItem(
val id: Long,
val name: String,
data class MangaCategoryItem(
val category: FavouriteCategory,
val isChecked: Boolean,
) : ListModel {
override fun areItemsTheSame(other: ListModel): Boolean {
return other is MangaCategoryItem && other.id == id
return other is MangaCategoryItem && other.category.id == category.id
}
override fun getChangePayload(previousState: ListModel): Any? {
@@ -20,22 +20,4 @@ class MangaCategoryItem(
super.getChangePayload(previousState)
}
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as MangaCategoryItem
if (id != other.id) return false
if (name != other.name) return false
return isChecked == other.isChecked
}
override fun hashCode(): Int {
var result = id.hashCode()
result = 31 * result + name.hashCode()
result = 31 * result + isChecked.hashCode()
return result
}
}

View File

@@ -38,7 +38,7 @@ class FavouritesListMenuProvider(
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
if (menuItem.groupId == R.id.group_order) {
val order = enumValues<SortOrder>()[menuItem.order]
val order = SortOrder.entries[menuItem.order]
viewModel.setSortOrder(order)
return true
}

View File

@@ -1,7 +1,6 @@
package org.koitharu.kotatsu.filter.ui.model
import org.koitharu.kotatsu.core.ui.widgets.ChipsView
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.model.SortOrder
class FilterHeaderModel(

View File

@@ -12,5 +12,5 @@ enum class HistoryOrder(
PROGRESS(R.string.progress),
ALPHABETIC(R.string.by_name);
fun isGroupingSupported() = this == UPDATED || this == CREATED
fun isGroupingSupported() = this == UPDATED || this == CREATED || this == PROGRESS
}

View File

@@ -7,12 +7,14 @@ import org.koitharu.kotatsu.core.ui.list.fastscroll.FastScroller
import org.koitharu.kotatsu.list.ui.adapter.MangaListAdapter
import org.koitharu.kotatsu.list.ui.adapter.MangaListListener
import org.koitharu.kotatsu.list.ui.model.ListHeader
import org.koitharu.kotatsu.list.ui.size.ItemSizeResolver
class HistoryListAdapter(
coil: ImageLoader,
lifecycleOwner: LifecycleOwner,
listener: MangaListListener
) : MangaListAdapter(coil, lifecycleOwner, listener), FastScroller.SectionIndexer {
listener: MangaListListener,
sizeResolver: ItemSizeResolver,
) : MangaListAdapter(coil, lifecycleOwner, listener, sizeResolver), FastScroller.SectionIndexer {
override fun getSectionText(context: Context, position: Int): CharSequence? {
val list = items

View File

@@ -13,6 +13,7 @@ import org.koitharu.kotatsu.core.util.ext.addMenuProvider
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.databinding.FragmentListBinding
import org.koitharu.kotatsu.list.ui.MangaListFragment
import org.koitharu.kotatsu.list.ui.size.DynamicItemSizeResolver
import org.koitharu.kotatsu.parsers.model.MangaSource
@AndroidEntryPoint
@@ -55,5 +56,10 @@ class HistoryListFragment : MangaListFragment() {
}
}
override fun onCreateAdapter() = HistoryListAdapter(coil, viewLifecycleOwner, this)
override fun onCreateAdapter() = HistoryListAdapter(
coil,
viewLifecycleOwner,
this,
DynamicItemSizeResolver(resources, settings, adjustWidth = false),
)
}

View File

@@ -23,7 +23,7 @@ class HistoryListMenuProvider(
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menuInflater.inflate(R.menu.opt_history, menu)
val subMenu = menu.findItem(R.id.action_order)?.subMenu ?: return
for (order in HistoryOrder.values()) {
for (order in HistoryOrder.entries) {
subMenu.add(R.id.group_order, Menu.NONE, order.ordinal, order.titleResId)
}
subMenu.setGroupCheckable(R.id.group_order, true, true)
@@ -31,7 +31,7 @@ class HistoryListMenuProvider(
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
if (menuItem.groupId == R.id.group_order) {
val order = enumValues<HistoryOrder>()[menuItem.order]
val order = HistoryOrder.entries[menuItem.order]
viewModel.setSortOrder(order)
return true
}
@@ -51,7 +51,7 @@ class HistoryListMenuProvider(
}
override fun onPrepareMenu(menu: Menu) {
val order = viewModel.sortOrder.value ?: return
val order = viewModel.sortOrder.value
menu.findItem(R.id.action_order)?.subMenu?.forEach { item ->
if (item.order == order.ordinal) {
item.isChecked = true

View File

@@ -12,6 +12,7 @@ import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.plus
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.MangaHistory
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.ListMode
import org.koitharu.kotatsu.core.prefs.observeAsFlow
@@ -130,14 +131,17 @@ class HistoryListViewModel @Inject constructor(
mode: ListMode,
): List<ListModel> {
val result = ArrayList<ListModel>(if (grouped) (list.size * 1.4).toInt() else list.size + 1)
var prevDate: DateTimeAgo? = null
val order = sortOrder.value
var prevHeader: ListHeader? = null
for ((manga, history) in list) {
if (grouped) {
val date = timeAgo(history.updatedAt)
if (prevDate != date) {
result += ListHeader(date)
val header = history.header(order)
if (header != prevHeader) {
if (header != null) {
result += header
}
prevHeader = header
}
prevDate = date
}
result += when (mode) {
ListMode.LIST -> manga.toListModel(extraProvider)
@@ -148,6 +152,21 @@ class HistoryListViewModel @Inject constructor(
return result
}
private fun MangaHistory.header(order: HistoryOrder): ListHeader? = when (order) {
HistoryOrder.UPDATED -> ListHeader(timeAgo(updatedAt))
HistoryOrder.CREATED -> ListHeader(timeAgo(createdAt))
HistoryOrder.PROGRESS -> ListHeader(
when (percent) {
1f -> R.string.status_completed
in 0f..0.01f -> R.string.status_planned
in 0f..1f -> R.string.status_reading
else -> R.string.unknown
},
)
HistoryOrder.ALPHABETIC -> null
}
private fun timeAgo(date: Date): DateTimeAgo {
val diff = (System.currentTimeMillis() - date.time).coerceAtLeast(0L)
val diffMinutes = TimeUnit.MILLISECONDS.toMinutes(diff).toInt()

View File

@@ -22,6 +22,7 @@ import kotlinx.coroutines.launch
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.ListMode
import org.koitharu.kotatsu.core.ui.BaseFragment
import org.koitharu.kotatsu.core.ui.list.FitHeightGridLayoutManager
@@ -41,7 +42,7 @@ import org.koitharu.kotatsu.core.util.ext.viewLifecycleScope
import org.koitharu.kotatsu.databinding.FragmentListBinding
import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.download.ui.worker.DownloadStartedObserver
import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteCategoriesSheet
import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteSheet
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
import org.koitharu.kotatsu.list.ui.adapter.MangaListAdapter
import org.koitharu.kotatsu.list.ui.adapter.MangaListListener
@@ -49,6 +50,7 @@ import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
import org.koitharu.kotatsu.list.ui.model.ListHeader
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.MangaItemModel
import org.koitharu.kotatsu.list.ui.size.DynamicItemSizeResolver
import org.koitharu.kotatsu.main.ui.MainActivity
import org.koitharu.kotatsu.main.ui.owners.AppBarOwner
import org.koitharu.kotatsu.parsers.model.Manga
@@ -69,6 +71,9 @@ abstract class MangaListFragment :
@Inject
lateinit var coil: ImageLoader
@Inject
lateinit var settings: AppSettings
private var listAdapter: MangaListAdapter? = null
private var paginationListener: PaginationScrollListener? = null
private var selectionController: ListSelectionController? = null
@@ -147,7 +152,7 @@ abstract class MangaListFragment :
override fun onReadClick(manga: Manga, view: View) {
if (selectionController?.onItemClick(manga.id) != true) {
val intent = IntentBuilder(view.context).manga(manga).build()
startActivity(intent, scaleUpActivityOptionsOf(view))
startActivity(intent)
}
}
@@ -195,6 +200,7 @@ abstract class MangaListFragment :
coil = coil,
lifecycleOwner = viewLifecycleOwner,
listener = this,
sizeResolver = DynamicItemSizeResolver(resources, settings, adjustWidth = false)
)
}
@@ -283,7 +289,7 @@ abstract class MangaListFragment :
}
R.id.action_favourite -> {
FavouriteCategoriesSheet.show(childFragmentManager, selectedItems)
FavouriteSheet.show(childFragmentManager, selectedItems)
mode.finish()
true
}

View File

@@ -19,7 +19,7 @@ import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaTag
abstract class MangaListViewModel(
settings: AppSettings,
private val settings: AppSettings,
private val downloadScheduler: DownloadWorker.Scheduler,
) : BaseViewModel() {
@@ -46,4 +46,10 @@ abstract class MangaListViewModel(
onDownloadStarted.call(Unit)
}
}
fun List<Manga>.skipNsfwIfNeeded() = if (settings.isNsfwContentDisabled) {
filterNot { it.isNsfw }
} else {
this
}
}

View File

@@ -8,6 +8,7 @@ enum class ListItemType {
MANGA_LIST,
MANGA_LIST_DETAILED,
MANGA_GRID,
MANGA_NESTED_GROUP,
FOOTER_LOADING,
FOOTER_ERROR,
STATE_LOADING,

View File

@@ -23,7 +23,7 @@ import org.koitharu.kotatsu.parsers.model.Manga
fun mangaGridItemAD(
coil: ImageLoader,
lifecycleOwner: LifecycleOwner,
sizeResolver: ItemSizeResolver?,
sizeResolver: ItemSizeResolver,
clickListener: OnListItemClickListener<Manga>,
) = adapterDelegateViewBinding<MangaGridModel, ListModel, ItemMangaGridBinding>(
{ inflater, parent -> ItemMangaGridBinding.inflate(inflater, parent, false) },
@@ -36,7 +36,7 @@ fun mangaGridItemAD(
itemView.setOnLongClickListener {
clickListener.onItemLongClick(item.manga, it)
}
sizeResolver?.attachToView(lifecycleOwner, itemView, binding.textViewTitle, binding.progressView)
sizeResolver.attachToView(lifecycleOwner, itemView, binding.textViewTitle, binding.progressView)
bind { payloads ->
binding.textViewTitle.text = item.title

View File

@@ -4,17 +4,19 @@ import androidx.lifecycle.LifecycleOwner
import coil.ImageLoader
import org.koitharu.kotatsu.core.ui.BaseListAdapter
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.size.ItemSizeResolver
open class MangaListAdapter(
coil: ImageLoader,
lifecycleOwner: LifecycleOwner,
listener: MangaListListener,
sizeResolver: ItemSizeResolver,
) : BaseListAdapter<ListModel>() {
init {
addDelegate(ListItemType.MANGA_LIST, mangaListItemAD(coil, lifecycleOwner, listener))
addDelegate(ListItemType.MANGA_LIST_DETAILED, mangaListDetailedItemAD(coil, lifecycleOwner, listener))
addDelegate(ListItemType.MANGA_GRID, mangaGridItemAD(coil, lifecycleOwner, null, listener))
addDelegate(ListItemType.MANGA_GRID, mangaGridItemAD(coil, lifecycleOwner, sizeResolver, listener))
addDelegate(ListItemType.FOOTER_LOADING, loadingFooterAD())
addDelegate(ListItemType.STATE_LOADING, loadingStateAD())
addDelegate(ListItemType.STATE_ERROR, errorStateListAD(listener))

View File

@@ -21,7 +21,7 @@ class TypedListSpacingDecoration(
state: RecyclerView.State
) {
val itemType = parent.getChildViewHolder(view)?.itemViewType?.let {
ListItemType.values().getOrNull(it)
ListItemType.entries.getOrNull(it)
}
when (itemType) {
ListItemType.FILTER_SORT,
@@ -44,11 +44,12 @@ class TypedListSpacingDecoration(
ListItemType.EXPLORE_SOURCE_GRID,
ListItemType.EXPLORE_SOURCE_LIST,
ListItemType.EXPLORE_SUGGESTION,
ListItemType.MANGA_NESTED_GROUP,
null -> outRect.set(0)
ListItemType.TIP -> outRect.set(0) // TODO
ListItemType.HINT_EMPTY -> outRect.set(0) // TODO
ListItemType.FEED -> outRect.set(0) // TODO
ListItemType.FEED -> outRect.set(spacingList, 0, spacingList, 0)
}
}

View File

@@ -69,7 +69,6 @@ class PreviewFragment : BaseFragment<FragmentPreviewBinding>(), View.OnClickList
R.id.button_close -> closeSelf()
R.id.button_open -> startActivity(
DetailsActivity.newIntent(v.context, manga),
scaleUpActivityOptionsOf(requireView()),
)
R.id.textView_author -> startActivity(

View File

@@ -5,7 +5,6 @@ import android.content.res.Resources
import android.view.View
import android.widget.TextView
import androidx.annotation.StyleRes
import androidx.core.view.updateLayoutParams
import androidx.core.widget.TextViewCompat
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
@@ -14,7 +13,11 @@ import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.history.ui.util.ReadingProgressView
import kotlin.math.roundToInt
class DynamicItemSizeResolver(resources: Resources, private val settings: AppSettings) : ItemSizeResolver {
class DynamicItemSizeResolver(
resources: Resources,
private val settings: AppSettings,
private val adjustWidth: Boolean,
) : ItemSizeResolver {
private val gridWidth = resources.getDimension(R.dimen.preferred_grid_width)
private val scaleFactor: Float
@@ -72,8 +75,12 @@ class DynamicItemSizeResolver(resources: Resources, private val settings: AppSet
fun update() {
val newWidth = cellWidth
textView?.adjustTextAppearance(newWidth)
view.updateLayoutParams {
width = newWidth
if (adjustWidth) {
val lp = view.layoutParams
if (lp.width != newWidth) {
lp.width = newWidth
view.layoutParams = lp
}
}
progressView?.adjustSize(newWidth)
}

View File

@@ -15,7 +15,9 @@ import org.koitharu.kotatsu.core.model.isLocal
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.CompositeMutex
import org.koitharu.kotatsu.core.util.ext.children
import org.koitharu.kotatsu.core.util.ext.deleteAwait
import org.koitharu.kotatsu.core.util.ext.filterWith
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.local.data.input.LocalMangaInput
import org.koitharu.kotatsu.local.data.output.LocalMangaOutput
@@ -128,9 +130,6 @@ class LocalMangaRepository @Inject constructor(
suspend fun findSavedManga(remoteManga: Manga): LocalManga? {
val files = getAllFiles()
if (files.isEmpty()) {
return null
}
return channelFlow {
for (file in files) {
launch {
@@ -172,7 +171,7 @@ class LocalMangaRepository @Inject constructor(
val dirs = storageManager.getWriteableDirs()
runInterruptible(Dispatchers.IO) {
dirs.flatMap { dir ->
dir.listFiles(TempFileFilter())?.toList().orEmpty()
dir.children().filterWith(TempFileFilter())
}.forEach { file ->
file.deleteRecursively()
}
@@ -189,7 +188,7 @@ class LocalMangaRepository @Inject constructor(
}
private suspend fun getRawList(): ArrayList<LocalManga> {
val files = getAllFiles()
val files = getAllFiles().toList() // TODO remove toList()
return coroutineScope {
val dispatcher = Dispatchers.IO.limitedParallelism(MAX_PARALLELISM)
files.map { file ->
@@ -200,8 +199,8 @@ class LocalMangaRepository @Inject constructor(
}.filterNotNullTo(ArrayList(files.size))
}
private suspend fun getAllFiles() = storageManager.getReadableDirs().flatMap { dir ->
dir.listFiles()?.toList().orEmpty()
private suspend fun getAllFiles() = storageManager.getReadableDirs().asSequence().flatMap { dir ->
dir.children()
}
private fun Collection<LocalManga>.unwrap(): List<Manga> = map { it.manga }

View File

@@ -17,7 +17,6 @@ import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.ext.computeSize
import org.koitharu.kotatsu.core.util.ext.getStorageName
import org.koitharu.kotatsu.core.util.ext.resolveFile
import org.koitharu.kotatsu.core.util.ext.toFileOrNull
import org.koitharu.kotatsu.parsers.util.mapToSet
import java.io.File
import javax.inject.Inject

View File

@@ -9,6 +9,7 @@ import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.util.find
import org.koitharu.kotatsu.parsers.util.json.getBooleanOrDefault
import org.koitharu.kotatsu.parsers.util.json.getLongOrDefault
import org.koitharu.kotatsu.parsers.util.json.getStringOrNull
@@ -67,7 +68,7 @@ class MangaIndex(source: String?) {
isNsfw = json.getBooleanOrDefault("nsfw", false),
coverUrl = json.getString("cover"),
state = json.getStringOrNull("state")?.let { stateString ->
MangaState.values().find { it.name == stateString }
MangaState.entries.find(stateString)
},
description = json.getStringOrNull("description"),
tags = json.getJSONArray("tags").mapJSONToSet { x ->

View File

@@ -1,11 +1,16 @@
package org.koitharu.kotatsu.local.data
import java.io.File
import java.io.FileFilter
import java.io.FilenameFilter
class TempFileFilter : FilenameFilter {
class TempFileFilter : FilenameFilter, FileFilter {
override fun accept(dir: File, name: String): Boolean {
return name.endsWith(".tmp", ignoreCase = true)
}
override fun accept(file: File): Boolean {
return file.name.endsWith(".tmp", ignoreCase = true)
}
}

View File

@@ -4,6 +4,7 @@ import androidx.core.net.toFile
import androidx.core.net.toUri
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
import org.koitharu.kotatsu.core.util.AlphanumComparator
import org.koitharu.kotatsu.core.util.ext.listFilesRecursive
import org.koitharu.kotatsu.core.util.ext.longHashCode
import org.koitharu.kotatsu.core.util.ext.toListSorted
@@ -88,7 +89,7 @@ class LocalMangaDirInput(root: File) : LocalMangaInput(root) {
val file = chapter.url.toUri().toFile()
if (file.isDirectory) {
file.listFilesRecursive(ImageFileFilter())
.toListSorted(compareBy(org.koitharu.kotatsu.core.util.AlphanumComparator()) { x -> x.name })
.toListSorted(compareBy(AlphanumComparator()) { x -> x.name })
.map {
val pageUri = it.toUri().toString()
MangaPage(
@@ -104,7 +105,7 @@ class LocalMangaDirInput(root: File) : LocalMangaInput(root) {
.asSequence()
.filter { x -> !x.isDirectory }
.map { it.name }
.toListSorted(org.koitharu.kotatsu.core.util.AlphanumComparator())
.toListSorted(AlphanumComparator())
.map {
val pageUri = zipUri(file, it)
MangaPage(
@@ -121,7 +122,7 @@ class LocalMangaDirInput(root: File) : LocalMangaInput(root) {
private fun String.toHumanReadable() = replace("_", " ").toCamelCase()
private fun getChaptersFiles(): List<File> = root.listFilesRecursive(CbzFilter())
.toListSorted(compareBy(org.koitharu.kotatsu.core.util.AlphanumComparator()) { x -> x.name })
.toListSorted(compareBy(AlphanumComparator()) { x -> x.name })
private fun findFirstImageEntry(): String? {
val filter = ImageFileFilter()

View File

@@ -4,7 +4,6 @@ import androidx.core.net.toFile
import androidx.core.net.toUri
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
import org.koitharu.kotatsu.local.data.MangaIndex
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource

View File

@@ -46,8 +46,10 @@ class ImportWorker @AssistedInject constructor(
val result = runCatchingCancellable {
importer.import(uri).manga
}
val notification = buildNotification(result)
notificationManager.notify(uri.hashCode(), notification)
if (notificationManager.areNotificationsEnabled()) {
val notification = buildNotification(result)
notificationManager.notify(uri.hashCode(), notification)
}
return Result.success()
}

View File

@@ -20,10 +20,11 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.plus
import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.sync.withPermit
import okhttp3.OkHttpClient
import okhttp3.Request
import okio.source
import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.core.network.ImageProxyInterceptor
import org.koitharu.kotatsu.core.network.MangaHttpClient
@@ -39,6 +40,7 @@ import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.ramAvailable
import org.koitharu.kotatsu.core.util.ext.withProgress
import org.koitharu.kotatsu.core.util.progress.ProgressDeferred
import org.koitharu.kotatsu.core.zip.ZipPool
import org.koitharu.kotatsu.local.data.CbzFilter
import org.koitharu.kotatsu.local.data.PagesCache
import org.koitharu.kotatsu.parsers.model.MangaPage
@@ -47,7 +49,6 @@ import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
import java.io.File
import java.util.LinkedList
import java.util.concurrent.atomic.AtomicInteger
import java.util.zip.ZipFile
import javax.inject.Inject
import kotlin.coroutines.AbstractCoroutineContextElement
import kotlin.coroutines.CoroutineContext
@@ -70,10 +71,12 @@ class PageLoader @Inject constructor(
val loaderScope = RetainedLifecycleCoroutineScope(lifecycle) + InternalErrorHandler() + Dispatchers.Default
private val tasks = LongSparseArray<ProgressDeferred<File, Float>>()
private val semaphore = Semaphore(3)
private val convertLock = Mutex()
private val prefetchLock = Mutex()
private var repository: MangaRepository? = null
private val prefetchQueue = LinkedList<MangaPage>()
private val zipPool = ZipPool(2)
private val counter = AtomicInteger(0)
private var prefetchQueueLimit = PREFETCH_LIMIT_DEFAULT // TODO adaptive
@@ -81,6 +84,7 @@ class PageLoader @Inject constructor(
synchronized(tasks) {
tasks.clear()
}
zipPool.evictAll()
}
fun isPrefetchApplicable(): Boolean {
@@ -190,20 +194,15 @@ class PageLoader @Inject constructor(
}
}
private suspend fun loadPageImpl(page: MangaPage, progress: MutableStateFlow<Float>): File {
private suspend fun loadPageImpl(page: MangaPage, progress: MutableStateFlow<Float>): File = semaphore.withPermit {
val pageUrl = getPageUrl(page)
check(pageUrl.isNotBlank()) { "Cannot obtain full image url" }
val uri = Uri.parse(pageUrl)
return if (CbzFilter.isUriSupported(uri)) {
runInterruptible(Dispatchers.IO) {
ZipFile(uri.schemeSpecificPart)
}.use { zip ->
runInterruptible(Dispatchers.IO) {
val entry = zip.getEntry(uri.fragment)
zip.getInputStream(entry)
}.use {
cache.put(pageUrl, it.source())
}
zipPool[uri]
}.use {
cache.put(pageUrl, it)
}
} else {
val request = createPageRequest(page, pageUrl)

View File

@@ -44,7 +44,7 @@ class PageSaveHelper @Inject constructor(
val pageFile = pageLoader.loadPage(page, force = false)
val proposedName = getProposedFileName(pageUrl, pageFile)
val destination = withContext(Dispatchers.Main) {
suspendCancellableCoroutine<Uri> { cont ->
suspendCancellableCoroutine { cont ->
continuation = cont
saveLauncher.launch(proposedName)
}.also {

View File

@@ -101,7 +101,7 @@ class ReaderViewModel @Inject constructor(
val manga: DoubleManga?
get() = mangaData.value
val readerAnimation = settings.observeAsStateFlow(
val pageAnimation = settings.observeAsStateFlow(
scope = viewModelScope + Dispatchers.Default,
key = AppSettings.KEY_READER_ANIMATION,
valueProducer = { readerAnimation },

View File

@@ -4,8 +4,10 @@ import android.os.Bundle
import androidx.core.graphics.Insets
import androidx.fragment.app.activityViewModels
import androidx.viewbinding.ViewBinding
import org.koitharu.kotatsu.core.prefs.ReaderAnimation
import org.koitharu.kotatsu.core.ui.BaseFragment
import org.koitharu.kotatsu.core.util.ext.getParcelableCompat
import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.reader.ui.ReaderState
import org.koitharu.kotatsu.reader.ui.ReaderViewModel
@@ -54,6 +56,10 @@ abstract class BaseReaderFragment<B : ViewBinding> : BaseFragment<B>() {
"Adapter was not created or already destroyed"
}
protected fun isAnimationEnabled(): Boolean {
return context?.isAnimationsEnabled == true && viewModel.pageAnimation.value != ReaderAnimation.NONE
}
override fun onWindowInsetsChanged(insets: Insets) = Unit
abstract fun switchPageBy(delta: Int)

View File

@@ -11,8 +11,8 @@ import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.yield
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.core.prefs.ReaderAnimation
import org.koitharu.kotatsu.core.util.ext.doOnPageChanged
import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.recyclerView
import org.koitharu.kotatsu.core.util.ext.resetTransformations
@@ -22,6 +22,7 @@ import org.koitharu.kotatsu.reader.ui.ReaderState
import org.koitharu.kotatsu.reader.ui.pager.BaseReaderAdapter
import org.koitharu.kotatsu.reader.ui.pager.BaseReaderFragment
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
import org.koitharu.kotatsu.reader.ui.pager.standard.NoAnimPageTransformer
import org.koitharu.kotatsu.reader.ui.pager.standard.PagerReaderFragment
import javax.inject.Inject
import kotlin.math.absoluteValue
@@ -48,8 +49,12 @@ class ReversedReaderFragment : BaseReaderFragment<FragmentReaderStandardBinding>
doOnPageChanged(::notifyPageChanged)
}
viewModel.readerAnimation.observe(viewLifecycleOwner) {
val transformer = if (it) ReversedPageAnimTransformer() else null
viewModel.pageAnimation.observe(viewLifecycleOwner) {
val transformer = when (it) {
ReaderAnimation.NONE -> NoAnimPageTransformer()
ReaderAnimation.DEFAULT -> null
ReaderAnimation.ADVANCED -> ReversedPageAnimTransformer()
}
binding.pager.setPageTransformer(transformer)
if (transformer == null) {
binding.pager.recyclerView?.children?.forEach { v ->
@@ -74,7 +79,7 @@ class ReversedReaderFragment : BaseReaderFragment<FragmentReaderStandardBinding>
override fun switchPageBy(delta: Int) {
with(requireViewBinding().pager) {
setCurrentItem(currentItem - delta, context.isAnimationsEnabled)
setCurrentItem(currentItem - delta, isAnimationEnabled())
}
}
@@ -82,7 +87,7 @@ class ReversedReaderFragment : BaseReaderFragment<FragmentReaderStandardBinding>
with(requireViewBinding().pager) {
setCurrentItem(
reversed(position),
smooth && context.isAnimationsEnabled && (currentItem - position).absoluteValue < PagerReaderFragment.SMOOTH_SCROLL_LIMIT,
smooth && isAnimationEnabled() && (currentItem - position).absoluteValue < PagerReaderFragment.SMOOTH_SCROLL_LIMIT,
)
}
}

View File

@@ -0,0 +1,15 @@
package org.koitharu.kotatsu.reader.ui.pager.standard
import android.view.View
import androidx.viewpager2.widget.ViewPager2
class NoAnimPageTransformer : ViewPager2.PageTransformer {
override fun transformPage(page: View, position: Float) {
page.translationX = when {
position in -0.5f..0.5f -> -position * page.width.toFloat()
position > 0 -> page.width.toFloat()
else -> -page.width.toFloat()
}
}
}

View File

@@ -11,8 +11,8 @@ import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.yield
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.core.prefs.ReaderAnimation
import org.koitharu.kotatsu.core.util.ext.doOnPageChanged
import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.recyclerView
import org.koitharu.kotatsu.core.util.ext.resetTransformations
@@ -47,8 +47,12 @@ class PagerReaderFragment : BaseReaderFragment<FragmentReaderStandardBinding>()
doOnPageChanged(::notifyPageChanged)
}
viewModel.readerAnimation.observe(viewLifecycleOwner) {
val transformer = if (it) PageAnimTransformer() else null
viewModel.pageAnimation.observe(viewLifecycleOwner) {
val transformer = when (it) {
ReaderAnimation.NONE -> NoAnimPageTransformer()
ReaderAnimation.DEFAULT -> null
ReaderAnimation.ADVANCED -> PageAnimTransformer()
}
binding.pager.setPageTransformer(transformer)
if (transformer == null) {
binding.pager.recyclerView?.children?.forEach { view ->
@@ -95,7 +99,7 @@ class PagerReaderFragment : BaseReaderFragment<FragmentReaderStandardBinding>()
override fun switchPageBy(delta: Int) {
with(requireViewBinding().pager) {
setCurrentItem(currentItem + delta, context.isAnimationsEnabled)
setCurrentItem(currentItem + delta, isAnimationEnabled())
}
}
@@ -103,7 +107,7 @@ class PagerReaderFragment : BaseReaderFragment<FragmentReaderStandardBinding>()
with(requireViewBinding().pager) {
setCurrentItem(
position,
smooth && context.isAnimationsEnabled && (currentItem - position).absoluteValue < SMOOTH_SCROLL_LIMIT,
smooth && isAnimationEnabled() && (currentItem - position).absoluteValue < SMOOTH_SCROLL_LIMIT,
)
}
}

View File

@@ -13,7 +13,6 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.core.util.ext.findCenterViewPosition
import org.koitharu.kotatsu.core.util.ext.firstVisibleItemPosition
import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.databinding.FragmentReaderWebtoonBinding
import org.koitharu.kotatsu.reader.domain.PageLoader
@@ -111,7 +110,7 @@ class WebtoonReaderFragment : BaseReaderFragment<FragmentReaderWebtoonBinding>()
override fun switchPageBy(delta: Int) {
with(requireViewBinding().recyclerView) {
if (context.isAnimationsEnabled) {
if (isAnimationEnabled()) {
smoothScrollBy(0, (height * 0.9).toInt() * delta, scrollInterpolator)
} else {
nestedScrollBy(0, (height * 0.9).toInt() * delta)

View File

@@ -57,17 +57,19 @@ class MangaPageFetcher(
private suspend fun loadPage(pageUrl: String): SourceResult {
val uri = pageUrl.toUri()
return if (CbzFilter.isUriSupported(uri)) {
val zip = runInterruptible(Dispatchers.IO) { ZipFile(uri.schemeSpecificPart) }
val entry = runInterruptible(Dispatchers.IO) { zip.getEntry(uri.fragment) }
return SourceResult(
source = ImageSource(
source = zip.getInputStream(entry).source().withExtraCloseable(zip).buffer(),
context = context,
metadata = MangaPageMetadata(page),
),
mimeType = null,
dataSource = DataSource.DISK,
)
runInterruptible(Dispatchers.IO) {
val zip = ZipFile(uri.schemeSpecificPart)
val entry = zip.getEntry(uri.fragment)
SourceResult(
source = ImageSource(
source = zip.getInputStream(entry).source().withExtraCloseable(zip).buffer(),
context = context,
metadata = MangaPageMetadata(page),
),
mimeType = null,
dataSource = DataSource.DISK,
)
}
} else {
val request = PageLoader.createPageRequest(page, pageUrl)
imageProxyInterceptor.interceptPageRequest(request, okHttpClient).use { response ->

View File

@@ -102,7 +102,7 @@ class PagesThumbnailsSheet :
} else {
val state = ReaderState(item.page.chapterId, item.page.index, 0)
val intent = IntentBuilder(view.context).manga(viewModel.manga).state(state).build()
startActivity(intent, scaleUpActivityOptionsOf(view))
startActivity(intent)
}
dismiss()
}

View File

@@ -13,6 +13,7 @@ import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.plus
@@ -66,7 +67,7 @@ open class RemoteListViewModel @Inject constructor(
private var randomJob: Job? = null
override val content = combine(
mangaList,
mangaList.map { it?.skipNsfwIfNeeded() },
listMode,
listError,
hasNextPage,

View File

@@ -84,7 +84,7 @@ class ScrobblerConfigViewModel @Inject constructor(
)
}
val grouped = list.groupBy { it.status }
val statuses = enumValues<ScrobblingStatus>()
val statuses = ScrobblingStatus.entries
val result = ArrayList<ListModel>(list.size + statuses.size)
for (st in statuses) {
val subList = grouped[st]
@@ -102,7 +102,7 @@ class ScrobblerConfigViewModel @Inject constructor(
): ScrobblerService {
val serviceId = savedStateHandle.get<Int>(ScrobblerConfigActivity.EXTRA_SERVICE_ID) ?: 0
if (serviceId != 0) {
return enumValues<ScrobblerService>().first { it.id == serviceId }
return ScrobblerService.entries.first { it.id == serviceId }
}
val uri = savedStateHandle.require<Uri>(BaseActivity.EXTRA_DATA)
return when (uri.host) {

View File

@@ -1,6 +1,5 @@
package org.koitharu.kotatsu.search.domain
import android.annotation.SuppressLint
import android.app.SearchManager
import android.content.Context
import android.provider.SearchRecentSuggestions
@@ -8,19 +7,18 @@ import dagger.Reusable
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.isActive
import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.entity.toManga
import org.koitharu.kotatsu.core.db.entity.toMangaTag
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
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.model.MangaTag
import org.koitharu.kotatsu.parsers.util.levenshteinDistance
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.search.ui.MangaSuggestionsProvider
import javax.inject.Inject
@@ -30,34 +28,22 @@ class MangaSearchRepository @Inject constructor(
private val sourcesRepository: MangaSourcesRepository,
@ApplicationContext private val context: Context,
private val recentSuggestions: SearchRecentSuggestions,
private val mangaRepositoryFactory: MangaRepository.Factory,
private val settings: AppSettings,
) {
fun globalSearch(query: String, concurrency: Int = DEFAULT_CONCURRENCY): Flow<Manga> =
flow {
emitAll(sourcesRepository.getEnabledSources().asFlow())
}.flatMapMerge(concurrency) { source ->
runCatchingCancellable {
mangaRepositoryFactory.create(source).getList(
offset = 0,
query = query,
)
}.getOrElse {
emptyList()
}.asFlow()
}.filter {
match(it, query)
}
suspend fun getMangaSuggestion(query: String, limit: Int, source: MangaSource?): List<Manga> {
if (query.isEmpty()) {
return emptyList()
}
val skipNsfw = settings.isNsfwContentDisabled
return if (source != null) {
db.mangaDao.searchByTitle("%$query%", source.name, limit)
} else {
db.mangaDao.searchByTitle("%$query%", limit)
}.map { it.toManga() }
}.let {
if (skipNsfw) it.filterNot { x -> x.manga.isNsfw } else it
}
.map { it.toManga() }
.sortedBy { x -> x.title.levenshteinDistance(query) }
}
@@ -67,7 +53,7 @@ class MangaSearchRepository @Inject constructor(
): List<String> = withContext(Dispatchers.IO) {
context.contentResolver.query(
MangaSuggestionsProvider.QUERY_URI,
SUGGESTION_PROJECTION,
arrayOf(SearchManager.SUGGEST_COLUMN_QUERY),
"${SearchManager.SUGGEST_COLUMN_QUERY} LIKE ?",
arrayOf("%$query%"),
"date DESC",
@@ -102,8 +88,11 @@ class MangaSearchRepository @Inject constructor(
if (query.length < 3) {
return emptyList()
}
val skipNsfw = settings.isNsfwContentDisabled
val sources = sourcesRepository.allMangaSources
.filter { x -> x.title.contains(query, ignoreCase = true) }
.filter { x ->
(x.contentType != ContentType.HENTAI || !skipNsfw) && x.title.contains(query, ignoreCase = true)
}
return if (limit == 0) {
sources
} else {
@@ -130,33 +119,10 @@ class MangaSearchRepository @Inject constructor(
suspend fun getSearchHistoryCount(): Int = withContext(Dispatchers.IO) {
context.contentResolver.query(
MangaSuggestionsProvider.QUERY_URI,
SUGGESTION_PROJECTION,
arrayOf(SearchManager.SUGGEST_COLUMN_QUERY),
null,
arrayOfNulls(1),
null,
)?.use { cursor -> cursor.count } ?: 0
}
private companion object {
private val REGEX_SPACE = Regex("\\s+")
val SUGGESTION_PROJECTION = arrayOf(SearchManager.SUGGEST_COLUMN_QUERY)
@SuppressLint("DefaultLocale")
fun match(manga: Manga, query: String): Boolean {
val words = HashSet<String>()
words += manga.title.lowercase().split(REGEX_SPACE)
words += manga.altTitle?.lowercase()?.split(REGEX_SPACE).orEmpty()
val words2 = query.lowercase().split(REGEX_SPACE).toSet()
for (w in words) {
for (w2 in words2) {
val diff = w.levenshteinDistance(w2) / ((w.length + w2.length) / 2f)
if (diff < 0.5) {
return true
}
}
}
return false
}
}
}

View File

@@ -27,7 +27,7 @@ import org.koitharu.kotatsu.core.util.ext.scaleUpActivityOptionsOf
import org.koitharu.kotatsu.databinding.ActivitySearchMultiBinding
import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.download.ui.worker.DownloadStartedObserver
import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteCategoriesSheet
import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteSheet
import org.koitharu.kotatsu.list.ui.MangaSelectionDecoration
import org.koitharu.kotatsu.list.ui.adapter.MangaListListener
import org.koitharu.kotatsu.list.ui.model.ListHeader
@@ -60,11 +60,12 @@ class MultiSearchActivity :
super.onCreate(savedInstanceState)
setContentView(ActivitySearchMultiBinding.inflate(layoutInflater))
window.statusBarColor = ContextCompat.getColor(this, R.color.dim_statusbar)
title = viewModel.query
val itemCLickListener = OnListItemClickListener<MultiSearchListModel> { item, view ->
startActivity(SearchActivity.newIntent(view.context, item.source, viewModel.query.value))
startActivity(SearchActivity.newIntent(view.context, item.source, viewModel.query))
}
val sizeResolver = DynamicItemSizeResolver(resources, settings)
val sizeResolver = DynamicItemSizeResolver(resources, settings, adjustWidth = true)
val selectionDecoration = MangaSelectionDecoration(this)
selectionController = ListSelectionController(
activity = this,
@@ -88,7 +89,6 @@ class MultiSearchActivity :
setSubtitle(R.string.search_results)
}
viewModel.query.observe(this) { title = it }
viewModel.list.observe(this) { adapter.items = it }
viewModel.onError.observeEvent(this, SnackbarErrorObserver(viewBinding.recyclerView, null))
viewModel.onDownloadStarted.observeEvent(this, DownloadStartedObserver(viewBinding.recyclerView))
@@ -118,7 +118,7 @@ class MultiSearchActivity :
override fun onReadClick(manga: Manga, view: View) {
if (!selectionController.onItemClick(manga.id)) {
val intent = IntentBuilder(this).manga(manga).build()
startActivity(intent, scaleUpActivityOptionsOf(view))
startActivity(intent)
}
}
@@ -130,7 +130,7 @@ class MultiSearchActivity :
}
override fun onRetryClick(error: Throwable) {
viewModel.doSearch(viewModel.query.value)
viewModel.retry()
}
override fun onUpdateFilter(tags: Set<MangaTag>) = Unit
@@ -159,7 +159,7 @@ class MultiSearchActivity :
}
R.id.action_favourite -> {
FavouriteCategoriesSheet.show(supportFragmentManager, collectSelectedItems())
FavouriteSheet.show(supportFragmentManager, collectSelectedItems())
mode.finish()
true
}

View File

@@ -1,22 +1,24 @@
package org.koitharu.kotatsu.search.ui.multi
import androidx.annotation.CheckResult
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.runningFold
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.plus
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.prefs.ListMode
@@ -48,15 +50,17 @@ class MultiSearchViewModel @Inject constructor(
private val sourcesRepository: MangaSourcesRepository,
) : BaseViewModel() {
private var searchJob: Job? = null
private val listData = MutableStateFlow<List<MultiSearchListModel>>(emptyList())
private val loadingData = MutableStateFlow(false)
val onDownloadStarted = MutableEventFlow<Unit>()
val query = savedStateHandle.get<String>(MultiSearchActivity.EXTRA_QUERY).orEmpty()
private val retryCounter = MutableStateFlow(0)
private val listData = retryCounter.flatMapLatest {
searchImpl(query).withLoading().withErrorHandling()
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)
val query = MutableStateFlow(savedStateHandle.get<String>(MultiSearchActivity.EXTRA_QUERY).orEmpty())
val list: StateFlow<List<ListModel>> = combine(
listData,
loadingData,
listData.filterNotNull(),
isLoading,
) { list, loading ->
when {
list.isEmpty() -> listOf(
@@ -76,13 +80,10 @@ class MultiSearchViewModel @Inject constructor(
}
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState))
init {
doSearch(query.value)
}
fun getItems(ids: Set<Long>): Set<Manga> {
val snapshot = listData.value ?: return emptySet()
val result = HashSet<Manga>(ids.size)
listData.value.forEach { x ->
snapshot.forEach { x ->
for (item in x.list) {
if (item.id in ids) {
result.add(item.manga)
@@ -92,21 +93,8 @@ class MultiSearchViewModel @Inject constructor(
return result
}
fun doSearch(q: String) {
val prevJob = searchJob
searchJob = launchJob(Dispatchers.Default) {
prevJob?.cancelAndJoin()
try {
listData.value = emptyList()
loadingData.value = true
query.value = q
searchImpl(q)
} catch (e: CancellationException) {
throw e
} finally {
loadingData.value = false
}
}
fun retry() {
retryCounter.value = retryCounter.value + 1
}
fun download(items: Set<Manga>) {
@@ -116,13 +104,14 @@ class MultiSearchViewModel @Inject constructor(
}
}
private suspend fun searchImpl(q: String) = coroutineScope {
@CheckResult
private fun searchImpl(q: String): Flow<List<MultiSearchListModel>> = channelFlow {
val sources = sourcesRepository.getEnabledSources()
val dispatcher = Dispatchers.Default.limitedParallelism(MAX_PARALLELISM)
val deferredList = sources.map { source ->
async(dispatcher) {
runCatchingCancellable {
withTimeout(8_000) {
val semaphore = Semaphore(MAX_PARALLELISM)
for (source in sources) {
launch {
val item = runCatchingCancellable {
semaphore.withPermit {
mangaRepositoryFactory.create(source).getList(offset = 0, query = q)
.toUi(ListMode.GRID, extraProvider)
}
@@ -139,12 +128,11 @@ class MultiSearchViewModel @Inject constructor(
MultiSearchListModel(source, true, emptyList(), error)
},
)
if (item != null) {
send(item)
}
}
}
for (deferred in deferredList) {
deferred.await()?.let { item ->
listData.update { x -> x + item }
}
}
}
}.runningFold<MultiSearchListModel, List<MultiSearchListModel>?>(null) { list, item -> list.orEmpty() + item }
.filterNotNull()
}

View File

@@ -47,8 +47,8 @@ fun searchResultsAD(
bind {
binding.textViewTitle.text = item.source.title
binding.buttonMore.isVisible = item.hasMore
adapter.notifyDataSetChanged()
adapter.items = item.list
adapter.notifyDataSetChanged()
binding.recyclerView.isGone = item.list.isEmpty()
binding.textViewError.textAndVisible = item.error?.getDisplayMessage(context.resources)
}

View File

@@ -49,7 +49,7 @@ class AppearanceSettingsFragment :
}
}
findPreference<ListPreference>(AppSettings.KEY_LIST_MODE)?.run {
entryValues = ListMode.values().names()
entryValues = ListMode.entries.names()
setDefaultValueCompat(ListMode.GRID.name)
}
findPreference<ActivityListPreference>(AppSettings.KEY_APP_LOCALE)?.run {
@@ -102,7 +102,7 @@ class AppearanceSettingsFragment :
}
private fun initLocalePicker(preference: ListPreference) {
val locales = resources.getLocalesConfig()
val locales = preference.context.getLocalesConfig()
.toList()
.sortedWith(LocaleComparator(preference.context))
preference.entries = Array(locales.size + 1) { i ->

View File

@@ -29,12 +29,7 @@ class NetworkSettingsFragment :
addPreferencesFromResource(R.xml.pref_network)
findPreference<Preference>(AppSettings.KEY_PREFETCH_CONTENT)?.isVisible = contentCache.isCachingEnabled
findPreference<ListPreference>(AppSettings.KEY_DOH)?.run {
entryValues = arrayOf(
DoHProvider.NONE,
DoHProvider.GOOGLE,
DoHProvider.CLOUDFLARE,
DoHProvider.ADGUARD,
).names()
entryValues = DoHProvider.entries.names()
setDefaultValueCompat(DoHProvider.NONE.name)
}
bindProxySummary()

View File

@@ -10,6 +10,7 @@ import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.ZoomMode
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.ReaderAnimation
import org.koitharu.kotatsu.core.prefs.ReaderBackground
import org.koitharu.kotatsu.core.prefs.ReaderMode
import org.koitharu.kotatsu.core.ui.BasePreferenceFragment
@@ -25,18 +26,22 @@ class ReaderSettingsFragment :
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
addPreferencesFromResource(R.xml.pref_reader)
findPreference<ListPreference>(AppSettings.KEY_READER_MODE)?.run {
entryValues = ReaderMode.values().names()
entryValues = ReaderMode.entries.names()
setDefaultValueCompat(ReaderMode.STANDARD.name)
}
findPreference<ListPreference>(AppSettings.KEY_READER_BACKGROUND)?.run {
entryValues = ReaderBackground.values().names()
entryValues = ReaderBackground.entries.names()
setDefaultValueCompat(ReaderBackground.DEFAULT.name)
}
findPreference<ListPreference>(AppSettings.KEY_READER_ANIMATION)?.run {
entryValues = ReaderAnimation.entries.names()
setDefaultValueCompat(ReaderAnimation.DEFAULT.name)
}
findPreference<MultiSelectListPreference>(AppSettings.KEY_READER_SWITCHERS)?.run {
summaryProvider = MultiSummaryProvider(R.string.gestures_only)
}
findPreference<ListPreference>(AppSettings.KEY_ZOOM_MODE)?.run {
entryValues = ZoomMode.values().names()
entryValues = ZoomMode.entries.names()
setDefaultValueCompat(ZoomMode.FIT_CENTER.name)
}
updateReaderModeDependency()

View File

@@ -9,8 +9,10 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.plus
import org.koitharu.kotatsu.core.model.getLocaleTitle
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
import org.koitharu.kotatsu.parsers.model.ContentType
import org.koitharu.kotatsu.parsers.util.SuspendLazy
import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem
import javax.inject.Inject
@@ -18,6 +20,7 @@ import javax.inject.Inject
@HiltViewModel
class NewSourcesViewModel @Inject constructor(
private val repository: MangaSourcesRepository,
private val settings: AppSettings,
) : BaseViewModel() {
private val newSources = SuspendLazy {
@@ -26,9 +29,16 @@ class NewSourcesViewModel @Inject constructor(
val content: StateFlow<List<SourceConfigItem>> = repository.observeAll()
.map { sources ->
val new = newSources.get()
val skipNsfw = settings.isNsfwContentDisabled
sources.mapNotNull { (source, enabled) ->
if (source in new) {
SourceConfigItem.SourceItem(source, enabled, source.getLocaleTitle(), false)
SourceConfigItem.SourceItem(
source = source,
isEnabled = enabled,
summary = source.getLocaleTitle(),
isDraggable = false,
isAvailable = !skipNsfw || source.contentType != ContentType.HENTAI,
)
} else {
null
}

View File

@@ -16,6 +16,7 @@ import androidx.recyclerview.widget.RecyclerView
import coil.ImageLoader
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.BaseFragment
import org.koitharu.kotatsu.core.ui.util.RecyclerViewOwner
import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver
@@ -41,6 +42,9 @@ class SourcesManageFragment :
@Inject
lateinit var coil: ImageLoader
@Inject
lateinit var settings: AppSettings
private var reorderHelper: ItemTouchHelper? = null
private val viewModel by viewModels<SourcesManageViewModel>()
@@ -128,9 +132,19 @@ class SourcesManageFragment :
true
}
R.id.action_no_nsfw -> {
settings.isNsfwContentDisabled = !menuItem.isChecked
true
}
else -> false
}
override fun onPrepareMenu(menu: Menu) {
super.onPrepareMenu(menu)
menu.findItem(R.id.action_no_nsfw).isChecked = settings.isNsfwContentDisabled
}
override fun onMenuItemActionExpand(item: MenuItem): Boolean {
(activity as? AppBarOwner)?.appBar?.setExpanded(false, true)
return true

View File

@@ -27,6 +27,7 @@ import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.core.util.ext.map
import org.koitharu.kotatsu.core.util.ext.toEnumSet
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
import org.koitharu.kotatsu.parsers.model.ContentType
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.toTitleCase
import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem
@@ -54,8 +55,9 @@ class SourcesManageViewModel @Inject constructor(
expandedGroups,
searchQuery,
observeTip(),
) { sources, groups, query, tip ->
buildList(sources, groups, query, tip)
settings.observeAsFlow(AppSettings.KEY_DISABLE_NSFW) { isNsfwContentDisabled },
) { sources, groups, query, tip, noNsfw ->
buildList(sources, groups, query, tip, noNsfw)
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList())
val onActionDone = MutableEventFlow<ReversibleAction>()
@@ -125,6 +127,7 @@ class SourcesManageViewModel @Inject constructor(
expanded: Set<String?>,
query: String?,
withTip: Boolean,
isNsfwDisabled: Boolean,
): List<SourceConfigItem> {
val allSources = repository.allMangaSources
val enabledSet = enabledSources.toEnumSet()
@@ -138,6 +141,7 @@ class SourcesManageViewModel @Inject constructor(
summary = it.getLocaleTitle(),
isEnabled = it in enabledSet,
isDraggable = false,
isAvailable = !isNsfwDisabled || !it.isNsfw(),
)
}.ifEmpty {
listOf(SourceConfigItem.EmptySearchResult)
@@ -163,6 +167,7 @@ class SourcesManageViewModel @Inject constructor(
summary = it.getLocaleTitle(),
isEnabled = true,
isDraggable = true,
isAvailable = false,
)
}
}
@@ -184,6 +189,7 @@ class SourcesManageViewModel @Inject constructor(
summary = null,
isEnabled = false,
isDraggable = false,
isAvailable = !isNsfwDisabled || !it.isNsfw(),
)
}
}
@@ -210,6 +216,8 @@ class SourcesManageViewModel @Inject constructor(
isTipEnabled(TIP_REORDER)
}
private fun MangaSource.isNsfw() = contentType == ContentType.HENTAI
private class LocaleKeyComparator : Comparator<String?> {
private val deviceLocales = LocaleListCompat.getAdjustedDefault()

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