Compare commits
123 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2f8d8e022 | ||
|
|
38b342b721 | ||
|
|
b036a8ed94 | ||
|
|
9bb76cc0b2 | ||
|
|
855b55da9d | ||
|
|
4855b2c160 | ||
|
|
89d395178c | ||
|
|
9942ad5e56 | ||
|
|
d59b0626bc | ||
|
|
63054e55d6 | ||
|
|
486daf69bf | ||
|
|
af209d7048 | ||
|
|
d739e30c84 | ||
|
|
32eb273fa9 | ||
|
|
8c5231bb3d | ||
|
|
be4fb3e873 | ||
|
|
d28eff7a75 | ||
|
|
e515069b53 | ||
|
|
05d22167c4 | ||
|
|
e5c765dd2f | ||
|
|
9ea1122ca0 | ||
|
|
4faef85086 | ||
|
|
b46c00f2d0 | ||
|
|
9358617a3a | ||
|
|
ba9f31835f | ||
|
|
357308bfbb | ||
|
|
cab56209c1 | ||
|
|
e9cd32c870 | ||
|
|
357517ceac | ||
|
|
a57fcce72b | ||
|
|
2e2a818c05 | ||
|
|
b6f618101f | ||
|
|
0ce368751a | ||
|
|
1d28538893 | ||
|
|
4ad2f3f608 | ||
|
|
5301cc7f97 | ||
|
|
1290db4a7c | ||
|
|
1f1309d934 | ||
|
|
350f1521a6 | ||
|
|
cebce20bed | ||
|
|
e5b6947586 | ||
|
|
ac96c49b60 | ||
|
|
a4345a40bf | ||
|
|
f518acb8ee | ||
|
|
b39a51d497 | ||
|
|
8819d8b1ee | ||
|
|
05a502b89a | ||
|
|
c320e3c26a | ||
|
|
938849c31e | ||
|
|
95c243daa1 | ||
|
|
6ce6a02b56 | ||
|
|
e92e9fb393 | ||
|
|
f4186a2787 | ||
|
|
8b93b699d3 | ||
|
|
7e13482ba5 | ||
|
|
04700a22c8 | ||
|
|
549d08cc06 | ||
|
|
0fccaf3fbc | ||
|
|
c7e0a47bee | ||
|
|
d527b6e390 | ||
|
|
12b2af6b93 | ||
|
|
63f4fab40f | ||
|
|
9a444cf965 | ||
|
|
b8be2f7158 | ||
|
|
9e2074040f | ||
|
|
020c151e31 | ||
|
|
52eb33a992 | ||
|
|
907b8fd0ec | ||
|
|
e35b2088a1 | ||
|
|
fbb4efb3df | ||
|
|
7ff47a322e | ||
|
|
fda1af5500 | ||
|
|
d88847d137 | ||
|
|
063527b240 | ||
|
|
b0470110a8 | ||
|
|
5a2a31d1c8 | ||
|
|
3b009d7c55 | ||
|
|
f7e937f2b8 | ||
|
|
16e23cc1cf | ||
|
|
d12528d80f | ||
|
|
9f04c7b148 | ||
|
|
7a3942f100 | ||
|
|
8e46f64f2a | ||
|
|
44c50fca2d | ||
|
|
55b4d14a93 | ||
|
|
743693299f | ||
|
|
7950a685a6 | ||
|
|
97cfcb5c01 | ||
|
|
b2dfcefee8 | ||
|
|
ee1ade40c3 | ||
|
|
3690e15cff | ||
|
|
a955dfbe50 | ||
|
|
5e9daa1206 | ||
|
|
a3c2956a4d | ||
|
|
10ecd92715 | ||
|
|
37d2d986ef | ||
|
|
0aadd6ebe2 | ||
|
|
c23ec9a4b8 | ||
|
|
22a37923f9 | ||
|
|
3fc506b438 | ||
|
|
e98dbd5069 | ||
|
|
2a469b27c5 | ||
|
|
0f3ef4559f | ||
|
|
a87ef0a0a6 | ||
|
|
a7a0a7f0db | ||
|
|
bc4622d610 | ||
|
|
8365603bf1 | ||
|
|
b1eabdba79 | ||
|
|
169e31e9ba | ||
|
|
66644d55a4 | ||
|
|
98314960cf | ||
|
|
b73e44874d | ||
|
|
6f45a44070 | ||
|
|
d9d11d685e | ||
|
|
5359267b5a | ||
|
|
a6662ab501 | ||
|
|
699a619c27 | ||
|
|
85ccbbf719 | ||
|
|
a396b33f3d | ||
|
|
6076f775c3 | ||
|
|
379fa88b4e | ||
|
|
9b24c507c5 | ||
|
|
98bd79f0be |
17
README.md
17
README.md
@@ -1,8 +1,8 @@
|
|||||||
# Kotatsu
|
# Kotatsu
|
||||||
|
|
||||||
Kotatsu is a free and open source manga reader for Android.
|
Kotatsu is a free and open-source manga reader for Android with built-in online content sources.
|
||||||
|
|
||||||
   [](https://hosted.weblate.org/engage/kotatsu/) [](https://t.me/kotatsuapp) [](https://discord.gg/NNJ5RgVBC5)
|
[](https://github.com/KotatsuApp/kotatsu-parsers)  [](https://hosted.weblate.org/engage/kotatsu/) [](https://t.me/kotatsuapp) [](https://discord.gg/NNJ5RgVBC5) [](https://github.com/KotatsuApp/Kotatsu/blob/devel/LICENSE)
|
||||||
|
|
||||||
### Download
|
### Download
|
||||||
|
|
||||||
@@ -12,16 +12,15 @@ Kotatsu is a free and open source manga reader for Android.
|
|||||||
### Main Features
|
### Main Features
|
||||||
|
|
||||||
* Online [manga catalogues](https://github.com/KotatsuApp/kotatsu-parsers)
|
* Online [manga catalogues](https://github.com/KotatsuApp/kotatsu-parsers)
|
||||||
* Search manga by name and genres
|
* Search manga by name, genres, and more filters
|
||||||
* Reading history and bookmarks
|
* Reading history and bookmarks
|
||||||
* Favourites organized by user-defined categories
|
* Favorites organized by user-defined categories
|
||||||
* Downloading manga and reading it offline. Third-party CBZ archives also supported
|
* Downloading manga and reading it offline. Third-party CBZ archives also supported
|
||||||
* Tablet-optimized Material You UI
|
* Tablet-optimized Material You UI
|
||||||
* Standard and Webtoon-optimized reader
|
* Standard and Webtoon-optimized customizable reader
|
||||||
* Notifications about new chapters with updates feed
|
* Notifications about new chapters with updates feed
|
||||||
* Integration with manga tracking services: Shikimori, AniList, MyAnimeList, Kitsu
|
* Integration with manga tracking services: Shikimori, AniList, MyAnimeList, Kitsu
|
||||||
* Password/fingerprint protect access to the app
|
* Password/fingerprint-protected access to the app
|
||||||
* History and favourites [synchronization](https://github.com/KotatsuApp/kotatsu-syncserver) across devices
|
|
||||||
|
|
||||||
### Screenshots
|
### Screenshots
|
||||||
|
|
||||||
@@ -53,5 +52,5 @@ install instructions.
|
|||||||
|
|
||||||
### DMCA disclaimer
|
### DMCA disclaimer
|
||||||
|
|
||||||
The developers of this application does not have any affiliation with the content available in the app.
|
The developers of this application do not have any affiliation with the content available in the app.
|
||||||
It is collecting from the sources freely available through any web browser.
|
It collects content from sources that are freely available through any web browser
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ android {
|
|||||||
applicationId 'org.koitharu.kotatsu'
|
applicationId 'org.koitharu.kotatsu'
|
||||||
minSdk = 21
|
minSdk = 21
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 668
|
versionCode = 680
|
||||||
versionName = '7.5.2'
|
versionName = '7.6.7'
|
||||||
generatedDensities = []
|
generatedDensities = []
|
||||||
testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner'
|
testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner'
|
||||||
ksp {
|
ksp {
|
||||||
@@ -48,11 +48,11 @@ android {
|
|||||||
}
|
}
|
||||||
compileOptions {
|
compileOptions {
|
||||||
coreLibraryDesugaringEnabled true
|
coreLibraryDesugaringEnabled true
|
||||||
sourceCompatibility JavaVersion.VERSION_1_8
|
sourceCompatibility JavaVersion.VERSION_11
|
||||||
targetCompatibility JavaVersion.VERSION_1_8
|
targetCompatibility JavaVersion.VERSION_11
|
||||||
}
|
}
|
||||||
kotlinOptions {
|
kotlinOptions {
|
||||||
jvmTarget = JavaVersion.VERSION_1_8.toString()
|
jvmTarget = JavaVersion.VERSION_11.toString()
|
||||||
freeCompilerArgs += [
|
freeCompilerArgs += [
|
||||||
'-opt-in=kotlin.ExperimentalStdlibApi',
|
'-opt-in=kotlin.ExperimentalStdlibApi',
|
||||||
'-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi',
|
'-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi',
|
||||||
@@ -64,7 +64,7 @@ android {
|
|||||||
}
|
}
|
||||||
lint {
|
lint {
|
||||||
abortOnError true
|
abortOnError true
|
||||||
disable 'MissingTranslation', 'PrivateResource', 'NotifyDataSetChanged', 'SetJavaScriptEnabled'
|
disable 'MissingTranslation', 'PrivateResource', 'SetJavaScriptEnabled'
|
||||||
}
|
}
|
||||||
testOptions {
|
testOptions {
|
||||||
unitTests.includeAndroidResources true
|
unitTests.includeAndroidResources true
|
||||||
@@ -82,24 +82,23 @@ afterEvaluate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
dependencies {
|
dependencies {
|
||||||
//noinspection GradleDependency
|
implementation('com.github.KotatsuApp:kotatsu-parsers:f80b586081') {
|
||||||
implementation('com.github.KotatsuApp:kotatsu-parsers:ad726a3fd7') {
|
|
||||||
exclude group: 'org.json', module: 'json'
|
exclude group: 'org.json', module: 'json'
|
||||||
}
|
}
|
||||||
|
|
||||||
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.2'
|
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.2'
|
||||||
implementation 'org.jetbrains.kotlin:kotlin-stdlib:2.0.10'
|
implementation 'org.jetbrains.kotlin:kotlin-stdlib:2.0.20'
|
||||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0-RC.2'
|
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0'
|
||||||
|
|
||||||
implementation 'androidx.appcompat:appcompat:1.7.0'
|
implementation 'androidx.appcompat:appcompat:1.7.0'
|
||||||
implementation 'androidx.core:core-ktx:1.13.1'
|
implementation 'androidx.core:core-ktx:1.13.1'
|
||||||
implementation 'androidx.activity:activity-ktx:1.9.2'
|
implementation 'androidx.activity:activity-ktx:1.9.3'
|
||||||
implementation 'androidx.fragment:fragment-ktx:1.8.3'
|
implementation 'androidx.fragment:fragment-ktx:1.8.5'
|
||||||
implementation 'androidx.transition:transition-ktx:1.5.1'
|
implementation 'androidx.transition:transition-ktx:1.5.1'
|
||||||
implementation 'androidx.collection:collection-ktx:1.4.3'
|
implementation 'androidx.collection:collection-ktx:1.4.4'
|
||||||
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.5'
|
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.6'
|
||||||
implementation 'androidx.lifecycle:lifecycle-service:2.8.5'
|
implementation 'androidx.lifecycle:lifecycle-service:2.8.6'
|
||||||
implementation 'androidx.lifecycle:lifecycle-process:2.8.5'
|
implementation 'androidx.lifecycle:lifecycle-process:2.8.6'
|
||||||
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
|
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
|
||||||
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
|
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
|
||||||
implementation 'androidx.recyclerview:recyclerview:1.3.2'
|
implementation 'androidx.recyclerview:recyclerview:1.3.2'
|
||||||
@@ -107,7 +106,7 @@ dependencies {
|
|||||||
implementation 'androidx.preference:preference-ktx:1.2.1'
|
implementation 'androidx.preference:preference-ktx:1.2.1'
|
||||||
implementation 'androidx.biometric:biometric-ktx:1.2.0-alpha05'
|
implementation 'androidx.biometric:biometric-ktx:1.2.0-alpha05'
|
||||||
implementation 'com.google.android.material:material:1.12.0'
|
implementation 'com.google.android.material:material:1.12.0'
|
||||||
implementation 'androidx.lifecycle:lifecycle-common-java8:2.8.5'
|
implementation 'androidx.lifecycle:lifecycle-common-java8:2.8.6'
|
||||||
implementation 'androidx.webkit:webkit:1.11.0'
|
implementation 'androidx.webkit:webkit:1.11.0'
|
||||||
|
|
||||||
implementation 'androidx.work:work-runtime:2.9.1'
|
implementation 'androidx.work:work-runtime:2.9.1'
|
||||||
@@ -125,7 +124,7 @@ dependencies {
|
|||||||
implementation 'com.squareup.okhttp3:okhttp:4.12.0'
|
implementation 'com.squareup.okhttp3:okhttp:4.12.0'
|
||||||
implementation 'com.squareup.okhttp3:okhttp-tls:4.12.0'
|
implementation 'com.squareup.okhttp3:okhttp-tls:4.12.0'
|
||||||
implementation 'com.squareup.okhttp3:okhttp-dnsoverhttps:4.12.0'
|
implementation 'com.squareup.okhttp3:okhttp-dnsoverhttps:4.12.0'
|
||||||
implementation 'com.squareup.okio:okio:3.9.0'
|
implementation 'com.squareup.okio:okio:3.9.1'
|
||||||
|
|
||||||
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.2'
|
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.2'
|
||||||
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:4.3.2'
|
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:4.3.2'
|
||||||
@@ -137,12 +136,13 @@ dependencies {
|
|||||||
|
|
||||||
implementation 'io.coil-kt:coil-base:2.7.0'
|
implementation 'io.coil-kt:coil-base:2.7.0'
|
||||||
implementation 'io.coil-kt:coil-svg:2.7.0'
|
implementation 'io.coil-kt:coil-svg:2.7.0'
|
||||||
implementation 'com.github.KotatsuApp:subsampling-scale-image-view:4ec7176962'
|
implementation 'org.aomedia.avif.android:avif:1.1.1.14d8e3c4'
|
||||||
|
implementation 'com.github.KotatsuApp:subsampling-scale-image-view:d1d10a6975'
|
||||||
implementation 'com.github.solkin:disk-lru-cache:1.4'
|
implementation 'com.github.solkin:disk-lru-cache:1.4'
|
||||||
implementation 'io.noties.markwon:core:4.6.2'
|
implementation 'io.noties.markwon:core:4.6.2'
|
||||||
|
|
||||||
implementation 'ch.acra:acra-http:5.11.3'
|
implementation 'ch.acra:acra-http:5.11.4'
|
||||||
implementation 'ch.acra:acra-dialog:5.11.3'
|
implementation 'ch.acra:acra-dialog:5.11.4'
|
||||||
|
|
||||||
implementation 'org.conscrypt:conscrypt-android:2.5.2'
|
implementation 'org.conscrypt:conscrypt-android:2.5.2'
|
||||||
|
|
||||||
@@ -151,14 +151,14 @@ dependencies {
|
|||||||
|
|
||||||
testImplementation 'junit:junit:4.13.2'
|
testImplementation 'junit:junit:4.13.2'
|
||||||
testImplementation 'org.json:json:20240303'
|
testImplementation 'org.json:json:20240303'
|
||||||
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1'
|
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0'
|
||||||
|
|
||||||
androidTestImplementation 'androidx.test:runner:1.6.1'
|
androidTestImplementation 'androidx.test:runner:1.6.1'
|
||||||
androidTestImplementation 'androidx.test:rules:1.6.1'
|
androidTestImplementation 'androidx.test:rules:1.6.1'
|
||||||
androidTestImplementation 'androidx.test:core-ktx:1.6.1'
|
androidTestImplementation 'androidx.test:core-ktx:1.6.1'
|
||||||
androidTestImplementation 'androidx.test.ext:junit-ktx:1.2.1'
|
androidTestImplementation 'androidx.test.ext:junit-ktx:1.2.1'
|
||||||
|
|
||||||
androidTestImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1'
|
androidTestImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0'
|
||||||
|
|
||||||
androidTestImplementation 'androidx.room:room-testing:2.6.1'
|
androidTestImplementation 'androidx.room:room-testing:2.6.1'
|
||||||
androidTestImplementation 'com.squareup.moshi:moshi-kotlin:1.15.1'
|
androidTestImplementation 'com.squareup.moshi:moshi-kotlin:1.15.1'
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package org.koitharu.kotatsu
|
package org.koitharu.kotatsu
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.os.Build
|
||||||
import android.os.StrictMode
|
import android.os.StrictMode
|
||||||
import androidx.fragment.app.strictmode.FragmentStrictMode
|
import androidx.fragment.app.strictmode.FragmentStrictMode
|
||||||
import org.koitharu.kotatsu.core.BaseApp
|
import org.koitharu.kotatsu.core.BaseApp
|
||||||
@@ -18,30 +19,55 @@ class KotatsuApp : BaseApp() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun enableStrictMode() {
|
private fun enableStrictMode() {
|
||||||
|
val notifier = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||||
|
StrictModeNotifier(this)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
StrictMode.setThreadPolicy(
|
StrictMode.setThreadPolicy(
|
||||||
StrictMode.ThreadPolicy.Builder()
|
StrictMode.ThreadPolicy.Builder().apply {
|
||||||
.detectAll()
|
detectNetwork()
|
||||||
.penaltyLog()
|
detectDiskWrites()
|
||||||
.build(),
|
detectCustomSlowCalls()
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) detectUnbufferedIo()
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) detectResourceMismatches()
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) detectExplicitGc()
|
||||||
|
penaltyLog()
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && notifier != null) {
|
||||||
|
penaltyListener(notifier.executor, notifier)
|
||||||
|
}
|
||||||
|
}.build(),
|
||||||
)
|
)
|
||||||
StrictMode.setVmPolicy(
|
StrictMode.setVmPolicy(
|
||||||
StrictMode.VmPolicy.Builder()
|
StrictMode.VmPolicy.Builder().apply {
|
||||||
.detectAll()
|
detectActivityLeaks()
|
||||||
.setClassInstanceLimit(LocalMangaRepository::class.java, 1)
|
detectLeakedSqlLiteObjects()
|
||||||
.setClassInstanceLimit(PagesCache::class.java, 1)
|
detectLeakedClosableObjects()
|
||||||
.setClassInstanceLimit(MangaLoaderContext::class.java, 1)
|
detectLeakedRegistrationObjects()
|
||||||
.setClassInstanceLimit(PageLoader::class.java, 1)
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) detectContentUriWithoutPermission()
|
||||||
.setClassInstanceLimit(ReaderViewModel::class.java, 1)
|
detectFileUriExposure()
|
||||||
.penaltyLog()
|
setClassInstanceLimit(LocalMangaRepository::class.java, 1)
|
||||||
.build(),
|
setClassInstanceLimit(PagesCache::class.java, 1)
|
||||||
|
setClassInstanceLimit(MangaLoaderContext::class.java, 1)
|
||||||
|
setClassInstanceLimit(PageLoader::class.java, 1)
|
||||||
|
setClassInstanceLimit(ReaderViewModel::class.java, 1)
|
||||||
|
penaltyLog()
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && notifier != null) {
|
||||||
|
penaltyListener(notifier.executor, notifier)
|
||||||
|
}
|
||||||
|
}.build()
|
||||||
)
|
)
|
||||||
FragmentStrictMode.defaultPolicy = FragmentStrictMode.Policy.Builder()
|
FragmentStrictMode.defaultPolicy = FragmentStrictMode.Policy.Builder().apply {
|
||||||
.penaltyDeath()
|
detectWrongFragmentContainer()
|
||||||
.detectFragmentReuse()
|
detectFragmentTagUsage()
|
||||||
.detectWrongFragmentContainer()
|
detectRetainInstanceUsage()
|
||||||
.detectRetainInstanceUsage()
|
detectSetUserVisibleHint()
|
||||||
.detectSetUserVisibleHint()
|
detectWrongNestedHierarchy()
|
||||||
.detectFragmentTagUsage()
|
detectFragmentReuse()
|
||||||
.build()
|
penaltyLog()
|
||||||
|
if (notifier != null) {
|
||||||
|
penaltyListener(notifier)
|
||||||
|
}
|
||||||
|
}.build()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package org.koitharu.kotatsu
|
||||||
|
|
||||||
|
import android.app.Notification
|
||||||
|
import android.app.Notification.BigTextStyle
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.StrictMode
|
||||||
|
import android.os.strictmode.Violation
|
||||||
|
import androidx.annotation.RequiresApi
|
||||||
|
import androidx.core.app.PendingIntentCompat
|
||||||
|
import androidx.core.content.getSystemService
|
||||||
|
import androidx.fragment.app.strictmode.FragmentStrictMode
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.asExecutor
|
||||||
|
import org.koitharu.kotatsu.core.util.ShareHelper
|
||||||
|
import kotlin.math.absoluteValue
|
||||||
|
import androidx.fragment.app.strictmode.Violation as FragmentViolation
|
||||||
|
|
||||||
|
@RequiresApi(Build.VERSION_CODES.P)
|
||||||
|
class StrictModeNotifier(
|
||||||
|
private val context: Context,
|
||||||
|
) : StrictMode.OnVmViolationListener, StrictMode.OnThreadViolationListener, FragmentStrictMode.OnViolationListener {
|
||||||
|
|
||||||
|
val executor = Dispatchers.Default.asExecutor()
|
||||||
|
|
||||||
|
private val notificationManager by lazy {
|
||||||
|
val nm = checkNotNull(context.getSystemService<NotificationManager>())
|
||||||
|
val channel = NotificationChannel(
|
||||||
|
CHANNEL_ID,
|
||||||
|
context.getString(R.string.strict_mode),
|
||||||
|
NotificationManager.IMPORTANCE_LOW,
|
||||||
|
)
|
||||||
|
nm.createNotificationChannel(channel)
|
||||||
|
nm
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onVmViolation(v: Violation) = showNotification(v)
|
||||||
|
|
||||||
|
override fun onThreadViolation(v: Violation) = showNotification(v)
|
||||||
|
|
||||||
|
override fun onViolation(violation: FragmentViolation) = showNotification(violation)
|
||||||
|
|
||||||
|
private fun showNotification(violation: Throwable) = Notification.Builder(context, CHANNEL_ID)
|
||||||
|
.setSmallIcon(R.drawable.ic_bug)
|
||||||
|
.setContentTitle(context.getString(R.string.strict_mode))
|
||||||
|
.setContentText(violation.message)
|
||||||
|
.setStyle(
|
||||||
|
BigTextStyle()
|
||||||
|
.setBigContentTitle(context.getString(R.string.strict_mode))
|
||||||
|
.setSummaryText(violation.message)
|
||||||
|
.bigText(violation.stackTraceToString()),
|
||||||
|
).setShowWhen(true)
|
||||||
|
.setContentIntent(
|
||||||
|
PendingIntentCompat.getActivity(
|
||||||
|
context,
|
||||||
|
0,
|
||||||
|
ShareHelper(context).getShareTextIntent(violation.stackTraceToString()),
|
||||||
|
0,
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.setGroup(CHANNEL_ID)
|
||||||
|
.build()
|
||||||
|
.let { notificationManager.notify(CHANNEL_ID, violation.hashCode().absoluteValue, it) }
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
|
||||||
|
const val CHANNEL_ID = "strict_mode"
|
||||||
|
}
|
||||||
|
}
|
||||||
15
app/src/debug/res/drawable-anydpi-v24/ic_bug.xml
Normal file
15
app/src/debug/res/drawable-anydpi-v24/ic_bug.xml
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24"
|
||||||
|
android:tint="#FFFFFF">
|
||||||
|
<group android:scaleX="0.98150784"
|
||||||
|
android:scaleY="0.98150784"
|
||||||
|
android:translateX="0.22190611"
|
||||||
|
android:translateY="-0.2688478">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M20,8h-2.81c-0.45,-0.78 -1.07,-1.45 -1.82,-1.96L17,4.41 15.59,3l-2.17,2.17C12.96,5.06 12.49,5 12,5c-0.49,0 -0.96,0.06 -1.41,0.17L8.41,3 7,4.41l1.62,1.63C7.88,6.55 7.26,7.22 6.81,8L4,8v2h2.09c-0.05,0.33 -0.09,0.66 -0.09,1v1L4,12v2h2v1c0,0.34 0.04,0.67 0.09,1L4,16v2h2.81c1.04,1.79 2.97,3 5.19,3s4.15,-1.21 5.19,-3L20,18v-2h-2.09c0.05,-0.33 0.09,-0.66 0.09,-1v-1h2v-2h-2v-1c0,-0.34 -0.04,-0.67 -0.09,-1L20,10L20,8zM14,16h-4v-2h4v2zM14,12h-4v-2h4v2z"/>
|
||||||
|
</group>
|
||||||
|
</vector>
|
||||||
BIN
app/src/debug/res/drawable-hdpi/ic_bug.png
Normal file
BIN
app/src/debug/res/drawable-hdpi/ic_bug.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 417 B |
BIN
app/src/debug/res/drawable-mdpi/ic_bug.png
Normal file
BIN
app/src/debug/res/drawable-mdpi/ic_bug.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 308 B |
BIN
app/src/debug/res/drawable-xhdpi/ic_bug.png
Normal file
BIN
app/src/debug/res/drawable-xhdpi/ic_bug.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 480 B |
BIN
app/src/debug/res/drawable-xxhdpi/ic_bug.png
Normal file
BIN
app/src/debug/res/drawable-xxhdpi/ic_bug.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 792 B |
@@ -1,3 +1,4 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<string name="app_name" translatable="false">Kotatsu Dev</string>
|
<string name="app_name" translatable="false">Kotatsu Dev</string>
|
||||||
</resources>
|
<string name="strict_mode">Strict mode</string>
|
||||||
|
</resources>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -14,18 +14,21 @@ import org.koitharu.kotatsu.parsers.model.Manga
|
|||||||
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaParserSource
|
import org.koitharu.kotatsu.parsers.model.MangaParserSource
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
|
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
private const val MAX_PARALLELISM = 4
|
private const val MAX_PARALLELISM = 4
|
||||||
private const val MATCH_THRESHOLD = 0.2f
|
private const val MATCH_THRESHOLD_DEFAULT = 0.2f
|
||||||
|
|
||||||
class AlternativesUseCase @Inject constructor(
|
class AlternativesUseCase @Inject constructor(
|
||||||
private val sourcesRepository: MangaSourcesRepository,
|
private val sourcesRepository: MangaSourcesRepository,
|
||||||
private val mangaRepositoryFactory: MangaRepository.Factory,
|
private val mangaRepositoryFactory: MangaRepository.Factory,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
suspend operator fun invoke(manga: Manga): Flow<Manga> {
|
suspend operator fun invoke(manga: Manga): Flow<Manga> = invoke(manga, MATCH_THRESHOLD_DEFAULT)
|
||||||
|
|
||||||
|
suspend operator fun invoke(manga: Manga, matchThreshold: Float): Flow<Manga> {
|
||||||
val sources = getSources(manga.source)
|
val sources = getSources(manga.source)
|
||||||
if (sources.isEmpty()) {
|
if (sources.isEmpty()) {
|
||||||
return emptyFlow()
|
return emptyFlow()
|
||||||
@@ -34,17 +37,17 @@ class AlternativesUseCase @Inject constructor(
|
|||||||
return channelFlow {
|
return channelFlow {
|
||||||
for (source in sources) {
|
for (source in sources) {
|
||||||
val repository = mangaRepositoryFactory.create(source)
|
val repository = mangaRepositoryFactory.create(source)
|
||||||
if (!repository.isSearchSupported) {
|
if (!repository.filterCapabilities.isSearchSupported) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
launch {
|
launch {
|
||||||
val list = runCatchingCancellable {
|
val list = runCatchingCancellable {
|
||||||
semaphore.withPermit {
|
semaphore.withPermit {
|
||||||
repository.getList(offset = 0, filter = MangaListFilter.Search(manga.title))
|
repository.getList(offset = 0, SortOrder.RELEVANCE, MangaListFilter(query = manga.title))
|
||||||
}
|
}
|
||||||
}.getOrDefault(emptyList())
|
}.getOrDefault(emptyList())
|
||||||
for (item in list) {
|
for (item in list) {
|
||||||
if (item.matches(manga)) {
|
if (item.matches(manga, matchThreshold)) {
|
||||||
send(item)
|
send(item)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -65,16 +68,16 @@ class AlternativesUseCase @Inject constructor(
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun Manga.matches(ref: Manga): Boolean {
|
private fun Manga.matches(ref: Manga, threshold: Float): Boolean {
|
||||||
return matchesTitles(title, ref.title) ||
|
return matchesTitles(title, ref.title, threshold) ||
|
||||||
matchesTitles(title, ref.altTitle) ||
|
matchesTitles(title, ref.altTitle, threshold) ||
|
||||||
matchesTitles(altTitle, ref.title) ||
|
matchesTitles(altTitle, ref.title, threshold) ||
|
||||||
matchesTitles(altTitle, ref.altTitle)
|
matchesTitles(altTitle, ref.altTitle, threshold)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun matchesTitles(a: String?, b: String?): Boolean {
|
private fun matchesTitles(a: String?, b: String?, threshold: Float): Boolean {
|
||||||
return !a.isNullOrEmpty() && !b.isNullOrEmpty() && a.almostEquals(b, MATCH_THRESHOLD)
|
return !a.isNullOrEmpty() && !b.isNullOrEmpty() && a.almostEquals(b, threshold)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun MangaSource.priority(ref: MangaSource): Int {
|
private fun MangaSource.priority(ref: MangaSource): Int {
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package org.koitharu.kotatsu.alternatives.domain
|
||||||
|
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.catch
|
||||||
|
import kotlinx.coroutines.flow.channelFlow
|
||||||
|
import kotlinx.coroutines.flow.filter
|
||||||
|
import kotlinx.coroutines.flow.lastOrNull
|
||||||
|
import kotlinx.coroutines.flow.runningFold
|
||||||
|
import kotlinx.coroutines.flow.transformWhile
|
||||||
|
import kotlinx.coroutines.flow.withIndex
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||||
|
import org.koitharu.kotatsu.core.model.chaptersCount
|
||||||
|
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
|
||||||
|
import org.koitharu.kotatsu.core.parser.MangaDataRepository
|
||||||
|
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||||
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
|
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import javax.inject.Inject
|
||||||
|
import kotlin.coroutines.cancellation.CancellationException
|
||||||
|
|
||||||
|
class AutoFixUseCase @Inject constructor(
|
||||||
|
private val mangaRepositoryFactory: MangaRepository.Factory,
|
||||||
|
private val alternativesUseCase: AlternativesUseCase,
|
||||||
|
private val migrateUseCase: MigrateUseCase,
|
||||||
|
private val mangaDataRepository: MangaDataRepository,
|
||||||
|
) {
|
||||||
|
|
||||||
|
suspend operator fun invoke(mangaId: Long): Pair<Manga, Manga?> {
|
||||||
|
val seed = checkNotNull(mangaDataRepository.findMangaById(mangaId)) { "Manga $mangaId not found" }
|
||||||
|
.getDetailsSafe()
|
||||||
|
if (seed.isHealthy()) {
|
||||||
|
return seed to null // no fix required
|
||||||
|
}
|
||||||
|
val replacement = alternativesUseCase(seed, matchThreshold = 0.02f)
|
||||||
|
.filter { it.isHealthy() }
|
||||||
|
.runningFold<Manga, Manga?>(null) { best, candidate ->
|
||||||
|
if (best == null || best < candidate) {
|
||||||
|
candidate
|
||||||
|
} else {
|
||||||
|
best
|
||||||
|
}
|
||||||
|
}.selectLastWithTimeout(4, 40, TimeUnit.SECONDS)
|
||||||
|
migrateUseCase(seed, replacement ?: throw NoAlternativesException(ParcelableManga(seed)))
|
||||||
|
return seed to replacement
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun Manga.isHealthy(): Boolean = runCatchingCancellable {
|
||||||
|
val repo = mangaRepositoryFactory.create(source)
|
||||||
|
val details = if (this.chapters != null) this else repo.getDetails(this)
|
||||||
|
val firstChapter = details.chapters?.firstOrNull() ?: return@runCatchingCancellable false
|
||||||
|
val pageUrl = repo.getPageUrl(repo.getPages(firstChapter).first())
|
||||||
|
pageUrl.toHttpUrlOrNull() != null
|
||||||
|
}.getOrDefault(false)
|
||||||
|
|
||||||
|
private suspend fun Manga.getDetailsSafe() = runCatchingCancellable {
|
||||||
|
mangaRepositoryFactory.create(source).getDetails(this)
|
||||||
|
}.getOrDefault(this)
|
||||||
|
|
||||||
|
private operator fun Manga.compareTo(other: Manga) = chaptersCount().compareTo(other.chaptersCount())
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST", "OPT_IN_USAGE")
|
||||||
|
private suspend fun <T> Flow<T>.selectLastWithTimeout(
|
||||||
|
minCount: Int,
|
||||||
|
timeout: Long,
|
||||||
|
timeUnit: TimeUnit
|
||||||
|
): T? = channelFlow<T?> {
|
||||||
|
var lastValue: T? = null
|
||||||
|
launch {
|
||||||
|
delay(timeUnit.toMillis(timeout))
|
||||||
|
close(InternalTimeoutException(lastValue))
|
||||||
|
}
|
||||||
|
withIndex().transformWhile { (index, value) ->
|
||||||
|
lastValue = value
|
||||||
|
emit(value)
|
||||||
|
index < minCount && !isClosedForSend
|
||||||
|
}.collect {
|
||||||
|
send(it)
|
||||||
|
}
|
||||||
|
}.catch { e ->
|
||||||
|
if (e is InternalTimeoutException) {
|
||||||
|
emit(e.value as T?)
|
||||||
|
} else {
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}.lastOrNull()
|
||||||
|
|
||||||
|
class NoAlternativesException(val seed: ParcelableManga) : NoSuchElementException()
|
||||||
|
|
||||||
|
private class InternalTimeoutException(val value: Any?) : CancellationException()
|
||||||
|
}
|
||||||
@@ -136,7 +136,7 @@ constructor(
|
|||||||
return HistoryEntity(
|
return HistoryEntity(
|
||||||
mangaId = newManga.id,
|
mangaId = newManga.id,
|
||||||
createdAt = history.createdAt,
|
createdAt = history.createdAt,
|
||||||
updatedAt = System.currentTimeMillis(),
|
updatedAt = history.updatedAt,
|
||||||
chapterId = currentChapter.id,
|
chapterId = currentChapter.id,
|
||||||
page = history.page,
|
page = history.page,
|
||||||
scroll = history.scroll,
|
scroll = history.scroll,
|
||||||
@@ -173,7 +173,7 @@ constructor(
|
|||||||
return HistoryEntity(
|
return HistoryEntity(
|
||||||
mangaId = newManga.id,
|
mangaId = newManga.id,
|
||||||
createdAt = history.createdAt,
|
createdAt = history.createdAt,
|
||||||
updatedAt = System.currentTimeMillis(),
|
updatedAt = history.updatedAt,
|
||||||
chapterId = newChapterId,
|
chapterId = newChapterId,
|
||||||
page = history.page,
|
page = history.page,
|
||||||
scroll = history.scroll,
|
scroll = history.scroll,
|
||||||
|
|||||||
@@ -30,7 +30,8 @@ import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD
|
|||||||
import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD
|
import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD
|
||||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
import org.koitharu.kotatsu.search.ui.SearchActivity
|
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
||||||
|
import org.koitharu.kotatsu.search.ui.MangaListActivity
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
@@ -81,7 +82,14 @@ class AlternativesActivity : BaseActivity<ActivityAlternativesBinding>(),
|
|||||||
|
|
||||||
override fun onItemClick(item: MangaAlternativeModel, view: View) {
|
override fun onItemClick(item: MangaAlternativeModel, view: View) {
|
||||||
when (view.id) {
|
when (view.id) {
|
||||||
R.id.chip_source -> startActivity(SearchActivity.newIntent(this, item.manga.source, viewModel.manga.title))
|
R.id.chip_source -> startActivity(
|
||||||
|
MangaListActivity.newIntent(
|
||||||
|
this,
|
||||||
|
item.manga.source,
|
||||||
|
MangaListFilter(query = viewModel.manga.title),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
R.id.button_migrate -> confirmMigration(item.manga)
|
R.id.button_migrate -> confirmMigration(item.manga)
|
||||||
else -> startActivity(DetailsActivity.newIntent(this, item.manga))
|
else -> startActivity(DetailsActivity.newIntent(this, item.manga))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,197 @@
|
|||||||
|
package org.koitharu.kotatsu.alternatives.ui
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.app.Notification
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.ServiceInfo
|
||||||
|
import androidx.core.app.NotificationChannelCompat
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import androidx.core.app.PendingIntentCompat
|
||||||
|
import androidx.core.app.ServiceCompat
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import coil.ImageLoader
|
||||||
|
import coil.request.ImageRequest
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.alternatives.domain.AutoFixUseCase
|
||||||
|
import org.koitharu.kotatsu.core.ErrorReporterReceiver
|
||||||
|
import org.koitharu.kotatsu.core.model.getTitle
|
||||||
|
import org.koitharu.kotatsu.core.ui.CoroutineIntentService
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.toBitmapOrNull
|
||||||
|
import org.koitharu.kotatsu.details.ui.DetailsActivity
|
||||||
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
|
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||||
|
import javax.inject.Inject
|
||||||
|
import com.google.android.material.R as materialR
|
||||||
|
|
||||||
|
@AndroidEntryPoint
|
||||||
|
class AutoFixService : CoroutineIntentService() {
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var autoFixUseCase: AutoFixUseCase
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var coil: ImageLoader
|
||||||
|
|
||||||
|
private lateinit var notificationManager: NotificationManagerCompat
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
notificationManager = NotificationManagerCompat.from(applicationContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun processIntent(startId: Int, intent: Intent) {
|
||||||
|
val ids = requireNotNull(intent.getLongArrayExtra(DATA_IDS))
|
||||||
|
startForeground(startId)
|
||||||
|
try {
|
||||||
|
for (mangaId in ids) {
|
||||||
|
val result = runCatchingCancellable {
|
||||||
|
autoFixUseCase.invoke(mangaId)
|
||||||
|
}
|
||||||
|
if (applicationContext.checkNotificationPermission(CHANNEL_ID)) {
|
||||||
|
val notification = buildNotification(result)
|
||||||
|
notificationManager.notify(TAG, startId, notification)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onError(startId: Int, error: Throwable) {
|
||||||
|
if (applicationContext.checkNotificationPermission(CHANNEL_ID)) {
|
||||||
|
val notification = runBlocking { buildNotification(Result.failure(error)) }
|
||||||
|
notificationManager.notify(TAG, startId, notification)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("InlinedApi")
|
||||||
|
private fun startForeground(startId: Int) {
|
||||||
|
val title = applicationContext.getString(R.string.fixing_manga)
|
||||||
|
val channel = NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_MIN)
|
||||||
|
.setName(title)
|
||||||
|
.setShowBadge(false)
|
||||||
|
.setVibrationEnabled(false)
|
||||||
|
.setSound(null, null)
|
||||||
|
.setLightsEnabled(false)
|
||||||
|
.build()
|
||||||
|
notificationManager.createNotificationChannel(channel)
|
||||||
|
|
||||||
|
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
|
||||||
|
.setContentTitle(title)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_MIN)
|
||||||
|
.setDefaults(0)
|
||||||
|
.setSilent(true)
|
||||||
|
.setOngoing(true)
|
||||||
|
.setProgress(0, 0, true)
|
||||||
|
.setSmallIcon(R.drawable.ic_stat_auto_fix)
|
||||||
|
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
|
||||||
|
.setCategory(NotificationCompat.CATEGORY_PROGRESS)
|
||||||
|
.addAction(
|
||||||
|
materialR.drawable.material_ic_clear_black_24dp,
|
||||||
|
applicationContext.getString(android.R.string.cancel),
|
||||||
|
getCancelIntent(startId),
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
ServiceCompat.startForeground(
|
||||||
|
this,
|
||||||
|
FOREGROUND_NOTIFICATION_ID,
|
||||||
|
notification,
|
||||||
|
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun buildNotification(result: Result<Pair<Manga, Manga?>>): Notification {
|
||||||
|
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||||
|
.setDefaults(0)
|
||||||
|
.setSilent(true)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
result.onSuccess { (seed, replacement) ->
|
||||||
|
if (replacement != null) {
|
||||||
|
notification.setLargeIcon(
|
||||||
|
coil.execute(
|
||||||
|
ImageRequest.Builder(applicationContext)
|
||||||
|
.data(replacement.coverUrl)
|
||||||
|
.tag(replacement.source)
|
||||||
|
.build(),
|
||||||
|
).toBitmapOrNull(),
|
||||||
|
)
|
||||||
|
notification.setSubText(replacement.title)
|
||||||
|
val intent = DetailsActivity.newIntent(applicationContext, replacement)
|
||||||
|
notification.setContentIntent(
|
||||||
|
PendingIntentCompat.getActivity(
|
||||||
|
applicationContext,
|
||||||
|
replacement.id.toInt(),
|
||||||
|
intent,
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT,
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
).setVisibility(
|
||||||
|
if (replacement.isNsfw) NotificationCompat.VISIBILITY_SECRET else NotificationCompat.VISIBILITY_PUBLIC,
|
||||||
|
)
|
||||||
|
notification
|
||||||
|
.setContentTitle(applicationContext.getString(R.string.fixed))
|
||||||
|
.setContentText(
|
||||||
|
applicationContext.getString(
|
||||||
|
R.string.manga_replaced,
|
||||||
|
seed.title,
|
||||||
|
seed.source.getTitle(applicationContext),
|
||||||
|
replacement.title,
|
||||||
|
replacement.source.getTitle(applicationContext),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.setSmallIcon(R.drawable.ic_stat_done)
|
||||||
|
} else {
|
||||||
|
notification
|
||||||
|
.setContentTitle(applicationContext.getString(R.string.fixing_manga))
|
||||||
|
.setContentText(applicationContext.getString(R.string.no_fix_required, seed.title))
|
||||||
|
.setSmallIcon(android.R.drawable.stat_sys_warning)
|
||||||
|
}
|
||||||
|
}.onFailure { error ->
|
||||||
|
notification
|
||||||
|
.setContentTitle(applicationContext.getString(R.string.error_occurred))
|
||||||
|
.setContentText(
|
||||||
|
if (error is AutoFixUseCase.NoAlternativesException) {
|
||||||
|
applicationContext.getString(R.string.no_alternatives_found, error.seed.manga.title)
|
||||||
|
} else {
|
||||||
|
error.getDisplayMessage(applicationContext.resources)
|
||||||
|
},
|
||||||
|
).setSmallIcon(android.R.drawable.stat_notify_error)
|
||||||
|
ErrorReporterReceiver.getPendingIntent(applicationContext, error)?.let { reportIntent ->
|
||||||
|
notification.addAction(
|
||||||
|
R.drawable.ic_alert_outline,
|
||||||
|
applicationContext.getString(R.string.report),
|
||||||
|
reportIntent,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return notification.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
private const val DATA_IDS = "ids"
|
||||||
|
private const val TAG = "auto_fix"
|
||||||
|
private const val CHANNEL_ID = "auto_fix"
|
||||||
|
private const val FOREGROUND_NOTIFICATION_ID = 38
|
||||||
|
|
||||||
|
fun start(context: Context, mangaIds: Collection<Long>): Boolean = try {
|
||||||
|
val intent = Intent(context, AutoFixService::class.java)
|
||||||
|
intent.putExtra(DATA_IDS, mangaIds.toLongArray())
|
||||||
|
ContextCompat.startForegroundService(context, intent)
|
||||||
|
true
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTraceDebug()
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,9 +17,6 @@ data class Bookmark(
|
|||||||
val percent: Float,
|
val percent: Float,
|
||||||
) : ListModel {
|
) : ListModel {
|
||||||
|
|
||||||
val directImageUrl: String?
|
|
||||||
get() = if (isImageUrlDirect()) imageUrl else null
|
|
||||||
|
|
||||||
val imageLoadData: Any
|
val imageLoadData: Any
|
||||||
get() = if (isImageUrlDirect()) imageUrl else toMangaPage()
|
get() = if (isImageUrlDirect()) imageUrl else toMangaPage()
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.bookmarks.ui
|
|||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.Menu
|
import android.view.Menu
|
||||||
|
import android.view.MenuInflater
|
||||||
import android.view.MenuItem
|
import android.view.MenuItem
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
@@ -129,7 +130,11 @@ class AllBookmarksFragment :
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onItemLongClick(item: Bookmark, view: View): Boolean {
|
override fun onItemLongClick(item: Bookmark, view: View): Boolean {
|
||||||
return selectionController?.onItemLongClick(item.pageId) ?: false
|
return selectionController?.onItemLongClick(view, item.pageId) ?: false
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onItemContextClick(item: Bookmark, view: View): Boolean {
|
||||||
|
return selectionController?.onItemContextClick(view, item.pageId) ?: false
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onRetryClick(error: Throwable) = Unit
|
override fun onRetryClick(error: Throwable) = Unit
|
||||||
@@ -148,23 +153,23 @@ class AllBookmarksFragment :
|
|||||||
|
|
||||||
override fun onCreateActionMode(
|
override fun onCreateActionMode(
|
||||||
controller: ListSelectionController,
|
controller: ListSelectionController,
|
||||||
mode: ActionMode,
|
menuInflater: MenuInflater,
|
||||||
menu: Menu,
|
menu: Menu,
|
||||||
): Boolean {
|
): Boolean {
|
||||||
mode.menuInflater.inflate(R.menu.mode_bookmarks, menu)
|
menuInflater.inflate(R.menu.mode_bookmarks, menu)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onActionItemClicked(
|
override fun onActionItemClicked(
|
||||||
controller: ListSelectionController,
|
controller: ListSelectionController,
|
||||||
mode: ActionMode,
|
mode: ActionMode?,
|
||||||
item: MenuItem,
|
item: MenuItem,
|
||||||
): Boolean {
|
): Boolean {
|
||||||
return when (item.itemId) {
|
return when (item.itemId) {
|
||||||
R.id.action_remove -> {
|
R.id.action_remove -> {
|
||||||
val ids = selectionController?.snapshot() ?: return false
|
val ids = selectionController?.snapshot() ?: return false
|
||||||
viewModel.removeBookmarks(ids)
|
viewModel.removeBookmarks(ids)
|
||||||
mode.finish()
|
mode?.finish()
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,10 +22,7 @@ fun bookmarkLargeAD(
|
|||||||
) = adapterDelegateViewBinding<Bookmark, ListModel, ItemBookmarkLargeBinding>(
|
) = adapterDelegateViewBinding<Bookmark, ListModel, ItemBookmarkLargeBinding>(
|
||||||
{ inflater, parent -> ItemBookmarkLargeBinding.inflate(inflater, parent, false) },
|
{ inflater, parent -> ItemBookmarkLargeBinding.inflate(inflater, parent, false) },
|
||||||
) {
|
) {
|
||||||
val listener = AdapterDelegateClickListenerAdapter(this, clickListener)
|
AdapterDelegateClickListenerAdapter(this, clickListener).attach(itemView)
|
||||||
|
|
||||||
binding.root.setOnClickListener(listener)
|
|
||||||
binding.root.setOnLongClickListener(listener)
|
|
||||||
|
|
||||||
bind {
|
bind {
|
||||||
binding.imageViewThumb.newImageRequest(lifecycleOwner, item.imageLoadData)?.run {
|
binding.imageViewThumb.newImageRequest(lifecycleOwner, item.imageLoadData)?.run {
|
||||||
|
|||||||
@@ -21,10 +21,7 @@ fun bookmarkListAD(
|
|||||||
) = adapterDelegateViewBinding<Bookmark, Bookmark, ItemBookmarkBinding>(
|
) = adapterDelegateViewBinding<Bookmark, Bookmark, ItemBookmarkBinding>(
|
||||||
{ inflater, parent -> ItemBookmarkBinding.inflate(inflater, parent, false) },
|
{ inflater, parent -> ItemBookmarkBinding.inflate(inflater, parent, false) },
|
||||||
) {
|
) {
|
||||||
val listener = AdapterDelegateClickListenerAdapter(this, clickListener)
|
AdapterDelegateClickListenerAdapter(this, clickListener).attach(itemView)
|
||||||
|
|
||||||
binding.root.setOnClickListener(listener)
|
|
||||||
binding.root.setOnLongClickListener(listener)
|
|
||||||
|
|
||||||
bind {
|
bind {
|
||||||
binding.imageViewThumb.newImageRequest(lifecycleOwner, item.imageLoadData)?.run {
|
binding.imageViewThumb.newImageRequest(lifecycleOwner, item.imageLoadData)?.run {
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import androidx.core.graphics.Insets
|
|||||||
import androidx.core.view.isVisible
|
import androidx.core.view.isVisible
|
||||||
import androidx.core.view.updatePadding
|
import androidx.core.view.updatePadding
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import okhttp3.internal.userAgent
|
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.core.model.MangaSource
|
import org.koitharu.kotatsu.core.model.MangaSource
|
||||||
import org.koitharu.kotatsu.core.network.CommonHeaders
|
import org.koitharu.kotatsu.core.network.CommonHeaders
|
||||||
@@ -45,7 +44,7 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
|
|||||||
}
|
}
|
||||||
val mangaSource = MangaSource(intent?.getStringExtra(EXTRA_SOURCE))
|
val mangaSource = MangaSource(intent?.getStringExtra(EXTRA_SOURCE))
|
||||||
val repository = mangaRepositoryFactory.create(mangaSource) as? ParserMangaRepository
|
val repository = mangaRepositoryFactory.create(mangaSource) as? ParserMangaRepository
|
||||||
repository?.getRequestHeaders()?.get(CommonHeaders.USER_AGENT)
|
val userAgent = repository?.getRequestHeaders()?.get(CommonHeaders.USER_AGENT)
|
||||||
viewBinding.webView.configureForParser(userAgent)
|
viewBinding.webView.configureForParser(userAgent)
|
||||||
CookieManager.getInstance().setAcceptThirdPartyCookies(viewBinding.webView, true)
|
CookieManager.getInstance().setAcceptThirdPartyCookies(viewBinding.webView, true)
|
||||||
viewBinding.webView.webViewClient = BrowserClient(this)
|
viewBinding.webView.webViewClient = BrowserClient(this)
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ class CaptchaNotifier(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
val manager = NotificationManagerCompat.from(context)
|
val manager = NotificationManagerCompat.from(context)
|
||||||
val channel = NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_DEFAULT)
|
val channel = NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_LOW)
|
||||||
.setName(context.getString(R.string.captcha_required))
|
.setName(context.getString(R.string.captcha_required))
|
||||||
.setShowBadge(true)
|
.setShowBadge(true)
|
||||||
.setVibrationEnabled(false)
|
.setVibrationEnabled(false)
|
||||||
@@ -41,8 +41,8 @@ class CaptchaNotifier(
|
|||||||
.setData(exception.url.toUri())
|
.setData(exception.url.toUri())
|
||||||
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||||
.setContentTitle(channel.name)
|
.setContentTitle(channel.name)
|
||||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||||
.setDefaults(NotificationCompat.DEFAULT_SOUND)
|
.setDefaults(0)
|
||||||
.setSmallIcon(android.R.drawable.stat_notify_error)
|
.setSmallIcon(android.R.drawable.stat_notify_error)
|
||||||
.setGroup(GROUP_CAPTCHA)
|
.setGroup(GROUP_CAPTCHA)
|
||||||
.setAutoCancel(true)
|
.setAutoCancel(true)
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import org.koitharu.kotatsu.core.ui.BaseActivity
|
|||||||
import org.koitharu.kotatsu.core.util.ext.configureForParser
|
import org.koitharu.kotatsu.core.util.ext.configureForParser
|
||||||
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
|
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
|
import org.koitharu.kotatsu.parsers.network.CloudFlareHelper
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import com.google.android.material.R as materialR
|
import com.google.android.material.R as materialR
|
||||||
|
|
||||||
@@ -175,8 +176,7 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
|
|||||||
|
|
||||||
private suspend fun clearCfCookies(url: HttpUrl) = runInterruptible(Dispatchers.Default) {
|
private suspend fun clearCfCookies(url: HttpUrl) = runInterruptible(Dispatchers.Default) {
|
||||||
cookieJar.removeCookies(url) { cookie ->
|
cookieJar.removeCookies(url) { cookie ->
|
||||||
val name = cookie.name
|
CloudFlareHelper.isCloudFlareCookie(cookie.name)
|
||||||
name.startsWith("cf_") || name.startsWith("_cf") || name.startsWith("__cf") || name == "csrftoken"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,11 +2,10 @@ package org.koitharu.kotatsu.browser.cloudflare
|
|||||||
|
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.webkit.WebView
|
import android.webkit.WebView
|
||||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
|
||||||
import org.koitharu.kotatsu.browser.BrowserClient
|
import org.koitharu.kotatsu.browser.BrowserClient
|
||||||
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
|
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
|
||||||
|
import org.koitharu.kotatsu.parsers.network.CloudFlareHelper
|
||||||
|
|
||||||
private const val CF_CLEARANCE = "cf_clearance"
|
|
||||||
private const val LOOP_COUNTER = 3
|
private const val LOOP_COUNTER = 3
|
||||||
|
|
||||||
class CloudFlareClient(
|
class CloudFlareClient(
|
||||||
@@ -50,8 +49,5 @@ class CloudFlareClient(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getClearance(): String? {
|
private fun getClearance() = CloudFlareHelper.getClearanceCookie(cookieJar, targetUrl)
|
||||||
return cookieJar.loadForRequest(targetUrl.toHttpUrl())
|
|
||||||
.find { it.name == CF_CLEARANCE }?.value
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package org.koitharu.kotatsu.core
|
package org.koitharu.kotatsu.core
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import android.content.ContentResolver
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.provider.SearchRecentSuggestions
|
import android.provider.SearchRecentSuggestions
|
||||||
import android.text.Html
|
import android.text.Html
|
||||||
@@ -28,6 +27,7 @@ import okhttp3.OkHttpClient
|
|||||||
import org.koitharu.kotatsu.BuildConfig
|
import org.koitharu.kotatsu.BuildConfig
|
||||||
import org.koitharu.kotatsu.browser.cloudflare.CaptchaNotifier
|
import org.koitharu.kotatsu.browser.cloudflare.CaptchaNotifier
|
||||||
import org.koitharu.kotatsu.core.db.MangaDatabase
|
import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||||
|
import org.koitharu.kotatsu.core.image.AvifImageDecoder
|
||||||
import org.koitharu.kotatsu.core.network.MangaHttpClient
|
import org.koitharu.kotatsu.core.network.MangaHttpClient
|
||||||
import org.koitharu.kotatsu.core.network.imageproxy.ImageProxyInterceptor
|
import org.koitharu.kotatsu.core.network.imageproxy.ImageProxyInterceptor
|
||||||
import org.koitharu.kotatsu.core.os.AppShortcutManager
|
import org.koitharu.kotatsu.core.os.AppShortcutManager
|
||||||
@@ -81,9 +81,7 @@ interface AppModule {
|
|||||||
@Singleton
|
@Singleton
|
||||||
fun provideMangaDatabase(
|
fun provideMangaDatabase(
|
||||||
@ApplicationContext context: Context,
|
@ApplicationContext context: Context,
|
||||||
): MangaDatabase {
|
): MangaDatabase = MangaDatabase(context)
|
||||||
return MangaDatabase(context)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
@@ -120,6 +118,7 @@ interface AppModule {
|
|||||||
ComponentRegistry.Builder()
|
ComponentRegistry.Builder()
|
||||||
.add(SvgDecoder.Factory())
|
.add(SvgDecoder.Factory())
|
||||||
.add(CbzFetcher.Factory())
|
.add(CbzFetcher.Factory())
|
||||||
|
.add(AvifImageDecoder.Factory())
|
||||||
.add(FaviconFetcher.Factory(context, okHttpClientLazy, mangaRepositoryFactory))
|
.add(FaviconFetcher.Factory(context, okHttpClientLazy, mangaRepositoryFactory))
|
||||||
.add(MangaPageKeyer())
|
.add(MangaPageKeyer())
|
||||||
.add(pageFetcherFactory)
|
.add(pageFetcherFactory)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import androidx.work.Configuration
|
|||||||
import androidx.work.WorkManager
|
import androidx.work.WorkManager
|
||||||
import dagger.hilt.android.HiltAndroidApp
|
import dagger.hilt.android.HiltAndroidApp
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.acra.ACRA
|
import org.acra.ACRA
|
||||||
@@ -28,6 +29,9 @@ import org.koitharu.kotatsu.core.os.AppValidator
|
|||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
import org.koitharu.kotatsu.core.util.WorkServiceStopHelper
|
import org.koitharu.kotatsu.core.util.WorkServiceStopHelper
|
||||||
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
|
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
|
||||||
|
import org.koitharu.kotatsu.local.data.LocalStorageChanges
|
||||||
|
import org.koitharu.kotatsu.local.data.index.LocalMangaIndex
|
||||||
|
import org.koitharu.kotatsu.local.domain.model.LocalManga
|
||||||
import org.koitharu.kotatsu.settings.work.WorkScheduleManager
|
import org.koitharu.kotatsu.settings.work.WorkScheduleManager
|
||||||
import java.security.Security
|
import java.security.Security
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -60,6 +64,13 @@ open class BaseApp : Application(), Configuration.Provider {
|
|||||||
@Inject
|
@Inject
|
||||||
lateinit var workManagerProvider: Provider<WorkManager>
|
lateinit var workManagerProvider: Provider<WorkManager>
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var localMangaIndexProvider: Provider<LocalMangaIndex>
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
@LocalStorageChanges
|
||||||
|
lateinit var localStorageChanges: MutableSharedFlow<LocalManga?>
|
||||||
|
|
||||||
override val workManagerConfiguration: Configuration
|
override val workManagerConfiguration: Configuration
|
||||||
get() = Configuration.Builder()
|
get() = Configuration.Builder()
|
||||||
.setWorkerFactory(workerFactory)
|
.setWorkerFactory(workerFactory)
|
||||||
@@ -82,6 +93,7 @@ open class BaseApp : Application(), Configuration.Provider {
|
|||||||
}
|
}
|
||||||
processLifecycleScope.launch(Dispatchers.Default) {
|
processLifecycleScope.launch(Dispatchers.Default) {
|
||||||
setupDatabaseObservers()
|
setupDatabaseObservers()
|
||||||
|
localStorageChanges.collect(localMangaIndexProvider.get())
|
||||||
}
|
}
|
||||||
workScheduleManager.init()
|
workScheduleManager.init()
|
||||||
WorkServiceStopHelper(workManagerProvider).setup()
|
WorkServiceStopHelper(workManagerProvider).setup()
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ import android.content.BroadcastReceiver
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import android.os.BadParcelableException
|
||||||
import androidx.core.app.PendingIntentCompat
|
import androidx.core.app.PendingIntentCompat
|
||||||
import org.koitharu.kotatsu.BuildConfig
|
import org.koitharu.kotatsu.BuildConfig
|
||||||
import org.koitharu.kotatsu.core.util.ext.getSerializableExtraCompat
|
import org.koitharu.kotatsu.core.util.ext.getSerializableExtraCompat
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||||
import org.koitharu.kotatsu.core.util.ext.report
|
import org.koitharu.kotatsu.core.util.ext.report
|
||||||
|
|
||||||
class ErrorReporterReceiver : BroadcastReceiver() {
|
class ErrorReporterReceiver : BroadcastReceiver() {
|
||||||
@@ -22,12 +24,15 @@ class ErrorReporterReceiver : BroadcastReceiver() {
|
|||||||
private const val EXTRA_ERROR = "err"
|
private const val EXTRA_ERROR = "err"
|
||||||
private const val ACTION_REPORT = "${BuildConfig.APPLICATION_ID}.action.REPORT_ERROR"
|
private const val ACTION_REPORT = "${BuildConfig.APPLICATION_ID}.action.REPORT_ERROR"
|
||||||
|
|
||||||
fun getPendingIntent(context: Context, e: Throwable): PendingIntent {
|
fun getPendingIntent(context: Context, e: Throwable): PendingIntent? = try {
|
||||||
val intent = Intent(context, ErrorReporterReceiver::class.java)
|
val intent = Intent(context, ErrorReporterReceiver::class.java)
|
||||||
intent.setAction(ACTION_REPORT)
|
intent.setAction(ACTION_REPORT)
|
||||||
intent.setData(Uri.parse("err://${e.hashCode()}"))
|
intent.setData(Uri.parse("err://${e.hashCode()}"))
|
||||||
intent.putExtra(EXTRA_ERROR, e)
|
intent.putExtra(EXTRA_ERROR, e)
|
||||||
return checkNotNull(PendingIntentCompat.getBroadcast(context, 0, intent, 0, false))
|
PendingIntentCompat.getBroadcast(context, 0, intent, 0, false)
|
||||||
|
} catch (e: BadParcelableException) {
|
||||||
|
e.printStackTraceDebug()
|
||||||
|
null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import org.json.JSONObject
|
|||||||
import org.koitharu.kotatsu.BuildConfig
|
import org.koitharu.kotatsu.BuildConfig
|
||||||
import org.koitharu.kotatsu.core.db.MangaDatabase
|
import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
import org.koitharu.kotatsu.parsers.util.json.JSONIterator
|
import org.koitharu.kotatsu.parsers.util.json.asTypedList
|
||||||
import org.koitharu.kotatsu.parsers.util.json.getLongOrDefault
|
import org.koitharu.kotatsu.parsers.util.json.getLongOrDefault
|
||||||
import org.koitharu.kotatsu.parsers.util.json.mapJSON
|
import org.koitharu.kotatsu.parsers.util.json.mapJSON
|
||||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||||
@@ -130,7 +130,7 @@ class BackupRepository @Inject constructor(
|
|||||||
|
|
||||||
suspend fun restoreHistory(entry: BackupEntry): CompositeResult {
|
suspend fun restoreHistory(entry: BackupEntry): CompositeResult {
|
||||||
val result = CompositeResult()
|
val result = CompositeResult()
|
||||||
for (item in entry.data.JSONIterator()) {
|
for (item in entry.data.asTypedList<JSONObject>()) {
|
||||||
val mangaJson = item.getJSONObject("manga")
|
val mangaJson = item.getJSONObject("manga")
|
||||||
val manga = JsonDeserializer(mangaJson).toMangaEntity()
|
val manga = JsonDeserializer(mangaJson).toMangaEntity()
|
||||||
val tags = mangaJson.getJSONArray("tags").mapJSON {
|
val tags = mangaJson.getJSONArray("tags").mapJSON {
|
||||||
@@ -150,7 +150,7 @@ class BackupRepository @Inject constructor(
|
|||||||
|
|
||||||
suspend fun restoreCategories(entry: BackupEntry): CompositeResult {
|
suspend fun restoreCategories(entry: BackupEntry): CompositeResult {
|
||||||
val result = CompositeResult()
|
val result = CompositeResult()
|
||||||
for (item in entry.data.JSONIterator()) {
|
for (item in entry.data.asTypedList<JSONObject>()) {
|
||||||
val category = JsonDeserializer(item).toFavouriteCategoryEntity()
|
val category = JsonDeserializer(item).toFavouriteCategoryEntity()
|
||||||
result += runCatchingCancellable {
|
result += runCatchingCancellable {
|
||||||
db.getFavouriteCategoriesDao().upsert(category)
|
db.getFavouriteCategoriesDao().upsert(category)
|
||||||
@@ -161,7 +161,7 @@ class BackupRepository @Inject constructor(
|
|||||||
|
|
||||||
suspend fun restoreFavourites(entry: BackupEntry): CompositeResult {
|
suspend fun restoreFavourites(entry: BackupEntry): CompositeResult {
|
||||||
val result = CompositeResult()
|
val result = CompositeResult()
|
||||||
for (item in entry.data.JSONIterator()) {
|
for (item in entry.data.asTypedList<JSONObject>()) {
|
||||||
val mangaJson = item.getJSONObject("manga")
|
val mangaJson = item.getJSONObject("manga")
|
||||||
val manga = JsonDeserializer(mangaJson).toMangaEntity()
|
val manga = JsonDeserializer(mangaJson).toMangaEntity()
|
||||||
val tags = mangaJson.getJSONArray("tags").mapJSON {
|
val tags = mangaJson.getJSONArray("tags").mapJSON {
|
||||||
@@ -181,7 +181,7 @@ class BackupRepository @Inject constructor(
|
|||||||
|
|
||||||
suspend fun restoreBookmarks(entry: BackupEntry): CompositeResult {
|
suspend fun restoreBookmarks(entry: BackupEntry): CompositeResult {
|
||||||
val result = CompositeResult()
|
val result = CompositeResult()
|
||||||
for (item in entry.data.JSONIterator()) {
|
for (item in entry.data.asTypedList<JSONObject>()) {
|
||||||
val mangaJson = item.getJSONObject("manga")
|
val mangaJson = item.getJSONObject("manga")
|
||||||
val manga = JsonDeserializer(mangaJson).toMangaEntity()
|
val manga = JsonDeserializer(mangaJson).toMangaEntity()
|
||||||
val tags = item.getJSONArray("tags").mapJSON {
|
val tags = item.getJSONArray("tags").mapJSON {
|
||||||
@@ -203,7 +203,7 @@ class BackupRepository @Inject constructor(
|
|||||||
|
|
||||||
suspend fun restoreSources(entry: BackupEntry): CompositeResult {
|
suspend fun restoreSources(entry: BackupEntry): CompositeResult {
|
||||||
val result = CompositeResult()
|
val result = CompositeResult()
|
||||||
for (item in entry.data.JSONIterator()) {
|
for (item in entry.data.asTypedList<JSONObject>()) {
|
||||||
val source = JsonDeserializer(item).toMangaSourceEntity()
|
val source = JsonDeserializer(item).toMangaSourceEntity()
|
||||||
result += runCatchingCancellable {
|
result += runCatchingCancellable {
|
||||||
db.getSourcesDao().upsert(source)
|
db.getSourcesDao().upsert(source)
|
||||||
@@ -214,7 +214,7 @@ class BackupRepository @Inject constructor(
|
|||||||
|
|
||||||
fun restoreSettings(entry: BackupEntry): CompositeResult {
|
fun restoreSettings(entry: BackupEntry): CompositeResult {
|
||||||
val result = CompositeResult()
|
val result = CompositeResult()
|
||||||
for (item in entry.data.JSONIterator()) {
|
for (item in entry.data.asTypedList<JSONObject>()) {
|
||||||
result += runCatchingCancellable {
|
result += runCatchingCancellable {
|
||||||
settings.upsertAll(JsonDeserializer(item).toMap())
|
settings.upsertAll(JsonDeserializer(item).toMap())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,11 @@
|
|||||||
package org.koitharu.kotatsu.core.backup
|
package org.koitharu.kotatsu.core.backup
|
||||||
|
|
||||||
import kotlinx.coroutines.CoroutineStart
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.runInterruptible
|
import kotlinx.coroutines.runInterruptible
|
||||||
import okhttp3.internal.closeQuietly
|
import okhttp3.internal.closeQuietly
|
||||||
import okio.Closeable
|
import okio.Closeable
|
||||||
import org.json.JSONArray
|
import org.json.JSONArray
|
||||||
import org.koitharu.kotatsu.core.exceptions.BadBackupFormatException
|
import org.koitharu.kotatsu.core.exceptions.BadBackupFormatException
|
||||||
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
|
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.util.EnumSet
|
import java.util.EnumSet
|
||||||
import java.util.zip.ZipException
|
import java.util.zip.ZipException
|
||||||
@@ -36,13 +33,9 @@ class BackupZipInput private constructor(val file: File) : Closeable {
|
|||||||
zipFile.close()
|
zipFile.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun cleanupAsync() {
|
fun closeAndDelete() {
|
||||||
processLifecycleScope.launch(Dispatchers.IO, CoroutineStart.ATOMIC) {
|
closeQuietly()
|
||||||
runCatching {
|
file.delete()
|
||||||
closeQuietly()
|
|
||||||
file.delete()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@@ -55,7 +48,7 @@ class BackupZipInput private constructor(val file: File) : Closeable {
|
|||||||
throw BadBackupFormatException(null)
|
throw BadBackupFormatException(null)
|
||||||
}
|
}
|
||||||
res
|
res
|
||||||
} catch (exception: Exception) {
|
} catch (exception: Throwable) {
|
||||||
res?.closeQuietly()
|
res?.closeQuietly()
|
||||||
throw if (exception is ZipException) {
|
throw if (exception is ZipException) {
|
||||||
BadBackupFormatException(exception)
|
BadBackupFormatException(exception)
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import org.koitharu.kotatsu.core.db.migrations.Migration19To20
|
|||||||
import org.koitharu.kotatsu.core.db.migrations.Migration1To2
|
import org.koitharu.kotatsu.core.db.migrations.Migration1To2
|
||||||
import org.koitharu.kotatsu.core.db.migrations.Migration20To21
|
import org.koitharu.kotatsu.core.db.migrations.Migration20To21
|
||||||
import org.koitharu.kotatsu.core.db.migrations.Migration21To22
|
import org.koitharu.kotatsu.core.db.migrations.Migration21To22
|
||||||
|
import org.koitharu.kotatsu.core.db.migrations.Migration22To23
|
||||||
import org.koitharu.kotatsu.core.db.migrations.Migration2To3
|
import org.koitharu.kotatsu.core.db.migrations.Migration2To3
|
||||||
import org.koitharu.kotatsu.core.db.migrations.Migration3To4
|
import org.koitharu.kotatsu.core.db.migrations.Migration3To4
|
||||||
import org.koitharu.kotatsu.core.db.migrations.Migration4To5
|
import org.koitharu.kotatsu.core.db.migrations.Migration4To5
|
||||||
@@ -50,6 +51,8 @@ import org.koitharu.kotatsu.favourites.data.FavouriteEntity
|
|||||||
import org.koitharu.kotatsu.favourites.data.FavouritesDao
|
import org.koitharu.kotatsu.favourites.data.FavouritesDao
|
||||||
import org.koitharu.kotatsu.history.data.HistoryDao
|
import org.koitharu.kotatsu.history.data.HistoryDao
|
||||||
import org.koitharu.kotatsu.history.data.HistoryEntity
|
import org.koitharu.kotatsu.history.data.HistoryEntity
|
||||||
|
import org.koitharu.kotatsu.local.data.index.LocalMangaIndexDao
|
||||||
|
import org.koitharu.kotatsu.local.data.index.LocalMangaIndexEntity
|
||||||
import org.koitharu.kotatsu.scrobbling.common.data.ScrobblingDao
|
import org.koitharu.kotatsu.scrobbling.common.data.ScrobblingDao
|
||||||
import org.koitharu.kotatsu.scrobbling.common.data.ScrobblingEntity
|
import org.koitharu.kotatsu.scrobbling.common.data.ScrobblingEntity
|
||||||
import org.koitharu.kotatsu.stats.data.StatsDao
|
import org.koitharu.kotatsu.stats.data.StatsDao
|
||||||
@@ -60,14 +63,14 @@ import org.koitharu.kotatsu.tracker.data.TrackEntity
|
|||||||
import org.koitharu.kotatsu.tracker.data.TrackLogEntity
|
import org.koitharu.kotatsu.tracker.data.TrackLogEntity
|
||||||
import org.koitharu.kotatsu.tracker.data.TracksDao
|
import org.koitharu.kotatsu.tracker.data.TracksDao
|
||||||
|
|
||||||
const val DATABASE_VERSION = 22
|
const val DATABASE_VERSION = 23
|
||||||
|
|
||||||
@Database(
|
@Database(
|
||||||
entities = [
|
entities = [
|
||||||
MangaEntity::class, TagEntity::class, HistoryEntity::class, MangaTagsEntity::class,
|
MangaEntity::class, TagEntity::class, HistoryEntity::class, MangaTagsEntity::class,
|
||||||
FavouriteCategoryEntity::class, FavouriteEntity::class, MangaPrefsEntity::class,
|
FavouriteCategoryEntity::class, FavouriteEntity::class, MangaPrefsEntity::class,
|
||||||
TrackEntity::class, TrackLogEntity::class, SuggestionEntity::class, BookmarkEntity::class,
|
TrackEntity::class, TrackLogEntity::class, SuggestionEntity::class, BookmarkEntity::class,
|
||||||
ScrobblingEntity::class, MangaSourceEntity::class, StatsEntity::class,
|
ScrobblingEntity::class, MangaSourceEntity::class, StatsEntity::class, LocalMangaIndexEntity::class,
|
||||||
],
|
],
|
||||||
version = DATABASE_VERSION,
|
version = DATABASE_VERSION,
|
||||||
)
|
)
|
||||||
@@ -98,6 +101,8 @@ abstract class MangaDatabase : RoomDatabase() {
|
|||||||
abstract fun getSourcesDao(): MangaSourcesDao
|
abstract fun getSourcesDao(): MangaSourcesDao
|
||||||
|
|
||||||
abstract fun getStatsDao(): StatsDao
|
abstract fun getStatsDao(): StatsDao
|
||||||
|
|
||||||
|
abstract fun getLocalMangaIndexDao(): LocalMangaIndexDao
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getDatabaseMigrations(context: Context): Array<Migration> = arrayOf(
|
fun getDatabaseMigrations(context: Context): Array<Migration> = arrayOf(
|
||||||
@@ -122,6 +127,7 @@ fun getDatabaseMigrations(context: Context): Array<Migration> = arrayOf(
|
|||||||
Migration19To20(),
|
Migration19To20(),
|
||||||
Migration20To21(),
|
Migration20To21(),
|
||||||
Migration21To22(),
|
Migration21To22(),
|
||||||
|
Migration22To23(),
|
||||||
)
|
)
|
||||||
|
|
||||||
fun MangaDatabase(context: Context): MangaDatabase = Room
|
fun MangaDatabase(context: Context): MangaDatabase = Room
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ fun MangaEntity.toManga(tags: Set<MangaTag>) = Manga(
|
|||||||
|
|
||||||
fun MangaWithTags.toManga() = manga.toManga(tags.toMangaTags())
|
fun MangaWithTags.toManga() = manga.toManga(tags.toMangaTags())
|
||||||
|
|
||||||
|
fun Collection<MangaWithTags>.toMangaList() = map { it.toManga() }
|
||||||
|
|
||||||
// Model to entity
|
// Model to entity
|
||||||
|
|
||||||
fun Manga.toEntity() = MangaEntity(
|
fun Manga.toEntity() = MangaEntity(
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package org.koitharu.kotatsu.core.db.migrations
|
||||||
|
|
||||||
|
import androidx.room.migration.Migration
|
||||||
|
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||||
|
|
||||||
|
class Migration22To23 : Migration(22, 23) {
|
||||||
|
|
||||||
|
override fun migrate(db: SupportSQLiteDatabase) {
|
||||||
|
db.execSQL("CREATE TABLE IF NOT EXISTS `local_index` (`manga_id` INTEGER NOT NULL, `path` TEXT NOT NULL, PRIMARY KEY(`manga_id`), FOREIGN KEY(`manga_id`) REFERENCES `manga`(`manga_id`) ON UPDATE NO ACTION ON DELETE CASCADE )")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
package org.koitharu.kotatsu.core.exceptions
|
package org.koitharu.kotatsu.core.exceptions
|
||||||
|
|
||||||
import okhttp3.Headers
|
|
||||||
import okio.IOException
|
import okio.IOException
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
|
|
||||||
|
|||||||
@@ -3,5 +3,5 @@ package org.koitharu.kotatsu.core.exceptions
|
|||||||
import okio.IOException
|
import okio.IOException
|
||||||
|
|
||||||
class NoDataReceivedException(
|
class NoDataReceivedException(
|
||||||
private val url: String,
|
url: String,
|
||||||
) : IOException("No data has been received from $url")
|
) : IOException("No data has been received from $url")
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
|||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.core.ui.dialog.ErrorDetailsDialog
|
import org.koitharu.kotatsu.core.ui.dialog.ErrorDetailsDialog
|
||||||
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.isSerializable
|
||||||
import org.koitharu.kotatsu.parsers.exception.ParseException
|
import org.koitharu.kotatsu.parsers.exception.ParseException
|
||||||
|
|
||||||
class DialogErrorObserver(
|
class DialogErrorObserver(
|
||||||
@@ -32,7 +33,7 @@ class DialogErrorObserver(
|
|||||||
dialogBuilder.setPositiveButton(ExceptionResolver.getResolveStringId(value), listener)
|
dialogBuilder.setPositiveButton(ExceptionResolver.getResolveStringId(value), listener)
|
||||||
} else if (value is ParseException) {
|
} else if (value is ParseException) {
|
||||||
val fm = fragmentManager
|
val fm = fragmentManager
|
||||||
if (fm != null) {
|
if (fm != null && value.isSerializable()) {
|
||||||
dialogBuilder.setPositiveButton(R.string.details) { _, _ ->
|
dialogBuilder.setPositiveButton(R.string.details) { _, _ ->
|
||||||
ErrorDetailsDialog.show(fm, value, value.url)
|
ErrorDetailsDialog.show(fm, value, value.url)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import androidx.activity.result.ActivityResultCaller
|
|||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
import androidx.collection.MutableScatterMap
|
import androidx.collection.MutableScatterMap
|
||||||
import androidx.fragment.app.FragmentManager
|
import androidx.fragment.app.FragmentManager
|
||||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
|
||||||
import dagger.assisted.Assisted
|
import dagger.assisted.Assisted
|
||||||
import dagger.assisted.AssistedFactory
|
import dagger.assisted.AssistedFactory
|
||||||
import dagger.assisted.AssistedInject
|
import dagger.assisted.AssistedInject
|
||||||
@@ -19,7 +18,8 @@ import org.koitharu.kotatsu.core.exceptions.ProxyConfigException
|
|||||||
import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException
|
import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException
|
||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
import org.koitharu.kotatsu.core.ui.dialog.ErrorDetailsDialog
|
import org.koitharu.kotatsu.core.ui.dialog.ErrorDetailsDialog
|
||||||
import org.koitharu.kotatsu.core.util.ext.findActivity
|
import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.restartApplication
|
||||||
import org.koitharu.kotatsu.parsers.exception.AuthRequiredException
|
import org.koitharu.kotatsu.parsers.exception.AuthRequiredException
|
||||||
import org.koitharu.kotatsu.parsers.exception.NotFoundException
|
import org.koitharu.kotatsu.parsers.exception.NotFoundException
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
@@ -124,15 +124,16 @@ class ExceptionResolver @AssistedInject constructor(
|
|||||||
Toast.makeText(ctx, R.string.operation_not_supported, Toast.LENGTH_SHORT).show()
|
Toast.makeText(ctx, R.string.operation_not_supported, Toast.LENGTH_SHORT).show()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
MaterialAlertDialogBuilder(ctx)
|
buildAlertDialog(ctx) {
|
||||||
.setTitle(R.string.ignore_ssl_errors)
|
setTitle(R.string.ignore_ssl_errors)
|
||||||
.setMessage(R.string.ignore_ssl_errors_summary)
|
setMessage(R.string.ignore_ssl_errors_summary)
|
||||||
.setPositiveButton(R.string.apply) { _, _ ->
|
setPositiveButton(R.string.apply) { _, _ ->
|
||||||
settings.isSSLBypassEnabled = true
|
settings.isSSLBypassEnabled = true
|
||||||
Toast.makeText(ctx, R.string.settings_apply_restart_required, Toast.LENGTH_SHORT).show()
|
Toast.makeText(ctx, R.string.settings_apply_restart_required, Toast.LENGTH_LONG).show()
|
||||||
ctx.findActivity()?.finishAffinity()
|
ctx.restartApplication()
|
||||||
}.setNegativeButton(android.R.string.cancel, null)
|
}
|
||||||
.show()
|
setNegativeButton(android.R.string.cancel, null)
|
||||||
|
}.show()
|
||||||
}
|
}
|
||||||
|
|
||||||
private inline fun Host.withContext(block: Context.() -> Unit) {
|
private inline fun Host.withContext(block: Context.() -> Unit) {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import com.google.android.material.snackbar.Snackbar
|
|||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.core.ui.dialog.ErrorDetailsDialog
|
import org.koitharu.kotatsu.core.ui.dialog.ErrorDetailsDialog
|
||||||
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.isSerializable
|
||||||
import org.koitharu.kotatsu.main.ui.owners.BottomNavOwner
|
import org.koitharu.kotatsu.main.ui.owners.BottomNavOwner
|
||||||
import org.koitharu.kotatsu.parsers.exception.ParseException
|
import org.koitharu.kotatsu.parsers.exception.ParseException
|
||||||
|
|
||||||
@@ -33,7 +34,7 @@ class SnackbarErrorObserver(
|
|||||||
}
|
}
|
||||||
} else if (value is ParseException) {
|
} else if (value is ParseException) {
|
||||||
val fm = fragmentManager
|
val fm = fragmentManager
|
||||||
if (fm != null) {
|
if (fm != null && value.isSerializable()) {
|
||||||
snackbar.setAction(R.string.details) {
|
snackbar.setAction(R.string.details) {
|
||||||
ErrorDetailsDialog.show(fm, value, value.url)
|
ErrorDetailsDialog.show(fm, value, value.url)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,31 @@
|
|||||||
package org.koitharu.kotatsu.core.fs
|
package org.koitharu.kotatsu.core.fs
|
||||||
|
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import org.koitharu.kotatsu.core.util.iterator.CloseableIterator
|
import androidx.annotation.RequiresApi
|
||||||
|
import org.koitharu.kotatsu.core.util.CloseableSequence
|
||||||
import org.koitharu.kotatsu.core.util.iterator.MappingIterator
|
import org.koitharu.kotatsu.core.util.iterator.MappingIterator
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.nio.file.Files
|
import java.nio.file.Files
|
||||||
import java.nio.file.Path
|
import java.nio.file.Path
|
||||||
|
|
||||||
class FileSequence(private val dir: File) : Sequence<File> {
|
sealed interface FileSequence : CloseableSequence<File> {
|
||||||
|
|
||||||
override fun iterator(): Iterator<File> {
|
@RequiresApi(Build.VERSION_CODES.O)
|
||||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
class StreamImpl(dir: File) : FileSequence {
|
||||||
val stream = Files.newDirectoryStream(dir.toPath())
|
|
||||||
CloseableIterator(MappingIterator(stream.iterator(), Path::toFile), stream)
|
private val stream = Files.newDirectoryStream(dir.toPath())
|
||||||
} else {
|
|
||||||
dir.listFiles().orEmpty().iterator()
|
override fun iterator(): Iterator<File> = MappingIterator(stream.iterator(), Path::toFile)
|
||||||
}
|
|
||||||
|
override fun close() = stream.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
class ListImpl(dir: File) : FileSequence {
|
||||||
|
|
||||||
|
private val list = dir.listFiles().orEmpty()
|
||||||
|
|
||||||
|
override fun iterator(): Iterator<File> = list.iterator()
|
||||||
|
|
||||||
|
override fun close() = Unit
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package org.koitharu.kotatsu.core.image
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import androidx.core.graphics.drawable.toDrawable
|
||||||
|
import coil.ImageLoader
|
||||||
|
import coil.decode.DecodeResult
|
||||||
|
import coil.decode.Decoder
|
||||||
|
import coil.decode.ImageSource
|
||||||
|
import coil.fetch.SourceResult
|
||||||
|
import coil.request.Options
|
||||||
|
import com.davemorrissey.labs.subscaleview.decoder.ImageDecodeException
|
||||||
|
import kotlinx.coroutines.sync.Semaphore
|
||||||
|
import org.aomedia.avif.android.AvifDecoder
|
||||||
|
import org.aomedia.avif.android.AvifDecoder.Info
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.toByteBuffer
|
||||||
|
|
||||||
|
class AvifImageDecoder(source: ImageSource, options: Options, parallelismLock: Semaphore) :
|
||||||
|
BaseCoilDecoder(source, options, parallelismLock) {
|
||||||
|
|
||||||
|
override fun BitmapFactory.Options.decode(): DecodeResult {
|
||||||
|
val bytes = source.source().use {
|
||||||
|
it.inputStream().toByteBuffer()
|
||||||
|
}
|
||||||
|
val info = Info()
|
||||||
|
if (!AvifDecoder.getInfo(bytes, bytes.remaining(), info)) {
|
||||||
|
throw ImageDecodeException(
|
||||||
|
null,
|
||||||
|
"avif",
|
||||||
|
"Requested to decode byte buffer which cannot be handled by AvifDecoder",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val config = if (info.depth == 8 || info.alphaPresent) Bitmap.Config.ARGB_8888 else Bitmap.Config.RGB_565
|
||||||
|
val bitmap = Bitmap.createBitmap(info.width, info.height, config)
|
||||||
|
if (!AvifDecoder.decode(bytes, bytes.remaining(), bitmap)) {
|
||||||
|
bitmap.recycle()
|
||||||
|
throw ImageDecodeException(null, "avif")
|
||||||
|
}
|
||||||
|
return DecodeResult(
|
||||||
|
drawable = bitmap.toDrawable(options.context.resources),
|
||||||
|
isSampled = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
class Factory : Decoder.Factory {
|
||||||
|
|
||||||
|
private val parallelismLock = Semaphore(DEFAULT_PARALLELISM)
|
||||||
|
|
||||||
|
override fun create(
|
||||||
|
result: SourceResult,
|
||||||
|
options: Options,
|
||||||
|
imageLoader: ImageLoader
|
||||||
|
): Decoder? = if (isApplicable(result)) {
|
||||||
|
AvifImageDecoder(result.source, options, parallelismLock)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun equals(other: Any?) = other is Factory
|
||||||
|
|
||||||
|
override fun hashCode() = javaClass.hashCode()
|
||||||
|
|
||||||
|
private fun isApplicable(result: SourceResult): Boolean {
|
||||||
|
return result.mimeType == "image/avif"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package org.koitharu.kotatsu.core.image
|
||||||
|
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import coil.decode.DecodeResult
|
||||||
|
import coil.decode.Decoder
|
||||||
|
import coil.decode.ImageSource
|
||||||
|
import coil.request.Options
|
||||||
|
import coil.size.Dimension
|
||||||
|
import coil.size.Scale
|
||||||
|
import coil.size.Size
|
||||||
|
import coil.size.isOriginal
|
||||||
|
import coil.size.pxOrElse
|
||||||
|
import kotlinx.coroutines.runInterruptible
|
||||||
|
import kotlinx.coroutines.sync.Semaphore
|
||||||
|
import kotlinx.coroutines.sync.withPermit
|
||||||
|
import org.jetbrains.annotations.Blocking
|
||||||
|
|
||||||
|
abstract class BaseCoilDecoder(
|
||||||
|
protected val source: ImageSource,
|
||||||
|
protected val options: Options,
|
||||||
|
private val parallelismLock: Semaphore,
|
||||||
|
) : Decoder {
|
||||||
|
|
||||||
|
final override suspend fun decode(): DecodeResult = parallelismLock.withPermit {
|
||||||
|
runInterruptible { BitmapFactory.Options().decode() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Blocking
|
||||||
|
protected abstract fun BitmapFactory.Options.decode(): DecodeResult
|
||||||
|
|
||||||
|
protected companion object {
|
||||||
|
|
||||||
|
const val DEFAULT_PARALLELISM = 4
|
||||||
|
|
||||||
|
inline fun Size.widthPx(scale: Scale, original: () -> Int): Int {
|
||||||
|
return if (isOriginal) original() else width.toPx(scale)
|
||||||
|
}
|
||||||
|
|
||||||
|
inline fun Size.heightPx(scale: Scale, original: () -> Int): Int {
|
||||||
|
return if (isOriginal) original() else height.toPx(scale)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun Dimension.toPx(scale: Scale) = pxOrElse {
|
||||||
|
when (scale) {
|
||||||
|
Scale.FILL -> Int.MIN_VALUE
|
||||||
|
Scale.FIT -> Int.MAX_VALUE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package org.koitharu.kotatsu.core.image
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import android.graphics.ImageDecoder
|
||||||
|
import android.os.Build
|
||||||
|
import android.webkit.MimeTypeMap
|
||||||
|
import com.davemorrissey.labs.subscaleview.decoder.ImageDecodeException
|
||||||
|
import okhttp3.MediaType
|
||||||
|
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||||
|
import org.aomedia.avif.android.AvifDecoder
|
||||||
|
import org.aomedia.avif.android.AvifDecoder.Info
|
||||||
|
import org.jetbrains.annotations.Blocking
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.toByteBuffer
|
||||||
|
import java.io.File
|
||||||
|
import java.io.InputStream
|
||||||
|
import java.nio.ByteBuffer
|
||||||
|
import java.nio.file.Files
|
||||||
|
|
||||||
|
object BitmapDecoderCompat {
|
||||||
|
|
||||||
|
private const val FORMAT_AVIF = "avif"
|
||||||
|
|
||||||
|
@Blocking
|
||||||
|
fun decode(file: File): Bitmap = when (val format = getMimeType(file)?.subtype) {
|
||||||
|
FORMAT_AVIF -> file.inputStream().use { decodeAvif(it.toByteBuffer()) }
|
||||||
|
else -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||||
|
ImageDecoder.decodeBitmap(ImageDecoder.createSource(file))
|
||||||
|
} else {
|
||||||
|
checkBitmapNotNull(BitmapFactory.decodeFile(file.absolutePath), format)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Blocking
|
||||||
|
fun decode(stream: InputStream, type: MediaType?): Bitmap {
|
||||||
|
val format = type?.subtype
|
||||||
|
if (format == FORMAT_AVIF) {
|
||||||
|
return decodeAvif(stream.toByteBuffer())
|
||||||
|
}
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) {
|
||||||
|
return checkBitmapNotNull(BitmapFactory.decodeStream(stream), format)
|
||||||
|
}
|
||||||
|
val byteBuffer = stream.toByteBuffer()
|
||||||
|
return if (AvifDecoder.isAvifImage(byteBuffer)) {
|
||||||
|
decodeAvif(byteBuffer)
|
||||||
|
} else {
|
||||||
|
ImageDecoder.decodeBitmap(ImageDecoder.createSource(byteBuffer))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getMimeType(file: File): MediaType? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
Files.probeContentType(file.toPath())?.toMediaTypeOrNull()
|
||||||
|
} else {
|
||||||
|
MimeTypeMap.getSingleton().getMimeTypeFromExtension(file.extension)?.toMediaTypeOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun checkBitmapNotNull(bitmap: Bitmap?, format: String?): Bitmap =
|
||||||
|
bitmap ?: throw ImageDecodeException(null, format)
|
||||||
|
|
||||||
|
private fun decodeAvif(bytes: ByteBuffer): Bitmap {
|
||||||
|
val info = Info()
|
||||||
|
if (!AvifDecoder.getInfo(bytes, bytes.remaining(), info)) {
|
||||||
|
throw ImageDecodeException(
|
||||||
|
null,
|
||||||
|
FORMAT_AVIF,
|
||||||
|
"Requested to decode byte buffer which cannot be handled by AvifDecoder",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val config = if (info.depth == 8 || info.alphaPresent) Bitmap.Config.ARGB_8888 else Bitmap.Config.RGB_565
|
||||||
|
val bitmap = Bitmap.createBitmap(info.width, info.height, config)
|
||||||
|
if (!AvifDecoder.decode(bytes, bytes.remaining(), bitmap)) {
|
||||||
|
bitmap.recycle()
|
||||||
|
throw ImageDecodeException(null, FORMAT_AVIF)
|
||||||
|
}
|
||||||
|
return bitmap
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package org.koitharu.kotatsu.core.ui.image
|
package org.koitharu.kotatsu.core.image
|
||||||
|
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.graphics.BitmapFactory
|
import android.graphics.BitmapFactory
|
||||||
@@ -13,27 +13,14 @@ import coil.decode.Decoder
|
|||||||
import coil.decode.ImageSource
|
import coil.decode.ImageSource
|
||||||
import coil.fetch.SourceResult
|
import coil.fetch.SourceResult
|
||||||
import coil.request.Options
|
import coil.request.Options
|
||||||
import coil.size.Dimension
|
|
||||||
import coil.size.Scale
|
|
||||||
import coil.size.Size
|
|
||||||
import coil.size.isOriginal
|
|
||||||
import coil.size.pxOrElse
|
|
||||||
import kotlinx.coroutines.runInterruptible
|
|
||||||
import kotlinx.coroutines.sync.Semaphore
|
import kotlinx.coroutines.sync.Semaphore
|
||||||
import kotlinx.coroutines.sync.withPermit
|
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
class RegionBitmapDecoder(
|
class RegionBitmapDecoder(
|
||||||
private val source: ImageSource,
|
source: ImageSource, options: Options, parallelismLock: Semaphore
|
||||||
private val options: Options,
|
) : BaseCoilDecoder(source, options, parallelismLock) {
|
||||||
private val parallelismLock: Semaphore,
|
|
||||||
) : Decoder {
|
|
||||||
|
|
||||||
override suspend fun decode() = parallelismLock.withPermit {
|
override fun BitmapFactory.Options.decode(): DecodeResult {
|
||||||
runInterruptible { BitmapFactory.Options().decode() }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun BitmapFactory.Options.decode(): DecodeResult {
|
|
||||||
val regionDecoder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
val regionDecoder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
BitmapRegionDecoder.newInstance(source.source().inputStream())
|
BitmapRegionDecoder.newInstance(source.source().inputStream())
|
||||||
} else {
|
} else {
|
||||||
@@ -55,29 +42,6 @@ class RegionBitmapDecoder(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun BitmapFactory.Options.configureConfig() {
|
|
||||||
var config = options.config
|
|
||||||
|
|
||||||
inMutable = false
|
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= 26 && options.colorSpace != null) {
|
|
||||||
inPreferredColorSpace = options.colorSpace
|
|
||||||
}
|
|
||||||
inPremultiplied = options.premultipliedAlpha
|
|
||||||
|
|
||||||
// Decode the image as RGB_565 as an optimization if allowed.
|
|
||||||
if (options.allowRgb565 && config == Bitmap.Config.ARGB_8888 && outMimeType == "image/jpeg") {
|
|
||||||
config = Bitmap.Config.RGB_565
|
|
||||||
}
|
|
||||||
|
|
||||||
// High color depth images must be decoded as either RGBA_F16 or HARDWARE.
|
|
||||||
if (Build.VERSION.SDK_INT >= 26 && outConfig == Bitmap.Config.RGBA_F16 && config != Bitmap.Config.HARDWARE) {
|
|
||||||
config = Bitmap.Config.RGBA_F16
|
|
||||||
}
|
|
||||||
|
|
||||||
inPreferredConfig = config
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Compute and set the scaling properties for [BitmapFactory.Options]. */
|
/** Compute and set the scaling properties for [BitmapFactory.Options]. */
|
||||||
private fun BitmapFactory.Options.configureScale(srcWidth: Int, srcHeight: Int): Rect {
|
private fun BitmapFactory.Options.configureScale(srcWidth: Int, srcHeight: Int): Rect {
|
||||||
val dstWidth = options.size.widthPx(options.scale) { srcWidth }
|
val dstWidth = options.size.widthPx(options.scale) { srcWidth }
|
||||||
@@ -142,19 +106,38 @@ class RegionBitmapDecoder(
|
|||||||
return rect
|
return rect
|
||||||
}
|
}
|
||||||
|
|
||||||
class Factory(
|
private fun BitmapFactory.Options.configureConfig() {
|
||||||
maxParallelism: Int = DEFAULT_MAX_PARALLELISM,
|
var config = options.config
|
||||||
) : Decoder.Factory {
|
|
||||||
|
|
||||||
@Suppress("NEWER_VERSION_IN_SINCE_KOTLIN")
|
inMutable = false
|
||||||
@SinceKotlin("999.9") // Only public in Java.
|
|
||||||
constructor() : this()
|
|
||||||
|
|
||||||
private val parallelismLock = Semaphore(maxParallelism)
|
if (Build.VERSION.SDK_INT >= 26 && options.colorSpace != null) {
|
||||||
|
inPreferredColorSpace = options.colorSpace
|
||||||
override fun create(result: SourceResult, options: Options, imageLoader: ImageLoader): Decoder {
|
|
||||||
return RegionBitmapDecoder(result.source, options, parallelismLock)
|
|
||||||
}
|
}
|
||||||
|
inPremultiplied = options.premultipliedAlpha
|
||||||
|
|
||||||
|
// Decode the image as RGB_565 as an optimization if allowed.
|
||||||
|
if (options.allowRgb565 && config == Bitmap.Config.ARGB_8888 && outMimeType == "image/jpeg") {
|
||||||
|
config = Bitmap.Config.RGB_565
|
||||||
|
}
|
||||||
|
|
||||||
|
// High color depth images must be decoded as either RGBA_F16 or HARDWARE.
|
||||||
|
if (Build.VERSION.SDK_INT >= 26 && outConfig == Bitmap.Config.RGBA_F16 && config != Bitmap.Config.HARDWARE) {
|
||||||
|
config = Bitmap.Config.RGBA_F16
|
||||||
|
}
|
||||||
|
|
||||||
|
inPreferredConfig = config
|
||||||
|
}
|
||||||
|
|
||||||
|
object Factory : Decoder.Factory {
|
||||||
|
|
||||||
|
private val parallelismLock = Semaphore(DEFAULT_PARALLELISM)
|
||||||
|
|
||||||
|
override fun create(
|
||||||
|
result: SourceResult,
|
||||||
|
options: Options,
|
||||||
|
imageLoader: ImageLoader
|
||||||
|
): Decoder = RegionBitmapDecoder(result.source, options, parallelismLock)
|
||||||
|
|
||||||
override fun equals(other: Any?) = other is Factory
|
override fun equals(other: Any?) = other is Factory
|
||||||
|
|
||||||
@@ -165,21 +148,5 @@ class RegionBitmapDecoder(
|
|||||||
|
|
||||||
const val PARAM_SCROLL = "scroll"
|
const val PARAM_SCROLL = "scroll"
|
||||||
const val SCROLL_UNDEFINED = -1
|
const val SCROLL_UNDEFINED = -1
|
||||||
private const val DEFAULT_MAX_PARALLELISM = 4
|
|
||||||
|
|
||||||
private inline fun Size.widthPx(scale: Scale, original: () -> Int): Int {
|
|
||||||
return if (isOriginal) original() else width.toPx(scale)
|
|
||||||
}
|
|
||||||
|
|
||||||
private inline fun Size.heightPx(scale: Scale, original: () -> Int): Int {
|
|
||||||
return if (isOriginal) original() else height.toPx(scale)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun Dimension.toPx(scale: Scale) = pxOrElse {
|
|
||||||
when (scale) {
|
|
||||||
Scale.FILL -> Int.MIN_VALUE
|
|
||||||
Scale.FIT -> Int.MAX_VALUE
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package org.koitharu.kotatsu.core.io
|
||||||
|
|
||||||
|
import java.io.OutputStream
|
||||||
|
import java.util.Objects
|
||||||
|
|
||||||
|
class NullOutputStream : OutputStream() {
|
||||||
|
|
||||||
|
override fun write(b: Int) = Unit
|
||||||
|
|
||||||
|
override fun write(b: ByteArray, off: Int, len: Int) {
|
||||||
|
Objects.checkFromIndexSize(off, len, b.size)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,148 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.logs
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.annotation.WorkerThread
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.NonCancellable
|
|
||||||
import kotlinx.coroutines.cancelAndJoin
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.runBlocking
|
|
||||||
import kotlinx.coroutines.runInterruptible
|
|
||||||
import kotlinx.coroutines.sync.Mutex
|
|
||||||
import kotlinx.coroutines.sync.withLock
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.subdir
|
|
||||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
|
||||||
import java.io.File
|
|
||||||
import java.io.FileOutputStream
|
|
||||||
import java.time.LocalDateTime
|
|
||||||
import java.time.format.DateTimeFormatter
|
|
||||||
import java.time.format.FormatStyle
|
|
||||||
import java.util.Locale
|
|
||||||
import java.util.concurrent.ConcurrentLinkedQueue
|
|
||||||
|
|
||||||
private const val DIR = "logs"
|
|
||||||
private const val FLUSH_DELAY = 2_000L
|
|
||||||
private const val MAX_SIZE_BYTES = 1024 * 1024 // 1 MB
|
|
||||||
|
|
||||||
class FileLogger(
|
|
||||||
context: Context,
|
|
||||||
private val settings: AppSettings,
|
|
||||||
name: String,
|
|
||||||
) {
|
|
||||||
|
|
||||||
val file by lazy {
|
|
||||||
val dir = context.getExternalFilesDir(DIR) ?: context.filesDir.subdir(DIR)
|
|
||||||
File(dir, "$name.log")
|
|
||||||
}
|
|
||||||
val isEnabled: Boolean
|
|
||||||
get() = settings.isLoggingEnabled
|
|
||||||
private val dateTimeFormatter = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT).withLocale(Locale.ROOT)
|
|
||||||
private val buffer = ConcurrentLinkedQueue<String>()
|
|
||||||
private val mutex = Mutex()
|
|
||||||
private var flushJob: Job? = null
|
|
||||||
|
|
||||||
fun log(message: String, e: Throwable? = null) {
|
|
||||||
if (!isEnabled) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val text = buildString {
|
|
||||||
append(dateTimeFormatter.format(LocalDateTime.now()))
|
|
||||||
append(": ")
|
|
||||||
if (e != null) {
|
|
||||||
append("E!")
|
|
||||||
}
|
|
||||||
append(message)
|
|
||||||
if (e != null) {
|
|
||||||
append(' ')
|
|
||||||
append(e.stackTraceToString())
|
|
||||||
appendLine()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
buffer.add(text)
|
|
||||||
postFlush()
|
|
||||||
}
|
|
||||||
|
|
||||||
inline fun log(messageProducer: () -> String) {
|
|
||||||
if (isEnabled) {
|
|
||||||
log(messageProducer())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun flush() {
|
|
||||||
if (!isEnabled) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
flushJob?.cancelAndJoin()
|
|
||||||
flushImpl()
|
|
||||||
}
|
|
||||||
|
|
||||||
@WorkerThread
|
|
||||||
fun flushBlocking() {
|
|
||||||
if (!isEnabled) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
runBlockingSafe { flushJob?.cancelAndJoin() }
|
|
||||||
runBlockingSafe { flushImpl() }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun postFlush() {
|
|
||||||
if (flushJob?.isActive == true) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
flushJob = processLifecycleScope.launch(Dispatchers.Default) {
|
|
||||||
delay(FLUSH_DELAY)
|
|
||||||
runCatchingCancellable {
|
|
||||||
flushImpl()
|
|
||||||
}.onFailure {
|
|
||||||
it.printStackTraceDebug()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun flushImpl() = withContext(NonCancellable) {
|
|
||||||
mutex.withLock {
|
|
||||||
if (buffer.isEmpty()) {
|
|
||||||
return@withContext
|
|
||||||
}
|
|
||||||
runInterruptible(Dispatchers.IO) {
|
|
||||||
if (file.length() > MAX_SIZE_BYTES) {
|
|
||||||
rotate()
|
|
||||||
}
|
|
||||||
FileOutputStream(file, true).use {
|
|
||||||
while (true) {
|
|
||||||
val message = buffer.poll() ?: break
|
|
||||||
it.write(message.toByteArray())
|
|
||||||
it.write('\n'.code)
|
|
||||||
}
|
|
||||||
it.flush()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@WorkerThread
|
|
||||||
private fun rotate() {
|
|
||||||
val length = file.length()
|
|
||||||
val bakFile = File(file.parentFile, file.name + ".bak")
|
|
||||||
file.renameTo(bakFile)
|
|
||||||
bakFile.inputStream().use { input ->
|
|
||||||
input.skip(length - MAX_SIZE_BYTES / 2)
|
|
||||||
file.outputStream().use { output ->
|
|
||||||
input.copyTo(output)
|
|
||||||
output.flush()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
bakFile.delete()
|
|
||||||
}
|
|
||||||
|
|
||||||
private inline fun runBlockingSafe(crossinline block: suspend () -> Unit) = try {
|
|
||||||
runBlocking(NonCancellable) { block() }
|
|
||||||
} catch (_: InterruptedException) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.logs
|
|
||||||
|
|
||||||
import javax.inject.Qualifier
|
|
||||||
|
|
||||||
@Qualifier
|
|
||||||
@Retention(AnnotationRetention.BINARY)
|
|
||||||
annotation class TrackerLogger
|
|
||||||
|
|
||||||
@Qualifier
|
|
||||||
@Retention(AnnotationRetention.BINARY)
|
|
||||||
annotation class SyncLogger
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.logs
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.collection.arraySetOf
|
|
||||||
import dagger.Module
|
|
||||||
import dagger.Provides
|
|
||||||
import dagger.hilt.InstallIn
|
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
|
||||||
import dagger.hilt.components.SingletonComponent
|
|
||||||
import dagger.multibindings.ElementsIntoSet
|
|
||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
|
||||||
|
|
||||||
@Module
|
|
||||||
@InstallIn(SingletonComponent::class)
|
|
||||||
object LoggersModule {
|
|
||||||
|
|
||||||
@Provides
|
|
||||||
@TrackerLogger
|
|
||||||
fun provideTrackerLogger(
|
|
||||||
@ApplicationContext context: Context,
|
|
||||||
settings: AppSettings,
|
|
||||||
) = FileLogger(context, settings, "tracker")
|
|
||||||
|
|
||||||
@Provides
|
|
||||||
@SyncLogger
|
|
||||||
fun provideSyncLogger(
|
|
||||||
@ApplicationContext context: Context,
|
|
||||||
settings: AppSettings,
|
|
||||||
) = FileLogger(context, settings, "sync")
|
|
||||||
|
|
||||||
@Provides
|
|
||||||
@ElementsIntoSet
|
|
||||||
fun provideAllLoggers(
|
|
||||||
@TrackerLogger trackerLogger: FileLogger,
|
|
||||||
@SyncLogger syncLogger: FileLogger,
|
|
||||||
): Set<@JvmSuppressWildcards FileLogger> = arraySetOf(
|
|
||||||
trackerLogger,
|
|
||||||
syncLogger,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -4,6 +4,7 @@ import androidx.annotation.StringRes
|
|||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||||
|
|
||||||
|
@Deprecated("")
|
||||||
enum class GenericSortOrder(
|
enum class GenericSortOrder(
|
||||||
@StringRes val titleResId: Int,
|
@StringRes val titleResId: Int,
|
||||||
val ascending: SortOrder,
|
val ascending: SortOrder,
|
||||||
|
|||||||
@@ -1,16 +1,21 @@
|
|||||||
package org.koitharu.kotatsu.core.model
|
package org.koitharu.kotatsu.core.model
|
||||||
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import android.text.SpannableStringBuilder
|
||||||
import androidx.annotation.DrawableRes
|
import androidx.annotation.DrawableRes
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
import androidx.collection.MutableObjectIntMap
|
import androidx.collection.MutableObjectIntMap
|
||||||
import androidx.core.os.LocaleListCompat
|
import androidx.core.os.LocaleListCompat
|
||||||
|
import androidx.core.text.buildSpannedString
|
||||||
|
import androidx.core.text.strikeThrough
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.core.util.ext.iterator
|
import org.koitharu.kotatsu.core.util.ext.iterator
|
||||||
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
|
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
|
||||||
import org.koitharu.kotatsu.parsers.model.ContentRating
|
import org.koitharu.kotatsu.parsers.model.ContentRating
|
||||||
|
import org.koitharu.kotatsu.parsers.model.Demographic
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaState
|
import org.koitharu.kotatsu.parsers.model.MangaState
|
||||||
import org.koitharu.kotatsu.parsers.util.formatSimple
|
import org.koitharu.kotatsu.parsers.util.formatSimple
|
||||||
import org.koitharu.kotatsu.parsers.util.mapToSet
|
import org.koitharu.kotatsu.parsers.util.mapToSet
|
||||||
@@ -68,6 +73,17 @@ val ContentRating.titleResId: Int
|
|||||||
ContentRating.ADULT -> R.string.rating_adult
|
ContentRating.ADULT -> R.string.rating_adult
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@get:StringRes
|
||||||
|
val Demographic.titleResId: Int
|
||||||
|
get() = when (this) {
|
||||||
|
Demographic.SHOUNEN -> R.string.demographic_shounen
|
||||||
|
Demographic.SHOUJO -> R.string.demographic_shoujo
|
||||||
|
Demographic.SEINEN -> R.string.demographic_seinen
|
||||||
|
Demographic.JOSEI -> R.string.demographic_josei
|
||||||
|
Demographic.KODOMO -> R.string.demographic_kodomo
|
||||||
|
Demographic.NONE -> R.string.none
|
||||||
|
}
|
||||||
|
|
||||||
fun Manga.findChapter(id: Long): MangaChapter? {
|
fun Manga.findChapter(id: Long): MangaChapter? {
|
||||||
return chapters?.findById(id)
|
return chapters?.findById(id)
|
||||||
}
|
}
|
||||||
@@ -110,6 +126,9 @@ fun Manga.getPreferredBranch(history: MangaHistory?): String? {
|
|||||||
val Manga.isLocal: Boolean
|
val Manga.isLocal: Boolean
|
||||||
get() = source == LocalMangaSource
|
get() = source == LocalMangaSource
|
||||||
|
|
||||||
|
val Manga.isBroken: Boolean
|
||||||
|
get() = source == UnknownMangaSource
|
||||||
|
|
||||||
val Manga.appUrl: Uri
|
val Manga.appUrl: Uri
|
||||||
get() = Uri.parse("https://kotatsu.app/manga").buildUpon()
|
get() = Uri.parse("https://kotatsu.app/manga").buildUpon()
|
||||||
.appendQueryParameter("source", source.name)
|
.appendQueryParameter("source", source.name)
|
||||||
@@ -138,3 +157,26 @@ fun Manga.chaptersCount(): Int {
|
|||||||
}
|
}
|
||||||
return max
|
return max
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun MangaListFilter.getSummary() = buildSpannedString {
|
||||||
|
if (!query.isNullOrEmpty()) {
|
||||||
|
append(query)
|
||||||
|
if (tags.isNotEmpty() || tagsExclude.isNotEmpty()) {
|
||||||
|
append(' ')
|
||||||
|
append('(')
|
||||||
|
appendTagsSummary(this@getSummary)
|
||||||
|
append(')')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
appendTagsSummary(this@getSummary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun SpannableStringBuilder.appendTagsSummary(filter: MangaListFilter) {
|
||||||
|
filter.tags.joinTo(this) { it.title }
|
||||||
|
if (filter.tagsExclude.isNotEmpty()) {
|
||||||
|
strikeThrough {
|
||||||
|
filter.tagsExclude.joinTo(this) { it.title }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,4 +12,5 @@ data class MangaHistory(
|
|||||||
val page: Int,
|
val page: Int,
|
||||||
val scroll: Int,
|
val scroll: Int,
|
||||||
val percent: Float,
|
val percent: Float,
|
||||||
|
val chaptersCount: Int,
|
||||||
) : Parcelable
|
) : Parcelable
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ fun MangaSource(name: String?): MangaSource {
|
|||||||
return UnknownMangaSource
|
return UnknownMangaSource
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun Collection<String>.toMangaSources() = map(::MangaSource)
|
||||||
|
|
||||||
fun MangaSource.isNsfw(): Boolean = when (this) {
|
fun MangaSource.isNsfw(): Boolean = when (this) {
|
||||||
is MangaSourceInfo -> mangaSource.isNsfw()
|
is MangaSourceInfo -> mangaSource.isNsfw()
|
||||||
is MangaParserSource -> contentType == ContentType.HENTAI
|
is MangaParserSource -> contentType == ContentType.HENTAI
|
||||||
@@ -56,13 +58,26 @@ val ContentType.titleResId
|
|||||||
ContentType.HENTAI -> R.string.content_type_hentai
|
ContentType.HENTAI -> R.string.content_type_hentai
|
||||||
ContentType.COMICS -> R.string.content_type_comics
|
ContentType.COMICS -> R.string.content_type_comics
|
||||||
ContentType.OTHER -> R.string.content_type_other
|
ContentType.OTHER -> R.string.content_type_other
|
||||||
|
ContentType.MANHWA -> R.string.content_type_manhwa
|
||||||
|
ContentType.MANHUA -> R.string.content_type_manhua
|
||||||
|
ContentType.NOVEL -> R.string.content_type_novel
|
||||||
|
ContentType.ONE_SHOT -> R.string.content_type_one_shot
|
||||||
|
ContentType.DOUJINSHI -> R.string.content_type_doujinshi
|
||||||
|
ContentType.IMAGE_SET -> R.string.content_type_image_set
|
||||||
|
ContentType.ARTIST_CG -> R.string.content_type_artist_cg
|
||||||
|
ContentType.GAME_CG -> R.string.content_type_game_cg
|
||||||
}
|
}
|
||||||
|
|
||||||
fun MangaSource.getSummary(context: Context): String? = when (this) {
|
tailrec fun MangaSource.unwrap(): MangaSource = if (this is MangaSourceInfo) {
|
||||||
is MangaSourceInfo -> mangaSource.getSummary(context)
|
mangaSource.unwrap()
|
||||||
|
} else {
|
||||||
|
this
|
||||||
|
}
|
||||||
|
|
||||||
|
fun MangaSource.getSummary(context: Context): String? = when (val source = unwrap()) {
|
||||||
is MangaParserSource -> {
|
is MangaParserSource -> {
|
||||||
val type = context.getString(contentType.titleResId)
|
val type = context.getString(source.contentType.titleResId)
|
||||||
val locale = locale.toLocale().getDisplayName(context)
|
val locale = source.locale.toLocale().getDisplayName(context)
|
||||||
context.getString(R.string.source_summary_pattern, type, locale)
|
context.getString(R.string.source_summary_pattern, type, locale)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,11 +86,10 @@ fun MangaSource.getSummary(context: Context): String? = when (this) {
|
|||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
|
|
||||||
fun MangaSource.getTitle(context: Context): String = when (this) {
|
fun MangaSource.getTitle(context: Context): String = when (val source = unwrap()) {
|
||||||
is MangaSourceInfo -> mangaSource.getTitle(context)
|
is MangaParserSource -> source.title
|
||||||
is MangaParserSource -> title
|
|
||||||
LocalMangaSource -> context.getString(R.string.local_storage)
|
LocalMangaSource -> context.getString(R.string.local_storage)
|
||||||
is ExternalMangaSource -> resolveName(context)
|
is ExternalMangaSource -> source.resolveName(context)
|
||||||
else -> context.getString(R.string.unknown)
|
else -> context.getString(R.string.unknown)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package org.koitharu.kotatsu.core.model.parcelable
|
||||||
|
|
||||||
|
import android.os.Parcel
|
||||||
|
import android.os.Parcelable
|
||||||
|
import kotlinx.parcelize.Parceler
|
||||||
|
import kotlinx.parcelize.Parcelize
|
||||||
|
import kotlinx.parcelize.TypeParceler
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.readEnumSet
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.readParcelableCompat
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.readSerializableCompat
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.writeEnumSet
|
||||||
|
import org.koitharu.kotatsu.parsers.model.ContentRating
|
||||||
|
import org.koitharu.kotatsu.parsers.model.ContentType
|
||||||
|
import org.koitharu.kotatsu.parsers.model.Demographic
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaState
|
||||||
|
|
||||||
|
object MangaListFilterParceler : Parceler<MangaListFilter> {
|
||||||
|
|
||||||
|
override fun MangaListFilter.write(parcel: Parcel, flags: Int) {
|
||||||
|
parcel.writeString(query)
|
||||||
|
parcel.writeParcelable(ParcelableMangaTags(tags), 0)
|
||||||
|
parcel.writeParcelable(ParcelableMangaTags(tagsExclude), 0)
|
||||||
|
parcel.writeSerializable(locale)
|
||||||
|
parcel.writeSerializable(originalLocale)
|
||||||
|
parcel.writeEnumSet(states)
|
||||||
|
parcel.writeEnumSet(contentRating)
|
||||||
|
parcel.writeEnumSet(types)
|
||||||
|
parcel.writeEnumSet(demographics)
|
||||||
|
parcel.writeInt(year)
|
||||||
|
parcel.writeInt(yearFrom)
|
||||||
|
parcel.writeInt(yearTo)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun create(parcel: Parcel) = MangaListFilter(
|
||||||
|
query = parcel.readString(),
|
||||||
|
tags = parcel.readParcelableCompat<ParcelableMangaTags>()?.tags.orEmpty(),
|
||||||
|
tagsExclude = parcel.readParcelableCompat<ParcelableMangaTags>()?.tags.orEmpty(),
|
||||||
|
locale = parcel.readSerializableCompat(),
|
||||||
|
originalLocale = parcel.readSerializableCompat(),
|
||||||
|
states = parcel.readEnumSet<MangaState>().orEmpty(),
|
||||||
|
contentRating = parcel.readEnumSet<ContentRating>().orEmpty(),
|
||||||
|
types = parcel.readEnumSet<ContentType>().orEmpty(),
|
||||||
|
demographics = parcel.readEnumSet<Demographic>().orEmpty(),
|
||||||
|
year = parcel.readInt(),
|
||||||
|
yearFrom = parcel.readInt(),
|
||||||
|
yearTo = parcel.readInt(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Parcelize
|
||||||
|
@TypeParceler<MangaListFilter, MangaListFilterParceler>
|
||||||
|
data class ParcelableMangaListFilter(val filter: MangaListFilter) : Parcelable
|
||||||
@@ -2,41 +2,43 @@ package org.koitharu.kotatsu.core.network
|
|||||||
|
|
||||||
import okhttp3.Interceptor
|
import okhttp3.Interceptor
|
||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
import okhttp3.internal.closeQuietly
|
import okio.IOException
|
||||||
import org.jsoup.Jsoup
|
|
||||||
import org.koitharu.kotatsu.core.exceptions.CloudFlareBlockedException
|
import org.koitharu.kotatsu.core.exceptions.CloudFlareBlockedException
|
||||||
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
|
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
import java.net.HttpURLConnection.HTTP_FORBIDDEN
|
import org.koitharu.kotatsu.parsers.network.CloudFlareHelper
|
||||||
import java.net.HttpURLConnection.HTTP_UNAVAILABLE
|
|
||||||
|
|
||||||
class CloudFlareInterceptor : Interceptor {
|
class CloudFlareInterceptor : Interceptor {
|
||||||
|
|
||||||
override fun intercept(chain: Interceptor.Chain): Response {
|
override fun intercept(chain: Interceptor.Chain): Response {
|
||||||
val response = chain.proceed(chain.request())
|
val request = chain.request()
|
||||||
if (response.code == HTTP_FORBIDDEN || response.code == HTTP_UNAVAILABLE) {
|
val response = chain.proceed(request)
|
||||||
val content = response.body?.let { response.peekBody(Long.MAX_VALUE) }?.byteStream()?.use {
|
return when (CloudFlareHelper.checkResponseForProtection(response)) {
|
||||||
Jsoup.parse(it, Charsets.UTF_8.name(), response.request.url.toString())
|
CloudFlareHelper.PROTECTION_BLOCKED -> response.closeThrowing(
|
||||||
} ?: return response
|
CloudFlareBlockedException(
|
||||||
val hasCaptcha = content.getElementById("challenge-error-title") != null
|
url = request.url.toString(),
|
||||||
val isBlocked = content.selectFirst("h2[data-translate=\"blocked_why_headline\"]") != null
|
source = request.tag(MangaSource::class.java),
|
||||||
if (hasCaptcha || isBlocked) {
|
),
|
||||||
val request = response.request
|
)
|
||||||
response.closeQuietly()
|
|
||||||
if (isBlocked) {
|
CloudFlareHelper.PROTECTION_CAPTCHA -> response.closeThrowing(
|
||||||
throw CloudFlareBlockedException(
|
CloudFlareProtectedException(
|
||||||
url = request.url.toString(),
|
url = request.url.toString(),
|
||||||
source = request.tag(MangaSource::class.java),
|
source = request.tag(MangaSource::class.java),
|
||||||
)
|
headers = request.headers,
|
||||||
} else {
|
),
|
||||||
throw CloudFlareProtectedException(
|
)
|
||||||
url = request.url.toString(),
|
|
||||||
source = request.tag(MangaSource::class.java),
|
else -> response
|
||||||
headers = request.headers,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return response
|
}
|
||||||
|
|
||||||
|
private fun Response.closeThrowing(error: IOException): Nothing {
|
||||||
|
try {
|
||||||
|
close()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
error.addSuppressed(e)
|
||||||
|
}
|
||||||
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import okhttp3.Request
|
|||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
import okio.IOException
|
import okio.IOException
|
||||||
import org.koitharu.kotatsu.BuildConfig
|
import org.koitharu.kotatsu.BuildConfig
|
||||||
|
import org.koitharu.kotatsu.core.model.UnknownMangaSource
|
||||||
import org.koitharu.kotatsu.core.parser.MangaLoaderContextImpl
|
import org.koitharu.kotatsu.core.parser.MangaLoaderContextImpl
|
||||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||||
import org.koitharu.kotatsu.core.parser.ParserMangaRepository
|
import org.koitharu.kotatsu.core.parser.ParserMangaRepository
|
||||||
@@ -29,13 +30,13 @@ class CommonHeadersInterceptor @Inject constructor(
|
|||||||
override fun intercept(chain: Chain): Response {
|
override fun intercept(chain: Chain): Response {
|
||||||
val request = chain.request()
|
val request = chain.request()
|
||||||
val source = request.tag(MangaSource::class.java)
|
val source = request.tag(MangaSource::class.java)
|
||||||
val repository = if (source != null) {
|
val repository = if (source == null || source == UnknownMangaSource) {
|
||||||
mangaRepositoryFactoryLazy.get().create(source) as? ParserMangaRepository
|
if (BuildConfig.DEBUG && source == null) {
|
||||||
} else {
|
|
||||||
if (BuildConfig.DEBUG) {
|
|
||||||
Log.w("Http", "Request without source tag: ${request.url}")
|
Log.w("Http", "Request without source tag: ${request.url}")
|
||||||
}
|
}
|
||||||
null
|
null
|
||||||
|
} else {
|
||||||
|
mangaRepositoryFactoryLazy.get().create(source) as? ParserMangaRepository
|
||||||
}
|
}
|
||||||
val headersBuilder = request.headers.newBuilder()
|
val headersBuilder = request.headers.newBuilder()
|
||||||
repository?.getRequestHeaders()?.let {
|
repository?.getRequestHeaders()?.let {
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ interface NetworkModule {
|
|||||||
if (settings.isSSLBypassEnabled) {
|
if (settings.isSSLBypassEnabled) {
|
||||||
disableCertificateVerification()
|
disableCertificateVerification()
|
||||||
} else {
|
} else {
|
||||||
installExtraCertsificates(contextProvider.get())
|
installExtraCertificates(contextProvider.get())
|
||||||
}
|
}
|
||||||
cache(cache)
|
cache(cache)
|
||||||
addInterceptor(GZipInterceptor())
|
addInterceptor(GZipInterceptor())
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ fun OkHttpClient.Builder.disableCertificateVerification() = also { builder ->
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun OkHttpClient.Builder.installExtraCertsificates(context: Context) = also { builder ->
|
fun OkHttpClient.Builder.installExtraCertificates(context: Context) = also { builder ->
|
||||||
val certificatesBuilder = HandshakeCertificates.Builder()
|
val certificatesBuilder = HandshakeCertificates.Builder()
|
||||||
.addPlatformTrustedCertificates()
|
.addPlatformTrustedCertificates()
|
||||||
val assets = context.assets.list("").orEmpty()
|
val assets = context.assets.list("").orEmpty()
|
||||||
|
|||||||
@@ -180,7 +180,7 @@ class AppShortcutManager @Inject constructor(
|
|||||||
.setLongLabel(title)
|
.setLongLabel(title)
|
||||||
.setIcon(icon)
|
.setIcon(icon)
|
||||||
.setLongLived(true)
|
.setLongLived(true)
|
||||||
.setIntent(MangaListActivity.newIntent(context, source))
|
.setIntent(MangaListActivity.newIntent(context, source, null))
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import android.graphics.Rect as AndroidRect
|
|||||||
|
|
||||||
class BitmapWrapper private constructor(
|
class BitmapWrapper private constructor(
|
||||||
private val androidBitmap: AndroidBitmap,
|
private val androidBitmap: AndroidBitmap,
|
||||||
) : Bitmap {
|
) : Bitmap, AutoCloseable {
|
||||||
|
|
||||||
private val canvas by lazy { Canvas(androidBitmap) } // is not always used, so initialized lazily
|
private val canvas by lazy { Canvas(androidBitmap) } // is not always used, so initialized lazily
|
||||||
|
|
||||||
@@ -24,17 +24,21 @@ class BitmapWrapper private constructor(
|
|||||||
canvas.drawBitmap(androidSourceBitmap, src.toAndroidRect(), dst.toAndroidRect(), null)
|
canvas.drawBitmap(androidSourceBitmap, src.toAndroidRect(), dst.toAndroidRect(), null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
androidBitmap.recycle()
|
||||||
|
}
|
||||||
|
|
||||||
fun compressTo(output: OutputStream) {
|
fun compressTo(output: OutputStream) {
|
||||||
androidBitmap.compress(AndroidBitmap.CompressFormat.PNG, 100, output)
|
androidBitmap.compress(AndroidBitmap.CompressFormat.PNG, 100, output)
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
||||||
fun create(width: Int, height: Int): Bitmap = BitmapWrapper(
|
fun create(width: Int, height: Int) = BitmapWrapper(
|
||||||
AndroidBitmap.createBitmap(width, height, AndroidBitmap.Config.ARGB_8888),
|
AndroidBitmap.createBitmap(width, height, AndroidBitmap.Config.ARGB_8888),
|
||||||
)
|
)
|
||||||
|
|
||||||
fun create(bitmap: AndroidBitmap): Bitmap = BitmapWrapper(
|
fun create(bitmap: AndroidBitmap) = BitmapWrapper(
|
||||||
if (bitmap.isMutable) bitmap else bitmap.copy(AndroidBitmap.Config.ARGB_8888, true),
|
if (bitmap.isMutable) bitmap else bitmap.copy(AndroidBitmap.Config.ARGB_8888, true),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -7,9 +7,10 @@ import org.koitharu.kotatsu.parsers.config.ConfigKey
|
|||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaListFilterCapabilities
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaListFilterOptions
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaParserSource
|
import org.koitharu.kotatsu.parsers.model.MangaParserSource
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
|
||||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||||
import java.util.EnumSet
|
import java.util.EnumSet
|
||||||
|
|
||||||
@@ -24,14 +25,17 @@ class DummyParser(context: MangaLoaderContext) : MangaParser(context, MangaParse
|
|||||||
override val availableSortOrders: Set<SortOrder>
|
override val availableSortOrders: Set<SortOrder>
|
||||||
get() = EnumSet.allOf(SortOrder::class.java)
|
get() = EnumSet.allOf(SortOrder::class.java)
|
||||||
|
|
||||||
|
override val filterCapabilities: MangaListFilterCapabilities
|
||||||
|
get() = MangaListFilterCapabilities()
|
||||||
|
|
||||||
|
override suspend fun getFilterOptions(): MangaListFilterOptions = stub(null)
|
||||||
|
|
||||||
override suspend fun getDetails(manga: Manga): Manga = stub(manga)
|
override suspend fun getDetails(manga: Manga): Manga = stub(manga)
|
||||||
|
|
||||||
override suspend fun getList(offset: Int, filter: MangaListFilter?): List<Manga> = stub(null)
|
override suspend fun getList(offset: Int, order: SortOrder, filter: MangaListFilter): List<Manga> = stub(null)
|
||||||
|
|
||||||
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> = stub(null)
|
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> = stub(null)
|
||||||
|
|
||||||
override suspend fun getAvailableTags(): Set<MangaTag> = stub(null)
|
|
||||||
|
|
||||||
private fun stub(manga: Manga?): Nothing {
|
private fun stub(manga: Manga?): Nothing {
|
||||||
throw UnsupportedSourceException("Usage of Dummy parser", manga)
|
throw UnsupportedSourceException("Usage of Dummy parser", manga)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,37 +1,29 @@
|
|||||||
package org.koitharu.kotatsu.core.parser
|
package org.koitharu.kotatsu.core.parser
|
||||||
|
|
||||||
import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException
|
import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException
|
||||||
import org.koitharu.kotatsu.parsers.model.ContentRating
|
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaListFilterCapabilities
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaListFilterOptions
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaState
|
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
|
||||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||||
import java.util.EnumSet
|
import java.util.EnumSet
|
||||||
import java.util.Locale
|
|
||||||
|
|
||||||
class EmptyMangaRepository(override val source: MangaSource) : MangaRepository {
|
class EmptyMangaRepository(override val source: MangaSource) : MangaRepository {
|
||||||
|
|
||||||
override val sortOrders: Set<SortOrder>
|
override val sortOrders: Set<SortOrder>
|
||||||
get() = EnumSet.allOf(SortOrder::class.java)
|
get() = EnumSet.allOf(SortOrder::class.java)
|
||||||
override val states: Set<MangaState>
|
|
||||||
get() = emptySet()
|
|
||||||
override val contentRatings: Set<ContentRating>
|
|
||||||
get() = emptySet()
|
|
||||||
override var defaultSortOrder: SortOrder
|
override var defaultSortOrder: SortOrder
|
||||||
get() = SortOrder.NEWEST
|
get() = SortOrder.NEWEST
|
||||||
set(value) = Unit
|
set(value) = Unit
|
||||||
override val isMultipleTagsSupported: Boolean
|
|
||||||
get() = false
|
|
||||||
override val isTagsExclusionSupported: Boolean
|
|
||||||
get() = false
|
|
||||||
override val isSearchSupported: Boolean
|
|
||||||
get() = false
|
|
||||||
|
|
||||||
override suspend fun getList(offset: Int, filter: MangaListFilter?): List<Manga> = stub(null)
|
override val filterCapabilities: MangaListFilterCapabilities
|
||||||
|
get() = MangaListFilterCapabilities()
|
||||||
|
|
||||||
|
override suspend fun getList(offset: Int, order: SortOrder?, filter: MangaListFilter?): List<Manga> = stub(null)
|
||||||
|
|
||||||
override suspend fun getDetails(manga: Manga): Manga = stub(manga)
|
override suspend fun getDetails(manga: Manga): Manga = stub(manga)
|
||||||
|
|
||||||
@@ -39,9 +31,7 @@ class EmptyMangaRepository(override val source: MangaSource) : MangaRepository {
|
|||||||
|
|
||||||
override suspend fun getPageUrl(page: MangaPage): String = stub(null)
|
override suspend fun getPageUrl(page: MangaPage): String = stub(null)
|
||||||
|
|
||||||
override suspend fun getTags(): Set<MangaTag> = stub(null)
|
override suspend fun getFilterOptions(): MangaListFilterOptions = stub(null)
|
||||||
|
|
||||||
override suspend fun getLocales(): Set<Locale> = stub(null)
|
|
||||||
|
|
||||||
override suspend fun getRelated(seed: Manga): List<Manga> = stub(seed)
|
override suspend fun getRelated(seed: Manga): List<Manga> = stub(seed)
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import org.koitharu.kotatsu.core.model.MangaSource
|
|||||||
import org.koitharu.kotatsu.core.model.UnknownMangaSource
|
import org.koitharu.kotatsu.core.model.UnknownMangaSource
|
||||||
import org.koitharu.kotatsu.core.model.isNsfw
|
import org.koitharu.kotatsu.core.model.isNsfw
|
||||||
import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty
|
import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty
|
||||||
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
|
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
||||||
import org.koitharu.kotatsu.parsers.exception.NotFoundException
|
import org.koitharu.kotatsu.parsers.exception.NotFoundException
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
||||||
@@ -15,21 +15,20 @@ import org.koitharu.kotatsu.parsers.model.MangaSource
|
|||||||
import org.koitharu.kotatsu.parsers.util.almostEquals
|
import org.koitharu.kotatsu.parsers.util.almostEquals
|
||||||
import org.koitharu.kotatsu.parsers.util.levenshteinDistance
|
import org.koitharu.kotatsu.parsers.util.levenshteinDistance
|
||||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||||
import org.koitharu.kotatsu.parsers.util.toRelativeUrl
|
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@Reusable
|
@Reusable
|
||||||
class MangaLinkResolver @Inject constructor(
|
class MangaLinkResolver @Inject constructor(
|
||||||
private val repositoryFactory: MangaRepository.Factory,
|
private val repositoryFactory: MangaRepository.Factory,
|
||||||
private val sourcesRepository: MangaSourcesRepository,
|
|
||||||
private val dataRepository: MangaDataRepository,
|
private val dataRepository: MangaDataRepository,
|
||||||
|
private val context: MangaLoaderContext,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
suspend fun resolve(uri: Uri): Manga {
|
suspend fun resolve(uri: Uri): Manga {
|
||||||
return if (uri.scheme == "kotatsu" || uri.host == "kotatsu.app") {
|
return if (uri.scheme == "kotatsu" || uri.host == "kotatsu.app") {
|
||||||
resolveAppLink(uri)
|
resolveAppLink(uri)
|
||||||
} else {
|
} else {
|
||||||
resolveExternalLink(uri)
|
resolveExternalLink(uri.toString())
|
||||||
} ?: throw NotFoundException("Cannot resolve link", uri.toString())
|
} ?: throw NotFoundException("Cannot resolve link", uri.toString())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,23 +44,16 @@ class MangaLinkResolver @Inject constructor(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun resolveExternalLink(uri: Uri): Manga? {
|
private suspend fun resolveExternalLink(uri: String): Manga? {
|
||||||
dataRepository.findMangaByPublicUrl(uri.toString())?.let {
|
dataRepository.findMangaByPublicUrl(uri)?.let {
|
||||||
return it
|
return it
|
||||||
}
|
}
|
||||||
val host = uri.host ?: return null
|
return context.newLinkResolver(uri).getManga()
|
||||||
val repo = sourcesRepository.allMangaSources.asSequence()
|
|
||||||
.map { source ->
|
|
||||||
repositoryFactory.create(source) as ParserMangaRepository
|
|
||||||
}.find { repo ->
|
|
||||||
host in repo.domains
|
|
||||||
} ?: return null
|
|
||||||
return repo.findExact(uri.toString().toRelativeUrl(host), null)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun MangaRepository.findExact(url: String?, title: String?): Manga? {
|
private suspend fun MangaRepository.findExact(url: String?, title: String?): Manga? {
|
||||||
if (!title.isNullOrEmpty()) {
|
if (!title.isNullOrEmpty()) {
|
||||||
val list = getList(0, MangaListFilter.Search(title))
|
val list = getList(0, null, MangaListFilter(query = title))
|
||||||
if (url != null) {
|
if (url != null) {
|
||||||
list.find { it.url == url }?.let {
|
list.find { it.url == url }?.let {
|
||||||
return it
|
return it
|
||||||
@@ -80,17 +72,15 @@ class MangaLinkResolver @Inject constructor(
|
|||||||
}.ifNullOrEmpty {
|
}.ifNullOrEmpty {
|
||||||
seed.author
|
seed.author
|
||||||
} ?: return@runCatchingCancellable null
|
} ?: return@runCatchingCancellable null
|
||||||
val seedList = getList(0, MangaListFilter.Search(seedTitle))
|
val seedList = getList(0, null, MangaListFilter(query = seedTitle))
|
||||||
seedList.first { x -> x.url == url }
|
seedList.first { x -> x.url == url }
|
||||||
}.getOrThrow()
|
}.getOrThrow()
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun MangaRepository.getDetailsNoCache(manga: Manga): Manga {
|
private suspend fun MangaRepository.getDetailsNoCache(manga: Manga): Manga = if (this is CachingMangaRepository) {
|
||||||
return if (this is ParserMangaRepository) {
|
getDetails(manga, CachePolicy.READ_ONLY)
|
||||||
getDetails(manga, CachePolicy.READ_ONLY)
|
} else {
|
||||||
} else {
|
getDetails(manga)
|
||||||
getDetails(manga)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getSeedManga(source: MangaSource, url: String, title: String?) = Manga(
|
private fun getSeedManga(source: MangaSource, url: String, title: String?) = Manga(
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import android.util.Base64
|
|||||||
import android.webkit.WebView
|
import android.webkit.WebView
|
||||||
import androidx.annotation.MainThread
|
import androidx.annotation.MainThread
|
||||||
import androidx.core.os.LocaleListCompat
|
import androidx.core.os.LocaleListCompat
|
||||||
|
import com.davemorrissey.labs.subscaleview.decoder.ImageDecodeException
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
@@ -21,14 +22,16 @@ import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
|
|||||||
import org.koitharu.kotatsu.core.prefs.SourceSettings
|
import org.koitharu.kotatsu.core.prefs.SourceSettings
|
||||||
import org.koitharu.kotatsu.core.util.ext.configureForParser
|
import org.koitharu.kotatsu.core.util.ext.configureForParser
|
||||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||||
import org.koitharu.kotatsu.core.util.ext.requireBody
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.sanitizeHeaderValue
|
import org.koitharu.kotatsu.core.util.ext.sanitizeHeaderValue
|
||||||
import org.koitharu.kotatsu.core.util.ext.toList
|
import org.koitharu.kotatsu.core.util.ext.toList
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.use
|
||||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
||||||
import org.koitharu.kotatsu.parsers.bitmap.Bitmap
|
import org.koitharu.kotatsu.parsers.bitmap.Bitmap
|
||||||
import org.koitharu.kotatsu.parsers.config.MangaSourceConfig
|
import org.koitharu.kotatsu.parsers.config.MangaSourceConfig
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
import org.koitharu.kotatsu.parsers.network.UserAgents
|
import org.koitharu.kotatsu.parsers.network.UserAgents
|
||||||
|
import org.koitharu.kotatsu.parsers.util.map
|
||||||
|
import org.koitharu.kotatsu.parsers.util.mimeType
|
||||||
import java.lang.ref.WeakReference
|
import java.lang.ref.WeakReference
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -76,32 +79,25 @@ class MangaLoaderContextImpl @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun redrawImageResponse(response: Response, redraw: (image: Bitmap) -> Bitmap): Response {
|
override fun redrawImageResponse(response: Response, redraw: (image: Bitmap) -> Bitmap): Response {
|
||||||
val image = response.requireBody().byteStream()
|
return response.map { body ->
|
||||||
|
val opts = BitmapFactory.Options()
|
||||||
val opts = BitmapFactory.Options()
|
opts.inMutable = true
|
||||||
opts.inMutable = true
|
BitmapFactory.decodeStream(body.byteStream(), null, opts)?.use { bitmap ->
|
||||||
val bitmap = BitmapFactory.decodeStream(image, null, opts) ?: error("Cannot decode bitmap")
|
(redraw(BitmapWrapper.create(bitmap)) as BitmapWrapper).use { result ->
|
||||||
val result = redraw(BitmapWrapper.create(bitmap)) as BitmapWrapper
|
Buffer().also {
|
||||||
|
result.compressTo(it.outputStream())
|
||||||
val body = Buffer().also {
|
}.asResponseBody("image/jpeg".toMediaType())
|
||||||
result.compressTo(it.outputStream())
|
}
|
||||||
}.asResponseBody("image/jpeg".toMediaType())
|
} ?: throw ImageDecodeException(response.request.url.toString(), response.mimeType)
|
||||||
|
}
|
||||||
return response.newBuilder()
|
|
||||||
.body(body)
|
|
||||||
.build()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun createBitmap(width: Int, height: Int): Bitmap {
|
override fun createBitmap(width: Int, height: Int): Bitmap = BitmapWrapper.create(width, height)
|
||||||
return BitmapWrapper.create(width, height)
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainThread
|
@MainThread
|
||||||
private fun obtainWebView(): WebView {
|
private fun obtainWebView(): WebView = webViewCached?.get() ?: WebView(androidContext).also {
|
||||||
return webViewCached?.get() ?: WebView(androidContext).also {
|
it.configureForParser(null)
|
||||||
it.configureForParser(null)
|
webViewCached = WeakReference(it)
|
||||||
webViewCached = WeakReference(it)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun obtainWebViewUserAgent(): String {
|
private fun obtainWebViewUserAgent(): String {
|
||||||
|
|||||||
@@ -13,18 +13,16 @@ import org.koitharu.kotatsu.core.parser.external.ExternalMangaRepository
|
|||||||
import org.koitharu.kotatsu.core.parser.external.ExternalMangaSource
|
import org.koitharu.kotatsu.core.parser.external.ExternalMangaSource
|
||||||
import org.koitharu.kotatsu.local.data.LocalMangaRepository
|
import org.koitharu.kotatsu.local.data.LocalMangaRepository
|
||||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
||||||
import org.koitharu.kotatsu.parsers.model.ContentRating
|
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaListFilterCapabilities
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaListFilterOptions
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaParserSource
|
import org.koitharu.kotatsu.parsers.model.MangaParserSource
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
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.model.SortOrder
|
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||||
import java.lang.ref.WeakReference
|
import java.lang.ref.WeakReference
|
||||||
import java.util.Locale
|
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
import kotlin.collections.set
|
import kotlin.collections.set
|
||||||
@@ -35,19 +33,11 @@ interface MangaRepository {
|
|||||||
|
|
||||||
val sortOrders: Set<SortOrder>
|
val sortOrders: Set<SortOrder>
|
||||||
|
|
||||||
val states: Set<MangaState>
|
|
||||||
|
|
||||||
val contentRatings: Set<ContentRating>
|
|
||||||
|
|
||||||
var defaultSortOrder: SortOrder
|
var defaultSortOrder: SortOrder
|
||||||
|
|
||||||
val isMultipleTagsSupported: Boolean
|
val filterCapabilities: MangaListFilterCapabilities
|
||||||
|
|
||||||
val isTagsExclusionSupported: Boolean
|
suspend fun getList(offset: Int, order: SortOrder?, filter: MangaListFilter?): List<Manga>
|
||||||
|
|
||||||
val isSearchSupported: Boolean
|
|
||||||
|
|
||||||
suspend fun getList(offset: Int, filter: MangaListFilter?): List<Manga>
|
|
||||||
|
|
||||||
suspend fun getDetails(manga: Manga): Manga
|
suspend fun getDetails(manga: Manga): Manga
|
||||||
|
|
||||||
@@ -55,14 +45,12 @@ interface MangaRepository {
|
|||||||
|
|
||||||
suspend fun getPageUrl(page: MangaPage): String
|
suspend fun getPageUrl(page: MangaPage): String
|
||||||
|
|
||||||
suspend fun getTags(): Set<MangaTag>
|
suspend fun getFilterOptions(): MangaListFilterOptions
|
||||||
|
|
||||||
suspend fun getLocales(): Set<Locale>
|
|
||||||
|
|
||||||
suspend fun getRelated(seed: Manga): List<Manga>
|
suspend fun getRelated(seed: Manga): List<Manga>
|
||||||
|
|
||||||
suspend fun find(manga: Manga): Manga? {
|
suspend fun find(manga: Manga): Manga? {
|
||||||
val list = getList(0, MangaListFilter.Search(manga.title))
|
val list = getList(0, SortOrder.RELEVANCE, MangaListFilter(query = manga.title))
|
||||||
return list.find { x -> x.id == manga.id }
|
return list.find { x -> x.id == manga.id }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,19 +8,18 @@ import org.koitharu.kotatsu.core.prefs.SourceSettings
|
|||||||
import org.koitharu.kotatsu.parsers.MangaParser
|
import org.koitharu.kotatsu.parsers.MangaParser
|
||||||
import org.koitharu.kotatsu.parsers.MangaParserAuthProvider
|
import org.koitharu.kotatsu.parsers.MangaParserAuthProvider
|
||||||
import org.koitharu.kotatsu.parsers.config.ConfigKey
|
import org.koitharu.kotatsu.parsers.config.ConfigKey
|
||||||
import org.koitharu.kotatsu.parsers.model.ContentRating
|
|
||||||
import org.koitharu.kotatsu.parsers.model.Favicons
|
import org.koitharu.kotatsu.parsers.model.Favicons
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaListFilterCapabilities
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaListFilterOptions
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaParserSource
|
import org.koitharu.kotatsu.parsers.model.MangaParserSource
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaState
|
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
|
||||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||||
|
import org.koitharu.kotatsu.parsers.util.SuspendLazy
|
||||||
import org.koitharu.kotatsu.parsers.util.domain
|
import org.koitharu.kotatsu.parsers.util.domain
|
||||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||||
import java.util.Locale
|
|
||||||
|
|
||||||
class ParserMangaRepository(
|
class ParserMangaRepository(
|
||||||
private val parser: MangaParser,
|
private val parser: MangaParser,
|
||||||
@@ -28,17 +27,20 @@ class ParserMangaRepository(
|
|||||||
cache: MemoryContentCache,
|
cache: MemoryContentCache,
|
||||||
) : CachingMangaRepository(cache), Interceptor {
|
) : CachingMangaRepository(cache), Interceptor {
|
||||||
|
|
||||||
|
private val filterOptionsLazy = SuspendLazy {
|
||||||
|
mirrorSwitchInterceptor.withMirrorSwitching {
|
||||||
|
parser.getFilterOptions()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override val source: MangaParserSource
|
override val source: MangaParserSource
|
||||||
get() = parser.source
|
get() = parser.source
|
||||||
|
|
||||||
override val sortOrders: Set<SortOrder>
|
override val sortOrders: Set<SortOrder>
|
||||||
get() = parser.availableSortOrders
|
get() = parser.availableSortOrders
|
||||||
|
|
||||||
override val states: Set<MangaState>
|
override val filterCapabilities: MangaListFilterCapabilities
|
||||||
get() = parser.availableStates
|
get() = parser.filterCapabilities
|
||||||
|
|
||||||
override val contentRatings: Set<ContentRating>
|
|
||||||
get() = parser.availableContentRating
|
|
||||||
|
|
||||||
override var defaultSortOrder: SortOrder
|
override var defaultSortOrder: SortOrder
|
||||||
get() = getConfig().defaultSortOrder ?: sortOrders.first()
|
get() = getConfig().defaultSortOrder ?: sortOrders.first()
|
||||||
@@ -46,15 +48,6 @@ class ParserMangaRepository(
|
|||||||
getConfig().defaultSortOrder = value
|
getConfig().defaultSortOrder = value
|
||||||
}
|
}
|
||||||
|
|
||||||
override val isMultipleTagsSupported: Boolean
|
|
||||||
get() = parser.isMultipleTagsSupported
|
|
||||||
|
|
||||||
override val isSearchSupported: Boolean
|
|
||||||
get() = parser.isSearchSupported
|
|
||||||
|
|
||||||
override val isTagsExclusionSupported: Boolean
|
|
||||||
get() = parser.isTagsExclusionSupported
|
|
||||||
|
|
||||||
var domain: String
|
var domain: String
|
||||||
get() = parser.domain
|
get() = parser.domain
|
||||||
set(value) {
|
set(value) {
|
||||||
@@ -72,9 +65,9 @@ class ParserMangaRepository(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getList(offset: Int, filter: MangaListFilter?): List<Manga> {
|
override suspend fun getList(offset: Int, order: SortOrder?, filter: MangaListFilter?): List<Manga> {
|
||||||
return mirrorSwitchInterceptor.withMirrorSwitching {
|
return mirrorSwitchInterceptor.withMirrorSwitching {
|
||||||
parser.getList(offset, filter)
|
parser.getList(offset, order ?: defaultSortOrder, filter ?: MangaListFilter.EMPTY)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,13 +81,7 @@ class ParserMangaRepository(
|
|||||||
parser.getPageUrl(page)
|
parser.getPageUrl(page)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getTags(): Set<MangaTag> = mirrorSwitchInterceptor.withMirrorSwitching {
|
override suspend fun getFilterOptions(): MangaListFilterOptions = filterOptionsLazy.get()
|
||||||
parser.getAvailableTags()
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun getLocales(): Set<Locale> {
|
|
||||||
return parser.getAvailableLocales()
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun getFavicons(): Favicons = mirrorSwitchInterceptor.withMirrorSwitching {
|
suspend fun getFavicons(): Favicons = mirrorSwitchInterceptor.withMirrorSwitching {
|
||||||
parser.getFavicons()
|
parser.getFavicons()
|
||||||
|
|||||||
@@ -6,19 +6,18 @@ import kotlinx.coroutines.runInterruptible
|
|||||||
import org.koitharu.kotatsu.core.cache.MemoryContentCache
|
import org.koitharu.kotatsu.core.cache.MemoryContentCache
|
||||||
import org.koitharu.kotatsu.core.parser.CachingMangaRepository
|
import org.koitharu.kotatsu.core.parser.CachingMangaRepository
|
||||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||||
import org.koitharu.kotatsu.parsers.model.ContentRating
|
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaListFilterCapabilities
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaListFilterOptions
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaState
|
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
|
||||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||||
|
import org.koitharu.kotatsu.parsers.util.SuspendLazy
|
||||||
import java.util.EnumSet
|
import java.util.EnumSet
|
||||||
import java.util.Locale
|
|
||||||
|
|
||||||
class ExternalMangaRepository(
|
class ExternalMangaRepository(
|
||||||
private val contentResolver: ContentResolver,
|
contentResolver: ContentResolver,
|
||||||
override val source: ExternalMangaSource,
|
override val source: ExternalMangaSource,
|
||||||
cache: MemoryContentCache,
|
cache: MemoryContentCache,
|
||||||
) : CachingMangaRepository(cache) {
|
) : CachingMangaRepository(cache) {
|
||||||
@@ -33,31 +32,23 @@ class ExternalMangaRepository(
|
|||||||
}.getOrNull()
|
}.getOrNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val filterOptions = SuspendLazy(contentSource::getListFilterOptions)
|
||||||
|
|
||||||
override val sortOrders: Set<SortOrder>
|
override val sortOrders: Set<SortOrder>
|
||||||
get() = capabilities?.availableSortOrders ?: EnumSet.of(SortOrder.ALPHABETICAL)
|
get() = capabilities?.availableSortOrders ?: EnumSet.of(SortOrder.POPULARITY)
|
||||||
|
|
||||||
override val states: Set<MangaState>
|
override val filterCapabilities: MangaListFilterCapabilities
|
||||||
get() = capabilities?.availableStates.orEmpty()
|
get() = capabilities?.listFilterCapabilities ?: MangaListFilterCapabilities()
|
||||||
|
|
||||||
override val contentRatings: Set<ContentRating>
|
|
||||||
get() = capabilities?.availableContentRating.orEmpty()
|
|
||||||
|
|
||||||
override var defaultSortOrder: SortOrder
|
override var defaultSortOrder: SortOrder
|
||||||
get() = capabilities?.defaultSortOrder ?: SortOrder.ALPHABETICAL
|
get() = capabilities?.availableSortOrders?.firstOrNull() ?: SortOrder.ALPHABETICAL
|
||||||
set(value) = Unit
|
set(value) = Unit
|
||||||
|
|
||||||
override val isMultipleTagsSupported: Boolean
|
override suspend fun getFilterOptions(): MangaListFilterOptions = filterOptions.get()
|
||||||
get() = capabilities?.isMultipleTagsSupported ?: true
|
|
||||||
|
|
||||||
override val isTagsExclusionSupported: Boolean
|
override suspend fun getList(offset: Int, order: SortOrder?, filter: MangaListFilter?): List<Manga> =
|
||||||
get() = capabilities?.isTagsExclusionSupported ?: false
|
|
||||||
|
|
||||||
override val isSearchSupported: Boolean
|
|
||||||
get() = capabilities?.isSearchSupported ?: true
|
|
||||||
|
|
||||||
override suspend fun getList(offset: Int, filter: MangaListFilter?): List<Manga> =
|
|
||||||
runInterruptible(Dispatchers.IO) {
|
runInterruptible(Dispatchers.IO) {
|
||||||
contentSource.getList(offset, filter)
|
contentSource.getList(offset, order ?: defaultSortOrder, filter ?: MangaListFilter.EMPTY)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getDetailsImpl(manga: Manga): Manga = runInterruptible(Dispatchers.IO) {
|
override suspend fun getDetailsImpl(manga: Manga): Manga = runInterruptible(Dispatchers.IO) {
|
||||||
@@ -68,13 +59,9 @@ class ExternalMangaRepository(
|
|||||||
contentSource.getPages(chapter)
|
contentSource.getPages(chapter)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getPageUrl(page: MangaPage): String = page.url // TODO
|
override suspend fun getPageUrl(page: MangaPage): String = runInterruptible(Dispatchers.IO) {
|
||||||
|
contentSource.getPageUrl(page.url)
|
||||||
override suspend fun getTags(): Set<MangaTag> = runInterruptible(Dispatchers.IO) {
|
|
||||||
contentSource.getTags()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getLocales(): Set<Locale> = emptySet() // TODO
|
|
||||||
|
|
||||||
override suspend fun getRelatedMangaImpl(seed: Manga): List<Manga> = emptyList() // TODO
|
override suspend fun getRelatedMangaImpl(seed: Manga): List<Manga> = emptyList() // TODO
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,12 +8,14 @@ import androidx.core.net.toUri
|
|||||||
import org.jetbrains.annotations.Blocking
|
import org.jetbrains.annotations.Blocking
|
||||||
import org.koitharu.kotatsu.core.exceptions.IncompatiblePluginException
|
import org.koitharu.kotatsu.core.exceptions.IncompatiblePluginException
|
||||||
import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty
|
import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty
|
||||||
import org.koitharu.kotatsu.core.util.ext.toLocale
|
|
||||||
import org.koitharu.kotatsu.parsers.model.ContentRating
|
import org.koitharu.kotatsu.parsers.model.ContentRating
|
||||||
import org.koitharu.kotatsu.parsers.model.ContentType
|
import org.koitharu.kotatsu.parsers.model.ContentType
|
||||||
|
import org.koitharu.kotatsu.parsers.model.Demographic
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaListFilterCapabilities
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaListFilterOptions
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaState
|
import org.koitharu.kotatsu.parsers.model.MangaState
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||||
@@ -31,25 +33,29 @@ class ExternalPluginContentSource(
|
|||||||
|
|
||||||
@Blocking
|
@Blocking
|
||||||
@WorkerThread
|
@WorkerThread
|
||||||
fun getList(offset: Int, filter: MangaListFilter?): List<Manga> {
|
fun getListFilterOptions() = MangaListFilterOptions(
|
||||||
|
availableTags = fetchTags(),
|
||||||
|
availableStates = fetchEnumSet(MangaState::class.java, "filter/states"),
|
||||||
|
availableContentRating = fetchEnumSet(ContentRating::class.java, "filter/content_ratings"),
|
||||||
|
availableContentTypes = fetchEnumSet(ContentType::class.java, "filter/content_types"),
|
||||||
|
availableDemographics = fetchEnumSet(Demographic::class.java, "filter/demographics"),
|
||||||
|
availableLocales = fetchLocales(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Blocking
|
||||||
|
@WorkerThread
|
||||||
|
fun getList(offset: Int, order: SortOrder, filter: MangaListFilter): List<Manga> {
|
||||||
val uri = "content://${source.authority}/manga".toUri().buildUpon()
|
val uri = "content://${source.authority}/manga".toUri().buildUpon()
|
||||||
uri.appendQueryParameter("offset", offset.toString())
|
uri.appendQueryParameter("offset", offset.toString())
|
||||||
when (filter) {
|
filter.tags.forEach { uri.appendQueryParameter("tags_include", "${it.key}=${it.title}") }
|
||||||
is MangaListFilter.Advanced -> {
|
filter.tagsExclude.forEach { uri.appendQueryParameter("tags_exclude", "${it.key}=${it.title}") }
|
||||||
filter.tags.forEach { uri.appendQueryParameter("tags_include", "${it.key}=${it.title}") }
|
filter.states.forEach { uri.appendQueryParameter("state", it.name) }
|
||||||
filter.tagsExclude.forEach { uri.appendQueryParameter("tags_exclude", "${it.key}=${it.title}") }
|
filter.locale?.let { uri.appendQueryParameter("locale", it.language) }
|
||||||
filter.states.forEach { uri.appendQueryParameter("state", it.name) }
|
filter.contentRating.forEach { uri.appendQueryParameter("content_rating", it.name) }
|
||||||
filter.locale?.let { uri.appendQueryParameter("locale", it.language) }
|
if (!filter.query.isNullOrEmpty()) {
|
||||||
filter.contentRating.forEach { uri.appendQueryParameter("content_rating", it.name) }
|
uri.appendQueryParameter("query", filter.query)
|
||||||
}
|
|
||||||
|
|
||||||
is MangaListFilter.Search -> {
|
|
||||||
uri.appendQueryParameter("query", filter.query)
|
|
||||||
}
|
|
||||||
|
|
||||||
null -> Unit
|
|
||||||
}
|
}
|
||||||
return contentResolver.query(uri.build(), null, null, null, filter?.sortOrder?.name)
|
return contentResolver.query(uri.build(), null, null, null, order.name)
|
||||||
.safe()
|
.safe()
|
||||||
.use { cursor ->
|
.use { cursor ->
|
||||||
val result = ArrayList<Manga>(cursor.count)
|
val result = ArrayList<Manga>(cursor.count)
|
||||||
@@ -113,8 +119,8 @@ class ExternalPluginContentSource(
|
|||||||
|
|
||||||
@Blocking
|
@Blocking
|
||||||
@WorkerThread
|
@WorkerThread
|
||||||
fun getTags(): Set<MangaTag> {
|
private fun fetchTags(): Set<MangaTag> {
|
||||||
val uri = "content://${source.authority}/tags".toUri()
|
val uri = "content://${source.authority}/filter/tags".toUri()
|
||||||
return contentResolver.query(uri, null, null, null, null)
|
return contentResolver.query(uri, null, null, null, null)
|
||||||
.safe()
|
.safe()
|
||||||
.use { cursor ->
|
.use { cursor ->
|
||||||
@@ -132,6 +138,40 @@ class ExternalPluginContentSource(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Blocking
|
||||||
|
@WorkerThread
|
||||||
|
fun getPageUrl(url: String): String {
|
||||||
|
val uri = "content://${source.authority}/manga/pages/0".toUri().buildUpon()
|
||||||
|
.appendQueryParameter("url", url)
|
||||||
|
.build()
|
||||||
|
return contentResolver.query(uri, null, null, null, null)
|
||||||
|
.safe()
|
||||||
|
.use { cursor ->
|
||||||
|
if (cursor.moveToFirst()) {
|
||||||
|
cursor.getString(COLUMN_VALUE)
|
||||||
|
} else {
|
||||||
|
url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Blocking
|
||||||
|
@WorkerThread
|
||||||
|
private fun fetchLocales(): Set<Locale> {
|
||||||
|
val uri = "content://${source.authority}/filter/locales".toUri()
|
||||||
|
return contentResolver.query(uri, null, null, null, null)
|
||||||
|
.safe()
|
||||||
|
.use { cursor ->
|
||||||
|
val result = ArraySet<Locale>(cursor.count)
|
||||||
|
if (cursor.moveToFirst()) {
|
||||||
|
do {
|
||||||
|
result += Locale(cursor.getString(COLUMN_NAME))
|
||||||
|
} while (cursor.moveToNext())
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun getCapabilities(): MangaSourceCapabilities? {
|
fun getCapabilities(): MangaSourceCapabilities? {
|
||||||
val uri = "content://${source.authority}/capabilities".toUri()
|
val uri = "content://${source.authority}/capabilities".toUri()
|
||||||
return contentResolver.query(uri, null, null, null, null)
|
return contentResolver.query(uri, null, null, null, null)
|
||||||
@@ -144,26 +184,18 @@ class ExternalPluginContentSource(
|
|||||||
?.mapNotNullTo(EnumSet.noneOf(SortOrder::class.java)) {
|
?.mapNotNullTo(EnumSet.noneOf(SortOrder::class.java)) {
|
||||||
SortOrder.entries.find(it)
|
SortOrder.entries.find(it)
|
||||||
}.orEmpty(),
|
}.orEmpty(),
|
||||||
availableStates = cursor.getStringOrNull(COLUMN_STATES)
|
listFilterCapabilities = MangaListFilterCapabilities(
|
||||||
?.split(',')
|
isMultipleTagsSupported = cursor.getBooleanOrDefault(COLUMN_MULTIPLE_TAGS, false),
|
||||||
?.mapNotNullTo(EnumSet.noneOf(MangaState::class.java)) {
|
isTagsExclusionSupported = cursor.getBooleanOrDefault(COLUMN_TAGS_EXCLUSION, false),
|
||||||
MangaState.entries.find(it)
|
isSearchSupported = cursor.getBooleanOrDefault(COLUMN_SEARCH, false),
|
||||||
}.orEmpty(),
|
isSearchWithFiltersSupported = cursor.getBooleanOrDefault(
|
||||||
availableContentRating = cursor.getStringOrNull(COLUMN_CONTENT_RATING)
|
COLUMN_SEARCH_WITH_FILTERS,
|
||||||
?.split(',')
|
false,
|
||||||
?.mapNotNullTo(EnumSet.noneOf(ContentRating::class.java)) {
|
),
|
||||||
ContentRating.entries.find(it)
|
isYearSupported = cursor.getBooleanOrDefault(COLUMN_YEAR, false),
|
||||||
}.orEmpty(),
|
isYearRangeSupported = cursor.getBooleanOrDefault(COLUMN_YEAR_RANGE, false),
|
||||||
isMultipleTagsSupported = cursor.getBooleanOrDefault(COLUMN_MULTIPLE_TAGS_SUPPORTED, true),
|
isOriginalLocaleSupported = cursor.getBooleanOrDefault(COLUMN_ORIGINAL_LOCALE, false),
|
||||||
isTagsExclusionSupported = cursor.getBooleanOrDefault(COLUMN_TAGS_EXCLUSION_SUPPORTED, false),
|
),
|
||||||
isSearchSupported = cursor.getBooleanOrDefault(COLUMN_SEARCH_SUPPORTED, true),
|
|
||||||
contentType = cursor.getStringOrNull(COLUMN_CONTENT_TYPE)?.let {
|
|
||||||
ContentType.entries.find(it)
|
|
||||||
} ?: ContentType.OTHER,
|
|
||||||
defaultSortOrder = cursor.getStringOrNull(COLUMN_DEFAULT_SORT_ORDER)?.let {
|
|
||||||
SortOrder.entries.find(it)
|
|
||||||
} ?: SortOrder.ALPHABETICAL,
|
|
||||||
sourceLocale = cursor.getStringOrNull(COLUMN_LOCALE)?.toLocale() ?: Locale.ROOT,
|
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
@@ -233,6 +265,26 @@ class ExternalPluginContentSource(
|
|||||||
source = source,
|
source = source,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private fun <E : Enum<E>> fetchEnumSet(cls: Class<E>, path: String): EnumSet<E> {
|
||||||
|
val uri = "content://${source.authority}/$path".toUri()
|
||||||
|
return contentResolver.query(uri, null, null, null, null)
|
||||||
|
.safe()
|
||||||
|
.use { cursor ->
|
||||||
|
val result = EnumSet.noneOf(cls)
|
||||||
|
val enumConstants = cls.enumConstants ?: return@use result
|
||||||
|
if (cursor.moveToFirst()) {
|
||||||
|
do {
|
||||||
|
val name = cursor.getString(COLUMN_NAME)
|
||||||
|
val enumValue = enumConstants.find { it.name == name }
|
||||||
|
if (enumValue != null) {
|
||||||
|
result.add(enumValue)
|
||||||
|
}
|
||||||
|
} while (cursor.moveToNext())
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun Cursor?.safe() = ExternalPluginCursor(
|
private fun Cursor?.safe() = ExternalPluginCursor(
|
||||||
source = source,
|
source = source,
|
||||||
cursor = this ?: throw IncompatiblePluginException(source.name, null),
|
cursor = this ?: throw IncompatiblePluginException(source.name, null),
|
||||||
@@ -240,27 +292,19 @@ class ExternalPluginContentSource(
|
|||||||
|
|
||||||
class MangaSourceCapabilities(
|
class MangaSourceCapabilities(
|
||||||
val availableSortOrders: Set<SortOrder>,
|
val availableSortOrders: Set<SortOrder>,
|
||||||
val availableStates: Set<MangaState>,
|
val listFilterCapabilities: MangaListFilterCapabilities,
|
||||||
val availableContentRating: Set<ContentRating>,
|
|
||||||
val isMultipleTagsSupported: Boolean,
|
|
||||||
val isTagsExclusionSupported: Boolean,
|
|
||||||
val isSearchSupported: Boolean,
|
|
||||||
val contentType: ContentType,
|
|
||||||
val defaultSortOrder: SortOrder,
|
|
||||||
val sourceLocale: Locale,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
|
|
||||||
const val COLUMN_SORT_ORDERS = "sort_orders"
|
const val COLUMN_SORT_ORDERS = "sort_orders"
|
||||||
const val COLUMN_STATES = "states"
|
const val COLUMN_MULTIPLE_TAGS = "multiple_tags"
|
||||||
const val COLUMN_CONTENT_RATING = "content_rating"
|
const val COLUMN_TAGS_EXCLUSION = "tags_exclusion"
|
||||||
const val COLUMN_MULTIPLE_TAGS_SUPPORTED = "multiple_tags_supported"
|
const val COLUMN_SEARCH = "search"
|
||||||
const val COLUMN_TAGS_EXCLUSION_SUPPORTED = "tags_exclusion_supported"
|
const val COLUMN_SEARCH_WITH_FILTERS = "search_with_filters"
|
||||||
const val COLUMN_SEARCH_SUPPORTED = "search_supported"
|
const val COLUMN_YEAR = "year"
|
||||||
const val COLUMN_CONTENT_TYPE = "content_type"
|
const val COLUMN_YEAR_RANGE = "year_range"
|
||||||
const val COLUMN_DEFAULT_SORT_ORDER = "default_sort_order"
|
const val COLUMN_ORIGINAL_LOCALE = "original_locale"
|
||||||
const val COLUMN_LOCALE = "locale"
|
|
||||||
const val COLUMN_ID = "id"
|
const val COLUMN_ID = "id"
|
||||||
const val COLUMN_NAME = "name"
|
const val COLUMN_NAME = "name"
|
||||||
const val COLUMN_NUMBER = "number"
|
const val COLUMN_NUMBER = "number"
|
||||||
@@ -282,5 +326,6 @@ class ExternalPluginContentSource(
|
|||||||
const val COLUMN_DESCRIPTION = "description"
|
const val COLUMN_DESCRIPTION = "description"
|
||||||
const val COLUMN_PREVIEW = "preview"
|
const val COLUMN_PREVIEW = "preview"
|
||||||
const val COLUMN_KEY = "key"
|
const val COLUMN_KEY = "key"
|
||||||
|
const val COLUMN_VALUE = "value"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,12 +37,12 @@ import org.koitharu.kotatsu.core.parser.EmptyMangaRepository
|
|||||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||||
import org.koitharu.kotatsu.core.parser.ParserMangaRepository
|
import org.koitharu.kotatsu.core.parser.ParserMangaRepository
|
||||||
import org.koitharu.kotatsu.core.parser.external.ExternalMangaRepository
|
import org.koitharu.kotatsu.core.parser.external.ExternalMangaRepository
|
||||||
import org.koitharu.kotatsu.core.util.ext.requireBody
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.writeAllCancellable
|
import org.koitharu.kotatsu.core.util.ext.writeAllCancellable
|
||||||
import org.koitharu.kotatsu.local.data.CacheDir
|
import org.koitharu.kotatsu.local.data.CacheDir
|
||||||
import org.koitharu.kotatsu.local.data.util.withExtraCloseable
|
import org.koitharu.kotatsu.local.data.util.withExtraCloseable
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
import org.koitharu.kotatsu.parsers.util.await
|
import org.koitharu.kotatsu.parsers.util.await
|
||||||
|
import org.koitharu.kotatsu.parsers.util.requireBody
|
||||||
import java.net.HttpURLConnection
|
import java.net.HttpURLConnection
|
||||||
import kotlin.coroutines.coroutineContext
|
import kotlin.coroutines.coroutineContext
|
||||||
|
|
||||||
@@ -114,6 +114,7 @@ class FaviconFetcher(
|
|||||||
.url(url)
|
.url(url)
|
||||||
.get()
|
.get()
|
||||||
.tag(MangaSource::class.java, source)
|
.tag(MangaSource::class.java, source)
|
||||||
|
request.tag(MangaSource::class.java, source)
|
||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
options.tags.asMap().forEach { request.tag(it.key as Class<Any>, it.value) }
|
options.tags.asMap().forEach { request.tag(it.key as Class<Any>, it.value) }
|
||||||
val response = okHttpClient.newCall(request.build()).await()
|
val response = okHttpClient.newCall(request.build()).await()
|
||||||
|
|||||||
@@ -160,6 +160,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
|||||||
val isTrackerNsfwDisabled: Boolean
|
val isTrackerNsfwDisabled: Boolean
|
||||||
get() = prefs.getBoolean(KEY_TRACKER_NO_NSFW, false)
|
get() = prefs.getBoolean(KEY_TRACKER_NO_NSFW, false)
|
||||||
|
|
||||||
|
val trackerDownloadStrategy: TrackerDownloadStrategy
|
||||||
|
get() = prefs.getEnumValue(KEY_TRACKER_DOWNLOAD, TrackerDownloadStrategy.DISABLED)
|
||||||
|
|
||||||
var notificationSound: Uri
|
var notificationSound: Uri
|
||||||
get() = prefs.getString(KEY_NOTIFICATIONS_SOUND, null)?.toUriOrNull()
|
get() = prefs.getString(KEY_NOTIFICATIONS_SOUND, null)?.toUriOrNull()
|
||||||
?: Settings.System.DEFAULT_NOTIFICATION_URI
|
?: Settings.System.DEFAULT_NOTIFICATION_URI
|
||||||
@@ -236,9 +239,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
|||||||
}
|
}
|
||||||
} ?: EnumSet.allOf(SearchSuggestionType::class.java)
|
} ?: EnumSet.allOf(SearchSuggestionType::class.java)
|
||||||
|
|
||||||
val isLoggingEnabled: Boolean
|
|
||||||
get() = prefs.getBoolean(KEY_LOGGING_ENABLED, false)
|
|
||||||
|
|
||||||
var isBiometricProtectionEnabled: Boolean
|
var isBiometricProtectionEnabled: Boolean
|
||||||
get() = prefs.getBoolean(KEY_PROTECT_APP_BIOMETRIC, true)
|
get() = prefs.getBoolean(KEY_PROTECT_APP_BIOMETRIC, true)
|
||||||
set(value) = prefs.edit { putBoolean(KEY_PROTECT_APP_BIOMETRIC, value) }
|
set(value) = prefs.edit { putBoolean(KEY_PROTECT_APP_BIOMETRIC, value) }
|
||||||
@@ -600,6 +600,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
|||||||
const val KEY_TRACK_WARNING = "track_warning"
|
const val KEY_TRACK_WARNING = "track_warning"
|
||||||
const val KEY_TRACKER_NOTIFICATIONS = "tracker_notifications"
|
const val KEY_TRACKER_NOTIFICATIONS = "tracker_notifications"
|
||||||
const val KEY_TRACKER_NO_NSFW = "tracker_no_nsfw"
|
const val KEY_TRACKER_NO_NSFW = "tracker_no_nsfw"
|
||||||
|
const val KEY_TRACKER_DOWNLOAD = "tracker_download"
|
||||||
const val KEY_NOTIFICATIONS_SETTINGS = "notifications_settings"
|
const val KEY_NOTIFICATIONS_SETTINGS = "notifications_settings"
|
||||||
const val KEY_NOTIFICATIONS_SOUND = "notifications_sound"
|
const val KEY_NOTIFICATIONS_SOUND = "notifications_sound"
|
||||||
const val KEY_NOTIFICATIONS_VIBRATE = "notifications_vibrate"
|
const val KEY_NOTIFICATIONS_VIBRATE = "notifications_vibrate"
|
||||||
@@ -661,7 +662,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
|||||||
const val KEY_WEBTOON_ZOOM_OUT = "webtoon_zoom_out"
|
const val KEY_WEBTOON_ZOOM_OUT = "webtoon_zoom_out"
|
||||||
const val KEY_PREFETCH_CONTENT = "prefetch_content"
|
const val KEY_PREFETCH_CONTENT = "prefetch_content"
|
||||||
const val KEY_APP_LOCALE = "app_locale"
|
const val KEY_APP_LOCALE = "app_locale"
|
||||||
const val KEY_LOGGING_ENABLED = "logging"
|
|
||||||
const val KEY_SOURCES_GRID = "sources_grid"
|
const val KEY_SOURCES_GRID = "sources_grid"
|
||||||
const val KEY_UPDATES_UNSTABLE = "updates_unstable"
|
const val KEY_UPDATES_UNSTABLE = "updates_unstable"
|
||||||
const val KEY_TIPS_CLOSED = "tips_closed"
|
const val KEY_TIPS_CLOSED = "tips_closed"
|
||||||
@@ -705,9 +705,11 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
|||||||
const val KEY_APP_VERSION = "app_version"
|
const val KEY_APP_VERSION = "app_version"
|
||||||
const val KEY_IGNORE_DOZE = "ignore_dose"
|
const val KEY_IGNORE_DOZE = "ignore_dose"
|
||||||
const val KEY_TRACKER_DEBUG = "tracker_debug"
|
const val KEY_TRACKER_DEBUG = "tracker_debug"
|
||||||
const val KEY_LOGS_SHARE = "logs_share"
|
|
||||||
const val KEY_APP_UPDATE = "app_update"
|
const val KEY_APP_UPDATE = "app_update"
|
||||||
const val KEY_APP_TRANSLATION = "about_app_translation"
|
const val KEY_LINK_WEBLATE = "about_app_translation"
|
||||||
|
const val KEY_LINK_TELEGRAM = "about_telegram"
|
||||||
|
const val KEY_LINK_GITHUB = "about_github"
|
||||||
|
const val KEY_LINK_MANUAL = "about_help"
|
||||||
const val PROXY_TEST = "proxy_test"
|
const val PROXY_TEST = "proxy_test"
|
||||||
|
|
||||||
// old keys are for migration only
|
// old keys are for migration only
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
package org.koitharu.kotatsu.core.prefs
|
package org.koitharu.kotatsu.core.prefs
|
||||||
|
|
||||||
|
import androidx.annotation.Keep
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
import androidx.annotation.StyleRes
|
import androidx.annotation.StyleRes
|
||||||
import com.google.android.material.color.DynamicColors
|
import com.google.android.material.color.DynamicColors
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.parsers.util.find
|
import org.koitharu.kotatsu.parsers.util.find
|
||||||
|
|
||||||
|
@Keep
|
||||||
enum class ColorScheme(
|
enum class ColorScheme(
|
||||||
@StyleRes val styleResId: Int,
|
@StyleRes val styleResId: Int,
|
||||||
@StringRes val titleResId: Int,
|
@StringRes val titleResId: Int,
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
package org.koitharu.kotatsu.core.prefs
|
package org.koitharu.kotatsu.core.prefs
|
||||||
|
|
||||||
|
import androidx.annotation.Keep
|
||||||
|
|
||||||
|
@Keep
|
||||||
enum class DownloadFormat {
|
enum class DownloadFormat {
|
||||||
|
|
||||||
AUTOMATIC,
|
AUTOMATIC,
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package org.koitharu.kotatsu.core.prefs
|
package org.koitharu.kotatsu.core.prefs
|
||||||
|
|
||||||
|
import androidx.annotation.Keep
|
||||||
|
|
||||||
|
@Keep
|
||||||
enum class ListMode {
|
enum class ListMode {
|
||||||
|
|
||||||
LIST, DETAILED_LIST, GRID;
|
LIST, DETAILED_LIST, GRID;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ package org.koitharu.kotatsu.core.prefs
|
|||||||
|
|
||||||
import androidx.annotation.DrawableRes
|
import androidx.annotation.DrawableRes
|
||||||
import androidx.annotation.IdRes
|
import androidx.annotation.IdRes
|
||||||
|
import androidx.annotation.Keep
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
|
|
||||||
|
@Keep
|
||||||
enum class NavItem(
|
enum class NavItem(
|
||||||
@IdRes val id: Int,
|
@IdRes val id: Int,
|
||||||
@StringRes val title: Int,
|
@StringRes val title: Int,
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package org.koitharu.kotatsu.core.prefs
|
package org.koitharu.kotatsu.core.prefs
|
||||||
|
|
||||||
import android.net.ConnectivityManager
|
import android.net.ConnectivityManager
|
||||||
|
import androidx.annotation.Keep
|
||||||
|
|
||||||
|
@Keep
|
||||||
enum class NetworkPolicy(
|
enum class NetworkPolicy(
|
||||||
private val key: Int,
|
private val key: Int,
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
package org.koitharu.kotatsu.core.prefs
|
package org.koitharu.kotatsu.core.prefs
|
||||||
|
|
||||||
|
import androidx.annotation.Keep
|
||||||
|
|
||||||
|
@Keep
|
||||||
enum class ProgressIndicatorMode {
|
enum class ProgressIndicatorMode {
|
||||||
|
|
||||||
NONE, PERCENT_READ, PERCENT_LEFT, CHAPTERS_READ, CHAPTERS_LEFT;
|
NONE, PERCENT_READ, PERCENT_LEFT, CHAPTERS_READ, CHAPTERS_LEFT;
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
package org.koitharu.kotatsu.core.prefs
|
package org.koitharu.kotatsu.core.prefs
|
||||||
|
|
||||||
|
import androidx.annotation.Keep
|
||||||
|
|
||||||
|
@Keep
|
||||||
enum class ReaderAnimation {
|
enum class ReaderAnimation {
|
||||||
|
|
||||||
// Do not rename this
|
// Do not rename this
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ package org.koitharu.kotatsu.core.prefs
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.view.ContextThemeWrapper
|
import android.view.ContextThemeWrapper
|
||||||
|
import androidx.annotation.Keep
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.core.graphics.drawable.toDrawable
|
import androidx.core.graphics.drawable.toDrawable
|
||||||
import org.koitharu.kotatsu.core.util.ext.getThemeDrawable
|
import org.koitharu.kotatsu.core.util.ext.getThemeDrawable
|
||||||
import com.google.android.material.R as materialR
|
import com.google.android.material.R as materialR
|
||||||
|
|
||||||
|
@Keep
|
||||||
enum class ReaderBackground {
|
enum class ReaderBackground {
|
||||||
|
|
||||||
DEFAULT, LIGHT, DARK, WHITE, BLACK;
|
DEFAULT, LIGHT, DARK, WHITE, BLACK;
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
package org.koitharu.kotatsu.core.prefs
|
package org.koitharu.kotatsu.core.prefs
|
||||||
|
|
||||||
|
import androidx.annotation.Keep
|
||||||
|
|
||||||
|
@Keep
|
||||||
enum class ReaderMode(val id: Int) {
|
enum class ReaderMode(val id: Int) {
|
||||||
|
|
||||||
STANDARD(1),
|
STANDARD(1),
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
package org.koitharu.kotatsu.core.prefs
|
package org.koitharu.kotatsu.core.prefs
|
||||||
|
|
||||||
|
import androidx.annotation.Keep
|
||||||
|
|
||||||
|
@Keep
|
||||||
enum class ScreenshotsPolicy {
|
enum class ScreenshotsPolicy {
|
||||||
|
|
||||||
// Do not rename this
|
// Do not rename this
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
package org.koitharu.kotatsu.core.prefs
|
package org.koitharu.kotatsu.core.prefs
|
||||||
|
|
||||||
|
import androidx.annotation.Keep
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
|
|
||||||
|
@Keep
|
||||||
enum class SearchSuggestionType(
|
enum class SearchSuggestionType(
|
||||||
@StringRes val titleResId: Int,
|
@StringRes val titleResId: Int,
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package org.koitharu.kotatsu.core.prefs
|
||||||
|
|
||||||
|
import androidx.annotation.Keep
|
||||||
|
|
||||||
|
@Keep
|
||||||
|
enum class TrackerDownloadStrategy {
|
||||||
|
|
||||||
|
DISABLED, DOWNLOADED;
|
||||||
|
}
|
||||||
@@ -80,11 +80,11 @@ abstract class BasePreferenceFragment(@StringRes private val titleId: Int) :
|
|||||||
(activity as? SettingsActivity)?.setSectionTitle(title)
|
(activity as? SettingsActivity)?.setSectionTitle(title)
|
||||||
}
|
}
|
||||||
|
|
||||||
protected fun startActivitySafe(intent: Intent) {
|
protected fun startActivitySafe(intent: Intent): Boolean = try {
|
||||||
try {
|
startActivity(intent)
|
||||||
startActivity(intent)
|
true
|
||||||
} catch (_: ActivityNotFoundException) {
|
} catch (_: ActivityNotFoundException) {
|
||||||
Snackbar.make(listView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT).show()
|
Snackbar.make(listView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT).show()
|
||||||
}
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ abstract class BaseViewModel : ViewModel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected fun <T> Flow<T>.withErrorHandling() = catch { error ->
|
protected fun <T> Flow<T>.withErrorHandling() = catch { error ->
|
||||||
|
error.printStackTraceDebug()
|
||||||
errorEvent.call(error)
|
errorEvent.call(error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,21 @@
|
|||||||
package org.koitharu.kotatsu.core.ui
|
package org.koitharu.kotatsu.core.ui
|
||||||
|
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.content.IntentFilter
|
||||||
|
import android.os.PatternMatcher
|
||||||
import androidx.annotation.AnyThread
|
import androidx.annotation.AnyThread
|
||||||
import androidx.annotation.WorkerThread
|
import androidx.annotation.WorkerThread
|
||||||
|
import androidx.core.app.PendingIntentCompat
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.core.net.toUri
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.CoroutineExceptionHandler
|
import kotlinx.coroutines.CoroutineExceptionHandler
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
@@ -20,7 +29,15 @@ abstract class CoroutineIntentService : BaseService() {
|
|||||||
|
|
||||||
final override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
final override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
super.onStartCommand(intent, flags, startId)
|
super.onStartCommand(intent, flags, startId)
|
||||||
launchCoroutine(intent, startId)
|
val job = launchCoroutine(intent, startId)
|
||||||
|
val receiver = CancelReceiver(job)
|
||||||
|
ContextCompat.registerReceiver(
|
||||||
|
this,
|
||||||
|
receiver,
|
||||||
|
createIntentFilter(this, startId),
|
||||||
|
ContextCompat.RECEIVER_NOT_EXPORTED,
|
||||||
|
)
|
||||||
|
job.invokeOnCompletion { unregisterReceiver(receiver) }
|
||||||
return START_REDELIVER_INTENT
|
return START_REDELIVER_INTENT
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,8 +64,45 @@ abstract class CoroutineIntentService : BaseService() {
|
|||||||
@AnyThread
|
@AnyThread
|
||||||
protected abstract fun onError(startId: Int, error: Throwable)
|
protected abstract fun onError(startId: Int, error: Throwable)
|
||||||
|
|
||||||
|
protected fun getCancelIntent(startId: Int) = PendingIntentCompat.getBroadcast(
|
||||||
|
this,
|
||||||
|
0,
|
||||||
|
createCancelIntent(this, startId),
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
|
||||||
private fun errorHandler(startId: Int) = CoroutineExceptionHandler { _, throwable ->
|
private fun errorHandler(startId: Int) = CoroutineExceptionHandler { _, throwable ->
|
||||||
throwable.printStackTraceDebug()
|
throwable.printStackTraceDebug()
|
||||||
onError(startId, throwable)
|
onError(startId, throwable)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private class CancelReceiver(
|
||||||
|
private val job: Job
|
||||||
|
) : BroadcastReceiver() {
|
||||||
|
|
||||||
|
override fun onReceive(context: Context?, intent: Intent?) {
|
||||||
|
job.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
|
||||||
|
private const val SCHEME = "startid"
|
||||||
|
private const val ACTION_SUFFIX_CANCEL = ".ACTION_CANCEL"
|
||||||
|
|
||||||
|
fun createIntentFilter(service: CoroutineIntentService, startId: Int): IntentFilter {
|
||||||
|
val intentFilter = IntentFilter(cancelAction(service))
|
||||||
|
intentFilter.addDataScheme(SCHEME)
|
||||||
|
intentFilter.addDataPath(startId.toString(), PatternMatcher.PATTERN_LITERAL)
|
||||||
|
return intentFilter
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createCancelIntent(service: CoroutineIntentService, startId: Int): Intent {
|
||||||
|
return Intent(cancelAction(service))
|
||||||
|
.setData("$SCHEME://$startId".toUri())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun cancelAction(service: CoroutineIntentService) = service.javaClass.name + ACTION_SUFFIX_CANCEL
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package org.koitharu.kotatsu.core.ui
|
||||||
|
|
||||||
|
import android.view.View
|
||||||
|
|
||||||
|
fun interface OnContextClickListenerCompat {
|
||||||
|
|
||||||
|
fun onContextClick(v: View): Boolean
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ import kotlinx.coroutines.flow.FlowCollector
|
|||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
|
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
|
||||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||||
import java.util.Collections
|
import org.koitharu.kotatsu.parsers.util.move
|
||||||
import java.util.LinkedList
|
import java.util.LinkedList
|
||||||
|
|
||||||
open class ReorderableListAdapter<T : ListModel> : ListDelegationAdapter<List<T>>(), FlowCollector<List<T>?> {
|
open class ReorderableListAdapter<T : ListModel> : ListDelegationAdapter<List<T>>(), FlowCollector<List<T>?> {
|
||||||
@@ -28,13 +28,17 @@ open class ReorderableListAdapter<T : ListModel> : ListDelegationAdapter<List<T>
|
|||||||
listListeners.forEach { it.onCurrentListChanged(oldList, newList) }
|
listListeners.forEach { it.onCurrentListChanged(oldList, newList) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@Deprecated("Use emit() to dispatch list updates", level = DeprecationLevel.ERROR)
|
@Deprecated(
|
||||||
override fun setItems(items: List<T>?) {
|
message = "Use emit() to dispatch list updates",
|
||||||
super.setItems(items)
|
level = DeprecationLevel.ERROR,
|
||||||
}
|
replaceWith = ReplaceWith("emit(items)"),
|
||||||
|
)
|
||||||
|
override fun setItems(items: List<T>?) = super.setItems(items)
|
||||||
|
|
||||||
fun reorderItems(oldPos: Int, newPos: Int) {
|
fun reorderItems(oldPos: Int, newPos: Int) {
|
||||||
Collections.swap(items ?: return, oldPos, newPos)
|
val reordered = items?.toMutableList() ?: return
|
||||||
|
reordered.move(oldPos, newPos)
|
||||||
|
super.setItems(reordered)
|
||||||
notifyItemMoved(oldPos, newPos)
|
notifyItemMoved(oldPos, newPos)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package org.koitharu.kotatsu.core.ui.dialog
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.annotation.UiContext
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
|
|
||||||
|
object CommonAlertDialogs {
|
||||||
|
|
||||||
|
fun showDownloadConfirmation(
|
||||||
|
@UiContext context: Context,
|
||||||
|
onConfirmed: (startPaused: Boolean) -> Unit,
|
||||||
|
) = buildAlertDialog(context, isCentered = true) {
|
||||||
|
var startPaused = false
|
||||||
|
setTitle(R.string.save_manga)
|
||||||
|
setIcon(R.drawable.ic_download)
|
||||||
|
setMessage(R.string.save_manga_confirm)
|
||||||
|
setCheckbox(R.string.start_download, true) { _, isChecked ->
|
||||||
|
startPaused = !isChecked
|
||||||
|
}
|
||||||
|
setPositiveButton(R.string.save) { _, _ ->
|
||||||
|
onConfirmed(startPaused)
|
||||||
|
}
|
||||||
|
setNegativeButton(android.R.string.cancel, null)
|
||||||
|
}.show()
|
||||||
|
}
|
||||||
@@ -3,18 +3,46 @@ package org.koitharu.kotatsu.core.ui.list
|
|||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.View.OnClickListener
|
import android.view.View.OnClickListener
|
||||||
import android.view.View.OnLongClickListener
|
import android.view.View.OnLongClickListener
|
||||||
|
import androidx.core.util.Function
|
||||||
import com.hannesdorfmann.adapterdelegates4.dsl.AdapterDelegateViewBindingViewHolder
|
import com.hannesdorfmann.adapterdelegates4.dsl.AdapterDelegateViewBindingViewHolder
|
||||||
|
import org.koitharu.kotatsu.core.ui.OnContextClickListenerCompat
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.setOnContextClickListenerCompat
|
||||||
|
|
||||||
class AdapterDelegateClickListenerAdapter<I>(
|
class AdapterDelegateClickListenerAdapter<I, O>(
|
||||||
private val adapterDelegate: AdapterDelegateViewBindingViewHolder<out I, *>,
|
private val adapterDelegate: AdapterDelegateViewBindingViewHolder<out I, *>,
|
||||||
private val clickListener: OnListItemClickListener<I>,
|
private val clickListener: OnListItemClickListener<O>,
|
||||||
) : OnClickListener, OnLongClickListener {
|
private val itemMapper: Function<I, O>,
|
||||||
|
) : OnClickListener, OnLongClickListener, OnContextClickListenerCompat {
|
||||||
|
|
||||||
override fun onClick(v: View) {
|
override fun onClick(v: View) {
|
||||||
clickListener.onItemClick(adapterDelegate.item, v)
|
clickListener.onItemClick(mappedItem(), v)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onLongClick(v: View): Boolean {
|
override fun onLongClick(v: View): Boolean {
|
||||||
return clickListener.onItemLongClick(adapterDelegate.item, v)
|
return clickListener.onItemLongClick(mappedItem(), v)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onContextClick(v: View): Boolean {
|
||||||
|
return clickListener.onItemContextClick(mappedItem(), v)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mappedItem(): O = itemMapper.apply(adapterDelegate.item)
|
||||||
|
|
||||||
|
fun attach(itemView: View) {
|
||||||
|
itemView.setOnClickListener(this)
|
||||||
|
itemView.setOnLongClickListener(this)
|
||||||
|
itemView.setOnContextClickListenerCompat(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
operator fun <T> invoke(
|
||||||
|
adapterDelegate: AdapterDelegateViewBindingViewHolder<out T, *>,
|
||||||
|
clickListener: OnListItemClickListener<T>
|
||||||
|
): AdapterDelegateClickListenerAdapter<T, T> = AdapterDelegateClickListenerAdapter(
|
||||||
|
adapterDelegate = adapterDelegate,
|
||||||
|
clickListener = clickListener,
|
||||||
|
itemMapper = { x -> x },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,14 @@ package org.koitharu.kotatsu.core.ui.list
|
|||||||
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.Menu
|
import android.view.Menu
|
||||||
|
import android.view.MenuInflater
|
||||||
import android.view.MenuItem
|
import android.view.MenuItem
|
||||||
|
import android.view.View
|
||||||
import androidx.appcompat.app.AppCompatDelegate
|
import androidx.appcompat.app.AppCompatDelegate
|
||||||
import androidx.appcompat.view.ActionMode
|
import androidx.appcompat.view.ActionMode
|
||||||
|
import androidx.appcompat.widget.PopupMenu
|
||||||
import androidx.collection.LongSet
|
import androidx.collection.LongSet
|
||||||
|
import androidx.collection.longSetOf
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.lifecycle.LifecycleEventObserver
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
import androidx.lifecycle.LifecycleOwner
|
import androidx.lifecycle.LifecycleOwner
|
||||||
@@ -29,18 +33,21 @@ class ListSelectionController(
|
|||||||
) : ActionMode.Callback, SavedStateRegistry.SavedStateProvider {
|
) : ActionMode.Callback, SavedStateRegistry.SavedStateProvider {
|
||||||
|
|
||||||
private var actionMode: ActionMode? = null
|
private var actionMode: ActionMode? = null
|
||||||
|
private var focusedItemId: LongSet? = null
|
||||||
|
|
||||||
|
var useActionMode: Boolean = true
|
||||||
|
|
||||||
val count: Int
|
val count: Int
|
||||||
get() = decoration.checkedItemsCount
|
get() = if (focusedItemId != null) 1 else decoration.checkedItemsCount
|
||||||
|
|
||||||
init {
|
init {
|
||||||
registryOwner.lifecycle.addObserver(StateEventObserver())
|
registryOwner.lifecycle.addObserver(StateEventObserver())
|
||||||
}
|
}
|
||||||
|
|
||||||
fun snapshot(): Set<Long> = peekCheckedIds().toSet()
|
fun snapshot(): Set<Long> = (focusedItemId ?: peekCheckedIds()).toSet()
|
||||||
|
|
||||||
fun peekCheckedIds(): LongSet {
|
fun peekCheckedIds(): LongSet {
|
||||||
return decoration.checkedItemsIds
|
return focusedItemId ?: decoration.checkedItemsIds
|
||||||
}
|
}
|
||||||
|
|
||||||
fun clear() {
|
fun clear() {
|
||||||
@@ -52,6 +59,7 @@ class ListSelectionController(
|
|||||||
if (ids.isEmpty()) {
|
if (ids.isEmpty()) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
startActionMode()
|
||||||
decoration.checkAll(ids)
|
decoration.checkAll(ids)
|
||||||
notifySelectionChanged()
|
notifySelectionChanged()
|
||||||
}
|
}
|
||||||
@@ -80,15 +88,42 @@ class ListSelectionController(
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onItemLongClick(id: Long): Boolean {
|
fun onItemLongClick(view: View, id: Long): Boolean {
|
||||||
return startActionMode()?.also {
|
return if (useActionMode) {
|
||||||
decoration.setItemIsChecked(id, true)
|
startSelection(id)
|
||||||
notifySelectionChanged()
|
} else {
|
||||||
} != null
|
onItemContextClick(view, id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun onItemContextClick(view: View, id: Long): Boolean {
|
||||||
|
focusedItemId = longSetOf(id)
|
||||||
|
val menu = PopupMenu(view.context, view)
|
||||||
|
callback.onCreateActionMode(this, menu.menuInflater, menu.menu)
|
||||||
|
callback.onPrepareActionMode(this, null, menu.menu)
|
||||||
|
menu.setForceShowIcon(true)
|
||||||
|
if (menu.menu.hasVisibleItems()) {
|
||||||
|
menu.setOnMenuItemClickListener { menuItem ->
|
||||||
|
callback.onActionItemClicked(this, null, menuItem)
|
||||||
|
}
|
||||||
|
menu.setOnDismissListener {
|
||||||
|
focusedItemId = null
|
||||||
|
}
|
||||||
|
menu.show()
|
||||||
|
return true
|
||||||
|
} else {
|
||||||
|
focusedItemId = null
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun startSelection(id: Long): Boolean = startActionMode()?.also {
|
||||||
|
decoration.setItemIsChecked(id, true)
|
||||||
|
notifySelectionChanged()
|
||||||
|
} != null
|
||||||
|
|
||||||
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
|
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
|
||||||
return callback.onCreateActionMode(this, mode, menu)
|
return callback.onCreateActionMode(this, mode.menuInflater, menu)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean {
|
override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean {
|
||||||
@@ -106,6 +141,7 @@ class ListSelectionController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun startActionMode(): ActionMode? {
|
private fun startActionMode(): ActionMode? {
|
||||||
|
focusedItemId = null
|
||||||
return actionMode ?: appCompatDelegate.startSupportActionMode(this).also {
|
return actionMode ?: appCompatDelegate.startSupportActionMode(this).also {
|
||||||
actionMode = it
|
actionMode = it
|
||||||
}
|
}
|
||||||
@@ -134,14 +170,14 @@ class ListSelectionController(
|
|||||||
|
|
||||||
fun onSelectionChanged(controller: ListSelectionController, count: Int)
|
fun onSelectionChanged(controller: ListSelectionController, count: Int)
|
||||||
|
|
||||||
fun onCreateActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean
|
fun onCreateActionMode(controller: ListSelectionController, menuInflater: MenuInflater, menu: Menu): Boolean
|
||||||
|
|
||||||
fun onPrepareActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean {
|
fun onPrepareActionMode(controller: ListSelectionController, mode: ActionMode?, menu: Menu): Boolean {
|
||||||
mode.title = controller.count.toString()
|
mode?.title = controller.count.toString()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onActionItemClicked(controller: ListSelectionController, mode: ActionMode, item: MenuItem): Boolean
|
fun onActionItemClicked(controller: ListSelectionController, mode: ActionMode?, item: MenuItem): Boolean
|
||||||
|
|
||||||
fun onDestroyActionMode(controller: ListSelectionController, mode: ActionMode) = Unit
|
fun onDestroyActionMode(controller: ListSelectionController, mode: ActionMode) = Unit
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,5 +6,7 @@ fun interface OnListItemClickListener<I> {
|
|||||||
|
|
||||||
fun onItemClick(item: I, view: View)
|
fun onItemClick(item: I, view: View)
|
||||||
|
|
||||||
fun onItemLongClick(item: I, view: View) = false
|
fun onItemLongClick(item: I, view: View): Boolean = false
|
||||||
|
|
||||||
|
fun onItemContextClick(item: I, view: View): Boolean = onItemLongClick(item, view)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,14 +4,22 @@ import androidx.annotation.StringRes
|
|||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.core.model.SortDirection
|
import org.koitharu.kotatsu.core.model.SortDirection
|
||||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||||
|
import org.koitharu.kotatsu.parsers.model.SortOrder.ADDED
|
||||||
|
import org.koitharu.kotatsu.parsers.model.SortOrder.ADDED_ASC
|
||||||
import org.koitharu.kotatsu.parsers.model.SortOrder.ALPHABETICAL
|
import org.koitharu.kotatsu.parsers.model.SortOrder.ALPHABETICAL
|
||||||
import org.koitharu.kotatsu.parsers.model.SortOrder.ALPHABETICAL_DESC
|
import org.koitharu.kotatsu.parsers.model.SortOrder.ALPHABETICAL_DESC
|
||||||
import org.koitharu.kotatsu.parsers.model.SortOrder.NEWEST
|
import org.koitharu.kotatsu.parsers.model.SortOrder.NEWEST
|
||||||
import org.koitharu.kotatsu.parsers.model.SortOrder.NEWEST_ASC
|
import org.koitharu.kotatsu.parsers.model.SortOrder.NEWEST_ASC
|
||||||
import org.koitharu.kotatsu.parsers.model.SortOrder.POPULARITY
|
import org.koitharu.kotatsu.parsers.model.SortOrder.POPULARITY
|
||||||
import org.koitharu.kotatsu.parsers.model.SortOrder.POPULARITY_ASC
|
import org.koitharu.kotatsu.parsers.model.SortOrder.POPULARITY_ASC
|
||||||
|
import org.koitharu.kotatsu.parsers.model.SortOrder.POPULARITY_HOUR
|
||||||
|
import org.koitharu.kotatsu.parsers.model.SortOrder.POPULARITY_MONTH
|
||||||
|
import org.koitharu.kotatsu.parsers.model.SortOrder.POPULARITY_TODAY
|
||||||
|
import org.koitharu.kotatsu.parsers.model.SortOrder.POPULARITY_WEEK
|
||||||
|
import org.koitharu.kotatsu.parsers.model.SortOrder.POPULARITY_YEAR
|
||||||
import org.koitharu.kotatsu.parsers.model.SortOrder.RATING
|
import org.koitharu.kotatsu.parsers.model.SortOrder.RATING
|
||||||
import org.koitharu.kotatsu.parsers.model.SortOrder.RATING_ASC
|
import org.koitharu.kotatsu.parsers.model.SortOrder.RATING_ASC
|
||||||
|
import org.koitharu.kotatsu.parsers.model.SortOrder.RELEVANCE
|
||||||
import org.koitharu.kotatsu.parsers.model.SortOrder.UPDATED
|
import org.koitharu.kotatsu.parsers.model.SortOrder.UPDATED
|
||||||
import org.koitharu.kotatsu.parsers.model.SortOrder.UPDATED_ASC
|
import org.koitharu.kotatsu.parsers.model.SortOrder.UPDATED_ASC
|
||||||
|
|
||||||
@@ -28,6 +36,14 @@ val SortOrder.titleRes: Int
|
|||||||
POPULARITY_ASC -> R.string.unpopular
|
POPULARITY_ASC -> R.string.unpopular
|
||||||
RATING_ASC -> R.string.low_rating
|
RATING_ASC -> R.string.low_rating
|
||||||
NEWEST_ASC -> R.string.order_oldest
|
NEWEST_ASC -> R.string.order_oldest
|
||||||
|
ADDED -> R.string.recently_added
|
||||||
|
ADDED_ASC -> R.string.added_long_ago
|
||||||
|
RELEVANCE -> R.string.by_relevance
|
||||||
|
POPULARITY_HOUR -> R.string.popular_in_hour
|
||||||
|
POPULARITY_TODAY -> R.string.popular_today
|
||||||
|
POPULARITY_WEEK -> R.string.popular_in_week
|
||||||
|
POPULARITY_MONTH -> R.string.popular_in_month
|
||||||
|
POPULARITY_YEAR -> R.string.popular_in_year
|
||||||
}
|
}
|
||||||
|
|
||||||
val SortOrder.direction: SortDirection
|
val SortOrder.direction: SortDirection
|
||||||
@@ -36,11 +52,19 @@ val SortOrder.direction: SortDirection
|
|||||||
POPULARITY_ASC,
|
POPULARITY_ASC,
|
||||||
RATING_ASC,
|
RATING_ASC,
|
||||||
NEWEST_ASC,
|
NEWEST_ASC,
|
||||||
|
ADDED_ASC,
|
||||||
ALPHABETICAL -> SortDirection.ASC
|
ALPHABETICAL -> SortDirection.ASC
|
||||||
|
|
||||||
UPDATED,
|
UPDATED,
|
||||||
POPULARITY,
|
POPULARITY,
|
||||||
|
POPULARITY_HOUR,
|
||||||
|
POPULARITY_TODAY,
|
||||||
|
POPULARITY_WEEK,
|
||||||
|
POPULARITY_MONTH,
|
||||||
|
POPULARITY_YEAR,
|
||||||
RATING,
|
RATING,
|
||||||
NEWEST,
|
NEWEST,
|
||||||
|
ADDED,
|
||||||
|
RELEVANCE,
|
||||||
ALPHABETICAL_DESC -> SortDirection.DESC
|
ALPHABETICAL_DESC -> SortDirection.DESC
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,11 +4,15 @@ import android.view.MenuItem
|
|||||||
import android.view.View
|
import android.view.View
|
||||||
import androidx.appcompat.widget.PopupMenu
|
import androidx.appcompat.widget.PopupMenu
|
||||||
import androidx.core.view.MenuProvider
|
import androidx.core.view.MenuProvider
|
||||||
|
import org.koitharu.kotatsu.core.ui.OnContextClickListenerCompat
|
||||||
import org.koitharu.kotatsu.core.util.ext.setOnContextClickListenerCompat
|
import org.koitharu.kotatsu.core.util.ext.setOnContextClickListenerCompat
|
||||||
|
|
||||||
class PopupMenuMediator(
|
class PopupMenuMediator(
|
||||||
private val provider: MenuProvider,
|
private val provider: MenuProvider,
|
||||||
) : View.OnLongClickListener, PopupMenu.OnMenuItemClickListener, PopupMenu.OnDismissListener {
|
) : View.OnLongClickListener, OnContextClickListenerCompat, PopupMenu.OnMenuItemClickListener,
|
||||||
|
PopupMenu.OnDismissListener {
|
||||||
|
|
||||||
|
override fun onContextClick(v: View): Boolean = onLongClick(v)
|
||||||
|
|
||||||
override fun onLongClick(v: View): Boolean {
|
override fun onLongClick(v: View): Boolean {
|
||||||
val menu = PopupMenu(v.context, v)
|
val menu = PopupMenu(v.context, v)
|
||||||
|
|||||||
@@ -8,18 +8,32 @@ import androidx.annotation.ColorRes
|
|||||||
import androidx.annotation.DrawableRes
|
import androidx.annotation.DrawableRes
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
import androidx.core.view.children
|
import androidx.core.view.children
|
||||||
|
import coil.ImageLoader
|
||||||
|
import coil.request.Disposable
|
||||||
|
import coil.request.ImageRequest
|
||||||
|
import coil.transform.RoundedCornersTransformation
|
||||||
import com.google.android.material.chip.Chip
|
import com.google.android.material.chip.Chip
|
||||||
import com.google.android.material.chip.ChipDrawable
|
import com.google.android.material.chip.ChipDrawable
|
||||||
import com.google.android.material.chip.ChipGroup
|
import com.google.android.material.chip.ChipGroup
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.core.ui.image.ChipIconTarget
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.enqueueWith
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.setProgressIcon
|
||||||
|
import org.koitharu.kotatsu.parsers.util.ifZero
|
||||||
|
import javax.inject.Inject
|
||||||
import com.google.android.material.R as materialR
|
import com.google.android.material.R as materialR
|
||||||
|
|
||||||
|
@AndroidEntryPoint
|
||||||
class ChipsView @JvmOverloads constructor(
|
class ChipsView @JvmOverloads constructor(
|
||||||
context: Context,
|
context: Context,
|
||||||
attrs: AttributeSet? = null,
|
attrs: AttributeSet? = null,
|
||||||
defStyleAttr: Int = com.google.android.material.R.attr.chipGroupStyle,
|
defStyleAttr: Int = com.google.android.material.R.attr.chipGroupStyle,
|
||||||
) : ChipGroup(context, attrs, defStyleAttr) {
|
) : ChipGroup(context, attrs, defStyleAttr) {
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var coil: ImageLoader
|
||||||
|
|
||||||
private var isLayoutSuppressedCompat = false
|
private var isLayoutSuppressedCompat = false
|
||||||
private var isLayoutCalledOnSuppressed = false
|
private var isLayoutCalledOnSuppressed = false
|
||||||
private val chipOnClickListener = InternalChipClickListener()
|
private val chipOnClickListener = InternalChipClickListener()
|
||||||
@@ -36,11 +50,6 @@ class ChipsView @JvmOverloads constructor(
|
|||||||
children.forEach { it.isClickable = isChipClickable }
|
children.forEach { it.isClickable = isChipClickable }
|
||||||
}
|
}
|
||||||
var onChipCloseClickListener: OnChipCloseClickListener? = null
|
var onChipCloseClickListener: OnChipCloseClickListener? = null
|
||||||
set(value) {
|
|
||||||
field = value
|
|
||||||
val isCloseIconVisible = value != null
|
|
||||||
children.forEach { (it as? Chip)?.isCloseIconVisible = isCloseIconVisible }
|
|
||||||
}
|
|
||||||
|
|
||||||
init {
|
init {
|
||||||
val ta = context.obtainStyledAttributes(attrs, R.styleable.ChipsView, defStyleAttr, 0)
|
val ta = context.obtainStyledAttributes(attrs, R.styleable.ChipsView, defStyleAttr, 0)
|
||||||
@@ -95,15 +104,19 @@ class ChipsView @JvmOverloads constructor(
|
|||||||
val title: CharSequence? = null,
|
val title: CharSequence? = null,
|
||||||
@StringRes val titleResId: Int = 0,
|
@StringRes val titleResId: Int = 0,
|
||||||
@DrawableRes val icon: Int = 0,
|
@DrawableRes val icon: Int = 0,
|
||||||
|
val iconData: Any? = null,
|
||||||
@ColorRes val tint: Int = 0,
|
@ColorRes val tint: Int = 0,
|
||||||
val isChecked: Boolean = false,
|
val isChecked: Boolean = false,
|
||||||
|
val isLoading: Boolean = false,
|
||||||
val isDropdown: Boolean = false,
|
val isDropdown: Boolean = false,
|
||||||
|
val isCloseable: Boolean = false,
|
||||||
val data: Any? = null,
|
val data: Any? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
private inner class DataChip(context: Context) : Chip(context) {
|
private inner class DataChip(context: Context) : Chip(context) {
|
||||||
|
|
||||||
private var model: ChipModel? = null
|
private var model: ChipModel? = null
|
||||||
|
private var imageRequest: Disposable? = null
|
||||||
|
|
||||||
init {
|
init {
|
||||||
val drawable = ChipDrawable.createFromAttributes(context, null, 0, chipStyle)
|
val drawable = ChipDrawable.createFromAttributes(context, null, 0, chipStyle)
|
||||||
@@ -116,6 +129,9 @@ class ChipsView @JvmOverloads constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun bind(model: ChipModel) {
|
fun bind(model: ChipModel) {
|
||||||
|
if (this.model == model) {
|
||||||
|
return
|
||||||
|
}
|
||||||
this.model = model
|
this.model = model
|
||||||
|
|
||||||
if (model.titleResId == 0) {
|
if (model.titleResId == 0) {
|
||||||
@@ -131,15 +147,9 @@ class ChipsView @JvmOverloads constructor(
|
|||||||
isChecked = false
|
isChecked = false
|
||||||
isCheckable = false
|
isCheckable = false
|
||||||
}
|
}
|
||||||
if (model.icon == 0 || model.isChecked) {
|
bindIcon(model)
|
||||||
chipIcon = null
|
|
||||||
isChipIconVisible = false
|
|
||||||
} else {
|
|
||||||
setChipIconResource(model.icon)
|
|
||||||
isChipIconVisible = true
|
|
||||||
}
|
|
||||||
isCheckedIconVisible = model.isChecked
|
isCheckedIconVisible = model.isChecked
|
||||||
isCloseIconVisible = if (onChipCloseClickListener != null || model.isDropdown) {
|
isCloseIconVisible = if (model.isCloseable || model.isDropdown) {
|
||||||
setCloseIconResource(
|
setCloseIconResource(
|
||||||
if (model.isDropdown) R.drawable.ic_expand_more else materialR.drawable.ic_m3_chip_close,
|
if (model.isDropdown) R.drawable.ic_expand_more else materialR.drawable.ic_m3_chip_close,
|
||||||
)
|
)
|
||||||
@@ -151,6 +161,54 @@ class ChipsView @JvmOverloads constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun toggle() = Unit
|
override fun toggle() = Unit
|
||||||
|
|
||||||
|
private fun bindIcon(model: ChipModel) {
|
||||||
|
when {
|
||||||
|
model.isChecked -> {
|
||||||
|
imageRequest?.dispose()
|
||||||
|
imageRequest = null
|
||||||
|
chipIcon = null
|
||||||
|
isChipIconVisible = false
|
||||||
|
}
|
||||||
|
|
||||||
|
model.isLoading -> {
|
||||||
|
imageRequest?.dispose()
|
||||||
|
imageRequest = null
|
||||||
|
isChipIconVisible = true
|
||||||
|
setProgressIcon()
|
||||||
|
}
|
||||||
|
|
||||||
|
model.iconData != null -> {
|
||||||
|
val placeholder = model.icon.ifZero { materialR.drawable.navigation_empty_icon }
|
||||||
|
imageRequest = ImageRequest.Builder(context)
|
||||||
|
.data(model.iconData)
|
||||||
|
.crossfade(false)
|
||||||
|
.size(resources.getDimensionPixelSize(materialR.dimen.m3_chip_icon_size))
|
||||||
|
.target(ChipIconTarget(this))
|
||||||
|
.placeholder(placeholder)
|
||||||
|
.fallback(placeholder)
|
||||||
|
.error(placeholder)
|
||||||
|
.transformations(RoundedCornersTransformation(resources.getDimension(R.dimen.chip_icon_corner)))
|
||||||
|
.allowRgb565(true)
|
||||||
|
.enqueueWith(coil)
|
||||||
|
isChipIconVisible = true
|
||||||
|
}
|
||||||
|
|
||||||
|
model.icon != 0 -> {
|
||||||
|
imageRequest?.dispose()
|
||||||
|
imageRequest = null
|
||||||
|
setChipIconResource(model.icon)
|
||||||
|
isChipIconVisible = true
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
imageRequest?.dispose()
|
||||||
|
imageRequest = null
|
||||||
|
chipIcon = null
|
||||||
|
isChipIconVisible = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private inner class InternalChipClickListener : OnClickListener {
|
private inner class InternalChipClickListener : OnClickListener {
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
package org.koitharu.kotatsu.core.util
|
||||||
|
|
||||||
|
interface CloseableSequence<T> : Sequence<T>, AutoCloseable
|
||||||
@@ -1,13 +1,12 @@
|
|||||||
package org.koitharu.kotatsu.core.util
|
package org.koitharu.kotatsu.core.util
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.widget.Toast
|
|
||||||
import androidx.core.app.ShareCompat
|
import androidx.core.app.ShareCompat
|
||||||
import androidx.core.content.FileProvider
|
import androidx.core.content.FileProvider
|
||||||
import org.koitharu.kotatsu.BuildConfig
|
import org.koitharu.kotatsu.BuildConfig
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.core.logs.FileLogger
|
|
||||||
import org.koitharu.kotatsu.core.model.appUrl
|
import org.koitharu.kotatsu.core.model.appUrl
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
import java.io.File
|
import java.io.File
|
||||||
@@ -77,32 +76,9 @@ class ShareHelper(private val context: Context) {
|
|||||||
.startChooser()
|
.startChooser()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun shareText(text: String) {
|
fun getShareTextIntent(text: String): Intent = ShareCompat.IntentBuilder(context)
|
||||||
ShareCompat.IntentBuilder(context)
|
.setText(text)
|
||||||
.setText(text)
|
.setType(TYPE_TEXT)
|
||||||
.setType(TYPE_TEXT)
|
.setChooserTitle(R.string.share)
|
||||||
.setChooserTitle(R.string.share)
|
.createChooserIntent()
|
||||||
.startChooser()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun shareLogs(loggers: Collection<FileLogger>) {
|
|
||||||
val intentBuilder = ShareCompat.IntentBuilder(context)
|
|
||||||
.setType(TYPE_TEXT)
|
|
||||||
var hasLogs = false
|
|
||||||
for (logger in loggers) {
|
|
||||||
val logFile = logger.file
|
|
||||||
if (!logFile.exists()) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
val uri = FileProvider.getUriForFile(context, "${BuildConfig.APPLICATION_ID}.files", logFile)
|
|
||||||
intentBuilder.addStream(uri)
|
|
||||||
hasLogs = true
|
|
||||||
}
|
|
||||||
if (hasLogs) {
|
|
||||||
intentBuilder.setChooserTitle(R.string.share_logs)
|
|
||||||
intentBuilder.startChooser()
|
|
||||||
} else {
|
|
||||||
Toast.makeText(context, R.string.nothing_here, Toast.LENGTH_SHORT).show()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,10 +7,12 @@ import android.app.ActivityManager
|
|||||||
import android.app.ActivityManager.MemoryInfo
|
import android.app.ActivityManager.MemoryInfo
|
||||||
import android.app.ActivityOptions
|
import android.app.ActivityOptions
|
||||||
import android.app.LocaleConfig
|
import android.app.LocaleConfig
|
||||||
|
import android.content.ComponentName
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Context.ACTIVITY_SERVICE
|
import android.content.Context.ACTIVITY_SERVICE
|
||||||
import android.content.Context.POWER_SERVICE
|
import android.content.Context.POWER_SERVICE
|
||||||
import android.content.ContextWrapper
|
import android.content.ContextWrapper
|
||||||
|
import android.content.Intent
|
||||||
import android.content.OperationApplicationException
|
import android.content.OperationApplicationException
|
||||||
import android.content.SharedPreferences
|
import android.content.SharedPreferences
|
||||||
import android.content.SyncResult
|
import android.content.SyncResult
|
||||||
@@ -33,6 +35,7 @@ import androidx.annotation.IntegerRes
|
|||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import androidx.appcompat.app.AppCompatDelegate
|
import androidx.appcompat.app.AppCompatDelegate
|
||||||
import androidx.appcompat.app.AppCompatDialog
|
import androidx.appcompat.app.AppCompatDialog
|
||||||
|
import androidx.core.app.ActivityCompat
|
||||||
import androidx.core.app.ActivityOptionsCompat
|
import androidx.core.app.ActivityOptionsCompat
|
||||||
import androidx.core.app.NotificationManagerCompat
|
import androidx.core.app.NotificationManagerCompat
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
@@ -61,6 +64,7 @@ import okio.use
|
|||||||
import org.json.JSONException
|
import org.json.JSONException
|
||||||
import org.jsoup.internal.StringUtil.StringJoiner
|
import org.jsoup.internal.StringUtil.StringJoiner
|
||||||
import org.koitharu.kotatsu.BuildConfig
|
import org.koitharu.kotatsu.BuildConfig
|
||||||
|
import org.koitharu.kotatsu.main.ui.MainActivity
|
||||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||||
import org.xmlpull.v1.XmlPullParser
|
import org.xmlpull.v1.XmlPullParser
|
||||||
import org.xmlpull.v1.XmlPullParserException
|
import org.xmlpull.v1.XmlPullParserException
|
||||||
@@ -274,3 +278,10 @@ fun WebView.configureForParser(userAgentOverride: String?) = with(settings) {
|
|||||||
userAgentString = userAgentOverride
|
userAgentString = userAgentOverride
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun Context.restartApplication() {
|
||||||
|
val activity = findActivity()
|
||||||
|
val intent = Intent.makeRestartActivityTask(ComponentName(this, MainActivity::class.java))
|
||||||
|
startActivity(intent)
|
||||||
|
activity?.finishAndRemoveTask()
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import androidx.core.os.BundleCompat
|
|||||||
import androidx.core.os.ParcelCompat
|
import androidx.core.os.ParcelCompat
|
||||||
import androidx.lifecycle.SavedStateHandle
|
import androidx.lifecycle.SavedStateHandle
|
||||||
import java.io.Serializable
|
import java.io.Serializable
|
||||||
|
import java.util.EnumSet
|
||||||
|
|
||||||
// https://issuetracker.google.com/issues/240585930
|
// https://issuetracker.google.com/issues/240585930
|
||||||
|
|
||||||
@@ -19,6 +20,10 @@ inline fun <reified T : Parcelable> Bundle.getParcelableCompat(key: String): T?
|
|||||||
return BundleCompat.getParcelable(this, key, T::class.java)
|
return BundleCompat.getParcelable(this, key, T::class.java)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
inline fun <reified T : Parcelable> Bundle.requireParcelable(key: String): T = checkNotNull(getParcelableCompat(key)) {
|
||||||
|
"Parcelable of type \"${T::class.java.name}\" not found at \"$key\""
|
||||||
|
}
|
||||||
|
|
||||||
inline fun <reified T : Parcelable> Intent.getParcelableExtraCompat(key: String): T? {
|
inline fun <reified T : Parcelable> Intent.getParcelableExtraCompat(key: String): T? {
|
||||||
return IntentCompat.getParcelableExtra(this, key, T::class.java)
|
return IntentCompat.getParcelableExtra(this, key, T::class.java)
|
||||||
}
|
}
|
||||||
@@ -53,6 +58,31 @@ inline fun <reified T : Serializable> Bundle.requireSerializable(key: String): T
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun <E : Enum<E>> Parcel.writeEnumSet(set: Set<E>?) {
|
||||||
|
if (set == null) {
|
||||||
|
writeValue(null)
|
||||||
|
} else {
|
||||||
|
val array = IntArray(set.size)
|
||||||
|
set.forEachIndexed { i, e -> array[i] = e.ordinal }
|
||||||
|
writeIntArray(array)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
inline fun <reified E : Enum<E>> Parcel.readEnumSet(): Set<E>? = readEnumSet(E::class.java)
|
||||||
|
|
||||||
|
fun <E : Enum<E>> Parcel.readEnumSet(cls: Class<E>): Set<E>? {
|
||||||
|
val array = createIntArray() ?: return null
|
||||||
|
if (array.isEmpty()) {
|
||||||
|
return emptySet()
|
||||||
|
}
|
||||||
|
val enumValues = cls.enumConstants ?: return null
|
||||||
|
val set = EnumSet.noneOf(cls)
|
||||||
|
array.forEach { e ->
|
||||||
|
set.add(enumValues[e])
|
||||||
|
}
|
||||||
|
return set
|
||||||
|
}
|
||||||
|
|
||||||
fun <T> SavedStateHandle.require(key: String): T {
|
fun <T> SavedStateHandle.require(key: String): T {
|
||||||
return checkNotNull(get(key)) {
|
return checkNotNull(get(key)) {
|
||||||
"Value $key not found in SavedStateHandle or has a wrong type"
|
"Value $key not found in SavedStateHandle or has a wrong type"
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ import coil.request.SuccessResult
|
|||||||
import coil.util.CoilUtils
|
import coil.util.CoilUtils
|
||||||
import com.google.android.material.progressindicator.BaseProgressIndicator
|
import com.google.android.material.progressindicator.BaseProgressIndicator
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.core.image.RegionBitmapDecoder
|
||||||
import org.koitharu.kotatsu.core.ui.image.AnimatedPlaceholderDrawable
|
import org.koitharu.kotatsu.core.ui.image.AnimatedPlaceholderDrawable
|
||||||
import org.koitharu.kotatsu.core.ui.image.RegionBitmapDecoder
|
|
||||||
import org.koitharu.kotatsu.core.util.progress.ImageRequestIndicatorListener
|
import org.koitharu.kotatsu.core.util.progress.ImageRequestIndicatorListener
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
import com.google.android.material.R as materialR
|
import com.google.android.material.R as materialR
|
||||||
@@ -63,7 +63,7 @@ fun ImageRequest.Builder.indicator(indicators: List<BaseProgressIndicator<*>>):
|
|||||||
|
|
||||||
fun ImageRequest.Builder.decodeRegion(
|
fun ImageRequest.Builder.decodeRegion(
|
||||||
scroll: Int = RegionBitmapDecoder.SCROLL_UNDEFINED,
|
scroll: Int = RegionBitmapDecoder.SCROLL_UNDEFINED,
|
||||||
): ImageRequest.Builder = decoderFactory(RegionBitmapDecoder.Factory())
|
): ImageRequest.Builder = decoderFactory(RegionBitmapDecoder.Factory)
|
||||||
.setParameter(RegionBitmapDecoder.PARAM_SCROLL, scroll)
|
.setParameter(RegionBitmapDecoder.PARAM_SCROLL, scroll)
|
||||||
|
|
||||||
@Suppress("SpellCheckingInspection")
|
@Suppress("SpellCheckingInspection")
|
||||||
|
|||||||
@@ -25,6 +25,12 @@ fun <T> Collection<T>.asArrayList(): ArrayList<T> = if (this is ArrayList<*>) {
|
|||||||
ArrayList(this)
|
ArrayList(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun <E : Enum<E>> Set<E>.asEnumSet(cls: Class<E>): EnumSet<E> = if (this is EnumSet<*>) {
|
||||||
|
this as EnumSet<E>
|
||||||
|
} else {
|
||||||
|
EnumSet.noneOf(cls).apply { addAll(this@asEnumSet) }
|
||||||
|
}
|
||||||
|
|
||||||
fun <K, V> Map<K, V>.findKeyByValue(value: V): K? {
|
fun <K, V> Map<K, V>.findKeyByValue(value: V): K? {
|
||||||
for ((k, v) in entries) {
|
for ((k, v) in entries) {
|
||||||
if (v == value) {
|
if (v == value) {
|
||||||
@@ -86,8 +92,19 @@ fun LongSet.toLongArray(): LongArray {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
fun LongSet.toSet(): Set<Long> = toCollection(ArraySet<Long>(size))
|
fun LongSet.toSet(): Set<Long> = toCollection(ArraySet(size))
|
||||||
|
|
||||||
fun <R : MutableCollection<Long>> LongSet.toCollection(out: R): R = out.also { result ->
|
fun <R : MutableCollection<Long>> LongSet.toCollection(out: R): R = out.also { result ->
|
||||||
forEach(result::add)
|
forEach(result::add)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun <T, R> Collection<T>.mapSortedByCount(isDescending: Boolean = true, mapper: (T) -> R): List<R> {
|
||||||
|
val grouped = groupBy(mapper).toList()
|
||||||
|
val sortSelector: (Pair<R, List<T>>) -> Int = { it.second.size }
|
||||||
|
val sorted = if (isDescending) {
|
||||||
|
grouped.sortedByDescending(sortSelector)
|
||||||
|
} else {
|
||||||
|
grouped.sortedBy(sortSelector)
|
||||||
|
}
|
||||||
|
return sorted.map { it.first }
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,16 +7,17 @@ import android.os.Build
|
|||||||
import android.os.Environment
|
import android.os.Environment
|
||||||
import android.os.storage.StorageManager
|
import android.os.storage.StorageManager
|
||||||
import android.provider.OpenableColumns
|
import android.provider.OpenableColumns
|
||||||
|
import android.webkit.MimeTypeMap
|
||||||
import androidx.core.database.getStringOrNull
|
import androidx.core.database.getStringOrNull
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.runInterruptible
|
import kotlinx.coroutines.runInterruptible
|
||||||
import okhttp3.internal.closeQuietly
|
import okhttp3.MediaType
|
||||||
|
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||||
import org.jetbrains.annotations.Blocking
|
import org.jetbrains.annotations.Blocking
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.core.fs.FileSequence
|
import org.koitharu.kotatsu.core.fs.FileSequence
|
||||||
|
import java.io.BufferedReader
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.FileFilter
|
|
||||||
import java.io.InputStream
|
|
||||||
import java.nio.file.attribute.BasicFileAttributes
|
import java.nio.file.attribute.BasicFileAttributes
|
||||||
import java.util.zip.ZipEntry
|
import java.util.zip.ZipEntry
|
||||||
import java.util.zip.ZipFile
|
import java.util.zip.ZipFile
|
||||||
@@ -36,17 +37,15 @@ fun File.takeIfWriteable() = takeIf { it.exists() && it.canWrite() }
|
|||||||
fun File.isNotEmpty() = length() != 0L
|
fun File.isNotEmpty() = length() != 0L
|
||||||
|
|
||||||
@Blocking
|
@Blocking
|
||||||
fun ZipFile.readText(entry: ZipEntry) = getInputStream(entry).bufferedReader().use {
|
fun ZipFile.readText(entry: ZipEntry) = getInputStream(entry).use { output ->
|
||||||
it.readText()
|
output.bufferedReader().use(BufferedReader::readText)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Blocking
|
val ZipEntry.mimeType: MediaType?
|
||||||
fun ZipFile.getInputStreamOrClose(entry: ZipEntry): InputStream = try {
|
get() {
|
||||||
getInputStream(entry)
|
val ext = name.substringAfterLast('.')
|
||||||
} catch (e: Throwable) {
|
return MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext)?.toMediaTypeOrNull()
|
||||||
closeQuietly()
|
}
|
||||||
throw e
|
|
||||||
}
|
|
||||||
|
|
||||||
fun File.getStorageName(context: Context): String = runCatching {
|
fun File.getStorageName(context: Context): String = runCatching {
|
||||||
val manager = context.getSystemService(Context.STORAGE_SERVICE) as StorageManager
|
val manager = context.getSystemService(Context.STORAGE_SERVICE) as StorageManager
|
||||||
@@ -87,9 +86,13 @@ suspend fun File.computeSize(): Long = runInterruptible(Dispatchers.IO) {
|
|||||||
walkCompat(includeDirectories = false).sumOf { it.length() }
|
walkCompat(includeDirectories = false).sumOf { it.length() }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun File.children() = FileSequence(this)
|
inline fun <R> File.withChildren(block: (children: Sequence<File>) -> R): R = FileSequence(this).use(block)
|
||||||
|
|
||||||
fun Sequence<File>.filterWith(filter: FileFilter): Sequence<File> = filter { f -> filter.accept(f) }
|
fun FileSequence(dir: File): FileSequence = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
FileSequence.StreamImpl(dir)
|
||||||
|
} else {
|
||||||
|
FileSequence.ListImpl(dir)
|
||||||
|
}
|
||||||
|
|
||||||
val File.creationTime
|
val File.creationTime
|
||||||
get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import kotlinx.coroutines.flow.onEach
|
|||||||
import kotlinx.coroutines.flow.transform
|
import kotlinx.coroutines.flow.transform
|
||||||
import kotlinx.coroutines.flow.transformLatest
|
import kotlinx.coroutines.flow.transformLatest
|
||||||
import kotlinx.coroutines.flow.transformWhile
|
import kotlinx.coroutines.flow.transformWhile
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.parsers.util.SuspendLazy
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
import java.util.concurrent.atomic.AtomicInteger
|
import java.util.concurrent.atomic.AtomicInteger
|
||||||
|
|
||||||
@@ -132,3 +132,5 @@ suspend fun <T : Any> Flow<T?>.firstNotNull(): T = checkNotNull(first { x -> x !
|
|||||||
suspend fun <T : Any> Flow<T?>.firstNotNullOrNull(): T? = firstOrNull { x -> x != null }
|
suspend fun <T : Any> Flow<T?>.firstNotNullOrNull(): T? = firstOrNull { x -> x != null }
|
||||||
|
|
||||||
fun <T> Flow<Flow<T>>.flattenLatest() = flatMapLatest { it }
|
fun <T> Flow<Flow<T>>.flattenLatest() = flatMapLatest { it }
|
||||||
|
|
||||||
|
fun <T> SuspendLazy<T>.asFlow() = flow { emit(tryGet()) }
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import okhttp3.HttpUrl
|
|||||||
import okhttp3.MediaType.Companion.toMediaType
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
import okhttp3.RequestBody.Companion.toRequestBody
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
import okhttp3.ResponseBody
|
|
||||||
import okhttp3.internal.closeQuietly
|
import okhttp3.internal.closeQuietly
|
||||||
import okio.IOException
|
import okio.IOException
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
@@ -41,8 +40,6 @@ fun Response.ensureSuccess() = apply {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun Response.requireBody(): ResponseBody = checkNotNull(body) { "Response body is null" }
|
|
||||||
|
|
||||||
fun Cookie.newBuilder(): Cookie.Builder = Cookie.Builder().also { c ->
|
fun Cookie.newBuilder(): Cookie.Builder = Cookie.Builder().also { c ->
|
||||||
c.name(name)
|
c.name(name)
|
||||||
c.value(value)
|
c.value(value)
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user