Compare commits
132 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c51da5a9d5 | ||
|
|
bcfce29610 | ||
|
|
a87d18fae3 | ||
|
|
bbd421445c | ||
|
|
f4e3d797dc | ||
|
|
bd65cbb8b8 | ||
|
|
7d1f81607a | ||
|
|
3b6cd0ea7f | ||
|
|
aff70d8519 | ||
|
|
8a74faa4f0 | ||
|
|
c1ac207809 | ||
|
|
e34e745c84 | ||
|
|
50dd119ab5 | ||
|
|
d0ef177d56 | ||
|
|
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 |
1
.idea/gradle.xml
generated
1
.idea/gradle.xml
generated
@@ -4,6 +4,7 @@
|
||||
<component name="GradleSettings">
|
||||
<option name="linkedExternalProjectsSettings">
|
||||
<GradleProjectSettings>
|
||||
<option name="testRunner" value="CHOOSE_PER_TEST" />
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
||||
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
|
||||
<option name="modules">
|
||||
|
||||
@@ -8,16 +8,16 @@ plugins {
|
||||
}
|
||||
|
||||
android {
|
||||
compileSdk = 34
|
||||
buildToolsVersion = '34.0.0'
|
||||
compileSdk = 35
|
||||
buildToolsVersion = '35.0.0'
|
||||
namespace = 'org.koitharu.kotatsu'
|
||||
|
||||
defaultConfig {
|
||||
applicationId 'org.koitharu.kotatsu'
|
||||
minSdk = 21
|
||||
targetSdk = 35
|
||||
versionCode = 657
|
||||
versionName = '7.4'
|
||||
versionCode = 667
|
||||
versionName = '7.5.1'
|
||||
generatedDensities = []
|
||||
testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner'
|
||||
ksp {
|
||||
@@ -56,6 +56,7 @@ android {
|
||||
freeCompilerArgs += [
|
||||
'-opt-in=kotlin.ExperimentalStdlibApi',
|
||||
'-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi',
|
||||
'-opt-in=kotlinx.coroutines.ExperimentalForInheritanceCoroutinesApi',
|
||||
'-opt-in=kotlinx.coroutines.FlowPreview',
|
||||
'-opt-in=kotlin.contracts.ExperimentalContracts',
|
||||
'-opt-in=coil.annotation.ExperimentalCoilApi',
|
||||
@@ -82,23 +83,23 @@ afterEvaluate {
|
||||
}
|
||||
dependencies {
|
||||
//noinspection GradleDependency
|
||||
implementation('com.github.KotatsuApp:kotatsu-parsers:a9fc534ea7') {
|
||||
implementation('com.github.KotatsuApp:kotatsu-parsers:ad726a3fd7') {
|
||||
exclude group: 'org.json', module: 'json'
|
||||
}
|
||||
|
||||
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.4'
|
||||
implementation 'org.jetbrains.kotlin:kotlin-stdlib:2.0.10-RC'
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0-RC'
|
||||
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.2'
|
||||
implementation 'org.jetbrains.kotlin:kotlin-stdlib:2.0.10'
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0-RC.2'
|
||||
|
||||
implementation 'androidx.appcompat:appcompat:1.7.0'
|
||||
implementation 'androidx.core:core-ktx:1.13.1'
|
||||
implementation 'androidx.activity:activity-ktx:1.9.1'
|
||||
implementation 'androidx.fragment:fragment-ktx:1.8.2'
|
||||
implementation 'androidx.activity:activity-ktx:1.9.2'
|
||||
implementation 'androidx.fragment:fragment-ktx:1.8.3'
|
||||
implementation 'androidx.transition:transition-ktx:1.5.1'
|
||||
implementation 'androidx.collection:collection-ktx:1.4.2'
|
||||
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.4'
|
||||
implementation 'androidx.lifecycle:lifecycle-service:2.8.4'
|
||||
implementation 'androidx.lifecycle:lifecycle-process:2.8.4'
|
||||
implementation 'androidx.collection:collection-ktx:1.4.3'
|
||||
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.5'
|
||||
implementation 'androidx.lifecycle:lifecycle-service:2.8.5'
|
||||
implementation 'androidx.lifecycle:lifecycle-process:2.8.5'
|
||||
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
|
||||
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
|
||||
implementation 'androidx.recyclerview:recyclerview:1.3.2'
|
||||
@@ -106,12 +107,12 @@ dependencies {
|
||||
implementation 'androidx.preference:preference-ktx:1.2.1'
|
||||
implementation 'androidx.biometric:biometric-ktx:1.2.0-alpha05'
|
||||
implementation 'com.google.android.material:material:1.12.0'
|
||||
implementation 'androidx.lifecycle:lifecycle-common-java8:2.8.4'
|
||||
implementation 'androidx.lifecycle:lifecycle-common-java8:2.8.5'
|
||||
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
|
||||
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: 'org.checkerframework', module: 'checker-qual'
|
||||
exclude group: 'com.google.j2objc', module: 'j2objc-annotations'
|
||||
@@ -129,25 +130,23 @@ dependencies {
|
||||
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.2'
|
||||
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:4.3.2'
|
||||
|
||||
implementation 'com.google.dagger:hilt-android:2.51.1'
|
||||
kapt 'com.google.dagger:hilt-compiler:2.51.1'
|
||||
implementation 'com.google.dagger:hilt-android:2.52'
|
||||
kapt 'com.google.dagger:hilt-compiler:2.52'
|
||||
implementation 'androidx.hilt:hilt-work:1.2.0'
|
||||
kapt 'androidx.hilt:hilt-compiler:1.2.0'
|
||||
|
||||
implementation 'io.coil-kt:coil-base:2.7.0'
|
||||
implementation 'io.coil-kt:coil-svg:2.7.0'
|
||||
implementation 'com.github.KotatsuApp:subsampling-scale-image-view:882bc0620c'
|
||||
implementation 'com.github.KotatsuApp:subsampling-scale-image-view:4ec7176962'
|
||||
implementation 'com.github.solkin:disk-lru-cache:1.4'
|
||||
implementation 'io.noties.markwon:core:4.6.2'
|
||||
|
||||
implementation 'ch.acra:acra-http: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'
|
||||
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
@@ -164,6 +163,6 @@ dependencies {
|
||||
androidTestImplementation 'androidx.room:room-testing:2.6.1'
|
||||
androidTestImplementation 'com.squareup.moshi:moshi-kotlin:1.15.1'
|
||||
|
||||
androidTestImplementation 'com.google.dagger:hilt-android-testing:2.51.1'
|
||||
kaptAndroidTest 'com.google.dagger:hilt-android-compiler:2.51.1'
|
||||
androidTestImplementation 'com.google.dagger:hilt-android-testing:2.52'
|
||||
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.bouncycastle.**
|
||||
-dontwarn org.openjsse.**
|
||||
-dontwarn com.google.j2objc.annotations.**
|
||||
|
||||
-keep class org.koitharu.kotatsu.core.exceptions.* { *; }
|
||||
-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.parsers.MangaLoaderContext
|
||||
import org.koitharu.kotatsu.reader.domain.PageLoader
|
||||
import org.koitharu.kotatsu.reader.ui.ReaderViewModel
|
||||
|
||||
class KotatsuApp : BaseApp() {
|
||||
|
||||
@@ -30,6 +31,7 @@ class KotatsuApp : BaseApp() {
|
||||
.setClassInstanceLimit(PagesCache::class.java, 1)
|
||||
.setClassInstanceLimit(MangaLoaderContext::class.java, 1)
|
||||
.setClassInstanceLimit(PageLoader::class.java, 1)
|
||||
.setClassInstanceLimit(ReaderViewModel::class.java, 1)
|
||||
.penaltyLog()
|
||||
.build(),
|
||||
)
|
||||
|
||||
@@ -7,8 +7,8 @@ import org.koitharu.kotatsu.core.parser.MangaDataRepository
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.details.domain.ProgressUpdateUseCase
|
||||
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.list.domain.ReadingProgress.Companion.PROGRESS_NONE
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
|
||||
@@ -7,7 +7,7 @@ import androidx.core.text.inSpans
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import coil.ImageLoader
|
||||
import coil.request.ImageRequest
|
||||
import coil.transform.CircleCropTransformation
|
||||
import coil.transform.RoundedCornersTransformation
|
||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.model.getTitle
|
||||
@@ -75,7 +75,7 @@ fun alternativeAD(
|
||||
.fallback(R.drawable.ic_web)
|
||||
.error(R.drawable.ic_web)
|
||||
.source(item.manga.source)
|
||||
.transformations(CircleCropTransformation())
|
||||
.transformations(RoundedCornersTransformation(context.resources.getDimension(R.dimen.chip_icon_corner)))
|
||||
.allowRgb565(true)
|
||||
.enqueueWith(coil)
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import androidx.activity.viewModels
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.view.updatePadding
|
||||
import coil.ImageLoader
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.koitharu.kotatsu.R
|
||||
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.ui.BaseActivity
|
||||
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.util.ext.DIALOG_THEME_CENTERED
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
||||
import org.koitharu.kotatsu.databinding.ActivityAlternativesBinding
|
||||
@@ -89,22 +88,23 @@ class AlternativesActivity : BaseActivity<ActivityAlternativesBinding>(),
|
||||
}
|
||||
|
||||
private fun confirmMigration(target: Manga) {
|
||||
MaterialAlertDialogBuilder(this, DIALOG_THEME_CENTERED)
|
||||
.setIcon(R.drawable.ic_replace)
|
||||
.setTitle(R.string.manga_migration)
|
||||
.setMessage(
|
||||
buildAlertDialog(this, isCentered = true) {
|
||||
setIcon(R.drawable.ic_replace)
|
||||
setTitle(R.string.manga_migration)
|
||||
setMessage(
|
||||
getString(
|
||||
R.string.migrate_confirmation,
|
||||
viewModel.manga.title,
|
||||
viewModel.manga.source.getTitle(this),
|
||||
viewModel.manga.source.getTitle(context),
|
||||
target.title,
|
||||
target.source.getTitle(this),
|
||||
target.source.getTitle(context),
|
||||
),
|
||||
)
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.setPositiveButton(R.string.migrate) { _, _ ->
|
||||
setNegativeButton(android.R.string.cancel, null)
|
||||
setPositiveButton(R.string.migrate) { _, _ ->
|
||||
viewModel.migrate(target)
|
||||
}.show()
|
||||
}
|
||||
}.show()
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -46,7 +46,7 @@ class AllBookmarksFragment :
|
||||
BaseFragment<FragmentListSimpleBinding>(),
|
||||
ListStateHolderListener,
|
||||
OnListItemClickListener<Bookmark>,
|
||||
ListSelectionController.Callback2,
|
||||
ListSelectionController.Callback,
|
||||
FastScroller.FastScrollListener, ListHeaderClickListener {
|
||||
|
||||
@Inject
|
||||
|
||||
@@ -45,7 +45,7 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
|
||||
}
|
||||
val mangaSource = MangaSource(intent?.getStringExtra(EXTRA_SOURCE))
|
||||
val repository = mangaRepositoryFactory.create(mangaSource) as? ParserMangaRepository
|
||||
repository?.headers?.get(CommonHeaders.USER_AGENT)
|
||||
repository?.getRequestHeaders()?.get(CommonHeaders.USER_AGENT)
|
||||
viewBinding.webView.configureForParser(userAgent)
|
||||
CookieManager.getInstance().setAcceptThirdPartyCookies(viewBinding.webView, true)
|
||||
viewBinding.webView.webViewClient = BrowserClient(this)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.koitharu.kotatsu.browser.cloudflare
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
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.cookies.MutableCookieJar
|
||||
import org.koitharu.kotatsu.core.ui.BaseActivity
|
||||
import org.koitharu.kotatsu.core.util.TaggedActivityResult
|
||||
import org.koitharu.kotatsu.core.util.ext.configureForParser
|
||||
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
@@ -180,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 {
|
||||
return newIntent(context, input)
|
||||
}
|
||||
|
||||
override fun parseResult(resultCode: Int, intent: Intent?): TaggedActivityResult {
|
||||
return TaggedActivityResult(TAG, resultCode)
|
||||
override fun parseResult(resultCode: Int, intent: Intent?): Boolean {
|
||||
return resultCode == Activity.RESULT_OK
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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?
|
||||
}
|
||||
}
|
||||
@@ -4,36 +4,59 @@ import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room.RawQuery
|
||||
import androidx.room.Transaction
|
||||
import androidx.sqlite.db.SupportSQLiteQuery
|
||||
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.TrackLogWithManga
|
||||
|
||||
@Dao
|
||||
interface TrackLogsDao {
|
||||
abstract class TrackLogsDao : MangaQueryBuilder.ConditionCallback {
|
||||
|
||||
@Transaction
|
||||
@Query("SELECT * FROM track_logs ORDER BY created_at DESC LIMIT :limit OFFSET 0")
|
||||
fun observeAll(limit: Int): Flow<List<TrackLogWithManga>>
|
||||
fun observeAll(
|
||||
limit: Int,
|
||||
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")
|
||||
fun observeUnreadCount(): Flow<Int>
|
||||
abstract fun observeUnreadCount(): Flow<Int>
|
||||
|
||||
@Query("DELETE FROM track_logs")
|
||||
suspend fun clear()
|
||||
abstract suspend fun clear()
|
||||
|
||||
@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)
|
||||
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)")
|
||||
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)")
|
||||
suspend fun trim(size: Int)
|
||||
abstract suspend fun trim(size: Int)
|
||||
|
||||
@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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.widget.Toast
|
||||
import androidx.activity.result.ActivityResultCallback
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.activity.result.ActivityResultCaller
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.collection.MutableScatterMap
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.fragment.app.FragmentManager
|
||||
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.alternatives.ui.AlternativesActivity
|
||||
import org.koitharu.kotatsu.browser.BrowserActivity
|
||||
import org.koitharu.kotatsu.browser.cloudflare.CloudFlareActivity
|
||||
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.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.ui.BaseActivity.BaseActivityEntryPoint
|
||||
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.parsers.exception.AuthRequiredException
|
||||
import org.koitharu.kotatsu.parsers.exception.NotFoundException
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
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 java.security.cert.CertPathValidatorException
|
||||
import javax.inject.Provider
|
||||
import javax.net.ssl.SSLException
|
||||
import kotlin.coroutines.Continuation
|
||||
import kotlin.coroutines.resume
|
||||
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 activity: FragmentActivity?
|
||||
private val fragment: Fragment?
|
||||
private val sourceAuthContract: ActivityResultLauncher<MangaSource>
|
||||
private val cloudflareContract: ActivityResultLauncher<CloudFlareProtectedException>
|
||||
|
||||
val context: Context?
|
||||
get() = activity ?: fragment?.context
|
||||
|
||||
constructor(activity: FragmentActivity) {
|
||||
this.activity = activity
|
||||
fragment = null
|
||||
sourceAuthContract = activity.registerForActivityResult(SourceAuthActivity.Contract(), this)
|
||||
cloudflareContract = activity.registerForActivityResult(CloudFlareActivity.Contract(), this)
|
||||
private val sourceAuthContract = host.registerForActivityResult(SourceAuthActivity.Contract()) {
|
||||
handleActivityResult(SourceAuthActivity.TAG, it)
|
||||
}
|
||||
|
||||
constructor(fragment: Fragment) {
|
||||
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)
|
||||
private val cloudflareContract = host.registerForActivityResult(CloudFlareActivity.Contract()) {
|
||||
handleActivityResult(CloudFlareActivity.TAG, it)
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -74,6 +62,13 @@ class ExceptionResolver : ActivityResultCallback<TaggedActivityResult> {
|
||||
false
|
||||
}
|
||||
|
||||
is ProxyConfigException -> {
|
||||
host.withContext {
|
||||
startActivity(SettingsActivity.newProxySettingsIntent(this))
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
is NotFoundException -> {
|
||||
openInBrowser(e.url)
|
||||
false
|
||||
@@ -84,6 +79,20 @@ class ExceptionResolver : ActivityResultCallback<TaggedActivityResult> {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -97,21 +106,20 @@ class ExceptionResolver : ActivityResultCallback<TaggedActivityResult> {
|
||||
sourceAuthContract.launch(source)
|
||||
}
|
||||
|
||||
private fun openInBrowser(url: String) {
|
||||
context?.run {
|
||||
startActivity(BrowserActivity.newIntent(this, url, null, null))
|
||||
}
|
||||
private fun openInBrowser(url: String) = host.withContext {
|
||||
startActivity(BrowserActivity.newIntent(this, url, null, null))
|
||||
}
|
||||
|
||||
private fun openAlternatives(manga: Manga) {
|
||||
context?.run {
|
||||
startActivity(AlternativesActivity.newIntent(this, manga))
|
||||
}
|
||||
private fun openAlternatives(manga: Manga) = host.withContext {
|
||||
startActivity(AlternativesActivity.newIntent(this, manga))
|
||||
}
|
||||
|
||||
private fun handleActivityResult(tag: String, result: Boolean) {
|
||||
continuations.remove(tag)?.resume(result)
|
||||
}
|
||||
|
||||
private fun showSslErrorDialog() {
|
||||
val ctx = context ?: return
|
||||
val settings = getAppSettings(ctx)
|
||||
val ctx = host.getContext() ?: return
|
||||
if (settings.isSSLBypassEnabled) {
|
||||
Toast.makeText(ctx, R.string.operation_not_supported, Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
@@ -127,23 +135,38 @@ class ExceptionResolver : ActivityResultCallback<TaggedActivityResult> {
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun getAppSettings(context: Context): AppSettings {
|
||||
return EntryPointAccessors.fromApplication<BaseActivityEntryPoint>(context).settings
|
||||
private inline fun Host.withContext(block: Context.() -> Unit) {
|
||||
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 {
|
||||
|
||||
@StringRes
|
||||
fun getResolveStringId(e: Throwable) = when (e) {
|
||||
is CloudFlareProtectedException -> R.string.captcha_solve
|
||||
is ScrobblerAuthRequiredException,
|
||||
is AuthRequiredException -> R.string.sign_in
|
||||
|
||||
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 SSLException,
|
||||
is CertPathValidatorException -> R.string.fix
|
||||
|
||||
is ProxyConfigException -> R.string.settings
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
import okio.IOException
|
||||
import org.koitharu.kotatsu.core.exceptions.ProxyConfigException
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import java.io.IOException
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Proxy
|
||||
import java.net.ProxySelector
|
||||
@@ -31,9 +32,12 @@ class AppProxySelector(
|
||||
val type = settings.proxyType
|
||||
val address = settings.proxyAddress
|
||||
val port = settings.proxyPort
|
||||
if (type == Proxy.Type.DIRECT || address.isNullOrEmpty() || port == 0) {
|
||||
if (type == Proxy.Type.DIRECT) {
|
||||
return Proxy.NO_PROXY
|
||||
}
|
||||
if (address.isNullOrEmpty() || port == 0) {
|
||||
throw ProxyConfigException()
|
||||
}
|
||||
cachedProxy?.let {
|
||||
val addr = it.address() as? InetSocketAddress
|
||||
if (addr != null && it.type() == type && addr.port == port && addr.hostString == address) {
|
||||
|
||||
@@ -38,7 +38,7 @@ class CommonHeadersInterceptor @Inject constructor(
|
||||
null
|
||||
}
|
||||
val headersBuilder = request.headers.newBuilder()
|
||||
repository?.headers?.let {
|
||||
repository?.getRequestHeaders()?.let {
|
||||
headersBuilder.mergeWith(it, replaceExisting = false)
|
||||
}
|
||||
if (headersBuilder[CommonHeaders.USER_AGENT] == null) {
|
||||
|
||||
@@ -3,28 +3,27 @@ package org.koitharu.kotatsu.core.network
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
import okhttp3.internal.closeQuietly
|
||||
import org.koitharu.kotatsu.core.exceptions.TooManyRequestExceptions
|
||||
import java.time.Instant
|
||||
import org.koitharu.kotatsu.parsers.exception.TooManyRequestExceptions
|
||||
import java.time.ZonedDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class RateLimitInterceptor : Interceptor {
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val response = chain.proceed(chain.request())
|
||||
if (response.code == 429) {
|
||||
val retryDate = response.header(CommonHeaders.RETRY_AFTER)?.parseRetryDate()
|
||||
val request = response.request
|
||||
response.closeQuietly()
|
||||
throw TooManyRequestExceptions(
|
||||
url = request.url.toString(),
|
||||
retryAt = retryDate,
|
||||
retryAfter = response.header(CommonHeaders.RETRY_AFTER)?.parseRetryAfter() ?: 0L,
|
||||
)
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
private fun String.parseRetryDate(): Instant? {
|
||||
return toLongOrNull()?.let { Instant.now().plusSeconds(it) }
|
||||
?: ZonedDateTime.parse(this, DateTimeFormatter.RFC_1123_DATE_TIME).toInstant()
|
||||
private fun String.parseRetryAfter(): Long {
|
||||
return toLongOrNull()?.let { TimeUnit.SECONDS.toMillis(it) }
|
||||
?: ZonedDateTime.parse(this, DateTimeFormatter.RFC_1123_DATE_TIME).toInstant().toEpochMilli()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,9 @@ class NetworkState(
|
||||
|
||||
private val callback = NetworkCallbackImpl()
|
||||
|
||||
override val value: Boolean
|
||||
get() = connectivityManager.isOnline(settings)
|
||||
|
||||
@Synchronized
|
||||
override fun onActive() {
|
||||
invalidate()
|
||||
|
||||
@@ -46,6 +46,7 @@ class MangaDataRepository @Inject constructor(
|
||||
cfBrightness = colorFilter?.brightness ?: 0f,
|
||||
cfContrast = colorFilter?.contrast ?: 0f,
|
||||
cfInvert = colorFilter?.isInverted ?: false,
|
||||
cfGrayscale = colorFilter?.isGrayscale ?: false,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package org.koitharu.kotatsu.core.parser
|
||||
|
||||
import okhttp3.Headers
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
import org.koitharu.kotatsu.core.cache.MemoryContentCache
|
||||
@@ -65,9 +64,6 @@ class ParserMangaRepository(
|
||||
val domains: Array<out String>
|
||||
get() = parser.configKeyDomain.presetValues
|
||||
|
||||
val headers: Headers
|
||||
get() = parser.headers
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
return if (parser is Interceptor) {
|
||||
parser.intercept(chain)
|
||||
@@ -112,6 +108,8 @@ class ParserMangaRepository(
|
||||
|
||||
fun getAuthProvider(): MangaParserAuthProvider? = parser as? MangaParserAuthProvider
|
||||
|
||||
fun getRequestHeaders() = parser.getRequestHeaders()
|
||||
|
||||
fun getConfigKeys(): List<ConfigKey<*>> = ArrayList<ConfigKey<*>>().also {
|
||||
parser.onCreateConfig(it)
|
||||
}
|
||||
|
||||
@@ -1,19 +1,12 @@
|
||||
package org.koitharu.kotatsu.core.parser.external
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.database.Cursor
|
||||
import androidx.collection.ArraySet
|
||||
import androidx.core.database.getStringOrNull
|
||||
import androidx.core.net.toUri
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
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.ifNullOrEmpty
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
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
|
||||
@@ -21,9 +14,6 @@ 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
|
||||
|
||||
@@ -33,232 +23,58 @@ class ExternalMangaRepository(
|
||||
cache: MemoryContentCache,
|
||||
) : CachingMangaRepository(cache) {
|
||||
|
||||
private val capabilities by lazy { queryCapabilities() }
|
||||
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.Default) {
|
||||
val uri = "content://${source.authority}/manga".toUri().buildUpon()
|
||||
uri.appendQueryParameter("offset", offset.toString())
|
||||
when (filter) {
|
||||
is MangaListFilter.Advanced -> {
|
||||
filter.tags.forEach { uri.appendQueryParameter("tag_include", it.key) }
|
||||
filter.tagsExclude.forEach { uri.appendQueryParameter("tag_exclude", it.key) }
|
||||
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
|
||||
}
|
||||
contentResolver.query(uri.build(), null, null, null, filter?.sortOrder?.name)?.use { cursor ->
|
||||
val result = ArrayList<Manga>(cursor.count)
|
||||
if (cursor.moveToFirst()) {
|
||||
do {
|
||||
result += cursor.getManga()
|
||||
} while (cursor.moveToNext())
|
||||
}
|
||||
result
|
||||
}.orEmpty()
|
||||
runInterruptible(Dispatchers.IO) {
|
||||
contentSource.getList(offset, filter)
|
||||
}
|
||||
|
||||
override suspend fun getDetailsImpl(manga: Manga): Manga = coroutineScope {
|
||||
val chapters = async { queryChapters(manga.url) }
|
||||
val details = queryDetails(manga.url)
|
||||
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.await(),
|
||||
source = source,
|
||||
)
|
||||
override suspend fun getDetailsImpl(manga: Manga): Manga = runInterruptible(Dispatchers.IO) {
|
||||
contentSource.getDetails(manga)
|
||||
}
|
||||
|
||||
override suspend fun getPagesImpl(chapter: MangaChapter): List<MangaPage> = runInterruptible(Dispatchers.Default) {
|
||||
val uri = "content://${source.authority}/chapters".toUri()
|
||||
.buildUpon()
|
||||
.appendPath(chapter.url)
|
||||
.build()
|
||||
contentResolver.query(uri, null, null, null, null)?.use { cursor ->
|
||||
val result = ArrayList<MangaPage>(cursor.count)
|
||||
if (cursor.moveToFirst()) {
|
||||
do {
|
||||
result += MangaPage(
|
||||
id = cursor.getLong(0),
|
||||
url = cursor.getString(1),
|
||||
preview = cursor.getStringOrNull(2),
|
||||
source = source,
|
||||
)
|
||||
} while (cursor.moveToNext())
|
||||
}
|
||||
result
|
||||
}.orEmpty()
|
||||
override suspend fun getPagesImpl(chapter: MangaChapter): List<MangaPage> = runInterruptible(Dispatchers.IO) {
|
||||
contentSource.getPages(chapter)
|
||||
}
|
||||
|
||||
override suspend fun getPageUrl(page: MangaPage): String = page.url
|
||||
override suspend fun getPageUrl(page: MangaPage): String = page.url // TODO
|
||||
|
||||
override suspend fun getTags(): Set<MangaTag> = runInterruptible(Dispatchers.Default) {
|
||||
val uri = "content://${source.authority}/tags".toUri()
|
||||
contentResolver.query(uri, null, null, null, null)?.use { cursor ->
|
||||
val result = ArraySet<MangaTag>(cursor.count)
|
||||
if (cursor.moveToFirst()) {
|
||||
do {
|
||||
result += MangaTag(
|
||||
key = cursor.getString(0),
|
||||
title = cursor.getString(1),
|
||||
source = source,
|
||||
)
|
||||
} while (cursor.moveToNext())
|
||||
}
|
||||
result
|
||||
}.orEmpty()
|
||||
override suspend fun getTags(): Set<MangaTag> = runInterruptible(Dispatchers.IO) {
|
||||
contentSource.getTags()
|
||||
}
|
||||
|
||||
override suspend fun getLocales(): Set<Locale> = emptySet()
|
||||
override suspend fun getLocales(): Set<Locale> = emptySet() // TODO
|
||||
|
||||
override suspend fun getRelatedMangaImpl(seed: Manga): List<Manga> = emptyList() // TODO
|
||||
|
||||
private suspend fun queryDetails(url: String): Manga = runInterruptible(Dispatchers.Default) {
|
||||
val uri = "content://${source.authority}/manga".toUri()
|
||||
.buildUpon()
|
||||
.appendPath(url)
|
||||
.build()
|
||||
checkNotNull(
|
||||
contentResolver.query(uri, null, null, null, null)?.use { cursor ->
|
||||
cursor.moveToFirst()
|
||||
cursor.getManga()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun queryChapters(url: String): List<MangaChapter>? = runInterruptible(Dispatchers.Default) {
|
||||
val uri = "content://${source.authority}/manga/chapters".toUri()
|
||||
.buildUpon()
|
||||
.appendPath(url)
|
||||
.build()
|
||||
contentResolver.query(uri, null, null, null, null)?.use { cursor ->
|
||||
val result = ArrayList<MangaChapter>(cursor.count)
|
||||
if (cursor.moveToFirst()) {
|
||||
do {
|
||||
result += MangaChapter(
|
||||
id = cursor.getLong(0),
|
||||
name = cursor.getString(1),
|
||||
number = cursor.getFloat(2),
|
||||
volume = cursor.getInt(3),
|
||||
url = cursor.getString(4),
|
||||
scanlator = cursor.getStringOrNull(5),
|
||||
uploadDate = cursor.getLong(6),
|
||||
branch = cursor.getStringOrNull(7),
|
||||
source = source,
|
||||
)
|
||||
} while (cursor.moveToNext())
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
private fun Cursor.getManga() = Manga(
|
||||
id = getLong(0),
|
||||
title = getString(1),
|
||||
altTitle = getStringOrNull(2),
|
||||
url = getString(3),
|
||||
publicUrl = getString(4),
|
||||
rating = getFloat(5),
|
||||
isNsfw = getInt(6) > 1,
|
||||
coverUrl = getString(7),
|
||||
tags = getStringOrNull(8)?.split(':')?.mapNotNullToSet {
|
||||
val parts = it.splitTwoParts('=') ?: return@mapNotNullToSet null
|
||||
MangaTag(key = parts.first, title = parts.second, source = source)
|
||||
}.orEmpty(),
|
||||
state = getStringOrNull(9)?.let { MangaState.entries.find(it) },
|
||||
author = optString(10),
|
||||
largeCoverUrl = optString(11),
|
||||
description = optString(12),
|
||||
chapters = emptyList(),
|
||||
source = source,
|
||||
)
|
||||
|
||||
private fun Cursor.optString(columnIndex: Int): String? {
|
||||
return if (isNull(columnIndex)) {
|
||||
null
|
||||
} else {
|
||||
getString(columnIndex)
|
||||
}
|
||||
}
|
||||
|
||||
private fun queryCapabilities(): MangaSourceCapabilities? {
|
||||
val uri = "content://${source.authority}/capabilities".toUri()
|
||||
return contentResolver.query(uri, null, null, null, null)?.use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
MangaSourceCapabilities(
|
||||
availableSortOrders = cursor.getStringOrNull(0)
|
||||
?.split(',')
|
||||
?.mapNotNullTo(EnumSet.noneOf(SortOrder::class.java)) {
|
||||
SortOrder.entries.find(it)
|
||||
}.orEmpty(),
|
||||
availableStates = cursor.getStringOrNull(1)
|
||||
?.split(',')
|
||||
?.mapNotNullTo(EnumSet.noneOf(MangaState::class.java)) {
|
||||
MangaState.entries.find(it)
|
||||
}.orEmpty(),
|
||||
availableContentRating = cursor.getStringOrNull(2)
|
||||
?.split(',')
|
||||
?.mapNotNullTo(EnumSet.noneOf(ContentRating::class.java)) {
|
||||
ContentRating.entries.find(it)
|
||||
}.orEmpty(),
|
||||
isMultipleTagsSupported = cursor.getInt(3) > 1,
|
||||
isTagsExclusionSupported = cursor.getInt(4) > 1,
|
||||
isSearchSupported = cursor.getInt(5) > 1,
|
||||
contentType = ContentType.entries.find(cursor.getString(6)) ?: ContentType.OTHER,
|
||||
defaultSortOrder = cursor.getStringOrNull(7)?.let {
|
||||
SortOrder.entries.find(it)
|
||||
} ?: SortOrder.ALPHABETICAL,
|
||||
sourceLocale = cursor.getStringOrNull(8)?.let { Locale(it) } ?: Locale.ROOT,
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private 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,
|
||||
)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -85,6 +85,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
get() = prefs.getInt(KEY_GRID_SIZE_PAGES, 100)
|
||||
set(value) = prefs.edit { putInt(KEY_GRID_SIZE_PAGES, value) }
|
||||
|
||||
val isQuickFilterEnabled: Boolean
|
||||
get() = prefs.getBoolean(KEY_QUICK_FILTER, true)
|
||||
|
||||
var historyListMode: ListMode
|
||||
get() = prefs.getEnumValue(KEY_LIST_MODE_HISTORY, listMode)
|
||||
set(value) = prefs.edit { putEnumValue(KEY_LIST_MODE_HISTORY, value) }
|
||||
@@ -696,6 +699,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
const val KEY_FEED_HEADER = "feed_header"
|
||||
const val KEY_SEARCH_SUGGESTION_TYPES = "search_suggest_types"
|
||||
const val KEY_SOURCES_VERSION = "sources_version"
|
||||
const val KEY_QUICK_FILTER = "quick_filter"
|
||||
|
||||
// keys for non-persistent preferences
|
||||
const val KEY_APP_VERSION = "app_version"
|
||||
@@ -704,6 +708,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
const val KEY_LOGS_SHARE = "logs_share"
|
||||
const val KEY_APP_UPDATE = "app_update"
|
||||
const val KEY_APP_TRANSLATION = "about_app_translation"
|
||||
const val PROXY_TEST = "proxy_test"
|
||||
|
||||
// old keys are for migration only
|
||||
private const val KEY_IMAGES_PROXY_OLD = "images_proxy"
|
||||
|
||||
@@ -16,10 +16,6 @@ abstract class AlertDialogFragment<B : ViewBinding> : DialogFragment() {
|
||||
var viewBinding: B? = null
|
||||
private set
|
||||
|
||||
@Deprecated("", ReplaceWith("requireViewBinding()"))
|
||||
protected val binding: B
|
||||
get() = requireViewBinding()
|
||||
|
||||
final override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||
val binding = onCreateViewBinding(layoutInflater, null)
|
||||
viewBinding = binding
|
||||
@@ -51,9 +47,6 @@ abstract class AlertDialogFragment<B : ViewBinding> : DialogFragment() {
|
||||
|
||||
open fun onDialogCreated(dialog: AlertDialog) = Unit
|
||||
|
||||
@Deprecated("", ReplaceWith("viewBinding"))
|
||||
protected fun bindingOrNull() = viewBinding
|
||||
|
||||
fun requireViewBinding(): B = checkNotNull(viewBinding) {
|
||||
"Fragment $this did not return a ViewBinding from onCreateView() or this was called before onCreateView()."
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ package org.koitharu.kotatsu.core.ui
|
||||
import android.content.Intent
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.Color
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.KeyEvent
|
||||
import android.view.View
|
||||
@@ -14,25 +13,22 @@ import androidx.appcompat.view.ActionMode
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.viewbinding.ViewBinding
|
||||
import dagger.hilt.EntryPoint
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.EntryPointAccessors
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.ui.util.ActionModeDelegate
|
||||
import org.koitharu.kotatsu.core.ui.util.WindowInsetsDelegate
|
||||
import org.koitharu.kotatsu.core.util.ext.isWebViewUnavailable
|
||||
import org.koitharu.kotatsu.main.ui.protect.ScreenshotPolicyHelper
|
||||
|
||||
@Suppress("LeakingThis")
|
||||
abstract class BaseActivity<B : ViewBinding> :
|
||||
AppCompatActivity(),
|
||||
ExceptionResolver.Host,
|
||||
ScreenshotPolicyHelper.ContentContainer,
|
||||
WindowInsetsDelegate.WindowInsetsListener {
|
||||
|
||||
@@ -41,8 +37,8 @@ abstract class BaseActivity<B : ViewBinding> :
|
||||
lateinit var viewBinding: B
|
||||
private set
|
||||
|
||||
@JvmField
|
||||
protected val exceptionResolver = ExceptionResolver(this)
|
||||
protected lateinit var exceptionResolver: ExceptionResolver
|
||||
private set
|
||||
|
||||
@JvmField
|
||||
protected val insetsDelegate = WindowInsetsDelegate()
|
||||
@@ -53,13 +49,15 @@ abstract class BaseActivity<B : ViewBinding> :
|
||||
private var defaultStatusBarColor = Color.TRANSPARENT
|
||||
|
||||
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
|
||||
setTheme(settings.colorScheme.styleResId)
|
||||
if (isAmoledTheme) {
|
||||
setTheme(R.style.ThemeOverlay_Kotatsu_Amoled)
|
||||
}
|
||||
putDataToExtras(intent)
|
||||
exceptionResolver = entryPoint.exceptionResolverFactory.create(this)
|
||||
super.onCreate(savedInstanceState)
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
insetsDelegate.handleImeInsets = true
|
||||
@@ -88,6 +86,10 @@ abstract class BaseActivity<B : ViewBinding> :
|
||||
setupToolbar()
|
||||
}
|
||||
|
||||
override fun getContext() = this
|
||||
|
||||
override fun getChildFragmentManager(): FragmentManager = supportFragmentManager
|
||||
|
||||
protected fun setContentView(binding: B) {
|
||||
this.viewBinding = binding
|
||||
super.setContentView(binding.root)
|
||||
@@ -97,11 +99,6 @@ abstract class BaseActivity<B : ViewBinding> :
|
||||
}
|
||||
|
||||
override fun onSupportNavigateUp(): Boolean {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
// TODO fix behavior on Android 14
|
||||
dispatchNavigateUp()
|
||||
return true
|
||||
}
|
||||
val fm = supportFragmentManager
|
||||
if (fm.isStateSaved) {
|
||||
return false
|
||||
@@ -178,12 +175,6 @@ abstract class BaseActivity<B : ViewBinding> :
|
||||
|
||||
protected fun hasViewBinding() = ::viewBinding.isInitialized
|
||||
|
||||
@EntryPoint
|
||||
@InstallIn(SingletonComponent::class)
|
||||
interface BaseActivityEntryPoint {
|
||||
val settings: AppSettings
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
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
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.viewbinding.ViewBinding
|
||||
import dagger.hilt.android.EntryPointAccessors
|
||||
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
|
||||
import org.koitharu.kotatsu.core.ui.util.ActionModeDelegate
|
||||
import org.koitharu.kotatsu.core.ui.util.WindowInsetsDelegate
|
||||
|
||||
@Suppress("LeakingThis")
|
||||
abstract class BaseFragment<B : ViewBinding> :
|
||||
Fragment(),
|
||||
ExceptionResolver.Host,
|
||||
WindowInsetsDelegate.WindowInsetsListener {
|
||||
|
||||
var viewBinding: B? = null
|
||||
private set
|
||||
|
||||
@Deprecated("", ReplaceWith("requireViewBinding()"))
|
||||
protected val binding: B
|
||||
get() = requireViewBinding()
|
||||
|
||||
@JvmField
|
||||
protected val exceptionResolver = ExceptionResolver(this)
|
||||
protected lateinit var exceptionResolver: ExceptionResolver
|
||||
private set
|
||||
|
||||
@JvmField
|
||||
protected val insetsDelegate = WindowInsetsDelegate()
|
||||
@@ -31,6 +29,12 @@ abstract class BaseFragment<B : ViewBinding> :
|
||||
protected val actionModeDelegate: 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(
|
||||
inflater: LayoutInflater,
|
||||
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()."
|
||||
}
|
||||
|
||||
@Deprecated("", ReplaceWith("viewBinding"))
|
||||
protected fun bindingOrNull() = viewBinding
|
||||
|
||||
protected abstract fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): B
|
||||
|
||||
protected open fun onViewBindingCreated(binding: B, savedInstanceState: Bundle?) = Unit
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package org.koitharu.kotatsu.core.ui
|
||||
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
@@ -12,7 +13,9 @@ import androidx.preference.PreferenceFragmentCompat
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import dagger.hilt.android.EntryPointAccessors
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.ui.util.RecyclerViewOwner
|
||||
import org.koitharu.kotatsu.core.ui.util.WindowInsetsDelegate
|
||||
@@ -25,7 +28,11 @@ import javax.inject.Inject
|
||||
abstract class BasePreferenceFragment(@StringRes private val titleId: Int) :
|
||||
PreferenceFragmentCompat(),
|
||||
WindowInsetsDelegate.WindowInsetsListener,
|
||||
RecyclerViewOwner {
|
||||
RecyclerViewOwner,
|
||||
ExceptionResolver.Host {
|
||||
|
||||
protected lateinit var exceptionResolver: ExceptionResolver
|
||||
private set
|
||||
|
||||
@Inject
|
||||
lateinit var settings: AppSettings
|
||||
@@ -36,6 +43,12 @@ abstract class BasePreferenceFragment(@StringRes private val titleId: Int) :
|
||||
override val recyclerView: RecyclerView
|
||||
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?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
val themedContext = (view.parentView ?: view).context
|
||||
|
||||
@@ -52,8 +52,8 @@ open class ReorderableListAdapter<T : ListModel> : ListDelegationAdapter<List<T>
|
||||
}
|
||||
|
||||
protected class DiffCallback<T : ListModel>(
|
||||
val oldList: List<T>,
|
||||
val newList: List<T>,
|
||||
private val oldList: List<T>,
|
||||
private val newList: List<T>,
|
||||
) : DiffUtil.Callback() {
|
||||
|
||||
override fun getOldListSize(): Int = oldList.size
|
||||
@@ -71,5 +71,11 @@ open class ReorderableListAdapter<T : ListModel> : ListDelegationAdapter<List<T>
|
||||
val newItem = newList[newItemPosition]
|
||||
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,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()
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,7 @@ class ListSelectionController(
|
||||
private val appCompatDelegate: AppCompatDelegate,
|
||||
private val decoration: AbstractSelectionItemDecoration,
|
||||
private val registryOwner: SavedStateRegistryOwner,
|
||||
private val callback: Callback2,
|
||||
private val callback: Callback,
|
||||
) : ActionMode.Callback, SavedStateRegistry.SavedStateProvider {
|
||||
|
||||
private var actionMode: ActionMode? = null
|
||||
@@ -130,43 +130,7 @@ class ListSelectionController(
|
||||
notifySelectionChanged()
|
||||
}
|
||||
|
||||
@Deprecated("")
|
||||
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 {
|
||||
interface Callback {
|
||||
|
||||
fun onSelectionChanged(controller: ListSelectionController, count: Int)
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.koitharu.kotatsu.core.ui.list.lifecycle
|
||||
|
||||
import android.view.View
|
||||
import androidx.core.view.children
|
||||
import androidx.viewpager2.widget.ViewPager2
|
||||
import org.koitharu.kotatsu.core.util.ext.recyclerView
|
||||
@@ -8,16 +9,63 @@ class PagerLifecycleDispatcher(
|
||||
private val pager: ViewPager2,
|
||||
) : ViewPager2.OnPageChangeCallback() {
|
||||
|
||||
private var pendingUpdate: OneShotLayoutListener? = null
|
||||
|
||||
override fun onPageSelected(position: Int) {
|
||||
super.onPageSelected(position)
|
||||
val rv = pager.recyclerView ?: return
|
||||
for (child in rv.children) {
|
||||
val wh = rv.getChildViewHolder(child) ?: continue
|
||||
(wh as? LifecycleAwareViewHolder)?.setIsCurrent(wh.absoluteAdapterPosition == position)
|
||||
}
|
||||
setResumedPage(position)
|
||||
}
|
||||
|
||||
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 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.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
|
||||
val SortOrder.titleRes: Int
|
||||
get() = when (this) {
|
||||
SortOrder.UPDATED -> R.string.updated
|
||||
SortOrder.POPULARITY -> R.string.popular
|
||||
SortOrder.RATING -> R.string.by_rating
|
||||
SortOrder.NEWEST -> R.string.newest
|
||||
SortOrder.ALPHABETICAL -> R.string.by_name
|
||||
SortOrder.ALPHABETICAL_DESC -> R.string.by_name_reverse
|
||||
UPDATED -> R.string.updated
|
||||
POPULARITY -> R.string.popular
|
||||
RATING -> R.string.by_rating
|
||||
NEWEST -> R.string.newest
|
||||
ALPHABETICAL -> R.string.by_name
|
||||
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.BottomSheetDialog
|
||||
import com.google.android.material.sidesheet.SideSheetDialog
|
||||
import dagger.hilt.android.EntryPointAccessors
|
||||
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.BaseActivityEntryPoint
|
||||
import org.koitharu.kotatsu.core.ui.util.ActionModeDelegate
|
||||
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 isFitToContentsDisabled = false
|
||||
|
||||
var viewBinding: B? = null
|
||||
protected lateinit var exceptionResolver: ExceptionResolver
|
||||
private set
|
||||
|
||||
@Deprecated("", ReplaceWith("requireViewBinding()"))
|
||||
protected val binding: B
|
||||
get() = requireViewBinding()
|
||||
var viewBinding: B? = null
|
||||
private set
|
||||
|
||||
protected val behavior: AdaptiveSheetBehavior?
|
||||
get() = AdaptiveSheetBehavior.from(this)
|
||||
@@ -54,6 +56,12 @@ abstract class BaseAdaptiveSheet<B : ViewBinding> : AppCompatDialogFragment() {
|
||||
private set
|
||||
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(
|
||||
inflater: LayoutInflater,
|
||||
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,
|
||||
) : FlowCollector<Any?> {
|
||||
|
||||
override suspend fun emit(value: Any?) {
|
||||
host.invalidateMenu()
|
||||
}
|
||||
override suspend fun emit(value: Any?) = host.invalidateMenu()
|
||||
}
|
||||
|
||||
@@ -5,9 +5,9 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
|
||||
fun interface ReversibleHandle {
|
||||
|
||||
@@ -23,8 +23,3 @@ fun ReversibleHandle.reverseAsync() = processLifecycleScope.launch(Dispatchers.D
|
||||
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.util.AttributeSet
|
||||
import android.view.View
|
||||
import android.view.View.OnClickListener
|
||||
import androidx.annotation.ColorRes
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.core.view.children
|
||||
import com.google.android.material.chip.Chip
|
||||
import com.google.android.material.chip.ChipDrawable
|
||||
import com.google.android.material.chip.ChipGroup
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.util.ext.castOrNull
|
||||
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
class ChipsView @JvmOverloads constructor(
|
||||
@@ -22,9 +22,7 @@ class ChipsView @JvmOverloads constructor(
|
||||
|
||||
private var isLayoutSuppressedCompat = false
|
||||
private var isLayoutCalledOnSuppressed = false
|
||||
private val chipOnClickListener = OnClickListener {
|
||||
onChipClickListener?.onChipClick(it as Chip, it.tag)
|
||||
}
|
||||
private val chipOnClickListener = InternalChipClickListener()
|
||||
private val chipOnCloseListener = OnClickListener {
|
||||
val chip = it as Chip
|
||||
val data = it.tag
|
||||
@@ -70,8 +68,8 @@ class ChipsView @JvmOverloads constructor(
|
||||
suppressLayoutCompat(true)
|
||||
try {
|
||||
for ((i, model) in items.withIndex()) {
|
||||
val chip = getChildAt(i) as Chip? ?: addChip()
|
||||
bindChip(chip, model)
|
||||
val chip = getChildAt(i) as DataChip? ?: addChip()
|
||||
chip.bind(model)
|
||||
}
|
||||
if (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> {
|
||||
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 addChip() = DataChip(context).also { addView(it) }
|
||||
|
||||
private fun suppressLayoutCompat(suppress: Boolean) {
|
||||
isLayoutSuppressedCompat = suppress
|
||||
@@ -139,15 +92,74 @@ class ChipsView @JvmOverloads constructor(
|
||||
}
|
||||
|
||||
data class ChipModel(
|
||||
val title: CharSequence,
|
||||
val title: CharSequence? = null,
|
||||
@StringRes val titleResId: Int = 0,
|
||||
@DrawableRes val icon: Int = 0,
|
||||
val isCheckable: Boolean = false,
|
||||
@ColorRes val tint: Int = 0,
|
||||
val isChecked: Boolean = false,
|
||||
val isDropdown: Boolean = false,
|
||||
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 onChipClick(chip: Chip, data: Any?)
|
||||
|
||||
@@ -13,7 +13,7 @@ abstract class MediatorStateFlow<T>(initialValue: T) : StateFlow<T> {
|
||||
final override val replayCache: List<T>
|
||||
get() = delegate.replayCache
|
||||
|
||||
final override val value: T
|
||||
override val value: T
|
||||
get() = delegate.value
|
||||
|
||||
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.contract
|
||||
|
||||
class MultiMutex<T : Any> : Set<T> {
|
||||
open class MultiMutex<T : Any> : Set<T> {
|
||||
|
||||
private val delegates = ArrayMap<T, Mutex>()
|
||||
|
||||
@@ -20,19 +20,26 @@ class MultiMutex<T : Any> : Set<T> {
|
||||
elements.all { x -> delegates.containsKey(x) }
|
||||
}
|
||||
|
||||
override fun isEmpty(): Boolean {
|
||||
return delegates.isEmpty()
|
||||
override fun isEmpty(): Boolean = 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> {
|
||||
return delegates.keys.iterator()
|
||||
fun tryLock(element: T): Boolean {
|
||||
val mutex = synchronized(delegates) {
|
||||
delegates.getOrPut(element, ::Mutex)
|
||||
}
|
||||
return mutex.tryLock()
|
||||
}
|
||||
|
||||
suspend fun lock(element: T) {
|
||||
val mutex = synchronized(delegates) {
|
||||
delegates.getOrPut(element) {
|
||||
Mutex()
|
||||
}
|
||||
delegates.getOrPut(element, ::Mutex)
|
||||
}
|
||||
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.Color
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.PowerManager
|
||||
@@ -31,7 +30,6 @@ import android.view.Window
|
||||
import android.webkit.WebView
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.annotation.IntegerRes
|
||||
import androidx.annotation.WorkerThread
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.appcompat.app.AppCompatDialog
|
||||
@@ -79,8 +77,6 @@ val Context.powerManager: PowerManager?
|
||||
val Context.connectivityManager: ConnectivityManager
|
||||
get() = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
|
||||
fun String.toUriOrNull() = if (isEmpty()) null else Uri.parse(this)
|
||||
|
||||
suspend fun CoroutineWorker.trySetForeground(): Boolean = runCatchingCancellable {
|
||||
val info = getForegroundInfo()
|
||||
setForeground(info)
|
||||
@@ -131,8 +127,7 @@ fun SyncResult.onError(error: Throwable) {
|
||||
when (error) {
|
||||
is IOException -> stats.numIoExceptions++
|
||||
is OperationApplicationException,
|
||||
is SQLException,
|
||||
-> databaseError = true
|
||||
is SQLException -> databaseError = true
|
||||
|
||||
is JSONException -> stats.numParseExceptions++
|
||||
else -> if (BuildConfig.DEBUG) throw error
|
||||
@@ -253,7 +248,6 @@ fun Context.checkNotificationPermission(channelId: String?): Boolean {
|
||||
return hasPermission
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
suspend fun Bitmap.compressToPNG(output: File) = runInterruptible(Dispatchers.IO) {
|
||||
output.outputStream().use { 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
|
||||
}
|
||||
|
||||
fun ImageRequest.Builder.indicator(indicator: BaseProgressIndicator<*>): ImageRequest.Builder {
|
||||
return addListener(ImageRequestIndicatorListener(listOf(indicator)))
|
||||
}
|
||||
|
||||
fun ImageRequest.Builder.indicator(indicators: List<BaseProgressIndicator<*>>): ImageRequest.Builder {
|
||||
return addListener(ImageRequestIndicatorListener(indicators))
|
||||
}
|
||||
|
||||
@@ -37,3 +37,5 @@ fun JSONObject.toContentValues(): ContentValues {
|
||||
}
|
||||
|
||||
private fun String.escapeName() = "`$this`"
|
||||
|
||||
fun Cursor.getBoolean(columnIndex: Int) = getInt(columnIndex) > 0
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
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 java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneId
|
||||
import java.time.temporal.ChronoUnit
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
fun calculateTimeAgo(instant: Instant, showMonths: Boolean = false): DateTimeAgo {
|
||||
// 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 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,16 +4,21 @@ import android.os.SystemClock
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.channelFlow
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onCompletion
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.transform
|
||||
import kotlinx.coroutines.flow.transformLatest
|
||||
import kotlinx.coroutines.flow.transformWhile
|
||||
import org.koitharu.kotatsu.R
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
fun <T> Flow<T>.onFirst(action: suspend (T) -> Unit): Flow<T> {
|
||||
@@ -87,6 +92,21 @@ 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 }
|
||||
.transformWhile<T, Unit> { trySend(it).isSuccess }
|
||||
.collect()
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
fun <T1, T2, T3, T4, T5, T6, R> combine(
|
||||
flow: Flow<T1>,
|
||||
@@ -110,3 +130,5 @@ fun <T1, T2, T3, T4, T5, T6, R> combine(
|
||||
suspend fun <T : Any> Flow<T?>.firstNotNull(): T = checkNotNull(first { x -> x != null })
|
||||
|
||||
suspend fun <T : Any> Flow<T?>.firstNotNullOrNull(): T? = firstOrNull { x -> x != null }
|
||||
|
||||
fun <T> Flow<Flow<T>>.flattenLatest() = flatMapLatest { it }
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package org.koitharu.kotatsu.core.util.ext
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.FloatRange
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.parsers.util.ellipsize
|
||||
import org.koitharu.kotatsu.parsers.util.levenshteinDistance
|
||||
import java.util.UUID
|
||||
|
||||
@@ -40,3 +43,24 @@ fun CharSequence.sanitize(): CharSequence {
|
||||
}
|
||||
|
||||
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.widget.TextViewCompat
|
||||
|
||||
|
||||
var TextView.textAndVisible: CharSequence?
|
||||
get() = text?.takeIf { visibility == View.VISIBLE }
|
||||
set(value) {
|
||||
|
||||
@@ -8,11 +8,9 @@ import androidx.annotation.AttrRes
|
||||
import androidx.annotation.ColorInt
|
||||
import androidx.annotation.FloatRange
|
||||
import androidx.annotation.Px
|
||||
import androidx.annotation.StyleRes
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.res.use
|
||||
import androidx.core.graphics.ColorUtils
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
fun Context.getThemeDrawable(
|
||||
@AttrRes resId: Int,
|
||||
@@ -77,7 +75,3 @@ fun TypedArray.getDrawableCompat(context: Context, index: Int): Drawable? {
|
||||
val resId = getResourceId(index, 0)
|
||||
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.res.Resources
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.collection.arraySetOf
|
||||
import coil.network.HttpException
|
||||
import okio.FileNotFoundException
|
||||
import okio.IOException
|
||||
import okio.ProtocolException
|
||||
import org.acra.ktx.sendWithAcra
|
||||
import org.jsoup.HttpStatusException
|
||||
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.CloudFlareProtectedException
|
||||
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.ProxyConfigException
|
||||
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.UnsupportedSourceException
|
||||
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_STATES_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.NotFoundException
|
||||
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.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"
|
||||
|
||||
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 CloudFlareProtectedException -> resources.getString(R.string.captcha_required)
|
||||
is CloudFlareBlockedException -> resources.getString(R.string.blocked_by_server_message)
|
||||
is ActivityNotFoundException,
|
||||
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 BadBackupFormatException -> resources.getString(R.string.unsupported_backup_message)
|
||||
is FileNotFoundException -> resources.getString(R.string.file_not_found)
|
||||
is AccessDeniedException -> resources.getString(R.string.no_access_to_file)
|
||||
is EmptyHistoryException -> resources.getString(R.string.history_is_empty)
|
||||
is ProxyConfigException -> resources.getString(R.string.invalid_proxy_configuration)
|
||||
is SyncApiException,
|
||||
is ContentUnavailableException,
|
||||
-> message
|
||||
is ContentUnavailableException -> message
|
||||
|
||||
is ParseException -> shortMessage
|
||||
is UnknownHostException,
|
||||
is SocketTimeoutException,
|
||||
-> resources.getString(R.string.network_error)
|
||||
is SocketTimeoutException -> resources.getString(R.string.network_error)
|
||||
|
||||
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 NotFoundException -> resources.getString(R.string.not_found_404)
|
||||
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 UnknownHostException,
|
||||
is SocketTimeoutException,
|
||||
-> R.drawable.ic_plug_large
|
||||
is ProtocolException -> R.drawable.ic_plug_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 {
|
||||
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 {
|
||||
@@ -117,15 +156,6 @@ fun Throwable.report() {
|
||||
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 {
|
||||
val trace = stackTraceToString()
|
||||
return trace.contains("android.webkit.WebView.<init>")
|
||||
|
||||
@@ -5,6 +5,7 @@ import androidx.core.net.toFile
|
||||
import okio.Source
|
||||
import okio.source
|
||||
import okio.use
|
||||
import org.jetbrains.annotations.Blocking
|
||||
import org.koitharu.kotatsu.local.data.util.withExtraCloseable
|
||||
import java.io.File
|
||||
import java.util.zip.ZipFile
|
||||
@@ -12,6 +13,7 @@ import java.util.zip.ZipFile
|
||||
const val URI_SCHEME_FILE = "file"
|
||||
const val URI_SCHEME_ZIP = "file+zip"
|
||||
|
||||
@Blocking
|
||||
fun Uri.exists(): Boolean = when (scheme) {
|
||||
URI_SCHEME_FILE -> toFile().exists()
|
||||
URI_SCHEME_ZIP -> {
|
||||
@@ -22,6 +24,7 @@ fun Uri.exists(): Boolean = when (scheme) {
|
||||
else -> unsupportedUri(this)
|
||||
}
|
||||
|
||||
@Blocking
|
||||
fun Uri.isTargetNotEmpty(): Boolean = when (scheme) {
|
||||
URI_SCHEME_FILE -> toFile().isNotEmpty()
|
||||
URI_SCHEME_ZIP -> {
|
||||
@@ -32,6 +35,7 @@ fun Uri.isTargetNotEmpty(): Boolean = when (scheme) {
|
||||
else -> unsupportedUri(this)
|
||||
}
|
||||
|
||||
@Blocking
|
||||
fun Uri.source(): Source = when (scheme) {
|
||||
URI_SCHEME_FILE -> toFile().source()
|
||||
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 String.toUriOrNull() = if (isEmpty()) null else Uri.parse(this)
|
||||
|
||||
private fun unsupportedUri(uri: Uri): Nothing {
|
||||
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 androidx.appcompat.widget.ActionMenuView
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.core.view.SoftwareKeyboardControllerCompat
|
||||
import androidx.core.view.children
|
||||
import androidx.core.view.descendants
|
||||
import androidx.core.view.isVisible
|
||||
@@ -23,14 +22,6 @@ import com.google.android.material.slider.Slider
|
||||
import com.google.android.material.tabs.TabLayout
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
fun View.hideKeyboard() {
|
||||
SoftwareKeyboardControllerCompat(this).hide()
|
||||
}
|
||||
|
||||
fun View.showKeyboard() {
|
||||
SoftwareKeyboardControllerCompat(this).show()
|
||||
}
|
||||
|
||||
fun View.hasGlobalPoint(x: Int, y: Int): Boolean {
|
||||
if (visibility != View.VISIBLE) {
|
||||
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.util.recoverNotNull
|
||||
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.Provider
|
||||
|
||||
@@ -37,7 +37,7 @@ class DetailsLoadUseCase @Inject constructor(
|
||||
private val mangaRepositoryFactory: MangaRepository.Factory,
|
||||
private val recoverUseCase: RecoverMangaUseCase,
|
||||
private val imageGetter: Html.ImageGetter,
|
||||
private val trackerProvider: Provider<Tracker>,
|
||||
private val newChaptersUseCaseProvider: Provider<CheckNewChaptersUseCase>,
|
||||
) {
|
||||
|
||||
operator fun invoke(intent: MangaIntent): Flow<MangaDetails> = channelFlow {
|
||||
@@ -55,11 +55,32 @@ class DetailsLoadUseCase @Inject constructor(
|
||||
try {
|
||||
val details = getDetails(manga)
|
||||
launch { updateTracker(details) }
|
||||
send(MangaDetails(details, local?.peek(), details.description?.parseAsHtml(withImages = false)?.trim(), false))
|
||||
send(MangaDetails(details, local?.await(), details.description?.parseAsHtml(withImages = true)?.trim(), true))
|
||||
send(
|
||||
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) {
|
||||
local?.await()?.manga?.also { localManga ->
|
||||
send(MangaDetails(localManga, null, localManga.description?.parseAsHtml(withImages = false)?.trim(), true))
|
||||
send(
|
||||
MangaDetails(
|
||||
localManga,
|
||||
null,
|
||||
localManga.description?.parseAsHtml(withImages = false)?.trim(),
|
||||
true,
|
||||
),
|
||||
)
|
||||
} ?: close(e)
|
||||
}
|
||||
}
|
||||
@@ -97,7 +118,7 @@ class DetailsLoadUseCase @Inject constructor(
|
||||
}
|
||||
|
||||
private suspend fun updateTracker(details: Manga) = runCatchingCancellable {
|
||||
trackerProvider.get().syncWithDetails(details)
|
||||
newChaptersUseCaseProvider.get()(details)
|
||||
}.onFailure { e ->
|
||||
e.printStackTraceDebug()
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import org.koitharu.kotatsu.core.model.findChapter
|
||||
import org.koitharu.kotatsu.core.model.isLocal
|
||||
import org.koitharu.kotatsu.core.os.NetworkState
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.history.data.PROGRESS_NONE
|
||||
import org.koitharu.kotatsu.list.domain.ReadingProgress.Companion.PROGRESS_NONE
|
||||
import org.koitharu.kotatsu.local.data.LocalMangaRepository
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -3,7 +3,6 @@ package org.koitharu.kotatsu.details.ui
|
||||
import android.content.Context
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
||||
import org.koitharu.kotatsu.core.model.MangaHistory
|
||||
import org.koitharu.kotatsu.details.data.MangaDetails
|
||||
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
|
||||
import org.koitharu.kotatsu.details.ui.model.toListItem
|
||||
@@ -12,7 +11,7 @@ import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
import org.koitharu.kotatsu.parsers.util.mapToSet
|
||||
|
||||
fun MangaDetails.mapChapters(
|
||||
history: MangaHistory?,
|
||||
currentChapterId: Long,
|
||||
newCount: Int,
|
||||
branch: String?,
|
||||
bookmarks: List<Bookmark>,
|
||||
@@ -24,7 +23,6 @@ fun MangaDetails.mapChapters(
|
||||
return emptyList()
|
||||
}
|
||||
val bookmarked = bookmarks.mapToSet { it.chapterId }
|
||||
val currentId = history?.chapterId ?: 0L
|
||||
val newFrom = if (newCount == 0 || remoteChapters.isEmpty()) Int.MAX_VALUE else remoteChapters.size - newCount
|
||||
val ids = buildSet(maxOf(remoteChapters.size, localChapters.size)) {
|
||||
remoteChapters.mapTo(this) { it.id }
|
||||
@@ -36,14 +34,14 @@ fun MangaDetails.mapChapters(
|
||||
} else {
|
||||
null
|
||||
}
|
||||
var isUnread = currentId !in ids
|
||||
var isUnread = currentChapterId !in ids
|
||||
for (chapter in remoteChapters) {
|
||||
val local = localMap?.remove(chapter.id)
|
||||
if (chapter.id == currentId) {
|
||||
if (chapter.id == currentChapterId) {
|
||||
isUnread = true
|
||||
}
|
||||
result += (local ?: chapter).toListItem(
|
||||
isCurrent = chapter.id == currentId,
|
||||
isCurrent = chapter.id == currentChapterId,
|
||||
isUnread = isUnread,
|
||||
isNew = isUnread && result.size >= newFrom,
|
||||
isDownloaded = local != null,
|
||||
@@ -53,11 +51,11 @@ fun MangaDetails.mapChapters(
|
||||
}
|
||||
if (!localMap.isNullOrEmpty()) {
|
||||
for (chapter in localMap.values) {
|
||||
if (chapter.id == currentId) {
|
||||
if (chapter.id == currentChapterId) {
|
||||
isUnread = true
|
||||
}
|
||||
result += chapter.toListItem(
|
||||
isCurrent = chapter.id == currentId,
|
||||
isCurrent = chapter.id == currentChapterId,
|
||||
isUnread = isUnread,
|
||||
isNew = false,
|
||||
isDownloaded = !isLocal,
|
||||
|
||||
@@ -29,7 +29,7 @@ import androidx.swiperefreshlayout.widget.CircularProgressDrawable
|
||||
import coil.ImageLoader
|
||||
import coil.request.ImageRequest
|
||||
import coil.request.SuccessResult
|
||||
import coil.transform.CircleCropTransformation
|
||||
import coil.transform.RoundedCornersTransformation
|
||||
import coil.util.CoilUtils
|
||||
import com.google.android.material.chip.Chip
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
@@ -68,6 +68,7 @@ import org.koitharu.kotatsu.core.util.ext.enqueueWith
|
||||
import org.koitharu.kotatsu.core.util.ext.getThemeColor
|
||||
import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty
|
||||
import org.koitharu.kotatsu.core.util.ext.isTextTruncated
|
||||
import org.koitharu.kotatsu.core.util.ext.joinToStringWithLimit
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
||||
import org.koitharu.kotatsu.core.util.ext.parentView
|
||||
@@ -160,7 +161,7 @@ class DetailsActivity :
|
||||
}
|
||||
TitleExpandListener(viewBinding.textViewTitle).attach()
|
||||
|
||||
viewModel.details.filterNotNull().observe(this, ::onMangaUpdated)
|
||||
viewModel.mangaDetails.filterNotNull().observe(this, ::onMangaUpdated)
|
||||
viewModel.onMangaRemoved.observeEvent(this, ::onMangaRemoved)
|
||||
viewModel.onError
|
||||
.filterNot { ChaptersPagesSheet.isShown(supportFragmentManager) }
|
||||
@@ -356,23 +357,7 @@ class DetailsActivity :
|
||||
chip.text = if (categories.isEmpty()) {
|
||||
getString(R.string.add_to_favourites)
|
||||
} else {
|
||||
if (categories.size == 1) {
|
||||
categories.first().title.ellipsize(FAV_LABEL_LIMIT)
|
||||
}
|
||||
buildString(FAV_LABEL_LIMIT + 6) {
|
||||
for ((i, cat) in categories.withIndex()) {
|
||||
if (i == 0) {
|
||||
append(cat.title.ellipsize(FAV_LABEL_LIMIT - 4))
|
||||
} else if (length + cat.title.length > FAV_LABEL_LIMIT) {
|
||||
append(", ")
|
||||
append(getString(R.string.list_ellipsize_pattern, categories.size - i))
|
||||
break
|
||||
} else {
|
||||
append(", ")
|
||||
append(cat.title)
|
||||
}
|
||||
}
|
||||
}
|
||||
categories.joinToStringWithLimit(this, FAV_LABEL_LIMIT) { it.title }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -490,7 +475,7 @@ class DetailsActivity :
|
||||
.fallback(R.drawable.ic_web)
|
||||
.error(R.drawable.ic_web)
|
||||
.source(manga.source)
|
||||
.transformations(CircleCropTransformation())
|
||||
.transformations(RoundedCornersTransformation(resources.getDimension(R.dimen.chip_icon_corner)))
|
||||
.allowRgb565(true)
|
||||
.enqueueWith(coil)
|
||||
}
|
||||
|
||||
@@ -12,32 +12,23 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.mapLatest
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.plus
|
||||
import okio.FileNotFoundException
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
||||
import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository
|
||||
import org.koitharu.kotatsu.core.model.findById
|
||||
import org.koitharu.kotatsu.core.model.getPreferredBranch
|
||||
import org.koitharu.kotatsu.core.parser.MangaIntent
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.prefs.ListMode
|
||||
import org.koitharu.kotatsu.core.prefs.observeAsStateFlow
|
||||
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
||||
import org.koitharu.kotatsu.core.ui.util.ReversibleAction
|
||||
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
|
||||
import org.koitharu.kotatsu.core.util.ext.call
|
||||
import org.koitharu.kotatsu.core.util.ext.combine
|
||||
import org.koitharu.kotatsu.core.util.ext.computeSize
|
||||
import org.koitharu.kotatsu.core.util.ext.onEachWhile
|
||||
import org.koitharu.kotatsu.core.util.ext.requireValue
|
||||
import org.koitharu.kotatsu.details.data.MangaDetails
|
||||
import org.koitharu.kotatsu.details.domain.BranchComparator
|
||||
import org.koitharu.kotatsu.details.domain.DetailsInteractor
|
||||
@@ -45,9 +36,9 @@ import org.koitharu.kotatsu.details.domain.DetailsLoadUseCase
|
||||
import org.koitharu.kotatsu.details.domain.ProgressUpdateUseCase
|
||||
import org.koitharu.kotatsu.details.domain.ReadingTimeUseCase
|
||||
import org.koitharu.kotatsu.details.domain.RelatedMangaUseCase
|
||||
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
|
||||
import org.koitharu.kotatsu.details.ui.model.HistoryInfo
|
||||
import org.koitharu.kotatsu.details.ui.model.MangaBranch
|
||||
import org.koitharu.kotatsu.details.ui.pager.ChaptersPagesViewModel
|
||||
import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
|
||||
import org.koitharu.kotatsu.history.data.HistoryRepository
|
||||
import org.koitharu.kotatsu.list.domain.MangaListMapper
|
||||
@@ -57,6 +48,7 @@ import org.koitharu.kotatsu.local.domain.DeleteLocalMangaUseCase
|
||||
import org.koitharu.kotatsu.local.domain.model.LocalManga
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import org.koitharu.kotatsu.reader.ui.ReaderState
|
||||
import org.koitharu.kotatsu.scrobbling.common.domain.Scrobbler
|
||||
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo
|
||||
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingStatus
|
||||
@@ -66,37 +58,42 @@ import javax.inject.Inject
|
||||
@HiltViewModel
|
||||
class DetailsViewModel @Inject constructor(
|
||||
private val historyRepository: HistoryRepository,
|
||||
private val bookmarksRepository: BookmarksRepository,
|
||||
private val settings: AppSettings,
|
||||
bookmarksRepository: BookmarksRepository,
|
||||
settings: AppSettings,
|
||||
private val scrobblers: Set<@JvmSuppressWildcards Scrobbler>,
|
||||
@LocalStorageChanges private val localStorageChanges: SharedFlow<LocalManga?>,
|
||||
private val downloadScheduler: DownloadWorker.Scheduler,
|
||||
@LocalStorageChanges localStorageChanges: SharedFlow<LocalManga?>,
|
||||
downloadScheduler: DownloadWorker.Scheduler,
|
||||
private val interactor: DetailsInteractor,
|
||||
savedStateHandle: SavedStateHandle,
|
||||
private val deleteLocalMangaUseCase: DeleteLocalMangaUseCase,
|
||||
deleteLocalMangaUseCase: DeleteLocalMangaUseCase,
|
||||
private val relatedMangaUseCase: RelatedMangaUseCase,
|
||||
private val mangaListMapper: MangaListMapper,
|
||||
private val detailsLoadUseCase: DetailsLoadUseCase,
|
||||
private val progressUpdateUseCase: ProgressUpdateUseCase,
|
||||
private val readingTimeUseCase: ReadingTimeUseCase,
|
||||
private val statsRepository: StatsRepository,
|
||||
) : BaseViewModel() {
|
||||
statsRepository: StatsRepository,
|
||||
) : ChaptersPagesViewModel(
|
||||
settings = settings,
|
||||
interactor = interactor,
|
||||
bookmarksRepository = bookmarksRepository,
|
||||
historyRepository = historyRepository,
|
||||
downloadScheduler = downloadScheduler,
|
||||
deleteLocalMangaUseCase = deleteLocalMangaUseCase,
|
||||
localStorageChanges = localStorageChanges,
|
||||
) {
|
||||
|
||||
private val intent = MangaIntent(savedStateHandle)
|
||||
private var loadingJob: Job
|
||||
val mangaId = intent.mangaId
|
||||
|
||||
val onActionDone = MutableEventFlow<ReversibleAction>()
|
||||
val onSelectChapter = MutableEventFlow<Long>()
|
||||
val onDownloadStarted = MutableEventFlow<Unit>()
|
||||
|
||||
val details = MutableStateFlow(intent.manga?.let { MangaDetails(it, null, null, false) })
|
||||
val manga = details.map { x -> x?.toManga() }
|
||||
.withErrorHandling()
|
||||
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)
|
||||
init {
|
||||
mangaDetails.value = intent.manga?.let { MangaDetails(it, null, null, false) }
|
||||
}
|
||||
|
||||
val history = historyRepository.observeOne(mangaId)
|
||||
.withErrorHandling()
|
||||
.onEach { h ->
|
||||
readingState.value = h?.let(::ReaderState)
|
||||
}.withErrorHandling()
|
||||
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)
|
||||
|
||||
val favouriteCategories = interactor.observeFavourite(mangaId)
|
||||
@@ -109,31 +106,8 @@ class DetailsViewModel @Inject constructor(
|
||||
|
||||
val remoteManga = MutableStateFlow<Manga?>(null)
|
||||
|
||||
val newChaptersCount = details.flatMapLatest { d ->
|
||||
if (d?.isLocal == false) {
|
||||
interactor.observeNewChapters(mangaId)
|
||||
} else {
|
||||
flowOf(0)
|
||||
}
|
||||
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, 0)
|
||||
|
||||
private val chaptersQuery = MutableStateFlow("")
|
||||
val selectedBranch = MutableStateFlow<String?>(null)
|
||||
|
||||
val isChaptersReversed = settings.observeAsStateFlow(
|
||||
scope = viewModelScope + Dispatchers.Default,
|
||||
key = AppSettings.KEY_REVERSE_CHAPTERS,
|
||||
valueProducer = { isChaptersReverse },
|
||||
)
|
||||
|
||||
val isChaptersInGridView = settings.observeAsStateFlow(
|
||||
scope = viewModelScope + Dispatchers.Default,
|
||||
key = AppSettings.KEY_GRID_VIEW_CHAPTERS,
|
||||
valueProducer = { isChaptersGridView },
|
||||
)
|
||||
|
||||
val historyInfo: StateFlow<HistoryInfo> = combine(
|
||||
details,
|
||||
mangaDetails,
|
||||
selectedBranch,
|
||||
history,
|
||||
interactor.observeIncognitoMode(manga),
|
||||
@@ -145,11 +119,7 @@ class DetailsViewModel @Inject constructor(
|
||||
initialValue = HistoryInfo(null, null, null, false),
|
||||
)
|
||||
|
||||
val bookmarks = manga.flatMapLatest {
|
||||
if (it != null) bookmarksRepository.observeBookmarks(it) else flowOf(emptyList())
|
||||
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, emptyList())
|
||||
|
||||
val localSize = details
|
||||
val localSize = mangaDetails
|
||||
.map { it?.local }
|
||||
.distinctUntilChanged()
|
||||
.combine(localStorageChanges.onStart { emit(null) }) { x, _ -> x }
|
||||
@@ -163,7 +133,6 @@ class DetailsViewModel @Inject constructor(
|
||||
}
|
||||
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.WhileSubscribed(5000), 0L)
|
||||
|
||||
val onMangaRemoved = MutableEventFlow<Manga>()
|
||||
val isScrobblingAvailable: Boolean
|
||||
get() = scrobblers.any { it.isEnabled }
|
||||
|
||||
@@ -182,7 +151,7 @@ class DetailsViewModel @Inject constructor(
|
||||
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, emptyList())
|
||||
|
||||
val branches: StateFlow<List<MangaBranch>> = combine(
|
||||
details,
|
||||
mangaDetails,
|
||||
selectedBranch,
|
||||
history,
|
||||
) { m, b, h ->
|
||||
@@ -201,35 +170,8 @@ class DetailsViewModel @Inject constructor(
|
||||
}.sortedWith(BranchComparator())
|
||||
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList())
|
||||
|
||||
val isChaptersEmpty: StateFlow<Boolean> = details.map {
|
||||
it != null && it.isLoaded && it.allChapters.isEmpty()
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), false)
|
||||
|
||||
val chapters = combine(
|
||||
combine(
|
||||
details,
|
||||
history,
|
||||
selectedBranch,
|
||||
newChaptersCount,
|
||||
bookmarks,
|
||||
isChaptersInGridView,
|
||||
) { manga, history, branch, news, bookmarks, grid ->
|
||||
manga?.mapChapters(
|
||||
history,
|
||||
news,
|
||||
branch,
|
||||
bookmarks,
|
||||
grid,
|
||||
).orEmpty()
|
||||
},
|
||||
isChaptersReversed,
|
||||
chaptersQuery,
|
||||
) { list, reversed, query ->
|
||||
(if (reversed) list.asReversed() else list).filterSearch(query)
|
||||
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList())
|
||||
|
||||
val readingTime = combine(
|
||||
details,
|
||||
mangaDetails,
|
||||
selectedBranch,
|
||||
history,
|
||||
) { m, b, h ->
|
||||
@@ -242,18 +184,14 @@ class DetailsViewModel @Inject constructor(
|
||||
init {
|
||||
loadingJob = doLoad()
|
||||
launchJob(Dispatchers.Default) {
|
||||
localStorageChanges
|
||||
.collect { onDownloadComplete(it) }
|
||||
}
|
||||
launchJob(Dispatchers.Default) {
|
||||
val manga = details.firstOrNull { !it?.chapters.isNullOrEmpty() } ?: return@launchJob
|
||||
val manga = mangaDetails.firstOrNull { !it?.chapters.isNullOrEmpty() } ?: return@launchJob
|
||||
val h = history.firstOrNull()
|
||||
if (h != null) {
|
||||
progressUpdateUseCase(manga.toManga())
|
||||
}
|
||||
}
|
||||
launchJob(Dispatchers.Default) {
|
||||
val manga = details.firstOrNull { it != null && it.isLocal } ?: return@launchJob
|
||||
val manga = mangaDetails.firstOrNull { it != null && it.isLocal } ?: return@launchJob
|
||||
remoteManga.value = interactor.findRemote(manga.toManga())
|
||||
}
|
||||
}
|
||||
@@ -263,41 +201,6 @@ class DetailsViewModel @Inject constructor(
|
||||
loadingJob = doLoad()
|
||||
}
|
||||
|
||||
fun deleteLocal() {
|
||||
val m = details.value?.local?.manga
|
||||
if (m == null) {
|
||||
errorEvent.call(FileNotFoundException())
|
||||
return
|
||||
}
|
||||
launchLoadingJob(Dispatchers.Default) {
|
||||
deleteLocalMangaUseCase(m)
|
||||
onMangaRemoved.call(m)
|
||||
}
|
||||
}
|
||||
|
||||
fun removeBookmark(bookmark: Bookmark) {
|
||||
launchJob(Dispatchers.Default) {
|
||||
bookmarksRepository.removeBookmark(bookmark)
|
||||
onActionDone.call(ReversibleAction(R.string.bookmark_removed, null))
|
||||
}
|
||||
}
|
||||
|
||||
fun setChaptersReversed(newValue: Boolean) {
|
||||
settings.isChaptersReverse = newValue
|
||||
}
|
||||
|
||||
fun setChaptersInGridView(newValue: Boolean) {
|
||||
settings.isChaptersGridView = newValue
|
||||
}
|
||||
|
||||
fun setSelectedBranch(branch: String?) {
|
||||
selectedBranch.value = branch
|
||||
}
|
||||
|
||||
fun performChapterSearch(query: String?) {
|
||||
chaptersQuery.value = query?.trim().orEmpty()
|
||||
}
|
||||
|
||||
fun updateScrobbling(index: Int, rating: Float, status: ScrobblingStatus?) {
|
||||
val scrobbler = getScrobbler(index) ?: return
|
||||
launchJob(Dispatchers.Default) {
|
||||
@@ -319,34 +222,6 @@ class DetailsViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun markChapterAsCurrent(chapterId: Long) {
|
||||
launchJob(Dispatchers.Default) {
|
||||
val manga = checkNotNull(details.value)
|
||||
val chapters = checkNotNull(manga.chapters[selectedBranchValue])
|
||||
val chapterIndex = chapters.indexOfFirst { it.id == chapterId }
|
||||
check(chapterIndex in chapters.indices) { "Chapter not found" }
|
||||
val percent = chapterIndex / chapters.size.toFloat()
|
||||
historyRepository.addOrUpdate(
|
||||
manga = manga.toManga(),
|
||||
chapterId = chapterId,
|
||||
page = 0,
|
||||
scroll = 0,
|
||||
percent = percent,
|
||||
force = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun download(chaptersIds: Set<Long>?) {
|
||||
launchJob(Dispatchers.Default) {
|
||||
downloadScheduler.schedule(
|
||||
details.requireValue().toManga(),
|
||||
chaptersIds,
|
||||
)
|
||||
onDownloadStarted.call(Unit)
|
||||
}
|
||||
}
|
||||
|
||||
fun startChaptersSelection() {
|
||||
val chapters = chapters.value
|
||||
val chapter = chapters.find {
|
||||
@@ -374,28 +249,10 @@ class DetailsViewModel @Inject constructor(
|
||||
selectedBranch.value = manga.getPreferredBranch(hist)
|
||||
true
|
||||
}.collect {
|
||||
details.value = it
|
||||
mangaDetails.value = it
|
||||
}
|
||||
}
|
||||
|
||||
private fun List<ChapterListItem>.filterSearch(query: String): List<ChapterListItem> {
|
||||
if (query.isEmpty() || this.isEmpty()) {
|
||||
return this
|
||||
}
|
||||
return filter {
|
||||
it.chapter.name.contains(query, ignoreCase = true)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun onDownloadComplete(downloadedManga: LocalManga?) {
|
||||
downloadedManga ?: return
|
||||
launchJob {
|
||||
details.update {
|
||||
interactor.updateLocal(it, downloadedManga)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getScrobbler(index: Int): Scrobbler? {
|
||||
val info = scrobblingInfo.value.getOrNull(index)
|
||||
val scrobbler = if (info != null) {
|
||||
|
||||
@@ -4,7 +4,8 @@ import android.content.DialogInterface
|
||||
import android.view.View
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.model.ids
|
||||
import org.koitharu.kotatsu.core.ui.dialog.RecyclerViewAlertDialog
|
||||
import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog
|
||||
import org.koitharu.kotatsu.core.ui.dialog.setRecyclerViewList
|
||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.download.ui.dialog.DownloadOption
|
||||
import org.koitharu.kotatsu.download.ui.dialog.downloadOptionAD
|
||||
@@ -53,16 +54,14 @@ class DownloadDialogHelper(
|
||||
callback.onItemClick(item, host)
|
||||
dialog?.dismiss()
|
||||
}
|
||||
dialog = RecyclerViewAlertDialog.Builder<DownloadOption>(host.context)
|
||||
.addAdapterDelegate(downloadOptionAD(listener))
|
||||
.setCancelable(true)
|
||||
.setTitle(R.string.download)
|
||||
.setNegativeButton(android.R.string.cancel)
|
||||
.setNeutralButton(R.string.settings) { _, _ ->
|
||||
dialog = buildAlertDialog(host.context) {
|
||||
setCancelable(true)
|
||||
setTitle(R.string.download)
|
||||
setNegativeButton(android.R.string.cancel, null)
|
||||
setNeutralButton(R.string.settings) { _, _ ->
|
||||
host.context.startActivity(SettingsActivity.newDownloadsSettingsIntent(host.context))
|
||||
}
|
||||
.setItems(options)
|
||||
.create()
|
||||
.also { it.show() }
|
||||
setRecyclerViewList(options, downloadOptionAD(listener))
|
||||
}.also { it.show() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,14 +14,13 @@ import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet
|
||||
import org.koitharu.kotatsu.core.util.ext.setValueRounded
|
||||
import org.koitharu.kotatsu.core.util.progress.IntPercentLabelFormatter
|
||||
import org.koitharu.kotatsu.details.ui.DetailsViewModel
|
||||
import org.koitharu.kotatsu.details.ui.pager.ChaptersPagesSheet.Companion.TAB_BOOKMARKS
|
||||
import org.koitharu.kotatsu.details.ui.pager.ChaptersPagesSheet.Companion.TAB_CHAPTERS
|
||||
import org.koitharu.kotatsu.details.ui.pager.ChaptersPagesSheet.Companion.TAB_PAGES
|
||||
import java.lang.ref.WeakReference
|
||||
|
||||
class ChapterPagesMenuProvider(
|
||||
private val viewModel: DetailsViewModel,
|
||||
private val viewModel: ChaptersPagesViewModel,
|
||||
private val sheet: BaseAdaptiveSheet<*>,
|
||||
private val pager: ViewPager2,
|
||||
private val settings: AppSettings,
|
||||
|
||||
@@ -7,7 +7,6 @@ import android.view.ViewGroup
|
||||
import androidx.appcompat.view.ActionMode
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import com.google.android.material.tabs.TabLayoutMediator
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
|
||||
@@ -30,7 +29,6 @@ import org.koitharu.kotatsu.core.util.ext.setTabsEnabled
|
||||
import org.koitharu.kotatsu.core.util.ext.showDistinct
|
||||
import org.koitharu.kotatsu.core.util.ext.withArgs
|
||||
import org.koitharu.kotatsu.databinding.SheetChaptersPagesBinding
|
||||
import org.koitharu.kotatsu.details.ui.DetailsViewModel
|
||||
import org.koitharu.kotatsu.download.ui.worker.DownloadStartedObserver
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -40,7 +38,7 @@ class ChaptersPagesSheet : BaseAdaptiveSheet<SheetChaptersPagesBinding>(), Actio
|
||||
@Inject
|
||||
lateinit var settings: AppSettings
|
||||
|
||||
private val viewModel by activityViewModels<DetailsViewModel>()
|
||||
private val viewModel by ChaptersPagesViewModel.ActivityVMLazy(this)
|
||||
|
||||
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): SheetChaptersPagesBinding {
|
||||
return SheetChaptersPagesBinding.inflate(inflater, container, false)
|
||||
@@ -93,8 +91,8 @@ class ChaptersPagesSheet : BaseAdaptiveSheet<SheetChaptersPagesBinding>(), Actio
|
||||
}
|
||||
|
||||
override fun onActionModeStarted(mode: ActionMode) {
|
||||
expandAndLock()
|
||||
viewBinding?.toolbar?.menuView?.isVisible = false
|
||||
view?.post(::expandAndLock)
|
||||
}
|
||||
|
||||
override fun onActionModeFinished(mode: ActionMode) {
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
package org.koitharu.kotatsu.details.ui.pager
|
||||
|
||||
import android.app.Activity
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.plus
|
||||
import okio.FileNotFoundException
|
||||
import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.prefs.observeAsStateFlow
|
||||
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
||||
import org.koitharu.kotatsu.core.ui.util.ReversibleAction
|
||||
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
|
||||
import org.koitharu.kotatsu.core.util.ext.call
|
||||
import org.koitharu.kotatsu.core.util.ext.combine
|
||||
import org.koitharu.kotatsu.core.util.ext.requireValue
|
||||
import org.koitharu.kotatsu.details.data.MangaDetails
|
||||
import org.koitharu.kotatsu.details.domain.DetailsInteractor
|
||||
import org.koitharu.kotatsu.details.ui.DetailsActivity
|
||||
import org.koitharu.kotatsu.details.ui.DetailsViewModel
|
||||
import org.koitharu.kotatsu.details.ui.mapChapters
|
||||
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
|
||||
import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
|
||||
import org.koitharu.kotatsu.history.data.HistoryRepository
|
||||
import org.koitharu.kotatsu.local.domain.DeleteLocalMangaUseCase
|
||||
import org.koitharu.kotatsu.local.domain.model.LocalManga
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.reader.ui.ReaderActivity
|
||||
import org.koitharu.kotatsu.reader.ui.ReaderState
|
||||
import org.koitharu.kotatsu.reader.ui.ReaderViewModel
|
||||
|
||||
abstract class ChaptersPagesViewModel(
|
||||
@JvmField protected val settings: AppSettings,
|
||||
private val interactor: DetailsInteractor,
|
||||
private val bookmarksRepository: BookmarksRepository,
|
||||
private val historyRepository: HistoryRepository,
|
||||
private val downloadScheduler: DownloadWorker.Scheduler,
|
||||
private val deleteLocalMangaUseCase: DeleteLocalMangaUseCase,
|
||||
private val localStorageChanges: SharedFlow<LocalManga?>,
|
||||
) : BaseViewModel() {
|
||||
|
||||
val mangaDetails = MutableStateFlow<MangaDetails?>(null)
|
||||
val readingState = MutableStateFlow<ReaderState?>(null)
|
||||
|
||||
val onActionDone = MutableEventFlow<ReversibleAction>()
|
||||
val onSelectChapter = MutableEventFlow<Long>()
|
||||
val onDownloadStarted = MutableEventFlow<Unit>()
|
||||
val onMangaRemoved = MutableEventFlow<Manga>()
|
||||
|
||||
private val chaptersQuery = MutableStateFlow("")
|
||||
val selectedBranch = MutableStateFlow<String?>(null)
|
||||
|
||||
val manga = mangaDetails.map { x -> x?.toManga() }
|
||||
.withErrorHandling()
|
||||
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)
|
||||
|
||||
val isChaptersReversed = settings.observeAsStateFlow(
|
||||
scope = viewModelScope + Dispatchers.Default,
|
||||
key = AppSettings.KEY_REVERSE_CHAPTERS,
|
||||
valueProducer = { isChaptersReverse },
|
||||
)
|
||||
|
||||
val isChaptersInGridView = settings.observeAsStateFlow(
|
||||
scope = viewModelScope + Dispatchers.Default,
|
||||
key = AppSettings.KEY_GRID_VIEW_CHAPTERS,
|
||||
valueProducer = { isChaptersGridView },
|
||||
)
|
||||
|
||||
val newChaptersCount = mangaDetails.flatMapLatest { d ->
|
||||
if (d?.isLocal == false) {
|
||||
interactor.observeNewChapters(d.id)
|
||||
} else {
|
||||
flowOf(0)
|
||||
}
|
||||
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, 0)
|
||||
|
||||
val isChaptersEmpty: StateFlow<Boolean> = mangaDetails.map {
|
||||
it != null && it.isLoaded && it.allChapters.isEmpty()
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), false)
|
||||
|
||||
val bookmarks = mangaDetails.flatMapLatest {
|
||||
if (it != null) bookmarksRepository.observeBookmarks(it.toManga()) else flowOf(emptyList())
|
||||
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, emptyList())
|
||||
|
||||
val chapters = combine(
|
||||
combine(
|
||||
mangaDetails,
|
||||
readingState.map { it?.chapterId ?: 0L }.distinctUntilChanged(),
|
||||
selectedBranch,
|
||||
newChaptersCount,
|
||||
bookmarks,
|
||||
isChaptersInGridView,
|
||||
) { manga, currentChapterId, branch, news, bookmarks, grid ->
|
||||
manga?.mapChapters(
|
||||
currentChapterId,
|
||||
news,
|
||||
branch,
|
||||
bookmarks,
|
||||
grid,
|
||||
).orEmpty()
|
||||
},
|
||||
isChaptersReversed,
|
||||
chaptersQuery,
|
||||
) { list, reversed, query ->
|
||||
(if (reversed) list.asReversed() else list).filterSearch(query)
|
||||
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList())
|
||||
|
||||
init {
|
||||
launchJob(Dispatchers.Default) {
|
||||
localStorageChanges
|
||||
.collect { onDownloadComplete(it) }
|
||||
}
|
||||
}
|
||||
|
||||
fun setChaptersReversed(newValue: Boolean) {
|
||||
settings.isChaptersReverse = newValue
|
||||
}
|
||||
|
||||
fun setChaptersInGridView(newValue: Boolean) {
|
||||
settings.isChaptersGridView = newValue
|
||||
}
|
||||
|
||||
fun setSelectedBranch(branch: String?) {
|
||||
selectedBranch.value = branch
|
||||
}
|
||||
|
||||
fun performChapterSearch(query: String?) {
|
||||
chaptersQuery.value = query?.trim().orEmpty()
|
||||
}
|
||||
|
||||
fun getMangaOrNull(): Manga? = mangaDetails.value?.toManga()
|
||||
|
||||
fun requireManga() = mangaDetails.requireValue().toManga()
|
||||
|
||||
fun markChapterAsCurrent(chapterId: Long) {
|
||||
launchJob(Dispatchers.Default) {
|
||||
val manga = mangaDetails.requireValue()
|
||||
val chapters = checkNotNull(manga.chapters[selectedBranch.value])
|
||||
val chapterIndex = chapters.indexOfFirst { it.id == chapterId }
|
||||
check(chapterIndex in chapters.indices) { "Chapter not found" }
|
||||
val percent = chapterIndex / chapters.size.toFloat()
|
||||
historyRepository.addOrUpdate(
|
||||
manga = manga.toManga(),
|
||||
chapterId = chapterId,
|
||||
page = 0,
|
||||
scroll = 0,
|
||||
percent = percent,
|
||||
force = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun download(chaptersIds: Set<Long>?) {
|
||||
launchJob(Dispatchers.Default) {
|
||||
downloadScheduler.schedule(
|
||||
requireManga(),
|
||||
chaptersIds,
|
||||
)
|
||||
onDownloadStarted.call(Unit)
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteLocal() {
|
||||
val m = mangaDetails.value?.local?.manga
|
||||
if (m == null) {
|
||||
errorEvent.call(FileNotFoundException())
|
||||
return
|
||||
}
|
||||
launchLoadingJob(Dispatchers.Default) {
|
||||
deleteLocalMangaUseCase(m)
|
||||
onMangaRemoved.call(m)
|
||||
}
|
||||
}
|
||||
|
||||
private fun List<ChapterListItem>.filterSearch(query: String): List<ChapterListItem> {
|
||||
if (query.isEmpty() || this.isEmpty()) {
|
||||
return this
|
||||
}
|
||||
return filter {
|
||||
it.chapter.name.contains(query, ignoreCase = true)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun onDownloadComplete(downloadedManga: LocalManga?) {
|
||||
downloadedManga ?: return
|
||||
mangaDetails.update {
|
||||
interactor.updateLocal(it, downloadedManga)
|
||||
}
|
||||
}
|
||||
|
||||
class ActivityVMLazy(
|
||||
private val fragment: Fragment,
|
||||
) : Lazy<ChaptersPagesViewModel> {
|
||||
private var cached: ChaptersPagesViewModel? = null
|
||||
|
||||
override val value: ChaptersPagesViewModel
|
||||
get() {
|
||||
val viewModel = cached
|
||||
return if (viewModel == null) {
|
||||
val activity = fragment.requireActivity()
|
||||
val vmClass = getViewModelClass(activity)
|
||||
ViewModelProvider.create(
|
||||
store = activity.viewModelStore,
|
||||
factory = activity.defaultViewModelProviderFactory,
|
||||
extras = activity.defaultViewModelCreationExtras,
|
||||
)[vmClass].also { cached = it }
|
||||
} else {
|
||||
viewModel
|
||||
}
|
||||
}
|
||||
|
||||
override fun isInitialized(): Boolean = cached != null
|
||||
|
||||
private fun getViewModelClass(activity: Activity) = when (activity) {
|
||||
is ReaderActivity -> ReaderViewModel::class.java
|
||||
is DetailsActivity -> DetailsViewModel::class.java
|
||||
else -> error("Wrong activity ${activity.javaClass.simpleName} for ${ChaptersPagesViewModel::class.java.simpleName}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.appcompat.view.ActionMode
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import coil.ImageLoader
|
||||
@@ -30,7 +29,7 @@ import org.koitharu.kotatsu.core.util.ext.findParentCallback
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
||||
import org.koitharu.kotatsu.databinding.FragmentMangaBookmarksBinding
|
||||
import org.koitharu.kotatsu.details.ui.DetailsViewModel
|
||||
import org.koitharu.kotatsu.details.ui.pager.ChaptersPagesViewModel
|
||||
import org.koitharu.kotatsu.list.ui.GridSpanResolver
|
||||
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
|
||||
import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
|
||||
@@ -40,9 +39,9 @@ import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class BookmarksFragment : BaseFragment<FragmentMangaBookmarksBinding>(),
|
||||
OnListItemClickListener<Bookmark>, ListSelectionController.Callback2 {
|
||||
OnListItemClickListener<Bookmark>, ListSelectionController.Callback {
|
||||
|
||||
private val activityViewModel by activityViewModels<DetailsViewModel>()
|
||||
private val activityViewModel by ChaptersPagesViewModel.ActivityVMLazy(this)
|
||||
private val viewModel by viewModels<BookmarksViewModel>()
|
||||
|
||||
@Inject
|
||||
@@ -62,7 +61,7 @@ class BookmarksFragment : BaseFragment<FragmentMangaBookmarksBinding>(),
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
activityViewModel.manga.observe(this, viewModel)
|
||||
activityViewModel.mangaDetails.observe(this, viewModel)
|
||||
}
|
||||
|
||||
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentMangaBookmarksBinding {
|
||||
@@ -125,7 +124,7 @@ class BookmarksFragment : BaseFragment<FragmentMangaBookmarksBinding>(),
|
||||
dismissParentDialog()
|
||||
} else {
|
||||
val intent = IntentBuilder(view.context)
|
||||
.manga(activityViewModel.manga.value ?: return)
|
||||
.manga(activityViewModel.getMangaOrNull() ?: return)
|
||||
.bookmark(item)
|
||||
.incognito(true)
|
||||
.build()
|
||||
|
||||
@@ -21,6 +21,7 @@ import org.koitharu.kotatsu.core.ui.BaseViewModel
|
||||
import org.koitharu.kotatsu.core.ui.util.ReversibleAction
|
||||
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
|
||||
import org.koitharu.kotatsu.core.util.ext.call
|
||||
import org.koitharu.kotatsu.details.data.MangaDetails
|
||||
import org.koitharu.kotatsu.list.ui.model.EmptyState
|
||||
import org.koitharu.kotatsu.list.ui.model.ListHeader
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
@@ -32,7 +33,7 @@ import javax.inject.Inject
|
||||
class BookmarksViewModel @Inject constructor(
|
||||
private val bookmarksRepository: BookmarksRepository,
|
||||
settings: AppSettings,
|
||||
) : BaseViewModel(), FlowCollector<Manga?> {
|
||||
) : BaseViewModel(), FlowCollector<MangaDetails?> {
|
||||
|
||||
private val manga = MutableStateFlow<Manga?>(null)
|
||||
val onActionDone = MutableEventFlow<ReversibleAction>()
|
||||
@@ -50,8 +51,8 @@ class BookmarksViewModel @Inject constructor(
|
||||
.filterNotNull()
|
||||
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, listOf(LoadingState))
|
||||
|
||||
override suspend fun emit(value: Manga?) {
|
||||
manga.value = value
|
||||
override suspend fun emit(value: MangaDetails?) {
|
||||
manga.value = value?.toManga()
|
||||
}
|
||||
|
||||
fun removeBookmarks(ids: Set<Long>) {
|
||||
|
||||
@@ -2,26 +2,21 @@ package org.koitharu.kotatsu.details.ui.pager.chapters
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.appcompat.view.ActionMode
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.view.ancestors
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.viewpager2.widget.ViewPager2
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.model.LocalMangaSource
|
||||
import org.koitharu.kotatsu.core.ui.BaseFragment
|
||||
import org.koitharu.kotatsu.core.ui.list.ListSelectionController
|
||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||
@@ -32,28 +27,25 @@ import org.koitharu.kotatsu.core.util.ext.findAppCompatDelegate
|
||||
import org.koitharu.kotatsu.core.util.ext.findParentCallback
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
||||
import org.koitharu.kotatsu.core.util.ext.toCollection
|
||||
import org.koitharu.kotatsu.core.util.ext.toSet
|
||||
import org.koitharu.kotatsu.databinding.FragmentChaptersBinding
|
||||
import org.koitharu.kotatsu.details.ui.DetailsViewModel
|
||||
import org.koitharu.kotatsu.details.ui.adapter.ChaptersAdapter
|
||||
import org.koitharu.kotatsu.details.ui.adapter.ChaptersSelectionDecoration
|
||||
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
|
||||
import org.koitharu.kotatsu.details.ui.pager.ChaptersPagesViewModel
|
||||
import org.koitharu.kotatsu.details.ui.withVolumeHeaders
|
||||
import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
import org.koitharu.kotatsu.local.ui.LocalChaptersRemoveService
|
||||
import org.koitharu.kotatsu.reader.ui.ReaderActivity.IntentBuilder
|
||||
import org.koitharu.kotatsu.reader.ui.ReaderNavigationCallback
|
||||
import org.koitharu.kotatsu.reader.ui.ReaderState
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@AndroidEntryPoint
|
||||
class ChaptersFragment :
|
||||
BaseFragment<FragmentChaptersBinding>(),
|
||||
OnListItemClickListener<ChapterListItem>,
|
||||
ListSelectionController.Callback2 {
|
||||
OnListItemClickListener<ChapterListItem> {
|
||||
|
||||
private val viewModel by activityViewModels<DetailsViewModel>()
|
||||
private val viewModel by ChaptersPagesViewModel.ActivityVMLazy(this)
|
||||
|
||||
private var chaptersAdapter: ChaptersAdapter? = null
|
||||
private var selectionController: ListSelectionController? = null
|
||||
@@ -70,7 +62,7 @@ class ChaptersFragment :
|
||||
appCompatDelegate = checkNotNull(findAppCompatDelegate()),
|
||||
decoration = ChaptersSelectionDecoration(binding.root.context),
|
||||
registryOwner = this,
|
||||
callback = this,
|
||||
callback = ChaptersSelectionCallback(viewModel, binding.recyclerViewChapters),
|
||||
)
|
||||
viewModel.isChaptersInGridView.observe(viewLifecycleOwner) { chaptersInGridView ->
|
||||
binding.recyclerViewChapters.layoutManager = if (chaptersInGridView) {
|
||||
@@ -116,7 +108,7 @@ class ChaptersFragment :
|
||||
} else {
|
||||
startActivity(
|
||||
IntentBuilder(view.context)
|
||||
.manga(viewModel.manga.value ?: return)
|
||||
.manga(viewModel.getMangaOrNull() ?: return)
|
||||
.state(ReaderState(item.chapter.id, 0, 0))
|
||||
.build(),
|
||||
)
|
||||
@@ -127,126 +119,6 @@ class ChaptersFragment :
|
||||
return selectionController?.onItemLongClick(item.chapter.id) ?: false
|
||||
}
|
||||
|
||||
override fun onActionItemClicked(controller: ListSelectionController, mode: ActionMode, item: MenuItem): Boolean {
|
||||
return when (item.itemId) {
|
||||
R.id.action_save -> {
|
||||
viewModel.download(selectionController?.snapshot())
|
||||
mode.finish()
|
||||
true
|
||||
}
|
||||
|
||||
R.id.action_delete -> {
|
||||
val ids = selectionController?.peekCheckedIds()
|
||||
val manga = viewModel.manga.value
|
||||
when {
|
||||
ids == null || ids.isEmpty() || manga == null -> Unit
|
||||
ids.size == manga.chapters?.size -> viewModel.deleteLocal()
|
||||
else -> {
|
||||
LocalChaptersRemoveService.start(requireContext(), manga, ids.toSet())
|
||||
Snackbar.make(
|
||||
requireViewBinding().recyclerViewChapters,
|
||||
R.string.chapters_will_removed_background,
|
||||
Snackbar.LENGTH_LONG,
|
||||
).show()
|
||||
}
|
||||
}
|
||||
mode.finish()
|
||||
true
|
||||
}
|
||||
|
||||
R.id.action_select_range -> {
|
||||
val items = chaptersAdapter?.items ?: return false
|
||||
val ids = controller.peekCheckedIds().toCollection(HashSet())
|
||||
val buffer = HashSet<Long>()
|
||||
var isAdding = false
|
||||
for (x in items) {
|
||||
if (x !is ChapterListItem) {
|
||||
continue
|
||||
}
|
||||
if (x.chapter.id in ids) {
|
||||
isAdding = true
|
||||
if (buffer.isNotEmpty()) {
|
||||
ids.addAll(buffer)
|
||||
buffer.clear()
|
||||
}
|
||||
} else if (isAdding) {
|
||||
buffer.add(x.chapter.id)
|
||||
}
|
||||
}
|
||||
controller.addAll(ids)
|
||||
true
|
||||
}
|
||||
|
||||
R.id.action_select_all -> {
|
||||
val ids = chaptersAdapter?.items?.mapNotNull {
|
||||
if (it is ChapterListItem) {
|
||||
it.chapter.id
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} ?: return false
|
||||
controller.addAll(ids)
|
||||
true
|
||||
}
|
||||
|
||||
R.id.action_mark_current -> {
|
||||
val ids = controller.peekCheckedIds()
|
||||
if (ids.size == 1) {
|
||||
viewModel.markChapterAsCurrent(ids.first())
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
mode.finish()
|
||||
true
|
||||
}
|
||||
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean {
|
||||
mode.menuInflater.inflate(R.menu.mode_chapters, menu)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onPrepareActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean {
|
||||
val selectedIds = selectionController?.peekCheckedIds() ?: return false
|
||||
val allItems = chaptersAdapter?.items.orEmpty()
|
||||
val items = allItems.withIndex().mapNotNull<IndexedValue<ListModel>, IndexedValue<ChapterListItem>> { x ->
|
||||
val value = x.value
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
if (value is ChapterListItem && value.chapter.id in selectedIds) {
|
||||
x as IndexedValue<ChapterListItem>
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
var canSave = true
|
||||
var canDelete = true
|
||||
items.forEach { (_, x) ->
|
||||
val isLocal = x.isDownloaded || x.chapter.source == LocalMangaSource
|
||||
if (isLocal) canSave = false else canDelete = false
|
||||
}
|
||||
menu.findItem(R.id.action_save).isVisible = canSave
|
||||
menu.findItem(R.id.action_delete).isVisible = canDelete
|
||||
menu.findItem(R.id.action_select_all).isVisible = items.size < allItems.size
|
||||
menu.findItem(R.id.action_mark_current).isVisible = items.size == 1
|
||||
mode.title = items.size.toString()
|
||||
var hasGap = false
|
||||
for (i in 0 until items.size - 1) {
|
||||
if (items[i].index + 1 != items[i + 1].index) {
|
||||
hasGap = true
|
||||
break
|
||||
}
|
||||
}
|
||||
menu.findItem(R.id.action_select_range).isVisible = hasGap
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onSelectionChanged(controller: ListSelectionController, count: Int) {
|
||||
viewBinding?.recyclerViewChapters?.invalidateItemDecorations()
|
||||
}
|
||||
|
||||
override fun onWindowInsetsChanged(insets: Insets) = Unit
|
||||
|
||||
private fun onChaptersChanged(list: List<ListModel>) {
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
package org.koitharu.kotatsu.details.ui.pager.chapters
|
||||
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import androidx.appcompat.view.ActionMode
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.model.LocalMangaSource
|
||||
import org.koitharu.kotatsu.core.ui.list.BaseListSelectionCallback
|
||||
import org.koitharu.kotatsu.core.ui.list.ListSelectionController
|
||||
import org.koitharu.kotatsu.core.util.ext.toCollection
|
||||
import org.koitharu.kotatsu.core.util.ext.toSet
|
||||
import org.koitharu.kotatsu.details.ui.pager.ChaptersPagesViewModel
|
||||
import org.koitharu.kotatsu.local.ui.LocalChaptersRemoveService
|
||||
|
||||
class ChaptersSelectionCallback(
|
||||
private val viewModel: ChaptersPagesViewModel,
|
||||
recyclerView: RecyclerView,
|
||||
) : BaseListSelectionCallback(recyclerView) {
|
||||
|
||||
override fun onCreateActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean {
|
||||
mode.menuInflater.inflate(R.menu.mode_chapters, menu)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onPrepareActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean {
|
||||
val selectedIds = controller.peekCheckedIds()
|
||||
val allItems = viewModel.chapters.value
|
||||
val items = allItems.withIndex().filter { it.value.chapter.id in selectedIds }
|
||||
var canSave = true
|
||||
var canDelete = true
|
||||
items.forEach { (_, x) ->
|
||||
val isLocal = x.isDownloaded || x.chapter.source == LocalMangaSource
|
||||
if (isLocal) canSave = false else canDelete = false
|
||||
}
|
||||
menu.findItem(R.id.action_save).isVisible = canSave
|
||||
menu.findItem(R.id.action_delete).isVisible = canDelete
|
||||
menu.findItem(R.id.action_select_all).isVisible = items.size < allItems.size
|
||||
menu.findItem(R.id.action_mark_current).isVisible = items.size == 1
|
||||
mode.title = items.size.toString()
|
||||
var hasGap = false
|
||||
for (i in 0 until items.size - 1) {
|
||||
if (items[i].index + 1 != items[i + 1].index) {
|
||||
hasGap = true
|
||||
break
|
||||
}
|
||||
}
|
||||
menu.findItem(R.id.action_select_range).isVisible = hasGap
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onActionItemClicked(controller: ListSelectionController, mode: ActionMode, item: MenuItem): Boolean {
|
||||
return when (item.itemId) {
|
||||
R.id.action_save -> {
|
||||
viewModel.download(controller.snapshot())
|
||||
mode.finish()
|
||||
true
|
||||
}
|
||||
|
||||
R.id.action_delete -> {
|
||||
val ids = controller.peekCheckedIds()
|
||||
val manga = viewModel.getMangaOrNull()
|
||||
when {
|
||||
ids.isEmpty() || manga == null -> Unit
|
||||
ids.size == manga.chapters?.size -> viewModel.deleteLocal()
|
||||
else -> {
|
||||
LocalChaptersRemoveService.start(recyclerView.context, manga, ids.toSet())
|
||||
Snackbar.make(
|
||||
recyclerView,
|
||||
R.string.chapters_will_removed_background,
|
||||
Snackbar.LENGTH_LONG,
|
||||
).show()
|
||||
}
|
||||
}
|
||||
mode.finish()
|
||||
true
|
||||
}
|
||||
|
||||
R.id.action_select_range -> {
|
||||
val items = viewModel.chapters.value
|
||||
val ids = controller.peekCheckedIds().toCollection(HashSet())
|
||||
val buffer = HashSet<Long>()
|
||||
var isAdding = false
|
||||
for (x in items) {
|
||||
if (x.chapter.id in ids) {
|
||||
isAdding = true
|
||||
if (buffer.isNotEmpty()) {
|
||||
ids.addAll(buffer)
|
||||
buffer.clear()
|
||||
}
|
||||
} else if (isAdding) {
|
||||
buffer.add(x.chapter.id)
|
||||
}
|
||||
}
|
||||
controller.addAll(ids)
|
||||
true
|
||||
}
|
||||
|
||||
R.id.action_select_all -> {
|
||||
val ids = viewModel.chapters.value.map {
|
||||
it.chapter.id
|
||||
}
|
||||
controller.addAll(ids)
|
||||
true
|
||||
}
|
||||
|
||||
R.id.action_mark_current -> {
|
||||
val ids = controller.peekCheckedIds()
|
||||
if (ids.size == 1) {
|
||||
viewModel.markChapterAsCurrent(ids.first())
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
mode.finish()
|
||||
true
|
||||
}
|
||||
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import android.view.ViewGroup
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.view.isInvisible
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
@@ -30,7 +29,7 @@ import org.koitharu.kotatsu.core.util.ext.observe
|
||||
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
||||
import org.koitharu.kotatsu.core.util.ext.showOrHide
|
||||
import org.koitharu.kotatsu.databinding.FragmentPagesBinding
|
||||
import org.koitharu.kotatsu.details.ui.DetailsViewModel
|
||||
import org.koitharu.kotatsu.details.ui.pager.ChaptersPagesViewModel
|
||||
import org.koitharu.kotatsu.list.ui.GridSpanResolver
|
||||
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
|
||||
import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
|
||||
@@ -46,15 +45,15 @@ class PagesFragment :
|
||||
BaseFragment<FragmentPagesBinding>(),
|
||||
OnListItemClickListener<PageThumbnail> {
|
||||
|
||||
private val detailsViewModel by activityViewModels<DetailsViewModel>()
|
||||
private val viewModel by viewModels<PagesViewModel>()
|
||||
|
||||
@Inject
|
||||
lateinit var coil: ImageLoader
|
||||
|
||||
@Inject
|
||||
lateinit var settings: AppSettings
|
||||
|
||||
private val parentViewModel by ChaptersPagesViewModel.ActivityVMLazy(this)
|
||||
private val viewModel by viewModels<PagesViewModel>()
|
||||
|
||||
private var thumbnailsAdapter: PageThumbnailAdapter? = null
|
||||
private var spanResolver: GridSpanResolver? = null
|
||||
private var scrollListener: ScrollListener? = null
|
||||
@@ -64,12 +63,12 @@ class PagesFragment :
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
combine(
|
||||
detailsViewModel.details,
|
||||
detailsViewModel.history,
|
||||
detailsViewModel.selectedBranch,
|
||||
) { details, history, branch ->
|
||||
parentViewModel.mangaDetails,
|
||||
parentViewModel.readingState,
|
||||
parentViewModel.selectedBranch,
|
||||
) { details, readingState, branch ->
|
||||
if (details != null && (details.isLoaded || details.chapters.isNotEmpty())) {
|
||||
PagesViewModel.State(details.filterChapters(branch), history, branch)
|
||||
PagesViewModel.State(details.filterChapters(branch), readingState, branch)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
@@ -102,7 +101,7 @@ class PagesFragment :
|
||||
it.spanCount = checkNotNull(spanResolver).spanCount
|
||||
}
|
||||
}
|
||||
detailsViewModel.isChaptersEmpty.observe(viewLifecycleOwner, ::onNoChaptersChanged)
|
||||
parentViewModel.isChaptersEmpty.observe(viewLifecycleOwner, ::onNoChaptersChanged)
|
||||
viewModel.thumbnails.observe(viewLifecycleOwner, ::onThumbnailsChanged)
|
||||
viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(binding.recyclerView, this))
|
||||
viewModel.isLoading.observe(viewLifecycleOwner) { binding.progressBar.showOrHide(it) }
|
||||
@@ -127,7 +126,7 @@ class PagesFragment :
|
||||
} else {
|
||||
startActivity(
|
||||
IntentBuilder(view.context)
|
||||
.manga(detailsViewModel.manga.value ?: return)
|
||||
.manga(parentViewModel.getMangaOrNull() ?: return)
|
||||
.state(ReaderState(item.page.chapterId, item.page.index, 0))
|
||||
.build(),
|
||||
)
|
||||
|
||||
@@ -7,7 +7,6 @@ import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.plus
|
||||
import org.koitharu.kotatsu.core.model.MangaHistory
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.prefs.observeAsStateFlow
|
||||
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
||||
@@ -16,12 +15,13 @@ import org.koitharu.kotatsu.details.data.MangaDetails
|
||||
import org.koitharu.kotatsu.list.ui.model.ListHeader
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
import org.koitharu.kotatsu.reader.domain.ChaptersLoader
|
||||
import org.koitharu.kotatsu.reader.ui.ReaderState
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class PagesViewModel @Inject constructor(
|
||||
private val chaptersLoader: ChaptersLoader,
|
||||
private val settings: AppSettings,
|
||||
settings: AppSettings,
|
||||
) : BaseViewModel() {
|
||||
|
||||
private var loadingJob: Job? = null
|
||||
@@ -75,13 +75,13 @@ class PagesViewModel @Inject constructor(
|
||||
|
||||
private suspend fun doInit(state: State) {
|
||||
chaptersLoader.init(state.details)
|
||||
val initialChapterId = state.history?.chapterId?.takeIf {
|
||||
val initialChapterId = state.readerState?.chapterId?.takeIf {
|
||||
chaptersLoader.peekChapter(it) != null
|
||||
} ?: state.details.allChapters.firstOrNull()?.id ?: return
|
||||
if (!chaptersLoader.hasPages(initialChapterId)) {
|
||||
chaptersLoader.loadSingleChapter(initialChapterId)
|
||||
}
|
||||
updateList(state.history)
|
||||
updateList(state.readerState)
|
||||
}
|
||||
|
||||
private fun loadPrevNextChapter(isNext: Boolean): Job = launchJob(Dispatchers.Default) {
|
||||
@@ -91,13 +91,13 @@ class PagesViewModel @Inject constructor(
|
||||
val currentState = state.firstNotNull()
|
||||
val currentId = (if (isNext) chaptersLoader.last() else chaptersLoader.first()).chapterId
|
||||
chaptersLoader.loadPrevNextChapter(currentState.details, currentId, isNext)
|
||||
updateList(currentState.history)
|
||||
updateList(currentState.readerState)
|
||||
} finally {
|
||||
indicator.value = false
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateList(history: MangaHistory?) {
|
||||
private fun updateList(readerState: ReaderState?) {
|
||||
val snapshot = chaptersLoader.snapshot()
|
||||
val pages = buildList(snapshot.size + chaptersLoader.size + 2) {
|
||||
var previousChapterId = 0L
|
||||
@@ -109,7 +109,7 @@ class PagesViewModel @Inject constructor(
|
||||
previousChapterId = page.chapterId
|
||||
}
|
||||
this += PageThumbnail(
|
||||
isCurrent = history?.let {
|
||||
isCurrent = readerState?.let {
|
||||
page.chapterId == it.chapterId && page.index == it.page
|
||||
} ?: false,
|
||||
page = page,
|
||||
@@ -121,7 +121,7 @@ class PagesViewModel @Inject constructor(
|
||||
|
||||
data class State(
|
||||
val details: MangaDetails,
|
||||
val history: MangaHistory?,
|
||||
val readerState: ReaderState?,
|
||||
val branch: String?
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
package org.koitharu.kotatsu.download.domain
|
||||
|
||||
data class DownloadProgress(
|
||||
val totalChapters: Int,
|
||||
val currentChapter: Int,
|
||||
val totalPages: Int,
|
||||
val currentPage: Int,
|
||||
)
|
||||
@@ -1,7 +1,7 @@
|
||||
package org.koitharu.kotatsu.download.domain
|
||||
|
||||
import androidx.work.Data
|
||||
import org.koitharu.kotatsu.history.data.PROGRESS_NONE
|
||||
import org.koitharu.kotatsu.list.domain.ReadingProgress.Companion.PROGRESS_NONE
|
||||
import org.koitharu.kotatsu.local.domain.model.LocalManga
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import java.time.Instant
|
||||
@@ -11,12 +11,14 @@ data class DownloadState(
|
||||
val isIndeterminate: Boolean,
|
||||
val isPaused: Boolean = false,
|
||||
val isStopped: Boolean = false,
|
||||
val error: String? = null,
|
||||
val error: Throwable? = null,
|
||||
val errorMessage: String? = null,
|
||||
val totalChapters: Int = 0,
|
||||
val currentChapter: Int = 0,
|
||||
val totalPages: Int = 0,
|
||||
val currentPage: Int = 0,
|
||||
val eta: Long = -1L,
|
||||
val isStuck: Boolean = false,
|
||||
val localManga: LocalManga? = null,
|
||||
val downloadedChapters: Int = 0,
|
||||
val timestamp: Long = System.currentTimeMillis(),
|
||||
@@ -39,8 +41,9 @@ data class DownloadState(
|
||||
.putInt(DATA_MAX, max)
|
||||
.putInt(DATA_PROGRESS, progress)
|
||||
.putLong(DATA_ETA, eta)
|
||||
.putBoolean(DATA_STUCK, isStuck)
|
||||
.putLong(DATA_TIMESTAMP, timestamp)
|
||||
.putString(DATA_ERROR, error)
|
||||
.putString(DATA_ERROR, errorMessage)
|
||||
.putInt(DATA_CHAPTERS, downloadedChapters)
|
||||
.putBoolean(DATA_INDETERMINATE, isIndeterminate)
|
||||
.putBoolean(DATA_PAUSED, isPaused)
|
||||
@@ -53,6 +56,7 @@ data class DownloadState(
|
||||
private const val DATA_PROGRESS = "progress"
|
||||
private const val DATA_CHAPTERS = "chapter_cnt"
|
||||
private const val DATA_ETA = "eta"
|
||||
private const val DATA_STUCK = "stuck"
|
||||
const val DATA_TIMESTAMP = "timestamp"
|
||||
private const val DATA_ERROR = "error"
|
||||
private const val DATA_INDETERMINATE = "indeterminate"
|
||||
@@ -72,6 +76,8 @@ data class DownloadState(
|
||||
|
||||
fun getEta(data: Data): Long = data.getLong(DATA_ETA, -1L)
|
||||
|
||||
fun isStuck(data: Data): Boolean = data.getBoolean(DATA_STUCK, false)
|
||||
|
||||
fun getTimestamp(data: Data): Instant = Instant.ofEpochMilli(data.getLong(DATA_TIMESTAMP, 0L))
|
||||
|
||||
fun getDownloadedChapters(data: Data): Int = data.getInt(DATA_CHAPTERS, 0)
|
||||
|
||||
@@ -45,8 +45,9 @@ fun downloadItemAD(
|
||||
override fun onClick(v: View) {
|
||||
when (v.id) {
|
||||
R.id.button_cancel -> listener.onCancelClick(item)
|
||||
R.id.button_resume -> listener.onResumeClick(item, skip = false)
|
||||
R.id.button_skip -> listener.onResumeClick(item, skip = true)
|
||||
R.id.button_resume -> listener.onResumeClick(item)
|
||||
R.id.button_skip -> listener.onSkipClick(item)
|
||||
R.id.button_skip_all -> listener.onSkipAllClick(item)
|
||||
R.id.button_pause -> listener.onPauseClick(item)
|
||||
R.id.imageView_expand -> listener.onExpandClick(item)
|
||||
else -> listener.onItemClick(item, v)
|
||||
@@ -65,6 +66,7 @@ fun downloadItemAD(
|
||||
binding.buttonPause.setOnClickListener(clickListener)
|
||||
binding.buttonResume.setOnClickListener(clickListener)
|
||||
binding.buttonSkip.setOnClickListener(clickListener)
|
||||
binding.buttonSkipAll.setOnClickListener(clickListener)
|
||||
binding.imageViewExpand.setOnClickListener(clickListener)
|
||||
itemView.setOnClickListener(clickListener)
|
||||
itemView.setOnLongClickListener(clickListener)
|
||||
@@ -136,9 +138,14 @@ fun downloadItemAD(
|
||||
binding.progressBar.setProgressCompat(item.progress, payloads.isNotEmpty())
|
||||
binding.textViewPercent.text = percentPattern.format((item.percent * 100f).format(1))
|
||||
binding.textViewPercent.isVisible = true
|
||||
binding.textViewDetails.textAndVisible = if (item.isPaused) item.error else item.getEtaString()
|
||||
binding.textViewDetails.textAndVisible = when {
|
||||
item.isPaused -> item.getErrorMessage(context)
|
||||
item.isStuck -> context.getString(R.string.stuck)
|
||||
else -> item.getEtaString()
|
||||
}
|
||||
binding.buttonCancel.isVisible = true
|
||||
binding.buttonResume.isVisible = item.isPaused
|
||||
binding.buttonResume.setText(if (item.error == null) R.string.resume else R.string.retry)
|
||||
binding.buttonSkip.isVisible = item.isPaused && item.error != null
|
||||
binding.buttonPause.isVisible = item.canPause
|
||||
}
|
||||
@@ -171,7 +178,7 @@ fun downloadItemAD(
|
||||
binding.progressBar.isVisible = false
|
||||
binding.progressBar.isEnabled = true
|
||||
binding.textViewPercent.isVisible = false
|
||||
binding.textViewDetails.textAndVisible = item.error
|
||||
binding.textViewDetails.textAndVisible = item.getErrorMessage(context)
|
||||
binding.buttonCancel.isVisible = false
|
||||
binding.buttonResume.isVisible = false
|
||||
binding.buttonSkip.isVisible = false
|
||||
|
||||
@@ -8,7 +8,11 @@ interface DownloadItemListener : OnListItemClickListener<DownloadItemModel> {
|
||||
|
||||
fun onPauseClick(item: DownloadItemModel)
|
||||
|
||||
fun onResumeClick(item: DownloadItemModel, skip: Boolean)
|
||||
fun onResumeClick(item: DownloadItemModel)
|
||||
|
||||
fun onSkipClick(item: DownloadItemModel)
|
||||
|
||||
fun onSkipAllClick(item: DownloadItemModel)
|
||||
|
||||
fun onExpandClick(item: DownloadItemModel)
|
||||
}
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
package org.koitharu.kotatsu.download.ui.list
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Color
|
||||
import android.text.format.DateUtils
|
||||
import androidx.core.text.bold
|
||||
import androidx.core.text.buildSpannedString
|
||||
import androidx.core.text.color
|
||||
import androidx.work.WorkInfo
|
||||
import coil.memory.MemoryCache
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import org.koitharu.kotatsu.core.util.ext.getThemeColor
|
||||
import org.koitharu.kotatsu.download.ui.list.chapters.DownloadChapter
|
||||
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import java.time.Instant
|
||||
import java.util.UUID
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
data class DownloadItemModel(
|
||||
val id: UUID,
|
||||
@@ -21,6 +28,7 @@ data class DownloadItemModel(
|
||||
val max: Int,
|
||||
val progress: Int,
|
||||
val eta: Long,
|
||||
val isStuck: Boolean,
|
||||
val timestamp: Instant,
|
||||
val chaptersDownloaded: Int,
|
||||
val isExpanded: Boolean,
|
||||
@@ -51,6 +59,18 @@ data class DownloadItemModel(
|
||||
null
|
||||
}
|
||||
|
||||
fun getErrorMessage(context: Context): CharSequence? = if (error != null) {
|
||||
buildSpannedString {
|
||||
bold {
|
||||
color(context.getThemeColor(materialR.attr.colorError, Color.RED)) {
|
||||
append(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
override fun compareTo(other: DownloadItemModel): Int {
|
||||
return timestamp.compareTo(other.timestamp)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
package org.koitharu.kotatsu.download.ui.list
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
@@ -22,18 +20,21 @@ import org.koitharu.kotatsu.core.util.ext.observe
|
||||
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
||||
import org.koitharu.kotatsu.databinding.ActivityDownloadsBinding
|
||||
import org.koitharu.kotatsu.details.ui.DetailsActivity
|
||||
import org.koitharu.kotatsu.download.ui.worker.PausingReceiver
|
||||
import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
|
||||
import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class DownloadsActivity : BaseActivity<ActivityDownloadsBinding>(),
|
||||
DownloadItemListener,
|
||||
ListSelectionController.Callback2 {
|
||||
ListSelectionController.Callback {
|
||||
|
||||
@Inject
|
||||
lateinit var coil: ImageLoader
|
||||
|
||||
@Inject
|
||||
lateinit var scheduler: DownloadWorker.Scheduler
|
||||
|
||||
private val viewModel by viewModels<DownloadsViewModel>()
|
||||
private lateinit var selectionController: ListSelectionController
|
||||
|
||||
@@ -102,11 +103,19 @@ class DownloadsActivity : BaseActivity<ActivityDownloadsBinding>(),
|
||||
}
|
||||
|
||||
override fun onPauseClick(item: DownloadItemModel) {
|
||||
sendBroadcast(PausingReceiver.getPauseIntent(this, item.id))
|
||||
scheduler.pause(item.id)
|
||||
}
|
||||
|
||||
override fun onResumeClick(item: DownloadItemModel, skip: Boolean) {
|
||||
sendBroadcast(PausingReceiver.getResumeIntent(this, item.id, skip))
|
||||
override fun onResumeClick(item: DownloadItemModel) {
|
||||
scheduler.resume(item.id)
|
||||
}
|
||||
|
||||
override fun onSkipClick(item: DownloadItemModel) {
|
||||
scheduler.skip(item.id)
|
||||
}
|
||||
|
||||
override fun onSkipAllClick(item: DownloadItemModel) {
|
||||
scheduler.skipAll(item.id)
|
||||
}
|
||||
|
||||
override fun onSelectionChanged(controller: ListSelectionController, count: Int) {
|
||||
@@ -171,9 +180,4 @@ class DownloadsActivity : BaseActivity<ActivityDownloadsBinding>(),
|
||||
menu.findItem(R.id.action_remove)?.isVisible = canRemove
|
||||
return super.onPrepareActionMode(controller, mode, menu)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
fun newIntent(context: Context) = Intent(context, DownloadsActivity::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,9 +5,8 @@ import android.view.Menu
|
||||
import android.view.MenuInflater
|
||||
import android.view.MenuItem
|
||||
import androidx.core.view.MenuProvider
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.util.ext.DIALOG_THEME_CENTERED
|
||||
import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog
|
||||
import org.koitharu.kotatsu.settings.SettingsActivity
|
||||
|
||||
class DownloadsMenuProvider(
|
||||
@@ -42,24 +41,22 @@ class DownloadsMenuProvider(
|
||||
}
|
||||
|
||||
private fun confirmCancelAll() {
|
||||
MaterialAlertDialogBuilder(context, DIALOG_THEME_CENTERED)
|
||||
.setTitle(R.string.cancel_all)
|
||||
.setMessage(R.string.cancel_all_downloads_confirm)
|
||||
.setIcon(R.drawable.ic_cancel_multiple)
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.setPositiveButton(R.string.confirm) { _, _ ->
|
||||
viewModel.cancelAll()
|
||||
}.show()
|
||||
buildAlertDialog(context, isCentered = true) {
|
||||
setTitle(R.string.cancel_all)
|
||||
setMessage(R.string.cancel_all_downloads_confirm)
|
||||
setIcon(R.drawable.ic_cancel_multiple)
|
||||
setNegativeButton(android.R.string.cancel, null)
|
||||
setPositiveButton(R.string.confirm) { _, _ -> viewModel.cancelAll() }
|
||||
}.show()
|
||||
}
|
||||
|
||||
private fun confirmRemoveCompleted() {
|
||||
MaterialAlertDialogBuilder(context, DIALOG_THEME_CENTERED)
|
||||
.setTitle(R.string.remove_completed)
|
||||
.setMessage(R.string.remove_completed_downloads_confirm)
|
||||
.setIcon(R.drawable.ic_clear_all)
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.setPositiveButton(R.string.clear) { _, _ ->
|
||||
viewModel.removeCompleted()
|
||||
}.show()
|
||||
buildAlertDialog(context, isCentered = true) {
|
||||
setTitle(R.string.remove_completed)
|
||||
setMessage(R.string.remove_completed_downloads_confirm)
|
||||
setIcon(R.drawable.ic_clear_all)
|
||||
setNegativeButton(android.R.string.cancel, null)
|
||||
setPositiveButton(R.string.clear) { _, _ -> viewModel.removeCompleted() }
|
||||
}.show()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,7 +143,7 @@ class DownloadsViewModel @Inject constructor(
|
||||
var isResumed = false
|
||||
for (work in snapshot) {
|
||||
if (work.workState == WorkInfo.State.RUNNING && work.isPaused) {
|
||||
workScheduler.resume(work.id, skipError = false)
|
||||
workScheduler.resume(work.id)
|
||||
isResumed = true
|
||||
}
|
||||
}
|
||||
@@ -156,7 +156,7 @@ class DownloadsViewModel @Inject constructor(
|
||||
val snapshot = works.value ?: return
|
||||
for (work in snapshot) {
|
||||
if (work.id.mostSignificantBits in ids) {
|
||||
workScheduler.resume(work.id, skipError = false)
|
||||
workScheduler.resume(work.id)
|
||||
}
|
||||
}
|
||||
onActionDone.call(ReversibleAction(R.string.downloads_resumed, null))
|
||||
@@ -268,6 +268,7 @@ class DownloadsViewModel @Inject constructor(
|
||||
max = DownloadState.getMax(workData),
|
||||
progress = DownloadState.getProgress(workData),
|
||||
eta = DownloadState.getEta(workData),
|
||||
isStuck = DownloadState.isStuck(workData),
|
||||
timestamp = DownloadState.getTimestamp(workData),
|
||||
chaptersDownloaded = DownloadState.getDownloadedChapters(workData),
|
||||
isExpanded = isExpanded,
|
||||
|
||||
@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.download.ui.worker
|
||||
import android.app.Notification
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.text.format.DateUtils
|
||||
import androidx.core.app.NotificationChannelCompat
|
||||
@@ -21,8 +22,10 @@ import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.ErrorReporterReceiver
|
||||
import org.koitharu.kotatsu.core.model.LocalMangaSource
|
||||
import org.koitharu.kotatsu.core.util.ext.getDrawableOrThrow
|
||||
import org.koitharu.kotatsu.core.util.ext.isReportable
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.details.ui.DetailsActivity
|
||||
import org.koitharu.kotatsu.download.domain.DownloadState
|
||||
@@ -57,7 +60,7 @@ class DownloadNotificationFactory @AssistedInject constructor(
|
||||
private val queueIntent = PendingIntentCompat.getActivity(
|
||||
context,
|
||||
0,
|
||||
DownloadsActivity.newIntent(context),
|
||||
Intent(context, DownloadsActivity::class.java),
|
||||
0,
|
||||
false,
|
||||
)
|
||||
@@ -82,7 +85,15 @@ class DownloadNotificationFactory @AssistedInject constructor(
|
||||
NotificationCompat.Action(
|
||||
R.drawable.ic_action_resume,
|
||||
context.getString(R.string.resume),
|
||||
PausingReceiver.createResumePendingIntent(context, uuid, skipError = false),
|
||||
PausingReceiver.createResumePendingIntent(context, uuid),
|
||||
)
|
||||
}
|
||||
|
||||
private val actionRetry by lazy {
|
||||
NotificationCompat.Action(
|
||||
R.drawable.ic_retry,
|
||||
context.getString(R.string.retry),
|
||||
actionResume.actionIntent,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -90,7 +101,7 @@ class DownloadNotificationFactory @AssistedInject constructor(
|
||||
NotificationCompat.Action(
|
||||
R.drawable.ic_action_skip,
|
||||
context.getString(R.string.skip),
|
||||
PausingReceiver.createResumePendingIntent(context, uuid, skipError = true),
|
||||
PausingReceiver.createSkipPendingIntent(context, uuid),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -160,8 +171,14 @@ class DownloadNotificationFactory @AssistedInject constructor(
|
||||
} else {
|
||||
null
|
||||
}
|
||||
if (state.error != null) {
|
||||
builder.setContentText(context.getString(R.string.download_summary_pattern, percent, state.error))
|
||||
if (state.errorMessage != null) {
|
||||
builder.setContentText(
|
||||
context.getString(
|
||||
R.string.download_summary_pattern,
|
||||
percent,
|
||||
state.errorMessage,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
builder.setContentText(percent)
|
||||
}
|
||||
@@ -170,9 +187,11 @@ class DownloadNotificationFactory @AssistedInject constructor(
|
||||
builder.setOngoing(true)
|
||||
builder.setSmallIcon(R.drawable.ic_stat_paused)
|
||||
builder.addAction(actionCancel)
|
||||
builder.addAction(actionResume)
|
||||
if (state.error != null) {
|
||||
if (state.errorMessage != null) {
|
||||
builder.addAction(actionRetry)
|
||||
builder.addAction(actionSkip)
|
||||
} else {
|
||||
builder.addAction(actionResume)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,18 +199,27 @@ class DownloadNotificationFactory @AssistedInject constructor(
|
||||
builder.setProgress(0, 0, false)
|
||||
builder.setSmallIcon(android.R.drawable.stat_notify_error)
|
||||
builder.setSubText(context.getString(R.string.error))
|
||||
builder.setContentText(state.error)
|
||||
builder.setContentText(state.errorMessage)
|
||||
builder.setAutoCancel(true)
|
||||
builder.setOngoing(false)
|
||||
builder.setCategory(NotificationCompat.CATEGORY_ERROR)
|
||||
builder.setShowWhen(true)
|
||||
builder.setWhen(System.currentTimeMillis())
|
||||
builder.setStyle(NotificationCompat.BigTextStyle().bigText(state.error))
|
||||
builder.setStyle(NotificationCompat.BigTextStyle().bigText(state.errorMessage))
|
||||
if (state.error.isReportable()) {
|
||||
builder.addAction(
|
||||
NotificationCompat.Action(
|
||||
0,
|
||||
context.getString(R.string.report),
|
||||
ErrorReporterReceiver.getPendingIntent(context, state.error),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
builder.setProgress(state.max, state.progress, false)
|
||||
builder.setContentText(getProgressString(state.percent, state.eta))
|
||||
builder.setContentText(getProgressString(state.percent, state.eta, state.isStuck))
|
||||
builder.setCategory(NotificationCompat.CATEGORY_PROGRESS)
|
||||
builder.setStyle(null)
|
||||
builder.setOngoing(true)
|
||||
@@ -202,20 +230,20 @@ class DownloadNotificationFactory @AssistedInject constructor(
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
private fun getProgressString(percent: Float, eta: Long): CharSequence? {
|
||||
private fun getProgressString(percent: Float, eta: Long, isStuck: Boolean): CharSequence? {
|
||||
val percentString = if (percent >= 0f) {
|
||||
context.getString(R.string.percent_string_pattern, (percent * 100).format())
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val etaString = if (eta > 0L) {
|
||||
DateUtils.getRelativeTimeSpanString(
|
||||
val etaString = when {
|
||||
eta <= 0L -> null
|
||||
isStuck -> context.getString(R.string.stuck)
|
||||
else -> DateUtils.getRelativeTimeSpanString(
|
||||
eta,
|
||||
System.currentTimeMillis(),
|
||||
DateUtils.SECOND_IN_MILLIS,
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
return when {
|
||||
percentString == null && etaString == null -> null
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.koitharu.kotatsu.download.ui.worker
|
||||
|
||||
import android.content.Intent
|
||||
import android.view.View
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import kotlinx.coroutines.flow.FlowCollector
|
||||
@@ -18,7 +19,7 @@ class DownloadStartedObserver(
|
||||
snackbar.anchorView = it.bottomNav
|
||||
}
|
||||
snackbar.setAction(R.string.details) {
|
||||
it.context.startActivity(DownloadsActivity.newIntent(it.context))
|
||||
it.context.startActivity(Intent(it.context, DownloadsActivity::class.java))
|
||||
}
|
||||
snackbar.show()
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.channelFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.sync.withPermit
|
||||
@@ -41,7 +42,6 @@ import okio.buffer
|
||||
import okio.sink
|
||||
import okio.use
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.exceptions.TooManyRequestExceptions
|
||||
import org.koitharu.kotatsu.core.model.ids
|
||||
import org.koitharu.kotatsu.core.model.isLocal
|
||||
import org.koitharu.kotatsu.core.network.MangaHttpClient
|
||||
@@ -62,8 +62,10 @@ import org.koitharu.kotatsu.core.util.ext.getWorkInputData
|
||||
import org.koitharu.kotatsu.core.util.ext.getWorkSpec
|
||||
import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.core.util.ext.withTicker
|
||||
import org.koitharu.kotatsu.core.util.ext.writeAllCancellable
|
||||
import org.koitharu.kotatsu.core.util.progress.TimeLeftEstimator
|
||||
import org.koitharu.kotatsu.core.util.progress.RealtimeEtaEstimator
|
||||
import org.koitharu.kotatsu.download.domain.DownloadProgress
|
||||
import org.koitharu.kotatsu.download.domain.DownloadState
|
||||
import org.koitharu.kotatsu.local.data.LocalMangaRepository
|
||||
import org.koitharu.kotatsu.local.data.LocalStorageChanges
|
||||
@@ -71,7 +73,9 @@ import org.koitharu.kotatsu.local.data.PagesCache
|
||||
import org.koitharu.kotatsu.local.data.TempFileFilter
|
||||
import org.koitharu.kotatsu.local.data.input.LocalMangaInput
|
||||
import org.koitharu.kotatsu.local.data.output.LocalMangaOutput
|
||||
import org.koitharu.kotatsu.local.domain.MangaLock
|
||||
import org.koitharu.kotatsu.local.domain.model.LocalManga
|
||||
import org.koitharu.kotatsu.parsers.exception.TooManyRequestExceptions
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
@@ -91,6 +95,7 @@ class DownloadWorker @AssistedInject constructor(
|
||||
@MangaHttpClient private val okHttp: OkHttpClient,
|
||||
private val cache: PagesCache,
|
||||
private val localMangaRepository: LocalMangaRepository,
|
||||
private val mangaLock: MangaLock,
|
||||
private val mangaDataRepository: MangaDataRepository,
|
||||
private val mangaRepositoryFactory: MangaRepository.Factory,
|
||||
private val settings: AppSettings,
|
||||
@@ -108,7 +113,7 @@ class DownloadWorker @AssistedInject constructor(
|
||||
private val currentState: DownloadState
|
||||
get() = checkNotNull(lastPublishedState)
|
||||
|
||||
private val timeLeftEstimator = TimeLeftEstimator()
|
||||
private val etaEstimator = RealtimeEtaEstimator()
|
||||
private val notificationThrottler = Throttler(400)
|
||||
|
||||
override suspend fun doWork(): Result {
|
||||
@@ -130,17 +135,16 @@ class DownloadWorker @AssistedInject constructor(
|
||||
notificationManager.notify(id.hashCode(), notification)
|
||||
}
|
||||
Result.failure(
|
||||
currentState.copy(eta = -1L).toWorkData(),
|
||||
currentState.copy(eta = -1L, isStuck = false).toWorkData(),
|
||||
)
|
||||
} catch (e: IOException) {
|
||||
e.printStackTraceDebug()
|
||||
Result.retry()
|
||||
} catch (e: Exception) {
|
||||
e.printStackTraceDebug()
|
||||
Result.failure(
|
||||
currentState.copy(
|
||||
error = e.getDisplayMessage(applicationContext.resources),
|
||||
error = e,
|
||||
errorMessage = e.getDisplayMessage(applicationContext.resources),
|
||||
eta = -1L,
|
||||
isStuck = false,
|
||||
).toWorkData(),
|
||||
)
|
||||
} finally {
|
||||
@@ -169,7 +173,7 @@ class DownloadWorker @AssistedInject constructor(
|
||||
var manga = subject
|
||||
val chaptersToSkip = excludedIds.toMutableSet()
|
||||
val pausingReceiver = PausingReceiver(id, PausingHandle.current())
|
||||
withMangaLock(manga) {
|
||||
mangaLock.withLock(manga) {
|
||||
ContextCompat.registerReceiver(
|
||||
applicationContext,
|
||||
pausingReceiver,
|
||||
@@ -229,15 +233,23 @@ class DownloadWorker @AssistedInject constructor(
|
||||
}
|
||||
}
|
||||
}
|
||||
}.collect {
|
||||
}.map {
|
||||
DownloadProgress(
|
||||
totalChapters = chapters.size,
|
||||
currentChapter = chapterIndex,
|
||||
totalPages = pages.size,
|
||||
currentPage = pageCounter.getAndIncrement(),
|
||||
)
|
||||
}.withTicker(2L, TimeUnit.SECONDS).collect { progress ->
|
||||
publishState(
|
||||
currentState.copy(
|
||||
totalChapters = chapters.size,
|
||||
currentChapter = chapterIndex,
|
||||
totalPages = pages.size,
|
||||
currentPage = pageCounter.incrementAndGet(),
|
||||
totalChapters = progress.totalChapters,
|
||||
currentChapter = progress.currentChapter,
|
||||
totalPages = progress.totalPages,
|
||||
currentPage = progress.currentPage,
|
||||
isIndeterminate = false,
|
||||
eta = timeLeftEstimator.getEta(),
|
||||
eta = etaEstimator.getEta(),
|
||||
isStuck = etaEstimator.isStuck(),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -248,15 +260,20 @@ class DownloadWorker @AssistedInject constructor(
|
||||
}
|
||||
publishState(currentState.copy(downloadedChapters = currentState.downloadedChapters + 1))
|
||||
}
|
||||
publishState(currentState.copy(isIndeterminate = true, eta = -1L))
|
||||
publishState(currentState.copy(isIndeterminate = true, eta = -1L, isStuck = false))
|
||||
output.mergeWithExisting()
|
||||
output.finish()
|
||||
val localManga = LocalMangaInput.of(output.rootFile).getManga()
|
||||
localStorageChanges.emit(localManga)
|
||||
publishState(currentState.copy(localManga = localManga, eta = -1L))
|
||||
publishState(currentState.copy(localManga = localManga, eta = -1L, isStuck = false))
|
||||
} catch (e: Exception) {
|
||||
if (e !is CancellationException) {
|
||||
publishState(currentState.copy(error = e.getDisplayMessage(applicationContext.resources)))
|
||||
publishState(
|
||||
currentState.copy(
|
||||
error = e,
|
||||
errorMessage = e.getDisplayMessage(applicationContext.resources),
|
||||
),
|
||||
)
|
||||
}
|
||||
throw e
|
||||
} finally {
|
||||
@@ -281,12 +298,19 @@ class DownloadWorker @AssistedInject constructor(
|
||||
try {
|
||||
return block()
|
||||
} catch (e: IOException) {
|
||||
if (countDown <= 0) {
|
||||
val retryDelay = if (e is TooManyRequestExceptions) {
|
||||
e.getRetryDelay()
|
||||
} else {
|
||||
DOWNLOAD_ERROR_DELAY
|
||||
}
|
||||
if (countDown <= 0 || retryDelay < 0 || retryDelay > MAX_RETRY_DELAY) {
|
||||
publishState(
|
||||
currentState.copy(
|
||||
isPaused = true,
|
||||
error = e.getDisplayMessage(applicationContext.resources),
|
||||
error = e,
|
||||
errorMessage = e.getDisplayMessage(applicationContext.resources),
|
||||
eta = -1L,
|
||||
isStuck = false,
|
||||
),
|
||||
)
|
||||
countDown = MAX_FAILSAFE_ATTEMPTS
|
||||
@@ -298,15 +322,10 @@ class DownloadWorker @AssistedInject constructor(
|
||||
return null
|
||||
}
|
||||
} finally {
|
||||
publishState(currentState.copy(isPaused = false, error = null))
|
||||
publishState(currentState.copy(isPaused = false, error = null, errorMessage = null))
|
||||
}
|
||||
} else {
|
||||
countDown--
|
||||
val retryDelay = if (e is TooManyRequestExceptions) {
|
||||
e.retryAfter + DOWNLOAD_ERROR_DELAY
|
||||
} else {
|
||||
DOWNLOAD_ERROR_DELAY
|
||||
}
|
||||
delay(retryDelay)
|
||||
}
|
||||
}
|
||||
@@ -316,7 +335,7 @@ class DownloadWorker @AssistedInject constructor(
|
||||
private suspend fun checkIsPaused() {
|
||||
val pausingHandle = PausingHandle.current()
|
||||
if (pausingHandle.isPaused) {
|
||||
publishState(currentState.copy(isPaused = true, eta = -1L))
|
||||
publishState(currentState.copy(isPaused = true, eta = -1L, isStuck = false))
|
||||
try {
|
||||
pausingHandle.awaitResumed()
|
||||
} finally {
|
||||
@@ -354,9 +373,9 @@ class DownloadWorker @AssistedInject constructor(
|
||||
val previousState = currentState
|
||||
lastPublishedState = state
|
||||
if (previousState.isParticularProgress && state.isParticularProgress) {
|
||||
timeLeftEstimator.tick(state.progress, state.max)
|
||||
etaEstimator.onProgressChanged(state.progress, state.max)
|
||||
} else {
|
||||
timeLeftEstimator.emptyTick()
|
||||
etaEstimator.reset()
|
||||
notificationThrottler.reset()
|
||||
}
|
||||
val notification = notificationFactory.create(state)
|
||||
@@ -399,13 +418,6 @@ class DownloadWorker @AssistedInject constructor(
|
||||
return result
|
||||
}
|
||||
|
||||
private suspend inline fun <T> withMangaLock(manga: Manga, block: () -> T) = try {
|
||||
localMangaRepository.lockManga(manga.id)
|
||||
block()
|
||||
} finally {
|
||||
localMangaRepository.unlockManga(manga.id)
|
||||
}
|
||||
|
||||
@Reusable
|
||||
class Scheduler @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
@@ -458,15 +470,21 @@ class DownloadWorker @AssistedInject constructor(
|
||||
workManager.cancelAllWorkByTag(TAG).await()
|
||||
}
|
||||
|
||||
fun pause(id: UUID) {
|
||||
val intent = PausingReceiver.getPauseIntent(context, id)
|
||||
context.sendBroadcast(intent)
|
||||
}
|
||||
fun pause(id: UUID) = context.sendBroadcast(
|
||||
PausingReceiver.getPauseIntent(context, id),
|
||||
)
|
||||
|
||||
fun resume(id: UUID, skipError: Boolean) {
|
||||
val intent = PausingReceiver.getResumeIntent(context, id, skipError)
|
||||
context.sendBroadcast(intent)
|
||||
}
|
||||
fun resume(id: UUID) = context.sendBroadcast(
|
||||
PausingReceiver.getResumeIntent(context, id),
|
||||
)
|
||||
|
||||
fun skip(id: UUID) = context.sendBroadcast(
|
||||
PausingReceiver.getSkipIntent(context, id),
|
||||
)
|
||||
|
||||
fun skipAll(id: UUID) = context.sendBroadcast(
|
||||
PausingReceiver.getSkipAllIntent(context, id),
|
||||
)
|
||||
|
||||
suspend fun delete(id: UUID) {
|
||||
workManager.deleteWork(id)
|
||||
@@ -526,7 +544,8 @@ class DownloadWorker @AssistedInject constructor(
|
||||
|
||||
const val MAX_FAILSAFE_ATTEMPTS = 2
|
||||
const val MAX_PAGES_PARALLELISM = 4
|
||||
const val DOWNLOAD_ERROR_DELAY = 500L
|
||||
const val DOWNLOAD_ERROR_DELAY = 2_000L
|
||||
const val MAX_RETRY_DELAY = 7_200_000L // 2 hours
|
||||
const val SLOWDOWN_DELAY = 200L
|
||||
const val MANGA_ID = "manga_id"
|
||||
const val CHAPTERS_IDS = "chapters"
|
||||
|
||||
@@ -10,7 +10,10 @@ import kotlin.coroutines.CoroutineContext
|
||||
class PausingHandle : AbstractCoroutineContextElement(PausingHandle) {
|
||||
|
||||
private val paused = MutableStateFlow(false)
|
||||
private val isSkipError = MutableStateFlow(false)
|
||||
private val skipError = MutableStateFlow(false)
|
||||
|
||||
@Volatile
|
||||
private var skipAllErrors = false
|
||||
|
||||
@get:AnyThread
|
||||
val isPaused: Boolean
|
||||
@@ -27,18 +30,30 @@ class PausingHandle : AbstractCoroutineContextElement(PausingHandle) {
|
||||
}
|
||||
|
||||
@AnyThread
|
||||
fun resume(skipError: Boolean) {
|
||||
isSkipError.value = skipError
|
||||
fun resume() {
|
||||
skipError.value = false
|
||||
paused.value = false
|
||||
}
|
||||
|
||||
@AnyThread
|
||||
fun skip() {
|
||||
skipError.value = true
|
||||
paused.value = false
|
||||
}
|
||||
|
||||
@AnyThread
|
||||
fun skipAll() {
|
||||
skipAllErrors = true
|
||||
skip()
|
||||
}
|
||||
|
||||
suspend fun yield() {
|
||||
if (paused.value) {
|
||||
paused.first { !it }
|
||||
}
|
||||
}
|
||||
|
||||
fun skipCurrentError(): Boolean = isSkipError.compareAndSet(expect = true, update = false)
|
||||
fun skipCurrentError(): Boolean = skipError.compareAndSet(expect = true, update = skipAllErrors)
|
||||
|
||||
companion object : CoroutineContext.Key<PausingHandle> {
|
||||
|
||||
|
||||
@@ -21,8 +21,9 @@ class PausingReceiver(
|
||||
return
|
||||
}
|
||||
when (intent.action) {
|
||||
ACTION_RESUME -> pausingHandle.resume(skipError = false)
|
||||
ACTION_SKIP -> pausingHandle.resume(skipError = true)
|
||||
ACTION_RESUME -> pausingHandle.resume()
|
||||
ACTION_SKIP -> pausingHandle.skip()
|
||||
ACTION_SKIP_ALL -> pausingHandle.skipAll()
|
||||
ACTION_PAUSE -> pausingHandle.pause()
|
||||
}
|
||||
}
|
||||
@@ -32,6 +33,7 @@ class PausingReceiver(
|
||||
private const val ACTION_PAUSE = "org.koitharu.kotatsu.download.PAUSE"
|
||||
private const val ACTION_RESUME = "org.koitharu.kotatsu.download.RESUME"
|
||||
private const val ACTION_SKIP = "org.koitharu.kotatsu.download.SKIP"
|
||||
private const val ACTION_SKIP_ALL = "org.koitharu.kotatsu.download.SKIP_ALL"
|
||||
private const val EXTRA_UUID = "uuid"
|
||||
private const val SCHEME = "workuid"
|
||||
|
||||
@@ -39,20 +41,18 @@ class PausingReceiver(
|
||||
addAction(ACTION_PAUSE)
|
||||
addAction(ACTION_RESUME)
|
||||
addAction(ACTION_SKIP)
|
||||
addAction(ACTION_SKIP_ALL)
|
||||
addDataScheme(SCHEME)
|
||||
addDataPath(id.toString(), PatternMatcher.PATTERN_SIMPLE_GLOB)
|
||||
addDataPath(id.toString(), PatternMatcher.PATTERN_LITERAL)
|
||||
}
|
||||
|
||||
fun getPauseIntent(context: Context, id: UUID) = Intent(ACTION_PAUSE)
|
||||
.setData(Uri.parse("$SCHEME://$id"))
|
||||
.setPackage(context.packageName)
|
||||
.putExtra(EXTRA_UUID, id.toString())
|
||||
fun getPauseIntent(context: Context, id: UUID) = createIntent(context, id, ACTION_PAUSE)
|
||||
|
||||
fun getResumeIntent(context: Context, id: UUID, skipError: Boolean) = Intent(
|
||||
if (skipError) ACTION_SKIP else ACTION_RESUME,
|
||||
).setData(Uri.parse("$SCHEME://$id"))
|
||||
.setPackage(context.packageName)
|
||||
.putExtra(EXTRA_UUID, id.toString())
|
||||
fun getResumeIntent(context: Context, id: UUID) = createIntent(context, id, ACTION_RESUME)
|
||||
|
||||
fun getSkipIntent(context: Context, id: UUID) = createIntent(context, id, ACTION_SKIP)
|
||||
|
||||
fun getSkipAllIntent(context: Context, id: UUID) = createIntent(context, id, ACTION_SKIP_ALL)
|
||||
|
||||
fun createPausePendingIntent(context: Context, id: UUID) = PendingIntentCompat.getBroadcast(
|
||||
context,
|
||||
@@ -62,13 +62,27 @@ class PausingReceiver(
|
||||
false,
|
||||
)
|
||||
|
||||
fun createResumePendingIntent(context: Context, id: UUID, skipError: Boolean) =
|
||||
fun createResumePendingIntent(context: Context, id: UUID) =
|
||||
PendingIntentCompat.getBroadcast(
|
||||
context,
|
||||
0,
|
||||
getResumeIntent(context, id, skipError),
|
||||
getResumeIntent(context, id),
|
||||
0,
|
||||
false,
|
||||
)
|
||||
|
||||
fun createSkipPendingIntent(context: Context, id: UUID) =
|
||||
PendingIntentCompat.getBroadcast(
|
||||
context,
|
||||
0,
|
||||
getSkipIntent(context, id),
|
||||
0,
|
||||
false,
|
||||
)
|
||||
|
||||
private fun createIntent(context: Context, id: UUID, action: String) = Intent(action)
|
||||
.setData(Uri.parse("$SCHEME://$id"))
|
||||
.setPackage(context.packageName)
|
||||
.putExtra(EXTRA_UUID, id.toString())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import org.koitharu.kotatsu.core.parser.external.ExternalMangaSource
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.prefs.observeAsFlow
|
||||
import org.koitharu.kotatsu.core.ui.util.ReversibleHandle
|
||||
import org.koitharu.kotatsu.core.util.ext.flattenLatest
|
||||
import org.koitharu.kotatsu.parsers.model.ContentType
|
||||
import org.koitharu.kotatsu.parsers.model.MangaParserSource
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
@@ -61,7 +62,13 @@ class MangaSourcesRepository @Inject constructor(
|
||||
suspend fun getEnabledSources(): List<MangaSource> {
|
||||
assimilateNewSources()
|
||||
val order = settings.sourcesSortOrder
|
||||
return dao.findAllEnabled(order).toSources(settings.isNsfwContentDisabled, order)
|
||||
return dao.findAllEnabled(order).toSources(settings.isNsfwContentDisabled, order).let { enabled ->
|
||||
val external = getExternalSources()
|
||||
val list = ArrayList<MangaSourceInfo>(enabled.size + external.size)
|
||||
external.mapTo(list) { MangaSourceInfo(it, isEnabled = true, isPinned = true) }
|
||||
list.addAll(enabled)
|
||||
list
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getPinnedSources(): Set<MangaSource> {
|
||||
@@ -162,7 +169,7 @@ class MangaSourcesRepository @Inject constructor(
|
||||
dao.observeEnabled(order).map {
|
||||
it.toSources(skipNsfw, order)
|
||||
}
|
||||
}.flatMapLatest { it }
|
||||
}.flattenLatest()
|
||||
.onStart { assimilateNewSources() }
|
||||
.combine(observeExternalSources()) { enabled, external ->
|
||||
val list = ArrayList<MangaSourceInfo>(enabled.size + external.size)
|
||||
@@ -308,8 +315,6 @@ class MangaSourcesRepository @Inject constructor(
|
||||
}
|
||||
|
||||
private fun observeExternalSources(): Flow<List<ExternalMangaSource>> {
|
||||
val intent = Intent("app.kotatsu.parser.PROVIDE_MANGA")
|
||||
val pm = context.packageManager
|
||||
return callbackFlow {
|
||||
val receiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
@@ -333,15 +338,19 @@ class MangaSourcesRepository @Inject constructor(
|
||||
}.onStart {
|
||||
emit(null)
|
||||
}.map {
|
||||
pm.queryIntentContentProviders(intent, 0).map { resolveInfo ->
|
||||
ExternalMangaSource(
|
||||
packageName = resolveInfo.providerInfo.packageName,
|
||||
authority = resolveInfo.providerInfo.authority,
|
||||
)
|
||||
}
|
||||
getExternalSources()
|
||||
}.distinctUntilChanged()
|
||||
}
|
||||
|
||||
private fun getExternalSources() = context.packageManager.queryIntentContentProviders(
|
||||
Intent("app.kotatsu.parser.PROVIDE_MANGA"), 0,
|
||||
).map { resolveInfo ->
|
||||
ExternalMangaSource(
|
||||
packageName = resolveInfo.providerInfo.packageName,
|
||||
authority = resolveInfo.providerInfo.authority,
|
||||
)
|
||||
}
|
||||
|
||||
private fun List<MangaSourceEntity>.toSources(
|
||||
skipNsfwSources: Boolean,
|
||||
sortOrder: SourcesSortOrder?,
|
||||
|
||||
@@ -56,7 +56,7 @@ class ExploreFragment :
|
||||
BaseFragment<FragmentExploreBinding>(),
|
||||
RecyclerViewOwner,
|
||||
ExploreListEventListener,
|
||||
OnListItemClickListener<MangaSourceItem>, ListSelectionController.Callback2 {
|
||||
OnListItemClickListener<MangaSourceItem>, ListSelectionController.Callback {
|
||||
|
||||
@Inject
|
||||
lateinit var coil: ImageLoader
|
||||
@@ -129,7 +129,7 @@ class ExploreFragment :
|
||||
R.id.button_local -> MangaListActivity.newIntent(v.context, LocalMangaSource)
|
||||
R.id.button_bookmarks -> AllBookmarksActivity.newIntent(v.context)
|
||||
R.id.button_more -> SuggestionsActivity.newIntent(v.context)
|
||||
R.id.button_downloads -> DownloadsActivity.newIntent(v.context)
|
||||
R.id.button_downloads -> Intent(v.context, DownloadsActivity::class.java)
|
||||
R.id.button_random -> {
|
||||
viewModel.openRandom()
|
||||
return
|
||||
@@ -257,6 +257,7 @@ class ExploreFragment :
|
||||
val action = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
Intent.ACTION_DELETE
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
Intent.ACTION_UNINSTALL_PACKAGE
|
||||
}
|
||||
context?.startActivity(Intent(action, uri))
|
||||
|
||||
@@ -4,6 +4,7 @@ import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room.RoomWarnings
|
||||
import androidx.room.Upsert
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@@ -51,6 +52,10 @@ abstract class FavouriteCategoriesDao {
|
||||
@Query("SELECT MAX(sort_key) FROM favourite_categories WHERE deleted_at = 0")
|
||||
protected abstract suspend fun getMaxSortKey(): Int?
|
||||
|
||||
@SuppressWarnings(RoomWarnings.CURSOR_MISMATCH) // for the new_chapters column
|
||||
@Query("SELECT favourite_categories.*, (SELECT SUM(chapters_new) FROM tracks WHERE tracks.manga_id IN (SELECT manga_id FROM favourites WHERE favourites.category_id = favourite_categories.category_id)) AS new_chapters FROM favourite_categories WHERE track = 1 AND show_in_lib = 1 AND deleted_at = 0 AND new_chapters > 0 ORDER BY new_chapters DESC LIMIT :limit")
|
||||
abstract suspend fun getMostUpdatedCategories(limit: Int): List<FavouriteCategoryEntity>
|
||||
|
||||
suspend fun getNextSortKey(): Int {
|
||||
return (getMaxSortKey() ?: 0) + 1
|
||||
}
|
||||
|
||||
@@ -11,11 +11,15 @@ import androidx.sqlite.db.SimpleSQLiteQuery
|
||||
import androidx.sqlite.db.SupportSQLiteQuery
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import org.intellij.lang.annotations.Language
|
||||
import org.koitharu.kotatsu.core.db.MangaQueryBuilder
|
||||
import org.koitharu.kotatsu.core.db.TABLE_FAVOURITES
|
||||
import org.koitharu.kotatsu.favourites.domain.model.Cover
|
||||
import org.koitharu.kotatsu.list.domain.ListFilterOption
|
||||
import org.koitharu.kotatsu.list.domain.ListSortOrder
|
||||
import org.koitharu.kotatsu.list.domain.ReadingProgress.Companion.PROGRESS_COMPLETED
|
||||
|
||||
@Dao
|
||||
abstract class FavouritesDao {
|
||||
abstract class FavouritesDao : MangaQueryBuilder.ConditionCallback {
|
||||
|
||||
/** SELECT **/
|
||||
|
||||
@@ -27,27 +31,17 @@ abstract class FavouritesDao {
|
||||
@Query("SELECT * FROM favourites WHERE deleted_at = 0 GROUP BY manga_id ORDER BY created_at DESC LIMIT :limit")
|
||||
abstract suspend fun findLast(limit: Int): List<FavouriteManga>
|
||||
|
||||
fun observeAll(order: ListSortOrder, limit: Int): Flow<List<FavouriteManga>> {
|
||||
val orderBy = getOrderBy(order)
|
||||
val query = buildString {
|
||||
append(
|
||||
"SELECT * FROM favourites LEFT JOIN manga ON favourites.manga_id = manga.manga_id " +
|
||||
"WHERE favourites.deleted_at = 0 GROUP BY favourites.manga_id ORDER BY ",
|
||||
)
|
||||
append(orderBy)
|
||||
if (limit > 0) {
|
||||
append(" LIMIT ")
|
||||
append(limit)
|
||||
}
|
||||
}
|
||||
return observeAllImpl(SimpleSQLiteQuery(query))
|
||||
}
|
||||
fun observeAll(
|
||||
order: ListSortOrder,
|
||||
filterOptions: Set<ListFilterOption>,
|
||||
limit: Int
|
||||
): Flow<List<FavouriteManga>> = observeAll(0L, order, filterOptions, limit)
|
||||
|
||||
@Transaction
|
||||
@Query("SELECT * FROM favourites WHERE deleted_at = 0 ORDER BY created_at DESC LIMIT :limit OFFSET :offset")
|
||||
abstract suspend fun findAllRaw(offset: Int, limit: Int): List<FavouriteManga>
|
||||
|
||||
@Query("SELECT DISTINCT manga_id FROM favourites WHERE deleted_at = 0 AND category_id IN (SELECT category_id FROM favourite_categories WHERE track = 1)")
|
||||
@Query("SELECT DISTINCT manga_id FROM favourites WHERE deleted_at = 0 AND category_id IN (SELECT category_id FROM favourite_categories WHERE track = 1 AND deleted_at = 0)")
|
||||
abstract suspend fun findIdsWithTrack(): LongArray
|
||||
|
||||
@Transaction
|
||||
@@ -57,22 +51,28 @@ abstract class FavouritesDao {
|
||||
)
|
||||
abstract suspend fun findAll(categoryId: Long): List<FavouriteManga>
|
||||
|
||||
fun observeAll(categoryId: Long, order: ListSortOrder, limit: Int): Flow<List<FavouriteManga>> {
|
||||
val orderBy = getOrderBy(order)
|
||||
val query = buildString {
|
||||
append(
|
||||
"SELECT * FROM favourites LEFT JOIN manga ON favourites.manga_id = manga.manga_id " +
|
||||
"WHERE category_id = ? AND deleted_at = 0 GROUP BY favourites.manga_id ORDER BY ",
|
||||
fun observeAll(
|
||||
categoryId: Long,
|
||||
order: ListSortOrder,
|
||||
filterOptions: Set<ListFilterOption>,
|
||||
limit: Int
|
||||
): Flow<List<FavouriteManga>> = observeAllImpl(
|
||||
MangaQueryBuilder(TABLE_FAVOURITES, this)
|
||||
.join("LEFT JOIN manga ON favourites.manga_id = manga.manga_id")
|
||||
.where("deleted_at = 0")
|
||||
.where(
|
||||
if (categoryId != 0L) {
|
||||
"category_id = $categoryId"
|
||||
} else {
|
||||
"(SELECT show_in_lib FROM favourite_categories WHERE favourite_categories.category_id = favourites.category_id) = 1"
|
||||
},
|
||||
)
|
||||
append(orderBy)
|
||||
if (limit > 0) {
|
||||
append(" LIMIT ")
|
||||
append(limit)
|
||||
}
|
||||
}
|
||||
|
||||
return observeAllImpl(SimpleSQLiteQuery(query, arrayOf<Any>(categoryId)))
|
||||
}
|
||||
.filters(filterOptions)
|
||||
.groupBy("favourites.manga_id")
|
||||
.orderBy(getOrderBy(order))
|
||||
.limit(limit)
|
||||
.build(),
|
||||
)
|
||||
|
||||
suspend fun findCovers(categoryId: Long, order: ListSortOrder): List<Cover> {
|
||||
val orderBy = getOrderBy(order)
|
||||
@@ -94,7 +94,9 @@ abstract class FavouritesDao {
|
||||
val query = SimpleSQLiteQuery(
|
||||
"SELECT manga.cover_url AS url, manga.source AS source FROM favourites " +
|
||||
"LEFT JOIN manga ON favourites.manga_id = manga.manga_id " +
|
||||
"WHERE deleted_at = 0 GROUP BY manga.manga_id ORDER BY $orderBy LIMIT ?",
|
||||
"WHERE deleted_at = 0 AND " +
|
||||
"(SELECT show_in_lib FROM favourite_categories WHERE favourite_categories.category_id = favourites.category_id) = 1 " +
|
||||
"GROUP BY manga.manga_id ORDER BY $orderBy LIMIT ?",
|
||||
arrayOf<Any>(limit),
|
||||
)
|
||||
return findCoversImpl(query)
|
||||
@@ -112,8 +114,8 @@ abstract class FavouritesDao {
|
||||
@Query("SELECT favourite_categories.* FROM favourites LEFT JOIN favourite_categories ON favourite_categories.category_id = favourites.category_id WHERE favourites.manga_id = :mangaId AND favourites.deleted_at = 0")
|
||||
abstract fun observeCategories(mangaId: Long): Flow<List<FavouriteCategoryEntity>>
|
||||
|
||||
@Query("SELECT DISTINCT category_id FROM favourites WHERE manga_id IN (:mangaIds) AND deleted_at = 0 ORDER BY favourites.created_at ASC")
|
||||
abstract suspend fun findCategoriesIds(mangaIds: Collection<Long>): List<Long>
|
||||
@Query("SELECT DISTINCT category_id FROM favourites WHERE manga_id = :mangaId AND deleted_at = 0 ORDER BY favourites.created_at ASC")
|
||||
abstract suspend fun findCategoriesIds(mangaId: Long): List<Long>
|
||||
|
||||
@Query("SELECT COUNT(category_id) FROM favourites WHERE manga_id = :mangaId AND deleted_at = 0")
|
||||
abstract suspend fun findCategoriesCount(mangaId: Long): Int
|
||||
@@ -191,4 +193,12 @@ abstract class FavouritesDao {
|
||||
|
||||
else -> throw IllegalArgumentException("Sort order $sortOrder is not supported")
|
||||
}
|
||||
|
||||
override fun getCondition(option: ListFilterOption): String? = when (option) {
|
||||
ListFilterOption.Macro.COMPLETED -> "EXISTS(SELECT * FROM history WHERE history.manga_id = favourites.manga_id AND history.percent >= $PROGRESS_COMPLETED)"
|
||||
ListFilterOption.Macro.NEW_CHAPTERS -> "(SELECT chapters_new FROM tracks WHERE tracks.manga_id = favourites.manga_id) > 0"
|
||||
ListFilterOption.Macro.NSFW -> "manga.nsfw = 1"
|
||||
is ListFilterOption.Tag -> "EXISTS(SELECT * FROM manga_tags WHERE favourites.manga_id = manga_tags.manga_id AND tag_id = ${option.tagId})"
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package org.koitharu.kotatsu.favourites.domain
|
||||
|
||||
import org.koitharu.kotatsu.core.os.NetworkState
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.list.domain.ListFilterOption
|
||||
import org.koitharu.kotatsu.list.domain.MangaListQuickFilter
|
||||
import javax.inject.Inject
|
||||
|
||||
class FavoritesListQuickFilter @Inject constructor(
|
||||
private val settings: AppSettings,
|
||||
private val repository: FavouritesRepository,
|
||||
networkState: NetworkState,
|
||||
) : MangaListQuickFilter(settings) {
|
||||
|
||||
init {
|
||||
setFilterOption(ListFilterOption.Downloaded, !networkState.value)
|
||||
}
|
||||
|
||||
override suspend fun getAvailableFilterOptions(): List<ListFilterOption> = buildList {
|
||||
add(ListFilterOption.Downloaded)
|
||||
if (settings.isTrackerEnabled) {
|
||||
add(ListFilterOption.Macro.NEW_CHAPTERS)
|
||||
}
|
||||
add(ListFilterOption.Macro.COMPLETED)
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import org.koitharu.kotatsu.favourites.data.toFavouriteCategory
|
||||
import org.koitharu.kotatsu.favourites.data.toManga
|
||||
import org.koitharu.kotatsu.favourites.data.toMangaList
|
||||
import org.koitharu.kotatsu.favourites.domain.model.Cover
|
||||
import org.koitharu.kotatsu.list.domain.ListFilterOption
|
||||
import org.koitharu.kotatsu.list.domain.ListSortOrder
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import javax.inject.Inject
|
||||
@@ -26,6 +27,7 @@ import javax.inject.Inject
|
||||
@Reusable
|
||||
class FavouritesRepository @Inject constructor(
|
||||
private val db: MangaDatabase,
|
||||
private val localObserver: LocalFavoritesObserver,
|
||||
) {
|
||||
|
||||
suspend fun getAllManga(): List<Manga> {
|
||||
@@ -38,8 +40,11 @@ class FavouritesRepository @Inject constructor(
|
||||
return entities.toMangaList()
|
||||
}
|
||||
|
||||
fun observeAll(order: ListSortOrder, limit: Int): Flow<List<Manga>> {
|
||||
return db.getFavouritesDao().observeAll(order, limit)
|
||||
fun observeAll(order: ListSortOrder, filterOptions: Set<ListFilterOption>, limit: Int): Flow<List<Manga>> {
|
||||
if (ListFilterOption.Downloaded in filterOptions) {
|
||||
return localObserver.observeAll(order, filterOptions - ListFilterOption.Downloaded, limit)
|
||||
}
|
||||
return db.getFavouritesDao().observeAll(order, filterOptions, limit)
|
||||
.mapItems { it.toManga() }
|
||||
}
|
||||
|
||||
@@ -48,14 +53,22 @@ class FavouritesRepository @Inject constructor(
|
||||
return entities.toMangaList()
|
||||
}
|
||||
|
||||
fun observeAll(categoryId: Long, order: ListSortOrder, limit: Int): Flow<List<Manga>> {
|
||||
return db.getFavouritesDao().observeAll(categoryId, order, limit)
|
||||
fun observeAll(
|
||||
categoryId: Long,
|
||||
order: ListSortOrder,
|
||||
filterOptions: Set<ListFilterOption>,
|
||||
limit: Int
|
||||
): Flow<List<Manga>> {
|
||||
if (ListFilterOption.Downloaded in filterOptions) {
|
||||
return localObserver.observeAll(categoryId, order, filterOptions - ListFilterOption.Downloaded, limit)
|
||||
}
|
||||
return db.getFavouritesDao().observeAll(categoryId, order, filterOptions, limit)
|
||||
.mapItems { it.toManga() }
|
||||
}
|
||||
|
||||
fun observeAll(categoryId: Long, limit: Int): Flow<List<Manga>> {
|
||||
fun observeAll(categoryId: Long, filterOptions: Set<ListFilterOption>, limit: Int): Flow<List<Manga>> {
|
||||
return observeOrder(categoryId)
|
||||
.flatMapLatest { order -> observeAll(categoryId, order, limit) }
|
||||
.flatMapLatest { order -> observeAll(categoryId, order, filterOptions, limit) }
|
||||
}
|
||||
|
||||
fun observeMangaCount(): Flow<Int> {
|
||||
@@ -119,8 +132,8 @@ class FavouritesRepository @Inject constructor(
|
||||
return db.getFavouritesDao().findCategoriesCount(mangaId) != 0
|
||||
}
|
||||
|
||||
suspend fun getCategoriesIds(mangaIds: Collection<Long>): Set<Long> {
|
||||
return db.getFavouritesDao().findCategoriesIds(mangaIds).toSet()
|
||||
suspend fun getCategoriesIds(mangaId: Long): Set<Long> {
|
||||
return db.getFavouritesDao().findCategoriesIds(mangaId).toSet()
|
||||
}
|
||||
|
||||
suspend fun createCategory(
|
||||
@@ -227,6 +240,12 @@ class FavouritesRepository @Inject constructor(
|
||||
.distinctUntilChanged()
|
||||
}
|
||||
|
||||
suspend fun getMostUpdatedCategories(limit: Int): List<FavouriteCategory> {
|
||||
return db.getFavouriteCategoriesDao().getMostUpdatedCategories(limit).map {
|
||||
it.toFavouriteCategory()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun recoverToFavourites(ids: Collection<Long>) {
|
||||
db.withTransaction {
|
||||
for (id in ids) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user