Compare commits
1 Commits
v7.0
...
feature/do
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5df55d1fe9 |
2
.github/FUNDING.yml
vendored
Normal file
2
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
ko_fi: xtimms
|
||||||
|
custom: ["https://yoomoney.ru/to/410012543938752"]
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -10,13 +10,11 @@
|
|||||||
/.idea/compiler.xml
|
/.idea/compiler.xml
|
||||||
/.idea/workspace.xml
|
/.idea/workspace.xml
|
||||||
/.idea/navEditor.xml
|
/.idea/navEditor.xml
|
||||||
/.idea/ktlint-plugin.xml
|
|
||||||
/.idea/assetWizardSettings.xml
|
/.idea/assetWizardSettings.xml
|
||||||
/.idea/kotlinScripting.xml
|
/.idea/kotlinScripting.xml
|
||||||
/.idea/kotlinc.xml
|
/.idea/kotlinc.xml
|
||||||
/.idea/deploymentTargetDropDown.xml
|
/.idea/deploymentTargetDropDown.xml
|
||||||
/.idea/androidTestResultsUserPreferences.xml
|
/.idea/androidTestResultsUserPreferences.xml
|
||||||
/.idea/deploymentTargetSelector.xml
|
|
||||||
/.idea/render.experimental.xml
|
/.idea/render.experimental.xml
|
||||||
/.idea/inspectionProfiles/
|
/.idea/inspectionProfiles/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
@@ -24,4 +22,3 @@
|
|||||||
/captures
|
/captures
|
||||||
.externalNativeBuild
|
.externalNativeBuild
|
||||||
.cxx
|
.cxx
|
||||||
/.idea/deviceManager.xml
|
|
||||||
|
|||||||
3
.idea/gradle.xml
generated
3
.idea/gradle.xml
generated
@@ -4,8 +4,9 @@
|
|||||||
<component name="GradleSettings">
|
<component name="GradleSettings">
|
||||||
<option name="linkedExternalProjectsSettings">
|
<option name="linkedExternalProjectsSettings">
|
||||||
<GradleProjectSettings>
|
<GradleProjectSettings>
|
||||||
|
<option name="testRunner" value="GRADLE" />
|
||||||
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
||||||
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
|
<option name="gradleJvm" value="jbr-17" />
|
||||||
<option name="modules">
|
<option name="modules">
|
||||||
<set>
|
<set>
|
||||||
<option value="$PROJECT_DIR$" />
|
<option value="$PROJECT_DIR$" />
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
## Kotatsu contribution guidelines
|
## Kotatsu contribution guidelines
|
||||||
|
|
||||||
+ If you want to **fix bugs** or **implement new features** that **already have an [issue card](https://github.com/KotatsuApp/Kotatsu/issues):** please assign this issue to you and/or comment about it.
|
- If you want to fix bug or implement a new feature, that already mention in the [issues](https://github.com/KotatsuApp/Kotatsu/issues), please, assign this issue to you and/or comment about it.
|
||||||
+ If you want to **implement a new feature:** open an issue or discussion regarding it to ensure it will be accepted.
|
- Whether you have to implement new feature, please, open an issue or discussion regarding it to ensure it will be accepted.
|
||||||
+ **Translations** have to be managed using the [Weblate](https://hosted.weblate.org/engage/kotatsu/) platform.
|
- Translations have to be managed using the [Weblate](https://hosted.weblate.org/engage/kotatsu/) platform.
|
||||||
+ In case you want to **add a new manga source,** refer to the [parsers repository](https://github.com/KotatsuApp/kotatsu-parsers).
|
- In case you want to add a new manga source, refer to the [parsers repository](https://github.com/KotatsuApp/kotatsu-parsers).
|
||||||
|
|
||||||
**Refactoring** or some **dev-faces improvements** might also be accepted. However, please stick to the following principles:
|
Refactoring or some dev-faces improvements are also might be accepted, however please stick to the following principles:
|
||||||
|
- Performance matters. In the case of choosing between source code beauty and performance, performance should be a priority.
|
||||||
+ **Performance matters.** In the case of choosing between source code beauty and performance, performance should be a priority.
|
- Please, do not modify readme and other information files (except for typos).
|
||||||
+ Please, **do not modify readme and other information files** (except for typos).
|
- Avoid adding new dependencies unless required. APK size is important.
|
||||||
+ **Avoid adding new dependencies** unless required. APK size is important.
|
|
||||||
|
|||||||
53
LICENSE
53
LICENSE
@@ -619,3 +619,56 @@ Program, unless a warranty or assumption of liability accompanies a
|
|||||||
copy of the Program in return for a fee.
|
copy of the Program in return for a fee.
|
||||||
|
|
||||||
END OF TERMS AND CONDITIONS
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
How to Apply These Terms to Your New Programs
|
||||||
|
|
||||||
|
If you develop a new program, and you want it to be of the greatest
|
||||||
|
possible use to the public, the best way to achieve this is to make it
|
||||||
|
free software which everyone can redistribute and change under these terms.
|
||||||
|
|
||||||
|
To do so, attach the following notices to the program. It is safest
|
||||||
|
to attach them to the start of each source file to most effectively
|
||||||
|
state the exclusion of warranty; and each file should have at least
|
||||||
|
the "copyright" line and a pointer to where the full notice is found.
|
||||||
|
|
||||||
|
<one line to give the program's name and a brief idea of what it does.>
|
||||||
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
|
If the program does terminal interaction, make it output a short
|
||||||
|
notice like this when it starts in an interactive mode:
|
||||||
|
|
||||||
|
<program> Copyright (C) <year> <name of author>
|
||||||
|
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||||
|
This is free software, and you are welcome to redistribute it
|
||||||
|
under certain conditions; type `show c' for details.
|
||||||
|
|
||||||
|
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||||
|
parts of the General Public License. Of course, your program's commands
|
||||||
|
might be different; for a GUI interface, you would use an "about box".
|
||||||
|
|
||||||
|
You should also get your employer (if you work as a programmer) or school,
|
||||||
|
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||||
|
For more information on this, and how to apply and follow the GNU GPL, see
|
||||||
|
<https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
The GNU General Public License does not permit incorporating your program
|
||||||
|
into proprietary programs. If your program is a subroutine library, you
|
||||||
|
may consider it more useful to permit linking proprietary applications with
|
||||||
|
the library. If this is what you want to do, use the GNU Lesser General
|
||||||
|
Public License instead of this License. But first, please read
|
||||||
|
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ Kotatsu is a free and open source manga reader for Android.
|
|||||||
* Tablet-optimized Material You UI
|
* Tablet-optimized Material You UI
|
||||||
* Standard and Webtoon-optimized reader
|
* Standard and Webtoon-optimized reader
|
||||||
* Notifications about new chapters with updates feed
|
* Notifications about new chapters with updates feed
|
||||||
* Integration with manga tracking services: Shikimori, AniList, MyAnimeList, Kitsu
|
* Integration with manga tracking services: Shikimori, AniList, MyAnimeList
|
||||||
* Password/fingerprint protect access to the app
|
* Password/fingerprint protect access to the app
|
||||||
* History and favourites [synchronization](https://github.com/KotatsuApp/kotatsu-syncserver) across devices
|
* History and favourites [synchronization](https://github.com/KotatsuApp/kotatsu-syncserver) across devices
|
||||||
|
|
||||||
|
|||||||
102
app/build.gradle
102
app/build.gradle
@@ -16,13 +16,12 @@ android {
|
|||||||
applicationId 'org.koitharu.kotatsu'
|
applicationId 'org.koitharu.kotatsu'
|
||||||
minSdk = 21
|
minSdk = 21
|
||||||
targetSdk = 34
|
targetSdk = 34
|
||||||
versionCode = 641
|
versionCode = 584
|
||||||
versionName = '7.0'
|
versionName = '6.1.6'
|
||||||
generatedDensities = []
|
generatedDensities = []
|
||||||
testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner'
|
testInstrumentationRunner "org.koitharu.kotatsu.HiltTestRunner"
|
||||||
ksp {
|
ksp {
|
||||||
arg('room.generateKotlin', 'true')
|
arg("room.schemaLocation", "$projectDir/schemas")
|
||||||
arg('room.schemaLocation', "$projectDir/schemas")
|
|
||||||
}
|
}
|
||||||
androidResources {
|
androidResources {
|
||||||
generateLocaleConfig true
|
generateLocaleConfig true
|
||||||
@@ -33,6 +32,7 @@ android {
|
|||||||
applicationIdSuffix = '.debug'
|
applicationIdSuffix = '.debug'
|
||||||
}
|
}
|
||||||
release {
|
release {
|
||||||
|
multiDexEnabled false
|
||||||
minifyEnabled true
|
minifyEnabled true
|
||||||
shrinkResources true
|
shrinkResources true
|
||||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||||
@@ -47,12 +47,11 @@ android {
|
|||||||
main.java.srcDirs += 'src/main/kotlin/'
|
main.java.srcDirs += 'src/main/kotlin/'
|
||||||
}
|
}
|
||||||
compileOptions {
|
compileOptions {
|
||||||
coreLibraryDesugaringEnabled true
|
sourceCompatibility JavaVersion.VERSION_17
|
||||||
sourceCompatibility JavaVersion.VERSION_1_8
|
targetCompatibility JavaVersion.VERSION_17
|
||||||
targetCompatibility JavaVersion.VERSION_1_8
|
|
||||||
}
|
}
|
||||||
kotlinOptions {
|
kotlinOptions {
|
||||||
jvmTarget = JavaVersion.VERSION_1_8.toString()
|
jvmTarget = JavaVersion.VERSION_17.toString()
|
||||||
freeCompilerArgs += [
|
freeCompilerArgs += [
|
||||||
'-opt-in=kotlin.ExperimentalStdlibApi',
|
'-opt-in=kotlin.ExperimentalStdlibApi',
|
||||||
'-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi',
|
'-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi',
|
||||||
@@ -82,33 +81,32 @@ afterEvaluate {
|
|||||||
}
|
}
|
||||||
dependencies {
|
dependencies {
|
||||||
//noinspection GradleDependency
|
//noinspection GradleDependency
|
||||||
implementation('com.github.KotatsuApp:kotatsu-parsers:3e32a6280a') {
|
implementation('com.github.KotatsuApp:kotatsu-parsers:400a90464e') {
|
||||||
exclude group: 'org.json', module: 'json'
|
exclude group: 'org.json', module: 'json'
|
||||||
}
|
}
|
||||||
|
|
||||||
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.4'
|
implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.9.10'
|
||||||
implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.9.24'
|
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3'
|
||||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1'
|
|
||||||
|
|
||||||
implementation 'androidx.appcompat:appcompat:1.6.1'
|
implementation 'androidx.appcompat:appcompat:1.6.1'
|
||||||
implementation 'androidx.core:core-ktx:1.13.1'
|
implementation 'androidx.core:core-ktx:1.12.0'
|
||||||
implementation 'androidx.activity:activity-ktx:1.9.0'
|
implementation 'androidx.activity:activity-ktx:1.8.0'
|
||||||
implementation 'androidx.fragment:fragment-ktx:1.7.0'
|
implementation 'androidx.fragment:fragment-ktx:1.6.1'
|
||||||
implementation 'androidx.collection:collection-ktx:1.4.0'
|
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.2'
|
||||||
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0'
|
implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.6.2'
|
||||||
implementation 'androidx.lifecycle:lifecycle-service:2.7.0'
|
implementation 'androidx.lifecycle:lifecycle-service:2.6.2'
|
||||||
implementation 'androidx.lifecycle:lifecycle-process:2.7.0'
|
implementation 'androidx.lifecycle:lifecycle-process:2.6.2'
|
||||||
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
|
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
|
||||||
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
|
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
|
||||||
implementation 'androidx.recyclerview:recyclerview:1.3.2'
|
implementation 'androidx.recyclerview:recyclerview:1.3.1'
|
||||||
implementation 'androidx.viewpager2:viewpager2:1.1.0-rc01'
|
implementation 'androidx.viewpager2:viewpager2:1.1.0-beta02'
|
||||||
implementation 'androidx.preference:preference-ktx:1.2.1'
|
implementation 'androidx.preference:preference-ktx:1.2.1'
|
||||||
implementation 'androidx.biometric:biometric-ktx:1.2.0-alpha05'
|
implementation 'androidx.biometric:biometric-ktx:1.2.0-alpha05'
|
||||||
implementation 'com.google.android.material:material:1.12.0'
|
implementation 'com.google.android.material:material:1.10.0'
|
||||||
implementation 'androidx.lifecycle:lifecycle-common-java8:2.7.0'
|
implementation 'androidx.lifecycle:lifecycle-common-java8:2.6.2'
|
||||||
implementation 'androidx.webkit:webkit:1.11.0'
|
|
||||||
|
|
||||||
implementation 'androidx.work:work-runtime:2.9.0'
|
// TODO https://issuetracker.google.com/issues/254846063
|
||||||
|
implementation 'androidx.work:work-runtime-ktx:2.8.1'
|
||||||
//noinspection GradleDependency
|
//noinspection GradleDependency
|
||||||
implementation('com.google.guava:guava:32.0.1-android') {
|
implementation('com.google.guava:guava:32.0.1-android') {
|
||||||
exclude group: 'com.google.guava', module: 'failureaccess'
|
exclude group: 'com.google.guava', module: 'failureaccess'
|
||||||
@@ -116,53 +114,47 @@ dependencies {
|
|||||||
exclude group: 'com.google.j2objc', module: 'j2objc-annotations'
|
exclude group: 'com.google.j2objc', module: 'j2objc-annotations'
|
||||||
}
|
}
|
||||||
|
|
||||||
implementation 'androidx.room:room-runtime:2.6.1'
|
implementation 'androidx.room:room-runtime:2.5.2'
|
||||||
implementation 'androidx.room:room-ktx:2.6.1'
|
implementation 'androidx.room:room-ktx:2.5.2'
|
||||||
ksp 'androidx.room:room-compiler:2.6.1'
|
ksp 'androidx.room:room-compiler:2.5.2'
|
||||||
|
|
||||||
implementation 'com.squareup.okhttp3:okhttp:4.12.0'
|
implementation 'com.squareup.okhttp3:okhttp:4.11.0'
|
||||||
implementation 'com.squareup.okhttp3:okhttp-tls:4.12.0'
|
implementation 'com.squareup.okhttp3:okhttp-dnsoverhttps:4.11.0'
|
||||||
implementation 'com.squareup.okhttp3:okhttp-dnsoverhttps:4.12.0'
|
implementation 'com.squareup.okio:okio:3.6.0'
|
||||||
implementation 'com.squareup.okio:okio:3.9.0'
|
|
||||||
|
|
||||||
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.2'
|
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.2'
|
||||||
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:4.3.2'
|
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:4.3.2'
|
||||||
|
|
||||||
implementation 'com.google.dagger:hilt-android:2.51.1'
|
implementation 'com.google.dagger:hilt-android:2.48.1'
|
||||||
kapt 'com.google.dagger:hilt-compiler:2.51.1'
|
kapt 'com.google.dagger:hilt-compiler:2.48.1'
|
||||||
implementation 'androidx.hilt:hilt-work:1.2.0'
|
implementation 'androidx.hilt:hilt-work:1.0.0'
|
||||||
kapt 'androidx.hilt:hilt-compiler:1.2.0'
|
kapt 'androidx.hilt:hilt-compiler:1.0.0'
|
||||||
|
|
||||||
implementation 'io.coil-kt:coil-base:2.6.0'
|
implementation 'io.coil-kt:coil-base:2.4.0'
|
||||||
implementation 'io.coil-kt:coil-svg:2.6.0'
|
implementation 'io.coil-kt:coil-svg:2.4.0'
|
||||||
implementation 'com.github.KotatsuApp:subsampling-scale-image-view:02e6d6cfe9'
|
implementation 'com.github.KotatsuApp:subsampling-scale-image-view:169806d928'
|
||||||
implementation 'com.github.solkin:disk-lru-cache:1.4'
|
implementation 'com.github.solkin:disk-lru-cache:1.4'
|
||||||
implementation 'io.noties.markwon:core:4.6.2'
|
implementation 'io.noties.markwon:core:4.6.2'
|
||||||
|
|
||||||
implementation 'ch.acra:acra-http:5.11.3'
|
implementation 'ch.acra:acra-http:5.11.2'
|
||||||
implementation 'ch.acra:acra-dialog:5.11.3'
|
implementation 'ch.acra:acra-dialog:5.11.2'
|
||||||
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'
|
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.12'
|
||||||
|
|
||||||
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.14'
|
|
||||||
debugImplementation 'com.github.Koitharu:WorkInspector:5778dd1747'
|
|
||||||
|
|
||||||
testImplementation 'junit:junit:4.13.2'
|
testImplementation 'junit:junit:4.13.2'
|
||||||
testImplementation 'org.json:json:20240303'
|
testImplementation 'org.json:json:20230618'
|
||||||
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1'
|
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3'
|
||||||
|
|
||||||
androidTestImplementation 'androidx.test:runner:1.5.2'
|
androidTestImplementation 'androidx.test:runner:1.5.2'
|
||||||
androidTestImplementation 'androidx.test:rules:1.5.0'
|
androidTestImplementation 'androidx.test:rules:1.5.0'
|
||||||
androidTestImplementation 'androidx.test:core-ktx:1.5.0'
|
androidTestImplementation 'androidx.test:core-ktx:1.5.0'
|
||||||
androidTestImplementation 'androidx.test.ext:junit-ktx:1.1.5'
|
androidTestImplementation 'androidx.test.ext:junit-ktx:1.1.5'
|
||||||
|
|
||||||
androidTestImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1'
|
androidTestImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3'
|
||||||
|
|
||||||
androidTestImplementation 'androidx.room:room-testing:2.6.1'
|
androidTestImplementation 'androidx.room:room-testing:2.5.2'
|
||||||
androidTestImplementation 'com.squareup.moshi:moshi-kotlin:1.15.1'
|
androidTestImplementation 'com.squareup.moshi:moshi-kotlin:1.15.0'
|
||||||
|
|
||||||
androidTestImplementation 'com.google.dagger:hilt-android-testing:2.51.1'
|
androidTestImplementation 'com.google.dagger:hilt-android-testing:2.48.1'
|
||||||
kaptAndroidTest 'com.google.dagger:hilt-android-compiler:2.51.1'
|
kaptAndroidTest 'com.google.dagger:hilt-android-compiler:2.48.1'
|
||||||
}
|
}
|
||||||
|
|||||||
3
app/proguard-rules.pro
vendored
3
app/proguard-rules.pro
vendored
@@ -18,6 +18,3 @@
|
|||||||
-keep class org.koitharu.kotatsu.core.exceptions.* { *; }
|
-keep class org.koitharu.kotatsu.core.exceptions.* { *; }
|
||||||
-keep class org.koitharu.kotatsu.settings.NotificationSettingsLegacyFragment
|
-keep class org.koitharu.kotatsu.settings.NotificationSettingsLegacyFragment
|
||||||
-keep class org.koitharu.kotatsu.core.prefs.ScreenshotsPolicy { *; }
|
-keep class org.koitharu.kotatsu.core.prefs.ScreenshotsPolicy { *; }
|
||||||
-keep class org.koitharu.kotatsu.settings.backup.PeriodicalBackupSettingsFragment { *; }
|
|
||||||
-keep class org.jsoup.parser.Tag
|
|
||||||
-keep class org.jsoup.internal.StringUtil
|
|
||||||
|
|||||||
@@ -57,7 +57,6 @@ class AppShortcutManagerTest {
|
|||||||
page = 4,
|
page = 4,
|
||||||
scroll = 2,
|
scroll = 2,
|
||||||
percent = 0.3f,
|
percent = 0.3f,
|
||||||
force = false,
|
|
||||||
)
|
)
|
||||||
awaitUpdate()
|
awaitUpdate()
|
||||||
|
|
||||||
|
|||||||
@@ -7,9 +7,7 @@ import dagger.hilt.android.testing.HiltAndroidRule
|
|||||||
import dagger.hilt.android.testing.HiltAndroidTest
|
import dagger.hilt.android.testing.HiltAndroidTest
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.*
|
||||||
import org.junit.Assert.assertNull
|
|
||||||
import org.junit.Assert.assertTrue
|
|
||||||
import org.junit.Before
|
import org.junit.Before
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
@@ -63,7 +61,6 @@ class AppBackupAgentTest {
|
|||||||
page = 3,
|
page = 3,
|
||||||
scroll = 40,
|
scroll = 40,
|
||||||
percent = 0.2f,
|
percent = 0.2f,
|
||||||
force = false,
|
|
||||||
)
|
)
|
||||||
val history = checkNotNull(historyRepository.getOne(SampleData.manga))
|
val history = checkNotNull(historyRepository.getOne(SampleData.manga))
|
||||||
|
|
||||||
@@ -85,7 +82,7 @@ class AppBackupAgentTest {
|
|||||||
assertEquals(history, historyRepository.getOne(SampleData.manga))
|
assertEquals(history, historyRepository.getOne(SampleData.manga))
|
||||||
assertEquals(listOf(SampleData.manga), favouritesRepository.getManga(category.id))
|
assertEquals(listOf(SampleData.manga), favouritesRepository.getManga(category.id))
|
||||||
|
|
||||||
val allTags = database.getTagsDao().findTags(SampleData.tag.source.name).toMangaTags()
|
val allTags = database.tagsDao.findTags(SampleData.tag.source.name).toMangaTags()
|
||||||
assertTrue(SampleData.tag in allTags)
|
assertTrue(SampleData.tag in allTags)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -174,12 +174,12 @@ class TrackerTest {
|
|||||||
assertEquals(3, repository.getNewChaptersCount(mangaFirst.id))
|
assertEquals(3, repository.getNewChaptersCount(mangaFirst.id))
|
||||||
|
|
||||||
var chapter = requireNotNull(mangaFull.chapters).run { get(lastIndex - 1) }
|
var chapter = requireNotNull(mangaFull.chapters).run { get(lastIndex - 1) }
|
||||||
tracker.syncWithHistory(mangaFull, chapter.id)
|
repository.syncWithHistory(mangaFull, chapter.id)
|
||||||
|
|
||||||
assertEquals(1, repository.getNewChaptersCount(mangaFirst.id))
|
assertEquals(1, repository.getNewChaptersCount(mangaFirst.id))
|
||||||
|
|
||||||
chapter = requireNotNull(mangaFull.chapters).run { get(lastIndex) }
|
chapter = requireNotNull(mangaFull.chapters).run { get(lastIndex) }
|
||||||
tracker.syncWithHistory(mangaFull, chapter.id)
|
repository.syncWithHistory(mangaFull, chapter.id)
|
||||||
|
|
||||||
assertEquals(0, repository.getNewChaptersCount(mangaFirst.id))
|
assertEquals(0, repository.getNewChaptersCount(mangaFirst.id))
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
<manifest
|
|
||||||
xmlns:android="http://schemas.android.com/apk/res/android">
|
|
||||||
|
|
||||||
<application>
|
|
||||||
|
|
||||||
<activity
|
|
||||||
android:name=".tracker.ui.debug.TrackerDebugActivity"
|
|
||||||
android:label="@string/check_for_new_chapters" />
|
|
||||||
|
|
||||||
</application>
|
|
||||||
|
|
||||||
</manifest>
|
|
||||||
@@ -11,7 +11,7 @@ import org.koitharu.kotatsu.reader.domain.PageLoader
|
|||||||
|
|
||||||
class KotatsuApp : BaseApp() {
|
class KotatsuApp : BaseApp() {
|
||||||
|
|
||||||
override fun attachBaseContext(base: Context) {
|
override fun attachBaseContext(base: Context?) {
|
||||||
super.attachBaseContext(base)
|
super.attachBaseContext(base)
|
||||||
enableStrictMode()
|
enableStrictMode()
|
||||||
}
|
}
|
||||||
@@ -36,7 +36,7 @@ class KotatsuApp : BaseApp() {
|
|||||||
FragmentStrictMode.defaultPolicy = FragmentStrictMode.Policy.Builder()
|
FragmentStrictMode.defaultPolicy = FragmentStrictMode.Policy.Builder()
|
||||||
.penaltyDeath()
|
.penaltyDeath()
|
||||||
.detectFragmentReuse()
|
.detectFragmentReuse()
|
||||||
.detectWrongFragmentContainer()
|
// .detectWrongFragmentContainer() FIXME: migrate to ViewPager2
|
||||||
.detectRetainInstanceUsage()
|
.detectRetainInstanceUsage()
|
||||||
.detectSetUserVisibleHint()
|
.detectSetUserVisibleHint()
|
||||||
.detectFragmentTagUsage()
|
.detectFragmentTagUsage()
|
||||||
|
|||||||
@@ -10,8 +10,6 @@ class CurlLoggingInterceptor(
|
|||||||
private val curlOptions: String? = null
|
private val curlOptions: String? = null
|
||||||
) : Interceptor {
|
) : Interceptor {
|
||||||
|
|
||||||
private val escapeRegex = Regex("([\\[\\]\"])")
|
|
||||||
|
|
||||||
override fun intercept(chain: Interceptor.Chain): Response {
|
override fun intercept(chain: Interceptor.Chain): Response {
|
||||||
val request = chain.request()
|
val request = chain.request()
|
||||||
var isCompressed = false
|
var isCompressed = false
|
||||||
@@ -42,7 +40,7 @@ class CurlLoggingInterceptor(
|
|||||||
if (isCompressed) {
|
if (isCompressed) {
|
||||||
curlCmd.append(" --compressed")
|
curlCmd.append(" --compressed")
|
||||||
}
|
}
|
||||||
curlCmd.append(" \"").append(request.url.toString().escape()).append('"')
|
curlCmd.append(" \"").append(request.url).append('"')
|
||||||
|
|
||||||
log("---cURL (" + request.url + ")")
|
log("---cURL (" + request.url + ")")
|
||||||
log(curlCmd.toString())
|
log(curlCmd.toString())
|
||||||
@@ -50,12 +48,7 @@ class CurlLoggingInterceptor(
|
|||||||
return chain.proceed(request)
|
return chain.proceed(request)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun String.escape() = replace(escapeRegex) { match ->
|
private fun String.escape() = replace("\"", "\\\"")
|
||||||
"\\" + match.value
|
|
||||||
}
|
|
||||||
// .replace("\"", "\\\"")
|
|
||||||
// .replace("[", "\\[")
|
|
||||||
// .replace("]", "\\]")
|
|
||||||
|
|
||||||
private fun log(msg: String) {
|
private fun log(msg: String) {
|
||||||
Log.d("CURL", msg)
|
Log.d("CURL", msg)
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
package org.koitharu.kotatsu.core.parser
|
package org.koitharu.kotatsu.core.parser
|
||||||
|
|
||||||
import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException
|
|
||||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
||||||
import org.koitharu.kotatsu.parsers.MangaParser
|
import org.koitharu.kotatsu.parsers.MangaParser
|
||||||
import org.koitharu.kotatsu.parsers.config.ConfigKey
|
import org.koitharu.kotatsu.parsers.config.ConfigKey
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||||
@@ -19,20 +17,29 @@ import java.util.EnumSet
|
|||||||
class DummyParser(context: MangaLoaderContext) : MangaParser(context, MangaSource.DUMMY) {
|
class DummyParser(context: MangaLoaderContext) : MangaParser(context, MangaSource.DUMMY) {
|
||||||
|
|
||||||
override val configKeyDomain: ConfigKey.Domain
|
override val configKeyDomain: ConfigKey.Domain
|
||||||
get() = ConfigKey.Domain("localhost")
|
get() = ConfigKey.Domain("")
|
||||||
|
|
||||||
override val availableSortOrders: Set<SortOrder>
|
override val sortOrders: Set<SortOrder>
|
||||||
get() = EnumSet.allOf(SortOrder::class.java)
|
get() = EnumSet.allOf(SortOrder::class.java)
|
||||||
|
|
||||||
override suspend fun getDetails(manga: Manga): Manga = stub(manga)
|
override suspend fun getDetails(manga: Manga): Manga {
|
||||||
|
TODO("Not yet implemented")
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun getList(offset: Int, filter: MangaListFilter?): List<Manga> = stub(null)
|
override suspend fun getList(
|
||||||
|
offset: Int,
|
||||||
|
query: String?,
|
||||||
|
tags: Set<MangaTag>?,
|
||||||
|
sortOrder: SortOrder,
|
||||||
|
): List<Manga> {
|
||||||
|
TODO("Not yet implemented")
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> = stub(null)
|
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
|
||||||
|
TODO("Not yet implemented")
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun getAvailableTags(): Set<MangaTag> = stub(null)
|
override suspend fun getTags(): Set<MangaTag> {
|
||||||
|
TODO("Not yet implemented")
|
||||||
private fun stub(manga: Manga?): Nothing {
|
|
||||||
throw UnsupportedSourceException("Usage of Dummy parser", manga)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.util.ext
|
|
||||||
|
|
||||||
import android.os.Looper
|
|
||||||
|
|
||||||
fun Throwable.printStackTraceDebug() = printStackTrace()
|
|
||||||
|
|
||||||
fun assertNotInMainThread() = check(Looper.myLooper() != Looper.getMainLooper()) {
|
|
||||||
"Calling this from the main thread is prohibited"
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
package org.koitharu.kotatsu.core.util.ext
|
||||||
|
|
||||||
|
fun Throwable.printStackTraceDebug() = printStackTrace()
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.tracker.ui.debug
|
|
||||||
|
|
||||||
import android.graphics.Color
|
|
||||||
import android.text.format.DateUtils
|
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
import androidx.core.text.bold
|
|
||||||
import androidx.core.text.buildSpannedString
|
|
||||||
import androidx.core.text.color
|
|
||||||
import androidx.lifecycle.LifecycleOwner
|
|
||||||
import coil.ImageLoader
|
|
||||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
|
||||||
import org.koitharu.kotatsu.R
|
|
||||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.defaultPlaceholders
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.drawableStart
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.enqueueWith
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.getThemeColor
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.newImageRequest
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.source
|
|
||||||
import org.koitharu.kotatsu.databinding.ItemTrackDebugBinding
|
|
||||||
import org.koitharu.kotatsu.tracker.data.TrackEntity
|
|
||||||
import com.google.android.material.R as materialR
|
|
||||||
|
|
||||||
fun trackDebugAD(
|
|
||||||
lifecycleOwner: LifecycleOwner,
|
|
||||||
coil: ImageLoader,
|
|
||||||
clickListener: OnListItemClickListener<TrackDebugItem>,
|
|
||||||
) = adapterDelegateViewBinding<TrackDebugItem, TrackDebugItem, ItemTrackDebugBinding>(
|
|
||||||
{ layoutInflater, parent -> ItemTrackDebugBinding.inflate(layoutInflater, parent, false) },
|
|
||||||
) {
|
|
||||||
val indicatorNew = ContextCompat.getDrawable(context, R.drawable.ic_new)
|
|
||||||
|
|
||||||
itemView.setOnClickListener { v ->
|
|
||||||
clickListener.onItemClick(item, v)
|
|
||||||
}
|
|
||||||
|
|
||||||
bind {
|
|
||||||
binding.imageViewCover.newImageRequest(lifecycleOwner, item.manga.coverUrl)?.run {
|
|
||||||
defaultPlaceholders(context)
|
|
||||||
allowRgb565(true)
|
|
||||||
source(item.manga.source)
|
|
||||||
enqueueWith(coil)
|
|
||||||
}
|
|
||||||
binding.textViewTitle.text = item.manga.title
|
|
||||||
binding.textViewSummary.text = buildSpannedString {
|
|
||||||
item.lastCheckTime?.let {
|
|
||||||
append(
|
|
||||||
DateUtils.getRelativeDateTimeString(
|
|
||||||
context,
|
|
||||||
it.toEpochMilli(),
|
|
||||||
DateUtils.MINUTE_IN_MILLIS,
|
|
||||||
DateUtils.WEEK_IN_MILLIS,
|
|
||||||
0,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (item.lastResult == TrackEntity.RESULT_FAILED) {
|
|
||||||
append(" - ")
|
|
||||||
bold {
|
|
||||||
color(context.getThemeColor(materialR.attr.colorError, Color.RED)) {
|
|
||||||
append(getString(R.string.error))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
binding.textViewTitle.drawableStart = if (item.newChapters > 0) {
|
|
||||||
indicatorNew
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.tracker.ui.debug
|
|
||||||
|
|
||||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
|
||||||
import java.time.Instant
|
|
||||||
|
|
||||||
data class TrackDebugItem(
|
|
||||||
val manga: Manga,
|
|
||||||
val lastChapterId: Long,
|
|
||||||
val newChapters: Int,
|
|
||||||
val lastCheckTime: Instant?,
|
|
||||||
val lastChapterDate: Instant?,
|
|
||||||
val lastResult: Int,
|
|
||||||
) : ListModel {
|
|
||||||
|
|
||||||
override fun areItemsTheSame(other: ListModel): Boolean {
|
|
||||||
return other is TrackDebugItem && other.manga.id == manga.id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.tracker.ui.debug
|
|
||||||
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.view.View
|
|
||||||
import androidx.activity.viewModels
|
|
||||||
import androidx.core.graphics.Insets
|
|
||||||
import androidx.core.view.updatePadding
|
|
||||||
import coil.ImageLoader
|
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
|
||||||
import org.koitharu.kotatsu.core.ui.BaseActivity
|
|
||||||
import org.koitharu.kotatsu.core.ui.BaseListAdapter
|
|
||||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.observe
|
|
||||||
import org.koitharu.kotatsu.databinding.ActivityTrackerDebugBinding
|
|
||||||
import org.koitharu.kotatsu.details.ui.DetailsActivity
|
|
||||||
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
|
|
||||||
import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
|
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
@AndroidEntryPoint
|
|
||||||
class TrackerDebugActivity : BaseActivity<ActivityTrackerDebugBinding>(), OnListItemClickListener<TrackDebugItem> {
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
lateinit var coil: ImageLoader
|
|
||||||
|
|
||||||
private val viewModel by viewModels<TrackerDebugViewModel>()
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
|
||||||
super.onCreate(savedInstanceState)
|
|
||||||
setContentView(ActivityTrackerDebugBinding.inflate(layoutInflater))
|
|
||||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
|
||||||
val tracksAdapter = BaseListAdapter<TrackDebugItem>()
|
|
||||||
.addDelegate(ListItemType.FEED, trackDebugAD(this, coil, this))
|
|
||||||
with(viewBinding.recyclerView) {
|
|
||||||
adapter = tracksAdapter
|
|
||||||
addItemDecoration(TypedListSpacingDecoration(context, false))
|
|
||||||
}
|
|
||||||
viewModel.content.observe(this, tracksAdapter)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onWindowInsetsChanged(insets: Insets) {
|
|
||||||
val rv = viewBinding.recyclerView
|
|
||||||
rv.updatePadding(
|
|
||||||
left = insets.left + rv.paddingTop,
|
|
||||||
right = insets.right + rv.paddingTop,
|
|
||||||
bottom = insets.bottom,
|
|
||||||
)
|
|
||||||
viewBinding.toolbar.updatePadding(
|
|
||||||
left = insets.left,
|
|
||||||
right = insets.right,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onItemClick(item: TrackDebugItem, view: View) {
|
|
||||||
startActivity(DetailsActivity.newIntent(this, item.manga))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.tracker.ui.debug
|
|
||||||
|
|
||||||
import androidx.lifecycle.viewModelScope
|
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
import kotlinx.coroutines.flow.stateIn
|
|
||||||
import kotlinx.coroutines.plus
|
|
||||||
import org.koitharu.kotatsu.core.db.MangaDatabase
|
|
||||||
import org.koitharu.kotatsu.core.db.entity.toManga
|
|
||||||
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.toInstantOrNull
|
|
||||||
import org.koitharu.kotatsu.tracker.data.TrackWithManga
|
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
@HiltViewModel
|
|
||||||
class TrackerDebugViewModel @Inject constructor(
|
|
||||||
private val db: MangaDatabase
|
|
||||||
) : BaseViewModel() {
|
|
||||||
|
|
||||||
val content = db.getTracksDao().observeAll()
|
|
||||||
.map { it.toUiList() }
|
|
||||||
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList())
|
|
||||||
|
|
||||||
private fun List<TrackWithManga>.toUiList(): List<TrackDebugItem> = map {
|
|
||||||
TrackDebugItem(
|
|
||||||
manga = it.manga.toManga(emptySet()),
|
|
||||||
lastChapterId = it.track.lastChapterId,
|
|
||||||
newChapters = it.track.newChapters,
|
|
||||||
lastCheckTime = it.track.lastCheckTime.toInstantOrNull(),
|
|
||||||
lastChapterDate = it.track.lastChapterDate.toInstantOrNull(),
|
|
||||||
lastResult = it.track.lastResult,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<androidx.coordinatorlayout.widget.CoordinatorLayout
|
|
||||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
|
||||||
xmlns:tools="http://schemas.android.com/tools"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="match_parent">
|
|
||||||
|
|
||||||
<com.google.android.material.appbar.AppBarLayout
|
|
||||||
android:id="@+id/appbar"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:fitsSystemWindows="true">
|
|
||||||
|
|
||||||
<com.google.android.material.appbar.CollapsingToolbarLayout
|
|
||||||
android:id="@+id/collapsingToolbarLayout"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="?attr/collapsingToolbarLayoutMediumSize"
|
|
||||||
app:layout_scrollFlags="scroll|exitUntilCollapsed|snap"
|
|
||||||
app:toolbarId="@id/toolbar">
|
|
||||||
|
|
||||||
<com.google.android.material.appbar.MaterialToolbar
|
|
||||||
android:id="@id/toolbar"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="?attr/actionBarSize"
|
|
||||||
app:layout_collapseMode="pin" />
|
|
||||||
|
|
||||||
</com.google.android.material.appbar.CollapsingToolbarLayout>
|
|
||||||
|
|
||||||
</com.google.android.material.appbar.AppBarLayout>
|
|
||||||
|
|
||||||
<androidx.recyclerview.widget.RecyclerView
|
|
||||||
android:id="@+id/recyclerView"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="match_parent"
|
|
||||||
android:clipToPadding="false"
|
|
||||||
android:orientation="vertical"
|
|
||||||
android:padding="@dimen/list_spacing_normal"
|
|
||||||
android:scrollbars="vertical"
|
|
||||||
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
|
|
||||||
app:layout_behavior="com.google.android.material.appbar.AppBarLayout$ScrollingViewBehavior"
|
|
||||||
tools:listitem="@layout/item_track_debug" />
|
|
||||||
|
|
||||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
|
||||||
@@ -8,14 +8,4 @@
|
|||||||
android:title="@string/leak_canary_display_activity_label"
|
android:title="@string/leak_canary_display_activity_label"
|
||||||
app:showAsAction="never" />
|
app:showAsAction="never" />
|
||||||
|
|
||||||
<item
|
|
||||||
android:id="@id/action_tracker"
|
|
||||||
android:title="@string/check_for_new_chapters"
|
|
||||||
app:showAsAction="never" />
|
|
||||||
|
|
||||||
<item
|
|
||||||
android:id="@id/action_works"
|
|
||||||
android:title="Works"
|
|
||||||
app:showAsAction="never" />
|
|
||||||
|
|
||||||
</menu>
|
</menu>
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||||
<bool name="leak_canary_add_launcher_icon" tools:node="replace">false</bool>
|
<bool name="leak_canary_add_launcher_icon" tools:node="replace">false</bool>
|
||||||
<bool name="wi_launcher_icon_enabled" tools:node="replace">false</bool>
|
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,31 +0,0 @@
|
|||||||
-----BEGIN CERTIFICATE-----
|
|
||||||
MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAw
|
|
||||||
TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh
|
|
||||||
cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4
|
|
||||||
WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJu
|
|
||||||
ZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBY
|
|
||||||
MTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54rVygc
|
|
||||||
h77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+
|
|
||||||
0TM8ukj13Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7iS4+3mX6U
|
|
||||||
A5/TR5d8mUgjU+g4rk8Kb4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sW
|
|
||||||
T8KOEUt+zwvo/7V3LvSye0rgTBIlDHCNAymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyH
|
|
||||||
B5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ4Q7e2RCOFvu396j3x+UC
|
|
||||||
B5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf1b0SHzUv
|
|
||||||
KBds0pjBqAlkd25HN7rOrFleaJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWn
|
|
||||||
OlFuhjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTn
|
|
||||||
jh8BCNAw1FtxNrQHusEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbw
|
|
||||||
qHyGO0aoSCqI3Haadr8faqU9GY/rOPNk3sgrDQoo//fb4hVC1CLQJ13hef4Y53CI
|
|
||||||
rU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNV
|
|
||||||
HRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY9umbbjANBgkq
|
|
||||||
hkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZL
|
|
||||||
ubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ
|
|
||||||
3BebYhtF8GaV0nxvwuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KK
|
|
||||||
NFtY2PwByVS5uCbMiogziUwthDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5
|
|
||||||
ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJwTdwJx4nLCgdNbOhdjsnvzqvHu7Ur
|
|
||||||
TkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIuvtd7u+Nxe5AW0wdeRlN8NwdC
|
|
||||||
jNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZAJzVc
|
|
||||||
oyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq
|
|
||||||
4RgqsahDYVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPA
|
|
||||||
mRGunUHBcnWEvgJBQl9nJEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57d
|
|
||||||
emyPxgcYxn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc=
|
|
||||||
-----END CERTIFICATE-----
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.alternatives.domain
|
|
||||||
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
|
||||||
import kotlinx.coroutines.flow.channelFlow
|
|
||||||
import kotlinx.coroutines.flow.emptyFlow
|
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.sync.Semaphore
|
|
||||||
import kotlinx.coroutines.sync.withPermit
|
|
||||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.almostEquals
|
|
||||||
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
|
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
|
||||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
private const val MAX_PARALLELISM = 4
|
|
||||||
private const val MATCH_THRESHOLD = 0.2f
|
|
||||||
|
|
||||||
class AlternativesUseCase @Inject constructor(
|
|
||||||
private val sourcesRepository: MangaSourcesRepository,
|
|
||||||
private val mangaRepositoryFactory: MangaRepository.Factory,
|
|
||||||
) {
|
|
||||||
|
|
||||||
suspend operator fun invoke(manga: Manga): Flow<Manga> {
|
|
||||||
val sources = getSources(manga.source)
|
|
||||||
if (sources.isEmpty()) {
|
|
||||||
return emptyFlow()
|
|
||||||
}
|
|
||||||
val semaphore = Semaphore(MAX_PARALLELISM)
|
|
||||||
return channelFlow {
|
|
||||||
for (source in sources) {
|
|
||||||
val repository = mangaRepositoryFactory.create(source)
|
|
||||||
if (!repository.isSearchSupported) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
launch {
|
|
||||||
val list = runCatchingCancellable {
|
|
||||||
semaphore.withPermit {
|
|
||||||
repository.getList(offset = 0, filter = MangaListFilter.Search(manga.title))
|
|
||||||
}
|
|
||||||
}.getOrDefault(emptyList())
|
|
||||||
for (item in list) {
|
|
||||||
if (item.matches(manga)) {
|
|
||||||
send(item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}.map {
|
|
||||||
runCatchingCancellable {
|
|
||||||
mangaRepositoryFactory.create(it.source).getDetails(it)
|
|
||||||
}.getOrDefault(it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun getSources(ref: MangaSource): List<MangaSource> {
|
|
||||||
val result = ArrayList<MangaSource>(MangaSource.entries.size - 2)
|
|
||||||
result.addAll(sourcesRepository.getEnabledSources())
|
|
||||||
result.sortByDescending { it.priority(ref) }
|
|
||||||
result.addAll(sourcesRepository.getDisabledSources().sortedByDescending { it.priority(ref) })
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun Manga.matches(ref: Manga): Boolean {
|
|
||||||
return matchesTitles(title, ref.title) ||
|
|
||||||
matchesTitles(title, ref.altTitle) ||
|
|
||||||
matchesTitles(altTitle, ref.title) ||
|
|
||||||
matchesTitles(altTitle, ref.altTitle)
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun matchesTitles(a: String?, b: String?): Boolean {
|
|
||||||
return !a.isNullOrEmpty() && !b.isNullOrEmpty() && a.almostEquals(b, MATCH_THRESHOLD)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun MangaSource.priority(ref: MangaSource): Int {
|
|
||||||
var res = 0
|
|
||||||
if (locale == ref.locale) res += 2
|
|
||||||
if (contentType == ref.contentType) res++
|
|
||||||
return res
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.alternatives.domain
|
|
||||||
|
|
||||||
import androidx.room.withTransaction
|
|
||||||
import org.koitharu.kotatsu.core.db.MangaDatabase
|
|
||||||
import org.koitharu.kotatsu.core.model.getPreferredBranch
|
|
||||||
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.parsers.model.Manga
|
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
|
||||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
|
||||||
import org.koitharu.kotatsu.tracker.data.TrackEntity
|
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
class MigrateUseCase @Inject constructor(
|
|
||||||
private val mangaRepositoryFactory: MangaRepository.Factory,
|
|
||||||
private val mangaDataRepository: MangaDataRepository,
|
|
||||||
private val database: MangaDatabase,
|
|
||||||
private val progressUpdateUseCase: ProgressUpdateUseCase,
|
|
||||||
) {
|
|
||||||
|
|
||||||
suspend operator fun invoke(oldManga: Manga, newManga: Manga) {
|
|
||||||
val oldDetails = if (oldManga.chapters.isNullOrEmpty()) {
|
|
||||||
runCatchingCancellable {
|
|
||||||
mangaRepositoryFactory.create(oldManga.source).getDetails(oldManga)
|
|
||||||
}.getOrDefault(oldManga)
|
|
||||||
} else {
|
|
||||||
oldManga
|
|
||||||
}
|
|
||||||
val newDetails = if (newManga.chapters.isNullOrEmpty()) {
|
|
||||||
mangaRepositoryFactory.create(newManga.source).getDetails(newManga)
|
|
||||||
} else {
|
|
||||||
newManga
|
|
||||||
}
|
|
||||||
mangaDataRepository.storeManga(newDetails)
|
|
||||||
database.withTransaction {
|
|
||||||
// replace favorites
|
|
||||||
val favoritesDao = database.getFavouritesDao()
|
|
||||||
val oldFavourites = favoritesDao.findAllRaw(oldDetails.id)
|
|
||||||
if (oldFavourites.isNotEmpty()) {
|
|
||||||
favoritesDao.delete(oldManga.id)
|
|
||||||
for (f in oldFavourites) {
|
|
||||||
val e = f.copy(
|
|
||||||
mangaId = newManga.id,
|
|
||||||
)
|
|
||||||
favoritesDao.upsert(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// replace history
|
|
||||||
val historyDao = database.getHistoryDao()
|
|
||||||
val oldHistory = historyDao.find(oldDetails.id)
|
|
||||||
if (oldHistory != null) {
|
|
||||||
val newHistory = makeNewHistory(oldDetails, newDetails, oldHistory)
|
|
||||||
historyDao.delete(oldDetails.id)
|
|
||||||
historyDao.upsert(newHistory)
|
|
||||||
}
|
|
||||||
// track
|
|
||||||
val tracksDao = database.getTracksDao()
|
|
||||||
val oldTrack = tracksDao.find(oldDetails.id)
|
|
||||||
if (oldTrack != null) {
|
|
||||||
val lastChapter = newDetails.chapters?.lastOrNull()
|
|
||||||
val newTrack = TrackEntity(
|
|
||||||
mangaId = newDetails.id,
|
|
||||||
lastChapterId = lastChapter?.id ?: 0L,
|
|
||||||
newChapters = 0,
|
|
||||||
lastCheckTime = System.currentTimeMillis(),
|
|
||||||
lastChapterDate = lastChapter?.uploadDate ?: 0L,
|
|
||||||
lastResult = TrackEntity.RESULT_EXTERNAL_MODIFICATION,
|
|
||||||
)
|
|
||||||
tracksDao.delete(oldDetails.id)
|
|
||||||
tracksDao.upsert(newTrack)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
progressUpdateUseCase(newManga)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun makeNewHistory(
|
|
||||||
oldManga: Manga,
|
|
||||||
newManga: Manga,
|
|
||||||
history: HistoryEntity,
|
|
||||||
): HistoryEntity {
|
|
||||||
if (oldManga.chapters.isNullOrEmpty()) { // probably broken manga/source
|
|
||||||
val branch = newManga.getPreferredBranch(null)
|
|
||||||
val chapters = checkNotNull(newManga.getChapters(branch))
|
|
||||||
val currentChapter = if (history.percent in 0f..1f) {
|
|
||||||
chapters[(chapters.lastIndex * history.percent).toInt()]
|
|
||||||
} else {
|
|
||||||
chapters.first()
|
|
||||||
}
|
|
||||||
return HistoryEntity(
|
|
||||||
mangaId = newManga.id,
|
|
||||||
createdAt = history.createdAt,
|
|
||||||
updatedAt = System.currentTimeMillis(),
|
|
||||||
chapterId = currentChapter.id,
|
|
||||||
page = history.page,
|
|
||||||
scroll = history.scroll,
|
|
||||||
percent = history.percent,
|
|
||||||
deletedAt = 0,
|
|
||||||
chaptersCount = chapters.size,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
val branch = oldManga.getPreferredBranch(history.toMangaHistory())
|
|
||||||
val oldChapters = checkNotNull(oldManga.getChapters(branch))
|
|
||||||
var index = oldChapters.indexOfFirst { it.id == history.chapterId }
|
|
||||||
if (index < 0) {
|
|
||||||
index = if (history.percent in 0f..1f) {
|
|
||||||
(oldChapters.lastIndex * history.percent).toInt()
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val newChapters = checkNotNull(newManga.chapters).groupBy { it.branch }
|
|
||||||
val newBranch = if (newChapters.containsKey(branch)) {
|
|
||||||
branch
|
|
||||||
} else {
|
|
||||||
newManga.getPreferredBranch(null)
|
|
||||||
}
|
|
||||||
val newChapterId = checkNotNull(newChapters[newBranch]).let {
|
|
||||||
val oldChapter = oldChapters[index]
|
|
||||||
it.findByNumber(oldChapter.volume, oldChapter.number) ?: it.getOrNull(index) ?: it.last()
|
|
||||||
}.id
|
|
||||||
|
|
||||||
return HistoryEntity(
|
|
||||||
mangaId = newManga.id,
|
|
||||||
createdAt = history.createdAt,
|
|
||||||
updatedAt = System.currentTimeMillis(),
|
|
||||||
chapterId = newChapterId,
|
|
||||||
page = history.page,
|
|
||||||
scroll = history.scroll,
|
|
||||||
percent = PROGRESS_NONE,
|
|
||||||
deletedAt = 0,
|
|
||||||
chaptersCount = checkNotNull(newChapters[newBranch]).size,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun List<MangaChapter>.findByNumber(volume: Int, number: Float): MangaChapter? {
|
|
||||||
return if (number <= 0f) {
|
|
||||||
null
|
|
||||||
} else {
|
|
||||||
firstOrNull { it.volume == volume && it.number == number }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.alternatives.ui
|
|
||||||
|
|
||||||
import android.text.style.ForegroundColorSpan
|
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
import androidx.core.text.buildSpannedString
|
|
||||||
import androidx.core.text.inSpans
|
|
||||||
import androidx.lifecycle.LifecycleOwner
|
|
||||||
import coil.ImageLoader
|
|
||||||
import coil.request.ImageRequest
|
|
||||||
import coil.transform.CircleCropTransformation
|
|
||||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
|
||||||
import org.koitharu.kotatsu.R
|
|
||||||
import org.koitharu.kotatsu.core.parser.favicon.faviconUri
|
|
||||||
import org.koitharu.kotatsu.core.ui.image.ChipIconTarget
|
|
||||||
import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver
|
|
||||||
import org.koitharu.kotatsu.core.ui.image.TrimTransformation
|
|
||||||
import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter
|
|
||||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.defaultPlaceholders
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.enqueueWith
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.newImageRequest
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.source
|
|
||||||
import org.koitharu.kotatsu.databinding.ItemMangaAlternativeBinding
|
|
||||||
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
|
|
||||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
|
||||||
import kotlin.math.sign
|
|
||||||
import com.google.android.material.R as materialR
|
|
||||||
|
|
||||||
fun alternativeAD(
|
|
||||||
coil: ImageLoader,
|
|
||||||
lifecycleOwner: LifecycleOwner,
|
|
||||||
listener: OnListItemClickListener<MangaAlternativeModel>,
|
|
||||||
) = adapterDelegateViewBinding<MangaAlternativeModel, ListModel, ItemMangaAlternativeBinding>(
|
|
||||||
{ inflater, parent -> ItemMangaAlternativeBinding.inflate(inflater, parent, false) },
|
|
||||||
) {
|
|
||||||
|
|
||||||
val colorGreen = ContextCompat.getColor(context, R.color.common_green)
|
|
||||||
val colorRed = ContextCompat.getColor(context, R.color.common_red)
|
|
||||||
val clickListener = AdapterDelegateClickListenerAdapter(this, listener)
|
|
||||||
itemView.setOnClickListener(clickListener)
|
|
||||||
binding.buttonMigrate.setOnClickListener(clickListener)
|
|
||||||
binding.chipSource.setOnClickListener(clickListener)
|
|
||||||
|
|
||||||
bind { payloads ->
|
|
||||||
binding.textViewTitle.text = item.manga.title
|
|
||||||
binding.textViewSubtitle.text = buildSpannedString {
|
|
||||||
if (item.chaptersCount > 0) {
|
|
||||||
append(context.resources.getQuantityString(R.plurals.chapters, item.chaptersCount, item.chaptersCount))
|
|
||||||
} else {
|
|
||||||
append(context.getString(R.string.no_chapters))
|
|
||||||
}
|
|
||||||
when (item.chaptersDiff.sign) {
|
|
||||||
-1 -> inSpans(ForegroundColorSpan(colorRed)) {
|
|
||||||
append(" ▼ ")
|
|
||||||
append(item.chaptersDiff.toString())
|
|
||||||
}
|
|
||||||
|
|
||||||
1 -> inSpans(ForegroundColorSpan(colorGreen)) {
|
|
||||||
append(" ▲ +")
|
|
||||||
append(item.chaptersDiff.toString())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
binding.progressView.setPercent(item.progress, ListModelDiffCallback.PAYLOAD_PROGRESS_CHANGED in payloads)
|
|
||||||
binding.chipSource.also { chip ->
|
|
||||||
chip.text = item.manga.source.title
|
|
||||||
ImageRequest.Builder(context)
|
|
||||||
.data(item.manga.source.faviconUri())
|
|
||||||
.lifecycle(lifecycleOwner)
|
|
||||||
.crossfade(false)
|
|
||||||
.size(context.resources.getDimensionPixelSize(materialR.dimen.m3_chip_icon_size))
|
|
||||||
.target(ChipIconTarget(chip))
|
|
||||||
.placeholder(R.drawable.ic_web)
|
|
||||||
.fallback(R.drawable.ic_web)
|
|
||||||
.error(R.drawable.ic_web)
|
|
||||||
.source(item.manga.source)
|
|
||||||
.transformations(CircleCropTransformation())
|
|
||||||
.allowRgb565(true)
|
|
||||||
.enqueueWith(coil)
|
|
||||||
}
|
|
||||||
binding.imageViewCover.newImageRequest(lifecycleOwner, item.manga.coverUrl)?.run {
|
|
||||||
size(CoverSizeResolver(binding.imageViewCover))
|
|
||||||
defaultPlaceholders(context)
|
|
||||||
transformations(TrimTransformation())
|
|
||||||
allowRgb565(true)
|
|
||||||
tag(item.manga)
|
|
||||||
source(item.manga.source)
|
|
||||||
enqueueWith(coil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.alternatives.ui
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.view.View
|
|
||||||
import android.widget.Toast
|
|
||||||
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
|
|
||||||
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.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
|
|
||||||
import org.koitharu.kotatsu.details.ui.DetailsActivity
|
|
||||||
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
|
|
||||||
import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
|
|
||||||
import org.koitharu.kotatsu.list.ui.adapter.emptyStateListAD
|
|
||||||
import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD
|
|
||||||
import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD
|
|
||||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
|
||||||
import org.koitharu.kotatsu.search.ui.SearchActivity
|
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
@AndroidEntryPoint
|
|
||||||
class AlternativesActivity : BaseActivity<ActivityAlternativesBinding>(),
|
|
||||||
OnListItemClickListener<MangaAlternativeModel> {
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
lateinit var coil: ImageLoader
|
|
||||||
|
|
||||||
private val viewModel by viewModels<AlternativesViewModel>()
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
|
||||||
super.onCreate(savedInstanceState)
|
|
||||||
setContentView(ActivityAlternativesBinding.inflate(layoutInflater))
|
|
||||||
supportActionBar?.run {
|
|
||||||
setDisplayHomeAsUpEnabled(true)
|
|
||||||
subtitle = viewModel.manga.title
|
|
||||||
}
|
|
||||||
val listAdapter = BaseListAdapter<ListModel>()
|
|
||||||
.addDelegate(ListItemType.MANGA_LIST_DETAILED, alternativeAD(coil, this, this))
|
|
||||||
.addDelegate(ListItemType.STATE_EMPTY, emptyStateListAD(coil, this, null))
|
|
||||||
.addDelegate(ListItemType.FOOTER_LOADING, loadingFooterAD())
|
|
||||||
.addDelegate(ListItemType.STATE_LOADING, loadingStateAD())
|
|
||||||
with(viewBinding.recyclerView) {
|
|
||||||
setHasFixedSize(true)
|
|
||||||
addItemDecoration(TypedListSpacingDecoration(context, addHorizontalPadding = false))
|
|
||||||
adapter = listAdapter
|
|
||||||
}
|
|
||||||
|
|
||||||
viewModel.onError.observeEvent(this, SnackbarErrorObserver(viewBinding.recyclerView, null))
|
|
||||||
viewModel.content.observe(this, listAdapter)
|
|
||||||
viewModel.onMigrated.observeEvent(this) {
|
|
||||||
Toast.makeText(this, R.string.migration_completed, Toast.LENGTH_SHORT).show()
|
|
||||||
startActivity(DetailsActivity.newIntent(this, it))
|
|
||||||
finishAfterTransition()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onWindowInsetsChanged(insets: Insets) {
|
|
||||||
viewBinding.root.updatePadding(
|
|
||||||
left = insets.left,
|
|
||||||
right = insets.right,
|
|
||||||
)
|
|
||||||
viewBinding.recyclerView.updatePadding(
|
|
||||||
bottom = insets.bottom + viewBinding.recyclerView.paddingTop,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onItemClick(item: MangaAlternativeModel, view: View) {
|
|
||||||
when (view.id) {
|
|
||||||
R.id.chip_source -> startActivity(SearchActivity.newIntent(this, item.manga.source, viewModel.manga.title))
|
|
||||||
R.id.button_migrate -> confirmMigration(item.manga)
|
|
||||||
else -> startActivity(DetailsActivity.newIntent(this, item.manga))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun confirmMigration(target: Manga) {
|
|
||||||
MaterialAlertDialogBuilder(this, DIALOG_THEME_CENTERED)
|
|
||||||
.setIcon(R.drawable.ic_replace)
|
|
||||||
.setTitle(R.string.manga_migration)
|
|
||||||
.setMessage(
|
|
||||||
getString(
|
|
||||||
R.string.migrate_confirmation,
|
|
||||||
viewModel.manga.title,
|
|
||||||
viewModel.manga.source.title,
|
|
||||||
target.title,
|
|
||||||
target.source.title,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.setNegativeButton(android.R.string.cancel, null)
|
|
||||||
.setPositiveButton(R.string.migrate) { _, _ ->
|
|
||||||
viewModel.migrate(target)
|
|
||||||
}.show()
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
|
|
||||||
fun newIntent(context: Context, manga: Manga) = Intent(context, AlternativesActivity::class.java)
|
|
||||||
.putExtra(MangaIntent.KEY_MANGA, ParcelableManga(manga))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.alternatives.ui
|
|
||||||
|
|
||||||
import androidx.lifecycle.SavedStateHandle
|
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
import kotlinx.coroutines.flow.onEmpty
|
|
||||||
import kotlinx.coroutines.flow.runningFold
|
|
||||||
import org.koitharu.kotatsu.R
|
|
||||||
import org.koitharu.kotatsu.alternatives.domain.AlternativesUseCase
|
|
||||||
import org.koitharu.kotatsu.alternatives.domain.MigrateUseCase
|
|
||||||
import org.koitharu.kotatsu.core.model.chaptersCount
|
|
||||||
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
|
|
||||||
import org.koitharu.kotatsu.core.parser.MangaIntent
|
|
||||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
|
||||||
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.call
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.require
|
|
||||||
import org.koitharu.kotatsu.list.domain.ListExtraProvider
|
|
||||||
import org.koitharu.kotatsu.list.ui.model.EmptyState
|
|
||||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
|
||||||
import org.koitharu.kotatsu.list.ui.model.LoadingFooter
|
|
||||||
import org.koitharu.kotatsu.list.ui.model.LoadingState
|
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
|
||||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
@HiltViewModel
|
|
||||||
class AlternativesViewModel @Inject constructor(
|
|
||||||
savedStateHandle: SavedStateHandle,
|
|
||||||
private val mangaRepositoryFactory: MangaRepository.Factory,
|
|
||||||
private val alternativesUseCase: AlternativesUseCase,
|
|
||||||
private val migrateUseCase: MigrateUseCase,
|
|
||||||
private val extraProvider: ListExtraProvider,
|
|
||||||
) : BaseViewModel() {
|
|
||||||
|
|
||||||
val manga = savedStateHandle.require<ParcelableManga>(MangaIntent.KEY_MANGA).manga
|
|
||||||
|
|
||||||
val onMigrated = MutableEventFlow<Manga>()
|
|
||||||
val content = MutableStateFlow<List<ListModel>>(listOf(LoadingState))
|
|
||||||
private var migrationJob: Job? = null
|
|
||||||
|
|
||||||
init {
|
|
||||||
launchJob(Dispatchers.Default) {
|
|
||||||
val ref = runCatchingCancellable {
|
|
||||||
mangaRepositoryFactory.create(manga.source).getDetails(manga)
|
|
||||||
}.getOrDefault(manga)
|
|
||||||
val refCount = ref.chaptersCount()
|
|
||||||
alternativesUseCase(ref)
|
|
||||||
.map {
|
|
||||||
MangaAlternativeModel(
|
|
||||||
manga = it,
|
|
||||||
progress = extraProvider.getProgress(it.id),
|
|
||||||
referenceChapters = refCount,
|
|
||||||
)
|
|
||||||
}.runningFold<MangaAlternativeModel, List<ListModel>>(listOf(LoadingState)) { acc, item ->
|
|
||||||
acc.filterIsInstance<MangaAlternativeModel>() + item + LoadingFooter()
|
|
||||||
}.onEmpty {
|
|
||||||
emit(
|
|
||||||
listOf(
|
|
||||||
EmptyState(
|
|
||||||
icon = R.drawable.ic_empty_common,
|
|
||||||
textPrimary = R.string.nothing_found,
|
|
||||||
textSecondary = R.string.text_search_holder_secondary,
|
|
||||||
actionStringRes = 0,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}.collect {
|
|
||||||
content.value = it
|
|
||||||
}
|
|
||||||
content.value = content.value.filterNot { it is LoadingFooter }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun migrate(target: Manga) {
|
|
||||||
if (migrationJob?.isActive == true) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
migrationJob = launchLoadingJob(Dispatchers.Default) {
|
|
||||||
migrateUseCase(manga, target)
|
|
||||||
onMigrated.call(target)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun mapList(list: List<Manga>, refCount: Int): List<MangaAlternativeModel> {
|
|
||||||
return list.map {
|
|
||||||
MangaAlternativeModel(
|
|
||||||
manga = it,
|
|
||||||
progress = extraProvider.getProgress(it.id),
|
|
||||||
referenceChapters = refCount,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.alternatives.ui
|
|
||||||
|
|
||||||
import org.koitharu.kotatsu.core.model.chaptersCount
|
|
||||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
|
||||||
|
|
||||||
data class MangaAlternativeModel(
|
|
||||||
val manga: Manga,
|
|
||||||
val progress: Float,
|
|
||||||
private val referenceChapters: Int,
|
|
||||||
) : ListModel {
|
|
||||||
|
|
||||||
val chaptersCount = manga.chaptersCount()
|
|
||||||
|
|
||||||
val chaptersDiff: Int
|
|
||||||
get() = if (referenceChapters == 0 || chaptersCount == 0) 0 else chaptersCount - referenceChapters
|
|
||||||
|
|
||||||
override fun areItemsTheSame(other: ListModel): Boolean {
|
|
||||||
return other is MangaAlternativeModel && other.manga.id == manga.id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -12,6 +12,9 @@ import org.koitharu.kotatsu.core.db.entity.MangaWithTags
|
|||||||
@Dao
|
@Dao
|
||||||
abstract class BookmarksDao {
|
abstract class BookmarksDao {
|
||||||
|
|
||||||
|
@Query("SELECT * FROM bookmarks WHERE manga_id = :mangaId AND page_id = :pageId")
|
||||||
|
abstract suspend fun find(mangaId: Long, pageId: Long): BookmarkEntity?
|
||||||
|
|
||||||
@Query("SELECT * FROM bookmarks WHERE page_id = :pageId")
|
@Query("SELECT * FROM bookmarks WHERE page_id = :pageId")
|
||||||
abstract suspend fun find(pageId: Long): BookmarkEntity?
|
abstract suspend fun find(pageId: Long): BookmarkEntity?
|
||||||
|
|
||||||
@@ -39,6 +42,9 @@ abstract class BookmarksDao {
|
|||||||
@Delete
|
@Delete
|
||||||
abstract suspend fun delete(entity: BookmarkEntity)
|
abstract suspend fun delete(entity: BookmarkEntity)
|
||||||
|
|
||||||
|
@Query("DELETE FROM bookmarks WHERE manga_id = :mangaId AND page_id = :pageId")
|
||||||
|
abstract suspend fun delete(mangaId: Long, pageId: Long): Int
|
||||||
|
|
||||||
@Query("DELETE FROM bookmarks WHERE page_id = :pageId")
|
@Query("DELETE FROM bookmarks WHERE page_id = :pageId")
|
||||||
abstract suspend fun delete(pageId: Long): Int
|
abstract suspend fun delete(pageId: Long): Int
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ package org.koitharu.kotatsu.bookmarks.data
|
|||||||
|
|
||||||
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
import java.time.Instant
|
import java.util.Date
|
||||||
|
|
||||||
fun BookmarkEntity.toBookmark(manga: Manga) = Bookmark(
|
fun BookmarkEntity.toBookmark(manga: Manga) = Bookmark(
|
||||||
manga = manga,
|
manga = manga,
|
||||||
@@ -11,7 +11,7 @@ fun BookmarkEntity.toBookmark(manga: Manga) = Bookmark(
|
|||||||
page = page,
|
page = page,
|
||||||
scroll = scroll,
|
scroll = scroll,
|
||||||
imageUrl = imageUrl,
|
imageUrl = imageUrl,
|
||||||
createdAt = Instant.ofEpochMilli(createdAt),
|
createdAt = Date(createdAt),
|
||||||
percent = percent,
|
percent = percent,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -22,7 +22,7 @@ fun Bookmark.toEntity() = BookmarkEntity(
|
|||||||
page = page,
|
page = page,
|
||||||
scroll = scroll,
|
scroll = scroll,
|
||||||
imageUrl = imageUrl,
|
imageUrl = imageUrl,
|
||||||
createdAt = createdAt.toEpochMilli(),
|
createdAt = createdAt.time,
|
||||||
percent = percent,
|
percent = percent,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
package org.koitharu.kotatsu.bookmarks.domain
|
package org.koitharu.kotatsu.bookmarks.domain
|
||||||
|
|
||||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||||
import org.koitharu.kotatsu.local.data.hasImageExtension
|
import org.koitharu.kotatsu.local.data.ImageFileFilter
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||||
import java.time.Instant
|
import java.util.Date
|
||||||
|
|
||||||
data class Bookmark(
|
data class Bookmark(
|
||||||
val manga: Manga,
|
val manga: Manga,
|
||||||
@@ -13,7 +13,7 @@ data class Bookmark(
|
|||||||
val page: Int,
|
val page: Int,
|
||||||
val scroll: Int,
|
val scroll: Int,
|
||||||
val imageUrl: String,
|
val imageUrl: String,
|
||||||
val createdAt: Instant,
|
val createdAt: Date,
|
||||||
val percent: Float,
|
val percent: Float,
|
||||||
) : ListModel {
|
) : ListModel {
|
||||||
|
|
||||||
@@ -38,6 +38,7 @@ data class Bookmark(
|
|||||||
)
|
)
|
||||||
|
|
||||||
private fun isImageUrlDirect(): Boolean {
|
private fun isImageUrlDirect(): Boolean {
|
||||||
return hasImageExtension(imageUrl)
|
val extension = imageUrl.substringAfterLast('.')
|
||||||
|
return extension.isNotEmpty() && ImageFileFilter().isExtensionValid(extension)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,15 +25,15 @@ class BookmarksRepository @Inject constructor(
|
|||||||
) {
|
) {
|
||||||
|
|
||||||
fun observeBookmark(manga: Manga, chapterId: Long, page: Int): Flow<Bookmark?> {
|
fun observeBookmark(manga: Manga, chapterId: Long, page: Int): Flow<Bookmark?> {
|
||||||
return db.getBookmarksDao().observe(manga.id, chapterId, page).map { it?.toBookmark(manga) }
|
return db.bookmarksDao.observe(manga.id, chapterId, page).map { it?.toBookmark(manga) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun observeBookmarks(manga: Manga): Flow<List<Bookmark>> {
|
fun observeBookmarks(manga: Manga): Flow<List<Bookmark>> {
|
||||||
return db.getBookmarksDao().observe(manga.id).mapItems { it.toBookmark(manga) }
|
return db.bookmarksDao.observe(manga.id).mapItems { it.toBookmark(manga) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun observeBookmarks(): Flow<Map<Manga, List<Bookmark>>> {
|
fun observeBookmarks(): Flow<Map<Manga, List<Bookmark>>> {
|
||||||
return db.getBookmarksDao().observe().map { map ->
|
return db.bookmarksDao.observe().map { map ->
|
||||||
val res = LinkedHashMap<Manga, List<Bookmark>>(map.size)
|
val res = LinkedHashMap<Manga, List<Bookmark>>(map.size)
|
||||||
for ((k, v) in map) {
|
for ((k, v) in map) {
|
||||||
val manga = k.toManga()
|
val manga = k.toManga()
|
||||||
@@ -46,9 +46,9 @@ class BookmarksRepository @Inject constructor(
|
|||||||
suspend fun addBookmark(bookmark: Bookmark) {
|
suspend fun addBookmark(bookmark: Bookmark) {
|
||||||
db.withTransaction {
|
db.withTransaction {
|
||||||
val tags = bookmark.manga.tags.toEntities()
|
val tags = bookmark.manga.tags.toEntities()
|
||||||
db.getTagsDao().upsert(tags)
|
db.tagsDao.upsert(tags)
|
||||||
db.getMangaDao().upsert(bookmark.manga.toEntity(), tags)
|
db.mangaDao.upsert(bookmark.manga.toEntity(), tags)
|
||||||
db.getBookmarksDao().insert(bookmark.toEntity())
|
db.bookmarksDao.insert(bookmark.toEntity())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,11 +56,11 @@ class BookmarksRepository @Inject constructor(
|
|||||||
val entity = bookmark.toEntity().copy(
|
val entity = bookmark.toEntity().copy(
|
||||||
imageUrl = imageUrl,
|
imageUrl = imageUrl,
|
||||||
)
|
)
|
||||||
db.getBookmarksDao().upsert(listOf(entity))
|
db.bookmarksDao.upsert(listOf(entity))
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun removeBookmark(mangaId: Long, chapterId: Long, page: Int) {
|
suspend fun removeBookmark(mangaId: Long, chapterId: Long, page: Int) {
|
||||||
check(db.getBookmarksDao().delete(mangaId, chapterId, page) != 0) {
|
check(db.bookmarksDao.delete(mangaId, chapterId, page) != 0) {
|
||||||
"Bookmark not found"
|
"Bookmark not found"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -72,7 +72,7 @@ class BookmarksRepository @Inject constructor(
|
|||||||
suspend fun removeBookmarks(ids: Set<Long>): ReversibleHandle {
|
suspend fun removeBookmarks(ids: Set<Long>): ReversibleHandle {
|
||||||
val entities = ArrayList<BookmarkEntity>(ids.size)
|
val entities = ArrayList<BookmarkEntity>(ids.size)
|
||||||
db.withTransaction {
|
db.withTransaction {
|
||||||
val dao = db.getBookmarksDao()
|
val dao = db.bookmarksDao
|
||||||
for (pageId in ids) {
|
for (pageId in ids) {
|
||||||
val e = dao.find(pageId)
|
val e = dao.find(pageId)
|
||||||
if (e != null) {
|
if (e != null) {
|
||||||
@@ -92,7 +92,7 @@ class BookmarksRepository @Inject constructor(
|
|||||||
db.withTransaction {
|
db.withTransaction {
|
||||||
for (e in entities) {
|
for (e in entities) {
|
||||||
try {
|
try {
|
||||||
db.getBookmarksDao().insert(e)
|
db.bookmarksDao.insert(e)
|
||||||
} catch (e: SQLException) {
|
} catch (e: SQLException) {
|
||||||
e.printStackTraceDebug()
|
e.printStackTraceDebug()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import org.koitharu.kotatsu.main.ui.owners.AppBarOwner
|
|||||||
import org.koitharu.kotatsu.main.ui.owners.SnackbarOwner
|
import org.koitharu.kotatsu.main.ui.owners.SnackbarOwner
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class AllBookmarksActivity :
|
class BookmarksActivity :
|
||||||
BaseActivity<ActivityContainerBinding>(),
|
BaseActivity<ActivityContainerBinding>(),
|
||||||
AppBarOwner,
|
AppBarOwner,
|
||||||
SnackbarOwner {
|
SnackbarOwner {
|
||||||
@@ -34,8 +34,8 @@ class AllBookmarksActivity :
|
|||||||
val fm = supportFragmentManager
|
val fm = supportFragmentManager
|
||||||
if (fm.findFragmentById(R.id.container) == null) {
|
if (fm.findFragmentById(R.id.container) == null) {
|
||||||
fm.commit {
|
fm.commit {
|
||||||
setReorderingAllowed(true)
|
val fragment = BookmarksFragment.newInstance()
|
||||||
replace(R.id.container, AllBookmarksFragment::class.java, null)
|
replace(R.id.container, fragment)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -49,6 +49,6 @@ class AllBookmarksActivity :
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
||||||
fun newIntent(context: Context) = Intent(context, AllBookmarksActivity::class.java)
|
fun newIntent(context: Context) = Intent(context, BookmarksActivity::class.java)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -17,7 +17,7 @@ import coil.ImageLoader
|
|||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
||||||
import org.koitharu.kotatsu.bookmarks.ui.adapter.BookmarksAdapter
|
import org.koitharu.kotatsu.bookmarks.ui.sheet.BookmarksAdapter
|
||||||
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
|
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
|
||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
import org.koitharu.kotatsu.core.ui.BaseFragment
|
import org.koitharu.kotatsu.core.ui.BaseFragment
|
||||||
@@ -25,12 +25,11 @@ import org.koitharu.kotatsu.core.ui.list.ListSelectionController
|
|||||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||||
import org.koitharu.kotatsu.core.ui.list.fastscroll.FastScroller
|
import org.koitharu.kotatsu.core.ui.list.fastscroll.FastScroller
|
||||||
import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver
|
import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver
|
||||||
import org.koitharu.kotatsu.core.util.ext.findAppCompatDelegate
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.observe
|
import org.koitharu.kotatsu.core.util.ext.observe
|
||||||
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
||||||
import org.koitharu.kotatsu.databinding.FragmentListSimpleBinding
|
import org.koitharu.kotatsu.databinding.FragmentListSimpleBinding
|
||||||
import org.koitharu.kotatsu.details.ui.DetailsActivity
|
import org.koitharu.kotatsu.details.ui.DetailsActivity
|
||||||
import org.koitharu.kotatsu.list.ui.GridSpanResolver
|
import org.koitharu.kotatsu.list.ui.MangaListSpanResolver
|
||||||
import org.koitharu.kotatsu.list.ui.adapter.ListHeaderClickListener
|
import org.koitharu.kotatsu.list.ui.adapter.ListHeaderClickListener
|
||||||
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
|
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
|
||||||
import org.koitharu.kotatsu.list.ui.adapter.ListStateHolderListener
|
import org.koitharu.kotatsu.list.ui.adapter.ListStateHolderListener
|
||||||
@@ -42,7 +41,7 @@ import org.koitharu.kotatsu.reader.ui.ReaderActivity
|
|||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class AllBookmarksFragment :
|
class BookmarksFragment :
|
||||||
BaseFragment<FragmentListSimpleBinding>(),
|
BaseFragment<FragmentListSimpleBinding>(),
|
||||||
ListStateHolderListener,
|
ListStateHolderListener,
|
||||||
OnListItemClickListener<Bookmark>,
|
OnListItemClickListener<Bookmark>,
|
||||||
@@ -55,7 +54,7 @@ class AllBookmarksFragment :
|
|||||||
@Inject
|
@Inject
|
||||||
lateinit var settings: AppSettings
|
lateinit var settings: AppSettings
|
||||||
|
|
||||||
private val viewModel by viewModels<AllBookmarksViewModel>()
|
private val viewModel by viewModels<BookmarksViewModel>()
|
||||||
private var bookmarksAdapter: BookmarksAdapter? = null
|
private var bookmarksAdapter: BookmarksAdapter? = null
|
||||||
private var selectionController: ListSelectionController? = null
|
private var selectionController: ListSelectionController? = null
|
||||||
|
|
||||||
@@ -72,7 +71,7 @@ class AllBookmarksFragment :
|
|||||||
) {
|
) {
|
||||||
super.onViewBindingCreated(binding, savedInstanceState)
|
super.onViewBindingCreated(binding, savedInstanceState)
|
||||||
selectionController = ListSelectionController(
|
selectionController = ListSelectionController(
|
||||||
appCompatDelegate = checkNotNull(findAppCompatDelegate()),
|
activity = requireActivity(),
|
||||||
decoration = BookmarksSelectionDecoration(binding.root.context),
|
decoration = BookmarksSelectionDecoration(binding.root.context),
|
||||||
registryOwner = this,
|
registryOwner = this,
|
||||||
callback = this,
|
callback = this,
|
||||||
@@ -86,7 +85,7 @@ class AllBookmarksFragment :
|
|||||||
val spanSizeLookup = SpanSizeLookup()
|
val spanSizeLookup = SpanSizeLookup()
|
||||||
with(binding.recyclerView) {
|
with(binding.recyclerView) {
|
||||||
setHasFixedSize(true)
|
setHasFixedSize(true)
|
||||||
val spanResolver = GridSpanResolver(resources)
|
val spanResolver = MangaListSpanResolver(resources)
|
||||||
addItemDecoration(TypedListSpacingDecoration(context, false))
|
addItemDecoration(TypedListSpacingDecoration(context, false))
|
||||||
adapter = bookmarksAdapter
|
adapter = bookmarksAdapter
|
||||||
addOnLayoutChangeListener(spanResolver)
|
addOnLayoutChangeListener(spanResolver)
|
||||||
@@ -101,7 +100,7 @@ class AllBookmarksFragment :
|
|||||||
}
|
}
|
||||||
viewModel.onError.observeEvent(
|
viewModel.onError.observeEvent(
|
||||||
viewLifecycleOwner,
|
viewLifecycleOwner,
|
||||||
SnackbarErrorObserver(binding.recyclerView, this),
|
SnackbarErrorObserver(binding.recyclerView, this)
|
||||||
)
|
)
|
||||||
viewModel.onActionDone.observeEvent(viewLifecycleOwner, ReversibleActionObserver(binding.recyclerView))
|
viewModel.onActionDone.observeEvent(viewLifecycleOwner, ReversibleActionObserver(binding.recyclerView))
|
||||||
}
|
}
|
||||||
@@ -207,12 +206,11 @@ class AllBookmarksFragment :
|
|||||||
companion object {
|
companion object {
|
||||||
|
|
||||||
@Deprecated(
|
@Deprecated(
|
||||||
"",
|
"", ReplaceWith(
|
||||||
ReplaceWith(
|
|
||||||
"BookmarksFragment()",
|
"BookmarksFragment()",
|
||||||
"org.koitharu.kotatsu.bookmarks.ui.BookmarksFragment",
|
"org.koitharu.kotatsu.bookmarks.ui.BookmarksFragment"
|
||||||
),
|
)
|
||||||
)
|
)
|
||||||
fun newInstance() = AllBookmarksFragment()
|
fun newInstance() = BookmarksFragment()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -25,7 +25,7 @@ import org.koitharu.kotatsu.parsers.model.Manga
|
|||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class AllBookmarksViewModel @Inject constructor(
|
class BookmarksViewModel @Inject constructor(
|
||||||
private val repository: BookmarksRepository,
|
private val repository: BookmarksRepository,
|
||||||
) : BaseViewModel() {
|
) : BaseViewModel() {
|
||||||
|
|
||||||
@@ -3,12 +3,12 @@ package org.koitharu.kotatsu.bookmarks.ui.adapter
|
|||||||
import androidx.lifecycle.LifecycleOwner
|
import androidx.lifecycle.LifecycleOwner
|
||||||
import coil.ImageLoader
|
import coil.ImageLoader
|
||||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
||||||
import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver
|
import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver
|
||||||
import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter
|
import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter
|
||||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||||
import org.koitharu.kotatsu.core.util.ext.decodeRegion
|
import org.koitharu.kotatsu.core.util.ext.decodeRegion
|
||||||
import org.koitharu.kotatsu.core.util.ext.defaultPlaceholders
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.enqueueWith
|
import org.koitharu.kotatsu.core.util.ext.enqueueWith
|
||||||
import org.koitharu.kotatsu.core.util.ext.newImageRequest
|
import org.koitharu.kotatsu.core.util.ext.newImageRequest
|
||||||
import org.koitharu.kotatsu.core.util.ext.source
|
import org.koitharu.kotatsu.core.util.ext.source
|
||||||
@@ -29,7 +29,9 @@ fun bookmarkListAD(
|
|||||||
bind {
|
bind {
|
||||||
binding.imageViewThumb.newImageRequest(lifecycleOwner, item.imageLoadData)?.run {
|
binding.imageViewThumb.newImageRequest(lifecycleOwner, item.imageLoadData)?.run {
|
||||||
size(CoverSizeResolver(binding.imageViewThumb))
|
size(CoverSizeResolver(binding.imageViewThumb))
|
||||||
defaultPlaceholders(context)
|
placeholder(R.drawable.ic_placeholder)
|
||||||
|
fallback(R.drawable.ic_placeholder)
|
||||||
|
error(R.drawable.ic_error_placeholder)
|
||||||
allowRgb565(true)
|
allowRgb565(true)
|
||||||
tag(item)
|
tag(item)
|
||||||
decodeRegion(item.scroll)
|
decodeRegion(item.scroll)
|
||||||
|
|||||||
@@ -1,36 +1,19 @@
|
|||||||
package org.koitharu.kotatsu.bookmarks.ui.adapter
|
package org.koitharu.kotatsu.bookmarks.ui.adapter
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.lifecycle.LifecycleOwner
|
import androidx.lifecycle.LifecycleOwner
|
||||||
import coil.ImageLoader
|
import coil.ImageLoader
|
||||||
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
||||||
import org.koitharu.kotatsu.core.ui.BaseListAdapter
|
import org.koitharu.kotatsu.core.ui.BaseListAdapter
|
||||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||||
import org.koitharu.kotatsu.core.ui.list.fastscroll.FastScroller
|
|
||||||
import org.koitharu.kotatsu.list.ui.adapter.ListHeaderClickListener
|
|
||||||
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
|
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
|
||||||
import org.koitharu.kotatsu.list.ui.adapter.emptyStateListAD
|
|
||||||
import org.koitharu.kotatsu.list.ui.adapter.listHeaderAD
|
|
||||||
import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD
|
|
||||||
import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD
|
|
||||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
|
||||||
|
|
||||||
class BookmarksAdapter(
|
class BookmarksAdapter(
|
||||||
coil: ImageLoader,
|
coil: ImageLoader,
|
||||||
lifecycleOwner: LifecycleOwner,
|
lifecycleOwner: LifecycleOwner,
|
||||||
clickListener: OnListItemClickListener<Bookmark>,
|
clickListener: OnListItemClickListener<Bookmark>,
|
||||||
headerClickListener: ListHeaderClickListener?,
|
) : BaseListAdapter<Bookmark>() {
|
||||||
) : BaseListAdapter<ListModel>(), FastScroller.SectionIndexer {
|
|
||||||
|
|
||||||
init {
|
init {
|
||||||
addDelegate(ListItemType.PAGE_THUMB, bookmarkLargeAD(coil, lifecycleOwner, clickListener))
|
addDelegate(ListItemType.PAGE_THUMB, bookmarkListAD(coil, lifecycleOwner, clickListener))
|
||||||
addDelegate(ListItemType.HEADER, listHeaderAD(headerClickListener))
|
|
||||||
addDelegate(ListItemType.FOOTER_LOADING, loadingFooterAD())
|
|
||||||
addDelegate(ListItemType.STATE_LOADING, loadingStateAD())
|
|
||||||
addDelegate(ListItemType.STATE_EMPTY, emptyStateListAD(coil, lifecycleOwner, null))
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getSectionText(context: Context, position: Int): CharSequence? {
|
|
||||||
return findHeader(position)?.getText(context)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
package org.koitharu.kotatsu.bookmarks.ui.adapter
|
package org.koitharu.kotatsu.bookmarks.ui.sheet
|
||||||
|
|
||||||
import androidx.lifecycle.LifecycleOwner
|
import androidx.lifecycle.LifecycleOwner
|
||||||
import coil.ImageLoader
|
import coil.ImageLoader
|
||||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
||||||
import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver
|
import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver
|
||||||
import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter
|
import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter
|
||||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||||
import org.koitharu.kotatsu.core.util.ext.decodeRegion
|
import org.koitharu.kotatsu.core.util.ext.decodeRegion
|
||||||
import org.koitharu.kotatsu.core.util.ext.defaultPlaceholders
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.enqueueWith
|
import org.koitharu.kotatsu.core.util.ext.enqueueWith
|
||||||
import org.koitharu.kotatsu.core.util.ext.newImageRequest
|
import org.koitharu.kotatsu.core.util.ext.newImageRequest
|
||||||
import org.koitharu.kotatsu.core.util.ext.source
|
import org.koitharu.kotatsu.core.util.ext.source
|
||||||
@@ -30,7 +30,9 @@ fun bookmarkLargeAD(
|
|||||||
bind {
|
bind {
|
||||||
binding.imageViewThumb.newImageRequest(lifecycleOwner, item.imageLoadData)?.run {
|
binding.imageViewThumb.newImageRequest(lifecycleOwner, item.imageLoadData)?.run {
|
||||||
size(CoverSizeResolver(binding.imageViewThumb))
|
size(CoverSizeResolver(binding.imageViewThumb))
|
||||||
defaultPlaceholders(context)
|
placeholder(R.drawable.ic_placeholder)
|
||||||
|
fallback(R.drawable.ic_placeholder)
|
||||||
|
error(R.drawable.ic_error_placeholder)
|
||||||
allowRgb565(true)
|
allowRgb565(true)
|
||||||
tag(item)
|
tag(item)
|
||||||
decodeRegion(item.scroll)
|
decodeRegion(item.scroll)
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package org.koitharu.kotatsu.bookmarks.ui.sheet
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.lifecycle.LifecycleOwner
|
||||||
|
import coil.ImageLoader
|
||||||
|
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
||||||
|
import org.koitharu.kotatsu.core.ui.BaseListAdapter
|
||||||
|
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||||
|
import org.koitharu.kotatsu.core.ui.list.fastscroll.FastScroller
|
||||||
|
import org.koitharu.kotatsu.list.ui.adapter.ListHeaderClickListener
|
||||||
|
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
|
||||||
|
import org.koitharu.kotatsu.list.ui.adapter.emptyStateListAD
|
||||||
|
import org.koitharu.kotatsu.list.ui.adapter.listHeaderAD
|
||||||
|
import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD
|
||||||
|
import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD
|
||||||
|
import org.koitharu.kotatsu.list.ui.model.ListHeader
|
||||||
|
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||||
|
|
||||||
|
class BookmarksAdapter(
|
||||||
|
coil: ImageLoader,
|
||||||
|
lifecycleOwner: LifecycleOwner,
|
||||||
|
clickListener: OnListItemClickListener<Bookmark>,
|
||||||
|
headerClickListener: ListHeaderClickListener?,
|
||||||
|
) : BaseListAdapter<ListModel>(), FastScroller.SectionIndexer {
|
||||||
|
|
||||||
|
init {
|
||||||
|
addDelegate(ListItemType.PAGE_THUMB, bookmarkLargeAD(coil, lifecycleOwner, clickListener))
|
||||||
|
addDelegate(ListItemType.HEADER, listHeaderAD(headerClickListener))
|
||||||
|
addDelegate(ListItemType.FOOTER_LOADING, loadingFooterAD())
|
||||||
|
addDelegate(ListItemType.STATE_LOADING, loadingStateAD())
|
||||||
|
addDelegate(ListItemType.STATE_EMPTY, emptyStateListAD(coil, lifecycleOwner, null))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getSectionText(context: Context, position: Int): CharSequence? {
|
||||||
|
val list = items
|
||||||
|
for (i in (0..position).reversed()) {
|
||||||
|
val item = list.getOrNull(i) ?: continue
|
||||||
|
if (item is ListHeader) {
|
||||||
|
return item.getText(context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
package org.koitharu.kotatsu.bookmarks.ui.sheet
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import androidx.fragment.app.FragmentManager
|
||||||
|
import androidx.fragment.app.viewModels
|
||||||
|
import androidx.recyclerview.widget.GridLayoutManager
|
||||||
|
import coil.ImageLoader
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
||||||
|
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
|
||||||
|
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
|
||||||
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
|
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||||
|
import org.koitharu.kotatsu.core.ui.sheet.AdaptiveSheetBehavior
|
||||||
|
import org.koitharu.kotatsu.core.ui.sheet.AdaptiveSheetCallback
|
||||||
|
import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet
|
||||||
|
import org.koitharu.kotatsu.core.util.RecyclerViewScrollCallback
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.observe
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.plus
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.showDistinct
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.withArgs
|
||||||
|
import org.koitharu.kotatsu.databinding.SheetPagesBinding
|
||||||
|
import org.koitharu.kotatsu.list.ui.MangaListSpanResolver
|
||||||
|
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
|
||||||
|
import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
|
||||||
|
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||||
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
|
import org.koitharu.kotatsu.reader.ui.ReaderActivity.IntentBuilder
|
||||||
|
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
|
||||||
|
import org.koitharu.kotatsu.reader.ui.thumbnails.OnPageSelectListener
|
||||||
|
import org.koitharu.kotatsu.reader.ui.thumbnails.PageThumbnail
|
||||||
|
import javax.inject.Inject
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
@AndroidEntryPoint
|
||||||
|
class BookmarksSheet :
|
||||||
|
BaseAdaptiveSheet<SheetPagesBinding>(),
|
||||||
|
AdaptiveSheetCallback,
|
||||||
|
OnListItemClickListener<Bookmark> {
|
||||||
|
|
||||||
|
private val viewModel by viewModels<BookmarksSheetViewModel>()
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var coil: ImageLoader
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var settings: AppSettings
|
||||||
|
|
||||||
|
private var bookmarksAdapter: BookmarksAdapter? = null
|
||||||
|
private var spanResolver: MangaListSpanResolver? = null
|
||||||
|
|
||||||
|
private val spanSizeLookup = SpanSizeLookup()
|
||||||
|
private val listCommitCallback = Runnable {
|
||||||
|
spanSizeLookup.invalidateCache()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): SheetPagesBinding {
|
||||||
|
return SheetPagesBinding.inflate(inflater, container, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewBindingCreated(binding: SheetPagesBinding, savedInstanceState: Bundle?) {
|
||||||
|
super.onViewBindingCreated(binding, savedInstanceState)
|
||||||
|
addSheetCallback(this)
|
||||||
|
spanResolver = MangaListSpanResolver(binding.root.resources)
|
||||||
|
bookmarksAdapter = BookmarksAdapter(
|
||||||
|
coil = coil,
|
||||||
|
lifecycleOwner = viewLifecycleOwner,
|
||||||
|
clickListener = this@BookmarksSheet,
|
||||||
|
headerClickListener = null,
|
||||||
|
)
|
||||||
|
viewBinding?.headerBar?.setTitle(R.string.bookmarks)
|
||||||
|
with(binding.recyclerView) {
|
||||||
|
addItemDecoration(TypedListSpacingDecoration(context, false))
|
||||||
|
adapter = bookmarksAdapter
|
||||||
|
addOnLayoutChangeListener(spanResolver)
|
||||||
|
spanResolver?.setGridSize(settings.gridSize / 100f, this)
|
||||||
|
(layoutManager as GridLayoutManager).spanSizeLookup = spanSizeLookup
|
||||||
|
}
|
||||||
|
viewModel.content.observe(viewLifecycleOwner, ::onThumbnailsChanged)
|
||||||
|
viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(binding.recyclerView, this))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroyView() {
|
||||||
|
spanResolver = null
|
||||||
|
bookmarksAdapter = null
|
||||||
|
spanSizeLookup.invalidateCache()
|
||||||
|
super.onDestroyView()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onItemClick(item: Bookmark, view: View) {
|
||||||
|
val listener = (parentFragment as? OnPageSelectListener) ?: (activity as? OnPageSelectListener)
|
||||||
|
if (listener != null) {
|
||||||
|
listener.onPageSelected(ReaderPage(item.toMangaPage(), item.page, item.chapterId))
|
||||||
|
} else {
|
||||||
|
val intent = IntentBuilder(view.context)
|
||||||
|
.manga(viewModel.manga)
|
||||||
|
.bookmark(item)
|
||||||
|
.incognito(true)
|
||||||
|
.build()
|
||||||
|
startActivity(intent)
|
||||||
|
}
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStateChanged(sheet: View, newState: Int) {
|
||||||
|
viewBinding?.recyclerView?.isFastScrollerEnabled = newState == AdaptiveSheetBehavior.STATE_EXPANDED
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onThumbnailsChanged(list: List<ListModel>) {
|
||||||
|
val adapter = bookmarksAdapter ?: return
|
||||||
|
if (adapter.itemCount == 0) {
|
||||||
|
var position = list.indexOfFirst { it is PageThumbnail && it.isCurrent }
|
||||||
|
if (position > 0) {
|
||||||
|
val spanCount = spanResolver?.spanCount ?: 0
|
||||||
|
val offset = if (position > spanCount + 1) {
|
||||||
|
(resources.getDimensionPixelSize(R.dimen.manga_list_details_item_height) * 0.6).roundToInt()
|
||||||
|
} else {
|
||||||
|
position = 0
|
||||||
|
0
|
||||||
|
}
|
||||||
|
val scrollCallback = RecyclerViewScrollCallback(requireViewBinding().recyclerView, position, offset)
|
||||||
|
adapter.setItems(list, listCommitCallback + scrollCallback)
|
||||||
|
} else {
|
||||||
|
adapter.setItems(list, listCommitCallback)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
adapter.setItems(list, listCommitCallback)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private inner class SpanSizeLookup : GridLayoutManager.SpanSizeLookup() {
|
||||||
|
|
||||||
|
init {
|
||||||
|
isSpanIndexCacheEnabled = true
|
||||||
|
isSpanGroupIndexCacheEnabled = true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getSpanSize(position: Int): Int {
|
||||||
|
val total = (viewBinding?.recyclerView?.layoutManager as? GridLayoutManager)?.spanCount ?: return 1
|
||||||
|
return when (bookmarksAdapter?.getItemViewType(position)) {
|
||||||
|
ListItemType.PAGE_THUMB.ordinal -> 1
|
||||||
|
else -> total
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun invalidateCache() {
|
||||||
|
invalidateSpanGroupIndexCache()
|
||||||
|
invalidateSpanIndexCache()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
const val ARG_MANGA = "manga"
|
||||||
|
|
||||||
|
private const val TAG = "BookmarksSheet"
|
||||||
|
|
||||||
|
fun show(fm: FragmentManager, manga: Manga) {
|
||||||
|
BookmarksSheet().withArgs(1) {
|
||||||
|
putParcelable(ARG_MANGA, ParcelableManga(manga))
|
||||||
|
}.showDistinct(fm, TAG)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package org.koitharu.kotatsu.bookmarks.ui.sheet
|
||||||
|
|
||||||
|
import androidx.lifecycle.SavedStateHandle
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.plus
|
||||||
|
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
||||||
|
import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository
|
||||||
|
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
|
||||||
|
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||||
|
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.require
|
||||||
|
import org.koitharu.kotatsu.list.ui.model.ListHeader
|
||||||
|
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||||
|
import org.koitharu.kotatsu.list.ui.model.LoadingFooter
|
||||||
|
import org.koitharu.kotatsu.parsers.util.SuspendLazy
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class BookmarksSheetViewModel @Inject constructor(
|
||||||
|
savedStateHandle: SavedStateHandle,
|
||||||
|
mangaRepositoryFactory: MangaRepository.Factory,
|
||||||
|
bookmarksRepository: BookmarksRepository,
|
||||||
|
) : BaseViewModel() {
|
||||||
|
|
||||||
|
val manga = savedStateHandle.require<ParcelableManga>(BookmarksSheet.ARG_MANGA).manga
|
||||||
|
private val chaptersLazy = SuspendLazy {
|
||||||
|
requireNotNull(manga.chapters ?: mangaRepositoryFactory.create(manga.source).getDetails(manga).chapters)
|
||||||
|
}
|
||||||
|
|
||||||
|
val content: StateFlow<List<ListModel>> = bookmarksRepository.observeBookmarks(manga)
|
||||||
|
.map { mapList(it) }
|
||||||
|
.withErrorHandling()
|
||||||
|
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, listOf(LoadingFooter()))
|
||||||
|
|
||||||
|
private suspend fun mapList(bookmarks: List<Bookmark>): List<ListModel> {
|
||||||
|
val chapters = chaptersLazy.get()
|
||||||
|
val bookmarksMap = bookmarks.groupBy { it.chapterId }
|
||||||
|
val result = ArrayList<ListModel>(bookmarks.size + bookmarksMap.size)
|
||||||
|
for (chapter in chapters) {
|
||||||
|
val b = bookmarksMap[chapter.id]
|
||||||
|
if (b.isNullOrEmpty()) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result += ListHeader(chapter.name)
|
||||||
|
result.addAll(b)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package org.koitharu.kotatsu.browser
|
package org.koitharu.kotatsu.browser
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
import android.content.ActivityNotFoundException
|
import android.content.ActivityNotFoundException
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
@@ -11,42 +12,31 @@ import android.webkit.CookieManager
|
|||||||
import androidx.core.graphics.Insets
|
import androidx.core.graphics.Insets
|
||||||
import androidx.core.view.isVisible
|
import androidx.core.view.isVisible
|
||||||
import androidx.core.view.updatePadding
|
import androidx.core.view.updatePadding
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.core.network.CommonHeaders
|
|
||||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
|
||||||
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
|
|
||||||
import org.koitharu.kotatsu.core.ui.BaseActivity
|
import org.koitharu.kotatsu.core.ui.BaseActivity
|
||||||
import org.koitharu.kotatsu.core.util.ext.configureForParser
|
import org.koitharu.kotatsu.core.util.ext.catchingWebViewUnavailability
|
||||||
import org.koitharu.kotatsu.core.util.ext.getSerializableExtraCompat
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.toUriOrNull
|
|
||||||
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
|
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
import org.koitharu.kotatsu.parsers.network.UserAgents
|
||||||
import javax.inject.Inject
|
|
||||||
import com.google.android.material.R as materialR
|
import com.google.android.material.R as materialR
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@SuppressLint("SetJavaScriptEnabled")
|
||||||
class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback {
|
class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback {
|
||||||
|
|
||||||
private lateinit var onBackPressedCallback: WebViewBackPressedCallback
|
private lateinit var onBackPressedCallback: WebViewBackPressedCallback
|
||||||
|
|
||||||
@Inject
|
|
||||||
lateinit var mangaRepositoryFactory: MangaRepository.Factory
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
if (!setContentViewWebViewSafe { ActivityBrowserBinding.inflate(layoutInflater) }) {
|
if (!catchingWebViewUnavailability { setContentView(ActivityBrowserBinding.inflate(layoutInflater)) }) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
supportActionBar?.run {
|
supportActionBar?.run {
|
||||||
setDisplayHomeAsUpEnabled(true)
|
setDisplayHomeAsUpEnabled(true)
|
||||||
setHomeAsUpIndicator(materialR.drawable.abc_ic_clear_material)
|
setHomeAsUpIndicator(materialR.drawable.abc_ic_clear_material)
|
||||||
}
|
}
|
||||||
val userAgent = intent?.getSerializableExtraCompat<MangaSource>(EXTRA_SOURCE)?.let { source ->
|
with(viewBinding.webView.settings) {
|
||||||
val repository = mangaRepositoryFactory.create(source) as? RemoteMangaRepository
|
javaScriptEnabled = true
|
||||||
repository?.headers?.get(CommonHeaders.USER_AGENT)
|
userAgentString = UserAgents.CHROME_MOBILE
|
||||||
}
|
}
|
||||||
viewBinding.webView.configureForParser(userAgent)
|
|
||||||
CookieManager.getInstance().setAcceptThirdPartyCookies(viewBinding.webView, true)
|
CookieManager.getInstance().setAcceptThirdPartyCookies(viewBinding.webView, true)
|
||||||
viewBinding.webView.webViewClient = BrowserClient(this)
|
viewBinding.webView.webViewClient = BrowserClient(this)
|
||||||
viewBinding.webView.webChromeClient = ProgressChromeClient(viewBinding.progressBar)
|
viewBinding.webView.webChromeClient = ProgressChromeClient(viewBinding.progressBar)
|
||||||
@@ -67,6 +57,16 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onSaveInstanceState(outState: Bundle) {
|
||||||
|
super.onSaveInstanceState(outState)
|
||||||
|
viewBinding.webView.saveState(outState)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
|
||||||
|
super.onRestoreInstanceState(savedInstanceState)
|
||||||
|
viewBinding.webView.restoreState(savedInstanceState)
|
||||||
|
}
|
||||||
|
|
||||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||||
super.onCreateOptionsMenu(menu)
|
super.onCreateOptionsMenu(menu)
|
||||||
menuInflater.inflate(R.menu.opt_browser, menu)
|
menuInflater.inflate(R.menu.opt_browser, menu)
|
||||||
@@ -81,14 +81,11 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
|
|||||||
}
|
}
|
||||||
|
|
||||||
R.id.action_browser -> {
|
R.id.action_browser -> {
|
||||||
val url = viewBinding.webView.url?.toUriOrNull()
|
val intent = Intent(Intent.ACTION_VIEW)
|
||||||
if (url != null) {
|
intent.data = Uri.parse(viewBinding.webView.url)
|
||||||
val intent = Intent(Intent.ACTION_VIEW)
|
try {
|
||||||
intent.data = url
|
startActivity(Intent.createChooser(intent, item.title))
|
||||||
try {
|
} catch (_: ActivityNotFoundException) {
|
||||||
startActivity(Intent.createChooser(intent, item.title))
|
|
||||||
} catch (_: ActivityNotFoundException) {
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
@@ -139,13 +136,11 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
|
|||||||
companion object {
|
companion object {
|
||||||
|
|
||||||
private const val EXTRA_TITLE = "title"
|
private const val EXTRA_TITLE = "title"
|
||||||
private const val EXTRA_SOURCE = "source"
|
|
||||||
|
|
||||||
fun newIntent(context: Context, url: String, source: MangaSource?, title: String?): Intent {
|
fun newIntent(context: Context, url: String, title: String?): Intent {
|
||||||
return Intent(context, BrowserActivity::class.java)
|
return Intent(context, BrowserActivity::class.java)
|
||||||
.setData(Uri.parse(url))
|
.setData(Uri.parse(url))
|
||||||
.putExtra(EXTRA_TITLE, title)
|
.putExtra(EXTRA_TITLE, title)
|
||||||
.putExtra(EXTRA_SOURCE, source)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
package org.koitharu.kotatsu.browser.cloudflare
|
package org.koitharu.kotatsu.browser.cloudflare
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
|
||||||
import android.os.Build
|
|
||||||
import android.provider.Settings
|
|
||||||
import androidx.core.app.NotificationChannelCompat
|
import androidx.core.app.NotificationChannelCompat
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import androidx.core.app.NotificationManagerCompat
|
import androidx.core.app.NotificationManagerCompat
|
||||||
@@ -16,14 +13,13 @@ import org.koitharu.kotatsu.R
|
|||||||
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
|
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
|
||||||
import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission
|
import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission
|
||||||
import org.koitharu.kotatsu.parsers.model.ContentType
|
import org.koitharu.kotatsu.parsers.model.ContentType
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
|
||||||
|
|
||||||
class CaptchaNotifier(
|
class CaptchaNotifier(
|
||||||
private val context: Context,
|
private val context: Context,
|
||||||
) : EventListener {
|
) : EventListener {
|
||||||
|
|
||||||
fun notify(exception: CloudFlareProtectedException) {
|
fun notify(exception: CloudFlareProtectedException) {
|
||||||
if (!context.checkNotificationPermission(CHANNEL_ID)) {
|
if (!context.checkNotificationPermission()) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val manager = NotificationManagerCompat.from(context)
|
val manager = NotificationManagerCompat.from(context)
|
||||||
@@ -36,14 +32,13 @@ class CaptchaNotifier(
|
|||||||
.build()
|
.build()
|
||||||
manager.createNotificationChannel(channel)
|
manager.createNotificationChannel(channel)
|
||||||
|
|
||||||
val intent = CloudFlareActivity.newIntent(context, exception)
|
val intent = CloudFlareActivity.newIntent(context, exception.url, exception.headers)
|
||||||
.setData(exception.url.toUri())
|
.setData(exception.url.toUri())
|
||||||
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||||
.setContentTitle(channel.name)
|
.setContentTitle(channel.name)
|
||||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||||
.setDefaults(NotificationCompat.DEFAULT_SOUND)
|
.setDefaults(NotificationCompat.DEFAULT_SOUND)
|
||||||
.setSmallIcon(android.R.drawable.stat_notify_error)
|
.setSmallIcon(android.R.drawable.stat_notify_error)
|
||||||
.setGroup(GROUP_CAPTCHA)
|
|
||||||
.setAutoCancel(true)
|
.setAutoCancel(true)
|
||||||
.setVisibility(
|
.setVisibility(
|
||||||
if (exception.source?.contentType == ContentType.HENTAI) {
|
if (exception.source?.contentType == ContentType.HENTAI) {
|
||||||
@@ -59,47 +54,21 @@ class CaptchaNotifier(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
.setContentIntent(PendingIntentCompat.getActivity(context, 0, intent, 0, false))
|
.setContentIntent(PendingIntentCompat.getActivity(context, 0, intent, 0, false))
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
.build()
|
||||||
val actionIntent = PendingIntentCompat.getActivity(
|
manager.notify(TAG, exception.source.hashCode(), notification)
|
||||||
context, SETTINGS_ACTION_CODE,
|
|
||||||
Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS)
|
|
||||||
.putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
|
|
||||||
.putExtra(Settings.EXTRA_CHANNEL_ID, CHANNEL_ID),
|
|
||||||
0, false,
|
|
||||||
)
|
|
||||||
notification.addAction(
|
|
||||||
R.drawable.ic_settings,
|
|
||||||
context.getString(R.string.notifications_settings),
|
|
||||||
actionIntent,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
manager.notify(TAG, exception.source.hashCode(), notification.build())
|
|
||||||
}
|
|
||||||
|
|
||||||
fun dismiss(source: MangaSource) {
|
|
||||||
NotificationManagerCompat.from(context).cancel(TAG, source.hashCode())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onError(request: ImageRequest, result: ErrorResult) {
|
override fun onError(request: ImageRequest, result: ErrorResult) {
|
||||||
super.onError(request, result)
|
super.onError(request, result)
|
||||||
val e = result.throwable
|
val e = result.throwable
|
||||||
if (e is CloudFlareProtectedException && request.parameters.value<Boolean>(PARAM_IGNORE_CAPTCHA) != true) {
|
if (e is CloudFlareProtectedException) {
|
||||||
notify(e)
|
notify(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
private companion object {
|
||||||
|
|
||||||
fun ImageRequest.Builder.ignoreCaptchaErrors() = setParameter(
|
|
||||||
key = PARAM_IGNORE_CAPTCHA,
|
|
||||||
value = true,
|
|
||||||
memoryCacheKey = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
private const val PARAM_IGNORE_CAPTCHA = "ignore_captcha"
|
|
||||||
private const val CHANNEL_ID = "captcha"
|
private const val CHANNEL_ID = "captcha"
|
||||||
private const val TAG = CHANNEL_ID
|
private const val TAG = CHANNEL_ID
|
||||||
private const val GROUP_CAPTCHA = "org.koitharu.kotatsu.CAPTCHA"
|
|
||||||
private const val SETTINGS_ACTION_CODE = 3
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,15 +23,13 @@ import okhttp3.HttpUrl
|
|||||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.browser.WebViewBackPressedCallback
|
import org.koitharu.kotatsu.browser.WebViewBackPressedCallback
|
||||||
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
|
|
||||||
import org.koitharu.kotatsu.core.model.MangaSource
|
|
||||||
import org.koitharu.kotatsu.core.network.CommonHeaders
|
import org.koitharu.kotatsu.core.network.CommonHeaders
|
||||||
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
|
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
|
||||||
import org.koitharu.kotatsu.core.ui.BaseActivity
|
import org.koitharu.kotatsu.core.ui.BaseActivity
|
||||||
import org.koitharu.kotatsu.core.util.TaggedActivityResult
|
import org.koitharu.kotatsu.core.util.TaggedActivityResult
|
||||||
import org.koitharu.kotatsu.core.util.ext.configureForParser
|
import org.koitharu.kotatsu.core.util.ext.catchingWebViewUnavailability
|
||||||
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
|
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
import org.koitharu.kotatsu.parsers.network.UserAgents
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import com.google.android.material.R as materialR
|
import com.google.android.material.R as materialR
|
||||||
|
|
||||||
@@ -43,12 +41,17 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
|
|||||||
@Inject
|
@Inject
|
||||||
lateinit var cookieJar: MutableCookieJar
|
lateinit var cookieJar: MutableCookieJar
|
||||||
|
|
||||||
private lateinit var cfClient: CloudFlareClient
|
|
||||||
private var onBackPressedCallback: WebViewBackPressedCallback? = null
|
private var onBackPressedCallback: WebViewBackPressedCallback? = null
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
if (!setContentViewWebViewSafe { ActivityBrowserBinding.inflate(layoutInflater) }) {
|
if (!catchingWebViewUnavailability {
|
||||||
|
setContentView(
|
||||||
|
ActivityBrowserBinding.inflate(
|
||||||
|
layoutInflater
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
supportActionBar?.run {
|
supportActionBar?.run {
|
||||||
@@ -56,9 +59,13 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
|
|||||||
setHomeAsUpIndicator(materialR.drawable.abc_ic_clear_material)
|
setHomeAsUpIndicator(materialR.drawable.abc_ic_clear_material)
|
||||||
}
|
}
|
||||||
val url = intent?.dataString.orEmpty()
|
val url = intent?.dataString.orEmpty()
|
||||||
cfClient = CloudFlareClient(cookieJar, this, url)
|
with(viewBinding.webView.settings) {
|
||||||
viewBinding.webView.configureForParser(intent?.getStringExtra(ARG_UA))
|
javaScriptEnabled = true
|
||||||
viewBinding.webView.webViewClient = cfClient
|
domStorageEnabled = true
|
||||||
|
databaseEnabled = true
|
||||||
|
userAgentString = intent?.getStringExtra(ARG_UA) ?: UserAgents.CHROME_MOBILE
|
||||||
|
}
|
||||||
|
viewBinding.webView.webViewClient = CloudFlareClient(cookieJar, this, url)
|
||||||
onBackPressedCallback = WebViewBackPressedCallback(viewBinding.webView).also {
|
onBackPressedCallback = WebViewBackPressedCallback(viewBinding.webView).also {
|
||||||
onBackPressedDispatcher.addCallback(it)
|
onBackPressedDispatcher.addCallback(it)
|
||||||
}
|
}
|
||||||
@@ -75,15 +82,23 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
runCatching {
|
viewBinding.webView.run {
|
||||||
viewBinding.webView
|
stopLoading()
|
||||||
}.onSuccess {
|
destroy()
|
||||||
it.stopLoading()
|
|
||||||
it.destroy()
|
|
||||||
}
|
}
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onSaveInstanceState(outState: Bundle) {
|
||||||
|
super.onSaveInstanceState(outState)
|
||||||
|
viewBinding.webView.saveState(outState)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
|
||||||
|
super.onRestoreInstanceState(savedInstanceState)
|
||||||
|
viewBinding.webView.restoreState(savedInstanceState)
|
||||||
|
}
|
||||||
|
|
||||||
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
|
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
|
||||||
menuInflater.inflate(R.menu.opt_captcha, menu)
|
menuInflater.inflate(R.menu.opt_captcha, menu)
|
||||||
return super.onCreateOptionsMenu(menu)
|
return super.onCreateOptionsMenu(menu)
|
||||||
@@ -108,7 +123,15 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
|
|||||||
}
|
}
|
||||||
|
|
||||||
R.id.action_retry -> {
|
R.id.action_retry -> {
|
||||||
restartCheck()
|
lifecycleScope.launch {
|
||||||
|
viewBinding.webView.stopLoading()
|
||||||
|
yield()
|
||||||
|
val targetUrl = intent?.dataString?.toHttpUrlOrNull()
|
||||||
|
if (targetUrl != null) {
|
||||||
|
clearCfCookies(targetUrl)
|
||||||
|
viewBinding.webView.loadUrl(targetUrl.toString())
|
||||||
|
}
|
||||||
|
}
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,16 +157,8 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
|
|||||||
viewBinding.progressBar.isInvisible = true
|
viewBinding.progressBar.isInvisible = true
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onLoopDetected() {
|
|
||||||
restartCheck()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCheckPassed() {
|
override fun onCheckPassed() {
|
||||||
pendingResult = RESULT_OK
|
pendingResult = RESULT_OK
|
||||||
val source = intent?.getStringExtra(ARG_SOURCE)
|
|
||||||
if (source != null) {
|
|
||||||
CaptchaNotifier(this).dismiss(MangaSource(source))
|
|
||||||
}
|
|
||||||
finishAfterTransition()
|
finishAfterTransition()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,29 +176,16 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
|
|||||||
subtitle?.toString()?.toHttpUrlOrNull()?.topPrivateDomain() ?: subtitle
|
subtitle?.toString()?.toHttpUrlOrNull()?.topPrivateDomain() ?: subtitle
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun restartCheck() {
|
|
||||||
lifecycleScope.launch {
|
|
||||||
viewBinding.webView.stopLoading()
|
|
||||||
yield()
|
|
||||||
cfClient.reset()
|
|
||||||
val targetUrl = intent?.dataString?.toHttpUrlOrNull()
|
|
||||||
if (targetUrl != null) {
|
|
||||||
clearCfCookies(targetUrl)
|
|
||||||
viewBinding.webView.loadUrl(targetUrl.toString())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun clearCfCookies(url: HttpUrl) = runInterruptible(Dispatchers.Default) {
|
private suspend fun clearCfCookies(url: HttpUrl) = runInterruptible(Dispatchers.Default) {
|
||||||
cookieJar.removeCookies(url) { cookie ->
|
cookieJar.removeCookies(url) { cookie ->
|
||||||
val name = cookie.name
|
val name = cookie.name
|
||||||
name.startsWith("cf_") || name.startsWith("_cf") || name.startsWith("__cf") || name == "csrftoken"
|
name.startsWith("cf_") || name.startsWith("_cf") || name.startsWith("__cf")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class Contract : ActivityResultContract<CloudFlareProtectedException, TaggedActivityResult>() {
|
class Contract : ActivityResultContract<Pair<String, Headers?>, TaggedActivityResult>() {
|
||||||
override fun createIntent(context: Context, input: CloudFlareProtectedException): Intent {
|
override fun createIntent(context: Context, input: Pair<String, Headers?>): Intent {
|
||||||
return newIntent(context, input)
|
return newIntent(context, input.first, input.second)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun parseResult(resultCode: Int, intent: Intent?): TaggedActivityResult {
|
override fun parseResult(resultCode: Int, intent: Intent?): TaggedActivityResult {
|
||||||
@@ -195,23 +197,13 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
|
|||||||
|
|
||||||
const val TAG = "CloudFlareActivity"
|
const val TAG = "CloudFlareActivity"
|
||||||
private const val ARG_UA = "ua"
|
private const val ARG_UA = "ua"
|
||||||
private const val ARG_SOURCE = "_source"
|
|
||||||
|
|
||||||
fun newIntent(context: Context, exception: CloudFlareProtectedException) = newIntent(
|
fun newIntent(
|
||||||
context = context,
|
|
||||||
url = exception.url,
|
|
||||||
source = exception.source,
|
|
||||||
headers = exception.headers,
|
|
||||||
)
|
|
||||||
|
|
||||||
private fun newIntent(
|
|
||||||
context: Context,
|
context: Context,
|
||||||
url: String,
|
url: String,
|
||||||
source: MangaSource?,
|
|
||||||
headers: Headers?,
|
headers: Headers?,
|
||||||
) = Intent(context, CloudFlareActivity::class.java).apply {
|
) = Intent(context, CloudFlareActivity::class.java).apply {
|
||||||
data = url.toUri()
|
data = url.toUri()
|
||||||
putExtra(ARG_SOURCE, source?.name)
|
|
||||||
headers?.get(CommonHeaders.USER_AGENT)?.let {
|
headers?.get(CommonHeaders.USER_AGENT)?.let {
|
||||||
putExtra(ARG_UA, it)
|
putExtra(ARG_UA, it)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,4 @@ interface CloudFlareCallback : BrowserCallback {
|
|||||||
fun onPageLoaded()
|
fun onPageLoaded()
|
||||||
|
|
||||||
fun onCheckPassed()
|
fun onCheckPassed()
|
||||||
|
|
||||||
fun onLoopDetected()
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import org.koitharu.kotatsu.browser.BrowserClient
|
|||||||
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
|
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
|
||||||
|
|
||||||
private const val CF_CLEARANCE = "cf_clearance"
|
private const val CF_CLEARANCE = "cf_clearance"
|
||||||
private const val LOOP_COUNTER = 3
|
|
||||||
|
|
||||||
class CloudFlareClient(
|
class CloudFlareClient(
|
||||||
private val cookieJar: MutableCookieJar,
|
private val cookieJar: MutableCookieJar,
|
||||||
@@ -16,7 +15,6 @@ class CloudFlareClient(
|
|||||||
) : BrowserClient(callback) {
|
) : BrowserClient(callback) {
|
||||||
|
|
||||||
private val oldClearance = getClearance()
|
private val oldClearance = getClearance()
|
||||||
private var counter = 0
|
|
||||||
|
|
||||||
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
|
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
|
||||||
super.onPageStarted(view, url, favicon)
|
super.onPageStarted(view, url, favicon)
|
||||||
@@ -33,20 +31,10 @@ class CloudFlareClient(
|
|||||||
callback.onPageLoaded()
|
callback.onPageLoaded()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun reset() {
|
|
||||||
counter = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun checkClearance() {
|
private fun checkClearance() {
|
||||||
val clearance = getClearance()
|
val clearance = getClearance()
|
||||||
if (clearance != null && clearance != oldClearance) {
|
if (clearance != null && clearance != oldClearance) {
|
||||||
callback.onCheckPassed()
|
callback.onCheckPassed()
|
||||||
} else {
|
|
||||||
counter++
|
|
||||||
if (counter >= LOOP_COUNTER) {
|
|
||||||
reset()
|
|
||||||
callback.onLoopDetected()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ import kotlinx.coroutines.flow.asSharedFlow
|
|||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import org.koitharu.kotatsu.BuildConfig
|
import org.koitharu.kotatsu.BuildConfig
|
||||||
import org.koitharu.kotatsu.browser.cloudflare.CaptchaNotifier
|
import org.koitharu.kotatsu.browser.cloudflare.CaptchaNotifier
|
||||||
|
import org.koitharu.kotatsu.core.cache.ContentCache
|
||||||
|
import org.koitharu.kotatsu.core.cache.MemoryContentCache
|
||||||
|
import org.koitharu.kotatsu.core.cache.StubContentCache
|
||||||
import org.koitharu.kotatsu.core.db.MangaDatabase
|
import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||||
import org.koitharu.kotatsu.core.network.ImageProxyInterceptor
|
import org.koitharu.kotatsu.core.network.ImageProxyInterceptor
|
||||||
import org.koitharu.kotatsu.core.network.MangaHttpClient
|
import org.koitharu.kotatsu.core.network.MangaHttpClient
|
||||||
@@ -37,10 +40,9 @@ import org.koitharu.kotatsu.core.parser.favicon.FaviconFetcher
|
|||||||
import org.koitharu.kotatsu.core.ui.image.CoilImageGetter
|
import org.koitharu.kotatsu.core.ui.image.CoilImageGetter
|
||||||
import org.koitharu.kotatsu.core.ui.util.ActivityRecreationHandle
|
import org.koitharu.kotatsu.core.ui.util.ActivityRecreationHandle
|
||||||
import org.koitharu.kotatsu.core.util.AcraScreenLogger
|
import org.koitharu.kotatsu.core.util.AcraScreenLogger
|
||||||
|
import org.koitharu.kotatsu.core.util.IncognitoModeIndicator
|
||||||
import org.koitharu.kotatsu.core.util.ext.connectivityManager
|
import org.koitharu.kotatsu.core.util.ext.connectivityManager
|
||||||
import org.koitharu.kotatsu.core.util.ext.isLowRamDevice
|
import org.koitharu.kotatsu.core.util.ext.isLowRamDevice
|
||||||
import org.koitharu.kotatsu.details.ui.pager.pages.MangaPageFetcher
|
|
||||||
import org.koitharu.kotatsu.details.ui.pager.pages.MangaPageKeyer
|
|
||||||
import org.koitharu.kotatsu.local.data.CacheDir
|
import org.koitharu.kotatsu.local.data.CacheDir
|
||||||
import org.koitharu.kotatsu.local.data.CbzFetcher
|
import org.koitharu.kotatsu.local.data.CbzFetcher
|
||||||
import org.koitharu.kotatsu.local.data.LocalStorageChanges
|
import org.koitharu.kotatsu.local.data.LocalStorageChanges
|
||||||
@@ -48,11 +50,11 @@ import org.koitharu.kotatsu.local.domain.model.LocalManga
|
|||||||
import org.koitharu.kotatsu.main.domain.CoverRestoreInterceptor
|
import org.koitharu.kotatsu.main.domain.CoverRestoreInterceptor
|
||||||
import org.koitharu.kotatsu.main.ui.protect.AppProtectHelper
|
import org.koitharu.kotatsu.main.ui.protect.AppProtectHelper
|
||||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
||||||
|
import org.koitharu.kotatsu.reader.ui.thumbnails.MangaPageFetcher
|
||||||
import org.koitharu.kotatsu.search.ui.MangaSuggestionsProvider
|
import org.koitharu.kotatsu.search.ui.MangaSuggestionsProvider
|
||||||
import org.koitharu.kotatsu.settings.backup.BackupObserver
|
import org.koitharu.kotatsu.settings.backup.BackupObserver
|
||||||
import org.koitharu.kotatsu.sync.domain.SyncController
|
import org.koitharu.kotatsu.sync.domain.SyncController
|
||||||
import org.koitharu.kotatsu.widget.WidgetUpdater
|
import org.koitharu.kotatsu.widget.WidgetUpdater
|
||||||
import javax.inject.Provider
|
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
@Module
|
@Module
|
||||||
@@ -85,7 +87,7 @@ interface AppModule {
|
|||||||
@Singleton
|
@Singleton
|
||||||
fun provideCoil(
|
fun provideCoil(
|
||||||
@ApplicationContext context: Context,
|
@ApplicationContext context: Context,
|
||||||
@MangaHttpClient okHttpClientProvider: Provider<OkHttpClient>,
|
@MangaHttpClient okHttpClient: OkHttpClient,
|
||||||
mangaRepositoryFactory: MangaRepository.Factory,
|
mangaRepositoryFactory: MangaRepository.Factory,
|
||||||
imageProxyInterceptor: ImageProxyInterceptor,
|
imageProxyInterceptor: ImageProxyInterceptor,
|
||||||
pageFetcherFactory: MangaPageFetcher.Factory,
|
pageFetcherFactory: MangaPageFetcher.Factory,
|
||||||
@@ -97,14 +99,11 @@ interface AppModule {
|
|||||||
.directory(rootDir.resolve(CacheDir.THUMBS.dir))
|
.directory(rootDir.resolve(CacheDir.THUMBS.dir))
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
val okHttpClientLazy = lazy {
|
|
||||||
okHttpClientProvider.get().newBuilder().cache(null).build()
|
|
||||||
}
|
|
||||||
return ImageLoader.Builder(context)
|
return ImageLoader.Builder(context)
|
||||||
.okHttpClient { okHttpClientLazy.value }
|
.okHttpClient(okHttpClient.newBuilder().cache(null).build())
|
||||||
.interceptorDispatcher(Dispatchers.Default)
|
.interceptorDispatcher(Dispatchers.Default)
|
||||||
.fetcherDispatcher(Dispatchers.Default)
|
.fetcherDispatcher(Dispatchers.IO)
|
||||||
.decoderDispatcher(Dispatchers.IO)
|
.decoderDispatcher(Dispatchers.Default)
|
||||||
.transformationDispatcher(Dispatchers.Default)
|
.transformationDispatcher(Dispatchers.Default)
|
||||||
.diskCache(diskCacheFactory)
|
.diskCache(diskCacheFactory)
|
||||||
.logger(if (BuildConfig.DEBUG) DebugLogger() else null)
|
.logger(if (BuildConfig.DEBUG) DebugLogger() else null)
|
||||||
@@ -114,8 +113,7 @@ interface AppModule {
|
|||||||
ComponentRegistry.Builder()
|
ComponentRegistry.Builder()
|
||||||
.add(SvgDecoder.Factory())
|
.add(SvgDecoder.Factory())
|
||||||
.add(CbzFetcher.Factory())
|
.add(CbzFetcher.Factory())
|
||||||
.add(FaviconFetcher.Factory(context, okHttpClientLazy, mangaRepositoryFactory))
|
.add(FaviconFetcher.Factory(context, okHttpClient, mangaRepositoryFactory))
|
||||||
.add(MangaPageKeyer())
|
|
||||||
.add(pageFetcherFactory)
|
.add(pageFetcherFactory)
|
||||||
.add(imageProxyInterceptor)
|
.add(imageProxyInterceptor)
|
||||||
.add(coverRestoreInterceptor)
|
.add(coverRestoreInterceptor)
|
||||||
@@ -149,13 +147,27 @@ interface AppModule {
|
|||||||
fun provideActivityLifecycleCallbacks(
|
fun provideActivityLifecycleCallbacks(
|
||||||
appProtectHelper: AppProtectHelper,
|
appProtectHelper: AppProtectHelper,
|
||||||
activityRecreationHandle: ActivityRecreationHandle,
|
activityRecreationHandle: ActivityRecreationHandle,
|
||||||
|
incognitoModeIndicator: IncognitoModeIndicator,
|
||||||
acraScreenLogger: AcraScreenLogger,
|
acraScreenLogger: AcraScreenLogger,
|
||||||
): Set<@JvmSuppressWildcards Application.ActivityLifecycleCallbacks> = arraySetOf(
|
): Set<@JvmSuppressWildcards Application.ActivityLifecycleCallbacks> = arraySetOf(
|
||||||
appProtectHelper,
|
appProtectHelper,
|
||||||
activityRecreationHandle,
|
activityRecreationHandle,
|
||||||
|
incognitoModeIndicator,
|
||||||
acraScreenLogger,
|
acraScreenLogger,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideContentCache(
|
||||||
|
application: Application,
|
||||||
|
): ContentCache {
|
||||||
|
return if (application.isLowRamDevice()) {
|
||||||
|
StubContentCache()
|
||||||
|
} else {
|
||||||
|
MemoryContentCache(application)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
@LocalStorageChanges
|
@LocalStorageChanges
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package org.koitharu.kotatsu.core
|
|||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.os.Build
|
|
||||||
import androidx.annotation.WorkerThread
|
import androidx.annotation.WorkerThread
|
||||||
import androidx.appcompat.app.AppCompatDelegate
|
import androidx.appcompat.app.AppCompatDelegate
|
||||||
import androidx.hilt.work.HiltWorkerFactory
|
import androidx.hilt.work.HiltWorkerFactory
|
||||||
@@ -12,7 +11,6 @@ import androidx.work.WorkManager
|
|||||||
import dagger.hilt.android.HiltAndroidApp
|
import dagger.hilt.android.HiltAndroidApp
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import org.acra.ACRA
|
import org.acra.ACRA
|
||||||
import org.acra.ReportField
|
import org.acra.ReportField
|
||||||
import org.acra.config.dialog
|
import org.acra.config.dialog
|
||||||
@@ -20,7 +18,6 @@ import org.acra.config.httpSender
|
|||||||
import org.acra.data.StringFormat
|
import org.acra.data.StringFormat
|
||||||
import org.acra.ktx.initAcra
|
import org.acra.ktx.initAcra
|
||||||
import org.acra.sender.HttpSender
|
import org.acra.sender.HttpSender
|
||||||
import org.conscrypt.Conscrypt
|
|
||||||
import org.koitharu.kotatsu.BuildConfig
|
import org.koitharu.kotatsu.BuildConfig
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.core.db.MangaDatabase
|
import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||||
@@ -29,7 +26,6 @@ import org.koitharu.kotatsu.core.prefs.AppSettings
|
|||||||
import org.koitharu.kotatsu.core.util.WorkServiceStopHelper
|
import org.koitharu.kotatsu.core.util.WorkServiceStopHelper
|
||||||
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
|
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
|
||||||
import org.koitharu.kotatsu.settings.work.WorkScheduleManager
|
import org.koitharu.kotatsu.settings.work.WorkScheduleManager
|
||||||
import java.security.Security
|
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Provider
|
import javax.inject.Provider
|
||||||
|
|
||||||
@@ -37,13 +33,13 @@ import javax.inject.Provider
|
|||||||
open class BaseApp : Application(), Configuration.Provider {
|
open class BaseApp : Application(), Configuration.Provider {
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
lateinit var databaseObserversProvider: Provider<Set<@JvmSuppressWildcards InvalidationTracker.Observer>>
|
lateinit var databaseObservers: Set<@JvmSuppressWildcards InvalidationTracker.Observer>
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
lateinit var activityLifecycleCallbacks: Set<@JvmSuppressWildcards ActivityLifecycleCallbacks>
|
lateinit var activityLifecycleCallbacks: Set<@JvmSuppressWildcards ActivityLifecycleCallbacks>
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
lateinit var database: Provider<MangaDatabase>
|
lateinit var database: MangaDatabase
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
lateinit var settings: AppSettings
|
lateinit var settings: AppSettings
|
||||||
@@ -60,26 +56,12 @@ open class BaseApp : Application(), Configuration.Provider {
|
|||||||
@Inject
|
@Inject
|
||||||
lateinit var workManagerProvider: Provider<WorkManager>
|
lateinit var workManagerProvider: Provider<WorkManager>
|
||||||
|
|
||||||
override val workManagerConfiguration: Configuration
|
|
||||||
get() = Configuration.Builder()
|
|
||||||
.setWorkerFactory(workerFactory)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
|
ACRA.errorReporter.putCustomData("isOriginalApp", appValidator.isOriginalApp.toString())
|
||||||
AppCompatDelegate.setDefaultNightMode(settings.theme)
|
AppCompatDelegate.setDefaultNightMode(settings.theme)
|
||||||
AppCompatDelegate.setApplicationLocales(settings.appLocales)
|
AppCompatDelegate.setApplicationLocales(settings.appLocales)
|
||||||
// TLS 1.3 support for Android < 10
|
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
|
|
||||||
Security.insertProviderAt(Conscrypt.newProvider(), 1)
|
|
||||||
}
|
|
||||||
setupActivityLifecycleCallbacks()
|
setupActivityLifecycleCallbacks()
|
||||||
processLifecycleScope.launch {
|
|
||||||
val isOriginalApp = withContext(Dispatchers.Default) {
|
|
||||||
appValidator.isOriginalApp
|
|
||||||
}
|
|
||||||
ACRA.errorReporter.putCustomData("isOriginalApp", isOriginalApp.toString())
|
|
||||||
}
|
|
||||||
processLifecycleScope.launch(Dispatchers.Default) {
|
processLifecycleScope.launch(Dispatchers.Default) {
|
||||||
setupDatabaseObservers()
|
setupDatabaseObservers()
|
||||||
}
|
}
|
||||||
@@ -87,11 +69,18 @@ open class BaseApp : Application(), Configuration.Provider {
|
|||||||
WorkServiceStopHelper(workManagerProvider).setup()
|
WorkServiceStopHelper(workManagerProvider).setup()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun attachBaseContext(base: Context) {
|
override fun attachBaseContext(base: Context?) {
|
||||||
super.attachBaseContext(base)
|
super.attachBaseContext(base)
|
||||||
initAcra {
|
initAcra {
|
||||||
buildConfigClass = BuildConfig::class.java
|
buildConfigClass = BuildConfig::class.java
|
||||||
reportFormat = StringFormat.JSON
|
reportFormat = StringFormat.JSON
|
||||||
|
excludeMatchingSharedPreferencesKeys = listOf(
|
||||||
|
"sources_\\w+",
|
||||||
|
AppSettings.KEY_APP_PASSWORD,
|
||||||
|
AppSettings.KEY_PROXY_LOGIN,
|
||||||
|
AppSettings.KEY_PROXY_ADDRESS,
|
||||||
|
AppSettings.KEY_PROXY_PASSWORD,
|
||||||
|
)
|
||||||
httpSender {
|
httpSender {
|
||||||
uri = getString(R.string.url_error_report)
|
uri = getString(R.string.url_error_report)
|
||||||
basicAuthLogin = getString(R.string.acra_login)
|
basicAuthLogin = getString(R.string.acra_login)
|
||||||
@@ -108,6 +97,7 @@ open class BaseApp : Application(), Configuration.Provider {
|
|||||||
ReportField.STACK_TRACE,
|
ReportField.STACK_TRACE,
|
||||||
ReportField.CRASH_CONFIGURATION,
|
ReportField.CRASH_CONFIGURATION,
|
||||||
ReportField.CUSTOM_DATA,
|
ReportField.CUSTOM_DATA,
|
||||||
|
ReportField.SHARED_PREFERENCES,
|
||||||
)
|
)
|
||||||
|
|
||||||
dialog {
|
dialog {
|
||||||
@@ -120,10 +110,16 @@ open class BaseApp : Application(), Configuration.Provider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun getWorkManagerConfiguration(): Configuration {
|
||||||
|
return Configuration.Builder()
|
||||||
|
.setWorkerFactory(workerFactory)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
@WorkerThread
|
@WorkerThread
|
||||||
private fun setupDatabaseObservers() {
|
private fun setupDatabaseObservers() {
|
||||||
val tracker = database.get().invalidationTracker
|
val tracker = database.invalidationTracker
|
||||||
databaseObserversProvider.get().forEach {
|
databaseObservers.forEach {
|
||||||
tracker.addObserver(it)
|
tracker.addObserver(it)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core
|
|
||||||
|
|
||||||
import android.app.PendingIntent
|
|
||||||
import android.content.BroadcastReceiver
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.net.Uri
|
|
||||||
import androidx.core.app.PendingIntentCompat
|
|
||||||
import org.koitharu.kotatsu.BuildConfig
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.getSerializableExtraCompat
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.report
|
|
||||||
|
|
||||||
class ErrorReporterReceiver : BroadcastReceiver() {
|
|
||||||
|
|
||||||
override fun onReceive(context: Context?, intent: Intent?) {
|
|
||||||
val e = intent?.getSerializableExtraCompat<Throwable>(EXTRA_ERROR) ?: return
|
|
||||||
e.report()
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
|
|
||||||
private const val EXTRA_ERROR = "err"
|
|
||||||
private const val ACTION_REPORT = "${BuildConfig.APPLICATION_ID}.action.REPORT_ERROR"
|
|
||||||
|
|
||||||
fun getPendingIntent(context: Context, e: Throwable): PendingIntent {
|
|
||||||
val intent = Intent(context, ErrorReporterReceiver::class.java)
|
|
||||||
intent.setAction(ACTION_REPORT)
|
|
||||||
intent.setData(Uri.parse("err://${e.hashCode()}"))
|
|
||||||
intent.putExtra(EXTRA_ERROR, e)
|
|
||||||
return checkNotNull(PendingIntentCompat.getBroadcast(context, 0, intent, 0, false))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,20 +3,17 @@ package org.koitharu.kotatsu.core.backup
|
|||||||
import org.json.JSONArray
|
import org.json.JSONArray
|
||||||
|
|
||||||
class BackupEntry(
|
class BackupEntry(
|
||||||
val name: Name,
|
val name: String,
|
||||||
val data: JSONArray
|
val data: JSONArray
|
||||||
) {
|
) {
|
||||||
|
|
||||||
enum class Name(
|
companion object Names {
|
||||||
val key: String,
|
|
||||||
) {
|
|
||||||
|
|
||||||
INDEX("index"),
|
const val INDEX = "index"
|
||||||
HISTORY("history"),
|
const val HISTORY = "history"
|
||||||
CATEGORIES("categories"),
|
const val CATEGORIES = "categories"
|
||||||
FAVOURITES("favourites"),
|
const val FAVOURITES = "favourites"
|
||||||
SETTINGS("settings"),
|
const val SETTINGS = "settings"
|
||||||
BOOKMARKS("bookmarks"),
|
const val BOOKMARKS = "bookmarks"
|
||||||
SOURCES("sources"),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,10 +7,8 @@ import org.koitharu.kotatsu.BuildConfig
|
|||||||
import org.koitharu.kotatsu.core.db.MangaDatabase
|
import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
import org.koitharu.kotatsu.parsers.util.json.JSONIterator
|
import org.koitharu.kotatsu.parsers.util.json.JSONIterator
|
||||||
import org.koitharu.kotatsu.parsers.util.json.getLongOrDefault
|
|
||||||
import org.koitharu.kotatsu.parsers.util.json.mapJSON
|
import org.koitharu.kotatsu.parsers.util.json.mapJSON
|
||||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||||
import java.util.Date
|
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
private const val PAGE_SIZE = 10
|
private const val PAGE_SIZE = 10
|
||||||
@@ -22,9 +20,9 @@ class BackupRepository @Inject constructor(
|
|||||||
|
|
||||||
suspend fun dumpHistory(): BackupEntry {
|
suspend fun dumpHistory(): BackupEntry {
|
||||||
var offset = 0
|
var offset = 0
|
||||||
val entry = BackupEntry(BackupEntry.Name.HISTORY, JSONArray())
|
val entry = BackupEntry(BackupEntry.HISTORY, JSONArray())
|
||||||
while (true) {
|
while (true) {
|
||||||
val history = db.getHistoryDao().findAll(offset, PAGE_SIZE)
|
val history = db.historyDao.findAll(offset, PAGE_SIZE)
|
||||||
if (history.isEmpty()) {
|
if (history.isEmpty()) {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -43,8 +41,8 @@ class BackupRepository @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun dumpCategories(): BackupEntry {
|
suspend fun dumpCategories(): BackupEntry {
|
||||||
val entry = BackupEntry(BackupEntry.Name.CATEGORIES, JSONArray())
|
val entry = BackupEntry(BackupEntry.CATEGORIES, JSONArray())
|
||||||
val categories = db.getFavouriteCategoriesDao().findAll()
|
val categories = db.favouriteCategoriesDao.findAll()
|
||||||
for (item in categories) {
|
for (item in categories) {
|
||||||
entry.data.put(JsonSerializer(item).toJson())
|
entry.data.put(JsonSerializer(item).toJson())
|
||||||
}
|
}
|
||||||
@@ -53,9 +51,9 @@ class BackupRepository @Inject constructor(
|
|||||||
|
|
||||||
suspend fun dumpFavourites(): BackupEntry {
|
suspend fun dumpFavourites(): BackupEntry {
|
||||||
var offset = 0
|
var offset = 0
|
||||||
val entry = BackupEntry(BackupEntry.Name.FAVOURITES, JSONArray())
|
val entry = BackupEntry(BackupEntry.FAVOURITES, JSONArray())
|
||||||
while (true) {
|
while (true) {
|
||||||
val favourites = db.getFavouritesDao().findAllRaw(offset, PAGE_SIZE)
|
val favourites = db.favouritesDao.findAll(offset, PAGE_SIZE)
|
||||||
if (favourites.isEmpty()) {
|
if (favourites.isEmpty()) {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -74,8 +72,8 @@ class BackupRepository @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun dumpBookmarks(): BackupEntry {
|
suspend fun dumpBookmarks(): BackupEntry {
|
||||||
val entry = BackupEntry(BackupEntry.Name.BOOKMARKS, JSONArray())
|
val entry = BackupEntry(BackupEntry.BOOKMARKS, JSONArray())
|
||||||
val all = db.getBookmarksDao().findAll()
|
val all = db.bookmarksDao.findAll()
|
||||||
for ((m, b) in all) {
|
for ((m, b) in all) {
|
||||||
val json = JSONObject()
|
val json = JSONObject()
|
||||||
val manga = JsonSerializer(m.manga).toJson()
|
val manga = JsonSerializer(m.manga).toJson()
|
||||||
@@ -92,7 +90,7 @@ class BackupRepository @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun dumpSettings(): BackupEntry {
|
fun dumpSettings(): BackupEntry {
|
||||||
val entry = BackupEntry(BackupEntry.Name.SETTINGS, JSONArray())
|
val entry = BackupEntry(BackupEntry.SETTINGS, JSONArray())
|
||||||
val settingsDump = settings.getAllValues().toMutableMap()
|
val settingsDump = settings.getAllValues().toMutableMap()
|
||||||
settingsDump.remove(AppSettings.KEY_APP_PASSWORD)
|
settingsDump.remove(AppSettings.KEY_APP_PASSWORD)
|
||||||
settingsDump.remove(AppSettings.KEY_PROXY_PASSWORD)
|
settingsDump.remove(AppSettings.KEY_PROXY_PASSWORD)
|
||||||
@@ -103,18 +101,8 @@ class BackupRepository @Inject constructor(
|
|||||||
return entry
|
return entry
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun dumpSources(): BackupEntry {
|
|
||||||
val entry = BackupEntry(BackupEntry.Name.SOURCES, JSONArray())
|
|
||||||
val all = db.getSourcesDao().findAll()
|
|
||||||
for (source in all) {
|
|
||||||
val json = JsonSerializer(source).toJson()
|
|
||||||
entry.data.put(json)
|
|
||||||
}
|
|
||||||
return entry
|
|
||||||
}
|
|
||||||
|
|
||||||
fun createIndex(): BackupEntry {
|
fun createIndex(): BackupEntry {
|
||||||
val entry = BackupEntry(BackupEntry.Name.INDEX, JSONArray())
|
val entry = BackupEntry(BackupEntry.INDEX, JSONArray())
|
||||||
val json = JSONObject()
|
val json = JSONObject()
|
||||||
json.put("app_id", BuildConfig.APPLICATION_ID)
|
json.put("app_id", BuildConfig.APPLICATION_ID)
|
||||||
json.put("app_version", BuildConfig.VERSION_CODE)
|
json.put("app_version", BuildConfig.VERSION_CODE)
|
||||||
@@ -123,11 +111,6 @@ class BackupRepository @Inject constructor(
|
|||||||
return entry
|
return entry
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getBackupDate(entry: BackupEntry?): Date? {
|
|
||||||
val timestamp = entry?.data?.optJSONObject(0)?.getLongOrDefault("created_at", 0) ?: 0
|
|
||||||
return if (timestamp == 0L) null else Date(timestamp)
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun restoreHistory(entry: BackupEntry): CompositeResult {
|
suspend fun restoreHistory(entry: BackupEntry): CompositeResult {
|
||||||
val result = CompositeResult()
|
val result = CompositeResult()
|
||||||
for (item in entry.data.JSONIterator()) {
|
for (item in entry.data.JSONIterator()) {
|
||||||
@@ -139,9 +122,9 @@ class BackupRepository @Inject constructor(
|
|||||||
val history = JsonDeserializer(item).toHistoryEntity()
|
val history = JsonDeserializer(item).toHistoryEntity()
|
||||||
result += runCatchingCancellable {
|
result += runCatchingCancellable {
|
||||||
db.withTransaction {
|
db.withTransaction {
|
||||||
db.getTagsDao().upsert(tags)
|
db.tagsDao.upsert(tags)
|
||||||
db.getMangaDao().upsert(manga, tags)
|
db.mangaDao.upsert(manga, tags)
|
||||||
db.getHistoryDao().upsert(history)
|
db.historyDao.upsert(history)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -153,7 +136,7 @@ class BackupRepository @Inject constructor(
|
|||||||
for (item in entry.data.JSONIterator()) {
|
for (item in entry.data.JSONIterator()) {
|
||||||
val category = JsonDeserializer(item).toFavouriteCategoryEntity()
|
val category = JsonDeserializer(item).toFavouriteCategoryEntity()
|
||||||
result += runCatchingCancellable {
|
result += runCatchingCancellable {
|
||||||
db.getFavouriteCategoriesDao().upsert(category)
|
db.favouriteCategoriesDao.upsert(category)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
@@ -170,9 +153,9 @@ class BackupRepository @Inject constructor(
|
|||||||
val favourite = JsonDeserializer(item).toFavouriteEntity()
|
val favourite = JsonDeserializer(item).toFavouriteEntity()
|
||||||
result += runCatchingCancellable {
|
result += runCatchingCancellable {
|
||||||
db.withTransaction {
|
db.withTransaction {
|
||||||
db.getTagsDao().upsert(tags)
|
db.tagsDao.upsert(tags)
|
||||||
db.getMangaDao().upsert(manga, tags)
|
db.mangaDao.upsert(manga, tags)
|
||||||
db.getFavouritesDao().upsert(favourite)
|
db.favouritesDao.upsert(favourite)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -192,26 +175,15 @@ class BackupRepository @Inject constructor(
|
|||||||
}
|
}
|
||||||
result += runCatchingCancellable {
|
result += runCatchingCancellable {
|
||||||
db.withTransaction {
|
db.withTransaction {
|
||||||
db.getTagsDao().upsert(tags)
|
db.tagsDao.upsert(tags)
|
||||||
db.getMangaDao().upsert(manga, tags)
|
db.mangaDao.upsert(manga, tags)
|
||||||
db.getBookmarksDao().upsert(bookmarks)
|
db.bookmarksDao.upsert(bookmarks)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun restoreSources(entry: BackupEntry): CompositeResult {
|
|
||||||
val result = CompositeResult()
|
|
||||||
for (item in entry.data.JSONIterator()) {
|
|
||||||
val source = JsonDeserializer(item).toMangaSourceEntity()
|
|
||||||
result += runCatchingCancellable {
|
|
||||||
db.getSourcesDao().upsert(source)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
fun restoreSettings(entry: BackupEntry): CompositeResult {
|
fun restoreSettings(entry: BackupEntry): CompositeResult {
|
||||||
val result = CompositeResult()
|
val result = CompositeResult()
|
||||||
for (item in entry.data.JSONIterator()) {
|
for (item in entry.data.JSONIterator()) {
|
||||||
|
|||||||
@@ -1,59 +1,25 @@
|
|||||||
package org.koitharu.kotatsu.core.backup
|
package org.koitharu.kotatsu.core.backup
|
||||||
|
|
||||||
import kotlinx.coroutines.CoroutineStart
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.runInterruptible
|
import kotlinx.coroutines.runInterruptible
|
||||||
import okio.Closeable
|
import okio.Closeable
|
||||||
import org.json.JSONArray
|
import org.json.JSONArray
|
||||||
import org.koitharu.kotatsu.core.exceptions.BadBackupFormatException
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
|
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.util.EnumSet
|
|
||||||
import java.util.zip.ZipException
|
|
||||||
import java.util.zip.ZipFile
|
import java.util.zip.ZipFile
|
||||||
|
|
||||||
class BackupZipInput private constructor(val file: File) : Closeable {
|
class BackupZipInput(val file: File) : Closeable {
|
||||||
|
|
||||||
private val zipFile = ZipFile(file)
|
private val zipFile = ZipFile(file)
|
||||||
|
|
||||||
suspend fun getEntry(name: BackupEntry.Name): BackupEntry? = runInterruptible(Dispatchers.IO) {
|
suspend fun getEntry(name: String): BackupEntry? = runInterruptible(Dispatchers.IO) {
|
||||||
val entry = zipFile.getEntry(name.key) ?: return@runInterruptible null
|
val entry = zipFile.getEntry(name) ?: return@runInterruptible null
|
||||||
val json = zipFile.getInputStream(entry).use {
|
val json = zipFile.getInputStream(entry).use {
|
||||||
JSONArray(it.bufferedReader().readText())
|
JSONArray(it.bufferedReader().readText())
|
||||||
}
|
}
|
||||||
BackupEntry(name, json)
|
BackupEntry(name, json)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun entries(): Set<BackupEntry.Name> = runInterruptible(Dispatchers.IO) {
|
|
||||||
zipFile.entries().toList().mapNotNullTo(EnumSet.noneOf(BackupEntry.Name::class.java)) { ze ->
|
|
||||||
BackupEntry.Name.entries.find { it.key == ze.name }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun close() {
|
override fun close() {
|
||||||
zipFile.close()
|
zipFile.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun cleanupAsync() {
|
|
||||||
processLifecycleScope.launch(Dispatchers.IO, CoroutineStart.ATOMIC) {
|
|
||||||
runCatching {
|
|
||||||
close()
|
|
||||||
file.delete()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
|
|
||||||
fun from(file: File): BackupZipInput = try {
|
|
||||||
val res = BackupZipInput(file)
|
|
||||||
if (res.zipFile.getEntry("index") == null) {
|
|
||||||
throw BadBackupFormatException(null)
|
|
||||||
}
|
|
||||||
res
|
|
||||||
} catch (e: ZipException) {
|
|
||||||
throw BadBackupFormatException(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ import kotlinx.coroutines.Dispatchers
|
|||||||
import kotlinx.coroutines.runInterruptible
|
import kotlinx.coroutines.runInterruptible
|
||||||
import okio.Closeable
|
import okio.Closeable
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.format
|
||||||
import org.koitharu.kotatsu.core.zip.ZipOutput
|
import org.koitharu.kotatsu.core.zip.ZipOutput
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.time.LocalDate
|
import java.util.Date
|
||||||
import java.time.format.DateTimeFormatter
|
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import java.util.zip.Deflater
|
import java.util.zip.Deflater
|
||||||
|
|
||||||
@@ -17,7 +17,7 @@ class BackupZipOutput(val file: File) : Closeable {
|
|||||||
private val output = ZipOutput(file, Deflater.BEST_COMPRESSION)
|
private val output = ZipOutput(file, Deflater.BEST_COMPRESSION)
|
||||||
|
|
||||||
suspend fun put(entry: BackupEntry) = runInterruptible(Dispatchers.IO) {
|
suspend fun put(entry: BackupEntry) = runInterruptible(Dispatchers.IO) {
|
||||||
output.put(entry.name.key, entry.data.toString(2))
|
output.put(entry.name, entry.data.toString(2))
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun finish() = runInterruptible(Dispatchers.IO) {
|
suspend fun finish() = runInterruptible(Dispatchers.IO) {
|
||||||
@@ -29,7 +29,7 @@ class BackupZipOutput(val file: File) : Closeable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const val DIR_BACKUPS = "backups"
|
private const val DIR_BACKUPS = "backups"
|
||||||
|
|
||||||
suspend fun BackupZipOutput(context: Context): BackupZipOutput = runInterruptible(Dispatchers.IO) {
|
suspend fun BackupZipOutput(context: Context): BackupZipOutput = runInterruptible(Dispatchers.IO) {
|
||||||
val dir = context.run {
|
val dir = context.run {
|
||||||
@@ -39,7 +39,7 @@ suspend fun BackupZipOutput(context: Context): BackupZipOutput = runInterruptibl
|
|||||||
val filename = buildString {
|
val filename = buildString {
|
||||||
append(context.getString(R.string.app_name).replace(' ', '_').lowercase(Locale.ROOT))
|
append(context.getString(R.string.app_name).replace(' ', '_').lowercase(Locale.ROOT))
|
||||||
append('_')
|
append('_')
|
||||||
append(LocalDate.now().format(DateTimeFormatter.ofPattern("ddMMyyyy")))
|
append(Date().format("ddMMyyyy"))
|
||||||
append(".bk.zip")
|
append(".bk.zip")
|
||||||
}
|
}
|
||||||
BackupZipOutput(File(dir, filename))
|
BackupZipOutput(File(dir, filename))
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package org.koitharu.kotatsu.core.backup
|
|||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import org.koitharu.kotatsu.bookmarks.data.BookmarkEntity
|
import org.koitharu.kotatsu.bookmarks.data.BookmarkEntity
|
||||||
import org.koitharu.kotatsu.core.db.entity.MangaEntity
|
import org.koitharu.kotatsu.core.db.entity.MangaEntity
|
||||||
import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity
|
|
||||||
import org.koitharu.kotatsu.core.db.entity.TagEntity
|
import org.koitharu.kotatsu.core.db.entity.TagEntity
|
||||||
import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity
|
import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity
|
||||||
import org.koitharu.kotatsu.favourites.data.FavouriteEntity
|
import org.koitharu.kotatsu.favourites.data.FavouriteEntity
|
||||||
@@ -54,7 +53,6 @@ class JsonDeserializer(private val json: JSONObject) {
|
|||||||
page = json.getInt("page"),
|
page = json.getInt("page"),
|
||||||
scroll = json.getDouble("scroll").toFloat(),
|
scroll = json.getDouble("scroll").toFloat(),
|
||||||
percent = json.getFloatOrDefault("percent", -1f),
|
percent = json.getFloatOrDefault("percent", -1f),
|
||||||
chaptersCount = json.getIntOrDefault("chapters", -1),
|
|
||||||
deletedAt = 0L,
|
deletedAt = 0L,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -80,12 +78,6 @@ class JsonDeserializer(private val json: JSONObject) {
|
|||||||
percent = json.getDouble("percent").toFloat(),
|
percent = json.getDouble("percent").toFloat(),
|
||||||
)
|
)
|
||||||
|
|
||||||
fun toMangaSourceEntity() = MangaSourceEntity(
|
|
||||||
source = json.getString("source"),
|
|
||||||
isEnabled = json.getBoolean("enabled"),
|
|
||||||
sortKey = json.getInt("sort_key"),
|
|
||||||
)
|
|
||||||
|
|
||||||
fun toMap(): Map<String, Any?> {
|
fun toMap(): Map<String, Any?> {
|
||||||
val map = mutableMapOf<String, Any?>()
|
val map = mutableMapOf<String, Any?>()
|
||||||
val keys = json.keys()
|
val keys = json.keys()
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package org.koitharu.kotatsu.core.backup
|
|||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import org.koitharu.kotatsu.bookmarks.data.BookmarkEntity
|
import org.koitharu.kotatsu.bookmarks.data.BookmarkEntity
|
||||||
import org.koitharu.kotatsu.core.db.entity.MangaEntity
|
import org.koitharu.kotatsu.core.db.entity.MangaEntity
|
||||||
import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity
|
|
||||||
import org.koitharu.kotatsu.core.db.entity.TagEntity
|
import org.koitharu.kotatsu.core.db.entity.TagEntity
|
||||||
import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity
|
import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity
|
||||||
import org.koitharu.kotatsu.favourites.data.FavouriteEntity
|
import org.koitharu.kotatsu.favourites.data.FavouriteEntity
|
||||||
@@ -41,7 +40,6 @@ class JsonSerializer private constructor(private val json: JSONObject) {
|
|||||||
put("page", e.page)
|
put("page", e.page)
|
||||||
put("scroll", e.scroll)
|
put("scroll", e.scroll)
|
||||||
put("percent", e.percent)
|
put("percent", e.percent)
|
||||||
put("chapters", e.chaptersCount)
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -84,14 +82,6 @@ class JsonSerializer private constructor(private val json: JSONObject) {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
constructor(e: MangaSourceEntity) : this(
|
|
||||||
JSONObject().apply {
|
|
||||||
put("source", e.source)
|
|
||||||
put("enabled", e.isEnabled)
|
|
||||||
put("sort_key", e.sortKey)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
constructor(m: Map<String, *>) : this(
|
constructor(m: Map<String, *>) : this(
|
||||||
JSONObject(m),
|
JSONObject(m),
|
||||||
)
|
)
|
||||||
|
|||||||
27
app/src/main/kotlin/org/koitharu/kotatsu/core/cache/ContentCache.kt
vendored
Normal file
27
app/src/main/kotlin/org/koitharu/kotatsu/core/cache/ContentCache.kt
vendored
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
package org.koitharu.kotatsu.core.cache
|
||||||
|
|
||||||
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
|
|
||||||
|
interface ContentCache {
|
||||||
|
|
||||||
|
val isCachingEnabled: Boolean
|
||||||
|
|
||||||
|
suspend fun getDetails(source: MangaSource, url: String): Manga?
|
||||||
|
|
||||||
|
fun putDetails(source: MangaSource, url: String, details: SafeDeferred<Manga>)
|
||||||
|
|
||||||
|
suspend fun getPages(source: MangaSource, url: String): List<MangaPage>?
|
||||||
|
|
||||||
|
fun putPages(source: MangaSource, url: String, pages: SafeDeferred<List<MangaPage>>)
|
||||||
|
|
||||||
|
suspend fun getRelatedManga(source: MangaSource, url: String): List<Manga>?
|
||||||
|
|
||||||
|
fun putRelatedManga(source: MangaSource, url: String, related: SafeDeferred<List<Manga>>)
|
||||||
|
|
||||||
|
data class Key(
|
||||||
|
val source: MangaSource,
|
||||||
|
val url: String,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,27 +2,24 @@ package org.koitharu.kotatsu.core.cache
|
|||||||
|
|
||||||
import androidx.collection.LruCache
|
import androidx.collection.LruCache
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
import org.koitharu.kotatsu.core.cache.MemoryContentCache.Key as CacheKey
|
|
||||||
|
|
||||||
class ExpiringLruCache<T>(
|
class ExpiringLruCache<T>(
|
||||||
val maxSize: Int,
|
val maxSize: Int,
|
||||||
private val lifetime: Long,
|
private val lifetime: Long,
|
||||||
private val timeUnit: TimeUnit,
|
private val timeUnit: TimeUnit,
|
||||||
) : Iterable<CacheKey> {
|
) {
|
||||||
|
|
||||||
private val cache = LruCache<CacheKey, ExpiringValue<T>>(maxSize)
|
private val cache = LruCache<ContentCache.Key, ExpiringValue<T>>(maxSize)
|
||||||
|
|
||||||
override fun iterator(): Iterator<CacheKey> = cache.snapshot().keys.iterator()
|
operator fun get(key: ContentCache.Key): T? {
|
||||||
|
val value = cache.get(key) ?: return null
|
||||||
operator fun get(key: CacheKey): T? {
|
|
||||||
val value = cache[key] ?: return null
|
|
||||||
if (value.isExpired) {
|
if (value.isExpired) {
|
||||||
cache.remove(key)
|
cache.remove(key)
|
||||||
}
|
}
|
||||||
return value.get()
|
return value.get()
|
||||||
}
|
}
|
||||||
|
|
||||||
operator fun set(key: CacheKey, value: T) {
|
operator fun set(key: ContentCache.Key, value: T) {
|
||||||
cache.put(key, ExpiringValue(value, lifetime, timeUnit))
|
cache.put(key, ExpiringValue(value, lifetime, timeUnit))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,8 +30,4 @@ class ExpiringLruCache<T>(
|
|||||||
fun trimToSize(size: Int) {
|
fun trimToSize(size: Int) {
|
||||||
cache.trimToSize(size)
|
cache.trimToSize(size)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun remove(key: CacheKey) {
|
|
||||||
cache.remove(key)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,57 +3,45 @@ package org.koitharu.kotatsu.core.cache
|
|||||||
import android.app.Application
|
import android.app.Application
|
||||||
import android.content.ComponentCallbacks2
|
import android.content.ComponentCallbacks2
|
||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
import org.koitharu.kotatsu.core.util.ext.isLowRamDevice
|
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
import javax.inject.Inject
|
|
||||||
import javax.inject.Singleton
|
|
||||||
|
|
||||||
@Singleton
|
class MemoryContentCache(application: Application) : ContentCache, ComponentCallbacks2 {
|
||||||
class MemoryContentCache @Inject constructor(application: Application) : ComponentCallbacks2 {
|
|
||||||
|
|
||||||
private val isLowRam = application.isLowRamDevice()
|
|
||||||
|
|
||||||
init {
|
init {
|
||||||
application.registerComponentCallbacks(this)
|
application.registerComponentCallbacks(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
private val detailsCache = ExpiringLruCache<SafeDeferred<Manga>>(if (isLowRam) 1 else 4, 5, TimeUnit.MINUTES)
|
private val detailsCache = ExpiringLruCache<SafeDeferred<Manga>>(4, 5, TimeUnit.MINUTES)
|
||||||
private val pagesCache =
|
private val pagesCache = ExpiringLruCache<SafeDeferred<List<MangaPage>>>(4, 10, TimeUnit.MINUTES)
|
||||||
ExpiringLruCache<SafeDeferred<List<MangaPage>>>(if (isLowRam) 1 else 4, 10, TimeUnit.MINUTES)
|
private val relatedMangaCache = ExpiringLruCache<SafeDeferred<List<Manga>>>(4, 10, TimeUnit.MINUTES)
|
||||||
private val relatedMangaCache =
|
|
||||||
ExpiringLruCache<SafeDeferred<List<Manga>>>(if (isLowRam) 1 else 3, 10, TimeUnit.MINUTES)
|
|
||||||
|
|
||||||
suspend fun getDetails(source: MangaSource, url: String): Manga? {
|
override val isCachingEnabled: Boolean = true
|
||||||
return detailsCache[Key(source, url)]?.awaitOrNull()
|
|
||||||
|
override suspend fun getDetails(source: MangaSource, url: String): Manga? {
|
||||||
|
return detailsCache[ContentCache.Key(source, url)]?.awaitOrNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun putDetails(source: MangaSource, url: String, details: SafeDeferred<Manga>) {
|
override fun putDetails(source: MangaSource, url: String, details: SafeDeferred<Manga>) {
|
||||||
detailsCache[Key(source, url)] = details
|
detailsCache[ContentCache.Key(source, url)] = details
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getPages(source: MangaSource, url: String): List<MangaPage>? {
|
override suspend fun getPages(source: MangaSource, url: String): List<MangaPage>? {
|
||||||
return pagesCache[Key(source, url)]?.awaitOrNull()
|
return pagesCache[ContentCache.Key(source, url)]?.awaitOrNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun putPages(source: MangaSource, url: String, pages: SafeDeferred<List<MangaPage>>) {
|
override fun putPages(source: MangaSource, url: String, pages: SafeDeferred<List<MangaPage>>) {
|
||||||
pagesCache[Key(source, url)] = pages
|
pagesCache[ContentCache.Key(source, url)] = pages
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getRelatedManga(source: MangaSource, url: String): List<Manga>? {
|
override suspend fun getRelatedManga(source: MangaSource, url: String): List<Manga>? {
|
||||||
return relatedMangaCache[Key(source, url)]?.awaitOrNull()
|
return relatedMangaCache[ContentCache.Key(source, url)]?.awaitOrNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun putRelatedManga(source: MangaSource, url: String, related: SafeDeferred<List<Manga>>) {
|
override fun putRelatedManga(source: MangaSource, url: String, related: SafeDeferred<List<Manga>>) {
|
||||||
relatedMangaCache[Key(source, url)] = related
|
relatedMangaCache[ContentCache.Key(source, url)] = related
|
||||||
}
|
|
||||||
|
|
||||||
fun clear(source: MangaSource) {
|
|
||||||
clearCache(detailsCache, source)
|
|
||||||
clearCache(pagesCache, source)
|
|
||||||
clearCache(relatedMangaCache, source)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onConfigurationChanged(newConfig: Configuration) = Unit
|
override fun onConfigurationChanged(newConfig: Configuration) = Unit
|
||||||
@@ -79,17 +67,4 @@ class MemoryContentCache @Inject constructor(application: Application) : Compone
|
|||||||
else -> cache.trimToSize(cache.maxSize / 2)
|
else -> cache.trimToSize(cache.maxSize / 2)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun clearCache(cache: ExpiringLruCache<*>, source: MangaSource) {
|
|
||||||
cache.forEach { key ->
|
|
||||||
if (key.source == source) {
|
|
||||||
cache.remove(key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
data class Key(
|
|
||||||
val source: MangaSource,
|
|
||||||
val url: String,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
22
app/src/main/kotlin/org/koitharu/kotatsu/core/cache/StubContentCache.kt
vendored
Normal file
22
app/src/main/kotlin/org/koitharu/kotatsu/core/cache/StubContentCache.kt
vendored
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
package org.koitharu.kotatsu.core.cache
|
||||||
|
|
||||||
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
|
|
||||||
|
class StubContentCache : ContentCache {
|
||||||
|
|
||||||
|
override val isCachingEnabled: Boolean = false
|
||||||
|
|
||||||
|
override suspend fun getDetails(source: MangaSource, url: String): Manga? = null
|
||||||
|
|
||||||
|
override fun putDetails(source: MangaSource, url: String, details: SafeDeferred<Manga>) = Unit
|
||||||
|
|
||||||
|
override suspend fun getPages(source: MangaSource, url: String): List<MangaPage>? = null
|
||||||
|
|
||||||
|
override fun putPages(source: MangaSource, url: String, pages: SafeDeferred<List<MangaPage>>) = Unit
|
||||||
|
|
||||||
|
override suspend fun getRelatedManga(source: MangaSource, url: String): List<Manga>? = null
|
||||||
|
|
||||||
|
override fun putRelatedManga(source: MangaSource, url: String, related: SafeDeferred<List<Manga>>) = Unit
|
||||||
|
}
|
||||||
@@ -29,9 +29,6 @@ import org.koitharu.kotatsu.core.db.migrations.Migration13To14
|
|||||||
import org.koitharu.kotatsu.core.db.migrations.Migration14To15
|
import org.koitharu.kotatsu.core.db.migrations.Migration14To15
|
||||||
import org.koitharu.kotatsu.core.db.migrations.Migration15To16
|
import org.koitharu.kotatsu.core.db.migrations.Migration15To16
|
||||||
import org.koitharu.kotatsu.core.db.migrations.Migration16To17
|
import org.koitharu.kotatsu.core.db.migrations.Migration16To17
|
||||||
import org.koitharu.kotatsu.core.db.migrations.Migration17To18
|
|
||||||
import org.koitharu.kotatsu.core.db.migrations.Migration18To19
|
|
||||||
import org.koitharu.kotatsu.core.db.migrations.Migration19To20
|
|
||||||
import org.koitharu.kotatsu.core.db.migrations.Migration1To2
|
import org.koitharu.kotatsu.core.db.migrations.Migration1To2
|
||||||
import org.koitharu.kotatsu.core.db.migrations.Migration2To3
|
import org.koitharu.kotatsu.core.db.migrations.Migration2To3
|
||||||
import org.koitharu.kotatsu.core.db.migrations.Migration3To4
|
import org.koitharu.kotatsu.core.db.migrations.Migration3To4
|
||||||
@@ -50,52 +47,48 @@ import org.koitharu.kotatsu.history.data.HistoryDao
|
|||||||
import org.koitharu.kotatsu.history.data.HistoryEntity
|
import org.koitharu.kotatsu.history.data.HistoryEntity
|
||||||
import org.koitharu.kotatsu.scrobbling.common.data.ScrobblingDao
|
import org.koitharu.kotatsu.scrobbling.common.data.ScrobblingDao
|
||||||
import org.koitharu.kotatsu.scrobbling.common.data.ScrobblingEntity
|
import org.koitharu.kotatsu.scrobbling.common.data.ScrobblingEntity
|
||||||
import org.koitharu.kotatsu.stats.data.StatsDao
|
|
||||||
import org.koitharu.kotatsu.stats.data.StatsEntity
|
|
||||||
import org.koitharu.kotatsu.suggestions.data.SuggestionDao
|
import org.koitharu.kotatsu.suggestions.data.SuggestionDao
|
||||||
import org.koitharu.kotatsu.suggestions.data.SuggestionEntity
|
import org.koitharu.kotatsu.suggestions.data.SuggestionEntity
|
||||||
import org.koitharu.kotatsu.tracker.data.TrackEntity
|
import org.koitharu.kotatsu.tracker.data.TrackEntity
|
||||||
import org.koitharu.kotatsu.tracker.data.TrackLogEntity
|
import org.koitharu.kotatsu.tracker.data.TrackLogEntity
|
||||||
import org.koitharu.kotatsu.tracker.data.TracksDao
|
import org.koitharu.kotatsu.tracker.data.TracksDao
|
||||||
|
|
||||||
const val DATABASE_VERSION = 20
|
const val DATABASE_VERSION = 17
|
||||||
|
|
||||||
@Database(
|
@Database(
|
||||||
entities = [
|
entities = [
|
||||||
MangaEntity::class, TagEntity::class, HistoryEntity::class, MangaTagsEntity::class,
|
MangaEntity::class, TagEntity::class, HistoryEntity::class, MangaTagsEntity::class,
|
||||||
FavouriteCategoryEntity::class, FavouriteEntity::class, MangaPrefsEntity::class,
|
FavouriteCategoryEntity::class, FavouriteEntity::class, MangaPrefsEntity::class,
|
||||||
TrackEntity::class, TrackLogEntity::class, SuggestionEntity::class, BookmarkEntity::class,
|
TrackEntity::class, TrackLogEntity::class, SuggestionEntity::class, BookmarkEntity::class,
|
||||||
ScrobblingEntity::class, MangaSourceEntity::class, StatsEntity::class,
|
ScrobblingEntity::class, MangaSourceEntity::class,
|
||||||
],
|
],
|
||||||
version = DATABASE_VERSION,
|
version = DATABASE_VERSION,
|
||||||
)
|
)
|
||||||
abstract class MangaDatabase : RoomDatabase() {
|
abstract class MangaDatabase : RoomDatabase() {
|
||||||
|
|
||||||
abstract fun getHistoryDao(): HistoryDao
|
abstract val historyDao: HistoryDao
|
||||||
|
|
||||||
abstract fun getTagsDao(): TagsDao
|
abstract val tagsDao: TagsDao
|
||||||
|
|
||||||
abstract fun getMangaDao(): MangaDao
|
abstract val mangaDao: MangaDao
|
||||||
|
|
||||||
abstract fun getFavouritesDao(): FavouritesDao
|
abstract val favouritesDao: FavouritesDao
|
||||||
|
|
||||||
abstract fun getPreferencesDao(): PreferencesDao
|
abstract val preferencesDao: PreferencesDao
|
||||||
|
|
||||||
abstract fun getFavouriteCategoriesDao(): FavouriteCategoriesDao
|
abstract val favouriteCategoriesDao: FavouriteCategoriesDao
|
||||||
|
|
||||||
abstract fun getTracksDao(): TracksDao
|
abstract val tracksDao: TracksDao
|
||||||
|
|
||||||
abstract fun getTrackLogsDao(): TrackLogsDao
|
abstract val trackLogsDao: TrackLogsDao
|
||||||
|
|
||||||
abstract fun getSuggestionDao(): SuggestionDao
|
abstract val suggestionDao: SuggestionDao
|
||||||
|
|
||||||
abstract fun getBookmarksDao(): BookmarksDao
|
abstract val bookmarksDao: BookmarksDao
|
||||||
|
|
||||||
abstract fun getScrobblingDao(): ScrobblingDao
|
abstract val scrobblingDao: ScrobblingDao
|
||||||
|
|
||||||
abstract fun getSourcesDao(): MangaSourcesDao
|
abstract val sourcesDao: MangaSourcesDao
|
||||||
|
|
||||||
abstract fun getStatsDao(): StatsDao
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getDatabaseMigrations(context: Context): Array<Migration> = arrayOf(
|
fun getDatabaseMigrations(context: Context): Array<Migration> = arrayOf(
|
||||||
@@ -115,9 +108,6 @@ fun getDatabaseMigrations(context: Context): Array<Migration> = arrayOf(
|
|||||||
Migration14To15(),
|
Migration14To15(),
|
||||||
Migration15To16(),
|
Migration15To16(),
|
||||||
Migration16To17(context),
|
Migration16To17(context),
|
||||||
Migration17To18(),
|
|
||||||
Migration18To19(),
|
|
||||||
Migration19To20(),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
fun MangaDatabase(context: Context): MangaDatabase = Room
|
fun MangaDatabase(context: Context): MangaDatabase = Room
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package org.koitharu.kotatsu.core.db.dao
|
package org.koitharu.kotatsu.core.db.dao
|
||||||
|
|
||||||
import androidx.room.Dao
|
import androidx.room.Dao
|
||||||
import androidx.room.Delete
|
|
||||||
import androidx.room.Insert
|
import androidx.room.Insert
|
||||||
import androidx.room.OnConflictStrategy
|
import androidx.room.OnConflictStrategy
|
||||||
import androidx.room.Query
|
import androidx.room.Query
|
||||||
@@ -24,13 +23,6 @@ abstract class MangaDao {
|
|||||||
@Query("SELECT * FROM manga WHERE public_url = :publicUrl")
|
@Query("SELECT * FROM manga WHERE public_url = :publicUrl")
|
||||||
abstract suspend fun findByPublicUrl(publicUrl: String): MangaWithTags?
|
abstract suspend fun findByPublicUrl(publicUrl: String): MangaWithTags?
|
||||||
|
|
||||||
@Transaction
|
|
||||||
@Query("SELECT * FROM manga WHERE source = :source")
|
|
||||||
abstract suspend fun findAllBySource(source: String): List<MangaWithTags>
|
|
||||||
|
|
||||||
@Query("SELECT author FROM manga WHERE author LIKE :query GROUP BY author ORDER BY COUNT(author) DESC LIMIT :limit")
|
|
||||||
abstract suspend fun findAuthors(query: String, limit: Int): List<String>
|
|
||||||
|
|
||||||
@Transaction
|
@Transaction
|
||||||
@Query("SELECT * FROM manga WHERE (title LIKE :query OR alt_title LIKE :query) AND manga_id IN (SELECT manga_id FROM favourites UNION SELECT manga_id FROM history) LIMIT :limit")
|
@Query("SELECT * FROM manga WHERE (title LIKE :query OR alt_title LIKE :query) AND manga_id IN (SELECT manga_id FROM favourites UNION SELECT manga_id FROM history) LIMIT :limit")
|
||||||
abstract suspend fun searchByTitle(query: String, limit: Int): List<MangaWithTags>
|
abstract suspend fun searchByTitle(query: String, limit: Int): List<MangaWithTags>
|
||||||
@@ -40,7 +32,7 @@ abstract class MangaDao {
|
|||||||
abstract suspend fun searchByTitle(query: String, source: String, limit: Int): List<MangaWithTags>
|
abstract suspend fun searchByTitle(query: String, source: String, limit: Int): List<MangaWithTags>
|
||||||
|
|
||||||
@Upsert
|
@Upsert
|
||||||
protected abstract suspend fun upsert(manga: MangaEntity)
|
abstract suspend fun upsert(manga: MangaEntity)
|
||||||
|
|
||||||
@Update(onConflict = OnConflictStrategy.IGNORE)
|
@Update(onConflict = OnConflictStrategy.IGNORE)
|
||||||
abstract suspend fun update(manga: MangaEntity): Int
|
abstract suspend fun update(manga: MangaEntity): Int
|
||||||
@@ -51,10 +43,6 @@ abstract class MangaDao {
|
|||||||
@Query("DELETE FROM manga_tags WHERE manga_id = :mangaId")
|
@Query("DELETE FROM manga_tags WHERE manga_id = :mangaId")
|
||||||
abstract suspend fun clearTagRelation(mangaId: Long)
|
abstract suspend fun clearTagRelation(mangaId: Long)
|
||||||
|
|
||||||
@Transaction
|
|
||||||
@Delete
|
|
||||||
abstract suspend fun delete(subjects: Collection<MangaEntity>)
|
|
||||||
|
|
||||||
@Transaction
|
@Transaction
|
||||||
open suspend fun upsert(manga: MangaEntity, tags: Iterable<TagEntity>? = null) {
|
open suspend fun upsert(manga: MangaEntity, tags: Iterable<TagEntity>? = null) {
|
||||||
upsert(manga)
|
upsert(manga)
|
||||||
|
|||||||
@@ -4,15 +4,10 @@ import androidx.room.Dao
|
|||||||
import androidx.room.Insert
|
import androidx.room.Insert
|
||||||
import androidx.room.OnConflictStrategy
|
import androidx.room.OnConflictStrategy
|
||||||
import androidx.room.Query
|
import androidx.room.Query
|
||||||
import androidx.room.RawQuery
|
|
||||||
import androidx.room.Transaction
|
import androidx.room.Transaction
|
||||||
import androidx.room.Upsert
|
import androidx.room.Upsert
|
||||||
import androidx.sqlite.db.SimpleSQLiteQuery
|
|
||||||
import androidx.sqlite.db.SupportSQLiteQuery
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import org.intellij.lang.annotations.Language
|
|
||||||
import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity
|
import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity
|
||||||
import org.koitharu.kotatsu.explore.data.SourcesSortOrder
|
|
||||||
|
|
||||||
@Dao
|
@Dao
|
||||||
abstract class MangaSourcesDao {
|
abstract class MangaSourcesDao {
|
||||||
@@ -20,15 +15,15 @@ abstract class MangaSourcesDao {
|
|||||||
@Query("SELECT * FROM sources ORDER BY sort_key")
|
@Query("SELECT * FROM sources ORDER BY sort_key")
|
||||||
abstract suspend fun findAll(): List<MangaSourceEntity>
|
abstract suspend fun findAll(): List<MangaSourceEntity>
|
||||||
|
|
||||||
@Query("SELECT source FROM sources WHERE enabled = 1")
|
@Query("SELECT * FROM sources WHERE enabled = 1 ORDER BY sort_key")
|
||||||
abstract suspend fun findAllEnabledNames(): List<String>
|
abstract suspend fun findAllEnabled(): List<MangaSourceEntity>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM sources WHERE enabled = 1 ORDER BY sort_key")
|
||||||
|
abstract fun observeEnabled(): Flow<List<MangaSourceEntity>>
|
||||||
|
|
||||||
@Query("SELECT * FROM sources ORDER BY sort_key")
|
@Query("SELECT * FROM sources ORDER BY sort_key")
|
||||||
abstract fun observeAll(): Flow<List<MangaSourceEntity>>
|
abstract fun observeAll(): Flow<List<MangaSourceEntity>>
|
||||||
|
|
||||||
@Query("SELECT enabled FROM sources WHERE source = :source")
|
|
||||||
abstract fun observeIsEnabled(source: String): Flow<Boolean>
|
|
||||||
|
|
||||||
@Query("SELECT IFNULL(MAX(sort_key),0) FROM sources")
|
@Query("SELECT IFNULL(MAX(sort_key),0) FROM sources")
|
||||||
abstract suspend fun getMaxSortKey(): Int
|
abstract suspend fun getMaxSortKey(): Int
|
||||||
|
|
||||||
@@ -45,22 +40,6 @@ abstract class MangaSourcesDao {
|
|||||||
@Upsert
|
@Upsert
|
||||||
abstract suspend fun upsert(entry: MangaSourceEntity)
|
abstract suspend fun upsert(entry: MangaSourceEntity)
|
||||||
|
|
||||||
fun observeEnabled(order: SourcesSortOrder): Flow<List<MangaSourceEntity>> {
|
|
||||||
val orderBy = getOrderBy(order)
|
|
||||||
|
|
||||||
@Language("RoomSql")
|
|
||||||
val query = SimpleSQLiteQuery("SELECT * FROM sources WHERE enabled = 1 ORDER BY $orderBy")
|
|
||||||
return observeImpl(query)
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun findAllEnabled(order: SourcesSortOrder): List<MangaSourceEntity> {
|
|
||||||
val orderBy = getOrderBy(order)
|
|
||||||
|
|
||||||
@Language("RoomSql")
|
|
||||||
val query = SimpleSQLiteQuery("SELECT * FROM sources WHERE enabled = 1 ORDER BY $orderBy")
|
|
||||||
return findAllImpl(query)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transaction
|
@Transaction
|
||||||
open suspend fun setEnabled(source: String, isEnabled: Boolean) {
|
open suspend fun setEnabled(source: String, isEnabled: Boolean) {
|
||||||
if (updateIsEnabled(source, isEnabled) == 0) {
|
if (updateIsEnabled(source, isEnabled) == 0) {
|
||||||
@@ -75,16 +54,4 @@ abstract class MangaSourcesDao {
|
|||||||
|
|
||||||
@Query("UPDATE sources SET enabled = :isEnabled WHERE source = :source")
|
@Query("UPDATE sources SET enabled = :isEnabled WHERE source = :source")
|
||||||
protected abstract suspend fun updateIsEnabled(source: String, isEnabled: Boolean): Int
|
protected abstract suspend fun updateIsEnabled(source: String, isEnabled: Boolean): Int
|
||||||
|
|
||||||
@RawQuery(observedEntities = [MangaSourceEntity::class])
|
|
||||||
protected abstract fun observeImpl(query: SupportSQLiteQuery): Flow<List<MangaSourceEntity>>
|
|
||||||
|
|
||||||
@RawQuery
|
|
||||||
protected abstract suspend fun findAllImpl(query: SupportSQLiteQuery): List<MangaSourceEntity>
|
|
||||||
|
|
||||||
private fun getOrderBy(order: SourcesSortOrder) = when (order) {
|
|
||||||
SourcesSortOrder.ALPHABETIC -> "source ASC"
|
|
||||||
SourcesSortOrder.POPULARITY -> "(SELECT COUNT(*) FROM manga WHERE source = sources.source) DESC"
|
|
||||||
SourcesSortOrder.MANUAL -> "sort_key ASC"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,16 +31,6 @@ abstract class TagsDao {
|
|||||||
)
|
)
|
||||||
abstract suspend fun findPopularTags(source: String, limit: Int): List<TagEntity>
|
abstract suspend fun findPopularTags(source: String, limit: Int): List<TagEntity>
|
||||||
|
|
||||||
@Query(
|
|
||||||
"""SELECT tags.* FROM tags
|
|
||||||
LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id
|
|
||||||
WHERE tags.source = :source
|
|
||||||
GROUP BY tags.title
|
|
||||||
ORDER BY COUNT(manga_id) ASC
|
|
||||||
LIMIT :limit""",
|
|
||||||
)
|
|
||||||
abstract suspend fun findRareTags(source: String, limit: Int): List<TagEntity>
|
|
||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"""SELECT tags.* FROM tags
|
"""SELECT tags.* FROM tags
|
||||||
LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id
|
LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
package org.koitharu.kotatsu.core.db.dao
|
package org.koitharu.kotatsu.core.db.dao
|
||||||
|
|
||||||
import androidx.room.Dao
|
import androidx.room.*
|
||||||
import androidx.room.Insert
|
|
||||||
import androidx.room.OnConflictStrategy
|
|
||||||
import androidx.room.Query
|
|
||||||
import androidx.room.Transaction
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import org.koitharu.kotatsu.tracker.data.TrackLogEntity
|
import org.koitharu.kotatsu.tracker.data.TrackLogEntity
|
||||||
import org.koitharu.kotatsu.tracker.data.TrackLogWithManga
|
import org.koitharu.kotatsu.tracker.data.TrackLogWithManga
|
||||||
@@ -16,24 +12,18 @@ interface TrackLogsDao {
|
|||||||
@Query("SELECT * FROM track_logs ORDER BY created_at DESC LIMIT :limit OFFSET 0")
|
@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): Flow<List<TrackLogWithManga>>
|
||||||
|
|
||||||
@Query("SELECT COUNT(*) FROM track_logs WHERE unread = 1")
|
|
||||||
fun observeUnreadCount(): Flow<Int>
|
|
||||||
|
|
||||||
@Query("DELETE FROM track_logs")
|
@Query("DELETE FROM track_logs")
|
||||||
suspend fun clear()
|
suspend fun clear()
|
||||||
|
|
||||||
@Query("UPDATE track_logs SET unread = 0 WHERE id = :id")
|
|
||||||
suspend fun markAsRead(id: Long)
|
|
||||||
|
|
||||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
suspend fun insert(entity: TrackLogEntity): Long
|
suspend fun insert(entity: TrackLogEntity): Long
|
||||||
|
|
||||||
|
@Query("DELETE FROM track_logs WHERE manga_id = :mangaId")
|
||||||
|
suspend fun removeAll(mangaId: Long)
|
||||||
|
|
||||||
@Query("DELETE FROM track_logs WHERE manga_id NOT IN (SELECT manga_id FROM tracks)")
|
@Query("DELETE FROM track_logs WHERE manga_id NOT IN (SELECT manga_id FROM tracks)")
|
||||||
suspend fun gc()
|
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)
|
|
||||||
|
|
||||||
@Query("SELECT COUNT(*) FROM track_logs")
|
@Query("SELECT COUNT(*) FROM track_logs")
|
||||||
suspend fun count(): Int
|
suspend fun count(): Int
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,5 +24,4 @@ data class MangaPrefsEntity(
|
|||||||
@ColumnInfo(name = "cf_brightness") val cfBrightness: Float,
|
@ColumnInfo(name = "cf_brightness") val cfBrightness: Float,
|
||||||
@ColumnInfo(name = "cf_contrast") val cfContrast: Float,
|
@ColumnInfo(name = "cf_contrast") val cfContrast: Float,
|
||||||
@ColumnInfo(name = "cf_invert") val cfInvert: Boolean,
|
@ColumnInfo(name = "cf_invert") val cfInvert: Boolean,
|
||||||
@ColumnInfo(name = "cf_grayscale") val cfGrayscale: Boolean,
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import androidx.sqlite.db.SupportSQLiteDatabase
|
|||||||
|
|
||||||
class Migration10To11 : Migration(10, 11) {
|
class Migration10To11 : Migration(10, 11) {
|
||||||
|
|
||||||
override fun migrate(db: SupportSQLiteDatabase) {
|
override fun migrate(database: SupportSQLiteDatabase) {
|
||||||
db.execSQL(
|
database.execSQL(
|
||||||
"""
|
"""
|
||||||
CREATE TABLE IF NOT EXISTS `bookmarks` (
|
CREATE TABLE IF NOT EXISTS `bookmarks` (
|
||||||
`manga_id` INTEGER NOT NULL,
|
`manga_id` INTEGER NOT NULL,
|
||||||
@@ -20,7 +20,7 @@ class Migration10To11 : Migration(10, 11) {
|
|||||||
FOREIGN KEY(`manga_id`) REFERENCES `manga`(`manga_id`) ON UPDATE NO ACTION ON DELETE CASCADE )
|
FOREIGN KEY(`manga_id`) REFERENCES `manga`(`manga_id`) ON UPDATE NO ACTION ON DELETE CASCADE )
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
)
|
)
|
||||||
db.execSQL("CREATE INDEX IF NOT EXISTS `index_bookmarks_manga_id` ON `bookmarks` (`manga_id`)")
|
database.execSQL("CREATE INDEX IF NOT EXISTS `index_bookmarks_manga_id` ON `bookmarks` (`manga_id`)")
|
||||||
db.execSQL("CREATE INDEX IF NOT EXISTS `index_bookmarks_page_id` ON `bookmarks` (`page_id`)")
|
database.execSQL("CREATE INDEX IF NOT EXISTS `index_bookmarks_page_id` ON `bookmarks` (`page_id`)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5,8 +5,8 @@ import androidx.sqlite.db.SupportSQLiteDatabase
|
|||||||
|
|
||||||
class Migration11To12 : Migration(11, 12) {
|
class Migration11To12 : Migration(11, 12) {
|
||||||
|
|
||||||
override fun migrate(db: SupportSQLiteDatabase) {
|
override fun migrate(database: SupportSQLiteDatabase) {
|
||||||
db.execSQL(
|
database.execSQL(
|
||||||
"""
|
"""
|
||||||
CREATE TABLE IF NOT EXISTS `scrobblings` (
|
CREATE TABLE IF NOT EXISTS `scrobblings` (
|
||||||
`scrobbler` INTEGER NOT NULL,
|
`scrobbler` INTEGER NOT NULL,
|
||||||
@@ -21,7 +21,7 @@ class Migration11To12 : Migration(11, 12) {
|
|||||||
)
|
)
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
)
|
)
|
||||||
db.execSQL("ALTER TABLE history ADD COLUMN `percent` REAL NOT NULL DEFAULT -1")
|
database.execSQL("ALTER TABLE history ADD COLUMN `percent` REAL NOT NULL DEFAULT -1")
|
||||||
db.execSQL("ALTER TABLE bookmarks ADD COLUMN `percent` REAL NOT NULL DEFAULT -1")
|
database.execSQL("ALTER TABLE bookmarks ADD COLUMN `percent` REAL NOT NULL DEFAULT -1")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import androidx.sqlite.db.SupportSQLiteDatabase
|
|||||||
|
|
||||||
class Migration12To13 : Migration(12, 13) {
|
class Migration12To13 : Migration(12, 13) {
|
||||||
|
|
||||||
override fun migrate(db: SupportSQLiteDatabase) {
|
override fun migrate(database: SupportSQLiteDatabase) {
|
||||||
db.execSQL("ALTER TABLE favourite_categories ADD COLUMN `show_in_lib` INTEGER NOT NULL DEFAULT 1")
|
database.execSQL("ALTER TABLE favourite_categories ADD COLUMN `show_in_lib` INTEGER NOT NULL DEFAULT 1")
|
||||||
db.execSQL("ALTER TABLE favourites ADD COLUMN `sort_key` INTEGER NOT NULL DEFAULT 0")
|
database.execSQL("ALTER TABLE favourites ADD COLUMN `sort_key` INTEGER NOT NULL DEFAULT 0")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5,11 +5,11 @@ import androidx.sqlite.db.SupportSQLiteDatabase
|
|||||||
|
|
||||||
class Migration13To14 : Migration(13, 14) {
|
class Migration13To14 : Migration(13, 14) {
|
||||||
|
|
||||||
override fun migrate(db: SupportSQLiteDatabase) {
|
override fun migrate(database: SupportSQLiteDatabase) {
|
||||||
db.execSQL("ALTER TABLE favourite_categories ADD COLUMN `deleted_at` INTEGER NOT NULL DEFAULT 0")
|
database.execSQL("ALTER TABLE favourite_categories ADD COLUMN `deleted_at` INTEGER NOT NULL DEFAULT 0")
|
||||||
db.execSQL("ALTER TABLE favourites ADD COLUMN `deleted_at` INTEGER NOT NULL DEFAULT 0")
|
database.execSQL("ALTER TABLE favourites ADD COLUMN `deleted_at` INTEGER NOT NULL DEFAULT 0")
|
||||||
db.execSQL("ALTER TABLE history ADD COLUMN `deleted_at` INTEGER NOT NULL DEFAULT 0")
|
database.execSQL("ALTER TABLE history ADD COLUMN `deleted_at` INTEGER NOT NULL DEFAULT 0")
|
||||||
db.execSQL("ALTER TABLE preferences ADD COLUMN `cf_brightness` REAL NOT NULL DEFAULT 0")
|
database.execSQL("ALTER TABLE preferences ADD COLUMN `cf_brightness` REAL NOT NULL DEFAULT 0")
|
||||||
db.execSQL("ALTER TABLE preferences ADD COLUMN `cf_contrast` REAL NOT NULL DEFAULT 0")
|
database.execSQL("ALTER TABLE preferences ADD COLUMN `cf_contrast` REAL NOT NULL DEFAULT 0")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,5 +5,5 @@ import androidx.sqlite.db.SupportSQLiteDatabase
|
|||||||
|
|
||||||
class Migration14To15 : Migration(14, 15) {
|
class Migration14To15 : Migration(14, 15) {
|
||||||
|
|
||||||
override fun migrate(db: SupportSQLiteDatabase) = Unit
|
override fun migrate(database: SupportSQLiteDatabase) = Unit
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase
|
|||||||
|
|
||||||
class Migration15To16 : Migration(15, 16) {
|
class Migration15To16 : Migration(15, 16) {
|
||||||
|
|
||||||
override fun migrate(db: SupportSQLiteDatabase) {
|
override fun migrate(database: SupportSQLiteDatabase) {
|
||||||
db.execSQL("ALTER TABLE preferences ADD COLUMN `cf_invert` INTEGER NOT NULL DEFAULT 0")
|
database.execSQL("ALTER TABLE preferences ADD COLUMN `cf_invert` INTEGER NOT NULL DEFAULT 0")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ class Migration16To17(context: Context) : Migration(16, 17) {
|
|||||||
|
|
||||||
private val prefs = PreferenceManager.getDefaultSharedPreferences(context)
|
private val prefs = PreferenceManager.getDefaultSharedPreferences(context)
|
||||||
|
|
||||||
override fun migrate(db: SupportSQLiteDatabase) {
|
override fun migrate(database: SupportSQLiteDatabase) {
|
||||||
db.execSQL("CREATE TABLE `sources` (`source` TEXT NOT NULL, `enabled` INTEGER NOT NULL, `sort_key` INTEGER NOT NULL, PRIMARY KEY(`source`))")
|
database.execSQL("CREATE TABLE `sources` (`source` TEXT NOT NULL, `enabled` INTEGER NOT NULL, `sort_key` INTEGER NOT NULL, PRIMARY KEY(`source`))")
|
||||||
db.execSQL("CREATE INDEX `index_sources_sort_key` ON `sources` (`sort_key`)")
|
database.execSQL("CREATE INDEX `index_sources_sort_key` ON `sources` (`sort_key`)")
|
||||||
val hiddenSources = prefs.getStringSet("sources_hidden", null).orEmpty()
|
val hiddenSources = prefs.getStringSet("sources_hidden", null).orEmpty()
|
||||||
val order = prefs.getString("sources_order_2", null)?.split('|').orEmpty()
|
val order = prefs.getString("sources_order_2", null)?.split('|').orEmpty()
|
||||||
val sources = MangaSource.entries
|
val sources = MangaSource.entries
|
||||||
@@ -30,7 +30,7 @@ class Migration16To17(context: Context) : Migration(16, 17) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
db.execSQL(
|
database.execSQL(
|
||||||
"INSERT INTO `sources` (`source`, `enabled`, `sort_key`) VALUES (?, ?, ?)",
|
"INSERT INTO `sources` (`source`, `enabled`, `sort_key`) VALUES (?, ?, ?)",
|
||||||
arrayOf(name, (!isHidden).toInt(), sortKey),
|
arrayOf(name, (!isHidden).toInt(), sortKey),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.db.migrations
|
|
||||||
|
|
||||||
import androidx.room.migration.Migration
|
|
||||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
|
||||||
|
|
||||||
class Migration17To18 : Migration(17, 18) {
|
|
||||||
|
|
||||||
override fun migrate(db: SupportSQLiteDatabase) {
|
|
||||||
db.execSQL("ALTER TABLE preferences ADD COLUMN `cf_grayscale` INTEGER NOT NULL DEFAULT 0")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.db.migrations
|
|
||||||
|
|
||||||
import androidx.room.migration.Migration
|
|
||||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
|
||||||
|
|
||||||
class Migration18To19 : Migration(18, 19) {
|
|
||||||
|
|
||||||
override fun migrate(db: SupportSQLiteDatabase) {
|
|
||||||
db.execSQL("ALTER TABLE history ADD COLUMN `chapters` INTEGER NOT NULL DEFAULT -1")
|
|
||||||
db.execSQL("CREATE TABLE IF NOT EXISTS `stats` (`manga_id` INTEGER NOT NULL, `started_at` INTEGER NOT NULL, `duration` INTEGER NOT NULL, `pages` INTEGER NOT NULL, PRIMARY KEY(`manga_id`, `started_at`), FOREIGN KEY(`manga_id`) REFERENCES `history`(`manga_id`) ON UPDATE NO ACTION ON DELETE CASCADE )")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.db.migrations
|
|
||||||
|
|
||||||
import androidx.room.migration.Migration
|
|
||||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
|
||||||
|
|
||||||
class Migration19To20 : Migration(19, 20) {
|
|
||||||
|
|
||||||
override fun migrate(db: SupportSQLiteDatabase) {
|
|
||||||
db.execSQL("CREATE TABLE tracks_bk (manga_id INTEGER NOT NULL, chapters_total INTEGER NOT NULL, last_chapter_id INTEGER NOT NULL, chapters_new INTEGER NOT NULL, last_check INTEGER NOT NULL, last_notified_id INTEGER NOT NULL, PRIMARY KEY(manga_id))")
|
|
||||||
db.execSQL("INSERT INTO tracks_bk SELECT manga_id, chapters_total, last_chapter_id, chapters_new, last_check, last_notified_id FROM tracks")
|
|
||||||
db.execSQL("DROP TABLE tracks")
|
|
||||||
db.execSQL("CREATE TABLE tracks (`manga_id` INTEGER NOT NULL, `last_chapter_id` INTEGER NOT NULL, `chapters_new` INTEGER NOT NULL, `last_check_time` INTEGER NOT NULL, `last_chapter_date` INTEGER NOT NULL, `last_result` INTEGER NOT NULL, PRIMARY KEY(`manga_id`), FOREIGN KEY(`manga_id`) REFERENCES `manga`(`manga_id`) ON UPDATE NO ACTION ON DELETE CASCADE )")
|
|
||||||
db.execSQL("INSERT INTO tracks SELECT manga_id, last_chapter_id, chapters_new, last_check AS last_check_time, 0 AS last_chapter_date, 0 AS last_result FROM tracks_bk")
|
|
||||||
db.execSQL("DROP TABLE tracks_bk")
|
|
||||||
|
|
||||||
db.execSQL("ALTER TABLE track_logs ADD COLUMN `unread` INTEGER NOT NULL DEFAULT 0")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -7,48 +7,48 @@ class Migration1To2 : Migration(1, 2) {
|
|||||||
/**
|
/**
|
||||||
* Adding foreign keys
|
* Adding foreign keys
|
||||||
*/
|
*/
|
||||||
override fun migrate(db: SupportSQLiteDatabase) {
|
override fun migrate(database: SupportSQLiteDatabase) {
|
||||||
/* manga_tags */
|
/* manga_tags */
|
||||||
db.execSQL(
|
database.execSQL(
|
||||||
"CREATE TABLE IF NOT EXISTS manga_tags_tmp (manga_id INTEGER NOT NULL, tag_id INTEGER NOT NULL, " +
|
"CREATE TABLE IF NOT EXISTS manga_tags_tmp (manga_id INTEGER NOT NULL, tag_id INTEGER NOT NULL, " +
|
||||||
"PRIMARY KEY(manga_id, tag_id), " +
|
"PRIMARY KEY(manga_id, tag_id), " +
|
||||||
"FOREIGN KEY(manga_id) REFERENCES manga(manga_id) ON UPDATE NO ACTION ON DELETE CASCADE, " +
|
"FOREIGN KEY(manga_id) REFERENCES manga(manga_id) ON UPDATE NO ACTION ON DELETE CASCADE, " +
|
||||||
"FOREIGN KEY(tag_id) REFERENCES tags(tag_id) ON UPDATE NO ACTION ON DELETE CASCADE )"
|
"FOREIGN KEY(tag_id) REFERENCES tags(tag_id) ON UPDATE NO ACTION ON DELETE CASCADE )"
|
||||||
)
|
)
|
||||||
db.execSQL("CREATE INDEX IF NOT EXISTS index_manga_tags_manga_id ON manga_tags_tmp (manga_id)")
|
database.execSQL("CREATE INDEX IF NOT EXISTS index_manga_tags_manga_id ON manga_tags_tmp (manga_id)")
|
||||||
db.execSQL("CREATE INDEX IF NOT EXISTS index_manga_tags_tag_id ON manga_tags_tmp (tag_id)")
|
database.execSQL("CREATE INDEX IF NOT EXISTS index_manga_tags_tag_id ON manga_tags_tmp (tag_id)")
|
||||||
db.execSQL("INSERT INTO manga_tags_tmp (manga_id, tag_id) SELECT manga_id, tag_id FROM manga_tags")
|
database.execSQL("INSERT INTO manga_tags_tmp (manga_id, tag_id) SELECT manga_id, tag_id FROM manga_tags")
|
||||||
db.execSQL("DROP TABLE manga_tags")
|
database.execSQL("DROP TABLE manga_tags")
|
||||||
db.execSQL("ALTER TABLE manga_tags_tmp RENAME TO manga_tags")
|
database.execSQL("ALTER TABLE manga_tags_tmp RENAME TO manga_tags")
|
||||||
/* favourites */
|
/* favourites */
|
||||||
db.execSQL(
|
database.execSQL(
|
||||||
"CREATE TABLE IF NOT EXISTS favourites_tmp (manga_id INTEGER NOT NULL, category_id INTEGER NOT NULL, created_at INTEGER NOT NULL, " +
|
"CREATE TABLE IF NOT EXISTS favourites_tmp (manga_id INTEGER NOT NULL, category_id INTEGER NOT NULL, created_at INTEGER NOT NULL, " +
|
||||||
"PRIMARY KEY(manga_id, category_id), " +
|
"PRIMARY KEY(manga_id, category_id), " +
|
||||||
"FOREIGN KEY(manga_id) REFERENCES manga(manga_id) ON UPDATE NO ACTION ON DELETE CASCADE , " +
|
"FOREIGN KEY(manga_id) REFERENCES manga(manga_id) ON UPDATE NO ACTION ON DELETE CASCADE , " +
|
||||||
"FOREIGN KEY(category_id) REFERENCES favourite_categories(category_id) ON UPDATE NO ACTION ON DELETE CASCADE )"
|
"FOREIGN KEY(category_id) REFERENCES favourite_categories(category_id) ON UPDATE NO ACTION ON DELETE CASCADE )"
|
||||||
)
|
)
|
||||||
db.execSQL("CREATE INDEX IF NOT EXISTS index_favourites_manga_id ON favourites_tmp (manga_id)")
|
database.execSQL("CREATE INDEX IF NOT EXISTS index_favourites_manga_id ON favourites_tmp (manga_id)")
|
||||||
db.execSQL("CREATE INDEX IF NOT EXISTS index_favourites_category_id ON favourites_tmp (category_id)")
|
database.execSQL("CREATE INDEX IF NOT EXISTS index_favourites_category_id ON favourites_tmp (category_id)")
|
||||||
db.execSQL("INSERT INTO favourites_tmp (manga_id, category_id, created_at) SELECT manga_id, category_id, created_at FROM favourites")
|
database.execSQL("INSERT INTO favourites_tmp (manga_id, category_id, created_at) SELECT manga_id, category_id, created_at FROM favourites")
|
||||||
db.execSQL("DROP TABLE favourites")
|
database.execSQL("DROP TABLE favourites")
|
||||||
db.execSQL("ALTER TABLE favourites_tmp RENAME TO favourites")
|
database.execSQL("ALTER TABLE favourites_tmp RENAME TO favourites")
|
||||||
/* history */
|
/* history */
|
||||||
db.execSQL(
|
database.execSQL(
|
||||||
"CREATE TABLE IF NOT EXISTS history_tmp (manga_id INTEGER NOT NULL, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, chapter_id INTEGER NOT NULL, page INTEGER NOT NULL, " +
|
"CREATE TABLE IF NOT EXISTS history_tmp (manga_id INTEGER NOT NULL, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, chapter_id INTEGER NOT NULL, page INTEGER NOT NULL, " +
|
||||||
"PRIMARY KEY(manga_id), " +
|
"PRIMARY KEY(manga_id), " +
|
||||||
"FOREIGN KEY(manga_id) REFERENCES manga(manga_id) ON UPDATE NO ACTION ON DELETE CASCADE )"
|
"FOREIGN KEY(manga_id) REFERENCES manga(manga_id) ON UPDATE NO ACTION ON DELETE CASCADE )"
|
||||||
)
|
)
|
||||||
db.execSQL("INSERT INTO history_tmp (manga_id, created_at, updated_at, chapter_id, page) SELECT manga_id, created_at, updated_at, chapter_id, page FROM history")
|
database.execSQL("INSERT INTO history_tmp (manga_id, created_at, updated_at, chapter_id, page) SELECT manga_id, created_at, updated_at, chapter_id, page FROM history")
|
||||||
db.execSQL("DROP TABLE history")
|
database.execSQL("DROP TABLE history")
|
||||||
db.execSQL("ALTER TABLE history_tmp RENAME TO history")
|
database.execSQL("ALTER TABLE history_tmp RENAME TO history")
|
||||||
/* preferences */
|
/* preferences */
|
||||||
db.execSQL(
|
database.execSQL(
|
||||||
"CREATE TABLE IF NOT EXISTS preferences_tmp (manga_id INTEGER NOT NULL, mode INTEGER NOT NULL," +
|
"CREATE TABLE IF NOT EXISTS preferences_tmp (manga_id INTEGER NOT NULL, mode INTEGER NOT NULL," +
|
||||||
" PRIMARY KEY(manga_id), " +
|
" PRIMARY KEY(manga_id), " +
|
||||||
"FOREIGN KEY(manga_id) REFERENCES manga(manga_id) ON UPDATE NO ACTION ON DELETE CASCADE )"
|
"FOREIGN KEY(manga_id) REFERENCES manga(manga_id) ON UPDATE NO ACTION ON DELETE CASCADE )"
|
||||||
)
|
)
|
||||||
db.execSQL("INSERT INTO preferences_tmp (manga_id, mode) SELECT manga_id, mode FROM preferences")
|
database.execSQL("INSERT INTO preferences_tmp (manga_id, mode) SELECT manga_id, mode FROM preferences")
|
||||||
db.execSQL("DROP TABLE preferences")
|
database.execSQL("DROP TABLE preferences")
|
||||||
db.execSQL("ALTER TABLE preferences_tmp RENAME TO preferences")
|
database.execSQL("ALTER TABLE preferences_tmp RENAME TO preferences")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5,7 +5,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase
|
|||||||
|
|
||||||
class Migration2To3 : Migration(2, 3) {
|
class Migration2To3 : Migration(2, 3) {
|
||||||
|
|
||||||
override fun migrate(db: SupportSQLiteDatabase) {
|
override fun migrate(database: SupportSQLiteDatabase) {
|
||||||
db.execSQL("ALTER TABLE history ADD COLUMN scroll REAL NOT NULL DEFAULT 0")
|
database.execSQL("ALTER TABLE history ADD COLUMN scroll REAL NOT NULL DEFAULT 0")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5,7 +5,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase
|
|||||||
|
|
||||||
class Migration3To4 : Migration(3, 4) {
|
class Migration3To4 : Migration(3, 4) {
|
||||||
|
|
||||||
override fun migrate(db: SupportSQLiteDatabase) {
|
override fun migrate(database: SupportSQLiteDatabase) {
|
||||||
db.execSQL("CREATE TABLE IF NOT EXISTS tracks (manga_id INTEGER NOT NULL, chapters_total INTEGER NOT NULL, last_chapter_id INTEGER NOT NULL, chapters_new INTEGER NOT NULL, last_check INTEGER NOT NULL, last_notified_id INTEGER NOT NULL, PRIMARY KEY(manga_id), FOREIGN KEY(manga_id) REFERENCES manga(manga_id) ON UPDATE NO ACTION ON DELETE CASCADE )")
|
database.execSQL("CREATE TABLE IF NOT EXISTS tracks (manga_id INTEGER NOT NULL, chapters_total INTEGER NOT NULL, last_chapter_id INTEGER NOT NULL, chapters_new INTEGER NOT NULL, last_check INTEGER NOT NULL, last_notified_id INTEGER NOT NULL, PRIMARY KEY(manga_id), FOREIGN KEY(manga_id) REFERENCES manga(manga_id) ON UPDATE NO ACTION ON DELETE CASCADE )")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5,7 +5,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase
|
|||||||
|
|
||||||
class Migration4To5 : Migration(4, 5) {
|
class Migration4To5 : Migration(4, 5) {
|
||||||
|
|
||||||
override fun migrate(db: SupportSQLiteDatabase) {
|
override fun migrate(database: SupportSQLiteDatabase) {
|
||||||
db.execSQL("ALTER TABLE favourite_categories ADD COLUMN sort_key INTEGER NOT NULL DEFAULT 0")
|
database.execSQL("ALTER TABLE favourite_categories ADD COLUMN sort_key INTEGER NOT NULL DEFAULT 0")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5,8 +5,8 @@ import androidx.sqlite.db.SupportSQLiteDatabase
|
|||||||
|
|
||||||
class Migration5To6 : Migration(5, 6) {
|
class Migration5To6 : Migration(5, 6) {
|
||||||
|
|
||||||
override fun migrate(db: SupportSQLiteDatabase) {
|
override fun migrate(database: SupportSQLiteDatabase) {
|
||||||
db.execSQL("CREATE TABLE IF NOT EXISTS track_logs (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, manga_id INTEGER NOT NULL, chapters TEXT NOT NULL, created_at INTEGER NOT NULL, FOREIGN KEY(manga_id) REFERENCES manga(manga_id) ON UPDATE NO ACTION ON DELETE CASCADE)")
|
database.execSQL("CREATE TABLE IF NOT EXISTS track_logs (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, manga_id INTEGER NOT NULL, chapters TEXT NOT NULL, created_at INTEGER NOT NULL, FOREIGN KEY(manga_id) REFERENCES manga(manga_id) ON UPDATE NO ACTION ON DELETE CASCADE)")
|
||||||
db.execSQL("CREATE INDEX IF NOT EXISTS index_track_logs_manga_id ON track_logs (manga_id)")
|
database.execSQL("CREATE INDEX IF NOT EXISTS index_track_logs_manga_id ON track_logs (manga_id)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5,7 +5,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase
|
|||||||
|
|
||||||
class Migration6To7 : Migration(6, 7) {
|
class Migration6To7 : Migration(6, 7) {
|
||||||
|
|
||||||
override fun migrate(db: SupportSQLiteDatabase) {
|
override fun migrate(database: SupportSQLiteDatabase) {
|
||||||
db.execSQL("ALTER TABLE manga ADD COLUMN public_url TEXT NOT NULL DEFAULT ''")
|
database.execSQL("ALTER TABLE manga ADD COLUMN public_url TEXT NOT NULL DEFAULT ''")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5,9 +5,9 @@ import androidx.sqlite.db.SupportSQLiteDatabase
|
|||||||
|
|
||||||
class Migration7To8 : Migration(7, 8) {
|
class Migration7To8 : Migration(7, 8) {
|
||||||
|
|
||||||
override fun migrate(db: SupportSQLiteDatabase) {
|
override fun migrate(database: SupportSQLiteDatabase) {
|
||||||
db.execSQL("ALTER TABLE manga ADD COLUMN nsfw INTEGER NOT NULL DEFAULT 0")
|
database.execSQL("ALTER TABLE manga ADD COLUMN nsfw INTEGER NOT NULL DEFAULT 0")
|
||||||
db.execSQL("CREATE TABLE IF NOT EXISTS suggestions (manga_id INTEGER NOT NULL, relevance REAL NOT NULL, created_at INTEGER NOT NULL, PRIMARY KEY(manga_id), FOREIGN KEY(manga_id) REFERENCES manga(manga_id) ON UPDATE NO ACTION ON DELETE CASCADE )")
|
database.execSQL("CREATE TABLE IF NOT EXISTS suggestions (manga_id INTEGER NOT NULL, relevance REAL NOT NULL, created_at INTEGER NOT NULL, PRIMARY KEY(manga_id), FOREIGN KEY(manga_id) REFERENCES manga(manga_id) ON UPDATE NO ACTION ON DELETE CASCADE )")
|
||||||
db.execSQL("CREATE INDEX IF NOT EXISTS index_suggestions_manga_id ON suggestions (manga_id)")
|
database.execSQL("CREATE INDEX IF NOT EXISTS index_suggestions_manga_id ON suggestions (manga_id)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6,7 +6,7 @@ import org.koitharu.kotatsu.parsers.model.SortOrder
|
|||||||
|
|
||||||
class Migration8To9 : Migration(8, 9) {
|
class Migration8To9 : Migration(8, 9) {
|
||||||
|
|
||||||
override fun migrate(db: SupportSQLiteDatabase) {
|
override fun migrate(database: SupportSQLiteDatabase) {
|
||||||
db.execSQL("ALTER TABLE favourite_categories ADD COLUMN `order` TEXT NOT NULL DEFAULT ${SortOrder.NEWEST.name}")
|
database.execSQL("ALTER TABLE favourite_categories ADD COLUMN `order` TEXT NOT NULL DEFAULT ${SortOrder.NEWEST.name}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5,7 +5,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase
|
|||||||
|
|
||||||
class Migration9To10 : Migration(9, 10) {
|
class Migration9To10 : Migration(9, 10) {
|
||||||
|
|
||||||
override fun migrate(db: SupportSQLiteDatabase) {
|
override fun migrate(database: SupportSQLiteDatabase) {
|
||||||
db.execSQL("ALTER TABLE favourite_categories ADD COLUMN `track` INTEGER NOT NULL DEFAULT 1")
|
database.execSQL("ALTER TABLE favourite_categories ADD COLUMN `track` INTEGER NOT NULL DEFAULT 1")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.exceptions
|
|
||||||
|
|
||||||
import java.io.IOException
|
|
||||||
|
|
||||||
class BadBackupFormatException(cause: Throwable?) : IOException(cause)
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.exceptions
|
|
||||||
|
|
||||||
import okhttp3.Headers
|
|
||||||
import okio.IOException
|
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
|
||||||
|
|
||||||
class CloudFlareBlockedException(
|
|
||||||
val url: String,
|
|
||||||
val source: MangaSource?,
|
|
||||||
) : IOException("Blocked by CloudFlare")
|
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package org.koitharu.kotatsu.core.exceptions
|
||||||
|
|
||||||
|
import org.koitharu.kotatsu.parsers.util.mapNotNullToSet
|
||||||
|
|
||||||
|
class CompositeException(val errors: Collection<Throwable>) : Exception() {
|
||||||
|
|
||||||
|
override val message: String = errors.mapNotNullToSet { it.message }.joinToString()
|
||||||
|
}
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.exceptions
|
|
||||||
|
|
||||||
import okio.IOException
|
|
||||||
|
|
||||||
class NoDataReceivedException(
|
|
||||||
private val url: String,
|
|
||||||
) : IOException("No data has been received from $url")
|
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
package org.koitharu.kotatsu.core.exceptions
|
package org.koitharu.kotatsu.core.exceptions
|
||||||
|
|
||||||
import okio.IOException
|
import okio.IOException
|
||||||
import java.time.Instant
|
import java.util.Date
|
||||||
import java.time.temporal.ChronoUnit
|
|
||||||
|
|
||||||
class TooManyRequestExceptions(
|
class TooManyRequestExceptions(
|
||||||
val url: String,
|
val url: String,
|
||||||
val retryAt: Instant?,
|
val retryAt: Date?,
|
||||||
) : IOException() {
|
) : IOException() {
|
||||||
|
|
||||||
val retryAfter: Long
|
val retryAfter: Long
|
||||||
get() = retryAt?.until(Instant.now(), ChronoUnit.MILLIS) ?: 0
|
get() = if (retryAt == null) 0 else (retryAt.time - System.currentTimeMillis()).coerceAtLeast(0)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.exceptions
|
|
||||||
|
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
|
||||||
|
|
||||||
class UnsupportedSourceException(
|
|
||||||
message: String?,
|
|
||||||
val manga: Manga?,
|
|
||||||
) : IllegalArgumentException(message)
|
|
||||||
@@ -21,7 +21,7 @@ abstract class ErrorObserver(
|
|||||||
private val onResolved: Consumer<Boolean>?,
|
private val onResolved: Consumer<Boolean>?,
|
||||||
) : FlowCollector<Throwable> {
|
) : FlowCollector<Throwable> {
|
||||||
|
|
||||||
protected open val activity = host.context.findActivity()
|
protected val activity = host.context.findActivity()
|
||||||
|
|
||||||
private val lifecycleScope: LifecycleCoroutineScope
|
private val lifecycleScope: LifecycleCoroutineScope
|
||||||
get() = checkNotNull(fragment?.viewLifecycleScope ?: (activity as? LifecycleOwner)?.lifecycle?.coroutineScope)
|
get() = checkNotNull(fragment?.viewLifecycleScope ?: (activity as? LifecycleOwner)?.lifecycle?.coroutineScope)
|
||||||
@@ -36,7 +36,7 @@ abstract class ErrorObserver(
|
|||||||
private fun isAlive(): Boolean {
|
private fun isAlive(): Boolean {
|
||||||
return when {
|
return when {
|
||||||
fragment != null -> fragment.view != null
|
fragment != null -> fragment.view != null
|
||||||
activity != null -> activity?.isDestroyed == false
|
activity != null -> !activity.isDestroyed
|
||||||
else -> true
|
else -> true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,17 +6,15 @@ import androidx.annotation.StringRes
|
|||||||
import androidx.collection.ArrayMap
|
import androidx.collection.ArrayMap
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.fragment.app.FragmentActivity
|
import androidx.fragment.app.FragmentActivity
|
||||||
|
import okhttp3.Headers
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.alternatives.ui.AlternativesActivity
|
|
||||||
import org.koitharu.kotatsu.browser.BrowserActivity
|
import org.koitharu.kotatsu.browser.BrowserActivity
|
||||||
import org.koitharu.kotatsu.browser.cloudflare.CloudFlareActivity
|
import org.koitharu.kotatsu.browser.cloudflare.CloudFlareActivity
|
||||||
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
|
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
|
||||||
import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException
|
|
||||||
import org.koitharu.kotatsu.core.ui.dialog.ErrorDetailsDialog
|
import org.koitharu.kotatsu.core.ui.dialog.ErrorDetailsDialog
|
||||||
import org.koitharu.kotatsu.core.util.TaggedActivityResult
|
import org.koitharu.kotatsu.core.util.TaggedActivityResult
|
||||||
import org.koitharu.kotatsu.parsers.exception.AuthRequiredException
|
import org.koitharu.kotatsu.parsers.exception.AuthRequiredException
|
||||||
import org.koitharu.kotatsu.parsers.exception.NotFoundException
|
import org.koitharu.kotatsu.parsers.exception.NotFoundException
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
import org.koitharu.kotatsu.settings.sources.auth.SourceAuthActivity
|
import org.koitharu.kotatsu.settings.sources.auth.SourceAuthActivity
|
||||||
import kotlin.coroutines.Continuation
|
import kotlin.coroutines.Continuation
|
||||||
@@ -29,7 +27,7 @@ class ExceptionResolver : ActivityResultCallback<TaggedActivityResult> {
|
|||||||
private val activity: FragmentActivity?
|
private val activity: FragmentActivity?
|
||||||
private val fragment: Fragment?
|
private val fragment: Fragment?
|
||||||
private val sourceAuthContract: ActivityResultLauncher<MangaSource>
|
private val sourceAuthContract: ActivityResultLauncher<MangaSource>
|
||||||
private val cloudflareContract: ActivityResultLauncher<CloudFlareProtectedException>
|
private val cloudflareContract: ActivityResultLauncher<Pair<String, Headers?>>
|
||||||
|
|
||||||
constructor(activity: FragmentActivity) {
|
constructor(activity: FragmentActivity) {
|
||||||
this.activity = activity
|
this.activity = activity
|
||||||
@@ -54,24 +52,19 @@ class ExceptionResolver : ActivityResultCallback<TaggedActivityResult> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun resolve(e: Throwable): Boolean = when (e) {
|
suspend fun resolve(e: Throwable): Boolean = when (e) {
|
||||||
is CloudFlareProtectedException -> resolveCF(e)
|
is CloudFlareProtectedException -> resolveCF(e.url, e.headers)
|
||||||
is AuthRequiredException -> resolveAuthException(e.source)
|
is AuthRequiredException -> resolveAuthException(e.source)
|
||||||
is NotFoundException -> {
|
is NotFoundException -> {
|
||||||
openInBrowser(e.url)
|
openInBrowser(e.url)
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
is UnsupportedSourceException -> {
|
|
||||||
e.manga?.let { openAlternatives(it) }
|
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> false
|
else -> false
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun resolveCF(e: CloudFlareProtectedException): Boolean = suspendCoroutine { cont ->
|
private suspend fun resolveCF(url: String, headers: Headers): Boolean = suspendCoroutine { cont ->
|
||||||
continuations[CloudFlareActivity.TAG] = cont
|
continuations[CloudFlareActivity.TAG] = cont
|
||||||
cloudflareContract.launch(e)
|
cloudflareContract.launch(url to headers)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun resolveAuthException(source: MangaSource): Boolean = suspendCoroutine { cont ->
|
private suspend fun resolveAuthException(source: MangaSource): Boolean = suspendCoroutine { cont ->
|
||||||
@@ -81,12 +74,7 @@ class ExceptionResolver : ActivityResultCallback<TaggedActivityResult> {
|
|||||||
|
|
||||||
private fun openInBrowser(url: String) {
|
private fun openInBrowser(url: String) {
|
||||||
val context = activity ?: fragment?.activity ?: return
|
val context = activity ?: fragment?.activity ?: return
|
||||||
context.startActivity(BrowserActivity.newIntent(context, url, null, null))
|
context.startActivity(BrowserActivity.newIntent(context, url, null))
|
||||||
}
|
|
||||||
|
|
||||||
private fun openAlternatives(manga: Manga) {
|
|
||||||
val context = activity ?: fragment?.activity ?: return
|
|
||||||
context.startActivity(AlternativesActivity.newIntent(context, manga))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getFragmentManager() = checkNotNull(fragment?.childFragmentManager ?: activity?.supportFragmentManager)
|
private fun getFragmentManager() = checkNotNull(fragment?.childFragmentManager ?: activity?.supportFragmentManager)
|
||||||
@@ -98,7 +86,6 @@ class ExceptionResolver : ActivityResultCallback<TaggedActivityResult> {
|
|||||||
is CloudFlareProtectedException -> R.string.captcha_solve
|
is CloudFlareProtectedException -> R.string.captcha_solve
|
||||||
is AuthRequiredException -> R.string.sign_in
|
is AuthRequiredException -> R.string.sign_in
|
||||||
is NotFoundException -> if (e.url.isNotEmpty()) R.string.open_in_browser else 0
|
is NotFoundException -> if (e.url.isNotEmpty()) R.string.open_in_browser else 0
|
||||||
is UnsupportedSourceException -> if (e.manga != null) R.string.alternatives else 0
|
|
||||||
else -> 0
|
else -> 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,9 +20,8 @@ import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
|||||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.FileOutputStream
|
import java.io.FileOutputStream
|
||||||
import java.time.LocalDateTime
|
import java.text.SimpleDateFormat
|
||||||
import java.time.format.DateTimeFormatter
|
import java.util.Date
|
||||||
import java.time.format.FormatStyle
|
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import java.util.concurrent.ConcurrentLinkedQueue
|
import java.util.concurrent.ConcurrentLinkedQueue
|
||||||
|
|
||||||
@@ -42,7 +41,11 @@ class FileLogger(
|
|||||||
}
|
}
|
||||||
val isEnabled: Boolean
|
val isEnabled: Boolean
|
||||||
get() = settings.isLoggingEnabled
|
get() = settings.isLoggingEnabled
|
||||||
private val dateTimeFormatter = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT).withLocale(Locale.ROOT)
|
private val dateFormat = SimpleDateFormat.getDateTimeInstance(
|
||||||
|
SimpleDateFormat.SHORT,
|
||||||
|
SimpleDateFormat.SHORT,
|
||||||
|
Locale.ROOT,
|
||||||
|
)
|
||||||
private val buffer = ConcurrentLinkedQueue<String>()
|
private val buffer = ConcurrentLinkedQueue<String>()
|
||||||
private val mutex = Mutex()
|
private val mutex = Mutex()
|
||||||
private var flushJob: Job? = null
|
private var flushJob: Job? = null
|
||||||
@@ -52,7 +55,7 @@ class FileLogger(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
val text = buildString {
|
val text = buildString {
|
||||||
append(dateTimeFormatter.format(LocalDateTime.now()))
|
append(dateFormat.format(Date()))
|
||||||
append(": ")
|
append(": ")
|
||||||
if (e != null) {
|
if (e != null) {
|
||||||
append("E!")
|
append("E!")
|
||||||
|
|||||||
@@ -2,18 +2,18 @@ package org.koitharu.kotatsu.core.model
|
|||||||
|
|
||||||
import android.os.Parcelable
|
import android.os.Parcelable
|
||||||
import kotlinx.parcelize.Parcelize
|
import kotlinx.parcelize.Parcelize
|
||||||
import org.koitharu.kotatsu.list.domain.ListSortOrder
|
|
||||||
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
|
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
|
||||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||||
import java.time.Instant
|
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||||
|
import java.util.Date
|
||||||
|
|
||||||
@Parcelize
|
@Parcelize
|
||||||
data class FavouriteCategory(
|
data class FavouriteCategory(
|
||||||
val id: Long,
|
val id: Long,
|
||||||
val title: String,
|
val title: String,
|
||||||
val sortKey: Int,
|
val sortKey: Int,
|
||||||
val order: ListSortOrder,
|
val order: SortOrder,
|
||||||
val createdAt: Instant,
|
val createdAt: Date,
|
||||||
val isTrackingEnabled: Boolean,
|
val isTrackingEnabled: Boolean,
|
||||||
val isVisibleInLibrary: Boolean,
|
val isVisibleInLibrary: Boolean,
|
||||||
) : Parcelable, ListModel {
|
) : Parcelable, ListModel {
|
||||||
|
|||||||
@@ -1,22 +1,13 @@
|
|||||||
package org.koitharu.kotatsu.core.model
|
package org.koitharu.kotatsu.core.model
|
||||||
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import androidx.annotation.DrawableRes
|
|
||||||
import androidx.annotation.StringRes
|
|
||||||
import androidx.collection.MutableObjectIntMap
|
|
||||||
import androidx.core.os.LocaleListCompat
|
import androidx.core.os.LocaleListCompat
|
||||||
import org.koitharu.kotatsu.R
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.iterator
|
import org.koitharu.kotatsu.core.util.ext.iterator
|
||||||
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
|
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
|
||||||
import org.koitharu.kotatsu.parsers.model.ContentRating
|
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaState
|
|
||||||
import org.koitharu.kotatsu.parsers.util.mapToSet
|
import org.koitharu.kotatsu.parsers.util.mapToSet
|
||||||
import java.text.DecimalFormat
|
|
||||||
import java.text.DecimalFormatSymbols
|
|
||||||
import com.google.android.material.R as materialR
|
|
||||||
|
|
||||||
@JvmName("mangaIds")
|
@JvmName("mangaIds")
|
||||||
fun Collection<Manga>.ids() = mapToSet { it.id }
|
fun Collection<Manga>.ids() = mapToSet { it.id }
|
||||||
@@ -32,44 +23,14 @@ fun Collection<ChapterListItem>.countChaptersByBranch(): Int {
|
|||||||
if (size <= 1) {
|
if (size <= 1) {
|
||||||
return size
|
return size
|
||||||
}
|
}
|
||||||
val acc = MutableObjectIntMap<String?>()
|
val acc = HashMap<String?, Int>()
|
||||||
for (item in this) {
|
for (item in this) {
|
||||||
val branch = item.chapter.branch
|
val branch = item.chapter.branch
|
||||||
acc[branch] = acc.getOrDefault(branch, 0) + 1
|
acc[branch] = (acc[branch] ?: 0) + 1
|
||||||
}
|
}
|
||||||
var max = 0
|
return acc.values.max()
|
||||||
acc.forEachValue { x -> if (x > max) max = x }
|
|
||||||
return max
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@get:StringRes
|
|
||||||
val MangaState.titleResId: Int
|
|
||||||
get() = when (this) {
|
|
||||||
MangaState.ONGOING -> R.string.state_ongoing
|
|
||||||
MangaState.FINISHED -> R.string.state_finished
|
|
||||||
MangaState.ABANDONED -> R.string.state_abandoned
|
|
||||||
MangaState.PAUSED -> R.string.state_paused
|
|
||||||
MangaState.UPCOMING -> R.string.state_upcoming
|
|
||||||
}
|
|
||||||
|
|
||||||
@get:DrawableRes
|
|
||||||
val MangaState.iconResId: Int
|
|
||||||
get() = when (this) {
|
|
||||||
MangaState.ONGOING -> R.drawable.ic_play
|
|
||||||
MangaState.FINISHED -> R.drawable.ic_state_finished
|
|
||||||
MangaState.ABANDONED -> R.drawable.ic_state_abandoned
|
|
||||||
MangaState.PAUSED -> R.drawable.ic_action_pause
|
|
||||||
MangaState.UPCOMING -> materialR.drawable.ic_clock_black_24dp
|
|
||||||
}
|
|
||||||
|
|
||||||
@get:StringRes
|
|
||||||
val ContentRating.titleResId: Int
|
|
||||||
get() = when (this) {
|
|
||||||
ContentRating.SAFE -> R.string.rating_safe
|
|
||||||
ContentRating.SUGGESTIVE -> R.string.rating_suggestive
|
|
||||||
ContentRating.ADULT -> R.string.rating_adult
|
|
||||||
}
|
|
||||||
|
|
||||||
fun Manga.findChapter(id: Long): MangaChapter? {
|
fun Manga.findChapter(id: Long): MangaChapter? {
|
||||||
return chapters?.findById(id)
|
return chapters?.findById(id)
|
||||||
}
|
}
|
||||||
@@ -118,32 +79,3 @@ val Manga.appUrl: Uri
|
|||||||
.appendQueryParameter("name", title)
|
.appendQueryParameter("name", title)
|
||||||
.appendQueryParameter("url", url)
|
.appendQueryParameter("url", url)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
private val chaptersNumberFormat = DecimalFormat("#.#").also { f ->
|
|
||||||
f.decimalFormatSymbols = DecimalFormatSymbols.getInstance().also {
|
|
||||||
it.decimalSeparator = '.'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun MangaChapter.formatNumber(): String? {
|
|
||||||
if (number <= 0f) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
return chaptersNumberFormat.format(number.toDouble())
|
|
||||||
}
|
|
||||||
|
|
||||||
fun Manga.chaptersCount(): Int {
|
|
||||||
if (chapters.isNullOrEmpty()) {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
val counters = MutableObjectIntMap<String?>()
|
|
||||||
var max = 0
|
|
||||||
chapters?.forEach { x ->
|
|
||||||
val c = counters.getOrDefault(x.branch, 0) + 1
|
|
||||||
counters[x.branch] = c
|
|
||||||
if (max < c) {
|
|
||||||
max = c
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return max
|
|
||||||
}
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user