Compare commits
193 Commits
feature/dy
...
v7.5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b9c2e49b9 | ||
|
|
afeb307453 | ||
|
|
7568b1aedc | ||
|
|
742d8cee00 | ||
|
|
d52bef28ff | ||
|
|
b2f48421c7 | ||
|
|
22643bf9cc | ||
|
|
861ca63ea9 | ||
|
|
6e34356b6f | ||
|
|
a9b3025724 | ||
|
|
0cc019ef19 | ||
|
|
eb49b31aeb | ||
|
|
5ebbfd1c00 | ||
|
|
0df67b86f8 | ||
|
|
2df567372e | ||
|
|
0fb3c69e10 | ||
|
|
44900dbcbe | ||
|
|
89cd295f28 | ||
|
|
d06811d94d | ||
|
|
ced22ebb0a | ||
|
|
3c2ad26f1d | ||
|
|
9535e35ba7 | ||
|
|
298e87dce2 | ||
|
|
165ce61ded | ||
|
|
c70e3547d1 | ||
|
|
05b5953f35 | ||
|
|
9cba6e694a | ||
|
|
b2c73ec9d8 | ||
|
|
cd7620673b | ||
|
|
09bfb2b0f4 | ||
|
|
6dde7e9535 | ||
|
|
98e24072ce | ||
|
|
fdc67f8f0e | ||
|
|
f8acabcc86 | ||
|
|
5a0771b751 | ||
|
|
6a231f76e1 | ||
|
|
d203edbdae | ||
|
|
91a5aa8d4c | ||
|
|
40076dea36 | ||
|
|
2ab7228727 | ||
|
|
52e500a5fb | ||
|
|
44734867cc | ||
|
|
6565d05274 | ||
|
|
204758cbbb | ||
|
|
a60e7e13ca | ||
|
|
3596109249 | ||
|
|
3c703d9771 | ||
|
|
1e87dc4c52 | ||
|
|
ae61d50a6c | ||
|
|
63fb40dd65 | ||
|
|
afe2248bb8 | ||
|
|
b3b82ace3f | ||
|
|
903fef6791 | ||
|
|
542ad29cd9 | ||
|
|
d588e8d941 | ||
|
|
6b786084cf | ||
|
|
85da41be9a | ||
|
|
6e8a1cd6af | ||
|
|
0f28d5de11 | ||
|
|
0d39909d89 | ||
|
|
e4282a8e9d | ||
|
|
05a64308ac | ||
|
|
7b01bafd53 | ||
|
|
b521460335 | ||
|
|
249c8377bd | ||
|
|
58cdc9f29a | ||
|
|
065beb72e1 | ||
|
|
c00614f17d | ||
|
|
d99bc08e49 | ||
|
|
9e49b28ac3 | ||
|
|
d06b396aec | ||
|
|
65abef1282 | ||
|
|
b66d3ee8d4 | ||
|
|
597abdb20c | ||
|
|
174fa800be | ||
|
|
28670bc7fb | ||
|
|
a61e406c91 | ||
|
|
20f357cb12 | ||
|
|
5ba6b81fac | ||
|
|
e34bcd47d5 | ||
|
|
62ed8705e8 | ||
|
|
de18324798 | ||
|
|
a7a943c8dc | ||
|
|
6e975b9d66 | ||
|
|
9e53dc3d5f | ||
|
|
809e7d8701 | ||
|
|
0015c5704a | ||
|
|
a7ff1610eb | ||
|
|
22c402fc5e | ||
|
|
f3c19f9c02 | ||
|
|
33b4b9fbcb | ||
|
|
00396f2e1b | ||
|
|
8b71f99666 | ||
|
|
d00822a6c3 | ||
|
|
6e92d46a63 | ||
|
|
66ed926ea8 | ||
|
|
b7741ce2af | ||
|
|
1a17324d26 | ||
|
|
4044936481 | ||
|
|
1efe86421a | ||
|
|
34dd080f6c | ||
|
|
f4838afab0 | ||
|
|
b207eebe56 | ||
|
|
4f454ab438 | ||
|
|
1ecf416113 | ||
|
|
94670a03ff | ||
|
|
e92f165677 | ||
|
|
4a03137a25 | ||
|
|
7e6e1fb6de | ||
|
|
f477797823 | ||
|
|
125b6740a6 | ||
|
|
1618a11955 | ||
|
|
966d6e2383 | ||
|
|
2f33a135fc | ||
|
|
207ea492d5 | ||
|
|
250d5432a0 | ||
|
|
9768758ecc | ||
|
|
20852dbd12 | ||
|
|
2bc632474d | ||
|
|
78fd754d91 | ||
|
|
bfa0045f1d | ||
|
|
97e2d58750 | ||
|
|
ff668931ba | ||
|
|
1c0149afc9 | ||
|
|
12ee3ef497 | ||
|
|
ae2e38acac | ||
|
|
f25050bce8 | ||
|
|
830d500a68 | ||
|
|
960e5d9d29 | ||
|
|
75b9f27761 | ||
|
|
67af210f07 | ||
|
|
06cdcac4df | ||
|
|
10dc1d10ed | ||
|
|
43c65bf95b | ||
|
|
cb4ee2dcca | ||
|
|
bc64a96cc0 | ||
|
|
23dab16afc | ||
|
|
8755106fd2 | ||
|
|
b2c6c95dbd | ||
|
|
20d5fcd54d | ||
|
|
0d09233b28 | ||
|
|
1f2700de38 | ||
|
|
d7ebdfbf5a | ||
|
|
14b70a78ab | ||
|
|
dd41af8b8e | ||
|
|
5b19d61069 | ||
|
|
be3e028f5c | ||
|
|
d231436eb0 | ||
|
|
4c6276d3f6 | ||
|
|
583c00d2b7 | ||
|
|
060ded3915 | ||
|
|
8482a8746f | ||
|
|
dc12c0e770 | ||
|
|
6338e89507 | ||
|
|
0f97d29f6a | ||
|
|
686f746070 | ||
|
|
5363719643 | ||
|
|
607785dcd4 | ||
|
|
c14d39c456 | ||
|
|
2c9220090a | ||
|
|
b17ef8b6ff | ||
|
|
6ac96747cf | ||
|
|
92c8a13f96 | ||
|
|
6d07c335de | ||
|
|
eba1679761 | ||
|
|
05b05be0bd | ||
|
|
287861f5d7 | ||
|
|
4102c4a0ae | ||
|
|
d8fa0e33f1 | ||
|
|
97bc638f5f | ||
|
|
064c0ae425 | ||
|
|
ae7aa52177 | ||
|
|
6edda72d61 | ||
|
|
2f58f32bdd | ||
|
|
0b821db046 | ||
|
|
36472998ee | ||
|
|
c2e7325876 | ||
|
|
28a4a3849c | ||
|
|
6e9c934912 | ||
|
|
675ef0e629 | ||
|
|
484914b2dc | ||
|
|
ee85ef50f4 | ||
|
|
dcee5542c5 | ||
|
|
9b3ce4d849 | ||
|
|
5ab7e586f3 | ||
|
|
9f5d4ed52c | ||
|
|
c3ca734005 | ||
|
|
a158a488f2 | ||
|
|
6048cb917e | ||
|
|
81aac0d431 | ||
|
|
dfb50fbddc | ||
|
|
1f03e0a84b | ||
|
|
77e393ae48 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -25,3 +25,4 @@
|
|||||||
.externalNativeBuild
|
.externalNativeBuild
|
||||||
.cxx
|
.cxx
|
||||||
/.idea/deviceManager.xml
|
/.idea/deviceManager.xml
|
||||||
|
/.kotlin/
|
||||||
|
|||||||
1
.idea/.gitignore
generated
vendored
1
.idea/.gitignore
generated
vendored
@@ -2,3 +2,4 @@
|
|||||||
/shelf/
|
/shelf/
|
||||||
/workspace.xml
|
/workspace.xml
|
||||||
/migrations.xml
|
/migrations.xml
|
||||||
|
/runConfigurations.xml
|
||||||
|
|||||||
1
.idea/gradle.xml
generated
1
.idea/gradle.xml
generated
@@ -4,6 +4,7 @@
|
|||||||
<component name="GradleSettings">
|
<component name="GradleSettings">
|
||||||
<option name="linkedExternalProjectsSettings">
|
<option name="linkedExternalProjectsSettings">
|
||||||
<GradleProjectSettings>
|
<GradleProjectSettings>
|
||||||
|
<option name="testRunner" value="CHOOSE_PER_TEST" />
|
||||||
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
||||||
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
|
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
|
||||||
<option name="modules">
|
<option name="modules">
|
||||||
|
|||||||
@@ -8,16 +8,16 @@ plugins {
|
|||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
compileSdk = 34
|
compileSdk = 35
|
||||||
buildToolsVersion = '34.0.0'
|
buildToolsVersion = '35.0.0'
|
||||||
namespace = 'org.koitharu.kotatsu'
|
namespace = 'org.koitharu.kotatsu'
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId 'org.koitharu.kotatsu'
|
applicationId 'org.koitharu.kotatsu'
|
||||||
minSdk = 21
|
minSdk = 21
|
||||||
targetSdk = 34
|
targetSdk = 35
|
||||||
versionCode = 650
|
versionCode = 666
|
||||||
versionName = '7.2.1'
|
versionName = '7.5'
|
||||||
generatedDensities = []
|
generatedDensities = []
|
||||||
testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner'
|
testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner'
|
||||||
ksp {
|
ksp {
|
||||||
@@ -56,6 +56,7 @@ android {
|
|||||||
freeCompilerArgs += [
|
freeCompilerArgs += [
|
||||||
'-opt-in=kotlin.ExperimentalStdlibApi',
|
'-opt-in=kotlin.ExperimentalStdlibApi',
|
||||||
'-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi',
|
'-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi',
|
||||||
|
'-opt-in=kotlinx.coroutines.ExperimentalForInheritanceCoroutinesApi',
|
||||||
'-opt-in=kotlinx.coroutines.FlowPreview',
|
'-opt-in=kotlinx.coroutines.FlowPreview',
|
||||||
'-opt-in=kotlin.contracts.ExperimentalContracts',
|
'-opt-in=kotlin.contracts.ExperimentalContracts',
|
||||||
'-opt-in=coil.annotation.ExperimentalCoilApi',
|
'-opt-in=coil.annotation.ExperimentalCoilApi',
|
||||||
@@ -82,23 +83,23 @@ afterEvaluate {
|
|||||||
}
|
}
|
||||||
dependencies {
|
dependencies {
|
||||||
//noinspection GradleDependency
|
//noinspection GradleDependency
|
||||||
implementation('com.github.KotatsuApp:kotatsu-parsers:f923acc5a7') {
|
implementation('com.github.KotatsuApp:kotatsu-parsers:b404b44008') {
|
||||||
exclude group: 'org.json', module: 'json'
|
exclude group: 'org.json', module: 'json'
|
||||||
}
|
}
|
||||||
|
|
||||||
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.4'
|
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.1'
|
||||||
implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.9.24'
|
implementation 'org.jetbrains.kotlin:kotlin-stdlib:2.0.10'
|
||||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1'
|
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0-RC.2'
|
||||||
|
|
||||||
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.0'
|
implementation 'androidx.activity:activity-ktx:1.9.1'
|
||||||
implementation 'androidx.fragment:fragment-ktx:1.8.0'
|
implementation 'androidx.fragment:fragment-ktx:1.8.2'
|
||||||
implementation 'androidx.transition:transition-ktx:1.5.0'
|
implementation 'androidx.transition:transition-ktx:1.5.1'
|
||||||
implementation 'androidx.collection:collection-ktx:1.4.0'
|
implementation 'androidx.collection:collection-ktx:1.4.3'
|
||||||
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.2'
|
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.4'
|
||||||
implementation 'androidx.lifecycle:lifecycle-service:2.8.2'
|
implementation 'androidx.lifecycle:lifecycle-service:2.8.4'
|
||||||
implementation 'androidx.lifecycle:lifecycle-process:2.8.2'
|
implementation 'androidx.lifecycle:lifecycle-process:2.8.4'
|
||||||
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'
|
||||||
@@ -106,12 +107,12 @@ 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.2'
|
implementation 'androidx.lifecycle:lifecycle-common-java8:2.8.4'
|
||||||
implementation 'androidx.webkit:webkit:1.11.0'
|
implementation 'androidx.webkit:webkit:1.11.0'
|
||||||
|
|
||||||
implementation 'androidx.work:work-runtime:2.9.0'
|
implementation 'androidx.work:work-runtime:2.9.1'
|
||||||
//noinspection GradleDependency
|
//noinspection GradleDependency
|
||||||
implementation('com.google.guava:guava:32.0.1-android') {
|
implementation('com.google.guava:guava:33.2.1-android') {
|
||||||
exclude group: 'com.google.guava', module: 'failureaccess'
|
exclude group: 'com.google.guava', module: 'failureaccess'
|
||||||
exclude group: 'org.checkerframework', module: 'checker-qual'
|
exclude group: 'org.checkerframework', module: 'checker-qual'
|
||||||
exclude group: 'com.google.j2objc', module: 'j2objc-annotations'
|
exclude group: 'com.google.j2objc', module: 'j2objc-annotations'
|
||||||
@@ -129,41 +130,39 @@ dependencies {
|
|||||||
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'
|
||||||
|
|
||||||
implementation 'com.google.dagger:hilt-android:2.51.1'
|
implementation 'com.google.dagger:hilt-android:2.52'
|
||||||
kapt 'com.google.dagger:hilt-compiler:2.51.1'
|
kapt 'com.google.dagger:hilt-compiler:2.52'
|
||||||
implementation 'androidx.hilt:hilt-work:1.2.0'
|
implementation 'androidx.hilt:hilt-work:1.2.0'
|
||||||
kapt 'androidx.hilt:hilt-compiler:1.2.0'
|
kapt 'androidx.hilt:hilt-compiler:1.2.0'
|
||||||
|
|
||||||
implementation 'io.coil-kt:coil-base:2.6.0'
|
implementation 'io.coil-kt:coil-base:2.7.0'
|
||||||
implementation 'io.coil-kt:coil-svg:2.6.0'
|
implementation 'io.coil-kt:coil-svg:2.7.0'
|
||||||
implementation 'com.github.KotatsuApp:subsampling-scale-image-view:8cafac256e'
|
implementation 'com.github.KotatsuApp:subsampling-scale-image-view:4ec7176962'
|
||||||
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.3'
|
||||||
implementation 'ch.acra:acra-dialog:5.11.3'
|
implementation 'ch.acra:acra-dialog:5.11.3'
|
||||||
compileOnly 'com.google.auto.service:auto-service-annotations:1.1.1'
|
|
||||||
ksp 'dev.zacsweers.autoservice:auto-service-ksp:1.1.0'
|
|
||||||
|
|
||||||
implementation 'org.conscrypt:conscrypt-android:2.5.2'
|
implementation 'org.conscrypt:conscrypt-android:2.5.3'
|
||||||
|
|
||||||
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.14'
|
debugImplementation 'com.squareup.leakcanary:leakcanary-android:3.0-alpha-8'
|
||||||
debugImplementation 'com.github.Koitharu:WorkInspector:5778dd1747'
|
debugImplementation 'com.github.Koitharu:WorkInspector:5778dd1747'
|
||||||
|
|
||||||
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.8.1'
|
||||||
|
|
||||||
androidTestImplementation 'androidx.test:runner:1.5.2'
|
androidTestImplementation 'androidx.test:runner:1.6.1'
|
||||||
androidTestImplementation 'androidx.test:rules:1.5.0'
|
androidTestImplementation 'androidx.test:rules:1.6.1'
|
||||||
androidTestImplementation 'androidx.test:core-ktx:1.5.0'
|
androidTestImplementation 'androidx.test:core-ktx:1.6.1'
|
||||||
androidTestImplementation 'androidx.test.ext:junit-ktx:1.1.5'
|
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.8.1'
|
||||||
|
|
||||||
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'
|
||||||
|
|
||||||
androidTestImplementation 'com.google.dagger:hilt-android-testing:2.51.1'
|
androidTestImplementation 'com.google.dagger:hilt-android-testing:2.52'
|
||||||
kaptAndroidTest 'com.google.dagger:hilt-android-compiler:2.51.1'
|
kaptAndroidTest 'com.google.dagger:hilt-android-compiler:2.52'
|
||||||
}
|
}
|
||||||
|
|||||||
1
app/proguard-rules.pro
vendored
1
app/proguard-rules.pro
vendored
@@ -14,6 +14,7 @@
|
|||||||
-dontwarn org.conscrypt.**
|
-dontwarn org.conscrypt.**
|
||||||
-dontwarn org.bouncycastle.**
|
-dontwarn org.bouncycastle.**
|
||||||
-dontwarn org.openjsse.**
|
-dontwarn org.openjsse.**
|
||||||
|
-dontwarn com.google.j2objc.annotations.**
|
||||||
|
|
||||||
-keep class org.koitharu.kotatsu.core.exceptions.* { *; }
|
-keep class org.koitharu.kotatsu.core.exceptions.* { *; }
|
||||||
-keep class org.koitharu.kotatsu.settings.NotificationSettingsLegacyFragment
|
-keep class org.koitharu.kotatsu.settings.NotificationSettingsLegacyFragment
|
||||||
|
|||||||
@@ -1,198 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.tracker.domain
|
|
||||||
|
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
|
||||||
import dagger.hilt.android.testing.HiltAndroidRule
|
|
||||||
import dagger.hilt.android.testing.HiltAndroidTest
|
|
||||||
import junit.framework.TestCase.*
|
|
||||||
import kotlinx.coroutines.test.runTest
|
|
||||||
import org.junit.Before
|
|
||||||
import org.junit.Rule
|
|
||||||
import org.junit.Test
|
|
||||||
import org.junit.runner.RunWith
|
|
||||||
import org.koitharu.kotatsu.SampleData
|
|
||||||
import org.koitharu.kotatsu.core.parser.MangaDataRepository
|
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
@HiltAndroidTest
|
|
||||||
@RunWith(AndroidJUnit4::class)
|
|
||||||
class TrackerTest {
|
|
||||||
|
|
||||||
@get:Rule
|
|
||||||
var hiltRule = HiltAndroidRule(this)
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
lateinit var repository: TrackingRepository
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
lateinit var dataRepository: MangaDataRepository
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
lateinit var tracker: Tracker
|
|
||||||
|
|
||||||
@Before
|
|
||||||
fun setUp() {
|
|
||||||
hiltRule.inject()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun noUpdates() = runTest {
|
|
||||||
val manga = loadManga("full.json")
|
|
||||||
tracker.deleteTrack(manga.id)
|
|
||||||
|
|
||||||
tracker.checkUpdates(manga, commit = true).apply {
|
|
||||||
assertFalse(isValid)
|
|
||||||
assert(newChapters.isEmpty())
|
|
||||||
}
|
|
||||||
assertEquals(0, repository.getNewChaptersCount(manga.id))
|
|
||||||
tracker.checkUpdates(manga, commit = true).apply {
|
|
||||||
assertTrue(isValid)
|
|
||||||
assert(newChapters.isEmpty())
|
|
||||||
}
|
|
||||||
assertEquals(0, repository.getNewChaptersCount(manga.id))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun hasUpdates() = runTest {
|
|
||||||
val mangaFirst = loadManga("first_chapters.json")
|
|
||||||
val mangaFull = loadManga("full.json")
|
|
||||||
tracker.deleteTrack(mangaFirst.id)
|
|
||||||
|
|
||||||
tracker.checkUpdates(mangaFirst, commit = true).apply {
|
|
||||||
assertFalse(isValid)
|
|
||||||
assert(newChapters.isEmpty())
|
|
||||||
}
|
|
||||||
assertEquals(0, repository.getNewChaptersCount(mangaFirst.id))
|
|
||||||
tracker.checkUpdates(mangaFull, commit = true).apply {
|
|
||||||
assertTrue(isValid)
|
|
||||||
assertEquals(3, newChapters.size)
|
|
||||||
}
|
|
||||||
assertEquals(3, repository.getNewChaptersCount(mangaFirst.id))
|
|
||||||
tracker.checkUpdates(mangaFull, commit = true).apply {
|
|
||||||
assertTrue(isValid)
|
|
||||||
assert(newChapters.isEmpty())
|
|
||||||
}
|
|
||||||
assertEquals(3, repository.getNewChaptersCount(mangaFirst.id))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun badIds() = runTest {
|
|
||||||
val mangaFirst = loadManga("first_chapters.json")
|
|
||||||
val mangaBad = loadManga("bad_ids.json")
|
|
||||||
tracker.deleteTrack(mangaFirst.id)
|
|
||||||
|
|
||||||
tracker.checkUpdates(mangaFirst, commit = true).apply {
|
|
||||||
assertFalse(isValid)
|
|
||||||
assert(newChapters.isEmpty())
|
|
||||||
}
|
|
||||||
assertEquals(0, repository.getNewChaptersCount(mangaFirst.id))
|
|
||||||
tracker.checkUpdates(mangaBad, commit = true).apply {
|
|
||||||
assertFalse(isValid)
|
|
||||||
assert(newChapters.isEmpty())
|
|
||||||
}
|
|
||||||
assertEquals(0, repository.getNewChaptersCount(mangaFirst.id))
|
|
||||||
tracker.checkUpdates(mangaFirst, commit = true).apply {
|
|
||||||
assertFalse(isValid)
|
|
||||||
assert(newChapters.isEmpty())
|
|
||||||
}
|
|
||||||
assertEquals(0, repository.getNewChaptersCount(mangaFirst.id))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun badIds2() = runTest {
|
|
||||||
val mangaFirst = loadManga("first_chapters.json")
|
|
||||||
val mangaBad = loadManga("bad_ids.json")
|
|
||||||
val mangaFull = loadManga("full.json")
|
|
||||||
tracker.deleteTrack(mangaFirst.id)
|
|
||||||
|
|
||||||
tracker.checkUpdates(mangaFirst, commit = true).apply {
|
|
||||||
assertFalse(isValid)
|
|
||||||
assert(newChapters.isEmpty())
|
|
||||||
}
|
|
||||||
assertEquals(0, repository.getNewChaptersCount(mangaFirst.id))
|
|
||||||
tracker.checkUpdates(mangaFull, commit = true).apply {
|
|
||||||
assertTrue(isValid)
|
|
||||||
assertEquals(3, newChapters.size)
|
|
||||||
}
|
|
||||||
assertEquals(3, repository.getNewChaptersCount(mangaFull.id))
|
|
||||||
tracker.checkUpdates(mangaBad, commit = true).apply {
|
|
||||||
assertFalse(isValid)
|
|
||||||
assert(newChapters.isEmpty())
|
|
||||||
}
|
|
||||||
assertEquals(0, repository.getNewChaptersCount(mangaFirst.id))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun fullReset() = runTest {
|
|
||||||
val mangaFull = loadManga("full.json")
|
|
||||||
val mangaFirst = loadManga("first_chapters.json")
|
|
||||||
val mangaEmpty = loadManga("empty.json")
|
|
||||||
tracker.deleteTrack(mangaFull.id)
|
|
||||||
|
|
||||||
assertEquals(0, repository.getNewChaptersCount(mangaFull.id))
|
|
||||||
tracker.checkUpdates(mangaFull, commit = true).apply {
|
|
||||||
assertFalse(isValid)
|
|
||||||
assert(newChapters.isEmpty())
|
|
||||||
}
|
|
||||||
assertEquals(0, repository.getNewChaptersCount(mangaFull.id))
|
|
||||||
tracker.checkUpdates(mangaEmpty, commit = true).apply {
|
|
||||||
assert(newChapters.isEmpty())
|
|
||||||
}
|
|
||||||
assertEquals(0, repository.getNewChaptersCount(mangaFull.id))
|
|
||||||
tracker.checkUpdates(mangaFirst, commit = true).apply {
|
|
||||||
assertFalse(isValid)
|
|
||||||
assert(newChapters.isEmpty())
|
|
||||||
}
|
|
||||||
assertEquals(0, repository.getNewChaptersCount(mangaFull.id))
|
|
||||||
tracker.checkUpdates(mangaFull, commit = true).apply {
|
|
||||||
assertTrue(isValid)
|
|
||||||
assertEquals(3, newChapters.size)
|
|
||||||
}
|
|
||||||
assertEquals(3, repository.getNewChaptersCount(mangaFull.id))
|
|
||||||
tracker.checkUpdates(mangaEmpty, commit = true).apply {
|
|
||||||
assertFalse(isValid)
|
|
||||||
assert(newChapters.isEmpty())
|
|
||||||
}
|
|
||||||
assertEquals(0, repository.getNewChaptersCount(mangaFull.id))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun syncWithHistory() = runTest {
|
|
||||||
val mangaFull = loadManga("full.json")
|
|
||||||
val mangaFirst = loadManga("first_chapters.json")
|
|
||||||
tracker.deleteTrack(mangaFull.id)
|
|
||||||
|
|
||||||
tracker.checkUpdates(mangaFirst, commit = true).apply {
|
|
||||||
assertFalse(isValid)
|
|
||||||
assert(newChapters.isEmpty())
|
|
||||||
}
|
|
||||||
assertEquals(0, repository.getNewChaptersCount(mangaFirst.id))
|
|
||||||
tracker.checkUpdates(mangaFull, commit = true).apply {
|
|
||||||
assertTrue(isValid)
|
|
||||||
assertEquals(3, newChapters.size)
|
|
||||||
}
|
|
||||||
assertEquals(3, repository.getNewChaptersCount(mangaFirst.id))
|
|
||||||
|
|
||||||
var chapter = requireNotNull(mangaFull.chapters).run { get(lastIndex - 1) }
|
|
||||||
tracker.syncWithHistory(mangaFull, chapter.id)
|
|
||||||
|
|
||||||
assertEquals(1, repository.getNewChaptersCount(mangaFirst.id))
|
|
||||||
|
|
||||||
chapter = requireNotNull(mangaFull.chapters).run { get(lastIndex) }
|
|
||||||
tracker.syncWithHistory(mangaFull, chapter.id)
|
|
||||||
|
|
||||||
assertEquals(0, repository.getNewChaptersCount(mangaFirst.id))
|
|
||||||
|
|
||||||
tracker.checkUpdates(mangaFull, commit = true).apply {
|
|
||||||
assertTrue(isValid)
|
|
||||||
assert(newChapters.isEmpty())
|
|
||||||
}
|
|
||||||
assertEquals(0, repository.getNewChaptersCount(mangaFirst.id))
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun loadManga(name: String): Manga {
|
|
||||||
val manga = SampleData.loadAsset("manga/$name", Manga::class)
|
|
||||||
dataRepository.storeManga(manga)
|
|
||||||
return manga
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -8,6 +8,7 @@ import org.koitharu.kotatsu.local.data.LocalMangaRepository
|
|||||||
import org.koitharu.kotatsu.local.data.PagesCache
|
import org.koitharu.kotatsu.local.data.PagesCache
|
||||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
||||||
import org.koitharu.kotatsu.reader.domain.PageLoader
|
import org.koitharu.kotatsu.reader.domain.PageLoader
|
||||||
|
import org.koitharu.kotatsu.reader.ui.ReaderViewModel
|
||||||
|
|
||||||
class KotatsuApp : BaseApp() {
|
class KotatsuApp : BaseApp() {
|
||||||
|
|
||||||
@@ -30,6 +31,7 @@ class KotatsuApp : BaseApp() {
|
|||||||
.setClassInstanceLimit(PagesCache::class.java, 1)
|
.setClassInstanceLimit(PagesCache::class.java, 1)
|
||||||
.setClassInstanceLimit(MangaLoaderContext::class.java, 1)
|
.setClassInstanceLimit(MangaLoaderContext::class.java, 1)
|
||||||
.setClassInstanceLimit(PageLoader::class.java, 1)
|
.setClassInstanceLimit(PageLoader::class.java, 1)
|
||||||
|
.setClassInstanceLimit(ReaderViewModel::class.java, 1)
|
||||||
.penaltyLog()
|
.penaltyLog()
|
||||||
.build(),
|
.build(),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -20,6 +20,10 @@
|
|||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||||
|
<uses-permission android:name="android.permission.REQUEST_DELETE_PACKAGES"/>
|
||||||
|
<uses-permission
|
||||||
|
android:name="android.permission.QUERY_ALL_PACKAGES"
|
||||||
|
tools:ignore="QueryAllPackagesPermission" />
|
||||||
<uses-permission
|
<uses-permission
|
||||||
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||||
android:maxSdkVersion="29" />
|
android:maxSdkVersion="29" />
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import org.koitharu.kotatsu.core.parser.MangaDataRepository
|
|||||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||||
import org.koitharu.kotatsu.details.domain.ProgressUpdateUseCase
|
import org.koitharu.kotatsu.details.domain.ProgressUpdateUseCase
|
||||||
import org.koitharu.kotatsu.history.data.HistoryEntity
|
import org.koitharu.kotatsu.history.data.HistoryEntity
|
||||||
import org.koitharu.kotatsu.history.data.PROGRESS_NONE
|
|
||||||
import org.koitharu.kotatsu.history.data.toMangaHistory
|
import org.koitharu.kotatsu.history.data.toMangaHistory
|
||||||
|
import org.koitharu.kotatsu.list.domain.ReadingProgress.Companion.PROGRESS_NONE
|
||||||
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.util.runCatchingCancellable
|
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||||
@@ -18,178 +18,178 @@ import org.koitharu.kotatsu.tracker.data.TrackEntity
|
|||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
class MigrateUseCase
|
class MigrateUseCase
|
||||||
@Inject
|
@Inject
|
||||||
constructor(
|
constructor(
|
||||||
private val mangaRepositoryFactory: MangaRepository.Factory,
|
private val mangaRepositoryFactory: MangaRepository.Factory,
|
||||||
private val mangaDataRepository: MangaDataRepository,
|
private val mangaDataRepository: MangaDataRepository,
|
||||||
private val database: MangaDatabase,
|
private val database: MangaDatabase,
|
||||||
private val progressUpdateUseCase: ProgressUpdateUseCase,
|
private val progressUpdateUseCase: ProgressUpdateUseCase,
|
||||||
private val scrobblers: Set<@JvmSuppressWildcards Scrobbler>,
|
private val scrobblers: Set<@JvmSuppressWildcards Scrobbler>,
|
||||||
|
) {
|
||||||
|
suspend operator fun invoke(
|
||||||
|
oldManga: Manga,
|
||||||
|
newManga: Manga,
|
||||||
) {
|
) {
|
||||||
suspend operator fun invoke(
|
val oldDetails =
|
||||||
oldManga: Manga,
|
if (oldManga.chapters.isNullOrEmpty()) {
|
||||||
newManga: Manga,
|
runCatchingCancellable {
|
||||||
) {
|
mangaRepositoryFactory.create(oldManga.source).getDetails(oldManga)
|
||||||
val oldDetails =
|
}.getOrDefault(oldManga)
|
||||||
if (oldManga.chapters.isNullOrEmpty()) {
|
} else {
|
||||||
runCatchingCancellable {
|
oldManga
|
||||||
mangaRepositoryFactory.create(oldManga.source).getDetails(oldManga)
|
}
|
||||||
}.getOrDefault(oldManga)
|
val newDetails =
|
||||||
} else {
|
if (newManga.chapters.isNullOrEmpty()) {
|
||||||
oldManga
|
mangaRepositoryFactory.create(newManga.source).getDetails(newManga)
|
||||||
}
|
} else {
|
||||||
val newDetails =
|
newManga
|
||||||
if (newManga.chapters.isNullOrEmpty()) {
|
}
|
||||||
mangaRepositoryFactory.create(newManga.source).getDetails(newManga)
|
mangaDataRepository.storeManga(newDetails)
|
||||||
} else {
|
database.withTransaction {
|
||||||
newManga
|
// replace favorites
|
||||||
}
|
val favoritesDao = database.getFavouritesDao()
|
||||||
mangaDataRepository.storeManga(newDetails)
|
val oldFavourites = favoritesDao.findAllRaw(oldDetails.id)
|
||||||
database.withTransaction {
|
if (oldFavourites.isNotEmpty()) {
|
||||||
// replace favorites
|
favoritesDao.delete(oldManga.id)
|
||||||
val favoritesDao = database.getFavouritesDao()
|
for (f in oldFavourites) {
|
||||||
val oldFavourites = favoritesDao.findAllRaw(oldDetails.id)
|
val e =
|
||||||
if (oldFavourites.isNotEmpty()) {
|
f.copy(
|
||||||
favoritesDao.delete(oldManga.id)
|
mangaId = newManga.id,
|
||||||
for (f in oldFavourites) {
|
|
||||||
val e =
|
|
||||||
f.copy(
|
|
||||||
mangaId = newManga.id,
|
|
||||||
)
|
|
||||||
favoritesDao.upsert(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// replace history
|
|
||||||
val historyDao = database.getHistoryDao()
|
|
||||||
val oldHistory = historyDao.find(oldDetails.id)
|
|
||||||
val newHistory =
|
|
||||||
if (oldHistory != null) {
|
|
||||||
val newHistory = makeNewHistory(oldDetails, newDetails, oldHistory)
|
|
||||||
historyDao.delete(oldDetails.id)
|
|
||||||
historyDao.upsert(newHistory)
|
|
||||||
newHistory
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
// track
|
|
||||||
val tracksDao = database.getTracksDao()
|
|
||||||
val oldTrack = tracksDao.find(oldDetails.id)
|
|
||||||
if (oldTrack != null) {
|
|
||||||
val lastChapter = newDetails.chapters?.lastOrNull()
|
|
||||||
val newTrack =
|
|
||||||
TrackEntity(
|
|
||||||
mangaId = newDetails.id,
|
|
||||||
lastChapterId = lastChapter?.id ?: 0L,
|
|
||||||
newChapters = 0,
|
|
||||||
lastCheckTime = System.currentTimeMillis(),
|
|
||||||
lastChapterDate = lastChapter?.uploadDate ?: 0L,
|
|
||||||
lastResult = TrackEntity.RESULT_EXTERNAL_MODIFICATION,
|
|
||||||
lastError = null,
|
|
||||||
)
|
)
|
||||||
tracksDao.delete(oldDetails.id)
|
favoritesDao.upsert(e)
|
||||||
tracksDao.upsert(newTrack)
|
|
||||||
}
|
}
|
||||||
// scrobbling
|
}
|
||||||
for (scrobbler in scrobblers) {
|
// replace history
|
||||||
if (!scrobbler.isEnabled) {
|
val historyDao = database.getHistoryDao()
|
||||||
continue
|
val oldHistory = historyDao.find(oldDetails.id)
|
||||||
}
|
val newHistory =
|
||||||
val prevInfo = scrobbler.getScrobblingInfoOrNull(oldDetails.id) ?: continue
|
if (oldHistory != null) {
|
||||||
scrobbler.unregisterScrobbling(oldDetails.id)
|
val newHistory = makeNewHistory(oldDetails, newDetails, oldHistory)
|
||||||
scrobbler.linkManga(newDetails.id, prevInfo.targetId)
|
historyDao.delete(oldDetails.id)
|
||||||
scrobbler.updateScrobblingInfo(
|
historyDao.upsert(newHistory)
|
||||||
|
newHistory
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
// track
|
||||||
|
val tracksDao = database.getTracksDao()
|
||||||
|
val oldTrack = tracksDao.find(oldDetails.id)
|
||||||
|
if (oldTrack != null) {
|
||||||
|
val lastChapter = newDetails.chapters?.lastOrNull()
|
||||||
|
val newTrack =
|
||||||
|
TrackEntity(
|
||||||
mangaId = newDetails.id,
|
mangaId = newDetails.id,
|
||||||
rating = prevInfo.rating,
|
lastChapterId = lastChapter?.id ?: 0L,
|
||||||
status =
|
newChapters = 0,
|
||||||
prevInfo.status ?: when {
|
lastCheckTime = System.currentTimeMillis(),
|
||||||
newHistory == null -> ScrobblingStatus.PLANNED
|
lastChapterDate = lastChapter?.uploadDate ?: 0L,
|
||||||
newHistory.percent == 1f -> ScrobblingStatus.COMPLETED
|
lastResult = TrackEntity.RESULT_EXTERNAL_MODIFICATION,
|
||||||
else -> ScrobblingStatus.READING
|
lastError = null,
|
||||||
},
|
|
||||||
comment = prevInfo.comment,
|
|
||||||
)
|
)
|
||||||
if (newHistory != null) {
|
tracksDao.delete(oldDetails.id)
|
||||||
scrobbler.scrobble(
|
tracksDao.upsert(newTrack)
|
||||||
manga = newDetails,
|
|
||||||
chapterId = newHistory.chapterId,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
progressUpdateUseCase(newManga)
|
// scrobbling
|
||||||
}
|
for (scrobbler in scrobblers) {
|
||||||
|
if (!scrobbler.isEnabled) {
|
||||||
private fun makeNewHistory(
|
continue
|
||||||
oldManga: Manga,
|
}
|
||||||
newManga: Manga,
|
val prevInfo = scrobbler.getScrobblingInfoOrNull(oldDetails.id) ?: continue
|
||||||
history: HistoryEntity,
|
scrobbler.unregisterScrobbling(oldDetails.id)
|
||||||
): HistoryEntity {
|
scrobbler.linkManga(newDetails.id, prevInfo.targetId)
|
||||||
if (oldManga.chapters.isNullOrEmpty()) { // probably broken manga/source
|
scrobbler.updateScrobblingInfo(
|
||||||
val branch = newManga.getPreferredBranch(null)
|
mangaId = newDetails.id,
|
||||||
val chapters = checkNotNull(newManga.getChapters(branch))
|
rating = prevInfo.rating,
|
||||||
val currentChapter =
|
status =
|
||||||
if (history.percent in 0f..1f) {
|
prevInfo.status ?: when {
|
||||||
chapters[(chapters.lastIndex * history.percent).toInt()]
|
newHistory == null -> ScrobblingStatus.PLANNED
|
||||||
} else {
|
newHistory.percent == 1f -> ScrobblingStatus.COMPLETED
|
||||||
chapters.first()
|
else -> ScrobblingStatus.READING
|
||||||
}
|
},
|
||||||
return HistoryEntity(
|
comment = prevInfo.comment,
|
||||||
mangaId = newManga.id,
|
|
||||||
createdAt = history.createdAt,
|
|
||||||
updatedAt = System.currentTimeMillis(),
|
|
||||||
chapterId = currentChapter.id,
|
|
||||||
page = history.page,
|
|
||||||
scroll = history.scroll,
|
|
||||||
percent = history.percent,
|
|
||||||
deletedAt = 0,
|
|
||||||
chaptersCount = chapters.size,
|
|
||||||
)
|
)
|
||||||
}
|
if (newHistory != null) {
|
||||||
val branch = oldManga.getPreferredBranch(history.toMangaHistory())
|
scrobbler.scrobble(
|
||||||
val oldChapters = checkNotNull(oldManga.getChapters(branch))
|
manga = newDetails,
|
||||||
var index = oldChapters.indexOfFirst { it.id == history.chapterId }
|
chapterId = newHistory.chapterId,
|
||||||
if (index < 0) {
|
)
|
||||||
index =
|
|
||||||
if (history.percent in 0f..1f) {
|
|
||||||
(oldChapters.lastIndex * history.percent).toInt()
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val newChapters = checkNotNull(newManga.chapters).groupBy { it.branch }
|
|
||||||
val newBranch =
|
|
||||||
if (newChapters.containsKey(branch)) {
|
|
||||||
branch
|
|
||||||
} else {
|
|
||||||
newManga.getPreferredBranch(null)
|
|
||||||
}
|
}
|
||||||
val newChapterId =
|
}
|
||||||
checkNotNull(newChapters[newBranch])
|
}
|
||||||
.let {
|
progressUpdateUseCase(newManga)
|
||||||
val oldChapter = oldChapters[index]
|
}
|
||||||
it.findByNumber(oldChapter.volume, oldChapter.number) ?: it.getOrNull(index) ?: it.last()
|
|
||||||
}.id
|
|
||||||
|
|
||||||
|
private fun makeNewHistory(
|
||||||
|
oldManga: Manga,
|
||||||
|
newManga: Manga,
|
||||||
|
history: HistoryEntity,
|
||||||
|
): HistoryEntity {
|
||||||
|
if (oldManga.chapters.isNullOrEmpty()) { // probably broken manga/source
|
||||||
|
val branch = newManga.getPreferredBranch(null)
|
||||||
|
val chapters = checkNotNull(newManga.getChapters(branch))
|
||||||
|
val currentChapter =
|
||||||
|
if (history.percent in 0f..1f) {
|
||||||
|
chapters[(chapters.lastIndex * history.percent).toInt()]
|
||||||
|
} else {
|
||||||
|
chapters.first()
|
||||||
|
}
|
||||||
return HistoryEntity(
|
return HistoryEntity(
|
||||||
mangaId = newManga.id,
|
mangaId = newManga.id,
|
||||||
createdAt = history.createdAt,
|
createdAt = history.createdAt,
|
||||||
updatedAt = System.currentTimeMillis(),
|
updatedAt = System.currentTimeMillis(),
|
||||||
chapterId = newChapterId,
|
chapterId = currentChapter.id,
|
||||||
page = history.page,
|
page = history.page,
|
||||||
scroll = history.scroll,
|
scroll = history.scroll,
|
||||||
percent = PROGRESS_NONE,
|
percent = history.percent,
|
||||||
deletedAt = 0,
|
deletedAt = 0,
|
||||||
chaptersCount = checkNotNull(newChapters[newBranch]).size,
|
chaptersCount = chapters.count { it.branch == currentChapter.branch },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
val branch = oldManga.getPreferredBranch(history.toMangaHistory())
|
||||||
private fun List<MangaChapter>.findByNumber(
|
val oldChapters = checkNotNull(oldManga.getChapters(branch))
|
||||||
volume: Int,
|
var index = oldChapters.indexOfFirst { it.id == history.chapterId }
|
||||||
number: Float,
|
if (index < 0) {
|
||||||
): MangaChapter? =
|
index =
|
||||||
if (number <= 0f) {
|
if (history.percent in 0f..1f) {
|
||||||
null
|
(oldChapters.lastIndex * history.percent).toInt()
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val newChapters = checkNotNull(newManga.chapters).groupBy { it.branch }
|
||||||
|
val newBranch =
|
||||||
|
if (newChapters.containsKey(branch)) {
|
||||||
|
branch
|
||||||
} else {
|
} else {
|
||||||
firstOrNull { it.volume == volume && it.number == number }
|
newManga.getPreferredBranch(null)
|
||||||
}
|
}
|
||||||
|
val newChapterId =
|
||||||
|
checkNotNull(newChapters[newBranch])
|
||||||
|
.let {
|
||||||
|
val oldChapter = oldChapters[index]
|
||||||
|
it.findByNumber(oldChapter.volume, oldChapter.number) ?: it.getOrNull(index) ?: it.last()
|
||||||
|
}.id
|
||||||
|
|
||||||
|
return HistoryEntity(
|
||||||
|
mangaId = newManga.id,
|
||||||
|
createdAt = history.createdAt,
|
||||||
|
updatedAt = System.currentTimeMillis(),
|
||||||
|
chapterId = newChapterId,
|
||||||
|
page = history.page,
|
||||||
|
scroll = history.scroll,
|
||||||
|
percent = PROGRESS_NONE,
|
||||||
|
deletedAt = 0,
|
||||||
|
chaptersCount = checkNotNull(newChapters[newBranch]).size,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun List<MangaChapter>.findByNumber(
|
||||||
|
volume: Int,
|
||||||
|
number: Float,
|
||||||
|
): MangaChapter? =
|
||||||
|
if (number <= 0f) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
firstOrNull { it.volume == volume && it.number == number }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import androidx.core.text.inSpans
|
|||||||
import androidx.lifecycle.LifecycleOwner
|
import androidx.lifecycle.LifecycleOwner
|
||||||
import coil.ImageLoader
|
import coil.ImageLoader
|
||||||
import coil.request.ImageRequest
|
import coil.request.ImageRequest
|
||||||
import coil.transform.CircleCropTransformation
|
import coil.transform.RoundedCornersTransformation
|
||||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.core.model.getTitle
|
import org.koitharu.kotatsu.core.model.getTitle
|
||||||
@@ -62,7 +62,7 @@ fun alternativeAD(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
binding.progressView.setPercent(item.progress, ListModelDiffCallback.PAYLOAD_PROGRESS_CHANGED in payloads)
|
binding.progressView.setProgress(item.progress, ListModelDiffCallback.PAYLOAD_PROGRESS_CHANGED in payloads)
|
||||||
binding.chipSource.also { chip ->
|
binding.chipSource.also { chip ->
|
||||||
chip.text = item.manga.source.getTitle(chip.context)
|
chip.text = item.manga.source.getTitle(chip.context)
|
||||||
ImageRequest.Builder(context)
|
ImageRequest.Builder(context)
|
||||||
@@ -75,7 +75,7 @@ fun alternativeAD(
|
|||||||
.fallback(R.drawable.ic_web)
|
.fallback(R.drawable.ic_web)
|
||||||
.error(R.drawable.ic_web)
|
.error(R.drawable.ic_web)
|
||||||
.source(item.manga.source)
|
.source(item.manga.source)
|
||||||
.transformations(CircleCropTransformation())
|
.transformations(RoundedCornersTransformation(context.resources.getDimension(R.dimen.chip_icon_corner)))
|
||||||
.allowRgb565(true)
|
.allowRgb565(true)
|
||||||
.enqueueWith(coil)
|
.enqueueWith(coil)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import androidx.activity.viewModels
|
|||||||
import androidx.core.graphics.Insets
|
import androidx.core.graphics.Insets
|
||||||
import androidx.core.view.updatePadding
|
import androidx.core.view.updatePadding
|
||||||
import coil.ImageLoader
|
import coil.ImageLoader
|
||||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
|
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
|
||||||
@@ -18,8 +17,8 @@ import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
|
|||||||
import org.koitharu.kotatsu.core.parser.MangaIntent
|
import org.koitharu.kotatsu.core.parser.MangaIntent
|
||||||
import org.koitharu.kotatsu.core.ui.BaseActivity
|
import org.koitharu.kotatsu.core.ui.BaseActivity
|
||||||
import org.koitharu.kotatsu.core.ui.BaseListAdapter
|
import org.koitharu.kotatsu.core.ui.BaseListAdapter
|
||||||
|
import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog
|
||||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||||
import org.koitharu.kotatsu.core.util.ext.DIALOG_THEME_CENTERED
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.observe
|
import org.koitharu.kotatsu.core.util.ext.observe
|
||||||
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
||||||
import org.koitharu.kotatsu.databinding.ActivityAlternativesBinding
|
import org.koitharu.kotatsu.databinding.ActivityAlternativesBinding
|
||||||
@@ -89,22 +88,23 @@ class AlternativesActivity : BaseActivity<ActivityAlternativesBinding>(),
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun confirmMigration(target: Manga) {
|
private fun confirmMigration(target: Manga) {
|
||||||
MaterialAlertDialogBuilder(this, DIALOG_THEME_CENTERED)
|
buildAlertDialog(this, isCentered = true) {
|
||||||
.setIcon(R.drawable.ic_replace)
|
setIcon(R.drawable.ic_replace)
|
||||||
.setTitle(R.string.manga_migration)
|
setTitle(R.string.manga_migration)
|
||||||
.setMessage(
|
setMessage(
|
||||||
getString(
|
getString(
|
||||||
R.string.migrate_confirmation,
|
R.string.migrate_confirmation,
|
||||||
viewModel.manga.title,
|
viewModel.manga.title,
|
||||||
viewModel.manga.source.getTitle(this),
|
viewModel.manga.source.getTitle(context),
|
||||||
target.title,
|
target.title,
|
||||||
target.source.getTitle(this),
|
target.source.getTitle(context),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.setNegativeButton(android.R.string.cancel, null)
|
setNegativeButton(android.R.string.cancel, null)
|
||||||
.setPositiveButton(R.string.migrate) { _, _ ->
|
setPositiveButton(R.string.migrate) { _, _ ->
|
||||||
viewModel.migrate(target)
|
viewModel.migrate(target)
|
||||||
}.show()
|
}
|
||||||
|
}.show()
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|||||||
@@ -15,11 +15,13 @@ import org.koitharu.kotatsu.core.model.chaptersCount
|
|||||||
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
|
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
|
||||||
import org.koitharu.kotatsu.core.parser.MangaIntent
|
import org.koitharu.kotatsu.core.parser.MangaIntent
|
||||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||||
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
||||||
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
|
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
|
||||||
import org.koitharu.kotatsu.core.util.ext.call
|
import org.koitharu.kotatsu.core.util.ext.call
|
||||||
import org.koitharu.kotatsu.core.util.ext.require
|
import org.koitharu.kotatsu.core.util.ext.require
|
||||||
import org.koitharu.kotatsu.list.domain.ListExtraProvider
|
import org.koitharu.kotatsu.history.data.HistoryRepository
|
||||||
|
import org.koitharu.kotatsu.list.domain.ReadingProgress
|
||||||
import org.koitharu.kotatsu.list.ui.model.EmptyState
|
import org.koitharu.kotatsu.list.ui.model.EmptyState
|
||||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||||
import org.koitharu.kotatsu.list.ui.model.LoadingFooter
|
import org.koitharu.kotatsu.list.ui.model.LoadingFooter
|
||||||
@@ -34,7 +36,8 @@ class AlternativesViewModel @Inject constructor(
|
|||||||
private val mangaRepositoryFactory: MangaRepository.Factory,
|
private val mangaRepositoryFactory: MangaRepository.Factory,
|
||||||
private val alternativesUseCase: AlternativesUseCase,
|
private val alternativesUseCase: AlternativesUseCase,
|
||||||
private val migrateUseCase: MigrateUseCase,
|
private val migrateUseCase: MigrateUseCase,
|
||||||
private val extraProvider: ListExtraProvider,
|
private val historyRepository: HistoryRepository,
|
||||||
|
private val settings: AppSettings,
|
||||||
) : BaseViewModel() {
|
) : BaseViewModel() {
|
||||||
|
|
||||||
val manga = savedStateHandle.require<ParcelableManga>(MangaIntent.KEY_MANGA).manga
|
val manga = savedStateHandle.require<ParcelableManga>(MangaIntent.KEY_MANGA).manga
|
||||||
@@ -53,7 +56,7 @@ class AlternativesViewModel @Inject constructor(
|
|||||||
.map {
|
.map {
|
||||||
MangaAlternativeModel(
|
MangaAlternativeModel(
|
||||||
manga = it,
|
manga = it,
|
||||||
progress = extraProvider.getProgress(it.id),
|
progress = getProgress(it.id),
|
||||||
referenceChapters = refCount,
|
referenceChapters = refCount,
|
||||||
)
|
)
|
||||||
}.runningFold<MangaAlternativeModel, List<ListModel>>(listOf(LoadingState)) { acc, item ->
|
}.runningFold<MangaAlternativeModel, List<ListModel>>(listOf(LoadingState)) { acc, item ->
|
||||||
@@ -86,13 +89,7 @@ class AlternativesViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun mapList(list: List<Manga>, refCount: Int): List<MangaAlternativeModel> {
|
private suspend fun getProgress(mangaId: Long): ReadingProgress? {
|
||||||
return list.map {
|
return historyRepository.getProgress(mangaId, settings.progressIndicatorMode)
|
||||||
MangaAlternativeModel(
|
|
||||||
manga = it,
|
|
||||||
progress = extraProvider.getProgress(it.id),
|
|
||||||
referenceChapters = refCount,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
package org.koitharu.kotatsu.alternatives.ui
|
package org.koitharu.kotatsu.alternatives.ui
|
||||||
|
|
||||||
import org.koitharu.kotatsu.core.model.chaptersCount
|
import org.koitharu.kotatsu.core.model.chaptersCount
|
||||||
|
import org.koitharu.kotatsu.list.domain.ReadingProgress
|
||||||
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
|
||||||
|
|
||||||
data class MangaAlternativeModel(
|
data class MangaAlternativeModel(
|
||||||
val manga: Manga,
|
val manga: Manga,
|
||||||
val progress: Float,
|
val progress: ReadingProgress?,
|
||||||
private val referenceChapters: Int,
|
private val referenceChapters: Int,
|
||||||
) : ListModel {
|
) : ListModel {
|
||||||
|
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ class AllBookmarksFragment :
|
|||||||
BaseFragment<FragmentListSimpleBinding>(),
|
BaseFragment<FragmentListSimpleBinding>(),
|
||||||
ListStateHolderListener,
|
ListStateHolderListener,
|
||||||
OnListItemClickListener<Bookmark>,
|
OnListItemClickListener<Bookmark>,
|
||||||
ListSelectionController.Callback2,
|
ListSelectionController.Callback,
|
||||||
FastScroller.FastScrollListener, ListHeaderClickListener {
|
FastScroller.FastScrollListener, ListHeaderClickListener {
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
|
|||||||
@@ -37,6 +37,6 @@ fun bookmarkLargeAD(
|
|||||||
source(item.manga.source)
|
source(item.manga.source)
|
||||||
enqueueWith(coil)
|
enqueueWith(coil)
|
||||||
}
|
}
|
||||||
binding.progressView.percent = item.percent
|
binding.progressView.setProgress(item.percent, false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ 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
|
||||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||||
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
|
import org.koitharu.kotatsu.core.parser.ParserMangaRepository
|
||||||
import org.koitharu.kotatsu.core.ui.BaseActivity
|
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.core.util.ext.toUriOrNull
|
import org.koitharu.kotatsu.core.util.ext.toUriOrNull
|
||||||
@@ -44,8 +44,8 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
|
|||||||
setHomeAsUpIndicator(materialR.drawable.abc_ic_clear_material)
|
setHomeAsUpIndicator(materialR.drawable.abc_ic_clear_material)
|
||||||
}
|
}
|
||||||
val mangaSource = MangaSource(intent?.getStringExtra(EXTRA_SOURCE))
|
val mangaSource = MangaSource(intent?.getStringExtra(EXTRA_SOURCE))
|
||||||
val repository = mangaRepositoryFactory.create(mangaSource) as? RemoteMangaRepository
|
val repository = mangaRepositoryFactory.create(mangaSource) as? ParserMangaRepository
|
||||||
repository?.headers?.get(CommonHeaders.USER_AGENT)
|
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)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package org.koitharu.kotatsu.browser.cloudflare
|
package org.koitharu.kotatsu.browser.cloudflare
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
@@ -28,7 +29,6 @@ import org.koitharu.kotatsu.core.model.MangaSource
|
|||||||
import org.koitharu.kotatsu.core.network.CommonHeaders
|
import org.koitharu.kotatsu.core.network.CommonHeaders
|
||||||
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
|
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
|
||||||
import org.koitharu.kotatsu.core.ui.BaseActivity
|
import org.koitharu.kotatsu.core.ui.BaseActivity
|
||||||
import org.koitharu.kotatsu.core.util.TaggedActivityResult
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.configureForParser
|
import org.koitharu.kotatsu.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
|
||||||
@@ -55,7 +55,11 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
|
|||||||
setDisplayHomeAsUpEnabled(true)
|
setDisplayHomeAsUpEnabled(true)
|
||||||
setHomeAsUpIndicator(materialR.drawable.abc_ic_clear_material)
|
setHomeAsUpIndicator(materialR.drawable.abc_ic_clear_material)
|
||||||
}
|
}
|
||||||
val url = intent?.dataString.orEmpty()
|
val url = intent?.dataString
|
||||||
|
if (url.isNullOrEmpty()) {
|
||||||
|
finishAfterTransition()
|
||||||
|
return
|
||||||
|
}
|
||||||
cfClient = CloudFlareClient(cookieJar, this, url)
|
cfClient = CloudFlareClient(cookieJar, this, url)
|
||||||
viewBinding.webView.configureForParser(intent?.getStringExtra(ARG_UA))
|
viewBinding.webView.configureForParser(intent?.getStringExtra(ARG_UA))
|
||||||
viewBinding.webView.webViewClient = cfClient
|
viewBinding.webView.webViewClient = cfClient
|
||||||
@@ -63,12 +67,7 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
|
|||||||
onBackPressedDispatcher.addCallback(it)
|
onBackPressedDispatcher.addCallback(it)
|
||||||
}
|
}
|
||||||
CookieManager.getInstance().setAcceptThirdPartyCookies(viewBinding.webView, true)
|
CookieManager.getInstance().setAcceptThirdPartyCookies(viewBinding.webView, true)
|
||||||
if (savedInstanceState != null) {
|
if (savedInstanceState == null) {
|
||||||
return
|
|
||||||
}
|
|
||||||
if (url.isEmpty()) {
|
|
||||||
finishAfterTransition()
|
|
||||||
} else {
|
|
||||||
onTitleChanged(getString(R.string.loading_), url)
|
onTitleChanged(getString(R.string.loading_), url)
|
||||||
viewBinding.webView.loadUrl(url)
|
viewBinding.webView.loadUrl(url)
|
||||||
}
|
}
|
||||||
@@ -181,13 +180,13 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class Contract : ActivityResultContract<CloudFlareProtectedException, TaggedActivityResult>() {
|
class Contract : ActivityResultContract<CloudFlareProtectedException, Boolean>() {
|
||||||
override fun createIntent(context: Context, input: CloudFlareProtectedException): Intent {
|
override fun createIntent(context: Context, input: CloudFlareProtectedException): Intent {
|
||||||
return newIntent(context, input)
|
return newIntent(context, input)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun parseResult(resultCode: Int, intent: Intent?): TaggedActivityResult {
|
override fun parseResult(resultCode: Int, intent: Intent?): Boolean {
|
||||||
return TaggedActivityResult(TAG, resultCode)
|
return resultCode == Activity.RESULT_OK
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
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
|
||||||
@@ -110,6 +111,8 @@ interface AppModule {
|
|||||||
.decoderDispatcher(Dispatchers.IO)
|
.decoderDispatcher(Dispatchers.IO)
|
||||||
.transformationDispatcher(Dispatchers.Default)
|
.transformationDispatcher(Dispatchers.Default)
|
||||||
.diskCache(diskCacheFactory)
|
.diskCache(diskCacheFactory)
|
||||||
|
.respectCacheHeaders(false)
|
||||||
|
.networkObserverEnabled(false)
|
||||||
.logger(if (BuildConfig.DEBUG) DebugLogger() else null)
|
.logger(if (BuildConfig.DEBUG) DebugLogger() else null)
|
||||||
.allowRgb565(context.isLowRamDevice())
|
.allowRgb565(context.isLowRamDevice())
|
||||||
.eventListener(CaptchaNotifier(context))
|
.eventListener(CaptchaNotifier(context))
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import com.google.auto.service.AutoService
|
|
||||||
import org.acra.builder.ReportBuilder
|
|
||||||
import org.acra.config.CoreConfiguration
|
|
||||||
import org.acra.config.ReportingAdministrator
|
|
||||||
|
|
||||||
@AutoService(ReportingAdministrator::class)
|
|
||||||
class ErrorReportingAdmin : ReportingAdministrator {
|
|
||||||
|
|
||||||
override fun shouldStartCollecting(
|
|
||||||
context: Context,
|
|
||||||
config: CoreConfiguration,
|
|
||||||
reportBuilder: ReportBuilder
|
|
||||||
): Boolean {
|
|
||||||
return reportBuilder.exception?.isDeadOs() != true
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun Throwable.isDeadOs(): Boolean {
|
|
||||||
val className = javaClass.simpleName
|
|
||||||
return className == "DeadSystemException" || className == "DeadSystemRuntimeException" || cause?.isDeadOs() == true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -12,6 +12,7 @@ import org.koitharu.kotatsu.parsers.model.SortOrder
|
|||||||
import org.koitharu.kotatsu.parsers.util.json.getBooleanOrDefault
|
import org.koitharu.kotatsu.parsers.util.json.getBooleanOrDefault
|
||||||
import org.koitharu.kotatsu.parsers.util.json.getFloatOrDefault
|
import org.koitharu.kotatsu.parsers.util.json.getFloatOrDefault
|
||||||
import org.koitharu.kotatsu.parsers.util.json.getIntOrDefault
|
import org.koitharu.kotatsu.parsers.util.json.getIntOrDefault
|
||||||
|
import org.koitharu.kotatsu.parsers.util.json.getLongOrDefault
|
||||||
import org.koitharu.kotatsu.parsers.util.json.getStringOrNull
|
import org.koitharu.kotatsu.parsers.util.json.getStringOrNull
|
||||||
|
|
||||||
class JsonDeserializer(private val json: JSONObject) {
|
class JsonDeserializer(private val json: JSONObject) {
|
||||||
@@ -85,6 +86,8 @@ class JsonDeserializer(private val json: JSONObject) {
|
|||||||
isEnabled = json.getBoolean("enabled"),
|
isEnabled = json.getBoolean("enabled"),
|
||||||
sortKey = json.getInt("sort_key"),
|
sortKey = json.getInt("sort_key"),
|
||||||
addedIn = json.getIntOrDefault("added_in", 0),
|
addedIn = json.getIntOrDefault("added_in", 0),
|
||||||
|
lastUsedAt = json.getLongOrDefault("used_at", 0L),
|
||||||
|
isPinned = json.getBooleanOrDefault("pinned", false),
|
||||||
)
|
)
|
||||||
|
|
||||||
fun toMap(): Map<String, Any?> {
|
fun toMap(): Map<String, Any?> {
|
||||||
|
|||||||
@@ -89,6 +89,9 @@ class JsonSerializer private constructor(private val json: JSONObject) {
|
|||||||
put("source", e.source)
|
put("source", e.source)
|
||||||
put("enabled", e.isEnabled)
|
put("enabled", e.isEnabled)
|
||||||
put("sort_key", e.sortKey)
|
put("sort_key", e.sortKey)
|
||||||
|
put("added_in", e.addedIn)
|
||||||
|
put("used_at", e.lastUsedAt)
|
||||||
|
put("pinned", e.isPinned)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import org.koitharu.kotatsu.core.db.migrations.Migration18To19
|
|||||||
import org.koitharu.kotatsu.core.db.migrations.Migration19To20
|
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.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
|
||||||
@@ -59,7 +60,7 @@ 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 = 21
|
const val DATABASE_VERSION = 22
|
||||||
|
|
||||||
@Database(
|
@Database(
|
||||||
entities = [
|
entities = [
|
||||||
@@ -120,6 +121,7 @@ fun getDatabaseMigrations(context: Context): Array<Migration> = arrayOf(
|
|||||||
Migration18To19(),
|
Migration18To19(),
|
||||||
Migration19To20(),
|
Migration19To20(),
|
||||||
Migration20To21(),
|
Migration20To21(),
|
||||||
|
Migration21To22(),
|
||||||
)
|
)
|
||||||
|
|
||||||
fun MangaDatabase(context: Context): MangaDatabase = Room
|
fun MangaDatabase(context: Context): MangaDatabase = Room
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
package org.koitharu.kotatsu.core.db
|
||||||
|
|
||||||
|
import androidx.sqlite.db.SimpleSQLiteQuery
|
||||||
|
import org.koitharu.kotatsu.list.domain.ListFilterOption
|
||||||
|
import java.util.LinkedList
|
||||||
|
|
||||||
|
class MangaQueryBuilder(
|
||||||
|
private val table: String,
|
||||||
|
private val conditionCallback: ConditionCallback
|
||||||
|
) {
|
||||||
|
|
||||||
|
private var filterOptions: Collection<ListFilterOption> = emptyList()
|
||||||
|
private var whereConditions = LinkedList<String>()
|
||||||
|
private var orderBy: String? = null
|
||||||
|
private var groupBy: String? = null
|
||||||
|
private var extraJoins: String? = null
|
||||||
|
private var limit: Int = 0
|
||||||
|
|
||||||
|
fun filters(options: Collection<ListFilterOption>) = apply {
|
||||||
|
filterOptions = options
|
||||||
|
}
|
||||||
|
|
||||||
|
fun where(condition: String) = apply {
|
||||||
|
whereConditions.add(condition)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun orderBy(orderBy: String?) = apply {
|
||||||
|
this@MangaQueryBuilder.orderBy = orderBy
|
||||||
|
}
|
||||||
|
|
||||||
|
fun groupBy(groupBy: String?) = apply {
|
||||||
|
this@MangaQueryBuilder.groupBy = groupBy
|
||||||
|
}
|
||||||
|
|
||||||
|
fun limit(limit: Int) = apply {
|
||||||
|
this@MangaQueryBuilder.limit = limit
|
||||||
|
}
|
||||||
|
|
||||||
|
fun join(join: String?) = apply {
|
||||||
|
extraJoins = join
|
||||||
|
}
|
||||||
|
|
||||||
|
fun build() = buildString {
|
||||||
|
append("SELECT * FROM ")
|
||||||
|
append(table)
|
||||||
|
extraJoins?.let {
|
||||||
|
append(' ')
|
||||||
|
append(it)
|
||||||
|
}
|
||||||
|
if (whereConditions.isNotEmpty()) {
|
||||||
|
whereConditions.joinTo(
|
||||||
|
buffer = this,
|
||||||
|
prefix = " WHERE ",
|
||||||
|
separator = " AND ",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (filterOptions.isNotEmpty()) {
|
||||||
|
if (whereConditions.isEmpty()) {
|
||||||
|
append(" WHERE")
|
||||||
|
} else {
|
||||||
|
append(" AND")
|
||||||
|
}
|
||||||
|
var isFirst = true
|
||||||
|
val groupedOptions = filterOptions.groupBy { it.groupKey }
|
||||||
|
for ((_, group) in groupedOptions) {
|
||||||
|
if (group.isEmpty()) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (isFirst) {
|
||||||
|
isFirst = false
|
||||||
|
append(' ')
|
||||||
|
} else {
|
||||||
|
append(" AND ")
|
||||||
|
}
|
||||||
|
if (group.size > 1) {
|
||||||
|
group.joinTo(
|
||||||
|
buffer = this,
|
||||||
|
separator = " OR ",
|
||||||
|
prefix = "(",
|
||||||
|
postfix = ")",
|
||||||
|
transform = ::getConditionOrThrow,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
append(getConditionOrThrow(group.single()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
groupBy?.let {
|
||||||
|
append(" GROUP BY ")
|
||||||
|
append(it)
|
||||||
|
}
|
||||||
|
orderBy?.let {
|
||||||
|
append(" ORDER BY ")
|
||||||
|
append(it)
|
||||||
|
}
|
||||||
|
if (limit > 0) {
|
||||||
|
append(" LIMIT ")
|
||||||
|
append(limit)
|
||||||
|
}
|
||||||
|
}.let { SimpleSQLiteQuery(it) }
|
||||||
|
|
||||||
|
private fun getConditionOrThrow(option: ListFilterOption): String = when (option) {
|
||||||
|
is ListFilterOption.Inverted -> "NOT(${getConditionOrThrow(option.option)})"
|
||||||
|
else -> requireNotNull(conditionCallback.getCondition(option)) {
|
||||||
|
"Unsupported filter option $option"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun interface ConditionCallback {
|
||||||
|
|
||||||
|
fun getCondition(option: ListFilterOption): String?
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,7 +18,7 @@ import org.koitharu.kotatsu.explore.data.SourcesSortOrder
|
|||||||
@Dao
|
@Dao
|
||||||
abstract class MangaSourcesDao {
|
abstract class MangaSourcesDao {
|
||||||
|
|
||||||
@Query("SELECT * FROM sources ORDER BY sort_key")
|
@Query("SELECT * FROM sources ORDER BY pinned DESC, sort_key")
|
||||||
abstract suspend fun findAll(): List<MangaSourceEntity>
|
abstract suspend fun findAll(): List<MangaSourceEntity>
|
||||||
|
|
||||||
@Query("SELECT source FROM sources WHERE enabled = 1")
|
@Query("SELECT source FROM sources WHERE enabled = 1")
|
||||||
@@ -27,7 +27,10 @@ abstract class MangaSourcesDao {
|
|||||||
@Query("SELECT * FROM sources WHERE added_in >= :version")
|
@Query("SELECT * FROM sources WHERE added_in >= :version")
|
||||||
abstract suspend fun findAllFromVersion(version: Int): List<MangaSourceEntity>
|
abstract suspend fun findAllFromVersion(version: Int): List<MangaSourceEntity>
|
||||||
|
|
||||||
@Query("SELECT * FROM sources ORDER BY sort_key")
|
@Query("SELECT * FROM sources ORDER BY used_at DESC LIMIT :limit")
|
||||||
|
abstract suspend fun findLastUsed(limit: Int): List<MangaSourceEntity>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM sources ORDER BY pinned DESC, sort_key")
|
||||||
abstract fun observeAll(): Flow<List<MangaSourceEntity>>
|
abstract fun observeAll(): Flow<List<MangaSourceEntity>>
|
||||||
|
|
||||||
@Query("SELECT enabled FROM sources WHERE source = :source")
|
@Query("SELECT enabled FROM sources WHERE source = :source")
|
||||||
@@ -42,6 +45,12 @@ abstract class MangaSourcesDao {
|
|||||||
@Query("UPDATE sources SET sort_key = :sortKey WHERE source = :source")
|
@Query("UPDATE sources SET sort_key = :sortKey WHERE source = :source")
|
||||||
abstract suspend fun setSortKey(source: String, sortKey: Int)
|
abstract suspend fun setSortKey(source: String, sortKey: Int)
|
||||||
|
|
||||||
|
@Query("UPDATE sources SET used_at = :value WHERE source = :source")
|
||||||
|
abstract suspend fun setLastUsed(source: String, value: Long)
|
||||||
|
|
||||||
|
@Query("UPDATE sources SET pinned = :isPinned WHERE source = :source")
|
||||||
|
abstract suspend fun setPinned(source: String, isPinned: Boolean)
|
||||||
|
|
||||||
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||||
@Transaction
|
@Transaction
|
||||||
abstract suspend fun insertIfAbsent(entries: Collection<MangaSourceEntity>)
|
abstract suspend fun insertIfAbsent(entries: Collection<MangaSourceEntity>)
|
||||||
@@ -49,11 +58,14 @@ abstract class MangaSourcesDao {
|
|||||||
@Upsert
|
@Upsert
|
||||||
abstract suspend fun upsert(entry: MangaSourceEntity)
|
abstract suspend fun upsert(entry: MangaSourceEntity)
|
||||||
|
|
||||||
|
@Query("SELECT * FROM sources WHERE pinned = 1")
|
||||||
|
abstract suspend fun findAllPinned(): List<MangaSourceEntity>
|
||||||
|
|
||||||
fun observeEnabled(order: SourcesSortOrder): Flow<List<MangaSourceEntity>> {
|
fun observeEnabled(order: SourcesSortOrder): Flow<List<MangaSourceEntity>> {
|
||||||
val orderBy = getOrderBy(order)
|
val orderBy = getOrderBy(order)
|
||||||
|
|
||||||
@Language("RoomSql")
|
@Language("RoomSql")
|
||||||
val query = SimpleSQLiteQuery("SELECT * FROM sources WHERE enabled = 1 ORDER BY $orderBy")
|
val query = SimpleSQLiteQuery("SELECT * FROM sources WHERE enabled = 1 ORDER BY pinned DESC, $orderBy")
|
||||||
return observeImpl(query)
|
return observeImpl(query)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,7 +73,7 @@ abstract class MangaSourcesDao {
|
|||||||
val orderBy = getOrderBy(order)
|
val orderBy = getOrderBy(order)
|
||||||
|
|
||||||
@Language("RoomSql")
|
@Language("RoomSql")
|
||||||
val query = SimpleSQLiteQuery("SELECT * FROM sources WHERE enabled = 1 ORDER BY $orderBy")
|
val query = SimpleSQLiteQuery("SELECT * FROM sources WHERE enabled = 1 ORDER BY pinned DESC, $orderBy")
|
||||||
return findAllImpl(query)
|
return findAllImpl(query)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,6 +85,8 @@ abstract class MangaSourcesDao {
|
|||||||
isEnabled = isEnabled,
|
isEnabled = isEnabled,
|
||||||
sortKey = getMaxSortKey() + 1,
|
sortKey = getMaxSortKey() + 1,
|
||||||
addedIn = BuildConfig.VERSION_CODE,
|
addedIn = BuildConfig.VERSION_CODE,
|
||||||
|
lastUsedAt = 0,
|
||||||
|
isPinned = false,
|
||||||
)
|
)
|
||||||
upsert(entity)
|
upsert(entity)
|
||||||
}
|
}
|
||||||
@@ -91,5 +105,6 @@ abstract class MangaSourcesDao {
|
|||||||
SourcesSortOrder.ALPHABETIC -> "source ASC"
|
SourcesSortOrder.ALPHABETIC -> "source ASC"
|
||||||
SourcesSortOrder.POPULARITY -> "(SELECT COUNT(*) FROM manga WHERE source = sources.source) DESC"
|
SourcesSortOrder.POPULARITY -> "(SELECT COUNT(*) FROM manga WHERE source = sources.source) DESC"
|
||||||
SourcesSortOrder.MANUAL -> "sort_key ASC"
|
SourcesSortOrder.MANUAL -> "sort_key ASC"
|
||||||
|
SourcesSortOrder.LAST_USED -> "used_at DESC"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,36 +4,59 @@ import androidx.room.Dao
|
|||||||
import androidx.room.Insert
|
import androidx.room.Insert
|
||||||
import androidx.room.OnConflictStrategy
|
import androidx.room.OnConflictStrategy
|
||||||
import androidx.room.Query
|
import androidx.room.Query
|
||||||
|
import androidx.room.RawQuery
|
||||||
import androidx.room.Transaction
|
import androidx.room.Transaction
|
||||||
|
import androidx.sqlite.db.SupportSQLiteQuery
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import org.koitharu.kotatsu.core.db.MangaQueryBuilder
|
||||||
|
import org.koitharu.kotatsu.list.domain.ListFilterOption
|
||||||
import org.koitharu.kotatsu.tracker.data.TrackLogEntity
|
import org.koitharu.kotatsu.tracker.data.TrackLogEntity
|
||||||
import org.koitharu.kotatsu.tracker.data.TrackLogWithManga
|
import org.koitharu.kotatsu.tracker.data.TrackLogWithManga
|
||||||
|
|
||||||
@Dao
|
@Dao
|
||||||
interface TrackLogsDao {
|
abstract class TrackLogsDao : MangaQueryBuilder.ConditionCallback {
|
||||||
|
|
||||||
@Transaction
|
fun observeAll(
|
||||||
@Query("SELECT * FROM track_logs ORDER BY created_at DESC LIMIT :limit OFFSET 0")
|
limit: Int,
|
||||||
fun observeAll(limit: Int): Flow<List<TrackLogWithManga>>
|
filterOptions: Set<ListFilterOption>,
|
||||||
|
): Flow<List<TrackLogWithManga>> = observeAllImpl(
|
||||||
|
MangaQueryBuilder("track_logs", this)
|
||||||
|
.filters(filterOptions)
|
||||||
|
.limit(limit)
|
||||||
|
.orderBy("created_at DESC")
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
|
||||||
@Query("SELECT COUNT(*) FROM track_logs WHERE unread = 1")
|
@Query("SELECT COUNT(*) FROM track_logs WHERE unread = 1")
|
||||||
fun observeUnreadCount(): Flow<Int>
|
abstract fun observeUnreadCount(): Flow<Int>
|
||||||
|
|
||||||
@Query("DELETE FROM track_logs")
|
@Query("DELETE FROM track_logs")
|
||||||
suspend fun clear()
|
abstract suspend fun clear()
|
||||||
|
|
||||||
@Query("UPDATE track_logs SET unread = 0 WHERE id = :id")
|
@Query("UPDATE track_logs SET unread = 0 WHERE id = :id")
|
||||||
suspend fun markAsRead(id: Long)
|
abstract suspend fun markAsRead(id: Long)
|
||||||
|
|
||||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
suspend fun insert(entity: TrackLogEntity): Long
|
abstract suspend fun insert(entity: TrackLogEntity): Long
|
||||||
|
|
||||||
@Query("DELETE FROM track_logs WHERE manga_id NOT IN (SELECT manga_id FROM tracks)")
|
@Query("DELETE FROM track_logs WHERE manga_id NOT IN (SELECT manga_id FROM tracks)")
|
||||||
suspend fun gc()
|
abstract suspend fun gc()
|
||||||
|
|
||||||
@Query("DELETE FROM track_logs WHERE id IN (SELECT id FROM track_logs ORDER BY created_at DESC LIMIT 0 OFFSET :size)")
|
@Query("DELETE FROM track_logs WHERE id IN (SELECT id FROM track_logs ORDER BY created_at DESC LIMIT 0 OFFSET :size)")
|
||||||
suspend fun trim(size: Int)
|
abstract suspend fun trim(size: Int)
|
||||||
|
|
||||||
@Query("SELECT COUNT(*) FROM track_logs")
|
@Query("SELECT COUNT(*) FROM track_logs")
|
||||||
suspend fun count(): Int
|
abstract suspend fun count(): Int
|
||||||
|
|
||||||
|
@Transaction
|
||||||
|
@RawQuery(observedEntities = [TrackLogEntity::class])
|
||||||
|
protected abstract fun observeAllImpl(query: SupportSQLiteQuery): Flow<List<TrackLogWithManga>>
|
||||||
|
|
||||||
|
override fun getCondition(option: ListFilterOption): String? = when (option) {
|
||||||
|
ListFilterOption.Macro.FAVORITE -> "EXISTS(SELECT * FROM favourites WHERE favourites.manga_id = track_logs.manga_id)"
|
||||||
|
is ListFilterOption.Favorite -> "EXISTS(SELECT * FROM favourites WHERE favourites.manga_id = track_logs.manga_id AND favourites.category_id = ${option.category.id})"
|
||||||
|
is ListFilterOption.Tag -> "EXISTS(SELECT * FROM manga_tags WHERE manga_tags.manga_id = track_logs.manga_id AND tag_id = ${option.tagId})"
|
||||||
|
ListFilterOption.Macro.NSFW -> "(SELECT nsfw FROM manga WHERE manga.manga_id = track_logs.manga_id) = 1"
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,4 +15,6 @@ data class MangaSourceEntity(
|
|||||||
@ColumnInfo(name = "enabled") val isEnabled: Boolean,
|
@ColumnInfo(name = "enabled") val isEnabled: Boolean,
|
||||||
@ColumnInfo(name = "sort_key", index = true) val sortKey: Int,
|
@ColumnInfo(name = "sort_key", index = true) val sortKey: Int,
|
||||||
@ColumnInfo(name = "added_in") val addedIn: Int,
|
@ColumnInfo(name = "added_in") val addedIn: Int,
|
||||||
|
@ColumnInfo(name = "used_at") val lastUsedAt: Long,
|
||||||
|
@ColumnInfo(name = "pinned") val isPinned: Boolean,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package org.koitharu.kotatsu.core.db.migrations
|
||||||
|
|
||||||
|
import androidx.room.migration.Migration
|
||||||
|
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||||
|
|
||||||
|
class Migration21To22 : Migration(21, 22) {
|
||||||
|
|
||||||
|
override fun migrate(db: SupportSQLiteDatabase) {
|
||||||
|
db.execSQL("ALTER TABLE sources ADD COLUMN `used_at` INTEGER NOT NULL DEFAULT 0")
|
||||||
|
db.execSQL("ALTER TABLE sources ADD COLUMN `pinned` INTEGER NOT NULL DEFAULT 0")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package org.koitharu.kotatsu.core.exceptions
|
||||||
|
|
||||||
|
class IncompatiblePluginException(
|
||||||
|
val name: String?,
|
||||||
|
cause: Throwable?,
|
||||||
|
) : RuntimeException(cause)
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package org.koitharu.kotatsu.core.exceptions
|
||||||
|
|
||||||
|
import java.net.ProtocolException
|
||||||
|
|
||||||
|
class ProxyConfigException : ProtocolException("Wrong proxy configuration")
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.exceptions
|
|
||||||
|
|
||||||
import okio.IOException
|
|
||||||
import java.time.Instant
|
|
||||||
import java.time.temporal.ChronoUnit
|
|
||||||
|
|
||||||
class TooManyRequestExceptions(
|
|
||||||
val url: String,
|
|
||||||
val retryAt: Instant?,
|
|
||||||
) : IOException() {
|
|
||||||
val retryAfter: Long
|
|
||||||
get() = retryAt?.until(Instant.now(), ChronoUnit.MILLIS) ?: 0
|
|
||||||
}
|
|
||||||
@@ -2,67 +2,55 @@ package org.koitharu.kotatsu.core.exceptions.resolve
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.activity.result.ActivityResultCallback
|
import androidx.activity.result.ActivityResultCaller
|
||||||
import androidx.activity.result.ActivityResultLauncher
|
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
import androidx.collection.MutableScatterMap
|
import androidx.collection.MutableScatterMap
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.FragmentManager
|
||||||
import androidx.fragment.app.FragmentActivity
|
|
||||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
import dagger.hilt.android.EntryPointAccessors
|
import dagger.assisted.Assisted
|
||||||
|
import dagger.assisted.AssistedFactory
|
||||||
|
import dagger.assisted.AssistedInject
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.alternatives.ui.AlternativesActivity
|
import org.koitharu.kotatsu.alternatives.ui.AlternativesActivity
|
||||||
import org.koitharu.kotatsu.browser.BrowserActivity
|
import org.koitharu.kotatsu.browser.BrowserActivity
|
||||||
import org.koitharu.kotatsu.browser.cloudflare.CloudFlareActivity
|
import org.koitharu.kotatsu.browser.cloudflare.CloudFlareActivity
|
||||||
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
|
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
|
||||||
|
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.BaseActivity.BaseActivityEntryPoint
|
|
||||||
import org.koitharu.kotatsu.core.ui.dialog.ErrorDetailsDialog
|
import org.koitharu.kotatsu.core.ui.dialog.ErrorDetailsDialog
|
||||||
import org.koitharu.kotatsu.core.util.TaggedActivityResult
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.findActivity
|
import org.koitharu.kotatsu.core.util.ext.findActivity
|
||||||
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
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
|
import org.koitharu.kotatsu.scrobbling.common.domain.ScrobblerAuthRequiredException
|
||||||
|
import org.koitharu.kotatsu.scrobbling.common.ui.ScrobblerAuthHelper
|
||||||
|
import org.koitharu.kotatsu.settings.SettingsActivity
|
||||||
import org.koitharu.kotatsu.settings.sources.auth.SourceAuthActivity
|
import org.koitharu.kotatsu.settings.sources.auth.SourceAuthActivity
|
||||||
import java.security.cert.CertPathValidatorException
|
import java.security.cert.CertPathValidatorException
|
||||||
|
import javax.inject.Provider
|
||||||
import javax.net.ssl.SSLException
|
import javax.net.ssl.SSLException
|
||||||
import kotlin.coroutines.Continuation
|
import kotlin.coroutines.Continuation
|
||||||
import kotlin.coroutines.resume
|
import kotlin.coroutines.resume
|
||||||
import kotlin.coroutines.suspendCoroutine
|
import kotlin.coroutines.suspendCoroutine
|
||||||
|
|
||||||
class ExceptionResolver : ActivityResultCallback<TaggedActivityResult> {
|
class ExceptionResolver @AssistedInject constructor(
|
||||||
|
@Assisted private val host: Host,
|
||||||
|
private val settings: AppSettings,
|
||||||
|
private val scrobblerAuthHelperProvider: Provider<ScrobblerAuthHelper>,
|
||||||
|
) {
|
||||||
private val continuations = MutableScatterMap<String, Continuation<Boolean>>(1)
|
private val continuations = MutableScatterMap<String, Continuation<Boolean>>(1)
|
||||||
private val activity: FragmentActivity?
|
|
||||||
private val fragment: Fragment?
|
|
||||||
private val sourceAuthContract: ActivityResultLauncher<MangaSource>
|
|
||||||
private val cloudflareContract: ActivityResultLauncher<CloudFlareProtectedException>
|
|
||||||
|
|
||||||
val context: Context?
|
private val sourceAuthContract = host.registerForActivityResult(SourceAuthActivity.Contract()) {
|
||||||
get() = activity ?: fragment?.context
|
handleActivityResult(SourceAuthActivity.TAG, it)
|
||||||
|
|
||||||
constructor(activity: FragmentActivity) {
|
|
||||||
this.activity = activity
|
|
||||||
fragment = null
|
|
||||||
sourceAuthContract = activity.registerForActivityResult(SourceAuthActivity.Contract(), this)
|
|
||||||
cloudflareContract = activity.registerForActivityResult(CloudFlareActivity.Contract(), this)
|
|
||||||
}
|
}
|
||||||
|
private val cloudflareContract = host.registerForActivityResult(CloudFlareActivity.Contract()) {
|
||||||
constructor(fragment: Fragment) {
|
handleActivityResult(CloudFlareActivity.TAG, it)
|
||||||
this.fragment = fragment
|
|
||||||
activity = null
|
|
||||||
sourceAuthContract = fragment.registerForActivityResult(SourceAuthActivity.Contract(), this)
|
|
||||||
cloudflareContract = fragment.registerForActivityResult(CloudFlareActivity.Contract(), this)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onActivityResult(result: TaggedActivityResult) {
|
|
||||||
continuations.remove(result.tag)?.resume(result.isSuccess)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun showDetails(e: Throwable, url: String?) {
|
fun showDetails(e: Throwable, url: String?) {
|
||||||
ErrorDetailsDialog.show(getFragmentManager(), e, url)
|
ErrorDetailsDialog.show(host.getChildFragmentManager(), e, url)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun resolve(e: Throwable): Boolean = when (e) {
|
suspend fun resolve(e: Throwable): Boolean = when (e) {
|
||||||
@@ -74,6 +62,13 @@ class ExceptionResolver : ActivityResultCallback<TaggedActivityResult> {
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
is ProxyConfigException -> {
|
||||||
|
host.withContext {
|
||||||
|
startActivity(SettingsActivity.newProxySettingsIntent(this))
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
is NotFoundException -> {
|
is NotFoundException -> {
|
||||||
openInBrowser(e.url)
|
openInBrowser(e.url)
|
||||||
false
|
false
|
||||||
@@ -84,6 +79,20 @@ class ExceptionResolver : ActivityResultCallback<TaggedActivityResult> {
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
is ScrobblerAuthRequiredException -> {
|
||||||
|
val authHelper = scrobblerAuthHelperProvider.get()
|
||||||
|
if (authHelper.isAuthorized(e.scrobbler)) {
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
host.withContext {
|
||||||
|
authHelper.startAuth(this, e.scrobbler).onFailure {
|
||||||
|
showDetails(it, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
else -> false
|
else -> false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,21 +106,20 @@ class ExceptionResolver : ActivityResultCallback<TaggedActivityResult> {
|
|||||||
sourceAuthContract.launch(source)
|
sourceAuthContract.launch(source)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun openInBrowser(url: String) {
|
private fun openInBrowser(url: String) = host.withContext {
|
||||||
context?.run {
|
startActivity(BrowserActivity.newIntent(this, url, null, null))
|
||||||
startActivity(BrowserActivity.newIntent(this, url, null, null))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun openAlternatives(manga: Manga) {
|
private fun openAlternatives(manga: Manga) = host.withContext {
|
||||||
context?.run {
|
startActivity(AlternativesActivity.newIntent(this, manga))
|
||||||
startActivity(AlternativesActivity.newIntent(this, manga))
|
}
|
||||||
}
|
|
||||||
|
private fun handleActivityResult(tag: String, result: Boolean) {
|
||||||
|
continuations.remove(tag)?.resume(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun showSslErrorDialog() {
|
private fun showSslErrorDialog() {
|
||||||
val ctx = context ?: return
|
val ctx = host.getContext() ?: return
|
||||||
val settings = getAppSettings(ctx)
|
|
||||||
if (settings.isSSLBypassEnabled) {
|
if (settings.isSSLBypassEnabled) {
|
||||||
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
|
||||||
@@ -127,23 +135,38 @@ class ExceptionResolver : ActivityResultCallback<TaggedActivityResult> {
|
|||||||
.show()
|
.show()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getAppSettings(context: Context): AppSettings {
|
private inline fun Host.withContext(block: Context.() -> Unit) {
|
||||||
return EntryPointAccessors.fromApplication<BaseActivityEntryPoint>(context).settings
|
getContext()?.apply(block)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getFragmentManager() = checkNotNull(fragment?.childFragmentManager ?: activity?.supportFragmentManager)
|
interface Host : ActivityResultCaller {
|
||||||
|
|
||||||
|
fun getChildFragmentManager(): FragmentManager
|
||||||
|
|
||||||
|
fun getContext(): Context?
|
||||||
|
}
|
||||||
|
|
||||||
|
@AssistedFactory
|
||||||
|
interface Factory {
|
||||||
|
|
||||||
|
fun create(host: Host): ExceptionResolver
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
||||||
@StringRes
|
@StringRes
|
||||||
fun getResolveStringId(e: Throwable) = when (e) {
|
fun getResolveStringId(e: Throwable) = when (e) {
|
||||||
is CloudFlareProtectedException -> R.string.captcha_solve
|
is CloudFlareProtectedException -> R.string.captcha_solve
|
||||||
|
is ScrobblerAuthRequiredException,
|
||||||
is AuthRequiredException -> R.string.sign_in
|
is AuthRequiredException -> R.string.sign_in
|
||||||
|
|
||||||
is NotFoundException -> if (e.url.isNotEmpty()) R.string.open_in_browser else 0
|
is NotFoundException -> if (e.url.isNotEmpty()) R.string.open_in_browser else 0
|
||||||
is UnsupportedSourceException -> if (e.manga != null) R.string.alternatives else 0
|
is UnsupportedSourceException -> if (e.manga != null) R.string.alternatives else 0
|
||||||
is SSLException,
|
is SSLException,
|
||||||
is CertPathValidatorException -> R.string.fix
|
is CertPathValidatorException -> R.string.fix
|
||||||
|
|
||||||
|
is ProxyConfigException -> R.string.settings
|
||||||
|
|
||||||
else -> 0
|
else -> 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package org.koitharu.kotatsu.core.model
|
||||||
|
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||||
|
|
||||||
|
enum class GenericSortOrder(
|
||||||
|
@StringRes val titleResId: Int,
|
||||||
|
val ascending: SortOrder,
|
||||||
|
val descending: SortOrder,
|
||||||
|
) {
|
||||||
|
|
||||||
|
UPDATED(R.string.updated, SortOrder.UPDATED_ASC, SortOrder.UPDATED),
|
||||||
|
RATING(R.string.by_rating, SortOrder.RATING_ASC, SortOrder.RATING),
|
||||||
|
POPULARITY(R.string.popularity, SortOrder.POPULARITY_ASC, SortOrder.POPULARITY),
|
||||||
|
DATE(R.string.by_date, SortOrder.NEWEST_ASC, SortOrder.NEWEST),
|
||||||
|
NAME(R.string.by_name, SortOrder.ALPHABETICAL, SortOrder.ALPHABETICAL_DESC),
|
||||||
|
;
|
||||||
|
|
||||||
|
operator fun get(direction: SortDirection): SortOrder = when (direction) {
|
||||||
|
SortDirection.ASC -> ascending
|
||||||
|
SortDirection.DESC -> descending
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
fun of(order: SortOrder): GenericSortOrder = entries.first { e ->
|
||||||
|
e.ascending == order || e.descending == order
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,12 +9,14 @@ import android.text.style.SuperscriptSpan
|
|||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
import androidx.core.text.inSpans
|
import androidx.core.text.inSpans
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.core.parser.external.ExternalMangaSource
|
||||||
import org.koitharu.kotatsu.core.util.ext.getDisplayName
|
import org.koitharu.kotatsu.core.util.ext.getDisplayName
|
||||||
import org.koitharu.kotatsu.core.util.ext.getThemeColor
|
import org.koitharu.kotatsu.core.util.ext.getThemeColor
|
||||||
import org.koitharu.kotatsu.core.util.ext.toLocale
|
import org.koitharu.kotatsu.core.util.ext.toLocale
|
||||||
import org.koitharu.kotatsu.parsers.model.ContentType
|
import org.koitharu.kotatsu.parsers.model.ContentType
|
||||||
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.util.splitTwoParts
|
||||||
import com.google.android.material.R as materialR
|
import com.google.android.material.R as materialR
|
||||||
|
|
||||||
data object LocalMangaSource : MangaSource {
|
data object LocalMangaSource : MangaSource {
|
||||||
@@ -26,11 +28,14 @@ data object UnknownMangaSource : MangaSource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun MangaSource(name: String?): MangaSource {
|
fun MangaSource(name: String?): MangaSource {
|
||||||
when (name) {
|
when (name ?: return UnknownMangaSource) {
|
||||||
null,
|
UnknownMangaSource.name -> return UnknownMangaSource
|
||||||
UnknownMangaSource.name -> UnknownMangaSource
|
|
||||||
|
|
||||||
LocalMangaSource.name -> LocalMangaSource
|
LocalMangaSource.name -> return LocalMangaSource
|
||||||
|
}
|
||||||
|
if (name.startsWith("content:")) {
|
||||||
|
val parts = name.substringAfter(':').splitTwoParts('/') ?: return UnknownMangaSource
|
||||||
|
return ExternalMangaSource(packageName = parts.first, authority = parts.second)
|
||||||
}
|
}
|
||||||
MangaParserSource.entries.forEach {
|
MangaParserSource.entries.forEach {
|
||||||
if (it.name == name) return it
|
if (it.name == name) return it
|
||||||
@@ -38,7 +43,8 @@ fun MangaSource(name: String?): MangaSource {
|
|||||||
return UnknownMangaSource
|
return UnknownMangaSource
|
||||||
}
|
}
|
||||||
|
|
||||||
fun MangaSource.isNsfw() = when (this) {
|
fun MangaSource.isNsfw(): Boolean = when (this) {
|
||||||
|
is MangaSourceInfo -> mangaSource.isNsfw()
|
||||||
is MangaParserSource -> contentType == ContentType.HENTAI
|
is MangaParserSource -> contentType == ContentType.HENTAI
|
||||||
else -> false
|
else -> false
|
||||||
}
|
}
|
||||||
@@ -53,18 +59,23 @@ val ContentType.titleResId
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun MangaSource.getSummary(context: Context): String? = when (this) {
|
fun MangaSource.getSummary(context: Context): String? = when (this) {
|
||||||
|
is MangaSourceInfo -> mangaSource.getSummary(context)
|
||||||
is MangaParserSource -> {
|
is MangaParserSource -> {
|
||||||
val type = context.getString(contentType.titleResId)
|
val type = context.getString(contentType.titleResId)
|
||||||
val locale = locale.toLocale().getDisplayName(context)
|
val locale = locale.toLocale().getDisplayName(context)
|
||||||
context.getString(R.string.source_summary_pattern, type, locale)
|
context.getString(R.string.source_summary_pattern, type, locale)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
is ExternalMangaSource -> context.getString(R.string.external_source)
|
||||||
|
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
|
|
||||||
fun MangaSource.getTitle(context: Context): String = when (this) {
|
fun MangaSource.getTitle(context: Context): String = when (this) {
|
||||||
|
is MangaSourceInfo -> mangaSource.getTitle(context)
|
||||||
is MangaParserSource -> title
|
is MangaParserSource -> title
|
||||||
LocalMangaSource -> context.getString(R.string.local_storage)
|
LocalMangaSource -> context.getString(R.string.local_storage)
|
||||||
|
is ExternalMangaSource -> resolveName(context)
|
||||||
else -> context.getString(R.string.unknown)
|
else -> context.getString(R.string.unknown)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package org.koitharu.kotatsu.core.model
|
||||||
|
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
|
|
||||||
|
data class MangaSourceInfo(
|
||||||
|
val mangaSource: MangaSource,
|
||||||
|
val isEnabled: Boolean,
|
||||||
|
val isPinned: Boolean,
|
||||||
|
) : MangaSource by mangaSource
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package org.koitharu.kotatsu.core.model
|
||||||
|
|
||||||
|
enum class SortDirection {
|
||||||
|
|
||||||
|
ASC, DESC;
|
||||||
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
package org.koitharu.kotatsu.core.network
|
package org.koitharu.kotatsu.core.network
|
||||||
|
|
||||||
|
import okio.IOException
|
||||||
|
import org.koitharu.kotatsu.core.exceptions.ProxyConfigException
|
||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||||
import java.io.IOException
|
|
||||||
import java.net.InetSocketAddress
|
import java.net.InetSocketAddress
|
||||||
import java.net.Proxy
|
import java.net.Proxy
|
||||||
import java.net.ProxySelector
|
import java.net.ProxySelector
|
||||||
@@ -31,9 +32,12 @@ class AppProxySelector(
|
|||||||
val type = settings.proxyType
|
val type = settings.proxyType
|
||||||
val address = settings.proxyAddress
|
val address = settings.proxyAddress
|
||||||
val port = settings.proxyPort
|
val port = settings.proxyPort
|
||||||
if (type == Proxy.Type.DIRECT || address.isNullOrEmpty() || port == 0) {
|
if (type == Proxy.Type.DIRECT) {
|
||||||
return Proxy.NO_PROXY
|
return Proxy.NO_PROXY
|
||||||
}
|
}
|
||||||
|
if (address.isNullOrEmpty() || port == 0) {
|
||||||
|
throw ProxyConfigException()
|
||||||
|
}
|
||||||
cachedProxy?.let {
|
cachedProxy?.let {
|
||||||
val addr = it.address() as? InetSocketAddress
|
val addr = it.address() as? InetSocketAddress
|
||||||
if (addr != null && it.type() == type && addr.port == port && addr.hostString == address) {
|
if (addr != null && it.type() == type && addr.port == port && addr.hostString == address) {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import okio.IOException
|
|||||||
import org.koitharu.kotatsu.BuildConfig
|
import org.koitharu.kotatsu.BuildConfig
|
||||||
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.RemoteMangaRepository
|
import org.koitharu.kotatsu.core.parser.ParserMangaRepository
|
||||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
import org.koitharu.kotatsu.parsers.util.mergeWith
|
import org.koitharu.kotatsu.parsers.util.mergeWith
|
||||||
@@ -30,7 +30,7 @@ class CommonHeadersInterceptor @Inject constructor(
|
|||||||
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) {
|
||||||
mangaRepositoryFactoryLazy.get().create(source) as? RemoteMangaRepository
|
mangaRepositoryFactoryLazy.get().create(source) as? ParserMangaRepository
|
||||||
} else {
|
} else {
|
||||||
if (BuildConfig.DEBUG) {
|
if (BuildConfig.DEBUG) {
|
||||||
Log.w("Http", "Request without source tag: ${request.url}")
|
Log.w("Http", "Request without source tag: ${request.url}")
|
||||||
@@ -38,7 +38,7 @@ class CommonHeadersInterceptor @Inject constructor(
|
|||||||
null
|
null
|
||||||
}
|
}
|
||||||
val headersBuilder = request.headers.newBuilder()
|
val headersBuilder = request.headers.newBuilder()
|
||||||
repository?.headers?.let {
|
repository?.getRequestHeaders()?.let {
|
||||||
headersBuilder.mergeWith(it, replaceExisting = false)
|
headersBuilder.mergeWith(it, replaceExisting = false)
|
||||||
}
|
}
|
||||||
if (headersBuilder[CommonHeaders.USER_AGENT] == null) {
|
if (headersBuilder[CommonHeaders.USER_AGENT] == null) {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import okhttp3.internal.canParseAsIpAddress
|
|||||||
import okhttp3.internal.closeQuietly
|
import okhttp3.internal.closeQuietly
|
||||||
import okhttp3.internal.publicsuffix.PublicSuffixDatabase
|
import okhttp3.internal.publicsuffix.PublicSuffixDatabase
|
||||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||||
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
|
import org.koitharu.kotatsu.core.parser.ParserMangaRepository
|
||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
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
|
||||||
@@ -54,7 +54,7 @@ class MirrorSwitchInterceptor @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun trySwitchMirror(repository: RemoteMangaRepository): Boolean = runInterruptible(Dispatchers.Default) {
|
suspend fun trySwitchMirror(repository: ParserMangaRepository): Boolean = runInterruptible(Dispatchers.Default) {
|
||||||
if (!isEnabled) {
|
if (!isEnabled) {
|
||||||
return@runInterruptible false
|
return@runInterruptible false
|
||||||
}
|
}
|
||||||
@@ -76,14 +76,14 @@ class MirrorSwitchInterceptor @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun rollback(repository: RemoteMangaRepository, oldMirror: String) = synchronized(obtainLock(repository.source)) {
|
fun rollback(repository: ParserMangaRepository, oldMirror: String) = synchronized(obtainLock(repository.source)) {
|
||||||
blacklist[repository.source]?.remove(oldMirror)
|
blacklist[repository.source]?.remove(oldMirror)
|
||||||
repository.domain = oldMirror
|
repository.domain = oldMirror
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun trySwitchMirror(request: Request, chain: Interceptor.Chain): Response? {
|
private fun trySwitchMirror(request: Request, chain: Interceptor.Chain): Response? {
|
||||||
val source = request.tag(MangaSource::class.java) ?: return null
|
val source = request.tag(MangaSource::class.java) ?: return null
|
||||||
val repository = mangaRepositoryFactoryLazy.get().create(source) as? RemoteMangaRepository ?: return null
|
val repository = mangaRepositoryFactoryLazy.get().create(source) as? ParserMangaRepository ?: return null
|
||||||
val mirrors = repository.getAvailableMirrors()
|
val mirrors = repository.getAvailableMirrors()
|
||||||
if (mirrors.isEmpty()) {
|
if (mirrors.isEmpty()) {
|
||||||
return null
|
return null
|
||||||
@@ -94,7 +94,7 @@ class MirrorSwitchInterceptor @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun tryMirrors(
|
private fun tryMirrors(
|
||||||
repository: RemoteMangaRepository,
|
repository: ParserMangaRepository,
|
||||||
mirrors: List<String>,
|
mirrors: List<String>,
|
||||||
chain: Interceptor.Chain,
|
chain: Interceptor.Chain,
|
||||||
request: Request,
|
request: Request,
|
||||||
|
|||||||
@@ -3,28 +3,27 @@ package org.koitharu.kotatsu.core.network
|
|||||||
import okhttp3.Interceptor
|
import okhttp3.Interceptor
|
||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
import okhttp3.internal.closeQuietly
|
import okhttp3.internal.closeQuietly
|
||||||
import org.koitharu.kotatsu.core.exceptions.TooManyRequestExceptions
|
import org.koitharu.kotatsu.parsers.exception.TooManyRequestExceptions
|
||||||
import java.time.Instant
|
|
||||||
import java.time.ZonedDateTime
|
import java.time.ZonedDateTime
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
class RateLimitInterceptor : Interceptor {
|
class RateLimitInterceptor : Interceptor {
|
||||||
override fun intercept(chain: Interceptor.Chain): Response {
|
override fun intercept(chain: Interceptor.Chain): Response {
|
||||||
val response = chain.proceed(chain.request())
|
val response = chain.proceed(chain.request())
|
||||||
if (response.code == 429) {
|
if (response.code == 429) {
|
||||||
val retryDate = response.header(CommonHeaders.RETRY_AFTER)?.parseRetryDate()
|
|
||||||
val request = response.request
|
val request = response.request
|
||||||
response.closeQuietly()
|
response.closeQuietly()
|
||||||
throw TooManyRequestExceptions(
|
throw TooManyRequestExceptions(
|
||||||
url = request.url.toString(),
|
url = request.url.toString(),
|
||||||
retryAt = retryDate,
|
retryAfter = response.header(CommonHeaders.RETRY_AFTER)?.parseRetryAfter() ?: 0L,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return response
|
return response
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun String.parseRetryDate(): Instant? {
|
private fun String.parseRetryAfter(): Long {
|
||||||
return toLongOrNull()?.let { Instant.now().plusSeconds(it) }
|
return toLongOrNull()?.let { TimeUnit.SECONDS.toMillis(it) }
|
||||||
?: ZonedDateTime.parse(this, DateTimeFormatter.RFC_1123_DATE_TIME).toInstant()
|
?: ZonedDateTime.parse(this, DateTimeFormatter.RFC_1123_DATE_TIME).toInstant().toEpochMilli()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ class NetworkState(
|
|||||||
|
|
||||||
private val callback = NetworkCallbackImpl()
|
private val callback = NetworkCallbackImpl()
|
||||||
|
|
||||||
|
override val value: Boolean
|
||||||
|
get() = connectivityManager.isOnline(settings)
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
override fun onActive() {
|
override fun onActive() {
|
||||||
invalidate()
|
invalidate()
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
package org.koitharu.kotatsu.core.parser
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.collection.MutableLongSet
|
||||||
|
import coil.request.CachePolicy
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.MainCoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.currentCoroutineContext
|
||||||
|
import org.koitharu.kotatsu.BuildConfig
|
||||||
|
import org.koitharu.kotatsu.core.cache.MemoryContentCache
|
||||||
|
import org.koitharu.kotatsu.core.cache.SafeDeferred
|
||||||
|
import org.koitharu.kotatsu.core.util.MultiMutex
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
|
||||||
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||||
|
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||||
|
|
||||||
|
abstract class CachingMangaRepository(
|
||||||
|
private val cache: MemoryContentCache,
|
||||||
|
) : MangaRepository {
|
||||||
|
|
||||||
|
private val detailsMutex = MultiMutex<Long>()
|
||||||
|
private val relatedMangaMutex = MultiMutex<Long>()
|
||||||
|
private val pagesMutex = MultiMutex<Long>()
|
||||||
|
|
||||||
|
final override suspend fun getDetails(manga: Manga): Manga = getDetails(manga, CachePolicy.ENABLED)
|
||||||
|
|
||||||
|
final override suspend fun getPages(chapter: MangaChapter): List<MangaPage> = pagesMutex.withLock(chapter.id) {
|
||||||
|
cache.getPages(source, chapter.url)?.let { return it }
|
||||||
|
val pages = asyncSafe {
|
||||||
|
getPagesImpl(chapter).distinctById()
|
||||||
|
}
|
||||||
|
cache.putPages(source, chapter.url, pages)
|
||||||
|
pages
|
||||||
|
}.await()
|
||||||
|
|
||||||
|
final override suspend fun getRelated(seed: Manga): List<Manga> = relatedMangaMutex.withLock(seed.id) {
|
||||||
|
cache.getRelatedManga(source, seed.url)?.let { return it }
|
||||||
|
val related = asyncSafe {
|
||||||
|
getRelatedMangaImpl(seed).filterNot { it.id == seed.id }
|
||||||
|
}
|
||||||
|
cache.putRelatedManga(source, seed.url, related)
|
||||||
|
related
|
||||||
|
}.await()
|
||||||
|
|
||||||
|
suspend fun getDetails(manga: Manga, cachePolicy: CachePolicy): Manga = detailsMutex.withLock(manga.id) {
|
||||||
|
if (cachePolicy.readEnabled) {
|
||||||
|
cache.getDetails(source, manga.url)?.let { return it }
|
||||||
|
}
|
||||||
|
val details = asyncSafe {
|
||||||
|
getDetailsImpl(manga)
|
||||||
|
}
|
||||||
|
if (cachePolicy.writeEnabled) {
|
||||||
|
cache.putDetails(source, manga.url, details)
|
||||||
|
}
|
||||||
|
details
|
||||||
|
}.await()
|
||||||
|
|
||||||
|
suspend fun peekDetails(manga: Manga): Manga? {
|
||||||
|
return cache.getDetails(source, manga.url)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun invalidateCache() {
|
||||||
|
cache.clear(source)
|
||||||
|
}
|
||||||
|
|
||||||
|
protected abstract suspend fun getDetailsImpl(manga: Manga): Manga
|
||||||
|
|
||||||
|
protected abstract suspend fun getRelatedMangaImpl(seed: Manga): List<Manga>
|
||||||
|
|
||||||
|
protected abstract suspend fun getPagesImpl(chapter: MangaChapter): List<MangaPage>
|
||||||
|
|
||||||
|
private suspend fun <T> asyncSafe(block: suspend CoroutineScope.() -> T): SafeDeferred<T> {
|
||||||
|
var dispatcher = currentCoroutineContext()[CoroutineDispatcher.Key]
|
||||||
|
if (dispatcher == null || dispatcher is MainCoroutineDispatcher) {
|
||||||
|
dispatcher = Dispatchers.Default
|
||||||
|
}
|
||||||
|
return SafeDeferred(
|
||||||
|
processLifecycleScope.async(dispatcher) {
|
||||||
|
runCatchingCancellable { block() }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun List<MangaPage>.distinctById(): List<MangaPage> {
|
||||||
|
if (isEmpty()) {
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
|
val result = ArrayList<MangaPage>(size)
|
||||||
|
val set = MutableLongSet(size)
|
||||||
|
for (page in this) {
|
||||||
|
if (set.add(page.id)) {
|
||||||
|
result.add(page)
|
||||||
|
} else if (BuildConfig.DEBUG) {
|
||||||
|
Log.w(null, "Duplicate page: $page")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,9 +13,6 @@ import org.koitharu.kotatsu.parsers.model.SortOrder
|
|||||||
import java.util.EnumSet
|
import java.util.EnumSet
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
/**
|
|
||||||
* This parser is just for parser development, it should not be used in releases
|
|
||||||
*/
|
|
||||||
class EmptyMangaRepository(override val source: MangaSource) : MangaRepository {
|
class EmptyMangaRepository(override val source: MangaSource) : MangaRepository {
|
||||||
|
|
||||||
override val sortOrders: Set<SortOrder>
|
override val sortOrders: Set<SortOrder>
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ class MangaDataRepository @Inject constructor(
|
|||||||
cfBrightness = colorFilter?.brightness ?: 0f,
|
cfBrightness = colorFilter?.brightness ?: 0f,
|
||||||
cfContrast = colorFilter?.contrast ?: 0f,
|
cfContrast = colorFilter?.contrast ?: 0f,
|
||||||
cfInvert = colorFilter?.isInverted ?: false,
|
cfInvert = colorFilter?.isInverted ?: false,
|
||||||
|
cfGrayscale = colorFilter?.isGrayscale ?: false,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ class MangaLinkResolver @Inject constructor(
|
|||||||
val host = uri.host ?: return null
|
val host = uri.host ?: return null
|
||||||
val repo = sourcesRepository.allMangaSources.asSequence()
|
val repo = sourcesRepository.allMangaSources.asSequence()
|
||||||
.map { source ->
|
.map { source ->
|
||||||
repositoryFactory.create(source) as RemoteMangaRepository
|
repositoryFactory.create(source) as ParserMangaRepository
|
||||||
}.find { repo ->
|
}.find { repo ->
|
||||||
host in repo.domains
|
host in repo.domains
|
||||||
} ?: return null
|
} ?: return null
|
||||||
@@ -86,7 +86,7 @@ class MangaLinkResolver @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun MangaRepository.getDetailsNoCache(manga: Manga): Manga {
|
private suspend fun MangaRepository.getDetailsNoCache(manga: Manga): Manga {
|
||||||
return if (this is RemoteMangaRepository) {
|
return if (this is ParserMangaRepository) {
|
||||||
getDetails(manga, CachePolicy.READ_ONLY)
|
getDetails(manga, CachePolicy.READ_ONLY)
|
||||||
} else {
|
} else {
|
||||||
getDetails(manga)
|
getDetails(manga)
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
package org.koitharu.kotatsu.core.parser
|
package org.koitharu.kotatsu.core.parser
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
import androidx.annotation.AnyThread
|
import androidx.annotation.AnyThread
|
||||||
import androidx.collection.ArrayMap
|
import androidx.collection.ArrayMap
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import org.koitharu.kotatsu.core.cache.MemoryContentCache
|
import org.koitharu.kotatsu.core.cache.MemoryContentCache
|
||||||
import org.koitharu.kotatsu.core.model.LocalMangaSource
|
import org.koitharu.kotatsu.core.model.LocalMangaSource
|
||||||
|
import org.koitharu.kotatsu.core.model.MangaSourceInfo
|
||||||
import org.koitharu.kotatsu.core.model.UnknownMangaSource
|
import org.koitharu.kotatsu.core.model.UnknownMangaSource
|
||||||
import org.koitharu.kotatsu.core.network.MirrorSwitchInterceptor
|
import org.koitharu.kotatsu.core.network.MirrorSwitchInterceptor
|
||||||
|
import org.koitharu.kotatsu.core.parser.external.ExternalMangaRepository
|
||||||
|
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.ContentRating
|
||||||
@@ -56,8 +61,14 @@ interface MangaRepository {
|
|||||||
|
|
||||||
suspend fun getRelated(seed: Manga): List<Manga>
|
suspend fun getRelated(seed: Manga): List<Manga>
|
||||||
|
|
||||||
|
suspend fun find(manga: Manga): Manga? {
|
||||||
|
val list = getList(0, MangaListFilter.Search(manga.title))
|
||||||
|
return list.find { x -> x.id == manga.id }
|
||||||
|
}
|
||||||
|
|
||||||
@Singleton
|
@Singleton
|
||||||
class Factory @Inject constructor(
|
class Factory @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
private val localMangaRepository: LocalMangaRepository,
|
private val localMangaRepository: LocalMangaRepository,
|
||||||
private val loaderContext: MangaLoaderContext,
|
private val loaderContext: MangaLoaderContext,
|
||||||
private val contentCache: MemoryContentCache,
|
private val contentCache: MemoryContentCache,
|
||||||
@@ -69,6 +80,7 @@ interface MangaRepository {
|
|||||||
@AnyThread
|
@AnyThread
|
||||||
fun create(source: MangaSource): MangaRepository {
|
fun create(source: MangaSource): MangaRepository {
|
||||||
when (source) {
|
when (source) {
|
||||||
|
is MangaSourceInfo -> return create(source.mangaSource)
|
||||||
LocalMangaSource -> return localMangaRepository
|
LocalMangaSource -> return localMangaRepository
|
||||||
UnknownMangaSource -> return EmptyMangaRepository(source)
|
UnknownMangaSource -> return EmptyMangaRepository(source)
|
||||||
}
|
}
|
||||||
@@ -86,12 +98,22 @@ interface MangaRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun createRepository(source: MangaSource): MangaRepository? = when (source) {
|
private fun createRepository(source: MangaSource): MangaRepository? = when (source) {
|
||||||
is MangaParserSource -> RemoteMangaRepository(
|
is MangaParserSource -> ParserMangaRepository(
|
||||||
parser = MangaParser(source, loaderContext),
|
parser = MangaParser(source, loaderContext),
|
||||||
cache = contentCache,
|
cache = contentCache,
|
||||||
mirrorSwitchInterceptor = mirrorSwitchInterceptor,
|
mirrorSwitchInterceptor = mirrorSwitchInterceptor,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
is ExternalMangaSource -> if (source.isAvailable(context)) {
|
||||||
|
ExternalMangaRepository(
|
||||||
|
contentResolver = context.contentResolver,
|
||||||
|
source = source,
|
||||||
|
cache = contentCache,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
EmptyMangaRepository(source)
|
||||||
|
}
|
||||||
|
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,10 @@
|
|||||||
package org.koitharu.kotatsu.core.parser
|
package org.koitharu.kotatsu.core.parser
|
||||||
|
|
||||||
import android.util.Log
|
|
||||||
import androidx.collection.MutableLongSet
|
|
||||||
import coil.request.CachePolicy
|
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.MainCoroutineDispatcher
|
|
||||||
import kotlinx.coroutines.async
|
|
||||||
import kotlinx.coroutines.currentCoroutineContext
|
|
||||||
import okhttp3.Headers
|
|
||||||
import okhttp3.Interceptor
|
import okhttp3.Interceptor
|
||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
import org.koitharu.kotatsu.BuildConfig
|
|
||||||
import org.koitharu.kotatsu.core.cache.MemoryContentCache
|
import org.koitharu.kotatsu.core.cache.MemoryContentCache
|
||||||
import org.koitharu.kotatsu.core.cache.SafeDeferred
|
|
||||||
import org.koitharu.kotatsu.core.network.MirrorSwitchInterceptor
|
import org.koitharu.kotatsu.core.network.MirrorSwitchInterceptor
|
||||||
import org.koitharu.kotatsu.core.prefs.SourceSettings
|
import org.koitharu.kotatsu.core.prefs.SourceSettings
|
||||||
import org.koitharu.kotatsu.core.util.MultiMutex
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
|
|
||||||
import org.koitharu.kotatsu.parsers.MangaParser
|
import org.koitharu.kotatsu.parsers.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
|
||||||
@@ -36,15 +22,11 @@ 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
|
import java.util.Locale
|
||||||
|
|
||||||
class RemoteMangaRepository(
|
class ParserMangaRepository(
|
||||||
private val parser: MangaParser,
|
private val parser: MangaParser,
|
||||||
private val cache: MemoryContentCache,
|
|
||||||
private val mirrorSwitchInterceptor: MirrorSwitchInterceptor,
|
private val mirrorSwitchInterceptor: MirrorSwitchInterceptor,
|
||||||
) : MangaRepository, Interceptor {
|
cache: MemoryContentCache,
|
||||||
|
) : CachingMangaRepository(cache), Interceptor {
|
||||||
private val detailsMutex = MultiMutex<Long>()
|
|
||||||
private val relatedMangaMutex = MultiMutex<Long>()
|
|
||||||
private val pagesMutex = MultiMutex<Long>()
|
|
||||||
|
|
||||||
override val source: MangaParserSource
|
override val source: MangaParserSource
|
||||||
get() = parser.source
|
get() = parser.source
|
||||||
@@ -82,9 +64,6 @@ class RemoteMangaRepository(
|
|||||||
val domains: Array<out String>
|
val domains: Array<out String>
|
||||||
get() = parser.configKeyDomain.presetValues
|
get() = parser.configKeyDomain.presetValues
|
||||||
|
|
||||||
val headers: Headers
|
|
||||||
get() = parser.headers
|
|
||||||
|
|
||||||
override fun intercept(chain: Interceptor.Chain): Response {
|
override fun intercept(chain: Interceptor.Chain): Response {
|
||||||
return if (parser is Interceptor) {
|
return if (parser is Interceptor) {
|
||||||
parser.intercept(chain)
|
parser.intercept(chain)
|
||||||
@@ -99,18 +78,11 @@ class RemoteMangaRepository(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getDetails(manga: Manga): Manga = getDetails(manga, CachePolicy.ENABLED)
|
override suspend fun getPagesImpl(
|
||||||
|
chapter: MangaChapter
|
||||||
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> = pagesMutex.withLock(chapter.id) {
|
): List<MangaPage> = mirrorSwitchInterceptor.withMirrorSwitching {
|
||||||
cache.getPages(source, chapter.url)?.let { return it }
|
parser.getPages(chapter)
|
||||||
val pages = asyncSafe {
|
}
|
||||||
mirrorSwitchInterceptor.withMirrorSwitching {
|
|
||||||
parser.getPages(chapter).distinctById()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
cache.putPages(source, chapter.url, pages)
|
|
||||||
pages
|
|
||||||
}.await()
|
|
||||||
|
|
||||||
override suspend fun getPageUrl(page: MangaPage): String = mirrorSwitchInterceptor.withMirrorSwitching {
|
override suspend fun getPageUrl(page: MangaPage): String = mirrorSwitchInterceptor.withMirrorSwitching {
|
||||||
parser.getPageUrl(page)
|
parser.getPageUrl(page)
|
||||||
@@ -128,41 +100,16 @@ class RemoteMangaRepository(
|
|||||||
parser.getFavicons()
|
parser.getFavicons()
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getRelated(seed: Manga): List<Manga> = relatedMangaMutex.withLock(seed.id) {
|
override suspend fun getRelatedMangaImpl(seed: Manga): List<Manga> = parser.getRelatedManga(seed)
|
||||||
cache.getRelatedManga(source, seed.url)?.let { return it }
|
|
||||||
val related = asyncSafe {
|
|
||||||
parser.getRelatedManga(seed).filterNot { it.id == seed.id }
|
|
||||||
}
|
|
||||||
cache.putRelatedManga(source, seed.url, related)
|
|
||||||
related
|
|
||||||
}.await()
|
|
||||||
|
|
||||||
suspend fun getDetails(manga: Manga, cachePolicy: CachePolicy): Manga = detailsMutex.withLock(manga.id) {
|
override suspend fun getDetailsImpl(manga: Manga): Manga = mirrorSwitchInterceptor.withMirrorSwitching {
|
||||||
if (cachePolicy.readEnabled) {
|
parser.getDetails(manga)
|
||||||
cache.getDetails(source, manga.url)?.let { return it }
|
|
||||||
}
|
|
||||||
val details = asyncSafe {
|
|
||||||
mirrorSwitchInterceptor.withMirrorSwitching {
|
|
||||||
parser.getDetails(manga)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (cachePolicy.writeEnabled) {
|
|
||||||
cache.putDetails(source, manga.url, details)
|
|
||||||
}
|
|
||||||
details
|
|
||||||
}.await()
|
|
||||||
|
|
||||||
suspend fun peekDetails(manga: Manga): Manga? {
|
|
||||||
return cache.getDetails(source, manga.url)
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun find(manga: Manga): Manga? {
|
|
||||||
val list = getList(0, MangaListFilter.Search(manga.title))
|
|
||||||
return list.find { x -> x.id == manga.id }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getAuthProvider(): MangaParserAuthProvider? = parser as? MangaParserAuthProvider
|
fun getAuthProvider(): MangaParserAuthProvider? = parser as? MangaParserAuthProvider
|
||||||
|
|
||||||
|
fun getRequestHeaders() = parser.getRequestHeaders()
|
||||||
|
|
||||||
fun getConfigKeys(): List<ConfigKey<*>> = ArrayList<ConfigKey<*>>().also {
|
fun getConfigKeys(): List<ConfigKey<*>> = ArrayList<ConfigKey<*>>().also {
|
||||||
parser.onCreateConfig(it)
|
parser.onCreateConfig(it)
|
||||||
}
|
}
|
||||||
@@ -175,40 +122,8 @@ class RemoteMangaRepository(
|
|||||||
return getConfig().isSlowdownEnabled
|
return getConfig().isSlowdownEnabled
|
||||||
}
|
}
|
||||||
|
|
||||||
fun invalidateCache() {
|
|
||||||
cache.clear(source)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getConfig() = parser.config as SourceSettings
|
fun getConfig() = parser.config as SourceSettings
|
||||||
|
|
||||||
private suspend fun <T> asyncSafe(block: suspend CoroutineScope.() -> T): SafeDeferred<T> {
|
|
||||||
var dispatcher = currentCoroutineContext()[CoroutineDispatcher.Key]
|
|
||||||
if (dispatcher == null || dispatcher is MainCoroutineDispatcher) {
|
|
||||||
dispatcher = Dispatchers.Default
|
|
||||||
}
|
|
||||||
return SafeDeferred(
|
|
||||||
processLifecycleScope.async(dispatcher) {
|
|
||||||
runCatchingCancellable { block() }
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun List<MangaPage>.distinctById(): List<MangaPage> {
|
|
||||||
if (isEmpty()) {
|
|
||||||
return emptyList()
|
|
||||||
}
|
|
||||||
val result = ArrayList<MangaPage>(size)
|
|
||||||
val set = MutableLongSet(size)
|
|
||||||
for (page in this) {
|
|
||||||
if (set.add(page.id)) {
|
|
||||||
result.add(page)
|
|
||||||
} else if (BuildConfig.DEBUG) {
|
|
||||||
Log.w(null, "Duplicate page: $page")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun <R> MirrorSwitchInterceptor.withMirrorSwitching(block: suspend () -> R): R {
|
private suspend fun <R> MirrorSwitchInterceptor.withMirrorSwitching(block: suspend () -> R): R {
|
||||||
if (!isEnabled) {
|
if (!isEnabled) {
|
||||||
return block()
|
return block()
|
||||||
@@ -220,14 +135,14 @@ class RemoteMangaRepository(
|
|||||||
if (result.isValidResult()) {
|
if (result.isValidResult()) {
|
||||||
return result.getOrThrow()
|
return result.getOrThrow()
|
||||||
}
|
}
|
||||||
return if (trySwitchMirror(this@RemoteMangaRepository)) {
|
return if (trySwitchMirror(this@ParserMangaRepository)) {
|
||||||
val newResult = runCatchingCancellable {
|
val newResult = runCatchingCancellable {
|
||||||
block()
|
block()
|
||||||
}
|
}
|
||||||
if (newResult.isValidResult()) {
|
if (newResult.isValidResult()) {
|
||||||
return newResult.getOrThrow()
|
return newResult.getOrThrow()
|
||||||
} else {
|
} else {
|
||||||
rollback(this@RemoteMangaRepository, initialMirror)
|
rollback(this@ParserMangaRepository, initialMirror)
|
||||||
return result.getOrThrow()
|
return result.getOrThrow()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
80
app/src/main/kotlin/org/koitharu/kotatsu/core/parser/external/ExternalMangaRepository.kt
vendored
Normal file
80
app/src/main/kotlin/org/koitharu/kotatsu/core/parser/external/ExternalMangaRepository.kt
vendored
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
package org.koitharu.kotatsu.core.parser.external
|
||||||
|
|
||||||
|
import android.content.ContentResolver
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.runInterruptible
|
||||||
|
import org.koitharu.kotatsu.core.cache.MemoryContentCache
|
||||||
|
import org.koitharu.kotatsu.core.parser.CachingMangaRepository
|
||||||
|
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.MangaChapter
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
||||||
|
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 java.util.EnumSet
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
class ExternalMangaRepository(
|
||||||
|
private val contentResolver: ContentResolver,
|
||||||
|
override val source: ExternalMangaSource,
|
||||||
|
cache: MemoryContentCache,
|
||||||
|
) : CachingMangaRepository(cache) {
|
||||||
|
|
||||||
|
private val contentSource = ExternalPluginContentSource(contentResolver, source)
|
||||||
|
|
||||||
|
private val capabilities by lazy {
|
||||||
|
runCatching {
|
||||||
|
contentSource.getCapabilities()
|
||||||
|
}.onFailure {
|
||||||
|
it.printStackTraceDebug()
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
override val sortOrders: Set<SortOrder>
|
||||||
|
get() = capabilities?.availableSortOrders ?: EnumSet.of(SortOrder.ALPHABETICAL)
|
||||||
|
|
||||||
|
override val states: Set<MangaState>
|
||||||
|
get() = capabilities?.availableStates.orEmpty()
|
||||||
|
|
||||||
|
override val contentRatings: Set<ContentRating>
|
||||||
|
get() = capabilities?.availableContentRating.orEmpty()
|
||||||
|
|
||||||
|
override var defaultSortOrder: SortOrder
|
||||||
|
get() = capabilities?.defaultSortOrder ?: SortOrder.ALPHABETICAL
|
||||||
|
set(value) = Unit
|
||||||
|
|
||||||
|
override val isMultipleTagsSupported: Boolean
|
||||||
|
get() = capabilities?.isMultipleTagsSupported ?: true
|
||||||
|
|
||||||
|
override val isTagsExclusionSupported: Boolean
|
||||||
|
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) {
|
||||||
|
contentSource.getList(offset, filter)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getDetailsImpl(manga: Manga): Manga = runInterruptible(Dispatchers.IO) {
|
||||||
|
contentSource.getDetails(manga)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getPagesImpl(chapter: MangaChapter): List<MangaPage> = runInterruptible(Dispatchers.IO) {
|
||||||
|
contentSource.getPages(chapter)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getPageUrl(page: MangaPage): String = page.url // TODO
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
30
app/src/main/kotlin/org/koitharu/kotatsu/core/parser/external/ExternalMangaSource.kt
vendored
Normal file
30
app/src/main/kotlin/org/koitharu/kotatsu/core/parser/external/ExternalMangaSource.kt
vendored
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
package org.koitharu.kotatsu.core.parser.external
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
|
|
||||||
|
data class ExternalMangaSource(
|
||||||
|
val packageName: String,
|
||||||
|
val authority: String,
|
||||||
|
) : MangaSource {
|
||||||
|
|
||||||
|
override val name: String
|
||||||
|
get() = "content:$packageName/$authority"
|
||||||
|
|
||||||
|
private var cachedName: String? = null
|
||||||
|
|
||||||
|
fun isAvailable(context: Context): Boolean {
|
||||||
|
return context.packageManager.resolveContentProvider(authority, 0)?.isEnabled == true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun resolveName(context: Context): String {
|
||||||
|
cachedName?.let {
|
||||||
|
return it
|
||||||
|
}
|
||||||
|
val pm = context.packageManager
|
||||||
|
val info = pm.resolveContentProvider(authority, 0)
|
||||||
|
return info?.loadLabel(pm)?.toString()?.also {
|
||||||
|
cachedName = it
|
||||||
|
} ?: authority
|
||||||
|
}
|
||||||
|
}
|
||||||
286
app/src/main/kotlin/org/koitharu/kotatsu/core/parser/external/ExternalPluginContentSource.kt
vendored
Normal file
286
app/src/main/kotlin/org/koitharu/kotatsu/core/parser/external/ExternalPluginContentSource.kt
vendored
Normal file
@@ -0,0 +1,286 @@
|
|||||||
|
package org.koitharu.kotatsu.core.parser.external
|
||||||
|
|
||||||
|
import android.content.ContentResolver
|
||||||
|
import android.database.Cursor
|
||||||
|
import androidx.annotation.WorkerThread
|
||||||
|
import androidx.collection.ArraySet
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import org.jetbrains.annotations.Blocking
|
||||||
|
import org.koitharu.kotatsu.core.exceptions.IncompatiblePluginException
|
||||||
|
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.ContentType
|
||||||
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
||||||
|
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.util.find
|
||||||
|
import org.koitharu.kotatsu.parsers.util.mapNotNullToSet
|
||||||
|
import org.koitharu.kotatsu.parsers.util.splitTwoParts
|
||||||
|
import java.util.EnumSet
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
class ExternalPluginContentSource(
|
||||||
|
private val contentResolver: ContentResolver,
|
||||||
|
private val source: ExternalMangaSource,
|
||||||
|
) {
|
||||||
|
|
||||||
|
@Blocking
|
||||||
|
@WorkerThread
|
||||||
|
fun getList(offset: Int, filter: MangaListFilter?): List<Manga> {
|
||||||
|
val uri = "content://${source.authority}/manga".toUri().buildUpon()
|
||||||
|
uri.appendQueryParameter("offset", offset.toString())
|
||||||
|
when (filter) {
|
||||||
|
is MangaListFilter.Advanced -> {
|
||||||
|
filter.tags.forEach { uri.appendQueryParameter("tags_include", "${it.key}=${it.title}") }
|
||||||
|
filter.tagsExclude.forEach { uri.appendQueryParameter("tags_exclude", "${it.key}=${it.title}") }
|
||||||
|
filter.states.forEach { uri.appendQueryParameter("state", it.name) }
|
||||||
|
filter.locale?.let { uri.appendQueryParameter("locale", it.language) }
|
||||||
|
filter.contentRating.forEach { uri.appendQueryParameter("content_rating", it.name) }
|
||||||
|
}
|
||||||
|
|
||||||
|
is MangaListFilter.Search -> {
|
||||||
|
uri.appendQueryParameter("query", filter.query)
|
||||||
|
}
|
||||||
|
|
||||||
|
null -> Unit
|
||||||
|
}
|
||||||
|
return contentResolver.query(uri.build(), null, null, null, filter?.sortOrder?.name)
|
||||||
|
.safe()
|
||||||
|
.use { cursor ->
|
||||||
|
val result = ArrayList<Manga>(cursor.count)
|
||||||
|
if (cursor.moveToFirst()) {
|
||||||
|
do {
|
||||||
|
result += cursor.getManga()
|
||||||
|
} while (cursor.moveToNext())
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Blocking
|
||||||
|
@WorkerThread
|
||||||
|
fun getDetails(manga: Manga): Manga {
|
||||||
|
val chapters = queryChapters(manga.url)
|
||||||
|
val details = queryDetails(manga.url)
|
||||||
|
return Manga(
|
||||||
|
id = manga.id,
|
||||||
|
title = details.title.ifBlank { manga.title },
|
||||||
|
altTitle = details.altTitle.ifNullOrEmpty { manga.altTitle },
|
||||||
|
url = details.url.ifEmpty { manga.url },
|
||||||
|
publicUrl = details.publicUrl.ifEmpty { manga.publicUrl },
|
||||||
|
rating = maxOf(details.rating, manga.rating),
|
||||||
|
isNsfw = details.isNsfw,
|
||||||
|
coverUrl = details.coverUrl.ifEmpty { manga.coverUrl },
|
||||||
|
tags = details.tags + manga.tags,
|
||||||
|
state = details.state ?: manga.state,
|
||||||
|
author = details.author.ifNullOrEmpty { manga.author },
|
||||||
|
largeCoverUrl = details.largeCoverUrl.ifNullOrEmpty { manga.largeCoverUrl },
|
||||||
|
description = details.description.ifNullOrEmpty { manga.description },
|
||||||
|
chapters = chapters,
|
||||||
|
source = source,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Blocking
|
||||||
|
@WorkerThread
|
||||||
|
fun getPages(chapter: MangaChapter): List<MangaPage> {
|
||||||
|
val uri = "content://${source.authority}/chapters".toUri()
|
||||||
|
.buildUpon()
|
||||||
|
.appendPath(chapter.url)
|
||||||
|
.build()
|
||||||
|
return contentResolver.query(uri, null, null, null, null)
|
||||||
|
.safe()
|
||||||
|
.use { cursor ->
|
||||||
|
val result = ArrayList<MangaPage>(cursor.count)
|
||||||
|
if (cursor.moveToFirst()) {
|
||||||
|
do {
|
||||||
|
result += MangaPage(
|
||||||
|
id = cursor.getLong(COLUMN_ID),
|
||||||
|
url = cursor.getString(COLUMN_URL),
|
||||||
|
preview = cursor.getStringOrNull(COLUMN_PREVIEW),
|
||||||
|
source = source,
|
||||||
|
)
|
||||||
|
} while (cursor.moveToNext())
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Blocking
|
||||||
|
@WorkerThread
|
||||||
|
fun getTags(): Set<MangaTag> {
|
||||||
|
val uri = "content://${source.authority}/tags".toUri()
|
||||||
|
return contentResolver.query(uri, null, null, null, null)
|
||||||
|
.safe()
|
||||||
|
.use { cursor ->
|
||||||
|
val result = ArraySet<MangaTag>(cursor.count)
|
||||||
|
if (cursor.moveToFirst()) {
|
||||||
|
do {
|
||||||
|
result += MangaTag(
|
||||||
|
key = cursor.getString(COLUMN_KEY),
|
||||||
|
title = cursor.getString(COLUMN_TITLE),
|
||||||
|
source = source,
|
||||||
|
)
|
||||||
|
} while (cursor.moveToNext())
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getCapabilities(): MangaSourceCapabilities? {
|
||||||
|
val uri = "content://${source.authority}/capabilities".toUri()
|
||||||
|
return contentResolver.query(uri, null, null, null, null)
|
||||||
|
.safe()
|
||||||
|
.use { cursor ->
|
||||||
|
if (cursor.moveToFirst()) {
|
||||||
|
MangaSourceCapabilities(
|
||||||
|
availableSortOrders = cursor.getStringOrNull(COLUMN_SORT_ORDERS)
|
||||||
|
?.split(',')
|
||||||
|
?.mapNotNullTo(EnumSet.noneOf(SortOrder::class.java)) {
|
||||||
|
SortOrder.entries.find(it)
|
||||||
|
}.orEmpty(),
|
||||||
|
availableStates = cursor.getStringOrNull(COLUMN_STATES)
|
||||||
|
?.split(',')
|
||||||
|
?.mapNotNullTo(EnumSet.noneOf(MangaState::class.java)) {
|
||||||
|
MangaState.entries.find(it)
|
||||||
|
}.orEmpty(),
|
||||||
|
availableContentRating = cursor.getStringOrNull(COLUMN_CONTENT_RATING)
|
||||||
|
?.split(',')
|
||||||
|
?.mapNotNullTo(EnumSet.noneOf(ContentRating::class.java)) {
|
||||||
|
ContentRating.entries.find(it)
|
||||||
|
}.orEmpty(),
|
||||||
|
isMultipleTagsSupported = cursor.getBooleanOrDefault(COLUMN_MULTIPLE_TAGS_SUPPORTED, true),
|
||||||
|
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 {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun queryDetails(url: String): Manga {
|
||||||
|
val uri = "content://${source.authority}/manga".toUri()
|
||||||
|
.buildUpon()
|
||||||
|
.appendPath(url)
|
||||||
|
.build()
|
||||||
|
return contentResolver.query(uri, null, null, null, null)
|
||||||
|
.safe()
|
||||||
|
.use { cursor ->
|
||||||
|
cursor.moveToFirst()
|
||||||
|
cursor.getManga()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun queryChapters(url: String): List<MangaChapter> {
|
||||||
|
val uri = "content://${source.authority}/manga/chapters".toUri()
|
||||||
|
.buildUpon()
|
||||||
|
.appendPath(url)
|
||||||
|
.build()
|
||||||
|
return contentResolver.query(uri, null, null, null, null)
|
||||||
|
.safe()
|
||||||
|
.use { cursor ->
|
||||||
|
val result = ArrayList<MangaChapter>(cursor.count)
|
||||||
|
if (cursor.moveToFirst()) {
|
||||||
|
do {
|
||||||
|
result += MangaChapter(
|
||||||
|
id = cursor.getLong(COLUMN_ID),
|
||||||
|
name = cursor.getString(COLUMN_NAME),
|
||||||
|
number = cursor.getFloatOrDefault(COLUMN_NUMBER, 0f),
|
||||||
|
volume = cursor.getIntOrDefault(COLUMN_VOLUME, 0),
|
||||||
|
url = cursor.getString(COLUMN_URL),
|
||||||
|
scanlator = cursor.getStringOrNull(COLUMN_SCANLATOR),
|
||||||
|
uploadDate = cursor.getLongOrDefault(COLUMN_UPLOAD_DATE, 0L),
|
||||||
|
branch = cursor.getStringOrNull(COLUMN_BRANCH),
|
||||||
|
source = source,
|
||||||
|
)
|
||||||
|
} while (cursor.moveToNext())
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ExternalPluginCursor.getManga() = Manga(
|
||||||
|
id = getLong(COLUMN_ID),
|
||||||
|
title = getString(COLUMN_TITLE),
|
||||||
|
altTitle = getStringOrNull(COLUMN_ALT_TITLE),
|
||||||
|
url = getString(COLUMN_URL),
|
||||||
|
publicUrl = getString(COLUMN_PUBLIC_URL),
|
||||||
|
rating = getFloat(COLUMN_RATING),
|
||||||
|
isNsfw = getBooleanOrDefault(COLUMN_IS_NSFW, false),
|
||||||
|
coverUrl = getString(COLUMN_COVER_URL),
|
||||||
|
tags = getStringOrNull(COLUMN_TAGS)?.split(':')?.mapNotNullToSet {
|
||||||
|
val parts = it.splitTwoParts('=') ?: return@mapNotNullToSet null
|
||||||
|
MangaTag(key = parts.first, title = parts.second, source = source)
|
||||||
|
}.orEmpty(),
|
||||||
|
state = getStringOrNull(COLUMN_STATE)?.let { MangaState.entries.find(it) },
|
||||||
|
author = getStringOrNull(COLUMN_AUTHOR),
|
||||||
|
largeCoverUrl = getStringOrNull(COLUMN_LARGE_COVER_URL),
|
||||||
|
description = getStringOrNull(COLUMN_DESCRIPTION),
|
||||||
|
chapters = emptyList(),
|
||||||
|
source = source,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun Cursor?.safe() = ExternalPluginCursor(
|
||||||
|
source = source,
|
||||||
|
cursor = this ?: throw IncompatiblePluginException(source.name, null),
|
||||||
|
)
|
||||||
|
|
||||||
|
class MangaSourceCapabilities(
|
||||||
|
val availableSortOrders: Set<SortOrder>,
|
||||||
|
val availableStates: Set<MangaState>,
|
||||||
|
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 {
|
||||||
|
|
||||||
|
const val COLUMN_SORT_ORDERS = "sort_orders"
|
||||||
|
const val COLUMN_STATES = "states"
|
||||||
|
const val COLUMN_CONTENT_RATING = "content_rating"
|
||||||
|
const val COLUMN_MULTIPLE_TAGS_SUPPORTED = "multiple_tags_supported"
|
||||||
|
const val COLUMN_TAGS_EXCLUSION_SUPPORTED = "tags_exclusion_supported"
|
||||||
|
const val COLUMN_SEARCH_SUPPORTED = "search_supported"
|
||||||
|
const val COLUMN_CONTENT_TYPE = "content_type"
|
||||||
|
const val COLUMN_DEFAULT_SORT_ORDER = "default_sort_order"
|
||||||
|
const val COLUMN_LOCALE = "locale"
|
||||||
|
const val COLUMN_ID = "id"
|
||||||
|
const val COLUMN_NAME = "name"
|
||||||
|
const val COLUMN_NUMBER = "number"
|
||||||
|
const val COLUMN_VOLUME = "volume"
|
||||||
|
const val COLUMN_URL = "url"
|
||||||
|
const val COLUMN_SCANLATOR = "scanlator"
|
||||||
|
const val COLUMN_UPLOAD_DATE = "upload_date"
|
||||||
|
const val COLUMN_BRANCH = "branch"
|
||||||
|
const val COLUMN_TITLE = "title"
|
||||||
|
const val COLUMN_ALT_TITLE = "alt_title"
|
||||||
|
const val COLUMN_PUBLIC_URL = "public_url"
|
||||||
|
const val COLUMN_RATING = "rating"
|
||||||
|
const val COLUMN_IS_NSFW = "is_nsfw"
|
||||||
|
const val COLUMN_COVER_URL = "cover_url"
|
||||||
|
const val COLUMN_TAGS = "tags"
|
||||||
|
const val COLUMN_STATE = "state"
|
||||||
|
const val COLUMN_AUTHOR = "author"
|
||||||
|
const val COLUMN_LARGE_COVER_URL = "large_cover_url"
|
||||||
|
const val COLUMN_DESCRIPTION = "description"
|
||||||
|
const val COLUMN_PREVIEW = "preview"
|
||||||
|
const val COLUMN_KEY = "key"
|
||||||
|
}
|
||||||
|
}
|
||||||
70
app/src/main/kotlin/org/koitharu/kotatsu/core/parser/external/ExternalPluginCursor.kt
vendored
Normal file
70
app/src/main/kotlin/org/koitharu/kotatsu/core/parser/external/ExternalPluginCursor.kt
vendored
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
package org.koitharu.kotatsu.core.parser.external
|
||||||
|
|
||||||
|
import android.database.Cursor
|
||||||
|
import android.database.CursorWrapper
|
||||||
|
import org.koitharu.kotatsu.core.exceptions.IncompatiblePluginException
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.getBoolean
|
||||||
|
|
||||||
|
class ExternalPluginCursor(private val source: ExternalMangaSource, cursor: Cursor) : CursorWrapper(cursor) {
|
||||||
|
|
||||||
|
override fun getColumnIndexOrThrow(columnName: String?): Int = try {
|
||||||
|
super.getColumnIndexOrThrow(columnName)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IncompatiblePluginException(source.name, e)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getString(columnName: String): String = getString(getColumnIndexOrThrow(columnName))
|
||||||
|
|
||||||
|
fun getStringOrNull(columnName: String): String? {
|
||||||
|
val columnIndex = getColumnIndex(columnName)
|
||||||
|
return when {
|
||||||
|
columnIndex < 0 -> null
|
||||||
|
isNull(columnIndex) -> null
|
||||||
|
else -> getString(columnIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getBoolean(columnName: String): Boolean = getBoolean(getColumnIndexOrThrow(columnName))
|
||||||
|
|
||||||
|
fun getBooleanOrDefault(columnName: String, defaultValue: Boolean): Boolean {
|
||||||
|
val columnIndex = getColumnIndex(columnName)
|
||||||
|
return when {
|
||||||
|
columnIndex < 0 -> defaultValue
|
||||||
|
isNull(columnIndex) -> defaultValue
|
||||||
|
else -> getBoolean(columnIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getInt(columnName: String): Int = getInt(getColumnIndexOrThrow(columnName))
|
||||||
|
|
||||||
|
fun getIntOrDefault(columnName: String, defaultValue: Int): Int {
|
||||||
|
val columnIndex = getColumnIndex(columnName)
|
||||||
|
return when {
|
||||||
|
columnIndex < 0 -> defaultValue
|
||||||
|
isNull(columnIndex) -> defaultValue
|
||||||
|
else -> getInt(columnIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getLong(columnName: String): Long = getLong(getColumnIndexOrThrow(columnName))
|
||||||
|
|
||||||
|
fun getLongOrDefault(columnName: String, defaultValue: Long): Long {
|
||||||
|
val columnIndex = getColumnIndex(columnName)
|
||||||
|
return when {
|
||||||
|
columnIndex < 0 -> defaultValue
|
||||||
|
isNull(columnIndex) -> defaultValue
|
||||||
|
else -> getLong(columnIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getFloat(columnName: String): Float = getFloat(getColumnIndexOrThrow(columnName))
|
||||||
|
|
||||||
|
fun getFloatOrDefault(columnName: String, defaultValue: Float): Float {
|
||||||
|
val columnIndex = getColumnIndex(columnName)
|
||||||
|
return when {
|
||||||
|
columnIndex < 0 -> defaultValue
|
||||||
|
isNull(columnIndex) -> defaultValue
|
||||||
|
else -> getFloat(columnIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,19 @@
|
|||||||
package org.koitharu.kotatsu.core.parser.favicon
|
package org.koitharu.kotatsu.core.parser.favicon
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.graphics.drawable.AdaptiveIconDrawable
|
||||||
|
import android.graphics.drawable.ColorDrawable
|
||||||
|
import android.graphics.drawable.Drawable
|
||||||
|
import android.graphics.drawable.LayerDrawable
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
import android.webkit.MimeTypeMap
|
import android.webkit.MimeTypeMap
|
||||||
import coil.ImageLoader
|
import coil.ImageLoader
|
||||||
import coil.decode.DataSource
|
import coil.decode.DataSource
|
||||||
import coil.decode.ImageSource
|
import coil.decode.ImageSource
|
||||||
import coil.disk.DiskCache
|
import coil.disk.DiskCache
|
||||||
|
import coil.fetch.DrawableResult
|
||||||
import coil.fetch.FetchResult
|
import coil.fetch.FetchResult
|
||||||
import coil.fetch.Fetcher
|
import coil.fetch.Fetcher
|
||||||
import coil.fetch.SourceResult
|
import coil.fetch.SourceResult
|
||||||
@@ -14,7 +21,9 @@ import coil.network.HttpException
|
|||||||
import coil.request.Options
|
import coil.request.Options
|
||||||
import coil.size.Size
|
import coil.size.Size
|
||||||
import coil.size.pxOrElse
|
import coil.size.pxOrElse
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.ensureActive
|
import kotlinx.coroutines.ensureActive
|
||||||
|
import kotlinx.coroutines.runInterruptible
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
@@ -24,8 +33,10 @@ import okio.Closeable
|
|||||||
import okio.buffer
|
import okio.buffer
|
||||||
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
|
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
|
||||||
import org.koitharu.kotatsu.core.model.MangaSource
|
import org.koitharu.kotatsu.core.model.MangaSource
|
||||||
|
import org.koitharu.kotatsu.core.parser.EmptyMangaRepository
|
||||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||||
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
|
import org.koitharu.kotatsu.core.parser.ParserMangaRepository
|
||||||
|
import org.koitharu.kotatsu.core.parser.external.ExternalMangaRepository
|
||||||
import org.koitharu.kotatsu.core.util.ext.requireBody
|
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
|
||||||
@@ -53,7 +64,20 @@ class FaviconFetcher(
|
|||||||
|
|
||||||
override suspend fun fetch(): FetchResult {
|
override suspend fun fetch(): FetchResult {
|
||||||
getCached(options)?.let { return it }
|
getCached(options)?.let { return it }
|
||||||
val repo = mangaRepositoryFactory.create(mangaSource) as RemoteMangaRepository
|
return when (val repo = mangaRepositoryFactory.create(mangaSource)) {
|
||||||
|
is ParserMangaRepository -> fetchParserFavicon(repo)
|
||||||
|
is ExternalMangaRepository -> fetchPluginIcon(repo)
|
||||||
|
is EmptyMangaRepository -> DrawableResult(
|
||||||
|
drawable = ColorDrawable(Color.WHITE),
|
||||||
|
isSampled = false,
|
||||||
|
dataSource = DataSource.MEMORY,
|
||||||
|
)
|
||||||
|
|
||||||
|
else -> throw IllegalArgumentException("")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun fetchParserFavicon(repo: ParserMangaRepository): FetchResult {
|
||||||
val sizePx = maxOf(
|
val sizePx = maxOf(
|
||||||
options.size.width.pxOrElse { FALLBACK_SIZE },
|
options.size.width.pxOrElse { FALLBACK_SIZE },
|
||||||
options.size.height.pxOrElse { FALLBACK_SIZE },
|
options.size.height.pxOrElse { FALLBACK_SIZE },
|
||||||
@@ -100,6 +124,20 @@ class FaviconFetcher(
|
|||||||
return response
|
return response
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun fetchPluginIcon(repository: ExternalMangaRepository): FetchResult {
|
||||||
|
val source = repository.source
|
||||||
|
val pm = options.context.packageManager
|
||||||
|
val icon = runInterruptible(Dispatchers.IO) {
|
||||||
|
val provider = pm.resolveContentProvider(source.authority, 0)
|
||||||
|
provider?.loadIcon(pm) ?: pm.getApplicationIcon(source.packageName)
|
||||||
|
}
|
||||||
|
return DrawableResult(
|
||||||
|
drawable = icon.nonAdaptive(),
|
||||||
|
isSampled = false,
|
||||||
|
dataSource = DataSource.DISK,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private fun getCached(options: Options): SourceResult? {
|
private fun getCached(options: Options): SourceResult? {
|
||||||
if (!options.diskCachePolicy.readEnabled) {
|
if (!options.diskCachePolicy.readEnabled) {
|
||||||
return null
|
return null
|
||||||
@@ -165,6 +203,13 @@ class FaviconFetcher(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun Drawable.nonAdaptive() =
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && this is AdaptiveIconDrawable) {
|
||||||
|
LayerDrawable(arrayOf(background, foreground))
|
||||||
|
} else {
|
||||||
|
this
|
||||||
|
}
|
||||||
|
|
||||||
class Factory(
|
class Factory(
|
||||||
context: Context,
|
context: Context,
|
||||||
okHttpClientLazy: Lazy<OkHttpClient>,
|
okHttpClientLazy: Lazy<OkHttpClient>,
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ import org.koitharu.kotatsu.reader.domain.ReaderColorFilter
|
|||||||
import java.io.File
|
import java.io.File
|
||||||
import java.net.Proxy
|
import java.net.Proxy
|
||||||
import java.util.EnumSet
|
import java.util.EnumSet
|
||||||
import java.util.Locale
|
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
@@ -86,6 +85,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
|||||||
get() = prefs.getInt(KEY_GRID_SIZE_PAGES, 100)
|
get() = prefs.getInt(KEY_GRID_SIZE_PAGES, 100)
|
||||||
set(value) = prefs.edit { putInt(KEY_GRID_SIZE_PAGES, value) }
|
set(value) = prefs.edit { putInt(KEY_GRID_SIZE_PAGES, value) }
|
||||||
|
|
||||||
|
val isQuickFilterEnabled: Boolean
|
||||||
|
get() = prefs.getBoolean(KEY_QUICK_FILTER, true)
|
||||||
|
|
||||||
var historyListMode: ListMode
|
var historyListMode: ListMode
|
||||||
get() = prefs.getEnumValue(KEY_LIST_MODE_HISTORY, listMode)
|
get() = prefs.getEnumValue(KEY_LIST_MODE_HISTORY, listMode)
|
||||||
set(value) = prefs.edit { putEnumValue(KEY_LIST_MODE_HISTORY, value) }
|
set(value) = prefs.edit { putEnumValue(KEY_LIST_MODE_HISTORY, value) }
|
||||||
@@ -193,8 +195,8 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
|||||||
get() = prefs.getBoolean(KEY_FEED_HEADER, true)
|
get() = prefs.getBoolean(KEY_FEED_HEADER, true)
|
||||||
set(value) = prefs.edit { putBoolean(KEY_FEED_HEADER, value) }
|
set(value) = prefs.edit { putBoolean(KEY_FEED_HEADER, value) }
|
||||||
|
|
||||||
val isReadingIndicatorsEnabled: Boolean
|
val progressIndicatorMode: ProgressIndicatorMode
|
||||||
get() = prefs.getBoolean(KEY_READING_INDICATORS, true)
|
get() = prefs.getEnumValue(KEY_PROGRESS_INDICATORS, ProgressIndicatorMode.PERCENT_READ)
|
||||||
|
|
||||||
val isHistoryExcludeNsfw: Boolean
|
val isHistoryExcludeNsfw: Boolean
|
||||||
get() = prefs.getBoolean(KEY_HISTORY_EXCLUDE_NSFW, false)
|
get() = prefs.getBoolean(KEY_HISTORY_EXCLUDE_NSFW, false)
|
||||||
@@ -485,6 +487,15 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
|||||||
val isAutoLocalChaptersCleanupEnabled: Boolean
|
val isAutoLocalChaptersCleanupEnabled: Boolean
|
||||||
get() = prefs.getBoolean(KEY_CHAPTERS_CLEAR_AUTO, false)
|
get() = prefs.getBoolean(KEY_CHAPTERS_CLEAR_AUTO, false)
|
||||||
|
|
||||||
|
fun isPagesCropEnabled(mode: ReaderMode): Boolean {
|
||||||
|
val rawValue = prefs.getStringSet(KEY_READER_CROP, emptySet())
|
||||||
|
if (rawValue.isNullOrEmpty()) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
val needle = if (mode == ReaderMode.WEBTOON) READER_CROP_WEBTOON else READER_CROP_PAGED
|
||||||
|
return needle.toString() in rawValue
|
||||||
|
}
|
||||||
|
|
||||||
fun isTipEnabled(tip: String): Boolean {
|
fun isTipEnabled(tip: String): Boolean {
|
||||||
return prefs.getStringSet(KEY_TIPS_CLOSED, emptySet())?.contains(tip) != true
|
return prefs.getStringSet(KEY_TIPS_CLOSED, emptySet())?.contains(tip) != true
|
||||||
}
|
}
|
||||||
@@ -597,6 +608,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
|||||||
const val KEY_READER_ANIMATION = "reader_animation2"
|
const val KEY_READER_ANIMATION = "reader_animation2"
|
||||||
const val KEY_READER_MODE = "reader_mode"
|
const val KEY_READER_MODE = "reader_mode"
|
||||||
const val KEY_READER_MODE_DETECT = "reader_mode_detect"
|
const val KEY_READER_MODE_DETECT = "reader_mode_detect"
|
||||||
|
const val KEY_READER_CROP = "reader_crop"
|
||||||
const val KEY_APP_PASSWORD = "app_password"
|
const val KEY_APP_PASSWORD = "app_password"
|
||||||
const val KEY_APP_PASSWORD_NUMERIC = "app_password_num"
|
const val KEY_APP_PASSWORD_NUMERIC = "app_password_num"
|
||||||
const val KEY_PROTECT_APP = "protect_app"
|
const val KEY_PROTECT_APP = "protect_app"
|
||||||
@@ -610,7 +622,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
|||||||
const val KEY_BACKUP_PERIODICAL_LAST = "backup_periodic_last"
|
const val KEY_BACKUP_PERIODICAL_LAST = "backup_periodic_last"
|
||||||
const val KEY_HISTORY_GROUPING = "history_grouping"
|
const val KEY_HISTORY_GROUPING = "history_grouping"
|
||||||
const val KEY_UPDATED_GROUPING = "updated_grouping"
|
const val KEY_UPDATED_GROUPING = "updated_grouping"
|
||||||
const val KEY_READING_INDICATORS = "reading_indicators"
|
const val KEY_PROGRESS_INDICATORS = "progress_indicators"
|
||||||
const val KEY_REVERSE_CHAPTERS = "reverse_chapters"
|
const val KEY_REVERSE_CHAPTERS = "reverse_chapters"
|
||||||
const val KEY_GRID_VIEW_CHAPTERS = "grid_view_chapters"
|
const val KEY_GRID_VIEW_CHAPTERS = "grid_view_chapters"
|
||||||
const val KEY_HISTORY_EXCLUDE_NSFW = "history_exclude_nsfw"
|
const val KEY_HISTORY_EXCLUDE_NSFW = "history_exclude_nsfw"
|
||||||
@@ -687,6 +699,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
|||||||
const val KEY_FEED_HEADER = "feed_header"
|
const val KEY_FEED_HEADER = "feed_header"
|
||||||
const val KEY_SEARCH_SUGGESTION_TYPES = "search_suggest_types"
|
const val KEY_SEARCH_SUGGESTION_TYPES = "search_suggest_types"
|
||||||
const val KEY_SOURCES_VERSION = "sources_version"
|
const val KEY_SOURCES_VERSION = "sources_version"
|
||||||
|
const val KEY_QUICK_FILTER = "quick_filter"
|
||||||
|
|
||||||
// keys for non-persistent preferences
|
// keys for non-persistent preferences
|
||||||
const val KEY_APP_VERSION = "app_version"
|
const val KEY_APP_VERSION = "app_version"
|
||||||
@@ -695,8 +708,13 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
|||||||
const val KEY_LOGS_SHARE = "logs_share"
|
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_APP_TRANSLATION = "about_app_translation"
|
||||||
|
const val PROXY_TEST = "proxy_test"
|
||||||
|
|
||||||
// old keys are for migration only
|
// old keys are for migration only
|
||||||
private const val KEY_IMAGES_PROXY_OLD = "images_proxy"
|
private const val KEY_IMAGES_PROXY_OLD = "images_proxy"
|
||||||
|
|
||||||
|
// values
|
||||||
|
private const val READER_CROP_PAGED = 1
|
||||||
|
private const val READER_CROP_WEBTOON = 2
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package org.koitharu.kotatsu.core.prefs
|
||||||
|
|
||||||
|
enum class ProgressIndicatorMode {
|
||||||
|
|
||||||
|
NONE, PERCENT_READ, PERCENT_LEFT, CHAPTERS_READ, CHAPTERS_LEFT;
|
||||||
|
}
|
||||||
@@ -12,5 +12,6 @@ enum class SearchSuggestionType(
|
|||||||
QUERIES_SUGGEST(R.string.suggested_queries),
|
QUERIES_SUGGEST(R.string.suggested_queries),
|
||||||
MANGA(R.string.content_type_manga),
|
MANGA(R.string.content_type_manga),
|
||||||
SOURCES(R.string.remote_sources),
|
SOURCES(R.string.remote_sources),
|
||||||
|
RECENT_SOURCES(R.string.recent_sources),
|
||||||
AUTHORS(R.string.authors),
|
AUTHORS(R.string.authors),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ class SourceSettings(context: Context, source: MangaSource) : MangaSourceConfig
|
|||||||
|
|
||||||
is ConfigKey.ShowSuspiciousContent -> prefs.getBoolean(key.key, key.defaultValue)
|
is ConfigKey.ShowSuspiciousContent -> prefs.getBoolean(key.key, key.defaultValue)
|
||||||
is ConfigKey.SplitByTranslations -> prefs.getBoolean(key.key, key.defaultValue)
|
is ConfigKey.SplitByTranslations -> prefs.getBoolean(key.key, key.defaultValue)
|
||||||
|
is ConfigKey.PreferredImageServer -> prefs.getString(key.key, key.defaultValue)?.takeUnless(String::isEmpty)
|
||||||
} as T
|
} as T
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,6 +48,7 @@ class SourceSettings(context: Context, source: MangaSource) : MangaSourceConfig
|
|||||||
is ConfigKey.ShowSuspiciousContent -> putBoolean(key.key, value as Boolean)
|
is ConfigKey.ShowSuspiciousContent -> putBoolean(key.key, value as Boolean)
|
||||||
is ConfigKey.UserAgent -> putString(key.key, (value as String?)?.sanitizeHeaderValue())
|
is ConfigKey.UserAgent -> putString(key.key, (value as String?)?.sanitizeHeaderValue())
|
||||||
is ConfigKey.SplitByTranslations -> putBoolean(key.key, value as Boolean)
|
is ConfigKey.SplitByTranslations -> putBoolean(key.key, value as Boolean)
|
||||||
|
is ConfigKey.PreferredImageServer -> putString(key.key, value as String? ?: "")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,10 +16,6 @@ abstract class AlertDialogFragment<B : ViewBinding> : DialogFragment() {
|
|||||||
var viewBinding: B? = null
|
var viewBinding: B? = null
|
||||||
private set
|
private set
|
||||||
|
|
||||||
@Deprecated("", ReplaceWith("requireViewBinding()"))
|
|
||||||
protected val binding: B
|
|
||||||
get() = requireViewBinding()
|
|
||||||
|
|
||||||
final override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
final override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||||
val binding = onCreateViewBinding(layoutInflater, null)
|
val binding = onCreateViewBinding(layoutInflater, null)
|
||||||
viewBinding = binding
|
viewBinding = binding
|
||||||
@@ -51,9 +47,6 @@ abstract class AlertDialogFragment<B : ViewBinding> : DialogFragment() {
|
|||||||
|
|
||||||
open fun onDialogCreated(dialog: AlertDialog) = Unit
|
open fun onDialogCreated(dialog: AlertDialog) = Unit
|
||||||
|
|
||||||
@Deprecated("", ReplaceWith("viewBinding"))
|
|
||||||
protected fun bindingOrNull() = viewBinding
|
|
||||||
|
|
||||||
fun requireViewBinding(): B = checkNotNull(viewBinding) {
|
fun requireViewBinding(): B = checkNotNull(viewBinding) {
|
||||||
"Fragment $this did not return a ViewBinding from onCreateView() or this was called before onCreateView()."
|
"Fragment $this did not return a ViewBinding from onCreateView() or this was called before onCreateView()."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,25 +14,22 @@ import androidx.appcompat.view.ActionMode
|
|||||||
import androidx.appcompat.widget.Toolbar
|
import androidx.appcompat.widget.Toolbar
|
||||||
import androidx.core.app.ActivityCompat
|
import androidx.core.app.ActivityCompat
|
||||||
import androidx.core.view.WindowCompat
|
import androidx.core.view.WindowCompat
|
||||||
|
import androidx.fragment.app.FragmentManager
|
||||||
import androidx.viewbinding.ViewBinding
|
import androidx.viewbinding.ViewBinding
|
||||||
import dagger.hilt.EntryPoint
|
|
||||||
import dagger.hilt.InstallIn
|
|
||||||
import dagger.hilt.android.EntryPointAccessors
|
import dagger.hilt.android.EntryPointAccessors
|
||||||
import dagger.hilt.components.SingletonComponent
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.flowOf
|
import kotlinx.coroutines.flow.flowOf
|
||||||
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.exceptions.resolve.ExceptionResolver
|
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
|
||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
|
||||||
import org.koitharu.kotatsu.core.ui.util.ActionModeDelegate
|
import org.koitharu.kotatsu.core.ui.util.ActionModeDelegate
|
||||||
import org.koitharu.kotatsu.core.ui.util.WindowInsetsDelegate
|
import org.koitharu.kotatsu.core.ui.util.WindowInsetsDelegate
|
||||||
import org.koitharu.kotatsu.core.util.ext.isWebViewUnavailable
|
import org.koitharu.kotatsu.core.util.ext.isWebViewUnavailable
|
||||||
import org.koitharu.kotatsu.main.ui.protect.ScreenshotPolicyHelper
|
import org.koitharu.kotatsu.main.ui.protect.ScreenshotPolicyHelper
|
||||||
|
|
||||||
@Suppress("LeakingThis")
|
|
||||||
abstract class BaseActivity<B : ViewBinding> :
|
abstract class BaseActivity<B : ViewBinding> :
|
||||||
AppCompatActivity(),
|
AppCompatActivity(),
|
||||||
|
ExceptionResolver.Host,
|
||||||
ScreenshotPolicyHelper.ContentContainer,
|
ScreenshotPolicyHelper.ContentContainer,
|
||||||
WindowInsetsDelegate.WindowInsetsListener {
|
WindowInsetsDelegate.WindowInsetsListener {
|
||||||
|
|
||||||
@@ -41,8 +38,8 @@ abstract class BaseActivity<B : ViewBinding> :
|
|||||||
lateinit var viewBinding: B
|
lateinit var viewBinding: B
|
||||||
private set
|
private set
|
||||||
|
|
||||||
@JvmField
|
protected lateinit var exceptionResolver: ExceptionResolver
|
||||||
protected val exceptionResolver = ExceptionResolver(this)
|
private set
|
||||||
|
|
||||||
@JvmField
|
@JvmField
|
||||||
protected val insetsDelegate = WindowInsetsDelegate()
|
protected val insetsDelegate = WindowInsetsDelegate()
|
||||||
@@ -53,13 +50,15 @@ abstract class BaseActivity<B : ViewBinding> :
|
|||||||
private var defaultStatusBarColor = Color.TRANSPARENT
|
private var defaultStatusBarColor = Color.TRANSPARENT
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
val settings = EntryPointAccessors.fromApplication(this, BaseActivityEntryPoint::class.java).settings
|
val entryPoint = EntryPointAccessors.fromApplication<BaseActivityEntryPoint>(this)
|
||||||
|
val settings = entryPoint.settings
|
||||||
isAmoledTheme = settings.isAmoledTheme
|
isAmoledTheme = settings.isAmoledTheme
|
||||||
setTheme(settings.colorScheme.styleResId)
|
setTheme(settings.colorScheme.styleResId)
|
||||||
if (isAmoledTheme) {
|
if (isAmoledTheme) {
|
||||||
setTheme(R.style.ThemeOverlay_Kotatsu_Amoled)
|
setTheme(R.style.ThemeOverlay_Kotatsu_Amoled)
|
||||||
}
|
}
|
||||||
putDataToExtras(intent)
|
putDataToExtras(intent)
|
||||||
|
exceptionResolver = entryPoint.exceptionResolverFactory.create(this)
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||||
insetsDelegate.handleImeInsets = true
|
insetsDelegate.handleImeInsets = true
|
||||||
@@ -88,6 +87,10 @@ abstract class BaseActivity<B : ViewBinding> :
|
|||||||
setupToolbar()
|
setupToolbar()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun getContext() = this
|
||||||
|
|
||||||
|
override fun getChildFragmentManager(): FragmentManager = supportFragmentManager
|
||||||
|
|
||||||
protected fun setContentView(binding: B) {
|
protected fun setContentView(binding: B) {
|
||||||
this.viewBinding = binding
|
this.viewBinding = binding
|
||||||
super.setContentView(binding.root)
|
super.setContentView(binding.root)
|
||||||
@@ -178,12 +181,6 @@ abstract class BaseActivity<B : ViewBinding> :
|
|||||||
|
|
||||||
protected fun hasViewBinding() = ::viewBinding.isInitialized
|
protected fun hasViewBinding() = ::viewBinding.isInitialized
|
||||||
|
|
||||||
@EntryPoint
|
|
||||||
@InstallIn(SingletonComponent::class)
|
|
||||||
interface BaseActivityEntryPoint {
|
|
||||||
val settings: AppSettings
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
||||||
const val EXTRA_DATA = "data"
|
const val EXTRA_DATA = "data"
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package org.koitharu.kotatsu.core.ui
|
||||||
|
|
||||||
|
import dagger.hilt.EntryPoint
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
|
||||||
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
|
|
||||||
|
@EntryPoint
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
interface BaseActivityEntryPoint {
|
||||||
|
|
||||||
|
val settings: AppSettings
|
||||||
|
|
||||||
|
val exceptionResolverFactory: ExceptionResolver.Factory
|
||||||
|
}
|
||||||
@@ -1,29 +1,27 @@
|
|||||||
package org.koitharu.kotatsu.core.ui
|
package org.koitharu.kotatsu.core.ui
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.viewbinding.ViewBinding
|
import androidx.viewbinding.ViewBinding
|
||||||
|
import dagger.hilt.android.EntryPointAccessors
|
||||||
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
|
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
|
||||||
import org.koitharu.kotatsu.core.ui.util.ActionModeDelegate
|
import org.koitharu.kotatsu.core.ui.util.ActionModeDelegate
|
||||||
import org.koitharu.kotatsu.core.ui.util.WindowInsetsDelegate
|
import org.koitharu.kotatsu.core.ui.util.WindowInsetsDelegate
|
||||||
|
|
||||||
@Suppress("LeakingThis")
|
|
||||||
abstract class BaseFragment<B : ViewBinding> :
|
abstract class BaseFragment<B : ViewBinding> :
|
||||||
Fragment(),
|
Fragment(),
|
||||||
|
ExceptionResolver.Host,
|
||||||
WindowInsetsDelegate.WindowInsetsListener {
|
WindowInsetsDelegate.WindowInsetsListener {
|
||||||
|
|
||||||
var viewBinding: B? = null
|
var viewBinding: B? = null
|
||||||
private set
|
private set
|
||||||
|
|
||||||
@Deprecated("", ReplaceWith("requireViewBinding()"))
|
protected lateinit var exceptionResolver: ExceptionResolver
|
||||||
protected val binding: B
|
private set
|
||||||
get() = requireViewBinding()
|
|
||||||
|
|
||||||
@JvmField
|
|
||||||
protected val exceptionResolver = ExceptionResolver(this)
|
|
||||||
|
|
||||||
@JvmField
|
@JvmField
|
||||||
protected val insetsDelegate = WindowInsetsDelegate()
|
protected val insetsDelegate = WindowInsetsDelegate()
|
||||||
@@ -31,6 +29,12 @@ abstract class BaseFragment<B : ViewBinding> :
|
|||||||
protected val actionModeDelegate: ActionModeDelegate
|
protected val actionModeDelegate: ActionModeDelegate
|
||||||
get() = (requireActivity() as BaseActivity<*>).actionModeDelegate
|
get() = (requireActivity() as BaseActivity<*>).actionModeDelegate
|
||||||
|
|
||||||
|
override fun onAttach(context: Context) {
|
||||||
|
super.onAttach(context)
|
||||||
|
val entryPoint = EntryPointAccessors.fromApplication<BaseActivityEntryPoint>(context)
|
||||||
|
exceptionResolver = entryPoint.exceptionResolverFactory.create(this)
|
||||||
|
}
|
||||||
|
|
||||||
final override fun onCreateView(
|
final override fun onCreateView(
|
||||||
inflater: LayoutInflater,
|
inflater: LayoutInflater,
|
||||||
container: ViewGroup?,
|
container: ViewGroup?,
|
||||||
@@ -59,9 +63,6 @@ abstract class BaseFragment<B : ViewBinding> :
|
|||||||
"Fragment $this did not return a ViewBinding from onCreateView() or this was called before onCreateView()."
|
"Fragment $this did not return a ViewBinding from onCreateView() or this was called before onCreateView()."
|
||||||
}
|
}
|
||||||
|
|
||||||
@Deprecated("", ReplaceWith("viewBinding"))
|
|
||||||
protected fun bindingOrNull() = viewBinding
|
|
||||||
|
|
||||||
protected abstract fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): B
|
protected abstract fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): B
|
||||||
|
|
||||||
protected open fun onViewBindingCreated(binding: B, savedInstanceState: Bundle?) = Unit
|
protected open fun onViewBindingCreated(binding: B, savedInstanceState: Bundle?) = Unit
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package org.koitharu.kotatsu.core.ui
|
package org.koitharu.kotatsu.core.ui
|
||||||
|
|
||||||
import android.content.ActivityNotFoundException
|
import android.content.ActivityNotFoundException
|
||||||
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.View
|
import android.view.View
|
||||||
@@ -12,7 +13,9 @@ import androidx.preference.PreferenceFragmentCompat
|
|||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import com.google.android.material.snackbar.Snackbar
|
import com.google.android.material.snackbar.Snackbar
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import dagger.hilt.android.EntryPointAccessors
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
|
||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
import org.koitharu.kotatsu.core.ui.util.RecyclerViewOwner
|
import org.koitharu.kotatsu.core.ui.util.RecyclerViewOwner
|
||||||
import org.koitharu.kotatsu.core.ui.util.WindowInsetsDelegate
|
import org.koitharu.kotatsu.core.ui.util.WindowInsetsDelegate
|
||||||
@@ -25,7 +28,11 @@ import javax.inject.Inject
|
|||||||
abstract class BasePreferenceFragment(@StringRes private val titleId: Int) :
|
abstract class BasePreferenceFragment(@StringRes private val titleId: Int) :
|
||||||
PreferenceFragmentCompat(),
|
PreferenceFragmentCompat(),
|
||||||
WindowInsetsDelegate.WindowInsetsListener,
|
WindowInsetsDelegate.WindowInsetsListener,
|
||||||
RecyclerViewOwner {
|
RecyclerViewOwner,
|
||||||
|
ExceptionResolver.Host {
|
||||||
|
|
||||||
|
protected lateinit var exceptionResolver: ExceptionResolver
|
||||||
|
private set
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
lateinit var settings: AppSettings
|
lateinit var settings: AppSettings
|
||||||
@@ -36,6 +43,12 @@ abstract class BasePreferenceFragment(@StringRes private val titleId: Int) :
|
|||||||
override val recyclerView: RecyclerView
|
override val recyclerView: RecyclerView
|
||||||
get() = listView
|
get() = listView
|
||||||
|
|
||||||
|
override fun onAttach(context: Context) {
|
||||||
|
super.onAttach(context)
|
||||||
|
val entryPoint = EntryPointAccessors.fromApplication<BaseActivityEntryPoint>(context)
|
||||||
|
exceptionResolver = entryPoint.exceptionResolverFactory.create(this)
|
||||||
|
}
|
||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
super.onViewCreated(view, savedInstanceState)
|
super.onViewCreated(view, savedInstanceState)
|
||||||
val themedContext = (view.parentView ?: view).context
|
val themedContext = (view.parentView ?: view).context
|
||||||
|
|||||||
@@ -52,8 +52,8 @@ open class ReorderableListAdapter<T : ListModel> : ListDelegationAdapter<List<T>
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected class DiffCallback<T : ListModel>(
|
protected class DiffCallback<T : ListModel>(
|
||||||
val oldList: List<T>,
|
private val oldList: List<T>,
|
||||||
val newList: List<T>,
|
private val newList: List<T>,
|
||||||
) : DiffUtil.Callback() {
|
) : DiffUtil.Callback() {
|
||||||
|
|
||||||
override fun getOldListSize(): Int = oldList.size
|
override fun getOldListSize(): Int = oldList.size
|
||||||
@@ -71,5 +71,11 @@ open class ReorderableListAdapter<T : ListModel> : ListDelegationAdapter<List<T>
|
|||||||
val newItem = newList[newItemPosition]
|
val newItem = newList[newItemPosition]
|
||||||
return newItem == oldItem
|
return newItem == oldItem
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun getChangePayload(oldItemPosition: Int, newItemPosition: Int): Any? {
|
||||||
|
val oldItem = oldList[oldItemPosition]
|
||||||
|
val newItem = newList[newItemPosition]
|
||||||
|
return newItem.getChangePayload(oldItem)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package org.koitharu.kotatsu.core.ui.dialog
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.widget.CompoundButton.OnCheckedChangeListener
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import androidx.appcompat.app.AlertDialog
|
||||||
|
import androidx.core.view.updatePadding
|
||||||
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
|
import com.hannesdorfmann.adapterdelegates4.AdapterDelegate
|
||||||
|
import com.hannesdorfmann.adapterdelegates4.AdapterDelegatesManager
|
||||||
|
import com.hannesdorfmann.adapterdelegates4.ListDelegationAdapter
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.databinding.DialogCheckboxBinding
|
||||||
|
import com.google.android.material.R as materialR
|
||||||
|
|
||||||
|
inline fun buildAlertDialog(
|
||||||
|
context: Context,
|
||||||
|
isCentered: Boolean = false,
|
||||||
|
block: MaterialAlertDialogBuilder.() -> Unit,
|
||||||
|
): AlertDialog = MaterialAlertDialogBuilder(
|
||||||
|
context,
|
||||||
|
if (isCentered) materialR.style.ThemeOverlay_Material3_MaterialAlertDialog_Centered else 0,
|
||||||
|
).apply(block).create()
|
||||||
|
|
||||||
|
fun <B : AlertDialog.Builder> B.setCheckbox(
|
||||||
|
@StringRes textResId: Int,
|
||||||
|
isChecked: Boolean,
|
||||||
|
onCheckedChangeListener: OnCheckedChangeListener
|
||||||
|
) = apply {
|
||||||
|
val binding = DialogCheckboxBinding.inflate(LayoutInflater.from(context))
|
||||||
|
binding.checkbox.setText(textResId)
|
||||||
|
binding.checkbox.isChecked = isChecked
|
||||||
|
binding.checkbox.setOnCheckedChangeListener(onCheckedChangeListener)
|
||||||
|
setView(binding.root)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun <B : AlertDialog.Builder, T> B.setRecyclerViewList(
|
||||||
|
list: List<T>,
|
||||||
|
delegate: AdapterDelegate<List<T>>,
|
||||||
|
) = apply {
|
||||||
|
val delegatesManager = AdapterDelegatesManager<List<T>>()
|
||||||
|
delegatesManager.addDelegate(delegate)
|
||||||
|
setRecyclerViewList(ListDelegationAdapter(delegatesManager).also { it.items = list })
|
||||||
|
}
|
||||||
|
|
||||||
|
fun <B : AlertDialog.Builder, T> B.setRecyclerViewList(
|
||||||
|
list: List<T>,
|
||||||
|
vararg delegates: AdapterDelegate<List<T>>,
|
||||||
|
) = apply {
|
||||||
|
val delegatesManager = AdapterDelegatesManager<List<T>>()
|
||||||
|
delegates.forEach { delegatesManager.addDelegate(it) }
|
||||||
|
setRecyclerViewList(ListDelegationAdapter(delegatesManager).also { it.items = list })
|
||||||
|
}
|
||||||
|
|
||||||
|
fun <B : AlertDialog.Builder> B.setRecyclerViewList(adapter: RecyclerView.Adapter<*>) = apply {
|
||||||
|
val recyclerView = RecyclerView(context)
|
||||||
|
recyclerView.layoutManager = LinearLayoutManager(context)
|
||||||
|
recyclerView.updatePadding(
|
||||||
|
top = context.resources.getDimensionPixelOffset(R.dimen.list_spacing),
|
||||||
|
)
|
||||||
|
recyclerView.clipToPadding = false
|
||||||
|
recyclerView.adapter = adapter
|
||||||
|
setView(recyclerView)
|
||||||
|
}
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.ui.dialog
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.DialogInterface
|
|
||||||
import android.view.LayoutInflater
|
|
||||||
import androidx.annotation.DrawableRes
|
|
||||||
import androidx.annotation.StringRes
|
|
||||||
import androidx.appcompat.app.AlertDialog
|
|
||||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
|
||||||
import org.koitharu.kotatsu.databinding.DialogCheckboxBinding
|
|
||||||
|
|
||||||
class CheckBoxAlertDialog private constructor(private val delegate: AlertDialog) :
|
|
||||||
DialogInterface by delegate {
|
|
||||||
|
|
||||||
fun show() = delegate.show()
|
|
||||||
|
|
||||||
class Builder(context: Context) {
|
|
||||||
|
|
||||||
private val binding = DialogCheckboxBinding.inflate(LayoutInflater.from(context))
|
|
||||||
|
|
||||||
private val delegate = MaterialAlertDialogBuilder(context)
|
|
||||||
.setView(binding.root)
|
|
||||||
|
|
||||||
fun setTitle(@StringRes titleResId: Int): Builder {
|
|
||||||
delegate.setTitle(titleResId)
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setTitle(title: CharSequence): Builder {
|
|
||||||
delegate.setTitle(title)
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setMessage(@StringRes messageId: Int): Builder {
|
|
||||||
delegate.setMessage(messageId)
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setMessage(message: CharSequence): Builder {
|
|
||||||
delegate.setMessage(message)
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setCheckBoxText(@StringRes textId: Int): Builder {
|
|
||||||
binding.checkbox.setText(textId)
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setCheckBoxChecked(isChecked: Boolean): Builder {
|
|
||||||
binding.checkbox.isChecked = isChecked
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setIcon(@DrawableRes iconId: Int): Builder {
|
|
||||||
delegate.setIcon(iconId)
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setPositiveButton(
|
|
||||||
@StringRes textId: Int,
|
|
||||||
listener: (DialogInterface, Boolean) -> Unit
|
|
||||||
): Builder {
|
|
||||||
delegate.setPositiveButton(textId) { dialog, _ ->
|
|
||||||
listener(dialog, binding.checkbox.isChecked)
|
|
||||||
}
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setNegativeButton(
|
|
||||||
@StringRes textId: Int,
|
|
||||||
listener: DialogInterface.OnClickListener? = null
|
|
||||||
): Builder {
|
|
||||||
delegate.setNegativeButton(textId, listener)
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
fun create() = CheckBoxAlertDialog(delegate.create())
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.ui.dialog
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.DialogInterface
|
|
||||||
import androidx.annotation.DrawableRes
|
|
||||||
import androidx.annotation.StringRes
|
|
||||||
import androidx.appcompat.app.AlertDialog
|
|
||||||
import androidx.core.view.updatePadding
|
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
|
||||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
|
||||||
import com.hannesdorfmann.adapterdelegates4.AdapterDelegate
|
|
||||||
import com.hannesdorfmann.adapterdelegates4.AdapterDelegatesManager
|
|
||||||
import com.hannesdorfmann.adapterdelegates4.ListDelegationAdapter
|
|
||||||
import org.koitharu.kotatsu.R
|
|
||||||
|
|
||||||
class RecyclerViewAlertDialog private constructor(
|
|
||||||
private val delegate: AlertDialog
|
|
||||||
) : DialogInterface by delegate {
|
|
||||||
|
|
||||||
fun show() = delegate.show()
|
|
||||||
|
|
||||||
class Builder<T>(context: Context) {
|
|
||||||
|
|
||||||
private val recyclerView = RecyclerView(context)
|
|
||||||
private val delegatesManager = AdapterDelegatesManager<List<T>>()
|
|
||||||
private var items: List<T>? = null
|
|
||||||
|
|
||||||
private val delegate = MaterialAlertDialogBuilder(context)
|
|
||||||
.setView(recyclerView)
|
|
||||||
|
|
||||||
init {
|
|
||||||
recyclerView.layoutManager = LinearLayoutManager(context)
|
|
||||||
recyclerView.updatePadding(
|
|
||||||
top = context.resources.getDimensionPixelOffset(R.dimen.list_spacing),
|
|
||||||
)
|
|
||||||
recyclerView.clipToPadding = false
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setTitle(@StringRes titleResId: Int): Builder<T> {
|
|
||||||
delegate.setTitle(titleResId)
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setTitle(title: CharSequence): Builder<T> {
|
|
||||||
delegate.setTitle(title)
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setIcon(@DrawableRes iconId: Int): Builder<T> {
|
|
||||||
delegate.setIcon(iconId)
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setPositiveButton(
|
|
||||||
@StringRes textId: Int,
|
|
||||||
listener: DialogInterface.OnClickListener,
|
|
||||||
): Builder<T> {
|
|
||||||
delegate.setPositiveButton(textId, listener)
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setNegativeButton(
|
|
||||||
@StringRes textId: Int,
|
|
||||||
listener: DialogInterface.OnClickListener? = null
|
|
||||||
): Builder<T> {
|
|
||||||
delegate.setNegativeButton(textId, listener)
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setNeutralButton(
|
|
||||||
@StringRes textId: Int,
|
|
||||||
listener: DialogInterface.OnClickListener,
|
|
||||||
): Builder<T> {
|
|
||||||
delegate.setNeutralButton(textId, listener)
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setCancelable(isCancelable: Boolean): Builder<T> {
|
|
||||||
delegate.setCancelable(isCancelable)
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
fun addAdapterDelegate(subject: AdapterDelegate<List<T>>): Builder<T> {
|
|
||||||
delegatesManager.addDelegate(subject)
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setItems(list: List<T>): Builder<T> {
|
|
||||||
items = list
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
fun create(): RecyclerViewAlertDialog {
|
|
||||||
recyclerView.adapter = ListDelegationAdapter(delegatesManager).also {
|
|
||||||
it.items = items
|
|
||||||
}
|
|
||||||
return RecyclerViewAlertDialog(delegate.create())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package org.koitharu.kotatsu.core.ui.dialog
|
||||||
|
|
||||||
|
import android.widget.CompoundButton
|
||||||
|
import android.widget.CompoundButton.OnCheckedChangeListener
|
||||||
|
|
||||||
|
class RememberCheckListener(
|
||||||
|
initialValue: Boolean,
|
||||||
|
) : OnCheckedChangeListener {
|
||||||
|
|
||||||
|
var isChecked: Boolean = initialValue
|
||||||
|
private set
|
||||||
|
|
||||||
|
override fun onCheckedChanged(buttonView: CompoundButton?, isChecked: Boolean) {
|
||||||
|
this.isChecked = isChecked
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package org.koitharu.kotatsu.core.ui.image
|
||||||
|
|
||||||
|
import android.animation.TimeAnimator
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Canvas
|
||||||
|
import android.graphics.drawable.Animatable
|
||||||
|
import androidx.annotation.StyleRes
|
||||||
|
import androidx.interpolator.view.animation.FastOutSlowInInterpolator
|
||||||
|
import com.google.android.material.animation.ArgbEvaluatorCompat
|
||||||
|
import com.google.android.material.color.MaterialColors
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.core.util.KotatsuColors
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.getAnimationDuration
|
||||||
|
import kotlin.math.abs
|
||||||
|
|
||||||
|
class AnimatedFaviconDrawable(
|
||||||
|
context: Context,
|
||||||
|
@StyleRes styleResId: Int,
|
||||||
|
name: String,
|
||||||
|
) : FaviconDrawable(context, styleResId, name), Animatable, TimeAnimator.TimeListener {
|
||||||
|
|
||||||
|
private val interpolator = FastOutSlowInInterpolator()
|
||||||
|
private val period = context.getAnimationDuration(R.integer.config_longAnimTime) * 2
|
||||||
|
private val timeAnimator = TimeAnimator()
|
||||||
|
|
||||||
|
private val colorHigh = MaterialColors.harmonize(KotatsuColors.random(name), colorBackground)
|
||||||
|
private val colorLow = ArgbEvaluatorCompat.getInstance().evaluate(0.3f, colorHigh, colorBackground)
|
||||||
|
|
||||||
|
init {
|
||||||
|
timeAnimator.setTimeListener(this)
|
||||||
|
updateColor()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun draw(canvas: Canvas) {
|
||||||
|
if (!isRunning && period > 0) {
|
||||||
|
updateColor()
|
||||||
|
start()
|
||||||
|
}
|
||||||
|
super.draw(canvas)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setAlpha(alpha: Int) = Unit
|
||||||
|
|
||||||
|
override fun getAlpha(): Int = 255
|
||||||
|
|
||||||
|
override fun onTimeUpdate(animation: TimeAnimator?, totalTime: Long, deltaTime: Long) {
|
||||||
|
callback?.also {
|
||||||
|
updateColor()
|
||||||
|
it.invalidateDrawable(this)
|
||||||
|
} ?: stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun start() {
|
||||||
|
timeAnimator.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun stop() {
|
||||||
|
timeAnimator.end()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun isRunning(): Boolean = timeAnimator.isStarted
|
||||||
|
|
||||||
|
private fun updateColor() {
|
||||||
|
if (period <= 0f) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val ph = period / 2
|
||||||
|
val fraction = abs((System.currentTimeMillis() % period) - ph) / ph.toFloat()
|
||||||
|
colorForeground = ArgbEvaluatorCompat.getInstance()
|
||||||
|
.evaluate(interpolator.getInterpolation(fraction), colorLow, colorHigh)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,18 +17,18 @@ import com.google.android.material.color.MaterialColors
|
|||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.core.util.KotatsuColors
|
import org.koitharu.kotatsu.core.util.KotatsuColors
|
||||||
|
|
||||||
class FaviconDrawable(
|
open class FaviconDrawable(
|
||||||
context: Context,
|
context: Context,
|
||||||
@StyleRes styleResId: Int,
|
@StyleRes styleResId: Int,
|
||||||
name: String,
|
name: String,
|
||||||
) : Drawable() {
|
) : Drawable() {
|
||||||
|
|
||||||
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
|
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||||
private var colorBackground = Color.WHITE
|
protected var colorBackground = Color.WHITE
|
||||||
|
protected var colorForeground = Color.DKGRAY
|
||||||
private var colorStroke = Color.LTGRAY
|
private var colorStroke = Color.LTGRAY
|
||||||
private val letter = name.take(1).uppercase()
|
private val letter = name.take(1).uppercase()
|
||||||
private var cornerSize = 0f
|
private var cornerSize = 0f
|
||||||
private var colorForeground = Color.DKGRAY
|
|
||||||
private val textBounds = Rect()
|
private val textBounds = Rect()
|
||||||
private val tempRect = Rect()
|
private val tempRect = Rect()
|
||||||
private val boundsF = RectF()
|
private val boundsF = RectF()
|
||||||
|
|||||||
@@ -1,15 +1,10 @@
|
|||||||
package org.koitharu.kotatsu.core.ui.image
|
package org.koitharu.kotatsu.core.ui.image
|
||||||
|
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import androidx.annotation.ColorInt
|
|
||||||
import androidx.core.graphics.alpha
|
|
||||||
import androidx.core.graphics.blue
|
|
||||||
import androidx.core.graphics.get
|
import androidx.core.graphics.get
|
||||||
import androidx.core.graphics.green
|
|
||||||
import androidx.core.graphics.red
|
|
||||||
import coil.size.Size
|
import coil.size.Size
|
||||||
import coil.transform.Transformation
|
import coil.transform.Transformation
|
||||||
import kotlin.math.abs
|
import org.koitharu.kotatsu.reader.domain.EdgeDetector.Companion.isColorTheSame
|
||||||
|
|
||||||
class TrimTransformation(
|
class TrimTransformation(
|
||||||
private val tolerance: Int = 20,
|
private val tolerance: Int = 20,
|
||||||
@@ -28,7 +23,7 @@ class TrimTransformation(
|
|||||||
var isColBlank = true
|
var isColBlank = true
|
||||||
val prevColor = input[x, 0]
|
val prevColor = input[x, 0]
|
||||||
for (y in 1 until input.height) {
|
for (y in 1 until input.height) {
|
||||||
if (!isColorTheSame(input[x, y], prevColor)) {
|
if (!isColorTheSame(input[x, y], prevColor, tolerance)) {
|
||||||
isColBlank = false
|
isColBlank = false
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -47,7 +42,7 @@ class TrimTransformation(
|
|||||||
var isColBlank = true
|
var isColBlank = true
|
||||||
val prevColor = input[x, 0]
|
val prevColor = input[x, 0]
|
||||||
for (y in 1 until input.height) {
|
for (y in 1 until input.height) {
|
||||||
if (!isColorTheSame(input[x, y], prevColor)) {
|
if (!isColorTheSame(input[x, y], prevColor, tolerance)) {
|
||||||
isColBlank = false
|
isColBlank = false
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -63,7 +58,7 @@ class TrimTransformation(
|
|||||||
var isRowBlank = true
|
var isRowBlank = true
|
||||||
val prevColor = input[0, y]
|
val prevColor = input[0, y]
|
||||||
for (x in 1 until input.width) {
|
for (x in 1 until input.width) {
|
||||||
if (!isColorTheSame(input[x, y], prevColor)) {
|
if (!isColorTheSame(input[x, y], prevColor, tolerance)) {
|
||||||
isRowBlank = false
|
isRowBlank = false
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -79,7 +74,7 @@ class TrimTransformation(
|
|||||||
var isRowBlank = true
|
var isRowBlank = true
|
||||||
val prevColor = input[0, y]
|
val prevColor = input[0, y]
|
||||||
for (x in 1 until input.width) {
|
for (x in 1 until input.width) {
|
||||||
if (!isColorTheSame(input[x, y], prevColor)) {
|
if (!isColorTheSame(input[x, y], prevColor, tolerance)) {
|
||||||
isRowBlank = false
|
isRowBlank = false
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -98,13 +93,6 @@ class TrimTransformation(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun isColorTheSame(@ColorInt a: Int, @ColorInt b: Int): Boolean {
|
|
||||||
return abs(a.red - b.red) <= tolerance &&
|
|
||||||
abs(a.green - b.green) <= tolerance &&
|
|
||||||
abs(a.blue - b.blue) <= tolerance &&
|
|
||||||
abs(a.alpha - b.alpha) <= tolerance
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun equals(other: Any?): Boolean {
|
override fun equals(other: Any?): Boolean {
|
||||||
return this === other || (other is TrimTransformation && other.tolerance == tolerance)
|
return this === other || (other is TrimTransformation && other.tolerance == tolerance)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package org.koitharu.kotatsu.core.ui.list
|
||||||
|
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
|
||||||
|
abstract class BaseListSelectionCallback(
|
||||||
|
protected val recyclerView: RecyclerView,
|
||||||
|
) : ListSelectionController.Callback {
|
||||||
|
|
||||||
|
override fun onSelectionChanged(controller: ListSelectionController, count: Int) {
|
||||||
|
recyclerView.invalidateItemDecorations()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
package org.koitharu.kotatsu.core.ui.list
|
package org.koitharu.kotatsu.core.ui.list
|
||||||
|
|
||||||
import android.app.Notification.Action
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.Menu
|
import android.view.Menu
|
||||||
import android.view.MenuItem
|
import android.view.MenuItem
|
||||||
import androidx.appcompat.app.AppCompatDelegate
|
import androidx.appcompat.app.AppCompatDelegate
|
||||||
import androidx.appcompat.view.ActionMode
|
import androidx.appcompat.view.ActionMode
|
||||||
|
import androidx.collection.LongSet
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.lifecycle.LifecycleEventObserver
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
import androidx.lifecycle.LifecycleOwner
|
import androidx.lifecycle.LifecycleOwner
|
||||||
@@ -14,6 +14,8 @@ import androidx.savedstate.SavedStateRegistry
|
|||||||
import androidx.savedstate.SavedStateRegistryOwner
|
import androidx.savedstate.SavedStateRegistryOwner
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import org.koitharu.kotatsu.core.ui.list.decor.AbstractSelectionItemDecoration
|
import org.koitharu.kotatsu.core.ui.list.decor.AbstractSelectionItemDecoration
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.toLongArray
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.toSet
|
||||||
import kotlin.coroutines.EmptyCoroutineContext
|
import kotlin.coroutines.EmptyCoroutineContext
|
||||||
|
|
||||||
private const val KEY_SELECTION = "selection"
|
private const val KEY_SELECTION = "selection"
|
||||||
@@ -23,7 +25,7 @@ class ListSelectionController(
|
|||||||
private val appCompatDelegate: AppCompatDelegate,
|
private val appCompatDelegate: AppCompatDelegate,
|
||||||
private val decoration: AbstractSelectionItemDecoration,
|
private val decoration: AbstractSelectionItemDecoration,
|
||||||
private val registryOwner: SavedStateRegistryOwner,
|
private val registryOwner: SavedStateRegistryOwner,
|
||||||
private val callback: Callback2,
|
private val callback: Callback,
|
||||||
) : ActionMode.Callback, SavedStateRegistry.SavedStateProvider {
|
) : ActionMode.Callback, SavedStateRegistry.SavedStateProvider {
|
||||||
|
|
||||||
private var actionMode: ActionMode? = null
|
private var actionMode: ActionMode? = null
|
||||||
@@ -35,11 +37,9 @@ class ListSelectionController(
|
|||||||
registryOwner.lifecycle.addObserver(StateEventObserver())
|
registryOwner.lifecycle.addObserver(StateEventObserver())
|
||||||
}
|
}
|
||||||
|
|
||||||
fun snapshot(): Set<Long> {
|
fun snapshot(): Set<Long> = peekCheckedIds().toSet()
|
||||||
return peekCheckedIds().toSet()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun peekCheckedIds(): Set<Long> {
|
fun peekCheckedIds(): LongSet {
|
||||||
return decoration.checkedItemsIds
|
return decoration.checkedItemsIds
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,43 +130,7 @@ class ListSelectionController(
|
|||||||
notifySelectionChanged()
|
notifySelectionChanged()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Deprecated("")
|
interface Callback {
|
||||||
interface Callback : Callback2 {
|
|
||||||
|
|
||||||
fun onSelectionChanged(count: Int)
|
|
||||||
|
|
||||||
fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean
|
|
||||||
|
|
||||||
fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean
|
|
||||||
|
|
||||||
fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean
|
|
||||||
|
|
||||||
fun onDestroyActionMode(mode: ActionMode) = Unit
|
|
||||||
|
|
||||||
override fun onSelectionChanged(controller: ListSelectionController, count: Int) {
|
|
||||||
onSelectionChanged(count)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreateActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean {
|
|
||||||
return onCreateActionMode(mode, menu)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onPrepareActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean {
|
|
||||||
return onPrepareActionMode(mode, menu)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onActionItemClicked(
|
|
||||||
controller: ListSelectionController,
|
|
||||||
mode: ActionMode,
|
|
||||||
item: MenuItem,
|
|
||||||
): Boolean = onActionItemClicked(mode, item)
|
|
||||||
|
|
||||||
override fun onDestroyActionMode(controller: ListSelectionController, mode: ActionMode) {
|
|
||||||
onDestroyActionMode(mode)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Callback2 {
|
|
||||||
|
|
||||||
fun onSelectionChanged(controller: ListSelectionController, count: Int)
|
fun onSelectionChanged(controller: ListSelectionController, count: Int)
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import android.graphics.Canvas
|
|||||||
import android.graphics.Rect
|
import android.graphics.Rect
|
||||||
import android.graphics.RectF
|
import android.graphics.RectF
|
||||||
import android.view.View
|
import android.view.View
|
||||||
|
import androidx.collection.LongSet
|
||||||
|
import androidx.collection.MutableLongSet
|
||||||
import androidx.core.view.children
|
import androidx.core.view.children
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import androidx.recyclerview.widget.RecyclerView.NO_ID
|
import androidx.recyclerview.widget.RecyclerView.NO_ID
|
||||||
@@ -12,7 +14,7 @@ abstract class AbstractSelectionItemDecoration : RecyclerView.ItemDecoration() {
|
|||||||
|
|
||||||
private val bounds = Rect()
|
private val bounds = Rect()
|
||||||
private val boundsF = RectF()
|
private val boundsF = RectF()
|
||||||
protected val selection = HashSet<Long>()
|
protected val selection = MutableLongSet()
|
||||||
|
|
||||||
protected var hasBackground: Boolean = true
|
protected var hasBackground: Boolean = true
|
||||||
protected var hasForeground: Boolean = false
|
protected var hasForeground: Boolean = false
|
||||||
@@ -21,7 +23,7 @@ abstract class AbstractSelectionItemDecoration : RecyclerView.ItemDecoration() {
|
|||||||
val checkedItemsCount: Int
|
val checkedItemsCount: Int
|
||||||
get() = selection.size
|
get() = selection.size
|
||||||
|
|
||||||
val checkedItemsIds: Set<Long>
|
val checkedItemsIds: LongSet
|
||||||
get() = selection
|
get() = selection
|
||||||
|
|
||||||
fun toggleItemChecked(id: Long) {
|
fun toggleItemChecked(id: Long) {
|
||||||
@@ -39,7 +41,9 @@ abstract class AbstractSelectionItemDecoration : RecyclerView.ItemDecoration() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun checkAll(ids: Collection<Long>) {
|
fun checkAll(ids: Collection<Long>) {
|
||||||
selection.addAll(ids)
|
for (id in ids) {
|
||||||
|
selection.add(id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun clearSelection() {
|
fun clearSelection() {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package org.koitharu.kotatsu.core.ui.list.lifecycle
|
package org.koitharu.kotatsu.core.ui.list.lifecycle
|
||||||
|
|
||||||
|
import android.view.View
|
||||||
import androidx.core.view.children
|
import androidx.core.view.children
|
||||||
import androidx.viewpager2.widget.ViewPager2
|
import androidx.viewpager2.widget.ViewPager2
|
||||||
import org.koitharu.kotatsu.core.util.ext.recyclerView
|
import org.koitharu.kotatsu.core.util.ext.recyclerView
|
||||||
@@ -8,16 +9,63 @@ class PagerLifecycleDispatcher(
|
|||||||
private val pager: ViewPager2,
|
private val pager: ViewPager2,
|
||||||
) : ViewPager2.OnPageChangeCallback() {
|
) : ViewPager2.OnPageChangeCallback() {
|
||||||
|
|
||||||
|
private var pendingUpdate: OneShotLayoutListener? = null
|
||||||
|
|
||||||
override fun onPageSelected(position: Int) {
|
override fun onPageSelected(position: Int) {
|
||||||
super.onPageSelected(position)
|
setResumedPage(position)
|
||||||
val rv = pager.recyclerView ?: return
|
|
||||||
for (child in rv.children) {
|
|
||||||
val wh = rv.getChildViewHolder(child) ?: continue
|
|
||||||
(wh as? LifecycleAwareViewHolder)?.setIsCurrent(wh.absoluteAdapterPosition == position)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun invalidate() {
|
fun invalidate() {
|
||||||
onPageSelected(pager.currentItem)
|
setResumedPage(pager.currentItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun postInvalidate() = pager.post {
|
||||||
|
invalidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setResumedPage(position: Int) {
|
||||||
|
pendingUpdate?.cancel()
|
||||||
|
pendingUpdate = null
|
||||||
|
var hasResumedItem = false
|
||||||
|
val rv = pager.recyclerView ?: return
|
||||||
|
if (rv.childCount == 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for (child in rv.children) {
|
||||||
|
val wh = rv.getChildViewHolder(child) ?: continue
|
||||||
|
val isCurrent = wh.absoluteAdapterPosition == position
|
||||||
|
(wh as? LifecycleAwareViewHolder)?.setIsCurrent(isCurrent)
|
||||||
|
if (isCurrent) {
|
||||||
|
hasResumedItem = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!hasResumedItem) {
|
||||||
|
rv.addOnLayoutChangeListener(OneShotLayoutListener(rv, position).also { pendingUpdate = it })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private inner class OneShotLayoutListener(
|
||||||
|
private val view: View,
|
||||||
|
private val targetPosition: Int,
|
||||||
|
) : View.OnLayoutChangeListener {
|
||||||
|
|
||||||
|
override fun onLayoutChange(
|
||||||
|
v: View?,
|
||||||
|
left: Int,
|
||||||
|
top: Int,
|
||||||
|
right: Int,
|
||||||
|
bottom: Int,
|
||||||
|
oldLeft: Int,
|
||||||
|
oldTop: Int,
|
||||||
|
oldRight: Int,
|
||||||
|
oldBottom: Int
|
||||||
|
) {
|
||||||
|
view.removeOnLayoutChangeListener(this)
|
||||||
|
setResumedPage(targetPosition)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancel() {
|
||||||
|
view.removeOnLayoutChangeListener(this)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,15 +2,45 @@ package org.koitharu.kotatsu.core.ui.model
|
|||||||
|
|
||||||
import androidx.annotation.StringRes
|
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.parsers.model.SortOrder
|
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||||
|
import org.koitharu.kotatsu.parsers.model.SortOrder.ALPHABETICAL
|
||||||
|
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_ASC
|
||||||
|
import org.koitharu.kotatsu.parsers.model.SortOrder.POPULARITY
|
||||||
|
import org.koitharu.kotatsu.parsers.model.SortOrder.POPULARITY_ASC
|
||||||
|
import org.koitharu.kotatsu.parsers.model.SortOrder.RATING
|
||||||
|
import org.koitharu.kotatsu.parsers.model.SortOrder.RATING_ASC
|
||||||
|
import org.koitharu.kotatsu.parsers.model.SortOrder.UPDATED
|
||||||
|
import org.koitharu.kotatsu.parsers.model.SortOrder.UPDATED_ASC
|
||||||
|
|
||||||
@get:StringRes
|
@get:StringRes
|
||||||
val SortOrder.titleRes: Int
|
val SortOrder.titleRes: Int
|
||||||
get() = when (this) {
|
get() = when (this) {
|
||||||
SortOrder.UPDATED -> R.string.updated
|
UPDATED -> R.string.updated
|
||||||
SortOrder.POPULARITY -> R.string.popular
|
POPULARITY -> R.string.popular
|
||||||
SortOrder.RATING -> R.string.by_rating
|
RATING -> R.string.by_rating
|
||||||
SortOrder.NEWEST -> R.string.newest
|
NEWEST -> R.string.newest
|
||||||
SortOrder.ALPHABETICAL -> R.string.by_name
|
ALPHABETICAL -> R.string.by_name
|
||||||
SortOrder.ALPHABETICAL_DESC -> R.string.by_name_reverse
|
ALPHABETICAL_DESC -> R.string.by_name_reverse
|
||||||
|
UPDATED_ASC -> R.string.updated_long_ago
|
||||||
|
POPULARITY_ASC -> R.string.unpopular
|
||||||
|
RATING_ASC -> R.string.low_rating
|
||||||
|
NEWEST_ASC -> R.string.order_oldest
|
||||||
|
}
|
||||||
|
|
||||||
|
val SortOrder.direction: SortDirection
|
||||||
|
get() = when (this) {
|
||||||
|
UPDATED_ASC,
|
||||||
|
POPULARITY_ASC,
|
||||||
|
RATING_ASC,
|
||||||
|
NEWEST_ASC,
|
||||||
|
ALPHABETICAL -> SortDirection.ASC
|
||||||
|
|
||||||
|
UPDATED,
|
||||||
|
POPULARITY,
|
||||||
|
RATING,
|
||||||
|
NEWEST,
|
||||||
|
ALPHABETICAL_DESC -> SortDirection.DESC
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,22 +21,24 @@ import androidx.viewbinding.ViewBinding
|
|||||||
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
||||||
import com.google.android.material.bottomsheet.BottomSheetDialog
|
import com.google.android.material.bottomsheet.BottomSheetDialog
|
||||||
import com.google.android.material.sidesheet.SideSheetDialog
|
import com.google.android.material.sidesheet.SideSheetDialog
|
||||||
|
import dagger.hilt.android.EntryPointAccessors
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
|
||||||
import org.koitharu.kotatsu.core.ui.BaseActivity
|
import org.koitharu.kotatsu.core.ui.BaseActivity
|
||||||
|
import org.koitharu.kotatsu.core.ui.BaseActivityEntryPoint
|
||||||
import org.koitharu.kotatsu.core.ui.util.ActionModeDelegate
|
import org.koitharu.kotatsu.core.ui.util.ActionModeDelegate
|
||||||
import com.google.android.material.R as materialR
|
import com.google.android.material.R as materialR
|
||||||
|
|
||||||
abstract class BaseAdaptiveSheet<B : ViewBinding> : AppCompatDialogFragment() {
|
abstract class BaseAdaptiveSheet<B : ViewBinding> : AppCompatDialogFragment(), ExceptionResolver.Host {
|
||||||
|
|
||||||
private var waitingForDismissAllowingStateLoss = false
|
private var waitingForDismissAllowingStateLoss = false
|
||||||
private var isFitToContentsDisabled = false
|
private var isFitToContentsDisabled = false
|
||||||
|
|
||||||
var viewBinding: B? = null
|
protected lateinit var exceptionResolver: ExceptionResolver
|
||||||
private set
|
private set
|
||||||
|
|
||||||
@Deprecated("", ReplaceWith("requireViewBinding()"))
|
var viewBinding: B? = null
|
||||||
protected val binding: B
|
private set
|
||||||
get() = requireViewBinding()
|
|
||||||
|
|
||||||
protected val behavior: AdaptiveSheetBehavior?
|
protected val behavior: AdaptiveSheetBehavior?
|
||||||
get() = AdaptiveSheetBehavior.from(this)
|
get() = AdaptiveSheetBehavior.from(this)
|
||||||
@@ -54,6 +56,12 @@ abstract class BaseAdaptiveSheet<B : ViewBinding> : AppCompatDialogFragment() {
|
|||||||
private set
|
private set
|
||||||
private var lockCounter = 0
|
private var lockCounter = 0
|
||||||
|
|
||||||
|
override fun onAttach(context: Context) {
|
||||||
|
super.onAttach(context)
|
||||||
|
val entryPoint = EntryPointAccessors.fromApplication<BaseActivityEntryPoint>(context)
|
||||||
|
exceptionResolver = entryPoint.exceptionResolverFactory.create(this)
|
||||||
|
}
|
||||||
|
|
||||||
final override fun onCreateView(
|
final override fun onCreateView(
|
||||||
inflater: LayoutInflater,
|
inflater: LayoutInflater,
|
||||||
container: ViewGroup?,
|
container: ViewGroup?,
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package org.koitharu.kotatsu.core.ui.util
|
||||||
|
|
||||||
|
import android.view.View
|
||||||
|
import com.google.android.material.appbar.AppBarLayout
|
||||||
|
|
||||||
|
class FadingAppbarMediator(
|
||||||
|
private val appBarLayout: AppBarLayout,
|
||||||
|
private val target: View
|
||||||
|
) : AppBarLayout.OnOffsetChangedListener {
|
||||||
|
|
||||||
|
private var isBound: Boolean = false
|
||||||
|
|
||||||
|
fun bind() {
|
||||||
|
if (!isBound) {
|
||||||
|
appBarLayout.addOnOffsetChangedListener(this)
|
||||||
|
isBound = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun unbind() {
|
||||||
|
if (isBound) {
|
||||||
|
appBarLayout.removeOnOffsetChangedListener(this)
|
||||||
|
isBound = false
|
||||||
|
}
|
||||||
|
target.alpha = 1f
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onOffsetChanged(appBarLayout: AppBarLayout?, verticalOffset: Int) {
|
||||||
|
val scrollRange = (appBarLayout ?: return).totalScrollRange
|
||||||
|
if (scrollRange <= 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
target.alpha = 1f + verticalOffset / (scrollRange / 2f)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,5 @@ class MenuInvalidator(
|
|||||||
private val host: MenuHost,
|
private val host: MenuHost,
|
||||||
) : FlowCollector<Any?> {
|
) : FlowCollector<Any?> {
|
||||||
|
|
||||||
override suspend fun emit(value: Any?) {
|
override suspend fun emit(value: Any?) = host.invalidateMenu()
|
||||||
host.invalidateMenu()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ import kotlinx.coroutines.Dispatchers
|
|||||||
import kotlinx.coroutines.NonCancellable
|
import kotlinx.coroutines.NonCancellable
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||||
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
|
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
|
||||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
|
||||||
|
|
||||||
fun interface ReversibleHandle {
|
fun interface ReversibleHandle {
|
||||||
|
|
||||||
@@ -23,8 +23,3 @@ fun ReversibleHandle.reverseAsync() = processLifecycleScope.launch(Dispatchers.D
|
|||||||
it.printStackTraceDebug()
|
it.printStackTraceDebug()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
operator fun ReversibleHandle.plus(other: ReversibleHandle) = ReversibleHandle {
|
|
||||||
this.reverse()
|
|
||||||
other.reverse()
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,16 +2,16 @@ package org.koitharu.kotatsu.core.ui.widgets
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.util.AttributeSet
|
import android.util.AttributeSet
|
||||||
|
import android.view.View
|
||||||
import android.view.View.OnClickListener
|
import android.view.View.OnClickListener
|
||||||
import androidx.annotation.ColorRes
|
import androidx.annotation.ColorRes
|
||||||
import androidx.annotation.DrawableRes
|
import androidx.annotation.DrawableRes
|
||||||
|
import androidx.annotation.StringRes
|
||||||
import androidx.core.view.children
|
import androidx.core.view.children
|
||||||
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 org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.core.util.ext.castOrNull
|
|
||||||
|
|
||||||
import com.google.android.material.R as materialR
|
import com.google.android.material.R as materialR
|
||||||
|
|
||||||
class ChipsView @JvmOverloads constructor(
|
class ChipsView @JvmOverloads constructor(
|
||||||
@@ -22,9 +22,7 @@ class ChipsView @JvmOverloads constructor(
|
|||||||
|
|
||||||
private var isLayoutSuppressedCompat = false
|
private var isLayoutSuppressedCompat = false
|
||||||
private var isLayoutCalledOnSuppressed = false
|
private var isLayoutCalledOnSuppressed = false
|
||||||
private val chipOnClickListener = OnClickListener {
|
private val chipOnClickListener = InternalChipClickListener()
|
||||||
onChipClickListener?.onChipClick(it as Chip, it.tag)
|
|
||||||
}
|
|
||||||
private val chipOnCloseListener = OnClickListener {
|
private val chipOnCloseListener = OnClickListener {
|
||||||
val chip = it as Chip
|
val chip = it as Chip
|
||||||
val data = it.tag
|
val data = it.tag
|
||||||
@@ -70,8 +68,8 @@ class ChipsView @JvmOverloads constructor(
|
|||||||
suppressLayoutCompat(true)
|
suppressLayoutCompat(true)
|
||||||
try {
|
try {
|
||||||
for ((i, model) in items.withIndex()) {
|
for ((i, model) in items.withIndex()) {
|
||||||
val chip = getChildAt(i) as Chip? ?: addChip()
|
val chip = getChildAt(i) as DataChip? ?: addChip()
|
||||||
bindChip(chip, model)
|
chip.bind(model)
|
||||||
}
|
}
|
||||||
if (childCount > items.size) {
|
if (childCount > items.size) {
|
||||||
removeViews(items.size, childCount - items.size)
|
removeViews(items.size, childCount - items.size)
|
||||||
@@ -81,52 +79,7 @@ class ChipsView @JvmOverloads constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun <T> getCheckedData(cls: Class<T>): Set<T> {
|
private fun addChip() = DataChip(context).also { addView(it) }
|
||||||
val result = LinkedHashSet<T>(childCount)
|
|
||||||
for (child in children) {
|
|
||||||
if (child is Chip && child.isChecked) {
|
|
||||||
result += cls.castOrNull(child.tag) ?: continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun bindChip(chip: Chip, model: ChipModel) {
|
|
||||||
chip.text = model.title
|
|
||||||
chip.isClickable = onChipClickListener != null || model.isCheckable
|
|
||||||
chip.isCheckable = model.isCheckable
|
|
||||||
if (model.icon == 0) {
|
|
||||||
chip.chipIcon = null
|
|
||||||
chip.isChipIconVisible = false
|
|
||||||
} else {
|
|
||||||
chip.setChipIconResource(model.icon)
|
|
||||||
chip.isChipIconVisible = true
|
|
||||||
}
|
|
||||||
chip.isChecked = model.isChecked
|
|
||||||
chip.isCheckedIconVisible = chip.isCheckable && model.icon == 0
|
|
||||||
chip.isCloseIconVisible = if (onChipCloseClickListener != null || model.isDropdown) {
|
|
||||||
chip.setCloseIconResource(
|
|
||||||
if (model.isDropdown) R.drawable.ic_expand_more else materialR.drawable.ic_m3_chip_close,
|
|
||||||
)
|
|
||||||
true
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
chip.tag = model.data
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun addChip(): Chip {
|
|
||||||
val chip = Chip(context)
|
|
||||||
val drawable = ChipDrawable.createFromAttributes(context, null, 0, chipStyle)
|
|
||||||
chip.setChipDrawable(drawable)
|
|
||||||
chip.isChipIconVisible = false
|
|
||||||
chip.setOnCloseIconClickListener(chipOnCloseListener)
|
|
||||||
chip.setEnsureMinTouchTargetSize(false)
|
|
||||||
chip.setOnClickListener(chipOnClickListener)
|
|
||||||
chip.isElegantTextHeight = false
|
|
||||||
addView(chip)
|
|
||||||
return chip
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun suppressLayoutCompat(suppress: Boolean) {
|
private fun suppressLayoutCompat(suppress: Boolean) {
|
||||||
isLayoutSuppressedCompat = suppress
|
isLayoutSuppressedCompat = suppress
|
||||||
@@ -139,15 +92,74 @@ class ChipsView @JvmOverloads constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
data class ChipModel(
|
data class ChipModel(
|
||||||
val title: CharSequence,
|
val title: CharSequence? = null,
|
||||||
|
@StringRes val titleResId: Int = 0,
|
||||||
@DrawableRes val icon: Int = 0,
|
@DrawableRes val icon: Int = 0,
|
||||||
val isCheckable: Boolean = false,
|
|
||||||
@ColorRes val tint: Int = 0,
|
@ColorRes val tint: Int = 0,
|
||||||
val isChecked: Boolean = false,
|
val isChecked: Boolean = false,
|
||||||
val isDropdown: Boolean = false,
|
val isDropdown: Boolean = false,
|
||||||
val data: Any? = null,
|
val data: Any? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private inner class DataChip(context: Context) : Chip(context) {
|
||||||
|
|
||||||
|
private var model: ChipModel? = null
|
||||||
|
|
||||||
|
init {
|
||||||
|
val drawable = ChipDrawable.createFromAttributes(context, null, 0, chipStyle)
|
||||||
|
setChipDrawable(drawable)
|
||||||
|
isChipIconVisible = false
|
||||||
|
setOnCloseIconClickListener(chipOnCloseListener)
|
||||||
|
setEnsureMinTouchTargetSize(false)
|
||||||
|
setOnClickListener(chipOnClickListener)
|
||||||
|
isElegantTextHeight = false
|
||||||
|
}
|
||||||
|
|
||||||
|
fun bind(model: ChipModel) {
|
||||||
|
this.model = model
|
||||||
|
|
||||||
|
if (model.titleResId == 0) {
|
||||||
|
text = model.title
|
||||||
|
} else {
|
||||||
|
setText(model.titleResId)
|
||||||
|
}
|
||||||
|
isClickable = onChipClickListener != null
|
||||||
|
if (model.isChecked) {
|
||||||
|
isCheckable = true
|
||||||
|
isChecked = true
|
||||||
|
} else {
|
||||||
|
isChecked = false
|
||||||
|
isCheckable = false
|
||||||
|
}
|
||||||
|
if (model.icon == 0 || model.isChecked) {
|
||||||
|
chipIcon = null
|
||||||
|
isChipIconVisible = false
|
||||||
|
} else {
|
||||||
|
setChipIconResource(model.icon)
|
||||||
|
isChipIconVisible = true
|
||||||
|
}
|
||||||
|
isCheckedIconVisible = model.isChecked
|
||||||
|
isCloseIconVisible = if (onChipCloseClickListener != null || model.isDropdown) {
|
||||||
|
setCloseIconResource(
|
||||||
|
if (model.isDropdown) R.drawable.ic_expand_more else materialR.drawable.ic_m3_chip_close,
|
||||||
|
)
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
tag = model.data
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun toggle() = Unit
|
||||||
|
}
|
||||||
|
|
||||||
|
private inner class InternalChipClickListener : OnClickListener {
|
||||||
|
override fun onClick(v: View?) {
|
||||||
|
val chip = v as? DataChip ?: return
|
||||||
|
onChipClickListener?.onChipClick(chip, chip.tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun interface OnChipClickListener {
|
fun interface OnChipClickListener {
|
||||||
|
|
||||||
fun onChipClick(chip: Chip, data: Any?)
|
fun onChipClick(chip: Chip, data: Any?)
|
||||||
|
|||||||
@@ -23,11 +23,16 @@ class SelectableTextView @JvmOverloads constructor(
|
|||||||
private fun fixSelectionRange() {
|
private fun fixSelectionRange() {
|
||||||
if (selectionStart < 0 || selectionEnd < 0) {
|
if (selectionStart < 0 || selectionEnd < 0) {
|
||||||
val spannableText = text as? Spannable ?: return
|
val spannableText = text as? Spannable ?: return
|
||||||
Selection.setSelection(spannableText, text.length)
|
Selection.setSelection(spannableText, spannableText.length)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun scrollTo(x: Int, y: Int) {
|
override fun scrollTo(x: Int, y: Int) {
|
||||||
super.scrollTo(0, 0)
|
super.scrollTo(0, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun selectAll() {
|
||||||
|
val spannableText = text as? Spannable ?: return
|
||||||
|
Selection.selectAll(spannableText)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ abstract class MediatorStateFlow<T>(initialValue: T) : StateFlow<T> {
|
|||||||
final override val replayCache: List<T>
|
final override val replayCache: List<T>
|
||||||
get() = delegate.replayCache
|
get() = delegate.replayCache
|
||||||
|
|
||||||
final override val value: T
|
override val value: T
|
||||||
get() = delegate.value
|
get() = delegate.value
|
||||||
|
|
||||||
final override suspend fun collect(collector: FlowCollector<T>): Nothing {
|
final override suspend fun collect(collector: FlowCollector<T>): Nothing {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import kotlinx.coroutines.sync.Mutex
|
|||||||
import kotlin.contracts.InvocationKind
|
import kotlin.contracts.InvocationKind
|
||||||
import kotlin.contracts.contract
|
import kotlin.contracts.contract
|
||||||
|
|
||||||
class MultiMutex<T : Any> : Set<T> {
|
open class MultiMutex<T : Any> : Set<T> {
|
||||||
|
|
||||||
private val delegates = ArrayMap<T, Mutex>()
|
private val delegates = ArrayMap<T, Mutex>()
|
||||||
|
|
||||||
@@ -20,19 +20,26 @@ class MultiMutex<T : Any> : Set<T> {
|
|||||||
elements.all { x -> delegates.containsKey(x) }
|
elements.all { x -> delegates.containsKey(x) }
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun isEmpty(): Boolean {
|
override fun isEmpty(): Boolean = delegates.isEmpty()
|
||||||
return delegates.isEmpty()
|
|
||||||
|
override fun iterator(): Iterator<T> = synchronized(delegates) {
|
||||||
|
delegates.keys.toList()
|
||||||
|
}.iterator()
|
||||||
|
|
||||||
|
fun isLocked(element: T): Boolean = synchronized(delegates) {
|
||||||
|
delegates[element]?.isLocked == true
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun iterator(): Iterator<T> {
|
fun tryLock(element: T): Boolean {
|
||||||
return delegates.keys.iterator()
|
val mutex = synchronized(delegates) {
|
||||||
|
delegates.getOrPut(element, ::Mutex)
|
||||||
|
}
|
||||||
|
return mutex.tryLock()
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun lock(element: T) {
|
suspend fun lock(element: T) {
|
||||||
val mutex = synchronized(delegates) {
|
val mutex = synchronized(delegates) {
|
||||||
delegates.getOrPut(element) {
|
delegates.getOrPut(element, ::Mutex)
|
||||||
Mutex()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
mutex.lock()
|
mutex.lock()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.util
|
|
||||||
|
|
||||||
import android.app.Activity
|
|
||||||
|
|
||||||
class TaggedActivityResult(
|
|
||||||
val tag: String,
|
|
||||||
val result: Int,
|
|
||||||
) {
|
|
||||||
|
|
||||||
val isSuccess: Boolean
|
|
||||||
get() = result == Activity.RESULT_OK
|
|
||||||
}
|
|
||||||
@@ -20,7 +20,6 @@ import android.database.SQLException
|
|||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.graphics.Color
|
import android.graphics.Color
|
||||||
import android.net.ConnectivityManager
|
import android.net.ConnectivityManager
|
||||||
import android.net.Uri
|
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.os.PowerManager
|
import android.os.PowerManager
|
||||||
@@ -31,7 +30,6 @@ import android.view.Window
|
|||||||
import android.webkit.WebView
|
import android.webkit.WebView
|
||||||
import androidx.activity.result.ActivityResultLauncher
|
import androidx.activity.result.ActivityResultLauncher
|
||||||
import androidx.annotation.IntegerRes
|
import androidx.annotation.IntegerRes
|
||||||
import androidx.annotation.WorkerThread
|
|
||||||
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
|
||||||
@@ -79,8 +77,6 @@ val Context.powerManager: PowerManager?
|
|||||||
val Context.connectivityManager: ConnectivityManager
|
val Context.connectivityManager: ConnectivityManager
|
||||||
get() = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
get() = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||||
|
|
||||||
fun String.toUriOrNull() = if (isEmpty()) null else Uri.parse(this)
|
|
||||||
|
|
||||||
suspend fun CoroutineWorker.trySetForeground(): Boolean = runCatchingCancellable {
|
suspend fun CoroutineWorker.trySetForeground(): Boolean = runCatchingCancellable {
|
||||||
val info = getForegroundInfo()
|
val info = getForegroundInfo()
|
||||||
setForeground(info)
|
setForeground(info)
|
||||||
@@ -131,8 +127,7 @@ fun SyncResult.onError(error: Throwable) {
|
|||||||
when (error) {
|
when (error) {
|
||||||
is IOException -> stats.numIoExceptions++
|
is IOException -> stats.numIoExceptions++
|
||||||
is OperationApplicationException,
|
is OperationApplicationException,
|
||||||
is SQLException,
|
is SQLException -> databaseError = true
|
||||||
-> databaseError = true
|
|
||||||
|
|
||||||
is JSONException -> stats.numParseExceptions++
|
is JSONException -> stats.numParseExceptions++
|
||||||
else -> if (BuildConfig.DEBUG) throw error
|
else -> if (BuildConfig.DEBUG) throw error
|
||||||
@@ -253,7 +248,6 @@ fun Context.checkNotificationPermission(channelId: String?): Boolean {
|
|||||||
return hasPermission
|
return hasPermission
|
||||||
}
|
}
|
||||||
|
|
||||||
@WorkerThread
|
|
||||||
suspend fun Bitmap.compressToPNG(output: File) = runInterruptible(Dispatchers.IO) {
|
suspend fun Bitmap.compressToPNG(output: File) = runInterruptible(Dispatchers.IO) {
|
||||||
output.outputStream().use { os ->
|
output.outputStream().use { os ->
|
||||||
if (!compress(Bitmap.CompressFormat.PNG, 100, os)) {
|
if (!compress(Bitmap.CompressFormat.PNG, 100, os)) {
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.util.ext
|
|
||||||
|
|
||||||
import android.view.View
|
|
||||||
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
|
||||||
import com.google.android.material.bottomsheet.BottomSheetBehavior.BottomSheetCallback
|
|
||||||
|
|
||||||
fun BottomSheetBehavior<*>.doOnExpansionsChanged(callback: (isExpanded: Boolean) -> Unit) {
|
|
||||||
var isExpended = state == BottomSheetBehavior.STATE_EXPANDED
|
|
||||||
callback(isExpended)
|
|
||||||
addBottomSheetCallback(
|
|
||||||
object : BottomSheetCallback() {
|
|
||||||
override fun onStateChanged(bottomSheet: View, newState: Int) {
|
|
||||||
val expanded = newState == BottomSheetBehavior.STATE_EXPANDED
|
|
||||||
if (expanded != isExpended) {
|
|
||||||
isExpended = expanded
|
|
||||||
callback(expanded)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onSlide(bottomSheet: View, slideOffset: Float) = Unit
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -57,10 +57,6 @@ fun ImageResult.toBitmapOrNull() = when (this) {
|
|||||||
is ErrorResult -> null
|
is ErrorResult -> null
|
||||||
}
|
}
|
||||||
|
|
||||||
fun ImageRequest.Builder.indicator(indicator: BaseProgressIndicator<*>): ImageRequest.Builder {
|
|
||||||
return addListener(ImageRequestIndicatorListener(listOf(indicator)))
|
|
||||||
}
|
|
||||||
|
|
||||||
fun ImageRequest.Builder.indicator(indicators: List<BaseProgressIndicator<*>>): ImageRequest.Builder {
|
fun ImageRequest.Builder.indicator(indicators: List<BaseProgressIndicator<*>>): ImageRequest.Builder {
|
||||||
return addListener(ImageRequestIndicatorListener(indicators))
|
return addListener(ImageRequestIndicatorListener(indicators))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package org.koitharu.kotatsu.core.util.ext
|
|||||||
|
|
||||||
import androidx.collection.ArrayMap
|
import androidx.collection.ArrayMap
|
||||||
import androidx.collection.ArraySet
|
import androidx.collection.ArraySet
|
||||||
|
import androidx.collection.LongSet
|
||||||
import org.koitharu.kotatsu.BuildConfig
|
import org.koitharu.kotatsu.BuildConfig
|
||||||
import java.util.Collections
|
import java.util.Collections
|
||||||
import java.util.EnumSet
|
import java.util.EnumSet
|
||||||
@@ -69,4 +70,24 @@ fun <T> Iterable<T>.sortedWithSafe(comparator: Comparator<in T>): List<T> = try
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun Collection<*>?.sizeOrZero() = if (this == null) 0 else size
|
fun Collection<*>?.sizeOrZero() = this?.size ?: 0
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
inline fun <T, reified R> Collection<T>.mapToArray(transform: (T) -> R): Array<R> {
|
||||||
|
val result = arrayOfNulls<R>(size)
|
||||||
|
forEachIndexed { index, t -> result[index] = transform(t) }
|
||||||
|
return result as Array<R>
|
||||||
|
}
|
||||||
|
|
||||||
|
fun LongSet.toLongArray(): LongArray {
|
||||||
|
val result = LongArray(size)
|
||||||
|
var i = 0
|
||||||
|
forEach { result[i++] = it }
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
fun LongSet.toSet(): Set<Long> = toCollection(ArraySet<Long>(size))
|
||||||
|
|
||||||
|
fun <R : MutableCollection<Long>> LongSet.toCollection(out: R): R = out.also { result ->
|
||||||
|
forEach(result::add)
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,12 +12,16 @@ import kotlinx.coroutines.CancellableContinuation
|
|||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Deferred
|
import kotlinx.coroutines.Deferred
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.joinAll
|
||||||
import kotlinx.coroutines.plus
|
import kotlinx.coroutines.plus
|
||||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||||
import org.koitharu.kotatsu.core.util.AcraCoroutineErrorHandler
|
import org.koitharu.kotatsu.core.util.AcraCoroutineErrorHandler
|
||||||
import org.koitharu.kotatsu.core.util.RetainedLifecycleCoroutineScope
|
import org.koitharu.kotatsu.core.util.RetainedLifecycleCoroutineScope
|
||||||
|
import org.koitharu.kotatsu.parsers.util.cancelAll
|
||||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||||
import kotlin.coroutines.EmptyCoroutineContext
|
import kotlin.coroutines.EmptyCoroutineContext
|
||||||
|
import kotlin.coroutines.cancellation.CancellationException
|
||||||
import kotlin.coroutines.resume
|
import kotlin.coroutines.resume
|
||||||
import kotlin.coroutines.resumeWithException
|
import kotlin.coroutines.resumeWithException
|
||||||
|
|
||||||
@@ -90,3 +94,10 @@ fun <T> Deferred<T>.peek(): T? = if (isCompleted) {
|
|||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Suppress("SuspendFunctionOnCoroutineScope")
|
||||||
|
suspend fun CoroutineScope.cancelChildrenAndJoin(cause: CancellationException? = null) {
|
||||||
|
val jobs = coroutineContext[Job]?.children?.toList() ?: return
|
||||||
|
jobs.cancelAll(cause)
|
||||||
|
jobs.joinAll()
|
||||||
|
}
|
||||||
|
|||||||
@@ -37,3 +37,5 @@ fun JSONObject.toContentValues(): ContentValues {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun String.escapeName() = "`$this`"
|
private fun String.escapeName() = "`$this`"
|
||||||
|
|
||||||
|
fun Cursor.getBoolean(columnIndex: Int) = getInt(columnIndex) > 0
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
package org.koitharu.kotatsu.core.util.ext
|
package org.koitharu.kotatsu.core.util.ext
|
||||||
|
|
||||||
|
import android.content.res.Resources
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.core.ui.model.DateTimeAgo
|
import org.koitharu.kotatsu.core.ui.model.DateTimeAgo
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import java.time.temporal.ChronoUnit
|
import java.time.temporal.ChronoUnit
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
fun calculateTimeAgo(instant: Instant, showMonths: Boolean = false): DateTimeAgo {
|
fun calculateTimeAgo(instant: Instant, showMonths: Boolean = false): DateTimeAgo {
|
||||||
// TODO: Use Java 9's LocalDate.ofInstant().
|
// TODO: Use Java 9's LocalDate.ofInstant().
|
||||||
@@ -33,3 +36,17 @@ fun calculateTimeAgo(instant: Instant, showMonths: Boolean = false): DateTimeAgo
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun Long.toInstantOrNull() = if (this == 0L) null else Instant.ofEpochMilli(this)
|
fun Long.toInstantOrNull() = if (this == 0L) null else Instant.ofEpochMilli(this)
|
||||||
|
|
||||||
|
fun Resources.formatDurationShort(millis: Long): String? {
|
||||||
|
val hours = TimeUnit.MILLISECONDS.toHours(millis).toInt()
|
||||||
|
val minutes = (TimeUnit.MILLISECONDS.toMinutes(millis) % 60).toInt()
|
||||||
|
val seconds = (TimeUnit.MILLISECONDS.toSeconds(millis) % 60).toInt()
|
||||||
|
return when {
|
||||||
|
hours == 0 && minutes == 0 && seconds == 0 -> null
|
||||||
|
hours != 0 && minutes != 0 -> getString(R.string.hours_minutes_short, hours, minutes)
|
||||||
|
hours != 0 -> getString(R.string.hours_short, hours)
|
||||||
|
minutes != 0 && seconds != 0 -> getString(R.string.minutes_seconds_short, minutes, seconds)
|
||||||
|
minutes != 0 -> getString(R.string.minutes_short, minutes)
|
||||||
|
else -> getString(R.string.seconds_short, seconds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import android.os.SystemClock
|
|||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.channelFlow
|
||||||
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.firstOrNull
|
import kotlinx.coroutines.flow.firstOrNull
|
||||||
@@ -14,6 +16,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 org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
import java.util.concurrent.atomic.AtomicInteger
|
import java.util.concurrent.atomic.AtomicInteger
|
||||||
|
|
||||||
fun <T> Flow<T>.onFirst(action: suspend (T) -> Unit): Flow<T> {
|
fun <T> Flow<T>.onFirst(action: suspend (T) -> Unit): Flow<T> {
|
||||||
@@ -87,6 +90,20 @@ fun <T> Flow<T>.zipWithPrevious(): Flow<Pair<T?, T>> = flow {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun tickerFlow(interval: Long, timeUnit: TimeUnit): Flow<Long> = flow {
|
||||||
|
while (true) {
|
||||||
|
emit(SystemClock.elapsedRealtime())
|
||||||
|
delay(timeUnit.toMillis(interval))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun <T> Flow<T>.withTicker(interval: Long, timeUnit: TimeUnit) = channelFlow<T> {
|
||||||
|
onCompletion { cause ->
|
||||||
|
close(cause)
|
||||||
|
}.combine(tickerFlow(interval, timeUnit)) { x, _ -> x }
|
||||||
|
.collectLatest { send(it) }
|
||||||
|
}
|
||||||
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
fun <T1, T2, T3, T4, T5, T6, R> combine(
|
fun <T1, T2, T3, T4, T5, T6, R> combine(
|
||||||
flow: Flow<T1>,
|
flow: Flow<T1>,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package org.koitharu.kotatsu.core.util.ext
|
package org.koitharu.kotatsu.core.util.ext
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
import android.graphics.Rect
|
import android.graphics.Rect
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
@@ -11,3 +12,9 @@ fun Rect.scale(factor: Double) {
|
|||||||
(height() - newHeight) / 2,
|
(height() - newHeight) / 2,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
inline fun <R> Bitmap.use(block: (Bitmap) -> R) = try {
|
||||||
|
block(this)
|
||||||
|
} finally {
|
||||||
|
recycle()
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package org.koitharu.kotatsu.core.util.ext
|
package org.koitharu.kotatsu.core.util.ext
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
import androidx.annotation.FloatRange
|
import androidx.annotation.FloatRange
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.parsers.util.ellipsize
|
||||||
import org.koitharu.kotatsu.parsers.util.levenshteinDistance
|
import org.koitharu.kotatsu.parsers.util.levenshteinDistance
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
@@ -40,3 +43,24 @@ fun CharSequence.sanitize(): CharSequence {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun Char.isReplacement() = this in '\uFFF0'..'\uFFFF'
|
fun Char.isReplacement() = this in '\uFFF0'..'\uFFFF'
|
||||||
|
|
||||||
|
fun <T> Collection<T>.joinToStringWithLimit(context: Context, limit: Int, transform: ((T) -> String)): String {
|
||||||
|
if (size == 1) {
|
||||||
|
return transform(first()).ellipsize(limit)
|
||||||
|
}
|
||||||
|
return buildString(limit + 6) {
|
||||||
|
for ((i, item) in this@joinToStringWithLimit.withIndex()) {
|
||||||
|
val str = transform(item)
|
||||||
|
when {
|
||||||
|
i == 0 -> append(str.ellipsize(limit - 4))
|
||||||
|
length + str.length > limit -> {
|
||||||
|
append(", ")
|
||||||
|
append(context.getString(R.string.list_ellipsize_pattern, this@joinToStringWithLimit.size - i))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> append(", ").append(str)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import androidx.core.content.res.use
|
|||||||
import androidx.core.view.isGone
|
import androidx.core.view.isGone
|
||||||
import androidx.core.widget.TextViewCompat
|
import androidx.core.widget.TextViewCompat
|
||||||
|
|
||||||
|
|
||||||
var TextView.textAndVisible: CharSequence?
|
var TextView.textAndVisible: CharSequence?
|
||||||
get() = text?.takeIf { visibility == View.VISIBLE }
|
get() = text?.takeIf { visibility == View.VISIBLE }
|
||||||
set(value) {
|
set(value) {
|
||||||
|
|||||||
@@ -8,11 +8,9 @@ import androidx.annotation.AttrRes
|
|||||||
import androidx.annotation.ColorInt
|
import androidx.annotation.ColorInt
|
||||||
import androidx.annotation.FloatRange
|
import androidx.annotation.FloatRange
|
||||||
import androidx.annotation.Px
|
import androidx.annotation.Px
|
||||||
import androidx.annotation.StyleRes
|
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.core.content.res.use
|
import androidx.core.content.res.use
|
||||||
import androidx.core.graphics.ColorUtils
|
import androidx.core.graphics.ColorUtils
|
||||||
import com.google.android.material.R as materialR
|
|
||||||
|
|
||||||
fun Context.getThemeDrawable(
|
fun Context.getThemeDrawable(
|
||||||
@AttrRes resId: Int,
|
@AttrRes resId: Int,
|
||||||
@@ -77,7 +75,3 @@ fun TypedArray.getDrawableCompat(context: Context, index: Int): Drawable? {
|
|||||||
val resId = getResourceId(index, 0)
|
val resId = getResourceId(index, 0)
|
||||||
return if (resId != 0) ContextCompat.getDrawable(context, resId) else null
|
return if (resId != 0) ContextCompat.getDrawable(context, resId) else null
|
||||||
}
|
}
|
||||||
|
|
||||||
@get:StyleRes
|
|
||||||
val DIALOG_THEME_CENTERED: Int
|
|
||||||
inline get() = materialR.style.ThemeOverlay_Material3_MaterialAlertDialog_Centered
|
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ package org.koitharu.kotatsu.core.util.ext
|
|||||||
import android.content.ActivityNotFoundException
|
import android.content.ActivityNotFoundException
|
||||||
import android.content.res.Resources
|
import android.content.res.Resources
|
||||||
import androidx.annotation.DrawableRes
|
import androidx.annotation.DrawableRes
|
||||||
import androidx.collection.arraySetOf
|
|
||||||
import coil.network.HttpException
|
import coil.network.HttpException
|
||||||
import okio.FileNotFoundException
|
import okio.FileNotFoundException
|
||||||
import okio.IOException
|
import okio.IOException
|
||||||
|
import okio.ProtocolException
|
||||||
import org.acra.ktx.sendWithAcra
|
import org.acra.ktx.sendWithAcra
|
||||||
import org.jsoup.HttpStatusException
|
import org.jsoup.HttpStatusException
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
@@ -15,12 +15,14 @@ import org.koitharu.kotatsu.core.exceptions.CaughtException
|
|||||||
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.core.exceptions.EmptyHistoryException
|
import org.koitharu.kotatsu.core.exceptions.EmptyHistoryException
|
||||||
|
import org.koitharu.kotatsu.core.exceptions.IncompatiblePluginException
|
||||||
import org.koitharu.kotatsu.core.exceptions.NoDataReceivedException
|
import org.koitharu.kotatsu.core.exceptions.NoDataReceivedException
|
||||||
|
import org.koitharu.kotatsu.core.exceptions.ProxyConfigException
|
||||||
import org.koitharu.kotatsu.core.exceptions.SyncApiException
|
import org.koitharu.kotatsu.core.exceptions.SyncApiException
|
||||||
import org.koitharu.kotatsu.core.exceptions.TooManyRequestExceptions
|
|
||||||
import org.koitharu.kotatsu.core.exceptions.UnsupportedFileException
|
import org.koitharu.kotatsu.core.exceptions.UnsupportedFileException
|
||||||
import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException
|
import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException
|
||||||
import org.koitharu.kotatsu.core.exceptions.WrongPasswordException
|
import org.koitharu.kotatsu.core.exceptions.WrongPasswordException
|
||||||
|
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
|
||||||
import org.koitharu.kotatsu.parsers.ErrorMessages.FILTER_BOTH_LOCALE_GENRES_NOT_SUPPORTED
|
import org.koitharu.kotatsu.parsers.ErrorMessages.FILTER_BOTH_LOCALE_GENRES_NOT_SUPPORTED
|
||||||
import org.koitharu.kotatsu.parsers.ErrorMessages.FILTER_BOTH_STATES_GENRES_NOT_SUPPORTED
|
import org.koitharu.kotatsu.parsers.ErrorMessages.FILTER_BOTH_STATES_GENRES_NOT_SUPPORTED
|
||||||
import org.koitharu.kotatsu.parsers.ErrorMessages.FILTER_MULTIPLE_GENRES_NOT_SUPPORTED
|
import org.koitharu.kotatsu.parsers.ErrorMessages.FILTER_MULTIPLE_GENRES_NOT_SUPPORTED
|
||||||
@@ -30,6 +32,8 @@ import org.koitharu.kotatsu.parsers.exception.AuthRequiredException
|
|||||||
import org.koitharu.kotatsu.parsers.exception.ContentUnavailableException
|
import org.koitharu.kotatsu.parsers.exception.ContentUnavailableException
|
||||||
import org.koitharu.kotatsu.parsers.exception.NotFoundException
|
import org.koitharu.kotatsu.parsers.exception.NotFoundException
|
||||||
import org.koitharu.kotatsu.parsers.exception.ParseException
|
import org.koitharu.kotatsu.parsers.exception.ParseException
|
||||||
|
import org.koitharu.kotatsu.parsers.exception.TooManyRequestExceptions
|
||||||
|
import org.koitharu.kotatsu.scrobbling.common.domain.ScrobblerAuthRequiredException
|
||||||
import java.net.SocketTimeoutException
|
import java.net.SocketTimeoutException
|
||||||
import java.net.UnknownHostException
|
import java.net.UnknownHostException
|
||||||
|
|
||||||
@@ -37,30 +41,47 @@ private const val MSG_NO_SPACE_LEFT = "No space left on device"
|
|||||||
private const val IMAGE_FORMAT_NOT_SUPPORTED = "Image format not supported"
|
private const val IMAGE_FORMAT_NOT_SUPPORTED = "Image format not supported"
|
||||||
|
|
||||||
fun Throwable.getDisplayMessage(resources: Resources): String = when (this) {
|
fun Throwable.getDisplayMessage(resources: Resources): String = when (this) {
|
||||||
|
is ScrobblerAuthRequiredException -> resources.getString(
|
||||||
|
R.string.scrobbler_auth_required,
|
||||||
|
resources.getString(scrobbler.titleResId),
|
||||||
|
)
|
||||||
|
|
||||||
is AuthRequiredException -> resources.getString(R.string.auth_required)
|
is AuthRequiredException -> resources.getString(R.string.auth_required)
|
||||||
is CloudFlareProtectedException -> resources.getString(R.string.captcha_required)
|
is CloudFlareProtectedException -> resources.getString(R.string.captcha_required)
|
||||||
is CloudFlareBlockedException -> resources.getString(R.string.blocked_by_server_message)
|
is CloudFlareBlockedException -> resources.getString(R.string.blocked_by_server_message)
|
||||||
is ActivityNotFoundException,
|
is ActivityNotFoundException,
|
||||||
is UnsupportedOperationException,
|
is UnsupportedOperationException,
|
||||||
-> resources.getString(R.string.operation_not_supported)
|
-> resources.getString(R.string.operation_not_supported)
|
||||||
|
|
||||||
|
is TooManyRequestExceptions -> {
|
||||||
|
val delay = getRetryDelay()
|
||||||
|
val formattedTime = if (delay > 0L && delay < Long.MAX_VALUE) {
|
||||||
|
resources.formatDurationShort(delay)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
if (formattedTime != null) {
|
||||||
|
resources.getString(R.string.too_many_requests_message_retry, formattedTime)
|
||||||
|
} else {
|
||||||
|
resources.getString(R.string.too_many_requests_message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
is TooManyRequestExceptions -> resources.getString(R.string.too_many_requests_message)
|
|
||||||
is UnsupportedFileException -> resources.getString(R.string.text_file_not_supported)
|
is UnsupportedFileException -> resources.getString(R.string.text_file_not_supported)
|
||||||
is BadBackupFormatException -> resources.getString(R.string.unsupported_backup_message)
|
is BadBackupFormatException -> resources.getString(R.string.unsupported_backup_message)
|
||||||
is FileNotFoundException -> resources.getString(R.string.file_not_found)
|
is FileNotFoundException -> resources.getString(R.string.file_not_found)
|
||||||
is AccessDeniedException -> resources.getString(R.string.no_access_to_file)
|
is AccessDeniedException -> resources.getString(R.string.no_access_to_file)
|
||||||
is EmptyHistoryException -> resources.getString(R.string.history_is_empty)
|
is EmptyHistoryException -> resources.getString(R.string.history_is_empty)
|
||||||
|
is ProxyConfigException -> resources.getString(R.string.invalid_proxy_configuration)
|
||||||
is SyncApiException,
|
is SyncApiException,
|
||||||
is ContentUnavailableException,
|
is ContentUnavailableException -> message
|
||||||
-> message
|
|
||||||
|
|
||||||
is ParseException -> shortMessage
|
is ParseException -> shortMessage
|
||||||
is UnknownHostException,
|
is UnknownHostException,
|
||||||
is SocketTimeoutException,
|
is SocketTimeoutException -> resources.getString(R.string.network_error)
|
||||||
-> resources.getString(R.string.network_error)
|
|
||||||
|
|
||||||
is NoDataReceivedException -> resources.getString(R.string.error_no_data_received)
|
is NoDataReceivedException -> resources.getString(R.string.error_no_data_received)
|
||||||
|
is IncompatiblePluginException -> resources.getString(R.string.plugin_incompatible)
|
||||||
is WrongPasswordException -> resources.getString(R.string.wrong_password)
|
is WrongPasswordException -> resources.getString(R.string.wrong_password)
|
||||||
is NotFoundException -> resources.getString(R.string.not_found_404)
|
is NotFoundException -> resources.getString(R.string.not_found_404)
|
||||||
is UnsupportedSourceException -> resources.getString(R.string.unsupported_source)
|
is UnsupportedSourceException -> resources.getString(R.string.unsupported_source)
|
||||||
@@ -79,7 +100,7 @@ fun Throwable.getDisplayIcon() = when (this) {
|
|||||||
is CloudFlareProtectedException -> R.drawable.ic_bot_large
|
is CloudFlareProtectedException -> R.drawable.ic_bot_large
|
||||||
is UnknownHostException,
|
is UnknownHostException,
|
||||||
is SocketTimeoutException,
|
is SocketTimeoutException,
|
||||||
-> R.drawable.ic_plug_large
|
is ProtocolException -> R.drawable.ic_plug_large
|
||||||
|
|
||||||
is CloudFlareBlockedException -> R.drawable.ic_denied_large
|
is CloudFlareBlockedException -> R.drawable.ic_denied_large
|
||||||
|
|
||||||
@@ -105,7 +126,25 @@ private fun getDisplayMessage(msg: String?, resources: Resources): String? = whe
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun Throwable.isReportable(): Boolean {
|
fun Throwable.isReportable(): Boolean {
|
||||||
return this is Error || this.javaClass in reportableExceptions
|
if (this is Error) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (this is CaughtException) {
|
||||||
|
return cause?.isReportable() == true
|
||||||
|
}
|
||||||
|
if (ExceptionResolver.canResolve(this)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (this is ParseException
|
||||||
|
|| this.isNetworkError()
|
||||||
|
|| this is CloudFlareBlockedException
|
||||||
|
|| this is CloudFlareProtectedException
|
||||||
|
|| this is BadBackupFormatException
|
||||||
|
|| this is WrongPasswordException
|
||||||
|
) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
fun Throwable.isNetworkError(): Boolean {
|
fun Throwable.isNetworkError(): Boolean {
|
||||||
@@ -117,15 +156,6 @@ fun Throwable.report() {
|
|||||||
exception.sendWithAcra()
|
exception.sendWithAcra()
|
||||||
}
|
}
|
||||||
|
|
||||||
private val reportableExceptions = arraySetOf<Class<*>>(
|
|
||||||
RuntimeException::class.java,
|
|
||||||
IllegalStateException::class.java,
|
|
||||||
IllegalArgumentException::class.java,
|
|
||||||
ConcurrentModificationException::class.java,
|
|
||||||
UnsupportedOperationException::class.java,
|
|
||||||
NoDataReceivedException::class.java,
|
|
||||||
)
|
|
||||||
|
|
||||||
fun Throwable.isWebViewUnavailable(): Boolean {
|
fun Throwable.isWebViewUnavailable(): Boolean {
|
||||||
val trace = stackTraceToString()
|
val trace = stackTraceToString()
|
||||||
return trace.contains("android.webkit.WebView.<init>")
|
return trace.contains("android.webkit.WebView.<init>")
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import androidx.core.net.toFile
|
|||||||
import okio.Source
|
import okio.Source
|
||||||
import okio.source
|
import okio.source
|
||||||
import okio.use
|
import okio.use
|
||||||
|
import org.jetbrains.annotations.Blocking
|
||||||
import org.koitharu.kotatsu.local.data.util.withExtraCloseable
|
import org.koitharu.kotatsu.local.data.util.withExtraCloseable
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.util.zip.ZipFile
|
import java.util.zip.ZipFile
|
||||||
@@ -12,6 +13,7 @@ import java.util.zip.ZipFile
|
|||||||
const val URI_SCHEME_FILE = "file"
|
const val URI_SCHEME_FILE = "file"
|
||||||
const val URI_SCHEME_ZIP = "file+zip"
|
const val URI_SCHEME_ZIP = "file+zip"
|
||||||
|
|
||||||
|
@Blocking
|
||||||
fun Uri.exists(): Boolean = when (scheme) {
|
fun Uri.exists(): Boolean = when (scheme) {
|
||||||
URI_SCHEME_FILE -> toFile().exists()
|
URI_SCHEME_FILE -> toFile().exists()
|
||||||
URI_SCHEME_ZIP -> {
|
URI_SCHEME_ZIP -> {
|
||||||
@@ -22,6 +24,7 @@ fun Uri.exists(): Boolean = when (scheme) {
|
|||||||
else -> unsupportedUri(this)
|
else -> unsupportedUri(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Blocking
|
||||||
fun Uri.isTargetNotEmpty(): Boolean = when (scheme) {
|
fun Uri.isTargetNotEmpty(): Boolean = when (scheme) {
|
||||||
URI_SCHEME_FILE -> toFile().isNotEmpty()
|
URI_SCHEME_FILE -> toFile().isNotEmpty()
|
||||||
URI_SCHEME_ZIP -> {
|
URI_SCHEME_ZIP -> {
|
||||||
@@ -32,6 +35,7 @@ fun Uri.isTargetNotEmpty(): Boolean = when (scheme) {
|
|||||||
else -> unsupportedUri(this)
|
else -> unsupportedUri(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Blocking
|
||||||
fun Uri.source(): Source = when (scheme) {
|
fun Uri.source(): Source = when (scheme) {
|
||||||
URI_SCHEME_FILE -> toFile().source()
|
URI_SCHEME_FILE -> toFile().source()
|
||||||
URI_SCHEME_ZIP -> {
|
URI_SCHEME_ZIP -> {
|
||||||
@@ -45,6 +49,8 @@ fun Uri.source(): Source = when (scheme) {
|
|||||||
|
|
||||||
fun File.toZipUri(entryName: String): Uri = Uri.parse("$URI_SCHEME_ZIP://$absolutePath#$entryName")
|
fun File.toZipUri(entryName: String): Uri = Uri.parse("$URI_SCHEME_ZIP://$absolutePath#$entryName")
|
||||||
|
|
||||||
|
fun String.toUriOrNull() = if (isEmpty()) null else Uri.parse(this)
|
||||||
|
|
||||||
private fun unsupportedUri(uri: Uri): Nothing {
|
private fun unsupportedUri(uri: Uri): Nothing {
|
||||||
throw IllegalArgumentException("Bad uri $uri: only schemes $URI_SCHEME_FILE and $URI_SCHEME_ZIP are supported")
|
throw IllegalArgumentException("Bad uri $uri: only schemes $URI_SCHEME_FILE and $URI_SCHEME_ZIP are supported")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import android.view.ViewGroup
|
|||||||
import android.widget.Checkable
|
import android.widget.Checkable
|
||||||
import androidx.appcompat.widget.ActionMenuView
|
import androidx.appcompat.widget.ActionMenuView
|
||||||
import androidx.appcompat.widget.Toolbar
|
import androidx.appcompat.widget.Toolbar
|
||||||
import androidx.core.view.SoftwareKeyboardControllerCompat
|
|
||||||
import androidx.core.view.children
|
import androidx.core.view.children
|
||||||
import androidx.core.view.descendants
|
import androidx.core.view.descendants
|
||||||
import androidx.core.view.isVisible
|
import androidx.core.view.isVisible
|
||||||
@@ -23,14 +22,6 @@ import com.google.android.material.slider.Slider
|
|||||||
import com.google.android.material.tabs.TabLayout
|
import com.google.android.material.tabs.TabLayout
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
fun View.hideKeyboard() {
|
|
||||||
SoftwareKeyboardControllerCompat(this).hide()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun View.showKeyboard() {
|
|
||||||
SoftwareKeyboardControllerCompat(this).show()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun View.hasGlobalPoint(x: Int, y: Int): Boolean {
|
fun View.hasGlobalPoint(x: Int, y: Int): Boolean {
|
||||||
if (visibility != View.VISIBLE) {
|
if (visibility != View.VISIBLE) {
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package org.koitharu.kotatsu.core.util.progress
|
||||||
|
|
||||||
|
import android.os.SystemClock
|
||||||
|
import androidx.annotation.AnyThread
|
||||||
|
import androidx.collection.CircularArray
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import kotlin.math.roundToLong
|
||||||
|
|
||||||
|
class RealtimeEtaEstimator {
|
||||||
|
|
||||||
|
private val ticks = CircularArray<Tick>(MAX_TICKS)
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var lastChange = 0L
|
||||||
|
|
||||||
|
@AnyThread
|
||||||
|
fun onProgressChanged(value: Int, total: Int) {
|
||||||
|
if (total <= 0 || value > total) {
|
||||||
|
reset()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val tick = Tick(value, total, SystemClock.elapsedRealtime())
|
||||||
|
synchronized(this) {
|
||||||
|
if (!ticks.isEmpty()) {
|
||||||
|
val last = ticks.last
|
||||||
|
if (last.value == tick.value && last.total == tick.total) {
|
||||||
|
ticks.popLast()
|
||||||
|
} else {
|
||||||
|
lastChange = tick.timestamp
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
lastChange = tick.timestamp
|
||||||
|
}
|
||||||
|
ticks.addLast(tick)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@AnyThread
|
||||||
|
fun reset() = synchronized(this) {
|
||||||
|
ticks.clear()
|
||||||
|
lastChange = 0L
|
||||||
|
}
|
||||||
|
|
||||||
|
@AnyThread
|
||||||
|
fun getEta(): Long {
|
||||||
|
val etl = getEstimatedTimeLeft()
|
||||||
|
return if (etl == NO_TIME || etl > MAX_TIME) NO_TIME else System.currentTimeMillis() + etl
|
||||||
|
}
|
||||||
|
|
||||||
|
@AnyThread
|
||||||
|
fun isStuck(): Boolean = synchronized(this) {
|
||||||
|
return ticks.size() >= MIN_ESTIMATE_TICKS && (SystemClock.elapsedRealtime() - lastChange) > STUCK_DELAY
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getEstimatedTimeLeft(): Long = synchronized(this) {
|
||||||
|
val ticksCount = ticks.size()
|
||||||
|
if (ticksCount < MIN_ESTIMATE_TICKS) {
|
||||||
|
return NO_TIME
|
||||||
|
}
|
||||||
|
val percentDiff = ticks.last.percent - ticks.first.percent
|
||||||
|
val timeDiff = ticks.last.timestamp - ticks.first.timestamp
|
||||||
|
if (percentDiff <= 0 || timeDiff <= 0) {
|
||||||
|
return NO_TIME
|
||||||
|
}
|
||||||
|
val averageTime = timeDiff / percentDiff
|
||||||
|
val percentLeft = 1.0 - ticks.last.percent
|
||||||
|
return (percentLeft * averageTime).roundToLong()
|
||||||
|
}
|
||||||
|
|
||||||
|
private class Tick(
|
||||||
|
@JvmField val value: Int,
|
||||||
|
@JvmField val total: Int,
|
||||||
|
@JvmField val timestamp: Long,
|
||||||
|
) {
|
||||||
|
|
||||||
|
init {
|
||||||
|
require(total > 0) { "total = $total" }
|
||||||
|
require(value >= 0) { "value = $value" }
|
||||||
|
require(value <= total) { "total = $total, value = $value" }
|
||||||
|
}
|
||||||
|
|
||||||
|
@JvmField
|
||||||
|
val percent = value.toDouble() / total.toDouble()
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
|
||||||
|
const val MAX_TICKS = 20
|
||||||
|
const val MIN_ESTIMATE_TICKS = 4
|
||||||
|
const val NO_TIME = -1L
|
||||||
|
const val STUCK_DELAY = 10_000L
|
||||||
|
val MAX_TIME = TimeUnit.DAYS.toMillis(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.util.progress
|
|
||||||
|
|
||||||
import android.os.SystemClock
|
|
||||||
import androidx.collection.IntList
|
|
||||||
import androidx.collection.MutableIntList
|
|
||||||
import java.util.concurrent.TimeUnit
|
|
||||||
import kotlin.math.roundToInt
|
|
||||||
import kotlin.math.roundToLong
|
|
||||||
|
|
||||||
private const val MIN_ESTIMATE_TICKS = 4
|
|
||||||
private const val NO_TIME = -1L
|
|
||||||
|
|
||||||
class TimeLeftEstimator {
|
|
||||||
|
|
||||||
private var times = MutableIntList()
|
|
||||||
private var lastTick: Tick? = null
|
|
||||||
private val tooLargeTime = TimeUnit.DAYS.toMillis(1)
|
|
||||||
|
|
||||||
fun tick(value: Int, total: Int) {
|
|
||||||
if (total < 0) {
|
|
||||||
emptyTick()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (lastTick?.value == value) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val tick = Tick(value, total, SystemClock.elapsedRealtime())
|
|
||||||
lastTick?.let {
|
|
||||||
val ticksCount = value - it.value
|
|
||||||
times.add(((tick.time - it.time) / ticksCount.toDouble()).roundToInt())
|
|
||||||
}
|
|
||||||
lastTick = tick
|
|
||||||
}
|
|
||||||
|
|
||||||
fun emptyTick() {
|
|
||||||
lastTick = null
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getEstimatedTimeLeft(): Long {
|
|
||||||
val progress = lastTick ?: return NO_TIME
|
|
||||||
if (times.size < MIN_ESTIMATE_TICKS) {
|
|
||||||
return NO_TIME
|
|
||||||
}
|
|
||||||
val timePerTick = times.average()
|
|
||||||
val ticksLeft = progress.total - progress.value
|
|
||||||
val eta = (ticksLeft * timePerTick).roundToLong()
|
|
||||||
return if (eta < tooLargeTime) eta else NO_TIME
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getEta(): Long {
|
|
||||||
val etl = getEstimatedTimeLeft()
|
|
||||||
return if (etl == NO_TIME) NO_TIME else System.currentTimeMillis() + etl
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun IntList.average(): Double {
|
|
||||||
if (isEmpty()) {
|
|
||||||
return 0.0
|
|
||||||
}
|
|
||||||
var acc = 0L
|
|
||||||
forEach { acc += it }
|
|
||||||
return acc / size.toDouble()
|
|
||||||
}
|
|
||||||
|
|
||||||
private class Tick(
|
|
||||||
@JvmField val value: Int,
|
|
||||||
@JvmField val total: Int,
|
|
||||||
@JvmField val time: Long,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -27,7 +27,7 @@ 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.util.recoverNotNull
|
import org.koitharu.kotatsu.parsers.util.recoverNotNull
|
||||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||||
import org.koitharu.kotatsu.tracker.domain.Tracker
|
import org.koitharu.kotatsu.tracker.domain.CheckNewChaptersUseCase
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Provider
|
import javax.inject.Provider
|
||||||
|
|
||||||
@@ -37,7 +37,7 @@ class DetailsLoadUseCase @Inject constructor(
|
|||||||
private val mangaRepositoryFactory: MangaRepository.Factory,
|
private val mangaRepositoryFactory: MangaRepository.Factory,
|
||||||
private val recoverUseCase: RecoverMangaUseCase,
|
private val recoverUseCase: RecoverMangaUseCase,
|
||||||
private val imageGetter: Html.ImageGetter,
|
private val imageGetter: Html.ImageGetter,
|
||||||
private val trackerProvider: Provider<Tracker>,
|
private val newChaptersUseCaseProvider: Provider<CheckNewChaptersUseCase>,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
operator fun invoke(intent: MangaIntent): Flow<MangaDetails> = channelFlow {
|
operator fun invoke(intent: MangaIntent): Flow<MangaDetails> = channelFlow {
|
||||||
@@ -55,11 +55,32 @@ class DetailsLoadUseCase @Inject constructor(
|
|||||||
try {
|
try {
|
||||||
val details = getDetails(manga)
|
val details = getDetails(manga)
|
||||||
launch { updateTracker(details) }
|
launch { updateTracker(details) }
|
||||||
send(MangaDetails(details, local?.peek(), details.description?.parseAsHtml(withImages = false), false))
|
send(
|
||||||
send(MangaDetails(details, local?.await(), details.description?.parseAsHtml(withImages = true), true))
|
MangaDetails(
|
||||||
|
details,
|
||||||
|
local?.peek(),
|
||||||
|
details.description?.parseAsHtml(withImages = false)?.trim(),
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
send(
|
||||||
|
MangaDetails(
|
||||||
|
details,
|
||||||
|
local?.await(),
|
||||||
|
details.description?.parseAsHtml(withImages = true)?.trim(),
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
)
|
||||||
} catch (e: IOException) {
|
} catch (e: IOException) {
|
||||||
local?.await()?.manga?.also { localManga ->
|
local?.await()?.manga?.also { localManga ->
|
||||||
send(MangaDetails(localManga, null, localManga.description?.parseAsHtml(withImages = false), true))
|
send(
|
||||||
|
MangaDetails(
|
||||||
|
localManga,
|
||||||
|
null,
|
||||||
|
localManga.description?.parseAsHtml(withImages = false)?.trim(),
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
)
|
||||||
} ?: close(e)
|
} ?: close(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -97,7 +118,7 @@ class DetailsLoadUseCase @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun updateTracker(details: Manga) = runCatchingCancellable {
|
private suspend fun updateTracker(details: Manga) = runCatchingCancellable {
|
||||||
trackerProvider.get().syncWithDetails(details)
|
newChaptersUseCaseProvider.get()(details)
|
||||||
}.onFailure { e ->
|
}.onFailure { e ->
|
||||||
e.printStackTraceDebug()
|
e.printStackTraceDebug()
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user