Compare commits
1 Commits
v7.7.7
...
ui_playgro
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e4e4f18066 |
2
.github/ISSUE_TEMPLATE/config.yml
vendored
2
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -2,4 +2,4 @@ blank_issues_enabled: false
|
|||||||
contact_links:
|
contact_links:
|
||||||
- name: ⚠️ Source issue
|
- name: ⚠️ Source issue
|
||||||
url: https://github.com/KotatsuApp/kotatsu-parsers/issues/new
|
url: https://github.com/KotatsuApp/kotatsu-parsers/issues/new
|
||||||
about: If you have a problem with a specific **manga source** or want to propose a new one, please open an issue in the kotatsu-parsers repository instead
|
about: If you have troubles with a manga parser or want to propose new manga source, please open an issue in the kotatsu-parsers repository instead
|
||||||
4
.github/ISSUE_TEMPLATE/report_bug.yml
vendored
4
.github/ISSUE_TEMPLATE/report_bug.yml
vendored
@@ -60,7 +60,7 @@ body:
|
|||||||
attributes:
|
attributes:
|
||||||
label: Acknowledgements
|
label: Acknowledgements
|
||||||
options:
|
options:
|
||||||
- label: This is not a duplicate of an existing issue. Please look through the list of [open issues](https://github.com/KotatsuApp/Kotatsu/issues) before creating a new one.
|
- label: I have searched the existing issues and this is a new ticket, **NOT** a duplicate or related to another open issue.
|
||||||
required: true
|
required: true
|
||||||
- label: This is not an issue with a specific manga source. Otherwise, you have to open an issue in the [parsers repository](https://github.com/KotatsuApp/kotatsu-parsers/issues/new/choose).
|
- label: If this is an issue with a parser, I should be opening an issue in the [parsers repository](https://github.com/KotatsuApp/kotatsu-parsers/issues/new/choose).
|
||||||
required: true
|
required: true
|
||||||
|
|||||||
4
.github/ISSUE_TEMPLATE/request_feature.yml
vendored
4
.github/ISSUE_TEMPLATE/request_feature.yml
vendored
@@ -20,5 +20,5 @@ body:
|
|||||||
label: Acknowledgements
|
label: Acknowledgements
|
||||||
description: Read this carefully, we will close and ignore your issue if you skimmed through this.
|
description: Read this carefully, we will close and ignore your issue if you skimmed through this.
|
||||||
options:
|
options:
|
||||||
- label: This is not a duplicate of an existing issue. Please look through the list of [open issues](https://github.com/KotatsuApp/Kotatsu/issues) before creating a new one.
|
- label: I have searched the existing issues and this is a new ticket, **NOT** a duplicate or related to another open issue.
|
||||||
required: true
|
required: true
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -25,5 +25,3 @@
|
|||||||
.externalNativeBuild
|
.externalNativeBuild
|
||||||
.cxx
|
.cxx
|
||||||
/.idea/deviceManager.xml
|
/.idea/deviceManager.xml
|
||||||
/.kotlin/
|
|
||||||
/.idea/AndroidProjectSystem.xml
|
|
||||||
|
|||||||
1
.idea/.gitignore
generated
vendored
1
.idea/.gitignore
generated
vendored
@@ -2,4 +2,3 @@
|
|||||||
/shelf/
|
/shelf/
|
||||||
/workspace.xml
|
/workspace.xml
|
||||||
/migrations.xml
|
/migrations.xml
|
||||||
/runConfigurations.xml
|
|
||||||
|
|||||||
1
.idea/gradle.xml
generated
1
.idea/gradle.xml
generated
@@ -4,7 +4,6 @@
|
|||||||
<component name="GradleSettings">
|
<component name="GradleSettings">
|
||||||
<option name="linkedExternalProjectsSettings">
|
<option name="linkedExternalProjectsSettings">
|
||||||
<GradleProjectSettings>
|
<GradleProjectSettings>
|
||||||
<option name="testRunner" value="CHOOSE_PER_TEST" />
|
|
||||||
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
||||||
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
|
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
|
||||||
<option name="modules">
|
<option name="modules">
|
||||||
|
|||||||
18
README.md
18
README.md
@@ -1,27 +1,27 @@
|
|||||||
# Kotatsu
|
# Kotatsu
|
||||||
|
|
||||||
Kotatsu is a free and open-source manga reader for Android with built-in online content sources.
|
Kotatsu is a free and open source manga reader for Android.
|
||||||
|
|
||||||
[](https://github.com/KotatsuApp/kotatsu-parsers)   [](https://hosted.weblate.org/engage/kotatsu/) [](https://t.me/kotatsuapp) [](https://discord.gg/NNJ5RgVBC5) [](https://github.com/KotatsuApp/Kotatsu/blob/devel/LICENSE)
|
   [](https://hosted.weblate.org/engage/kotatsu/) [](https://t.me/kotatsuapp) [](https://discord.gg/NNJ5RgVBC5)
|
||||||
|
|
||||||
### Download
|
### Download
|
||||||
|
|
||||||
- **Recommended:** Download and install APK from **[GitHub Releases](https://github.com/KotatsuApp/Kotatsu/releases/latest)**. Application has a built-in self-updating feature.
|
- **Recommended:** Download and install APK from **[GitHub Releases](https://github.com/KotatsuApp/Kotatsu/releases/latest)**. Application has a built-in self-updating feature.
|
||||||
- Get it on **[F-Droid](https://f-droid.org/packages/org.koitharu.kotatsu)**. The F-Droid build may be a bit outdated and some fixes might be missing.
|
- Get it on **[F-Droid](https://f-droid.org/packages/org.koitharu.kotatsu)**. The F-Droid build may be a bit outdated and some fixes might be missing.
|
||||||
- Also [nightly builds](https://github.com/KotatsuApp/Kotatsu-nightly/releases) are available (very unstable, use at your own risk).
|
|
||||||
|
|
||||||
### Main Features
|
### Main Features
|
||||||
|
|
||||||
* Online [manga catalogues](https://github.com/KotatsuApp/kotatsu-parsers)
|
* Online [manga catalogues](https://github.com/KotatsuApp/kotatsu-parsers)
|
||||||
* Search manga by name, genres, and more filters
|
* Search manga by name and genres
|
||||||
* Reading history and bookmarks
|
* Reading history and bookmarks
|
||||||
* Favorites organized by user-defined categories
|
* Favourites organized by user-defined categories
|
||||||
* Downloading manga and reading it offline. Third-party CBZ archives also supported
|
* Downloading manga and reading it offline. Third-party CBZ archives also supported
|
||||||
* Tablet-optimized Material You UI
|
* Tablet-optimized Material You UI
|
||||||
* Standard and Webtoon-optimized customizable 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, Kitsu
|
||||||
* Password/fingerprint-protected access to the app
|
* Password/fingerprint protect access to the app
|
||||||
|
* History and favourites [synchronization](https://github.com/KotatsuApp/kotatsu-syncserver) across devices
|
||||||
|
|
||||||
### Screenshots
|
### Screenshots
|
||||||
|
|
||||||
@@ -53,5 +53,5 @@ install instructions.
|
|||||||
|
|
||||||
### DMCA disclaimer
|
### DMCA disclaimer
|
||||||
|
|
||||||
The developers of this application do not have any affiliation with the content available in the app.
|
The developers of this application does not have any affiliation with the content available in the app.
|
||||||
It collects content from sources that are freely available through any web browser
|
It is collecting from the sources freely available through any web browser.
|
||||||
|
|||||||
180
app/build.gradle
180
app/build.gradle
@@ -1,5 +1,3 @@
|
|||||||
import java.time.LocalDateTime
|
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id 'com.android.application'
|
id 'com.android.application'
|
||||||
id 'kotlin-android'
|
id 'kotlin-android'
|
||||||
@@ -10,16 +8,16 @@ plugins {
|
|||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
compileSdk = 35
|
compileSdk = 34
|
||||||
buildToolsVersion = '35.0.0'
|
buildToolsVersion = '34.0.0'
|
||||||
namespace = 'org.koitharu.kotatsu'
|
namespace = 'org.koitharu.kotatsu'
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId 'org.koitharu.kotatsu'
|
applicationId 'org.koitharu.kotatsu'
|
||||||
minSdk = 21
|
minSdk = 21
|
||||||
targetSdk = 35
|
targetSdk = 34
|
||||||
versionCode = 699
|
versionCode = 642
|
||||||
versionName = '7.7.7'
|
versionName = '7.0.1'
|
||||||
generatedDensities = []
|
generatedDensities = []
|
||||||
testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner'
|
testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner'
|
||||||
ksp {
|
ksp {
|
||||||
@@ -39,46 +37,33 @@ android {
|
|||||||
shrinkResources true
|
shrinkResources true
|
||||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||||
}
|
}
|
||||||
nightly {
|
|
||||||
initWith release
|
|
||||||
applicationIdSuffix = '.nightly'
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
viewBinding true
|
viewBinding true
|
||||||
buildConfig true
|
buildConfig true
|
||||||
}
|
}
|
||||||
packagingOptions {
|
|
||||||
resources {
|
|
||||||
excludes += [
|
|
||||||
'META-INF/README.md',
|
|
||||||
'META-INF/NOTICE.md'
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sourceSets {
|
sourceSets {
|
||||||
androidTest.assets.srcDirs += files("$projectDir/schemas".toString())
|
androidTest.assets.srcDirs += files("$projectDir/schemas".toString())
|
||||||
main.java.srcDirs += 'src/main/kotlin/'
|
main.java.srcDirs += 'src/main/kotlin/'
|
||||||
}
|
}
|
||||||
compileOptions {
|
compileOptions {
|
||||||
coreLibraryDesugaringEnabled true
|
coreLibraryDesugaringEnabled true
|
||||||
sourceCompatibility JavaVersion.VERSION_11
|
sourceCompatibility JavaVersion.VERSION_1_8
|
||||||
targetCompatibility JavaVersion.VERSION_11
|
targetCompatibility JavaVersion.VERSION_1_8
|
||||||
}
|
}
|
||||||
kotlinOptions {
|
kotlinOptions {
|
||||||
jvmTarget = JavaVersion.VERSION_11.toString()
|
jvmTarget = JavaVersion.VERSION_1_8.toString()
|
||||||
freeCompilerArgs += [
|
freeCompilerArgs += [
|
||||||
'-opt-in=kotlin.ExperimentalStdlibApi',
|
'-opt-in=kotlin.ExperimentalStdlibApi',
|
||||||
'-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi',
|
'-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi',
|
||||||
'-opt-in=kotlinx.coroutines.ExperimentalForInheritanceCoroutinesApi',
|
|
||||||
'-opt-in=kotlinx.coroutines.FlowPreview',
|
'-opt-in=kotlinx.coroutines.FlowPreview',
|
||||||
'-opt-in=kotlin.contracts.ExperimentalContracts',
|
'-opt-in=kotlin.contracts.ExperimentalContracts',
|
||||||
'-opt-in=coil3.annotation.ExperimentalCoilApi',
|
'-opt-in=coil.annotation.ExperimentalCoilApi',
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
lint {
|
lint {
|
||||||
abortOnError true
|
abortOnError true
|
||||||
disable 'MissingTranslation', 'PrivateResource', 'SetJavaScriptEnabled', 'SimpleDateFormat'
|
disable 'MissingTranslation', 'PrivateResource', 'NotifyDataSetChanged', 'SetJavaScriptEnabled'
|
||||||
}
|
}
|
||||||
testOptions {
|
testOptions {
|
||||||
unitTests.includeAndroidResources true
|
unitTests.includeAndroidResources true
|
||||||
@@ -87,15 +72,6 @@ android {
|
|||||||
freeCompilerArgs += ['-opt-in=org.koitharu.kotatsu.parsers.InternalParsersApi']
|
freeCompilerArgs += ['-opt-in=org.koitharu.kotatsu.parsers.InternalParsersApi']
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
applicationVariants.configureEach { variant ->
|
|
||||||
if (variant.name == 'nightly') {
|
|
||||||
variant.outputs.each { output ->
|
|
||||||
def now = LocalDateTime.now()
|
|
||||||
output.versionCodeOverride = now.format("yyMMdd").toInteger()
|
|
||||||
output.versionNameOverride = 'N' + now.format("yyyyMMdd")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
afterEvaluate {
|
afterEvaluate {
|
||||||
compileDebugKotlin {
|
compileDebugKotlin {
|
||||||
@@ -105,92 +81,88 @@ afterEvaluate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
dependencies {
|
dependencies {
|
||||||
def parsersVersion = libs.versions.parsers.get()
|
//noinspection GradleDependency
|
||||||
if (System.properties.containsKey('parsersVersionOverride')) {
|
implementation('com.github.KotatsuApp:kotatsu-parsers:078b59b1e2') {
|
||||||
// usage:
|
|
||||||
// -DparsersVersionOverride=$(curl -s https://api.github.com/repos/kotatsuapp/kotatsu-parsers/commits/master -H "Accept: application/vnd.github.sha" | cut -c -10)
|
|
||||||
parsersVersion = System.getProperty('parsersVersionOverride')
|
|
||||||
}
|
|
||||||
//noinspection UseTomlInstead
|
|
||||||
implementation("com.github.KotatsuApp:kotatsu-parsers:$parsersVersion") {
|
|
||||||
exclude group: 'org.json', module: 'json'
|
exclude group: 'org.json', module: 'json'
|
||||||
}
|
}
|
||||||
|
|
||||||
coreLibraryDesugaring libs.desugar.jdk.libs
|
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.4'
|
||||||
implementation libs.kotlin.stdlib
|
implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.9.24'
|
||||||
implementation libs.kotlinx.coroutines.android
|
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1'
|
||||||
implementation libs.kotlinx.coroutines.guava
|
|
||||||
|
|
||||||
implementation libs.androidx.appcompat
|
implementation 'androidx.appcompat:appcompat:1.6.1'
|
||||||
implementation libs.androidx.core
|
implementation 'androidx.core:core-ktx:1.13.1'
|
||||||
implementation libs.androidx.activity
|
implementation 'androidx.activity:activity-ktx:1.9.0'
|
||||||
implementation libs.androidx.fragment
|
implementation 'androidx.fragment:fragment-ktx:1.7.1'
|
||||||
implementation libs.androidx.transition
|
implementation 'androidx.collection:collection-ktx:1.4.0'
|
||||||
implementation libs.androidx.collection
|
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.0'
|
||||||
implementation libs.lifecycle.viewmodel
|
implementation 'androidx.lifecycle:lifecycle-service:2.8.0'
|
||||||
implementation libs.lifecycle.service
|
implementation 'androidx.lifecycle:lifecycle-process:2.8.0'
|
||||||
implementation libs.lifecycle.process
|
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
|
||||||
implementation libs.androidx.constraintlayout
|
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
|
||||||
implementation libs.androidx.swiperefreshlayout
|
implementation 'androidx.recyclerview:recyclerview:1.3.2'
|
||||||
implementation libs.androidx.recyclerview
|
implementation 'androidx.viewpager2:viewpager2:1.1.0'
|
||||||
implementation libs.androidx.viewpager2
|
implementation 'androidx.preference:preference-ktx:1.2.1'
|
||||||
implementation libs.androidx.preference
|
implementation 'androidx.biometric:biometric-ktx:1.2.0-alpha05'
|
||||||
implementation libs.androidx.biometric
|
implementation 'com.google.android.material:material:1.12.0'
|
||||||
implementation libs.material
|
implementation 'androidx.lifecycle:lifecycle-common-java8:2.8.0'
|
||||||
implementation libs.androidx.lifecycle.common.java8
|
implementation 'androidx.webkit:webkit:1.11.0'
|
||||||
implementation libs.androidx.webkit
|
|
||||||
|
|
||||||
implementation libs.androidx.work.runtime
|
implementation 'androidx.work:work-runtime:2.9.0'
|
||||||
implementation libs.guava
|
//noinspection GradleDependency
|
||||||
|
implementation('com.google.guava:guava:32.0.1-android') {
|
||||||
|
exclude group: 'com.google.guava', module: 'failureaccess'
|
||||||
|
exclude group: 'org.checkerframework', module: 'checker-qual'
|
||||||
|
exclude group: 'com.google.j2objc', module: 'j2objc-annotations'
|
||||||
|
}
|
||||||
|
|
||||||
implementation libs.androidx.room.runtime
|
implementation 'androidx.room:room-runtime:2.6.1'
|
||||||
implementation libs.androidx.room.ktx
|
implementation 'androidx.room:room-ktx:2.6.1'
|
||||||
ksp libs.androidx.room.compiler
|
ksp 'androidx.room:room-compiler:2.6.1'
|
||||||
|
|
||||||
implementation libs.okhttp
|
implementation 'com.squareup.okhttp3:okhttp:4.12.0'
|
||||||
implementation libs.okhttp.tls
|
implementation 'com.squareup.okhttp3:okhttp-tls:4.12.0'
|
||||||
implementation libs.okhttp.dnsoverhttps
|
implementation 'com.squareup.okhttp3:okhttp-dnsoverhttps:4.12.0'
|
||||||
implementation libs.okio
|
implementation 'com.squareup.okio:okio:3.9.0'
|
||||||
|
|
||||||
implementation libs.adapterdelegates
|
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.2'
|
||||||
implementation libs.adapterdelegates.viewbinding
|
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:4.3.2'
|
||||||
|
|
||||||
implementation libs.hilt.android
|
implementation 'com.google.dagger:hilt-android:2.51.1'
|
||||||
kapt libs.hilt.compiler
|
kapt 'com.google.dagger:hilt-compiler:2.51.1'
|
||||||
implementation libs.androidx.hilt.work
|
implementation 'androidx.hilt:hilt-work:1.2.0'
|
||||||
kapt libs.androidx.hilt.compiler
|
kapt 'androidx.hilt:hilt-compiler:1.2.0'
|
||||||
|
|
||||||
implementation libs.coil.core
|
implementation 'io.coil-kt:coil-base:2.6.0'
|
||||||
implementation libs.coil.network
|
implementation 'io.coil-kt:coil-svg:2.6.0'
|
||||||
implementation libs.coil.gif
|
implementation 'com.github.KotatsuApp:subsampling-scale-image-view:02e6d6cfe9'
|
||||||
implementation libs.coil.svg
|
implementation 'com.github.solkin:disk-lru-cache:1.4'
|
||||||
implementation libs.avif.decoder
|
implementation 'io.noties.markwon:core:4.6.2'
|
||||||
implementation libs.ssiv
|
|
||||||
implementation libs.disk.lru.cache
|
|
||||||
implementation libs.markwon
|
|
||||||
|
|
||||||
implementation libs.acra.http
|
implementation 'ch.acra:acra-http:5.11.3'
|
||||||
implementation libs.acra.dialog
|
implementation 'ch.acra:acra-dialog:5.11.3'
|
||||||
|
compileOnly 'com.google.auto.service:auto-service-annotations:1.1.1'
|
||||||
|
ksp 'dev.zacsweers.autoservice:auto-service-ksp:1.1.0'
|
||||||
|
|
||||||
implementation libs.conscrypt.android
|
implementation 'org.conscrypt:conscrypt-android:2.5.2'
|
||||||
|
|
||||||
debugImplementation libs.leakcanary.android
|
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.14'
|
||||||
debugImplementation libs.workinspector
|
debugImplementation 'com.github.Koitharu:WorkInspector:5778dd1747'
|
||||||
|
|
||||||
testImplementation libs.junit
|
testImplementation 'junit:junit:4.13.2'
|
||||||
testImplementation libs.json
|
testImplementation 'org.json:json:20240303'
|
||||||
testImplementation libs.kotlinx.coroutines.test
|
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1'
|
||||||
|
|
||||||
androidTestImplementation libs.androidx.runner
|
androidTestImplementation 'androidx.test:runner:1.5.2'
|
||||||
androidTestImplementation libs.androidx.rules
|
androidTestImplementation 'androidx.test:rules:1.5.0'
|
||||||
androidTestImplementation libs.androidx.test.core
|
androidTestImplementation 'androidx.test:core-ktx:1.5.0'
|
||||||
androidTestImplementation libs.androidx.junit
|
androidTestImplementation 'androidx.test.ext:junit-ktx:1.1.5'
|
||||||
|
|
||||||
androidTestImplementation libs.kotlinx.coroutines.test
|
androidTestImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1'
|
||||||
|
|
||||||
androidTestImplementation libs.androidx.room.testing
|
androidTestImplementation 'androidx.room:room-testing:2.6.1'
|
||||||
androidTestImplementation libs.moshi.kotlin
|
androidTestImplementation 'com.squareup.moshi:moshi-kotlin:1.15.1'
|
||||||
|
|
||||||
androidTestImplementation libs.hilt.android.testing
|
androidTestImplementation 'com.google.dagger:hilt-android-testing:2.51.1'
|
||||||
kaptAndroidTest libs.hilt.android.compiler
|
kaptAndroidTest 'com.google.dagger:hilt-android-compiler:2.51.1'
|
||||||
}
|
}
|
||||||
|
|||||||
7
app/proguard-rules.pro
vendored
7
app/proguard-rules.pro
vendored
@@ -14,8 +14,6 @@
|
|||||||
-dontwarn org.conscrypt.**
|
-dontwarn org.conscrypt.**
|
||||||
-dontwarn org.bouncycastle.**
|
-dontwarn org.bouncycastle.**
|
||||||
-dontwarn org.openjsse.**
|
-dontwarn org.openjsse.**
|
||||||
-dontwarn com.google.j2objc.annotations.**
|
|
||||||
-dontwarn coil3.PlatformContext
|
|
||||||
|
|
||||||
-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
|
||||||
@@ -23,8 +21,3 @@
|
|||||||
-keep class org.koitharu.kotatsu.settings.backup.PeriodicalBackupSettingsFragment { *; }
|
-keep class org.koitharu.kotatsu.settings.backup.PeriodicalBackupSettingsFragment { *; }
|
||||||
-keep class org.jsoup.parser.Tag
|
-keep class org.jsoup.parser.Tag
|
||||||
-keep class org.jsoup.internal.StringUtil
|
-keep class org.jsoup.internal.StringUtil
|
||||||
|
|
||||||
-keep class org.acra.security.NoKeyStoreFactory { *; }
|
|
||||||
-keep class org.acra.config.DefaultRetryPolicy { *; }
|
|
||||||
-keep class org.acra.attachment.DefaultAttachmentProvider { *; }
|
|
||||||
-keep class org.acra.sender.JobSenderService
|
|
||||||
|
|||||||
@@ -0,0 +1,198 @@
|
|||||||
|
package org.koitharu.kotatsu.tracker.domain
|
||||||
|
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import dagger.hilt.android.testing.HiltAndroidRule
|
||||||
|
import dagger.hilt.android.testing.HiltAndroidTest
|
||||||
|
import junit.framework.TestCase.*
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.koitharu.kotatsu.SampleData
|
||||||
|
import org.koitharu.kotatsu.core.parser.MangaDataRepository
|
||||||
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@HiltAndroidTest
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class TrackerTest {
|
||||||
|
|
||||||
|
@get:Rule
|
||||||
|
var hiltRule = HiltAndroidRule(this)
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var repository: TrackingRepository
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var dataRepository: MangaDataRepository
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var tracker: Tracker
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
hiltRule.inject()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun noUpdates() = runTest {
|
||||||
|
val manga = loadManga("full.json")
|
||||||
|
tracker.deleteTrack(manga.id)
|
||||||
|
|
||||||
|
tracker.checkUpdates(manga, commit = true).apply {
|
||||||
|
assertFalse(isValid)
|
||||||
|
assert(newChapters.isEmpty())
|
||||||
|
}
|
||||||
|
assertEquals(0, repository.getNewChaptersCount(manga.id))
|
||||||
|
tracker.checkUpdates(manga, commit = true).apply {
|
||||||
|
assertTrue(isValid)
|
||||||
|
assert(newChapters.isEmpty())
|
||||||
|
}
|
||||||
|
assertEquals(0, repository.getNewChaptersCount(manga.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun hasUpdates() = runTest {
|
||||||
|
val mangaFirst = loadManga("first_chapters.json")
|
||||||
|
val mangaFull = loadManga("full.json")
|
||||||
|
tracker.deleteTrack(mangaFirst.id)
|
||||||
|
|
||||||
|
tracker.checkUpdates(mangaFirst, commit = true).apply {
|
||||||
|
assertFalse(isValid)
|
||||||
|
assert(newChapters.isEmpty())
|
||||||
|
}
|
||||||
|
assertEquals(0, repository.getNewChaptersCount(mangaFirst.id))
|
||||||
|
tracker.checkUpdates(mangaFull, commit = true).apply {
|
||||||
|
assertTrue(isValid)
|
||||||
|
assertEquals(3, newChapters.size)
|
||||||
|
}
|
||||||
|
assertEquals(3, repository.getNewChaptersCount(mangaFirst.id))
|
||||||
|
tracker.checkUpdates(mangaFull, commit = true).apply {
|
||||||
|
assertTrue(isValid)
|
||||||
|
assert(newChapters.isEmpty())
|
||||||
|
}
|
||||||
|
assertEquals(3, repository.getNewChaptersCount(mangaFirst.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun badIds() = runTest {
|
||||||
|
val mangaFirst = loadManga("first_chapters.json")
|
||||||
|
val mangaBad = loadManga("bad_ids.json")
|
||||||
|
tracker.deleteTrack(mangaFirst.id)
|
||||||
|
|
||||||
|
tracker.checkUpdates(mangaFirst, commit = true).apply {
|
||||||
|
assertFalse(isValid)
|
||||||
|
assert(newChapters.isEmpty())
|
||||||
|
}
|
||||||
|
assertEquals(0, repository.getNewChaptersCount(mangaFirst.id))
|
||||||
|
tracker.checkUpdates(mangaBad, commit = true).apply {
|
||||||
|
assertFalse(isValid)
|
||||||
|
assert(newChapters.isEmpty())
|
||||||
|
}
|
||||||
|
assertEquals(0, repository.getNewChaptersCount(mangaFirst.id))
|
||||||
|
tracker.checkUpdates(mangaFirst, commit = true).apply {
|
||||||
|
assertFalse(isValid)
|
||||||
|
assert(newChapters.isEmpty())
|
||||||
|
}
|
||||||
|
assertEquals(0, repository.getNewChaptersCount(mangaFirst.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun badIds2() = runTest {
|
||||||
|
val mangaFirst = loadManga("first_chapters.json")
|
||||||
|
val mangaBad = loadManga("bad_ids.json")
|
||||||
|
val mangaFull = loadManga("full.json")
|
||||||
|
tracker.deleteTrack(mangaFirst.id)
|
||||||
|
|
||||||
|
tracker.checkUpdates(mangaFirst, commit = true).apply {
|
||||||
|
assertFalse(isValid)
|
||||||
|
assert(newChapters.isEmpty())
|
||||||
|
}
|
||||||
|
assertEquals(0, repository.getNewChaptersCount(mangaFirst.id))
|
||||||
|
tracker.checkUpdates(mangaFull, commit = true).apply {
|
||||||
|
assertTrue(isValid)
|
||||||
|
assertEquals(3, newChapters.size)
|
||||||
|
}
|
||||||
|
assertEquals(3, repository.getNewChaptersCount(mangaFull.id))
|
||||||
|
tracker.checkUpdates(mangaBad, commit = true).apply {
|
||||||
|
assertFalse(isValid)
|
||||||
|
assert(newChapters.isEmpty())
|
||||||
|
}
|
||||||
|
assertEquals(0, repository.getNewChaptersCount(mangaFirst.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun fullReset() = runTest {
|
||||||
|
val mangaFull = loadManga("full.json")
|
||||||
|
val mangaFirst = loadManga("first_chapters.json")
|
||||||
|
val mangaEmpty = loadManga("empty.json")
|
||||||
|
tracker.deleteTrack(mangaFull.id)
|
||||||
|
|
||||||
|
assertEquals(0, repository.getNewChaptersCount(mangaFull.id))
|
||||||
|
tracker.checkUpdates(mangaFull, commit = true).apply {
|
||||||
|
assertFalse(isValid)
|
||||||
|
assert(newChapters.isEmpty())
|
||||||
|
}
|
||||||
|
assertEquals(0, repository.getNewChaptersCount(mangaFull.id))
|
||||||
|
tracker.checkUpdates(mangaEmpty, commit = true).apply {
|
||||||
|
assert(newChapters.isEmpty())
|
||||||
|
}
|
||||||
|
assertEquals(0, repository.getNewChaptersCount(mangaFull.id))
|
||||||
|
tracker.checkUpdates(mangaFirst, commit = true).apply {
|
||||||
|
assertFalse(isValid)
|
||||||
|
assert(newChapters.isEmpty())
|
||||||
|
}
|
||||||
|
assertEquals(0, repository.getNewChaptersCount(mangaFull.id))
|
||||||
|
tracker.checkUpdates(mangaFull, commit = true).apply {
|
||||||
|
assertTrue(isValid)
|
||||||
|
assertEquals(3, newChapters.size)
|
||||||
|
}
|
||||||
|
assertEquals(3, repository.getNewChaptersCount(mangaFull.id))
|
||||||
|
tracker.checkUpdates(mangaEmpty, commit = true).apply {
|
||||||
|
assertFalse(isValid)
|
||||||
|
assert(newChapters.isEmpty())
|
||||||
|
}
|
||||||
|
assertEquals(0, repository.getNewChaptersCount(mangaFull.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun syncWithHistory() = runTest {
|
||||||
|
val mangaFull = loadManga("full.json")
|
||||||
|
val mangaFirst = loadManga("first_chapters.json")
|
||||||
|
tracker.deleteTrack(mangaFull.id)
|
||||||
|
|
||||||
|
tracker.checkUpdates(mangaFirst, commit = true).apply {
|
||||||
|
assertFalse(isValid)
|
||||||
|
assert(newChapters.isEmpty())
|
||||||
|
}
|
||||||
|
assertEquals(0, repository.getNewChaptersCount(mangaFirst.id))
|
||||||
|
tracker.checkUpdates(mangaFull, commit = true).apply {
|
||||||
|
assertTrue(isValid)
|
||||||
|
assertEquals(3, newChapters.size)
|
||||||
|
}
|
||||||
|
assertEquals(3, repository.getNewChaptersCount(mangaFirst.id))
|
||||||
|
|
||||||
|
var chapter = requireNotNull(mangaFull.chapters).run { get(lastIndex - 1) }
|
||||||
|
tracker.syncWithHistory(mangaFull, chapter.id)
|
||||||
|
|
||||||
|
assertEquals(1, repository.getNewChaptersCount(mangaFirst.id))
|
||||||
|
|
||||||
|
chapter = requireNotNull(mangaFull.chapters).run { get(lastIndex) }
|
||||||
|
tracker.syncWithHistory(mangaFull, chapter.id)
|
||||||
|
|
||||||
|
assertEquals(0, repository.getNewChaptersCount(mangaFirst.id))
|
||||||
|
|
||||||
|
tracker.checkUpdates(mangaFull, commit = true).apply {
|
||||||
|
assertTrue(isValid)
|
||||||
|
assert(newChapters.isEmpty())
|
||||||
|
}
|
||||||
|
assertEquals(0, repository.getNewChaptersCount(mangaFirst.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun loadManga(name: String): Manga {
|
||||||
|
val manga = SampleData.loadAsset("manga/$name", Manga::class)
|
||||||
|
dataRepository.storeManga(manga)
|
||||||
|
return manga
|
||||||
|
}
|
||||||
|
}
|
||||||
12
app/src/debug/AndroidManifest.xml
Normal file
12
app/src/debug/AndroidManifest.xml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<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>
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
package org.koitharu.kotatsu
|
package org.koitharu.kotatsu
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.os.Build
|
|
||||||
import android.os.StrictMode
|
import android.os.StrictMode
|
||||||
import androidx.fragment.app.strictmode.FragmentStrictMode
|
import androidx.fragment.app.strictmode.FragmentStrictMode
|
||||||
import org.koitharu.kotatsu.core.BaseApp
|
import org.koitharu.kotatsu.core.BaseApp
|
||||||
@@ -9,7 +8,6 @@ import org.koitharu.kotatsu.local.data.LocalMangaRepository
|
|||||||
import org.koitharu.kotatsu.local.data.PagesCache
|
import org.koitharu.kotatsu.local.data.PagesCache
|
||||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
||||||
import org.koitharu.kotatsu.reader.domain.PageLoader
|
import org.koitharu.kotatsu.reader.domain.PageLoader
|
||||||
import org.koitharu.kotatsu.reader.ui.ReaderViewModel
|
|
||||||
|
|
||||||
class KotatsuApp : BaseApp() {
|
class KotatsuApp : BaseApp() {
|
||||||
|
|
||||||
@@ -19,55 +17,29 @@ class KotatsuApp : BaseApp() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun enableStrictMode() {
|
private fun enableStrictMode() {
|
||||||
val notifier = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
|
||||||
StrictModeNotifier(this)
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
StrictMode.setThreadPolicy(
|
StrictMode.setThreadPolicy(
|
||||||
StrictMode.ThreadPolicy.Builder().apply {
|
StrictMode.ThreadPolicy.Builder()
|
||||||
detectNetwork()
|
.detectAll()
|
||||||
detectDiskWrites()
|
.penaltyLog()
|
||||||
detectCustomSlowCalls()
|
.build(),
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) detectUnbufferedIo()
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) detectResourceMismatches()
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) detectExplicitGc()
|
|
||||||
penaltyLog()
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && notifier != null) {
|
|
||||||
penaltyListener(notifier.executor, notifier)
|
|
||||||
}
|
|
||||||
}.build(),
|
|
||||||
)
|
)
|
||||||
StrictMode.setVmPolicy(
|
StrictMode.setVmPolicy(
|
||||||
StrictMode.VmPolicy.Builder().apply {
|
StrictMode.VmPolicy.Builder()
|
||||||
detectActivityLeaks()
|
.detectAll()
|
||||||
detectLeakedSqlLiteObjects()
|
.setClassInstanceLimit(LocalMangaRepository::class.java, 1)
|
||||||
detectLeakedClosableObjects()
|
.setClassInstanceLimit(PagesCache::class.java, 1)
|
||||||
detectLeakedRegistrationObjects()
|
.setClassInstanceLimit(MangaLoaderContext::class.java, 1)
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) detectContentUriWithoutPermission()
|
.setClassInstanceLimit(PageLoader::class.java, 1)
|
||||||
detectFileUriExposure()
|
.penaltyLog()
|
||||||
setClassInstanceLimit(LocalMangaRepository::class.java, 1)
|
.build(),
|
||||||
setClassInstanceLimit(PagesCache::class.java, 1)
|
|
||||||
setClassInstanceLimit(MangaLoaderContext::class.java, 1)
|
|
||||||
setClassInstanceLimit(PageLoader::class.java, 1)
|
|
||||||
setClassInstanceLimit(ReaderViewModel::class.java, 1)
|
|
||||||
penaltyLog()
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && notifier != null) {
|
|
||||||
penaltyListener(notifier.executor, notifier)
|
|
||||||
}
|
|
||||||
}.build()
|
|
||||||
)
|
)
|
||||||
FragmentStrictMode.defaultPolicy = FragmentStrictMode.Policy.Builder().apply {
|
FragmentStrictMode.defaultPolicy = FragmentStrictMode.Policy.Builder()
|
||||||
detectWrongFragmentContainer()
|
.penaltyDeath()
|
||||||
detectFragmentTagUsage()
|
.detectFragmentReuse()
|
||||||
detectRetainInstanceUsage()
|
.detectWrongFragmentContainer()
|
||||||
detectSetUserVisibleHint()
|
.detectRetainInstanceUsage()
|
||||||
detectWrongNestedHierarchy()
|
.detectSetUserVisibleHint()
|
||||||
detectFragmentReuse()
|
.detectFragmentTagUsage()
|
||||||
penaltyLog()
|
.build()
|
||||||
if (notifier != null) {
|
|
||||||
penaltyListener(notifier)
|
|
||||||
}
|
|
||||||
}.build()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,73 +0,0 @@
|
|||||||
package org.koitharu.kotatsu
|
|
||||||
|
|
||||||
import android.app.Notification
|
|
||||||
import android.app.Notification.BigTextStyle
|
|
||||||
import android.app.NotificationChannel
|
|
||||||
import android.app.NotificationManager
|
|
||||||
import android.content.Context
|
|
||||||
import android.os.Build
|
|
||||||
import android.os.StrictMode
|
|
||||||
import android.os.strictmode.Violation
|
|
||||||
import androidx.annotation.RequiresApi
|
|
||||||
import androidx.core.app.PendingIntentCompat
|
|
||||||
import androidx.core.content.getSystemService
|
|
||||||
import androidx.fragment.app.strictmode.FragmentStrictMode
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.asExecutor
|
|
||||||
import org.koitharu.kotatsu.core.util.ShareHelper
|
|
||||||
import kotlin.math.absoluteValue
|
|
||||||
import androidx.fragment.app.strictmode.Violation as FragmentViolation
|
|
||||||
|
|
||||||
@RequiresApi(Build.VERSION_CODES.P)
|
|
||||||
class StrictModeNotifier(
|
|
||||||
private val context: Context,
|
|
||||||
) : StrictMode.OnVmViolationListener, StrictMode.OnThreadViolationListener, FragmentStrictMode.OnViolationListener {
|
|
||||||
|
|
||||||
val executor = Dispatchers.Default.asExecutor()
|
|
||||||
|
|
||||||
private val notificationManager by lazy {
|
|
||||||
val nm = checkNotNull(context.getSystemService<NotificationManager>())
|
|
||||||
val channel = NotificationChannel(
|
|
||||||
CHANNEL_ID,
|
|
||||||
context.getString(R.string.strict_mode),
|
|
||||||
NotificationManager.IMPORTANCE_LOW,
|
|
||||||
)
|
|
||||||
nm.createNotificationChannel(channel)
|
|
||||||
nm
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onVmViolation(v: Violation) = showNotification(v)
|
|
||||||
|
|
||||||
override fun onThreadViolation(v: Violation) = showNotification(v)
|
|
||||||
|
|
||||||
override fun onViolation(violation: FragmentViolation) = showNotification(violation)
|
|
||||||
|
|
||||||
private fun showNotification(violation: Throwable) = Notification.Builder(context, CHANNEL_ID)
|
|
||||||
.setSmallIcon(R.drawable.ic_bug)
|
|
||||||
.setContentTitle(context.getString(R.string.strict_mode))
|
|
||||||
.setContentText(violation.message)
|
|
||||||
.setStyle(
|
|
||||||
BigTextStyle()
|
|
||||||
.setBigContentTitle(context.getString(R.string.strict_mode))
|
|
||||||
.setSummaryText(violation.message)
|
|
||||||
.bigText(violation.stackTraceToString()),
|
|
||||||
).setShowWhen(true)
|
|
||||||
.setContentIntent(
|
|
||||||
PendingIntentCompat.getActivity(
|
|
||||||
context,
|
|
||||||
0,
|
|
||||||
ShareHelper(context).getShareTextIntent(violation.stackTraceToString()),
|
|
||||||
0,
|
|
||||||
false,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.setAutoCancel(true)
|
|
||||||
.setGroup(CHANNEL_ID)
|
|
||||||
.build()
|
|
||||||
.let { notificationManager.notify(CHANNEL_ID, violation.hashCode().absoluteValue, it) }
|
|
||||||
|
|
||||||
private companion object {
|
|
||||||
|
|
||||||
const val CHANNEL_ID = "strict_mode"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,6 @@ package org.koitharu.kotatsu.core.network
|
|||||||
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import okhttp3.Interceptor
|
import okhttp3.Interceptor
|
||||||
import okhttp3.Request
|
|
||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
import okio.Buffer
|
import okio.Buffer
|
||||||
import org.koitharu.kotatsu.core.network.CommonHeaders.ACCEPT_ENCODING
|
import org.koitharu.kotatsu.core.network.CommonHeaders.ACCEPT_ENCODING
|
||||||
@@ -13,11 +12,8 @@ class CurlLoggingInterceptor(
|
|||||||
|
|
||||||
private val escapeRegex = Regex("([\\[\\]\"])")
|
private val escapeRegex = Regex("([\\[\\]\"])")
|
||||||
|
|
||||||
override fun intercept(chain: Interceptor.Chain): Response = chain.proceed(chain.request()).also {
|
override fun intercept(chain: Interceptor.Chain): Response {
|
||||||
logRequest(it.networkResponse?.request ?: it.request)
|
val request = chain.request()
|
||||||
}
|
|
||||||
|
|
||||||
private fun logRequest(request: Request) {
|
|
||||||
var isCompressed = false
|
var isCompressed = false
|
||||||
|
|
||||||
val curlCmd = StringBuilder()
|
val curlCmd = StringBuilder()
|
||||||
@@ -50,11 +46,16 @@ class CurlLoggingInterceptor(
|
|||||||
|
|
||||||
log("---cURL (" + request.url + ")")
|
log("---cURL (" + request.url + ")")
|
||||||
log(curlCmd.toString())
|
log(curlCmd.toString())
|
||||||
|
|
||||||
|
return chain.proceed(request)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun String.escape() = replace(escapeRegex) { match ->
|
private fun String.escape() = replace(escapeRegex) { match ->
|
||||||
"\\" + match.value
|
"\\" + match.value
|
||||||
}
|
}
|
||||||
|
// .replace("\"", "\\\"")
|
||||||
|
// .replace("[", "\\[")
|
||||||
|
// .replace("]", "\\]")
|
||||||
|
|
||||||
private fun log(msg: String) {
|
private fun log(msg: String) {
|
||||||
Log.d("CURL", msg)
|
Log.d("CURL", msg)
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.settings
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.view.Menu
|
|
||||||
import android.view.MenuInflater
|
|
||||||
import android.view.MenuItem
|
|
||||||
import androidx.core.view.MenuProvider
|
|
||||||
import leakcanary.LeakCanary
|
|
||||||
import org.koitharu.kotatsu.R
|
|
||||||
import org.koitharu.workinspector.WorkInspector
|
|
||||||
|
|
||||||
class SettingsMenuProvider(
|
|
||||||
private val context: Context,
|
|
||||||
) : MenuProvider {
|
|
||||||
|
|
||||||
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
|
|
||||||
menuInflater.inflate(R.menu.opt_settings, menu)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) {
|
|
||||||
R.id.action_leaks -> {
|
|
||||||
context.startActivity(LeakCanary.newLeakDisplayActivityIntent())
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
R.id.action_works -> {
|
|
||||||
context.startActivity(WorkInspector.getIntent(context))
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -7,8 +7,7 @@ import androidx.core.text.bold
|
|||||||
import androidx.core.text.buildSpannedString
|
import androidx.core.text.buildSpannedString
|
||||||
import androidx.core.text.color
|
import androidx.core.text.color
|
||||||
import androidx.lifecycle.LifecycleOwner
|
import androidx.lifecycle.LifecycleOwner
|
||||||
import coil3.ImageLoader
|
import coil.ImageLoader
|
||||||
import coil3.request.allowRgb565
|
|
||||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||||
@@ -16,8 +15,8 @@ import org.koitharu.kotatsu.core.util.ext.defaultPlaceholders
|
|||||||
import org.koitharu.kotatsu.core.util.ext.drawableStart
|
import org.koitharu.kotatsu.core.util.ext.drawableStart
|
||||||
import org.koitharu.kotatsu.core.util.ext.enqueueWith
|
import org.koitharu.kotatsu.core.util.ext.enqueueWith
|
||||||
import org.koitharu.kotatsu.core.util.ext.getThemeColor
|
import org.koitharu.kotatsu.core.util.ext.getThemeColor
|
||||||
import org.koitharu.kotatsu.core.util.ext.mangaSourceExtra
|
|
||||||
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.databinding.ItemTrackDebugBinding
|
import org.koitharu.kotatsu.databinding.ItemTrackDebugBinding
|
||||||
import org.koitharu.kotatsu.tracker.data.TrackEntity
|
import org.koitharu.kotatsu.tracker.data.TrackEntity
|
||||||
import com.google.android.material.R as materialR
|
import com.google.android.material.R as materialR
|
||||||
@@ -39,27 +38,27 @@ fun trackDebugAD(
|
|||||||
binding.imageViewCover.newImageRequest(lifecycleOwner, item.manga.coverUrl)?.run {
|
binding.imageViewCover.newImageRequest(lifecycleOwner, item.manga.coverUrl)?.run {
|
||||||
defaultPlaceholders(context)
|
defaultPlaceholders(context)
|
||||||
allowRgb565(true)
|
allowRgb565(true)
|
||||||
mangaSourceExtra(item.manga.source)
|
source(item.manga.source)
|
||||||
enqueueWith(coil)
|
enqueueWith(coil)
|
||||||
}
|
}
|
||||||
binding.textViewTitle.text = item.manga.title
|
binding.textViewTitle.text = item.manga.title
|
||||||
binding.textViewSummary.text = buildSpannedString {
|
binding.textViewSummary.text = buildSpannedString {
|
||||||
append(
|
item.lastCheckTime?.let {
|
||||||
item.lastCheckTime?.let {
|
append(
|
||||||
DateUtils.getRelativeDateTimeString(
|
DateUtils.getRelativeDateTimeString(
|
||||||
context,
|
context,
|
||||||
it.toEpochMilli(),
|
it.toEpochMilli(),
|
||||||
DateUtils.MINUTE_IN_MILLIS,
|
DateUtils.MINUTE_IN_MILLIS,
|
||||||
DateUtils.WEEK_IN_MILLIS,
|
DateUtils.WEEK_IN_MILLIS,
|
||||||
0,
|
0,
|
||||||
)
|
),
|
||||||
} ?: getString(R.string.never),
|
)
|
||||||
)
|
}
|
||||||
if (item.lastResult == TrackEntity.RESULT_FAILED) {
|
if (item.lastResult == TrackEntity.RESULT_FAILED) {
|
||||||
append(" - ")
|
append(" - ")
|
||||||
bold {
|
bold {
|
||||||
color(context.getThemeColor(materialR.attr.colorError, Color.RED)) {
|
color(context.getThemeColor(materialR.attr.colorError, Color.RED)) {
|
||||||
append(item.lastError ?: getString(R.string.error))
|
append(getString(R.string.error))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -11,7 +11,6 @@ data class TrackDebugItem(
|
|||||||
val lastCheckTime: Instant?,
|
val lastCheckTime: Instant?,
|
||||||
val lastChapterDate: Instant?,
|
val lastChapterDate: Instant?,
|
||||||
val lastResult: Int,
|
val lastResult: Int,
|
||||||
val lastError: String?,
|
|
||||||
) : ListModel {
|
) : ListModel {
|
||||||
|
|
||||||
override fun areItemsTheSame(other: ListModel): Boolean {
|
override fun areItemsTheSame(other: ListModel): Boolean {
|
||||||
@@ -5,7 +5,7 @@ import android.view.View
|
|||||||
import androidx.activity.viewModels
|
import androidx.activity.viewModels
|
||||||
import androidx.core.graphics.Insets
|
import androidx.core.graphics.Insets
|
||||||
import androidx.core.view.updatePadding
|
import androidx.core.view.updatePadding
|
||||||
import coil3.ImageLoader
|
import coil.ImageLoader
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import org.koitharu.kotatsu.core.ui.BaseActivity
|
import org.koitharu.kotatsu.core.ui.BaseActivity
|
||||||
import org.koitharu.kotatsu.core.ui.BaseListAdapter
|
import org.koitharu.kotatsu.core.ui.BaseListAdapter
|
||||||
@@ -32,7 +32,6 @@ class TrackerDebugActivity : BaseActivity<ActivityTrackerDebugBinding>(), OnList
|
|||||||
val tracksAdapter = BaseListAdapter<TrackDebugItem>()
|
val tracksAdapter = BaseListAdapter<TrackDebugItem>()
|
||||||
.addDelegate(ListItemType.FEED, trackDebugAD(this, coil, this))
|
.addDelegate(ListItemType.FEED, trackDebugAD(this, coil, this))
|
||||||
with(viewBinding.recyclerView) {
|
with(viewBinding.recyclerView) {
|
||||||
setHasFixedSize(true)
|
|
||||||
adapter = tracksAdapter
|
adapter = tracksAdapter
|
||||||
addItemDecoration(TypedListSpacingDecoration(context, false))
|
addItemDecoration(TypedListSpacingDecoration(context, false))
|
||||||
}
|
}
|
||||||
@@ -16,7 +16,7 @@ import javax.inject.Inject
|
|||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class TrackerDebugViewModel @Inject constructor(
|
class TrackerDebugViewModel @Inject constructor(
|
||||||
db: MangaDatabase
|
private val db: MangaDatabase
|
||||||
) : BaseViewModel() {
|
) : BaseViewModel() {
|
||||||
|
|
||||||
val content = db.getTracksDao().observeAll()
|
val content = db.getTracksDao().observeAll()
|
||||||
@@ -31,7 +31,6 @@ class TrackerDebugViewModel @Inject constructor(
|
|||||||
lastCheckTime = it.track.lastCheckTime.toInstantOrNull(),
|
lastCheckTime = it.track.lastCheckTime.toInstantOrNull(),
|
||||||
lastChapterDate = it.track.lastChapterDate.toInstantOrNull(),
|
lastChapterDate = it.track.lastChapterDate.toInstantOrNull(),
|
||||||
lastResult = it.track.lastResult,
|
lastResult = it.track.lastResult,
|
||||||
lastError = it.track.lastError,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="24dp"
|
|
||||||
android:height="24dp"
|
|
||||||
android:viewportWidth="24"
|
|
||||||
android:viewportHeight="24"
|
|
||||||
android:tint="#FFFFFF">
|
|
||||||
<group android:scaleX="0.98150784"
|
|
||||||
android:scaleY="0.98150784"
|
|
||||||
android:translateX="0.22190611"
|
|
||||||
android:translateY="-0.2688478">
|
|
||||||
<path
|
|
||||||
android:fillColor="@android:color/white"
|
|
||||||
android:pathData="M20,8h-2.81c-0.45,-0.78 -1.07,-1.45 -1.82,-1.96L17,4.41 15.59,3l-2.17,2.17C12.96,5.06 12.49,5 12,5c-0.49,0 -0.96,0.06 -1.41,0.17L8.41,3 7,4.41l1.62,1.63C7.88,6.55 7.26,7.22 6.81,8L4,8v2h2.09c-0.05,0.33 -0.09,0.66 -0.09,1v1L4,12v2h2v1c0,0.34 0.04,0.67 0.09,1L4,16v2h2.81c1.04,1.79 2.97,3 5.19,3s4.15,-1.21 5.19,-3L20,18v-2h-2.09c0.05,-0.33 0.09,-0.66 0.09,-1v-1h2v-2h-2v-1c0,-0.34 -0.04,-0.67 -0.09,-1L20,10L20,8zM14,16h-4v-2h4v2zM14,12h-4v-2h4v2z"/>
|
|
||||||
</group>
|
|
||||||
</vector>
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 417 B |
Binary file not shown.
|
Before Width: | Height: | Size: 308 B |
Binary file not shown.
|
Before Width: | Height: | Size: 480 B |
Binary file not shown.
|
Before Width: | Height: | Size: 792 B |
@@ -4,10 +4,9 @@
|
|||||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
xmlns:tools="http://schemas.android.com/tools"
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="72dp"
|
||||||
android:background="@drawable/list_selector"
|
android:background="@drawable/list_selector"
|
||||||
android:clipChildren="false"
|
android:clipChildren="false">
|
||||||
android:minHeight="72dp">
|
|
||||||
|
|
||||||
<com.google.android.material.imageview.ShapeableImageView
|
<com.google.android.material.imageview.ShapeableImageView
|
||||||
android:id="@+id/imageView_cover"
|
android:id="@+id/imageView_cover"
|
||||||
@@ -15,7 +14,9 @@
|
|||||||
android:layout_height="40dp"
|
android:layout_height="40dp"
|
||||||
android:layout_marginStart="8dp"
|
android:layout_marginStart="8dp"
|
||||||
android:layout_marginTop="16dp"
|
android:layout_marginTop="16dp"
|
||||||
|
android:layout_marginBottom="16dp"
|
||||||
android:scaleType="centerCrop"
|
android:scaleType="centerCrop"
|
||||||
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
app:layout_constraintTop_toTopOf="parent"
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
app:shapeAppearanceOverlay="@style/ShapeAppearanceOverlay.Kotatsu.Cover.Small"
|
app:shapeAppearanceOverlay="@style/ShapeAppearanceOverlay.Kotatsu.Cover.Small"
|
||||||
@@ -31,6 +32,7 @@
|
|||||||
android:ellipsize="end"
|
android:ellipsize="end"
|
||||||
android:maxLines="1"
|
android:maxLines="1"
|
||||||
android:textAppearance="?attr/textAppearanceTitleSmall"
|
android:textAppearance="?attr/textAppearanceTitleSmall"
|
||||||
|
app:layout_constraintBottom_toTopOf="@+id/textView_summary"
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
app:layout_constraintStart_toEndOf="@+id/imageView_cover"
|
app:layout_constraintStart_toEndOf="@+id/imageView_cover"
|
||||||
app:layout_constraintTop_toTopOf="@+id/imageView_cover"
|
app:layout_constraintTop_toTopOf="@+id/imageView_cover"
|
||||||
@@ -41,14 +43,14 @@
|
|||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginStart="16dp"
|
android:layout_marginStart="16dp"
|
||||||
android:layout_marginTop="2dp"
|
|
||||||
android:layout_marginEnd="8dp"
|
android:layout_marginEnd="8dp"
|
||||||
android:ellipsize="end"
|
android:ellipsize="end"
|
||||||
android:paddingBottom="16dp"
|
android:maxLines="1"
|
||||||
android:textAppearance="?attr/textAppearanceBodySmall"
|
android:textAppearance="?attr/textAppearanceBodySmall"
|
||||||
|
app:layout_constraintBottom_toBottomOf="@+id/imageView_cover"
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
app:layout_constraintStart_toEndOf="@+id/imageView_cover"
|
app:layout_constraintStart_toEndOf="@+id/imageView_cover"
|
||||||
app:layout_constraintTop_toBottomOf="@+id/textView_title"
|
app:layout_constraintTop_toBottomOf="@+id/textView_title"
|
||||||
tools:text="@tools:sample/lorem[2]" />
|
tools:text="@tools:sample/lorem/random" />
|
||||||
|
|
||||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
@@ -8,9 +8,14 @@
|
|||||||
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
|
<item
|
||||||
android:id="@id/action_works"
|
android:id="@id/action_works"
|
||||||
android:title="@string/wi_lib_name"
|
android:title="Works"
|
||||||
app:showAsAction="never" />
|
app:showAsAction="never" />
|
||||||
|
|
||||||
</menu>
|
</menu>
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<string name="app_name" translatable="false">Kotatsu Dev</string>
|
<string name="app_name" translatable="false">Kotatsu Dev</string>
|
||||||
<string name="strict_mode">Strict mode</string>
|
</resources>
|
||||||
</resources>
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -12,23 +12,19 @@ import org.koitharu.kotatsu.core.util.ext.almostEquals
|
|||||||
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
|
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaParserSource
|
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
|
||||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
private const val MAX_PARALLELISM = 4
|
private const val MAX_PARALLELISM = 4
|
||||||
private const val MATCH_THRESHOLD_DEFAULT = 0.2f
|
private const val MATCH_THRESHOLD = 0.2f
|
||||||
|
|
||||||
class AlternativesUseCase @Inject constructor(
|
class AlternativesUseCase @Inject constructor(
|
||||||
private val sourcesRepository: MangaSourcesRepository,
|
private val sourcesRepository: MangaSourcesRepository,
|
||||||
private val mangaRepositoryFactory: MangaRepository.Factory,
|
private val mangaRepositoryFactory: MangaRepository.Factory,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
suspend operator fun invoke(manga: Manga): Flow<Manga> = invoke(manga, MATCH_THRESHOLD_DEFAULT)
|
suspend operator fun invoke(manga: Manga): Flow<Manga> {
|
||||||
|
|
||||||
suspend operator fun invoke(manga: Manga, matchThreshold: Float): Flow<Manga> {
|
|
||||||
val sources = getSources(manga.source)
|
val sources = getSources(manga.source)
|
||||||
if (sources.isEmpty()) {
|
if (sources.isEmpty()) {
|
||||||
return emptyFlow()
|
return emptyFlow()
|
||||||
@@ -37,17 +33,17 @@ class AlternativesUseCase @Inject constructor(
|
|||||||
return channelFlow {
|
return channelFlow {
|
||||||
for (source in sources) {
|
for (source in sources) {
|
||||||
val repository = mangaRepositoryFactory.create(source)
|
val repository = mangaRepositoryFactory.create(source)
|
||||||
if (!repository.filterCapabilities.isSearchSupported) {
|
if (!repository.isSearchSupported) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
launch {
|
launch {
|
||||||
val list = runCatchingCancellable {
|
val list = runCatchingCancellable {
|
||||||
semaphore.withPermit {
|
semaphore.withPermit {
|
||||||
repository.getList(offset = 0, SortOrder.RELEVANCE, MangaListFilter(query = manga.title))
|
repository.getList(offset = 0, filter = MangaListFilter.Search(manga.title))
|
||||||
}
|
}
|
||||||
}.getOrDefault(emptyList())
|
}.getOrDefault(emptyList())
|
||||||
for (item in list) {
|
for (item in list) {
|
||||||
if (item.matches(manga, matchThreshold)) {
|
if (item.matches(manga)) {
|
||||||
send(item)
|
send(item)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -61,31 +57,29 @@ class AlternativesUseCase @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun getSources(ref: MangaSource): List<MangaSource> {
|
private suspend fun getSources(ref: MangaSource): List<MangaSource> {
|
||||||
val result = ArrayList<MangaSource>(MangaParserSource.entries.size - 2)
|
val result = ArrayList<MangaSource>(MangaSource.entries.size - 2)
|
||||||
result.addAll(sourcesRepository.getEnabledSources())
|
result.addAll(sourcesRepository.getEnabledSources())
|
||||||
result.sortByDescending { it.priority(ref) }
|
result.sortByDescending { it.priority(ref) }
|
||||||
result.addAll(sourcesRepository.getDisabledSources().sortedByDescending { it.priority(ref) })
|
result.addAll(sourcesRepository.getDisabledSources().sortedByDescending { it.priority(ref) })
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun Manga.matches(ref: Manga, threshold: Float): Boolean {
|
private fun Manga.matches(ref: Manga): Boolean {
|
||||||
return matchesTitles(title, ref.title, threshold) ||
|
return matchesTitles(title, ref.title) ||
|
||||||
matchesTitles(title, ref.altTitle, threshold) ||
|
matchesTitles(title, ref.altTitle) ||
|
||||||
matchesTitles(altTitle, ref.title, threshold) ||
|
matchesTitles(altTitle, ref.title) ||
|
||||||
matchesTitles(altTitle, ref.altTitle, threshold)
|
matchesTitles(altTitle, ref.altTitle)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun matchesTitles(a: String?, b: String?, threshold: Float): Boolean {
|
private fun matchesTitles(a: String?, b: String?): Boolean {
|
||||||
return !a.isNullOrEmpty() && !b.isNullOrEmpty() && a.almostEquals(b, threshold)
|
return !a.isNullOrEmpty() && !b.isNullOrEmpty() && a.almostEquals(b, MATCH_THRESHOLD)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun MangaSource.priority(ref: MangaSource): Int {
|
private fun MangaSource.priority(ref: MangaSource): Int {
|
||||||
var res = 0
|
var res = 0
|
||||||
if (this is MangaParserSource && ref is MangaParserSource) {
|
if (locale == ref.locale) res += 2
|
||||||
if (locale == ref.locale) res += 2
|
if (contentType == ref.contentType) res++
|
||||||
if (contentType == ref.contentType) res++
|
|
||||||
}
|
|
||||||
return res
|
return res
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,93 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.alternatives.domain
|
|
||||||
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
|
||||||
import kotlinx.coroutines.flow.catch
|
|
||||||
import kotlinx.coroutines.flow.channelFlow
|
|
||||||
import kotlinx.coroutines.flow.filter
|
|
||||||
import kotlinx.coroutines.flow.lastOrNull
|
|
||||||
import kotlinx.coroutines.flow.runningFold
|
|
||||||
import kotlinx.coroutines.flow.transformWhile
|
|
||||||
import kotlinx.coroutines.flow.withIndex
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
|
||||||
import org.koitharu.kotatsu.core.model.chaptersCount
|
|
||||||
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
|
|
||||||
import org.koitharu.kotatsu.core.parser.MangaDataRepository
|
|
||||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
|
||||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
|
||||||
import java.util.concurrent.TimeUnit
|
|
||||||
import javax.inject.Inject
|
|
||||||
import kotlin.coroutines.cancellation.CancellationException
|
|
||||||
|
|
||||||
class AutoFixUseCase @Inject constructor(
|
|
||||||
private val mangaRepositoryFactory: MangaRepository.Factory,
|
|
||||||
private val alternativesUseCase: AlternativesUseCase,
|
|
||||||
private val migrateUseCase: MigrateUseCase,
|
|
||||||
private val mangaDataRepository: MangaDataRepository,
|
|
||||||
) {
|
|
||||||
|
|
||||||
suspend operator fun invoke(mangaId: Long): Pair<Manga, Manga?> {
|
|
||||||
val seed = checkNotNull(mangaDataRepository.findMangaById(mangaId)) { "Manga $mangaId not found" }
|
|
||||||
.getDetailsSafe()
|
|
||||||
if (seed.isHealthy()) {
|
|
||||||
return seed to null // no fix required
|
|
||||||
}
|
|
||||||
val replacement = alternativesUseCase(seed, matchThreshold = 0.02f)
|
|
||||||
.filter { it.isHealthy() }
|
|
||||||
.runningFold<Manga, Manga?>(null) { best, candidate ->
|
|
||||||
if (best == null || best < candidate) {
|
|
||||||
candidate
|
|
||||||
} else {
|
|
||||||
best
|
|
||||||
}
|
|
||||||
}.selectLastWithTimeout(4, 40, TimeUnit.SECONDS)
|
|
||||||
migrateUseCase(seed, replacement ?: throw NoAlternativesException(ParcelableManga(seed)))
|
|
||||||
return seed to replacement
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun Manga.isHealthy(): Boolean = runCatchingCancellable {
|
|
||||||
val repo = mangaRepositoryFactory.create(source)
|
|
||||||
val details = if (this.chapters != null) this else repo.getDetails(this)
|
|
||||||
val firstChapter = details.chapters?.firstOrNull() ?: return@runCatchingCancellable false
|
|
||||||
val pageUrl = repo.getPageUrl(repo.getPages(firstChapter).first())
|
|
||||||
pageUrl.toHttpUrlOrNull() != null
|
|
||||||
}.getOrDefault(false)
|
|
||||||
|
|
||||||
private suspend fun Manga.getDetailsSafe() = runCatchingCancellable {
|
|
||||||
mangaRepositoryFactory.create(source).getDetails(this)
|
|
||||||
}.getOrDefault(this)
|
|
||||||
|
|
||||||
private operator fun Manga.compareTo(other: Manga) = chaptersCount().compareTo(other.chaptersCount())
|
|
||||||
|
|
||||||
@Suppress("UNCHECKED_CAST", "OPT_IN_USAGE")
|
|
||||||
private suspend fun <T> Flow<T>.selectLastWithTimeout(
|
|
||||||
minCount: Int,
|
|
||||||
timeout: Long,
|
|
||||||
timeUnit: TimeUnit
|
|
||||||
): T? = channelFlow<T?> {
|
|
||||||
var lastValue: T? = null
|
|
||||||
launch {
|
|
||||||
delay(timeUnit.toMillis(timeout))
|
|
||||||
close(InternalTimeoutException(lastValue))
|
|
||||||
}
|
|
||||||
withIndex().transformWhile { (index, value) ->
|
|
||||||
lastValue = value
|
|
||||||
emit(value)
|
|
||||||
index < minCount && !isClosedForSend
|
|
||||||
}.collect {
|
|
||||||
send(it)
|
|
||||||
}
|
|
||||||
}.catch { e ->
|
|
||||||
if (e is InternalTimeoutException) {
|
|
||||||
emit(e.value as T?)
|
|
||||||
} else {
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
}.lastOrNull()
|
|
||||||
|
|
||||||
class NoAlternativesException(val seed: ParcelableManga) : NoSuchElementException()
|
|
||||||
|
|
||||||
private class InternalTimeoutException(val value: Any?) : CancellationException()
|
|
||||||
}
|
|
||||||
@@ -7,43 +7,34 @@ import org.koitharu.kotatsu.core.parser.MangaDataRepository
|
|||||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||||
import org.koitharu.kotatsu.details.domain.ProgressUpdateUseCase
|
import org.koitharu.kotatsu.details.domain.ProgressUpdateUseCase
|
||||||
import org.koitharu.kotatsu.history.data.HistoryEntity
|
import org.koitharu.kotatsu.history.data.HistoryEntity
|
||||||
|
import org.koitharu.kotatsu.history.data.PROGRESS_NONE
|
||||||
import org.koitharu.kotatsu.history.data.toMangaHistory
|
import org.koitharu.kotatsu.history.data.toMangaHistory
|
||||||
import org.koitharu.kotatsu.list.domain.ReadingProgress.Companion.PROGRESS_NONE
|
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||||
import org.koitharu.kotatsu.scrobbling.common.domain.Scrobbler
|
|
||||||
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingStatus
|
|
||||||
import org.koitharu.kotatsu.tracker.data.TrackEntity
|
import org.koitharu.kotatsu.tracker.data.TrackEntity
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
class MigrateUseCase
|
class MigrateUseCase @Inject constructor(
|
||||||
@Inject
|
|
||||||
constructor(
|
|
||||||
private val mangaRepositoryFactory: MangaRepository.Factory,
|
private val mangaRepositoryFactory: MangaRepository.Factory,
|
||||||
private val mangaDataRepository: MangaDataRepository,
|
private val mangaDataRepository: MangaDataRepository,
|
||||||
private val database: MangaDatabase,
|
private val database: MangaDatabase,
|
||||||
private val progressUpdateUseCase: ProgressUpdateUseCase,
|
private val progressUpdateUseCase: ProgressUpdateUseCase,
|
||||||
private val scrobblers: Set<@JvmSuppressWildcards Scrobbler>,
|
|
||||||
) {
|
) {
|
||||||
suspend operator fun invoke(
|
|
||||||
oldManga: Manga,
|
suspend operator fun invoke(oldManga: Manga, newManga: Manga) {
|
||||||
newManga: Manga,
|
val oldDetails = if (oldManga.chapters.isNullOrEmpty()) {
|
||||||
) {
|
runCatchingCancellable {
|
||||||
val oldDetails =
|
mangaRepositoryFactory.create(oldManga.source).getDetails(oldManga)
|
||||||
if (oldManga.chapters.isNullOrEmpty()) {
|
}.getOrDefault(oldManga)
|
||||||
runCatchingCancellable {
|
} else {
|
||||||
mangaRepositoryFactory.create(oldManga.source).getDetails(oldManga)
|
oldManga
|
||||||
}.getOrDefault(oldManga)
|
}
|
||||||
} else {
|
val newDetails = if (newManga.chapters.isNullOrEmpty()) {
|
||||||
oldManga
|
mangaRepositoryFactory.create(newManga.source).getDetails(newManga)
|
||||||
}
|
} else {
|
||||||
val newDetails =
|
newManga
|
||||||
if (newManga.chapters.isNullOrEmpty()) {
|
}
|
||||||
mangaRepositoryFactory.create(newManga.source).getDetails(newManga)
|
|
||||||
} else {
|
|
||||||
newManga
|
|
||||||
}
|
|
||||||
mangaDataRepository.storeManga(newDetails)
|
mangaDataRepository.storeManga(newDetails)
|
||||||
database.withTransaction {
|
database.withTransaction {
|
||||||
// replace favorites
|
// replace favorites
|
||||||
@@ -52,69 +43,36 @@ constructor(
|
|||||||
if (oldFavourites.isNotEmpty()) {
|
if (oldFavourites.isNotEmpty()) {
|
||||||
favoritesDao.delete(oldManga.id)
|
favoritesDao.delete(oldManga.id)
|
||||||
for (f in oldFavourites) {
|
for (f in oldFavourites) {
|
||||||
val e =
|
val e = f.copy(
|
||||||
f.copy(
|
mangaId = newManga.id,
|
||||||
mangaId = newManga.id,
|
)
|
||||||
)
|
|
||||||
favoritesDao.upsert(e)
|
favoritesDao.upsert(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// replace history
|
// replace history
|
||||||
val historyDao = database.getHistoryDao()
|
val historyDao = database.getHistoryDao()
|
||||||
val oldHistory = historyDao.find(oldDetails.id)
|
val oldHistory = historyDao.find(oldDetails.id)
|
||||||
val newHistory =
|
if (oldHistory != null) {
|
||||||
if (oldHistory != null) {
|
val newHistory = makeNewHistory(oldDetails, newDetails, oldHistory)
|
||||||
val newHistory = makeNewHistory(oldDetails, newDetails, oldHistory)
|
historyDao.delete(oldDetails.id)
|
||||||
historyDao.delete(oldDetails.id)
|
historyDao.upsert(newHistory)
|
||||||
historyDao.upsert(newHistory)
|
}
|
||||||
newHistory
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
// track
|
// track
|
||||||
val tracksDao = database.getTracksDao()
|
val tracksDao = database.getTracksDao()
|
||||||
val oldTrack = tracksDao.find(oldDetails.id)
|
val oldTrack = tracksDao.find(oldDetails.id)
|
||||||
if (oldTrack != null) {
|
if (oldTrack != null) {
|
||||||
val lastChapter = newDetails.chapters?.lastOrNull()
|
val lastChapter = newDetails.chapters?.lastOrNull()
|
||||||
val newTrack =
|
val newTrack = TrackEntity(
|
||||||
TrackEntity(
|
mangaId = newDetails.id,
|
||||||
mangaId = newDetails.id,
|
lastChapterId = lastChapter?.id ?: 0L,
|
||||||
lastChapterId = lastChapter?.id ?: 0L,
|
newChapters = 0,
|
||||||
newChapters = 0,
|
lastCheckTime = System.currentTimeMillis(),
|
||||||
lastCheckTime = System.currentTimeMillis(),
|
lastChapterDate = lastChapter?.uploadDate ?: 0L,
|
||||||
lastChapterDate = lastChapter?.uploadDate ?: 0L,
|
lastResult = TrackEntity.RESULT_EXTERNAL_MODIFICATION,
|
||||||
lastResult = TrackEntity.RESULT_EXTERNAL_MODIFICATION,
|
)
|
||||||
lastError = null,
|
|
||||||
)
|
|
||||||
tracksDao.delete(oldDetails.id)
|
tracksDao.delete(oldDetails.id)
|
||||||
tracksDao.upsert(newTrack)
|
tracksDao.upsert(newTrack)
|
||||||
}
|
}
|
||||||
// scrobbling
|
|
||||||
for (scrobbler in scrobblers) {
|
|
||||||
if (!scrobbler.isEnabled) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
val prevInfo = scrobbler.getScrobblingInfoOrNull(oldDetails.id) ?: continue
|
|
||||||
scrobbler.unregisterScrobbling(oldDetails.id)
|
|
||||||
scrobbler.linkManga(newDetails.id, prevInfo.targetId)
|
|
||||||
scrobbler.updateScrobblingInfo(
|
|
||||||
mangaId = newDetails.id,
|
|
||||||
rating = prevInfo.rating,
|
|
||||||
status =
|
|
||||||
prevInfo.status ?: when {
|
|
||||||
newHistory == null -> ScrobblingStatus.PLANNED
|
|
||||||
newHistory.percent == 1f -> ScrobblingStatus.COMPLETED
|
|
||||||
else -> ScrobblingStatus.READING
|
|
||||||
},
|
|
||||||
comment = prevInfo.comment,
|
|
||||||
)
|
|
||||||
if (newHistory != null) {
|
|
||||||
scrobbler.scrobble(
|
|
||||||
manga = newDetails,
|
|
||||||
chapterId = newHistory.chapterId,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
progressUpdateUseCase(newManga)
|
progressUpdateUseCase(newManga)
|
||||||
}
|
}
|
||||||
@@ -127,53 +85,48 @@ constructor(
|
|||||||
if (oldManga.chapters.isNullOrEmpty()) { // probably broken manga/source
|
if (oldManga.chapters.isNullOrEmpty()) { // probably broken manga/source
|
||||||
val branch = newManga.getPreferredBranch(null)
|
val branch = newManga.getPreferredBranch(null)
|
||||||
val chapters = checkNotNull(newManga.getChapters(branch))
|
val chapters = checkNotNull(newManga.getChapters(branch))
|
||||||
val currentChapter =
|
val currentChapter = if (history.percent in 0f..1f) {
|
||||||
if (history.percent in 0f..1f) {
|
chapters[(chapters.lastIndex * history.percent).toInt()]
|
||||||
chapters[(chapters.lastIndex * history.percent).toInt()]
|
} else {
|
||||||
} else {
|
chapters.first()
|
||||||
chapters.first()
|
}
|
||||||
}
|
|
||||||
return HistoryEntity(
|
return HistoryEntity(
|
||||||
mangaId = newManga.id,
|
mangaId = newManga.id,
|
||||||
createdAt = history.createdAt,
|
createdAt = history.createdAt,
|
||||||
updatedAt = history.updatedAt,
|
updatedAt = System.currentTimeMillis(),
|
||||||
chapterId = currentChapter.id,
|
chapterId = currentChapter.id,
|
||||||
page = history.page,
|
page = history.page,
|
||||||
scroll = history.scroll,
|
scroll = history.scroll,
|
||||||
percent = history.percent,
|
percent = history.percent,
|
||||||
deletedAt = 0,
|
deletedAt = 0,
|
||||||
chaptersCount = chapters.count { it.branch == currentChapter.branch },
|
chaptersCount = chapters.size,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val branch = oldManga.getPreferredBranch(history.toMangaHistory())
|
val branch = oldManga.getPreferredBranch(history.toMangaHistory())
|
||||||
val oldChapters = checkNotNull(oldManga.getChapters(branch))
|
val oldChapters = checkNotNull(oldManga.getChapters(branch))
|
||||||
var index = oldChapters.indexOfFirst { it.id == history.chapterId }
|
var index = oldChapters.indexOfFirst { it.id == history.chapterId }
|
||||||
if (index < 0) {
|
if (index < 0) {
|
||||||
index =
|
index = if (history.percent in 0f..1f) {
|
||||||
if (history.percent in 0f..1f) {
|
(oldChapters.lastIndex * history.percent).toInt()
|
||||||
(oldChapters.lastIndex * history.percent).toInt()
|
} else {
|
||||||
} else {
|
0
|
||||||
0
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
val newChapters = checkNotNull(newManga.chapters).groupBy { it.branch }
|
val newChapters = checkNotNull(newManga.chapters).groupBy { it.branch }
|
||||||
val newBranch =
|
val newBranch = if (newChapters.containsKey(branch)) {
|
||||||
if (newChapters.containsKey(branch)) {
|
branch
|
||||||
branch
|
} else {
|
||||||
} else {
|
newManga.getPreferredBranch(null)
|
||||||
newManga.getPreferredBranch(null)
|
}
|
||||||
}
|
val newChapterId = checkNotNull(newChapters[newBranch]).let {
|
||||||
val newChapterId =
|
val oldChapter = oldChapters[index]
|
||||||
checkNotNull(newChapters[newBranch])
|
it.findByNumber(oldChapter.volume, oldChapter.number) ?: it.getOrNull(index) ?: it.last()
|
||||||
.let {
|
}.id
|
||||||
val oldChapter = oldChapters[index]
|
|
||||||
it.findByNumber(oldChapter.volume, oldChapter.number) ?: it.getOrNull(index) ?: it.last()
|
|
||||||
}.id
|
|
||||||
|
|
||||||
return HistoryEntity(
|
return HistoryEntity(
|
||||||
mangaId = newManga.id,
|
mangaId = newManga.id,
|
||||||
createdAt = history.createdAt,
|
createdAt = history.createdAt,
|
||||||
updatedAt = history.updatedAt,
|
updatedAt = System.currentTimeMillis(),
|
||||||
chapterId = newChapterId,
|
chapterId = newChapterId,
|
||||||
page = history.page,
|
page = history.page,
|
||||||
scroll = history.scroll,
|
scroll = history.scroll,
|
||||||
@@ -183,13 +136,11 @@ constructor(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun List<MangaChapter>.findByNumber(
|
private fun List<MangaChapter>.findByNumber(volume: Int, number: Float): MangaChapter? {
|
||||||
volume: Int,
|
return if (number <= 0f) {
|
||||||
number: Float,
|
|
||||||
): MangaChapter? =
|
|
||||||
if (number <= 0f) {
|
|
||||||
null
|
null
|
||||||
} else {
|
} else {
|
||||||
firstOrNull { it.volume == volume && it.number == number }
|
firstOrNull { it.volume == volume && it.number == number }
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,19 +5,11 @@ import androidx.core.content.ContextCompat
|
|||||||
import androidx.core.text.buildSpannedString
|
import androidx.core.text.buildSpannedString
|
||||||
import androidx.core.text.inSpans
|
import androidx.core.text.inSpans
|
||||||
import androidx.lifecycle.LifecycleOwner
|
import androidx.lifecycle.LifecycleOwner
|
||||||
import coil3.ImageLoader
|
import coil.ImageLoader
|
||||||
import coil3.request.ImageRequest
|
import coil.request.ImageRequest
|
||||||
import coil3.request.allowRgb565
|
import coil.transform.CircleCropTransformation
|
||||||
import coil3.request.crossfade
|
|
||||||
import coil3.request.error
|
|
||||||
import coil3.request.fallback
|
|
||||||
import coil3.request.lifecycle
|
|
||||||
import coil3.request.placeholder
|
|
||||||
import coil3.request.transformations
|
|
||||||
import coil3.transform.RoundedCornersTransformation
|
|
||||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.core.model.getTitle
|
|
||||||
import org.koitharu.kotatsu.core.parser.favicon.faviconUri
|
import org.koitharu.kotatsu.core.parser.favicon.faviconUri
|
||||||
import org.koitharu.kotatsu.core.ui.image.ChipIconTarget
|
import org.koitharu.kotatsu.core.ui.image.ChipIconTarget
|
||||||
import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver
|
import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver
|
||||||
@@ -26,9 +18,8 @@ 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.defaultPlaceholders
|
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.mangaExtra
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.mangaSourceExtra
|
|
||||||
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.databinding.ItemMangaAlternativeBinding
|
import org.koitharu.kotatsu.databinding.ItemMangaAlternativeBinding
|
||||||
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
|
||||||
@@ -70,9 +61,9 @@ fun alternativeAD(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
binding.progressView.setProgress(item.progress, ListModelDiffCallback.PAYLOAD_PROGRESS_CHANGED in payloads)
|
binding.progressView.setPercent(item.progress, ListModelDiffCallback.PAYLOAD_PROGRESS_CHANGED in payloads)
|
||||||
binding.chipSource.also { chip ->
|
binding.chipSource.also { chip ->
|
||||||
chip.text = item.manga.source.getTitle(chip.context)
|
chip.text = item.manga.source.title
|
||||||
ImageRequest.Builder(context)
|
ImageRequest.Builder(context)
|
||||||
.data(item.manga.source.faviconUri())
|
.data(item.manga.source.faviconUri())
|
||||||
.lifecycle(lifecycleOwner)
|
.lifecycle(lifecycleOwner)
|
||||||
@@ -82,8 +73,8 @@ fun alternativeAD(
|
|||||||
.placeholder(R.drawable.ic_web)
|
.placeholder(R.drawable.ic_web)
|
||||||
.fallback(R.drawable.ic_web)
|
.fallback(R.drawable.ic_web)
|
||||||
.error(R.drawable.ic_web)
|
.error(R.drawable.ic_web)
|
||||||
.mangaSourceExtra(item.manga.source)
|
.source(item.manga.source)
|
||||||
.transformations(RoundedCornersTransformation(context.resources.getDimension(R.dimen.chip_icon_corner)))
|
.transformations(CircleCropTransformation())
|
||||||
.allowRgb565(true)
|
.allowRgb565(true)
|
||||||
.enqueueWith(coil)
|
.enqueueWith(coil)
|
||||||
}
|
}
|
||||||
@@ -92,7 +83,8 @@ fun alternativeAD(
|
|||||||
defaultPlaceholders(context)
|
defaultPlaceholders(context)
|
||||||
transformations(TrimTransformation())
|
transformations(TrimTransformation())
|
||||||
allowRgb565(true)
|
allowRgb565(true)
|
||||||
mangaExtra(item.manga)
|
tag(item.manga)
|
||||||
|
source(item.manga.source)
|
||||||
enqueueWith(coil)
|
enqueueWith(coil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,17 +8,17 @@ import android.widget.Toast
|
|||||||
import androidx.activity.viewModels
|
import androidx.activity.viewModels
|
||||||
import androidx.core.graphics.Insets
|
import androidx.core.graphics.Insets
|
||||||
import androidx.core.view.updatePadding
|
import androidx.core.view.updatePadding
|
||||||
import coil3.ImageLoader
|
import coil.ImageLoader
|
||||||
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
|
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
|
||||||
import org.koitharu.kotatsu.core.model.getTitle
|
|
||||||
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
|
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
|
||||||
import org.koitharu.kotatsu.core.parser.MangaIntent
|
import org.koitharu.kotatsu.core.parser.MangaIntent
|
||||||
import org.koitharu.kotatsu.core.ui.BaseActivity
|
import org.koitharu.kotatsu.core.ui.BaseActivity
|
||||||
import org.koitharu.kotatsu.core.ui.BaseListAdapter
|
import org.koitharu.kotatsu.core.ui.BaseListAdapter
|
||||||
import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog
|
|
||||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.DIALOG_THEME_CENTERED
|
||||||
import org.koitharu.kotatsu.core.util.ext.observe
|
import org.koitharu.kotatsu.core.util.ext.observe
|
||||||
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
||||||
import org.koitharu.kotatsu.databinding.ActivityAlternativesBinding
|
import org.koitharu.kotatsu.databinding.ActivityAlternativesBinding
|
||||||
@@ -30,8 +30,7 @@ import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD
|
|||||||
import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD
|
import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD
|
||||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
import org.koitharu.kotatsu.search.ui.SearchActivity
|
||||||
import org.koitharu.kotatsu.search.ui.MangaListActivity
|
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
@@ -82,37 +81,29 @@ class AlternativesActivity : BaseActivity<ActivityAlternativesBinding>(),
|
|||||||
|
|
||||||
override fun onItemClick(item: MangaAlternativeModel, view: View) {
|
override fun onItemClick(item: MangaAlternativeModel, view: View) {
|
||||||
when (view.id) {
|
when (view.id) {
|
||||||
R.id.chip_source -> startActivity(
|
R.id.chip_source -> startActivity(SearchActivity.newIntent(this, item.manga.source, viewModel.manga.title))
|
||||||
MangaListActivity.newIntent(
|
|
||||||
this,
|
|
||||||
item.manga.source,
|
|
||||||
MangaListFilter(query = viewModel.manga.title),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
R.id.button_migrate -> confirmMigration(item.manga)
|
R.id.button_migrate -> confirmMigration(item.manga)
|
||||||
else -> startActivity(DetailsActivity.newIntent(this, item.manga))
|
else -> startActivity(DetailsActivity.newIntent(this, item.manga))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun confirmMigration(target: Manga) {
|
private fun confirmMigration(target: Manga) {
|
||||||
buildAlertDialog(this, isCentered = true) {
|
MaterialAlertDialogBuilder(this, DIALOG_THEME_CENTERED)
|
||||||
setIcon(R.drawable.ic_replace)
|
.setIcon(R.drawable.ic_replace)
|
||||||
setTitle(R.string.manga_migration)
|
.setTitle(R.string.manga_migration)
|
||||||
setMessage(
|
.setMessage(
|
||||||
getString(
|
getString(
|
||||||
R.string.migrate_confirmation,
|
R.string.migrate_confirmation,
|
||||||
viewModel.manga.title,
|
viewModel.manga.title,
|
||||||
viewModel.manga.source.getTitle(context),
|
viewModel.manga.source.title,
|
||||||
target.title,
|
target.title,
|
||||||
target.source.getTitle(context),
|
target.source.title,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
setNegativeButton(android.R.string.cancel, null)
|
.setNegativeButton(android.R.string.cancel, null)
|
||||||
setPositiveButton(R.string.migrate) { _, _ ->
|
.setPositiveButton(R.string.migrate) { _, _ ->
|
||||||
viewModel.migrate(target)
|
viewModel.migrate(target)
|
||||||
}
|
}.show()
|
||||||
}.show()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|||||||
@@ -15,13 +15,11 @@ import org.koitharu.kotatsu.core.model.chaptersCount
|
|||||||
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
|
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
|
||||||
import org.koitharu.kotatsu.core.parser.MangaIntent
|
import org.koitharu.kotatsu.core.parser.MangaIntent
|
||||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
|
||||||
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
||||||
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
|
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
|
||||||
import org.koitharu.kotatsu.core.util.ext.call
|
import org.koitharu.kotatsu.core.util.ext.call
|
||||||
import org.koitharu.kotatsu.core.util.ext.require
|
import org.koitharu.kotatsu.core.util.ext.require
|
||||||
import org.koitharu.kotatsu.history.data.HistoryRepository
|
import org.koitharu.kotatsu.list.domain.ListExtraProvider
|
||||||
import org.koitharu.kotatsu.list.domain.ReadingProgress
|
|
||||||
import org.koitharu.kotatsu.list.ui.model.EmptyState
|
import org.koitharu.kotatsu.list.ui.model.EmptyState
|
||||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||||
import org.koitharu.kotatsu.list.ui.model.LoadingFooter
|
import org.koitharu.kotatsu.list.ui.model.LoadingFooter
|
||||||
@@ -36,8 +34,7 @@ class AlternativesViewModel @Inject constructor(
|
|||||||
private val mangaRepositoryFactory: MangaRepository.Factory,
|
private val mangaRepositoryFactory: MangaRepository.Factory,
|
||||||
private val alternativesUseCase: AlternativesUseCase,
|
private val alternativesUseCase: AlternativesUseCase,
|
||||||
private val migrateUseCase: MigrateUseCase,
|
private val migrateUseCase: MigrateUseCase,
|
||||||
private val historyRepository: HistoryRepository,
|
private val extraProvider: ListExtraProvider,
|
||||||
private val settings: AppSettings,
|
|
||||||
) : BaseViewModel() {
|
) : BaseViewModel() {
|
||||||
|
|
||||||
val manga = savedStateHandle.require<ParcelableManga>(MangaIntent.KEY_MANGA).manga
|
val manga = savedStateHandle.require<ParcelableManga>(MangaIntent.KEY_MANGA).manga
|
||||||
@@ -56,7 +53,7 @@ class AlternativesViewModel @Inject constructor(
|
|||||||
.map {
|
.map {
|
||||||
MangaAlternativeModel(
|
MangaAlternativeModel(
|
||||||
manga = it,
|
manga = it,
|
||||||
progress = getProgress(it.id),
|
progress = extraProvider.getProgress(it.id),
|
||||||
referenceChapters = refCount,
|
referenceChapters = refCount,
|
||||||
)
|
)
|
||||||
}.runningFold<MangaAlternativeModel, List<ListModel>>(listOf(LoadingState)) { acc, item ->
|
}.runningFold<MangaAlternativeModel, List<ListModel>>(listOf(LoadingState)) { acc, item ->
|
||||||
@@ -89,7 +86,13 @@ class AlternativesViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun getProgress(mangaId: Long): ReadingProgress? {
|
private suspend fun mapList(list: List<Manga>, refCount: Int): List<MangaAlternativeModel> {
|
||||||
return historyRepository.getProgress(mangaId, settings.progressIndicatorMode)
|
return list.map {
|
||||||
|
MangaAlternativeModel(
|
||||||
|
manga = it,
|
||||||
|
progress = extraProvider.getProgress(it.id),
|
||||||
|
referenceChapters = refCount,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,193 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.alternatives.ui
|
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import android.app.Notification
|
|
||||||
import android.app.PendingIntent
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.content.pm.ServiceInfo
|
|
||||||
import androidx.core.app.NotificationChannelCompat
|
|
||||||
import androidx.core.app.NotificationCompat
|
|
||||||
import androidx.core.app.NotificationManagerCompat
|
|
||||||
import androidx.core.app.PendingIntentCompat
|
|
||||||
import androidx.core.app.ServiceCompat
|
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
import coil3.ImageLoader
|
|
||||||
import coil3.request.ImageRequest
|
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
|
||||||
import kotlinx.coroutines.runBlocking
|
|
||||||
import org.koitharu.kotatsu.R
|
|
||||||
import org.koitharu.kotatsu.alternatives.domain.AutoFixUseCase
|
|
||||||
import org.koitharu.kotatsu.core.ErrorReporterReceiver
|
|
||||||
import org.koitharu.kotatsu.core.model.getTitle
|
|
||||||
import org.koitharu.kotatsu.core.ui.CoroutineIntentService
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.mangaSourceExtra
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.toBitmapOrNull
|
|
||||||
import org.koitharu.kotatsu.details.ui.DetailsActivity
|
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
|
||||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
|
||||||
import javax.inject.Inject
|
|
||||||
import com.google.android.material.R as materialR
|
|
||||||
|
|
||||||
@AndroidEntryPoint
|
|
||||||
class AutoFixService : CoroutineIntentService() {
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
lateinit var autoFixUseCase: AutoFixUseCase
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
lateinit var coil: ImageLoader
|
|
||||||
|
|
||||||
private lateinit var notificationManager: NotificationManagerCompat
|
|
||||||
|
|
||||||
override fun onCreate() {
|
|
||||||
super.onCreate()
|
|
||||||
notificationManager = NotificationManagerCompat.from(applicationContext)
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun IntentJobContext.processIntent(intent: Intent) {
|
|
||||||
val ids = requireNotNull(intent.getLongArrayExtra(DATA_IDS))
|
|
||||||
startForeground(this)
|
|
||||||
for (mangaId in ids) {
|
|
||||||
val result = runCatchingCancellable {
|
|
||||||
autoFixUseCase.invoke(mangaId)
|
|
||||||
}
|
|
||||||
if (applicationContext.checkNotificationPermission(CHANNEL_ID)) {
|
|
||||||
val notification = buildNotification(result)
|
|
||||||
notificationManager.notify(TAG, startId, notification)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun IntentJobContext.onError(error: Throwable) {
|
|
||||||
if (applicationContext.checkNotificationPermission(CHANNEL_ID)) {
|
|
||||||
val notification = runBlocking { buildNotification(Result.failure(error)) }
|
|
||||||
notificationManager.notify(TAG, startId, notification)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@SuppressLint("InlinedApi")
|
|
||||||
private fun startForeground(jobContext: IntentJobContext) {
|
|
||||||
val title = applicationContext.getString(R.string.fixing_manga)
|
|
||||||
val channel = NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_MIN)
|
|
||||||
.setName(title)
|
|
||||||
.setShowBadge(false)
|
|
||||||
.setVibrationEnabled(false)
|
|
||||||
.setSound(null, null)
|
|
||||||
.setLightsEnabled(false)
|
|
||||||
.build()
|
|
||||||
notificationManager.createNotificationChannel(channel)
|
|
||||||
|
|
||||||
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
|
|
||||||
.setContentTitle(title)
|
|
||||||
.setPriority(NotificationCompat.PRIORITY_MIN)
|
|
||||||
.setDefaults(0)
|
|
||||||
.setSilent(true)
|
|
||||||
.setOngoing(true)
|
|
||||||
.setProgress(0, 0, true)
|
|
||||||
.setSmallIcon(R.drawable.ic_stat_auto_fix)
|
|
||||||
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
|
|
||||||
.setCategory(NotificationCompat.CATEGORY_PROGRESS)
|
|
||||||
.addAction(
|
|
||||||
materialR.drawable.material_ic_clear_black_24dp,
|
|
||||||
applicationContext.getString(android.R.string.cancel),
|
|
||||||
jobContext.getCancelIntent(),
|
|
||||||
)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
jobContext.setForeground(
|
|
||||||
FOREGROUND_NOTIFICATION_ID,
|
|
||||||
notification,
|
|
||||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun buildNotification(result: Result<Pair<Manga, Manga?>>): Notification {
|
|
||||||
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
|
|
||||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
|
||||||
.setDefaults(0)
|
|
||||||
.setSilent(true)
|
|
||||||
.setAutoCancel(true)
|
|
||||||
result.onSuccess { (seed, replacement) ->
|
|
||||||
if (replacement != null) {
|
|
||||||
notification.setLargeIcon(
|
|
||||||
coil.execute(
|
|
||||||
ImageRequest.Builder(applicationContext)
|
|
||||||
.data(replacement.coverUrl)
|
|
||||||
.mangaSourceExtra(replacement.source)
|
|
||||||
.build(),
|
|
||||||
).toBitmapOrNull(),
|
|
||||||
)
|
|
||||||
notification.setSubText(replacement.title)
|
|
||||||
val intent = DetailsActivity.newIntent(applicationContext, replacement)
|
|
||||||
notification.setContentIntent(
|
|
||||||
PendingIntentCompat.getActivity(
|
|
||||||
applicationContext,
|
|
||||||
replacement.id.toInt(),
|
|
||||||
intent,
|
|
||||||
PendingIntent.FLAG_UPDATE_CURRENT,
|
|
||||||
false,
|
|
||||||
),
|
|
||||||
).setVisibility(
|
|
||||||
if (replacement.isNsfw) NotificationCompat.VISIBILITY_SECRET else NotificationCompat.VISIBILITY_PUBLIC,
|
|
||||||
)
|
|
||||||
notification
|
|
||||||
.setContentTitle(applicationContext.getString(R.string.fixed))
|
|
||||||
.setContentText(
|
|
||||||
applicationContext.getString(
|
|
||||||
R.string.manga_replaced,
|
|
||||||
seed.title,
|
|
||||||
seed.source.getTitle(applicationContext),
|
|
||||||
replacement.title,
|
|
||||||
replacement.source.getTitle(applicationContext),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.setSmallIcon(R.drawable.ic_stat_done)
|
|
||||||
} else {
|
|
||||||
notification
|
|
||||||
.setContentTitle(applicationContext.getString(R.string.fixing_manga))
|
|
||||||
.setContentText(applicationContext.getString(R.string.no_fix_required, seed.title))
|
|
||||||
.setSmallIcon(android.R.drawable.stat_sys_warning)
|
|
||||||
}
|
|
||||||
}.onFailure { error ->
|
|
||||||
notification
|
|
||||||
.setContentTitle(applicationContext.getString(R.string.error_occurred))
|
|
||||||
.setContentText(
|
|
||||||
if (error is AutoFixUseCase.NoAlternativesException) {
|
|
||||||
applicationContext.getString(R.string.no_alternatives_found, error.seed.manga.title)
|
|
||||||
} else {
|
|
||||||
error.getDisplayMessage(applicationContext.resources)
|
|
||||||
},
|
|
||||||
).setSmallIcon(android.R.drawable.stat_notify_error)
|
|
||||||
ErrorReporterReceiver.getPendingIntent(applicationContext, error)?.let { reportIntent ->
|
|
||||||
notification.addAction(
|
|
||||||
R.drawable.ic_alert_outline,
|
|
||||||
applicationContext.getString(R.string.report),
|
|
||||||
reportIntent,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return notification.build()
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
|
|
||||||
private const val DATA_IDS = "ids"
|
|
||||||
private const val TAG = "auto_fix"
|
|
||||||
private const val CHANNEL_ID = "auto_fix"
|
|
||||||
private const val FOREGROUND_NOTIFICATION_ID = 38
|
|
||||||
|
|
||||||
fun start(context: Context, mangaIds: Collection<Long>): Boolean = try {
|
|
||||||
val intent = Intent(context, AutoFixService::class.java)
|
|
||||||
intent.putExtra(DATA_IDS, mangaIds.toLongArray())
|
|
||||||
ContextCompat.startForegroundService(context, intent)
|
|
||||||
true
|
|
||||||
} catch (e: Exception) {
|
|
||||||
e.printStackTraceDebug()
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,13 +1,12 @@
|
|||||||
package org.koitharu.kotatsu.alternatives.ui
|
package org.koitharu.kotatsu.alternatives.ui
|
||||||
|
|
||||||
import org.koitharu.kotatsu.core.model.chaptersCount
|
import org.koitharu.kotatsu.core.model.chaptersCount
|
||||||
import org.koitharu.kotatsu.list.domain.ReadingProgress
|
|
||||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
|
|
||||||
data class MangaAlternativeModel(
|
data class MangaAlternativeModel(
|
||||||
val manga: Manga,
|
val manga: Manga,
|
||||||
val progress: ReadingProgress?,
|
val progress: Float,
|
||||||
private val referenceChapters: Int,
|
private val referenceChapters: Int,
|
||||||
) : ListModel {
|
) : ListModel {
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ data class Bookmark(
|
|||||||
val percent: Float,
|
val percent: Float,
|
||||||
) : ListModel {
|
) : ListModel {
|
||||||
|
|
||||||
|
val directImageUrl: String?
|
||||||
|
get() = if (isImageUrlDirect()) imageUrl else null
|
||||||
|
|
||||||
val imageLoadData: Any
|
val imageLoadData: Any
|
||||||
get() = if (isImageUrlDirect()) imageUrl else toMangaPage()
|
get() = if (isImageUrlDirect()) imageUrl else toMangaPage()
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package org.koitharu.kotatsu.bookmarks.ui
|
|||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.Menu
|
import android.view.Menu
|
||||||
import android.view.MenuInflater
|
|
||||||
import android.view.MenuItem
|
import android.view.MenuItem
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
@@ -14,7 +13,7 @@ import androidx.core.view.updateLayoutParams
|
|||||||
import androidx.core.view.updatePadding
|
import androidx.core.view.updatePadding
|
||||||
import androidx.fragment.app.viewModels
|
import androidx.fragment.app.viewModels
|
||||||
import androidx.recyclerview.widget.GridLayoutManager
|
import androidx.recyclerview.widget.GridLayoutManager
|
||||||
import coil3.ImageLoader
|
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
|
||||||
@@ -47,7 +46,7 @@ class AllBookmarksFragment :
|
|||||||
BaseFragment<FragmentListSimpleBinding>(),
|
BaseFragment<FragmentListSimpleBinding>(),
|
||||||
ListStateHolderListener,
|
ListStateHolderListener,
|
||||||
OnListItemClickListener<Bookmark>,
|
OnListItemClickListener<Bookmark>,
|
||||||
ListSelectionController.Callback,
|
ListSelectionController.Callback2,
|
||||||
FastScroller.FastScrollListener, ListHeaderClickListener {
|
FastScroller.FastScrollListener, ListHeaderClickListener {
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
@@ -130,11 +129,7 @@ class AllBookmarksFragment :
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onItemLongClick(item: Bookmark, view: View): Boolean {
|
override fun onItemLongClick(item: Bookmark, view: View): Boolean {
|
||||||
return selectionController?.onItemLongClick(view, item.pageId) ?: false
|
return selectionController?.onItemLongClick(item.pageId) ?: false
|
||||||
}
|
|
||||||
|
|
||||||
override fun onItemContextClick(item: Bookmark, view: View): Boolean {
|
|
||||||
return selectionController?.onItemContextClick(view, item.pageId) ?: false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onRetryClick(error: Throwable) = Unit
|
override fun onRetryClick(error: Throwable) = Unit
|
||||||
@@ -153,23 +148,23 @@ class AllBookmarksFragment :
|
|||||||
|
|
||||||
override fun onCreateActionMode(
|
override fun onCreateActionMode(
|
||||||
controller: ListSelectionController,
|
controller: ListSelectionController,
|
||||||
menuInflater: MenuInflater,
|
mode: ActionMode,
|
||||||
menu: Menu,
|
menu: Menu,
|
||||||
): Boolean {
|
): Boolean {
|
||||||
menuInflater.inflate(R.menu.mode_bookmarks, menu)
|
mode.menuInflater.inflate(R.menu.mode_bookmarks, menu)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onActionItemClicked(
|
override fun onActionItemClicked(
|
||||||
controller: ListSelectionController,
|
controller: ListSelectionController,
|
||||||
mode: ActionMode?,
|
mode: ActionMode,
|
||||||
item: MenuItem,
|
item: MenuItem,
|
||||||
): Boolean {
|
): Boolean {
|
||||||
return when (item.itemId) {
|
return when (item.itemId) {
|
||||||
R.id.action_remove -> {
|
R.id.action_remove -> {
|
||||||
val ids = selectionController?.snapshot() ?: return false
|
val ids = selectionController?.snapshot() ?: return false
|
||||||
viewModel.removeBookmarks(ids)
|
viewModel.removeBookmarks(ids)
|
||||||
mode?.finish()
|
mode.finish()
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,17 @@
|
|||||||
package org.koitharu.kotatsu.bookmarks.ui.adapter
|
package org.koitharu.kotatsu.bookmarks.ui.adapter
|
||||||
|
|
||||||
import androidx.lifecycle.LifecycleOwner
|
import androidx.lifecycle.LifecycleOwner
|
||||||
import coil3.ImageLoader
|
import coil.ImageLoader
|
||||||
import coil3.request.allowRgb565
|
|
||||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
||||||
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.bookmarkExtra
|
|
||||||
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.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.databinding.ItemBookmarkLargeBinding
|
import org.koitharu.kotatsu.databinding.ItemBookmarkLargeBinding
|
||||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||||
|
|
||||||
@@ -23,17 +22,21 @@ fun bookmarkLargeAD(
|
|||||||
) = adapterDelegateViewBinding<Bookmark, ListModel, ItemBookmarkLargeBinding>(
|
) = adapterDelegateViewBinding<Bookmark, ListModel, ItemBookmarkLargeBinding>(
|
||||||
{ inflater, parent -> ItemBookmarkLargeBinding.inflate(inflater, parent, false) },
|
{ inflater, parent -> ItemBookmarkLargeBinding.inflate(inflater, parent, false) },
|
||||||
) {
|
) {
|
||||||
AdapterDelegateClickListenerAdapter(this, clickListener).attach(itemView)
|
val listener = AdapterDelegateClickListenerAdapter(this, clickListener)
|
||||||
|
|
||||||
|
binding.root.setOnClickListener(listener)
|
||||||
|
binding.root.setOnLongClickListener(listener)
|
||||||
|
|
||||||
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)
|
defaultPlaceholders(context)
|
||||||
allowRgb565(true)
|
allowRgb565(true)
|
||||||
bookmarkExtra(item)
|
tag(item)
|
||||||
decodeRegion(item.scroll)
|
decodeRegion(item.scroll)
|
||||||
|
source(item.manga.source)
|
||||||
enqueueWith(coil)
|
enqueueWith(coil)
|
||||||
}
|
}
|
||||||
binding.progressView.setProgress(item.percent, false)
|
binding.progressView.percent = item.percent
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,19 @@
|
|||||||
package org.koitharu.kotatsu.bookmarks.ui.adapter
|
package org.koitharu.kotatsu.bookmarks.ui.adapter
|
||||||
|
|
||||||
import androidx.lifecycle.LifecycleOwner
|
import androidx.lifecycle.LifecycleOwner
|
||||||
import coil3.ImageLoader
|
import coil.ImageLoader
|
||||||
import coil3.request.allowRgb565
|
|
||||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
||||||
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.bookmarkExtra
|
|
||||||
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.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.databinding.ItemBookmarkBinding
|
import org.koitharu.kotatsu.databinding.ItemBookmarkBinding
|
||||||
|
|
||||||
// TODO check usages
|
|
||||||
fun bookmarkListAD(
|
fun bookmarkListAD(
|
||||||
coil: ImageLoader,
|
coil: ImageLoader,
|
||||||
lifecycleOwner: LifecycleOwner,
|
lifecycleOwner: LifecycleOwner,
|
||||||
@@ -23,15 +21,19 @@ fun bookmarkListAD(
|
|||||||
) = adapterDelegateViewBinding<Bookmark, Bookmark, ItemBookmarkBinding>(
|
) = adapterDelegateViewBinding<Bookmark, Bookmark, ItemBookmarkBinding>(
|
||||||
{ inflater, parent -> ItemBookmarkBinding.inflate(inflater, parent, false) },
|
{ inflater, parent -> ItemBookmarkBinding.inflate(inflater, parent, false) },
|
||||||
) {
|
) {
|
||||||
AdapterDelegateClickListenerAdapter(this, clickListener).attach(itemView)
|
val listener = AdapterDelegateClickListenerAdapter(this, clickListener)
|
||||||
|
|
||||||
|
binding.root.setOnClickListener(listener)
|
||||||
|
binding.root.setOnLongClickListener(listener)
|
||||||
|
|
||||||
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)
|
defaultPlaceholders(context)
|
||||||
allowRgb565(true)
|
allowRgb565(true)
|
||||||
bookmarkExtra(item)
|
tag(item)
|
||||||
decodeRegion(item.scroll)
|
decodeRegion(item.scroll)
|
||||||
|
source(item.manga.source)
|
||||||
enqueueWith(coil)
|
enqueueWith(coil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ package org.koitharu.kotatsu.bookmarks.ui.adapter
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.lifecycle.LifecycleOwner
|
import androidx.lifecycle.LifecycleOwner
|
||||||
import coil3.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
|
||||||
|
|||||||
@@ -13,12 +13,12 @@ import androidx.core.view.isVisible
|
|||||||
import androidx.core.view.updatePadding
|
import androidx.core.view.updatePadding
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.core.model.MangaSource
|
|
||||||
import org.koitharu.kotatsu.core.network.CommonHeaders
|
import org.koitharu.kotatsu.core.network.CommonHeaders
|
||||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||||
import org.koitharu.kotatsu.core.parser.ParserMangaRepository
|
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.configureForParser
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.getSerializableExtraCompat
|
||||||
import org.koitharu.kotatsu.core.util.ext.toUriOrNull
|
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.model.MangaSource
|
||||||
@@ -42,9 +42,10 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
|
|||||||
setDisplayHomeAsUpEnabled(true)
|
setDisplayHomeAsUpEnabled(true)
|
||||||
setHomeAsUpIndicator(materialR.drawable.abc_ic_clear_material)
|
setHomeAsUpIndicator(materialR.drawable.abc_ic_clear_material)
|
||||||
}
|
}
|
||||||
val mangaSource = MangaSource(intent?.getStringExtra(EXTRA_SOURCE))
|
val userAgent = intent?.getSerializableExtraCompat<MangaSource>(EXTRA_SOURCE)?.let { source ->
|
||||||
val repository = mangaRepositoryFactory.create(mangaSource) as? ParserMangaRepository
|
val repository = mangaRepositoryFactory.create(source) as? RemoteMangaRepository
|
||||||
val userAgent = repository?.getRequestHeaders()?.get(CommonHeaders.USER_AGENT)
|
repository?.headers?.get(CommonHeaders.USER_AGENT)
|
||||||
|
}
|
||||||
viewBinding.webView.configureForParser(userAgent)
|
viewBinding.webView.configureForParser(userAgent)
|
||||||
CookieManager.getInstance().setAcceptThirdPartyCookies(viewBinding.webView, true)
|
CookieManager.getInstance().setAcceptThirdPartyCookies(viewBinding.webView, true)
|
||||||
viewBinding.webView.webViewClient = BrowserClient(this)
|
viewBinding.webView.webViewClient = BrowserClient(this)
|
||||||
@@ -107,10 +108,8 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
|
|||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
if (hasViewBinding()) {
|
viewBinding.webView.stopLoading()
|
||||||
viewBinding.webView.stopLoading()
|
viewBinding.webView.destroy()
|
||||||
viewBinding.webView.destroy()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onLoadingStateChanged(isLoading: Boolean) {
|
override fun onLoadingStateChanged(isLoading: Boolean) {
|
||||||
@@ -146,7 +145,7 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
|
|||||||
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?.name)
|
.putExtra(EXTRA_SOURCE, source)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,27 +9,25 @@ import androidx.core.app.NotificationCompat
|
|||||||
import androidx.core.app.NotificationManagerCompat
|
import androidx.core.app.NotificationManagerCompat
|
||||||
import androidx.core.app.PendingIntentCompat
|
import androidx.core.app.PendingIntentCompat
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import coil3.EventListener
|
import coil.EventListener
|
||||||
import coil3.Extras
|
import coil.request.ErrorResult
|
||||||
import coil3.request.ErrorResult
|
import coil.request.ImageRequest
|
||||||
import coil3.request.ImageRequest
|
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
|
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
|
||||||
import org.koitharu.kotatsu.core.model.getTitle
|
|
||||||
import org.koitharu.kotatsu.core.model.isNsfw
|
|
||||||
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.MangaSource
|
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(CHANNEL_ID)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val manager = NotificationManagerCompat.from(context)
|
val manager = NotificationManagerCompat.from(context)
|
||||||
val channel = NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_LOW)
|
val channel = NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_DEFAULT)
|
||||||
.setName(context.getString(R.string.captcha_required))
|
.setName(context.getString(R.string.captcha_required))
|
||||||
.setShowBadge(true)
|
.setShowBadge(true)
|
||||||
.setVibrationEnabled(false)
|
.setVibrationEnabled(false)
|
||||||
@@ -42,13 +40,13 @@ class CaptchaNotifier(
|
|||||||
.setData(exception.url.toUri())
|
.setData(exception.url.toUri())
|
||||||
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||||
.setContentTitle(channel.name)
|
.setContentTitle(channel.name)
|
||||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||||
.setDefaults(0)
|
.setDefaults(NotificationCompat.DEFAULT_SOUND)
|
||||||
.setSmallIcon(R.drawable.ic_bot)
|
.setSmallIcon(android.R.drawable.stat_notify_error)
|
||||||
.setGroup(GROUP_CAPTCHA)
|
.setGroup(GROUP_CAPTCHA)
|
||||||
.setAutoCancel(true)
|
.setAutoCancel(true)
|
||||||
.setVisibility(
|
.setVisibility(
|
||||||
if (exception.source?.isNsfw() == true) {
|
if (exception.source?.contentType == ContentType.HENTAI) {
|
||||||
NotificationCompat.VISIBILITY_SECRET
|
NotificationCompat.VISIBILITY_SECRET
|
||||||
} else {
|
} else {
|
||||||
NotificationCompat.VISIBILITY_PUBLIC
|
NotificationCompat.VISIBILITY_PUBLIC
|
||||||
@@ -57,7 +55,7 @@ class CaptchaNotifier(
|
|||||||
.setContentText(
|
.setContentText(
|
||||||
context.getString(
|
context.getString(
|
||||||
R.string.captcha_required_summary,
|
R.string.captcha_required_summary,
|
||||||
exception.source?.getTitle(context) ?: context.getString(R.string.app_name),
|
exception.source?.title ?: context.getString(R.string.app_name),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.setContentIntent(PendingIntentCompat.getActivity(context, 0, intent, 0, false))
|
.setContentIntent(PendingIntentCompat.getActivity(context, 0, intent, 0, false))
|
||||||
@@ -85,19 +83,20 @@ class CaptchaNotifier(
|
|||||||
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.extras[ignoreCaptchaKey] != true) {
|
if (e is CloudFlareProtectedException && request.parameters.value<Boolean>(PARAM_IGNORE_CAPTCHA) != true) {
|
||||||
notify(e)
|
notify(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
||||||
fun ImageRequest.Builder.ignoreCaptchaErrors() = apply {
|
fun ImageRequest.Builder.ignoreCaptchaErrors() = setParameter(
|
||||||
extras[ignoreCaptchaKey] = true
|
key = PARAM_IGNORE_CAPTCHA,
|
||||||
}
|
value = true,
|
||||||
|
memoryCacheKey = null,
|
||||||
val ignoreCaptchaKey = Extras.Key(false)
|
)
|
||||||
|
|
||||||
|
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 GROUP_CAPTCHA = "org.koitharu.kotatsu.CAPTCHA"
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package org.koitharu.kotatsu.browser.cloudflare
|
package org.koitharu.kotatsu.browser.cloudflare
|
||||||
|
|
||||||
import android.app.Activity
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
@@ -29,10 +28,10 @@ import org.koitharu.kotatsu.core.model.MangaSource
|
|||||||
import org.koitharu.kotatsu.core.network.CommonHeaders
|
import org.koitharu.kotatsu.core.network.CommonHeaders
|
||||||
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
|
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
|
||||||
import org.koitharu.kotatsu.core.ui.BaseActivity
|
import org.koitharu.kotatsu.core.ui.BaseActivity
|
||||||
|
import org.koitharu.kotatsu.core.util.TaggedActivityResult
|
||||||
import org.koitharu.kotatsu.core.util.ext.configureForParser
|
import org.koitharu.kotatsu.core.util.ext.configureForParser
|
||||||
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
|
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
import org.koitharu.kotatsu.parsers.network.CloudFlareHelper
|
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import com.google.android.material.R as materialR
|
import com.google.android.material.R as materialR
|
||||||
|
|
||||||
@@ -56,11 +55,7 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
|
|||||||
setDisplayHomeAsUpEnabled(true)
|
setDisplayHomeAsUpEnabled(true)
|
||||||
setHomeAsUpIndicator(materialR.drawable.abc_ic_clear_material)
|
setHomeAsUpIndicator(materialR.drawable.abc_ic_clear_material)
|
||||||
}
|
}
|
||||||
val url = intent?.dataString
|
val url = intent?.dataString.orEmpty()
|
||||||
if (url.isNullOrEmpty()) {
|
|
||||||
finishAfterTransition()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
cfClient = CloudFlareClient(cookieJar, this, url)
|
cfClient = CloudFlareClient(cookieJar, this, url)
|
||||||
viewBinding.webView.configureForParser(intent?.getStringExtra(ARG_UA))
|
viewBinding.webView.configureForParser(intent?.getStringExtra(ARG_UA))
|
||||||
viewBinding.webView.webViewClient = cfClient
|
viewBinding.webView.webViewClient = cfClient
|
||||||
@@ -68,7 +63,12 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
|
|||||||
onBackPressedDispatcher.addCallback(it)
|
onBackPressedDispatcher.addCallback(it)
|
||||||
}
|
}
|
||||||
CookieManager.getInstance().setAcceptThirdPartyCookies(viewBinding.webView, true)
|
CookieManager.getInstance().setAcceptThirdPartyCookies(viewBinding.webView, true)
|
||||||
if (savedInstanceState == null) {
|
if (savedInstanceState != null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (url.isEmpty()) {
|
||||||
|
finishAfterTransition()
|
||||||
|
} else {
|
||||||
onTitleChanged(getString(R.string.loading_), url)
|
onTitleChanged(getString(R.string.loading_), url)
|
||||||
viewBinding.webView.loadUrl(url)
|
viewBinding.webView.loadUrl(url)
|
||||||
}
|
}
|
||||||
@@ -176,17 +176,18 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
|
|||||||
|
|
||||||
private suspend fun clearCfCookies(url: HttpUrl) = runInterruptible(Dispatchers.Default) {
|
private suspend fun clearCfCookies(url: HttpUrl) = runInterruptible(Dispatchers.Default) {
|
||||||
cookieJar.removeCookies(url) { cookie ->
|
cookieJar.removeCookies(url) { cookie ->
|
||||||
CloudFlareHelper.isCloudFlareCookie(cookie.name)
|
val name = cookie.name
|
||||||
|
name.startsWith("cf_") || name.startsWith("_cf") || name.startsWith("__cf") || name == "csrftoken"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class Contract : ActivityResultContract<CloudFlareProtectedException, Boolean>() {
|
class Contract : ActivityResultContract<CloudFlareProtectedException, TaggedActivityResult>() {
|
||||||
override fun createIntent(context: Context, input: CloudFlareProtectedException): Intent {
|
override fun createIntent(context: Context, input: CloudFlareProtectedException): Intent {
|
||||||
return newIntent(context, input)
|
return newIntent(context, input)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun parseResult(resultCode: Int, intent: Intent?): Boolean {
|
override fun parseResult(resultCode: Int, intent: Intent?): TaggedActivityResult {
|
||||||
return resultCode == Activity.RESULT_OK
|
return TaggedActivityResult(TAG, resultCode)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,10 +2,11 @@ package org.koitharu.kotatsu.browser.cloudflare
|
|||||||
|
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.webkit.WebView
|
import android.webkit.WebView
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||||
import org.koitharu.kotatsu.browser.BrowserClient
|
import org.koitharu.kotatsu.browser.BrowserClient
|
||||||
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
|
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
|
||||||
import org.koitharu.kotatsu.parsers.network.CloudFlareHelper
|
|
||||||
|
|
||||||
|
private const val CF_CLEARANCE = "cf_clearance"
|
||||||
private const val LOOP_COUNTER = 3
|
private const val LOOP_COUNTER = 3
|
||||||
|
|
||||||
class CloudFlareClient(
|
class CloudFlareClient(
|
||||||
@@ -49,5 +50,8 @@ class CloudFlareClient(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getClearance() = CloudFlareHelper.getClearanceCookie(cookieJar, targetUrl)
|
private fun getClearance(): String? {
|
||||||
|
return cookieJar.loadForRequest(targetUrl.toHttpUrl())
|
||||||
|
.find { it.name == CF_CLEARANCE }?.value
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,21 +2,16 @@ 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 android.provider.SearchRecentSuggestions
|
import android.provider.SearchRecentSuggestions
|
||||||
import android.text.Html
|
import android.text.Html
|
||||||
import androidx.collection.arraySetOf
|
import androidx.collection.arraySetOf
|
||||||
import androidx.room.InvalidationTracker
|
import androidx.room.InvalidationTracker
|
||||||
import androidx.work.WorkManager
|
import androidx.work.WorkManager
|
||||||
import coil3.ImageLoader
|
import coil.ComponentRegistry
|
||||||
import coil3.disk.DiskCache
|
import coil.ImageLoader
|
||||||
import coil3.disk.directory
|
import coil.decode.SvgDecoder
|
||||||
import coil3.gif.AnimatedImageDecoder
|
import coil.disk.DiskCache
|
||||||
import coil3.gif.GifDecoder
|
import coil.util.DebugLogger
|
||||||
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
|
|
||||||
import coil3.request.allowRgb565
|
|
||||||
import coil3.svg.SvgDecoder
|
|
||||||
import coil3.util.DebugLogger
|
|
||||||
import dagger.Binds
|
import dagger.Binds
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
import dagger.Provides
|
import dagger.Provides
|
||||||
@@ -32,11 +27,8 @@ import okhttp3.OkHttpClient
|
|||||||
import org.koitharu.kotatsu.BuildConfig
|
import org.koitharu.kotatsu.BuildConfig
|
||||||
import org.koitharu.kotatsu.browser.cloudflare.CaptchaNotifier
|
import org.koitharu.kotatsu.browser.cloudflare.CaptchaNotifier
|
||||||
import org.koitharu.kotatsu.core.db.MangaDatabase
|
import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||||
import org.koitharu.kotatsu.core.image.AvifImageDecoder
|
import org.koitharu.kotatsu.core.network.ImageProxyInterceptor
|
||||||
import org.koitharu.kotatsu.core.image.CbzFetcher
|
|
||||||
import org.koitharu.kotatsu.core.image.MangaSourceHeaderInterceptor
|
|
||||||
import org.koitharu.kotatsu.core.network.MangaHttpClient
|
import org.koitharu.kotatsu.core.network.MangaHttpClient
|
||||||
import org.koitharu.kotatsu.core.network.imageproxy.ImageProxyInterceptor
|
|
||||||
import org.koitharu.kotatsu.core.os.AppShortcutManager
|
import org.koitharu.kotatsu.core.os.AppShortcutManager
|
||||||
import org.koitharu.kotatsu.core.os.NetworkState
|
import org.koitharu.kotatsu.core.os.NetworkState
|
||||||
import org.koitharu.kotatsu.core.parser.MangaLoaderContextImpl
|
import org.koitharu.kotatsu.core.parser.MangaLoaderContextImpl
|
||||||
@@ -51,11 +43,11 @@ 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.MangaPageFetcher
|
||||||
import org.koitharu.kotatsu.details.ui.pager.pages.MangaPageKeyer
|
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.LocalStorageChanges
|
import org.koitharu.kotatsu.local.data.LocalStorageChanges
|
||||||
import org.koitharu.kotatsu.local.domain.model.LocalManga
|
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.main.ui.protect.ScreenshotPolicyHelper
|
|
||||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
||||||
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
|
||||||
@@ -87,7 +79,9 @@ interface AppModule {
|
|||||||
@Singleton
|
@Singleton
|
||||||
fun provideMangaDatabase(
|
fun provideMangaDatabase(
|
||||||
@ApplicationContext context: Context,
|
@ApplicationContext context: Context,
|
||||||
): MangaDatabase = MangaDatabase(context)
|
): MangaDatabase {
|
||||||
|
return MangaDatabase(context)
|
||||||
|
}
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
@@ -98,7 +92,6 @@ interface AppModule {
|
|||||||
imageProxyInterceptor: ImageProxyInterceptor,
|
imageProxyInterceptor: ImageProxyInterceptor,
|
||||||
pageFetcherFactory: MangaPageFetcher.Factory,
|
pageFetcherFactory: MangaPageFetcher.Factory,
|
||||||
coverRestoreInterceptor: CoverRestoreInterceptor,
|
coverRestoreInterceptor: CoverRestoreInterceptor,
|
||||||
networkStateProvider: Provider<NetworkState>,
|
|
||||||
): ImageLoader {
|
): ImageLoader {
|
||||||
val diskCacheFactory = {
|
val diskCacheFactory = {
|
||||||
val rootDir = context.externalCacheDir ?: context.cacheDir
|
val rootDir = context.externalCacheDir ?: context.cacheDir
|
||||||
@@ -110,39 +103,34 @@ interface AppModule {
|
|||||||
okHttpClientProvider.get().newBuilder().cache(null).build()
|
okHttpClientProvider.get().newBuilder().cache(null).build()
|
||||||
}
|
}
|
||||||
return ImageLoader.Builder(context)
|
return ImageLoader.Builder(context)
|
||||||
.interceptorCoroutineContext(Dispatchers.Default)
|
.okHttpClient { okHttpClientLazy.value }
|
||||||
|
.interceptorDispatcher(Dispatchers.Default)
|
||||||
|
.fetcherDispatcher(Dispatchers.Default)
|
||||||
|
.decoderDispatcher(Dispatchers.IO)
|
||||||
|
.transformationDispatcher(Dispatchers.Default)
|
||||||
.diskCache(diskCacheFactory)
|
.diskCache(diskCacheFactory)
|
||||||
.logger(if (BuildConfig.DEBUG) DebugLogger() else null)
|
.logger(if (BuildConfig.DEBUG) DebugLogger() else null)
|
||||||
.allowRgb565(context.isLowRamDevice())
|
.allowRgb565(context.isLowRamDevice())
|
||||||
.eventListener(CaptchaNotifier(context))
|
.eventListener(CaptchaNotifier(context))
|
||||||
.components {
|
.components(
|
||||||
add(
|
ComponentRegistry.Builder()
|
||||||
OkHttpNetworkFetcherFactory(
|
.add(SvgDecoder.Factory())
|
||||||
callFactory = okHttpClientLazy::value,
|
.add(CbzFetcher.Factory())
|
||||||
connectivityChecker = { networkStateProvider.get() },
|
.add(FaviconFetcher.Factory(context, okHttpClientLazy, mangaRepositoryFactory))
|
||||||
),
|
.add(MangaPageKeyer())
|
||||||
)
|
.add(pageFetcherFactory)
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
.add(imageProxyInterceptor)
|
||||||
add(AnimatedImageDecoder.Factory())
|
.add(coverRestoreInterceptor)
|
||||||
} else {
|
.build(),
|
||||||
add(GifDecoder.Factory())
|
).build()
|
||||||
}
|
|
||||||
add(SvgDecoder.Factory())
|
|
||||||
add(CbzFetcher.Factory())
|
|
||||||
add(AvifImageDecoder.Factory())
|
|
||||||
add(FaviconFetcher.Factory(mangaRepositoryFactory))
|
|
||||||
add(MangaPageKeyer())
|
|
||||||
add(pageFetcherFactory)
|
|
||||||
add(imageProxyInterceptor)
|
|
||||||
add(coverRestoreInterceptor)
|
|
||||||
add(MangaSourceHeaderInterceptor())
|
|
||||||
}.build()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
fun provideSearchSuggestions(
|
fun provideSearchSuggestions(
|
||||||
@ApplicationContext context: Context,
|
@ApplicationContext context: Context,
|
||||||
): SearchRecentSuggestions = MangaSuggestionsProvider.createSuggestions(context)
|
): SearchRecentSuggestions {
|
||||||
|
return MangaSuggestionsProvider.createSuggestions(context)
|
||||||
|
}
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@ElementsIntoSet
|
@ElementsIntoSet
|
||||||
@@ -164,12 +152,10 @@ interface AppModule {
|
|||||||
appProtectHelper: AppProtectHelper,
|
appProtectHelper: AppProtectHelper,
|
||||||
activityRecreationHandle: ActivityRecreationHandle,
|
activityRecreationHandle: ActivityRecreationHandle,
|
||||||
acraScreenLogger: AcraScreenLogger,
|
acraScreenLogger: AcraScreenLogger,
|
||||||
screenshotPolicyHelper: ScreenshotPolicyHelper,
|
|
||||||
): Set<@JvmSuppressWildcards Application.ActivityLifecycleCallbacks> = arraySetOf(
|
): Set<@JvmSuppressWildcards Application.ActivityLifecycleCallbacks> = arraySetOf(
|
||||||
appProtectHelper,
|
appProtectHelper,
|
||||||
activityRecreationHandle,
|
activityRecreationHandle,
|
||||||
acraScreenLogger,
|
acraScreenLogger,
|
||||||
screenshotPolicyHelper,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import androidx.work.Configuration
|
|||||||
import androidx.work.WorkManager
|
import androidx.work.WorkManager
|
||||||
import dagger.hilt.android.HiltAndroidApp
|
import dagger.hilt.android.HiltAndroidApp
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.acra.ACRA
|
import org.acra.ACRA
|
||||||
@@ -29,9 +28,6 @@ import org.koitharu.kotatsu.core.os.AppValidator
|
|||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
import org.koitharu.kotatsu.core.util.WorkServiceStopHelper
|
import org.koitharu.kotatsu.core.util.WorkServiceStopHelper
|
||||||
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
|
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
|
||||||
import org.koitharu.kotatsu.local.data.LocalStorageChanges
|
|
||||||
import org.koitharu.kotatsu.local.data.index.LocalMangaIndex
|
|
||||||
import org.koitharu.kotatsu.local.domain.model.LocalManga
|
|
||||||
import org.koitharu.kotatsu.settings.work.WorkScheduleManager
|
import org.koitharu.kotatsu.settings.work.WorkScheduleManager
|
||||||
import java.security.Security
|
import java.security.Security
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -64,13 +60,6 @@ open class BaseApp : Application(), Configuration.Provider {
|
|||||||
@Inject
|
@Inject
|
||||||
lateinit var workManagerProvider: Provider<WorkManager>
|
lateinit var workManagerProvider: Provider<WorkManager>
|
||||||
|
|
||||||
@Inject
|
|
||||||
lateinit var localMangaIndexProvider: Provider<LocalMangaIndex>
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
@LocalStorageChanges
|
|
||||||
lateinit var localStorageChanges: MutableSharedFlow<LocalManga?>
|
|
||||||
|
|
||||||
override val workManagerConfiguration: Configuration
|
override val workManagerConfiguration: Configuration
|
||||||
get() = Configuration.Builder()
|
get() = Configuration.Builder()
|
||||||
.setWorkerFactory(workerFactory)
|
.setWorkerFactory(workerFactory)
|
||||||
@@ -78,9 +67,6 @@ open class BaseApp : Application(), Configuration.Provider {
|
|||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
if (ACRA.isACRASenderServiceProcess()) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
AppCompatDelegate.setDefaultNightMode(settings.theme)
|
AppCompatDelegate.setDefaultNightMode(settings.theme)
|
||||||
AppCompatDelegate.setApplicationLocales(settings.appLocales)
|
AppCompatDelegate.setApplicationLocales(settings.appLocales)
|
||||||
// TLS 1.3 support for Android < 10
|
// TLS 1.3 support for Android < 10
|
||||||
@@ -96,7 +82,6 @@ open class BaseApp : Application(), Configuration.Provider {
|
|||||||
}
|
}
|
||||||
processLifecycleScope.launch(Dispatchers.Default) {
|
processLifecycleScope.launch(Dispatchers.Default) {
|
||||||
setupDatabaseObservers()
|
setupDatabaseObservers()
|
||||||
localStorageChanges.collect(localMangaIndexProvider.get())
|
|
||||||
}
|
}
|
||||||
workScheduleManager.init()
|
workScheduleManager.init()
|
||||||
WorkServiceStopHelper(workManagerProvider).setup()
|
WorkServiceStopHelper(workManagerProvider).setup()
|
||||||
|
|||||||
@@ -5,11 +5,9 @@ import android.content.BroadcastReceiver
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.BadParcelableException
|
|
||||||
import androidx.core.app.PendingIntentCompat
|
import androidx.core.app.PendingIntentCompat
|
||||||
import org.koitharu.kotatsu.BuildConfig
|
import org.koitharu.kotatsu.BuildConfig
|
||||||
import org.koitharu.kotatsu.core.util.ext.getSerializableExtraCompat
|
import org.koitharu.kotatsu.core.util.ext.getSerializableExtraCompat
|
||||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.report
|
import org.koitharu.kotatsu.core.util.ext.report
|
||||||
|
|
||||||
class ErrorReporterReceiver : BroadcastReceiver() {
|
class ErrorReporterReceiver : BroadcastReceiver() {
|
||||||
@@ -24,15 +22,12 @@ class ErrorReporterReceiver : BroadcastReceiver() {
|
|||||||
private const val EXTRA_ERROR = "err"
|
private const val EXTRA_ERROR = "err"
|
||||||
private const val ACTION_REPORT = "${BuildConfig.APPLICATION_ID}.action.REPORT_ERROR"
|
private const val ACTION_REPORT = "${BuildConfig.APPLICATION_ID}.action.REPORT_ERROR"
|
||||||
|
|
||||||
fun getPendingIntent(context: Context, e: Throwable): PendingIntent? = try {
|
fun getPendingIntent(context: Context, e: Throwable): PendingIntent {
|
||||||
val intent = Intent(context, ErrorReporterReceiver::class.java)
|
val intent = Intent(context, ErrorReporterReceiver::class.java)
|
||||||
intent.setAction(ACTION_REPORT)
|
intent.setAction(ACTION_REPORT)
|
||||||
intent.setData(Uri.parse("err://${e.hashCode()}"))
|
intent.setData(Uri.parse("err://${e.hashCode()}"))
|
||||||
intent.putExtra(EXTRA_ERROR, e)
|
intent.putExtra(EXTRA_ERROR, e)
|
||||||
PendingIntentCompat.getBroadcast(context, 0, intent, 0, false)
|
return checkNotNull(PendingIntentCompat.getBroadcast(context, 0, intent, 0, false))
|
||||||
} catch (e: BadParcelableException) {
|
|
||||||
e.printStackTraceDebug()
|
|
||||||
null
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.backup
|
|
||||||
|
|
||||||
import android.net.Uri
|
|
||||||
import java.util.Date
|
|
||||||
|
|
||||||
data class BackupFile(
|
|
||||||
val uri: Uri,
|
|
||||||
val dateTime: Date,
|
|
||||||
): Comparable<BackupFile> {
|
|
||||||
|
|
||||||
override fun compareTo(other: BackupFile): Int = compareValues(dateTime, other.dateTime)
|
|
||||||
}
|
|
||||||
@@ -6,7 +6,7 @@ import org.json.JSONObject
|
|||||||
import org.koitharu.kotatsu.BuildConfig
|
import org.koitharu.kotatsu.BuildConfig
|
||||||
import org.koitharu.kotatsu.core.db.MangaDatabase
|
import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
import org.koitharu.kotatsu.parsers.util.json.asTypedList
|
import org.koitharu.kotatsu.parsers.util.json.JSONIterator
|
||||||
import org.koitharu.kotatsu.parsers.util.json.getLongOrDefault
|
import org.koitharu.kotatsu.parsers.util.json.getLongOrDefault
|
||||||
import org.koitharu.kotatsu.parsers.util.json.mapJSON
|
import org.koitharu.kotatsu.parsers.util.json.mapJSON
|
||||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||||
@@ -130,7 +130,7 @@ class BackupRepository @Inject constructor(
|
|||||||
|
|
||||||
suspend fun restoreHistory(entry: BackupEntry): CompositeResult {
|
suspend fun restoreHistory(entry: BackupEntry): CompositeResult {
|
||||||
val result = CompositeResult()
|
val result = CompositeResult()
|
||||||
for (item in entry.data.asTypedList<JSONObject>()) {
|
for (item in entry.data.JSONIterator()) {
|
||||||
val mangaJson = item.getJSONObject("manga")
|
val mangaJson = item.getJSONObject("manga")
|
||||||
val manga = JsonDeserializer(mangaJson).toMangaEntity()
|
val manga = JsonDeserializer(mangaJson).toMangaEntity()
|
||||||
val tags = mangaJson.getJSONArray("tags").mapJSON {
|
val tags = mangaJson.getJSONArray("tags").mapJSON {
|
||||||
@@ -150,7 +150,7 @@ class BackupRepository @Inject constructor(
|
|||||||
|
|
||||||
suspend fun restoreCategories(entry: BackupEntry): CompositeResult {
|
suspend fun restoreCategories(entry: BackupEntry): CompositeResult {
|
||||||
val result = CompositeResult()
|
val result = CompositeResult()
|
||||||
for (item in entry.data.asTypedList<JSONObject>()) {
|
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.getFavouriteCategoriesDao().upsert(category)
|
||||||
@@ -161,7 +161,7 @@ class BackupRepository @Inject constructor(
|
|||||||
|
|
||||||
suspend fun restoreFavourites(entry: BackupEntry): CompositeResult {
|
suspend fun restoreFavourites(entry: BackupEntry): CompositeResult {
|
||||||
val result = CompositeResult()
|
val result = CompositeResult()
|
||||||
for (item in entry.data.asTypedList<JSONObject>()) {
|
for (item in entry.data.JSONIterator()) {
|
||||||
val mangaJson = item.getJSONObject("manga")
|
val mangaJson = item.getJSONObject("manga")
|
||||||
val manga = JsonDeserializer(mangaJson).toMangaEntity()
|
val manga = JsonDeserializer(mangaJson).toMangaEntity()
|
||||||
val tags = mangaJson.getJSONArray("tags").mapJSON {
|
val tags = mangaJson.getJSONArray("tags").mapJSON {
|
||||||
@@ -181,7 +181,7 @@ class BackupRepository @Inject constructor(
|
|||||||
|
|
||||||
suspend fun restoreBookmarks(entry: BackupEntry): CompositeResult {
|
suspend fun restoreBookmarks(entry: BackupEntry): CompositeResult {
|
||||||
val result = CompositeResult()
|
val result = CompositeResult()
|
||||||
for (item in entry.data.asTypedList<JSONObject>()) {
|
for (item in entry.data.JSONIterator()) {
|
||||||
val mangaJson = item.getJSONObject("manga")
|
val mangaJson = item.getJSONObject("manga")
|
||||||
val manga = JsonDeserializer(mangaJson).toMangaEntity()
|
val manga = JsonDeserializer(mangaJson).toMangaEntity()
|
||||||
val tags = item.getJSONArray("tags").mapJSON {
|
val tags = item.getJSONArray("tags").mapJSON {
|
||||||
@@ -203,7 +203,7 @@ class BackupRepository @Inject constructor(
|
|||||||
|
|
||||||
suspend fun restoreSources(entry: BackupEntry): CompositeResult {
|
suspend fun restoreSources(entry: BackupEntry): CompositeResult {
|
||||||
val result = CompositeResult()
|
val result = CompositeResult()
|
||||||
for (item in entry.data.asTypedList<JSONObject>()) {
|
for (item in entry.data.JSONIterator()) {
|
||||||
val source = JsonDeserializer(item).toMangaSourceEntity()
|
val source = JsonDeserializer(item).toMangaSourceEntity()
|
||||||
result += runCatchingCancellable {
|
result += runCatchingCancellable {
|
||||||
db.getSourcesDao().upsert(source)
|
db.getSourcesDao().upsert(source)
|
||||||
@@ -214,7 +214,7 @@ class BackupRepository @Inject constructor(
|
|||||||
|
|
||||||
fun restoreSettings(entry: BackupEntry): CompositeResult {
|
fun restoreSettings(entry: BackupEntry): CompositeResult {
|
||||||
val result = CompositeResult()
|
val result = CompositeResult()
|
||||||
for (item in entry.data.asTypedList<JSONObject>()) {
|
for (item in entry.data.JSONIterator()) {
|
||||||
result += runCatchingCancellable {
|
result += runCatchingCancellable {
|
||||||
settings.upsertAll(JsonDeserializer(item).toMap())
|
settings.upsertAll(JsonDeserializer(item).toMap())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
package org.koitharu.kotatsu.core.backup
|
package org.koitharu.kotatsu.core.backup
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CoroutineStart
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.runInterruptible
|
import kotlinx.coroutines.runInterruptible
|
||||||
import okhttp3.internal.closeQuietly
|
|
||||||
import okio.Closeable
|
import okio.Closeable
|
||||||
import org.json.JSONArray
|
import org.json.JSONArray
|
||||||
import org.koitharu.kotatsu.core.exceptions.BadBackupFormatException
|
import org.koitharu.kotatsu.core.exceptions.BadBackupFormatException
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.util.EnumSet
|
import java.util.EnumSet
|
||||||
import java.util.zip.ZipException
|
import java.util.zip.ZipException
|
||||||
@@ -33,29 +35,25 @@ class BackupZipInput private constructor(val file: File) : Closeable {
|
|||||||
zipFile.close()
|
zipFile.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun closeAndDelete() {
|
fun cleanupAsync() {
|
||||||
closeQuietly()
|
processLifecycleScope.launch(Dispatchers.IO, CoroutineStart.ATOMIC) {
|
||||||
file.delete()
|
runCatching {
|
||||||
|
close()
|
||||||
|
file.delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
||||||
fun from(file: File): BackupZipInput {
|
fun from(file: File): BackupZipInput = try {
|
||||||
var res: BackupZipInput? = null
|
val res = BackupZipInput(file)
|
||||||
return try {
|
if (res.zipFile.getEntry("index") == null) {
|
||||||
res = BackupZipInput(file)
|
throw BadBackupFormatException(null)
|
||||||
if (res.zipFile.getEntry("index") == null) {
|
|
||||||
throw BadBackupFormatException(null)
|
|
||||||
}
|
|
||||||
res
|
|
||||||
} catch (exception: Throwable) {
|
|
||||||
res?.closeQuietly()
|
|
||||||
throw if (exception is ZipException) {
|
|
||||||
BadBackupFormatException(exception)
|
|
||||||
} else {
|
|
||||||
exception
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
res
|
||||||
|
} catch (e: ZipException) {
|
||||||
|
throw BadBackupFormatException(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +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.printStackTraceDebug
|
|
||||||
import org.koitharu.kotatsu.core.zip.ZipOutput
|
import org.koitharu.kotatsu.core.zip.ZipOutput
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.text.ParseException
|
import java.time.LocalDate
|
||||||
import java.text.SimpleDateFormat
|
import java.time.format.DateTimeFormatter
|
||||||
import java.util.Date
|
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import java.util.zip.Deflater
|
import java.util.zip.Deflater
|
||||||
|
|
||||||
@@ -29,32 +27,20 @@ class BackupZipOutput(val file: File) : Closeable {
|
|||||||
override fun close() {
|
override fun close() {
|
||||||
output.close()
|
output.close()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
companion object {
|
|
||||||
|
const val DIR_BACKUPS = "backups"
|
||||||
const val DIR_BACKUPS = "backups"
|
|
||||||
private val dateTimeFormat = SimpleDateFormat("yyyyMMdd-HHmm")
|
suspend fun BackupZipOutput(context: Context): BackupZipOutput = runInterruptible(Dispatchers.IO) {
|
||||||
|
val dir = context.run {
|
||||||
fun generateFileName(context: Context) = buildString {
|
getExternalFilesDir(DIR_BACKUPS) ?: File(filesDir, DIR_BACKUPS)
|
||||||
append(context.getString(R.string.app_name).replace(' ', '_').lowercase(Locale.ROOT))
|
}
|
||||||
append('_')
|
dir.mkdirs()
|
||||||
append(dateTimeFormat.format(Date()))
|
val filename = buildString {
|
||||||
append(".bk.zip")
|
append(context.getString(R.string.app_name).replace(' ', '_').lowercase(Locale.ROOT))
|
||||||
}
|
append('_')
|
||||||
|
append(LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd")))
|
||||||
fun parseBackupDateTime(fileName: String): Date? = try {
|
append(".bk.zip")
|
||||||
dateTimeFormat.parse(fileName.substringAfterLast('_').substringBefore('.'))
|
}
|
||||||
} catch (e: ParseException) {
|
BackupZipOutput(File(dir, filename))
|
||||||
e.printStackTraceDebug()
|
|
||||||
null
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun createTemp(context: Context): BackupZipOutput = runInterruptible(Dispatchers.IO) {
|
|
||||||
val dir = context.run {
|
|
||||||
getExternalFilesDir(DIR_BACKUPS) ?: File(filesDir, DIR_BACKUPS)
|
|
||||||
}
|
|
||||||
dir.mkdirs()
|
|
||||||
BackupZipOutput(File(dir, generateFileName(context)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,91 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.backup
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.net.Uri
|
|
||||||
import androidx.annotation.CheckResult
|
|
||||||
import androidx.documentfile.provider.DocumentFile
|
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.runInterruptible
|
|
||||||
import okio.buffer
|
|
||||||
import okio.sink
|
|
||||||
import okio.source
|
|
||||||
import org.jetbrains.annotations.Blocking
|
|
||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
|
||||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
|
||||||
import java.io.File
|
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
class ExternalBackupStorage @Inject constructor(
|
|
||||||
@ApplicationContext private val context: Context,
|
|
||||||
private val settings: AppSettings,
|
|
||||||
) {
|
|
||||||
|
|
||||||
suspend fun list(): List<BackupFile> = runInterruptible(Dispatchers.IO) {
|
|
||||||
getRootOrThrow().listFiles().mapNotNull {
|
|
||||||
if (it.isFile && it.canRead()) {
|
|
||||||
BackupFile(
|
|
||||||
uri = it.uri,
|
|
||||||
dateTime = it.name?.let { fileName ->
|
|
||||||
BackupZipOutput.parseBackupDateTime(fileName)
|
|
||||||
} ?: return@mapNotNull null,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}.sortedDescending()
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun listOrNull() = runCatchingCancellable {
|
|
||||||
list()
|
|
||||||
}.onFailure { e ->
|
|
||||||
e.printStackTraceDebug()
|
|
||||||
}.getOrNull()
|
|
||||||
|
|
||||||
suspend fun put(file: File): Uri = runInterruptible(Dispatchers.IO) {
|
|
||||||
val out = checkNotNull(getRootOrThrow().createFile("application/zip", file.nameWithoutExtension)) {
|
|
||||||
"Cannot create target backup file"
|
|
||||||
}
|
|
||||||
checkNotNull(context.contentResolver.openOutputStream(out.uri, "wt")).sink().use { sink ->
|
|
||||||
file.source().buffer().use { src ->
|
|
||||||
src.readAll(sink)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out.uri
|
|
||||||
}
|
|
||||||
|
|
||||||
@CheckResult
|
|
||||||
suspend fun delete(victim: BackupFile) = runInterruptible(Dispatchers.IO) {
|
|
||||||
val df = DocumentFile.fromSingleUri(context, victim.uri)
|
|
||||||
df != null && df.delete()
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun getLastBackupDate() = listOrNull()?.maxOfOrNull { it.dateTime }
|
|
||||||
|
|
||||||
suspend fun trim(maxCount: Int): Boolean {
|
|
||||||
if (maxCount == Int.MAX_VALUE) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
val list = listOrNull()
|
|
||||||
if (list == null || list.size <= maxCount) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
var result = false
|
|
||||||
for (i in maxCount until list.size) {
|
|
||||||
if (delete(list[i])) {
|
|
||||||
result = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
@Blocking
|
|
||||||
private fun getRootOrThrow(): DocumentFile {
|
|
||||||
val uri = checkNotNull(settings.periodicalBackupDirectory) {
|
|
||||||
"Backup directory is not specified"
|
|
||||||
}
|
|
||||||
val root = DocumentFile.fromTreeUri(context, uri)
|
|
||||||
return checkNotNull(root) { "Cannot obtain DocumentFile from $uri" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -12,7 +12,6 @@ import org.koitharu.kotatsu.parsers.model.SortOrder
|
|||||||
import org.koitharu.kotatsu.parsers.util.json.getBooleanOrDefault
|
import org.koitharu.kotatsu.parsers.util.json.getBooleanOrDefault
|
||||||
import org.koitharu.kotatsu.parsers.util.json.getFloatOrDefault
|
import org.koitharu.kotatsu.parsers.util.json.getFloatOrDefault
|
||||||
import org.koitharu.kotatsu.parsers.util.json.getIntOrDefault
|
import org.koitharu.kotatsu.parsers.util.json.getIntOrDefault
|
||||||
import org.koitharu.kotatsu.parsers.util.json.getLongOrDefault
|
|
||||||
import org.koitharu.kotatsu.parsers.util.json.getStringOrNull
|
import org.koitharu.kotatsu.parsers.util.json.getStringOrNull
|
||||||
|
|
||||||
class JsonDeserializer(private val json: JSONObject) {
|
class JsonDeserializer(private val json: JSONObject) {
|
||||||
@@ -85,9 +84,6 @@ class JsonDeserializer(private val json: JSONObject) {
|
|||||||
source = json.getString("source"),
|
source = json.getString("source"),
|
||||||
isEnabled = json.getBoolean("enabled"),
|
isEnabled = json.getBoolean("enabled"),
|
||||||
sortKey = json.getInt("sort_key"),
|
sortKey = json.getInt("sort_key"),
|
||||||
addedIn = json.getIntOrDefault("added_in", 0),
|
|
||||||
lastUsedAt = json.getLongOrDefault("used_at", 0L),
|
|
||||||
isPinned = json.getBooleanOrDefault("pinned", false),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
fun toMap(): Map<String, Any?> {
|
fun toMap(): Map<String, Any?> {
|
||||||
|
|||||||
@@ -89,9 +89,6 @@ class JsonSerializer private constructor(private val json: JSONObject) {
|
|||||||
put("source", e.source)
|
put("source", e.source)
|
||||||
put("enabled", e.isEnabled)
|
put("enabled", e.isEnabled)
|
||||||
put("sort_key", e.sortKey)
|
put("sort_key", e.sortKey)
|
||||||
put("added_in", e.addedIn)
|
|
||||||
put("used_at", e.lastUsedAt)
|
|
||||||
put("pinned", e.isPinned)
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -16,16 +16,16 @@ class MemoryContentCache @Inject constructor(application: Application) : Compone
|
|||||||
|
|
||||||
private val isLowRam = application.isLowRamDevice()
|
private val isLowRam = application.isLowRamDevice()
|
||||||
|
|
||||||
|
init {
|
||||||
|
application.registerComponentCallbacks(this)
|
||||||
|
}
|
||||||
|
|
||||||
private val detailsCache = ExpiringLruCache<SafeDeferred<Manga>>(if (isLowRam) 1 else 4, 5, TimeUnit.MINUTES)
|
private val detailsCache = ExpiringLruCache<SafeDeferred<Manga>>(if (isLowRam) 1 else 4, 5, TimeUnit.MINUTES)
|
||||||
private val pagesCache =
|
private val pagesCache =
|
||||||
ExpiringLruCache<SafeDeferred<List<MangaPage>>>(if (isLowRam) 1 else 4, 10, TimeUnit.MINUTES)
|
ExpiringLruCache<SafeDeferred<List<MangaPage>>>(if (isLowRam) 1 else 4, 10, TimeUnit.MINUTES)
|
||||||
private val relatedMangaCache =
|
private val relatedMangaCache =
|
||||||
ExpiringLruCache<SafeDeferred<List<Manga>>>(if (isLowRam) 1 else 3, 10, TimeUnit.MINUTES)
|
ExpiringLruCache<SafeDeferred<List<Manga>>>(if (isLowRam) 1 else 3, 10, TimeUnit.MINUTES)
|
||||||
|
|
||||||
init {
|
|
||||||
application.registerComponentCallbacks(this)
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun getDetails(source: MangaSource, url: String): Manga? {
|
suspend fun getDetails(source: MangaSource, url: String): Manga? {
|
||||||
return detailsCache[Key(source, url)]?.awaitOrNull()
|
return detailsCache[Key(source, url)]?.awaitOrNull()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,9 +33,6 @@ import org.koitharu.kotatsu.core.db.migrations.Migration17To18
|
|||||||
import org.koitharu.kotatsu.core.db.migrations.Migration18To19
|
import org.koitharu.kotatsu.core.db.migrations.Migration18To19
|
||||||
import org.koitharu.kotatsu.core.db.migrations.Migration19To20
|
import org.koitharu.kotatsu.core.db.migrations.Migration19To20
|
||||||
import org.koitharu.kotatsu.core.db.migrations.Migration1To2
|
import org.koitharu.kotatsu.core.db.migrations.Migration1To2
|
||||||
import org.koitharu.kotatsu.core.db.migrations.Migration20To21
|
|
||||||
import org.koitharu.kotatsu.core.db.migrations.Migration21To22
|
|
||||||
import org.koitharu.kotatsu.core.db.migrations.Migration22To23
|
|
||||||
import org.koitharu.kotatsu.core.db.migrations.Migration2To3
|
import org.koitharu.kotatsu.core.db.migrations.Migration2To3
|
||||||
import org.koitharu.kotatsu.core.db.migrations.Migration3To4
|
import org.koitharu.kotatsu.core.db.migrations.Migration3To4
|
||||||
import org.koitharu.kotatsu.core.db.migrations.Migration4To5
|
import org.koitharu.kotatsu.core.db.migrations.Migration4To5
|
||||||
@@ -51,8 +48,6 @@ import org.koitharu.kotatsu.favourites.data.FavouriteEntity
|
|||||||
import org.koitharu.kotatsu.favourites.data.FavouritesDao
|
import org.koitharu.kotatsu.favourites.data.FavouritesDao
|
||||||
import org.koitharu.kotatsu.history.data.HistoryDao
|
import org.koitharu.kotatsu.history.data.HistoryDao
|
||||||
import org.koitharu.kotatsu.history.data.HistoryEntity
|
import org.koitharu.kotatsu.history.data.HistoryEntity
|
||||||
import org.koitharu.kotatsu.local.data.index.LocalMangaIndexDao
|
|
||||||
import org.koitharu.kotatsu.local.data.index.LocalMangaIndexEntity
|
|
||||||
import org.koitharu.kotatsu.scrobbling.common.data.ScrobblingDao
|
import org.koitharu.kotatsu.scrobbling.common.data.ScrobblingDao
|
||||||
import org.koitharu.kotatsu.scrobbling.common.data.ScrobblingEntity
|
import org.koitharu.kotatsu.scrobbling.common.data.ScrobblingEntity
|
||||||
import org.koitharu.kotatsu.stats.data.StatsDao
|
import org.koitharu.kotatsu.stats.data.StatsDao
|
||||||
@@ -63,14 +58,14 @@ import org.koitharu.kotatsu.tracker.data.TrackEntity
|
|||||||
import org.koitharu.kotatsu.tracker.data.TrackLogEntity
|
import org.koitharu.kotatsu.tracker.data.TrackLogEntity
|
||||||
import org.koitharu.kotatsu.tracker.data.TracksDao
|
import org.koitharu.kotatsu.tracker.data.TracksDao
|
||||||
|
|
||||||
const val DATABASE_VERSION = 23
|
const val DATABASE_VERSION = 20
|
||||||
|
|
||||||
@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, LocalMangaIndexEntity::class,
|
ScrobblingEntity::class, MangaSourceEntity::class, StatsEntity::class,
|
||||||
],
|
],
|
||||||
version = DATABASE_VERSION,
|
version = DATABASE_VERSION,
|
||||||
)
|
)
|
||||||
@@ -101,8 +96,6 @@ abstract class MangaDatabase : RoomDatabase() {
|
|||||||
abstract fun getSourcesDao(): MangaSourcesDao
|
abstract fun getSourcesDao(): MangaSourcesDao
|
||||||
|
|
||||||
abstract fun getStatsDao(): StatsDao
|
abstract fun getStatsDao(): StatsDao
|
||||||
|
|
||||||
abstract fun getLocalMangaIndexDao(): LocalMangaIndexDao
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getDatabaseMigrations(context: Context): Array<Migration> = arrayOf(
|
fun getDatabaseMigrations(context: Context): Array<Migration> = arrayOf(
|
||||||
@@ -125,9 +118,6 @@ fun getDatabaseMigrations(context: Context): Array<Migration> = arrayOf(
|
|||||||
Migration17To18(),
|
Migration17To18(),
|
||||||
Migration18To19(),
|
Migration18To19(),
|
||||||
Migration19To20(),
|
Migration19To20(),
|
||||||
Migration20To21(),
|
|
||||||
Migration21To22(),
|
|
||||||
Migration22To23(),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
fun MangaDatabase(context: Context): MangaDatabase = Room
|
fun MangaDatabase(context: Context): MangaDatabase = Room
|
||||||
|
|||||||
@@ -1,113 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.db
|
|
||||||
|
|
||||||
import androidx.sqlite.db.SimpleSQLiteQuery
|
|
||||||
import org.koitharu.kotatsu.list.domain.ListFilterOption
|
|
||||||
import java.util.LinkedList
|
|
||||||
|
|
||||||
class MangaQueryBuilder(
|
|
||||||
private val table: String,
|
|
||||||
private val conditionCallback: ConditionCallback
|
|
||||||
) {
|
|
||||||
|
|
||||||
private var filterOptions: Collection<ListFilterOption> = emptyList()
|
|
||||||
private var whereConditions = LinkedList<String>()
|
|
||||||
private var orderBy: String? = null
|
|
||||||
private var groupBy: String? = null
|
|
||||||
private var extraJoins: String? = null
|
|
||||||
private var limit: Int = 0
|
|
||||||
|
|
||||||
fun filters(options: Collection<ListFilterOption>) = apply {
|
|
||||||
filterOptions = options
|
|
||||||
}
|
|
||||||
|
|
||||||
fun where(condition: String) = apply {
|
|
||||||
whereConditions.add(condition)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun orderBy(orderBy: String?) = apply {
|
|
||||||
this@MangaQueryBuilder.orderBy = orderBy
|
|
||||||
}
|
|
||||||
|
|
||||||
fun groupBy(groupBy: String?) = apply {
|
|
||||||
this@MangaQueryBuilder.groupBy = groupBy
|
|
||||||
}
|
|
||||||
|
|
||||||
fun limit(limit: Int) = apply {
|
|
||||||
this@MangaQueryBuilder.limit = limit
|
|
||||||
}
|
|
||||||
|
|
||||||
fun join(join: String?) = apply {
|
|
||||||
extraJoins = join
|
|
||||||
}
|
|
||||||
|
|
||||||
fun build() = buildString {
|
|
||||||
append("SELECT * FROM ")
|
|
||||||
append(table)
|
|
||||||
extraJoins?.let {
|
|
||||||
append(' ')
|
|
||||||
append(it)
|
|
||||||
}
|
|
||||||
if (whereConditions.isNotEmpty()) {
|
|
||||||
whereConditions.joinTo(
|
|
||||||
buffer = this,
|
|
||||||
prefix = " WHERE ",
|
|
||||||
separator = " AND ",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (filterOptions.isNotEmpty()) {
|
|
||||||
if (whereConditions.isEmpty()) {
|
|
||||||
append(" WHERE")
|
|
||||||
} else {
|
|
||||||
append(" AND")
|
|
||||||
}
|
|
||||||
var isFirst = true
|
|
||||||
val groupedOptions = filterOptions.groupBy { it.groupKey }
|
|
||||||
for ((_, group) in groupedOptions) {
|
|
||||||
if (group.isEmpty()) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if (isFirst) {
|
|
||||||
isFirst = false
|
|
||||||
append(' ')
|
|
||||||
} else {
|
|
||||||
append(" AND ")
|
|
||||||
}
|
|
||||||
if (group.size > 1) {
|
|
||||||
group.joinTo(
|
|
||||||
buffer = this,
|
|
||||||
separator = " OR ",
|
|
||||||
prefix = "(",
|
|
||||||
postfix = ")",
|
|
||||||
transform = ::getConditionOrThrow,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
append(getConditionOrThrow(group.single()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
groupBy?.let {
|
|
||||||
append(" GROUP BY ")
|
|
||||||
append(it)
|
|
||||||
}
|
|
||||||
orderBy?.let {
|
|
||||||
append(" ORDER BY ")
|
|
||||||
append(it)
|
|
||||||
}
|
|
||||||
if (limit > 0) {
|
|
||||||
append(" LIMIT ")
|
|
||||||
append(limit)
|
|
||||||
}
|
|
||||||
}.let { SimpleSQLiteQuery(it) }
|
|
||||||
|
|
||||||
private fun getConditionOrThrow(option: ListFilterOption): String = when (option) {
|
|
||||||
is ListFilterOption.Inverted -> "NOT(${getConditionOrThrow(option.option)})"
|
|
||||||
else -> requireNotNull(conditionCallback.getCondition(option)) {
|
|
||||||
"Unsupported filter option $option"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun interface ConditionCallback {
|
|
||||||
|
|
||||||
fun getCondition(option: ListFilterOption): String?
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -11,26 +11,19 @@ import androidx.sqlite.db.SimpleSQLiteQuery
|
|||||||
import androidx.sqlite.db.SupportSQLiteQuery
|
import androidx.sqlite.db.SupportSQLiteQuery
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import org.intellij.lang.annotations.Language
|
import org.intellij.lang.annotations.Language
|
||||||
import org.koitharu.kotatsu.BuildConfig
|
|
||||||
import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity
|
import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity
|
||||||
import org.koitharu.kotatsu.explore.data.SourcesSortOrder
|
import org.koitharu.kotatsu.explore.data.SourcesSortOrder
|
||||||
|
|
||||||
@Dao
|
@Dao
|
||||||
abstract class MangaSourcesDao {
|
abstract class MangaSourcesDao {
|
||||||
|
|
||||||
@Query("SELECT * FROM sources ORDER BY pinned DESC, 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 source FROM sources WHERE enabled = 1")
|
||||||
abstract suspend fun findAllEnabledNames(): List<String>
|
abstract suspend fun findAllEnabledNames(): List<String>
|
||||||
|
|
||||||
@Query("SELECT * FROM sources WHERE added_in >= :version")
|
@Query("SELECT * FROM sources ORDER BY sort_key")
|
||||||
abstract suspend fun findAllFromVersion(version: Int): List<MangaSourceEntity>
|
|
||||||
|
|
||||||
@Query("SELECT * FROM sources ORDER BY used_at DESC LIMIT :limit")
|
|
||||||
abstract suspend fun findLastUsed(limit: Int): List<MangaSourceEntity>
|
|
||||||
|
|
||||||
@Query("SELECT * FROM sources ORDER BY pinned DESC, sort_key")
|
|
||||||
abstract fun observeAll(): Flow<List<MangaSourceEntity>>
|
abstract fun observeAll(): Flow<List<MangaSourceEntity>>
|
||||||
|
|
||||||
@Query("SELECT enabled FROM sources WHERE source = :source")
|
@Query("SELECT enabled FROM sources WHERE source = :source")
|
||||||
@@ -45,12 +38,6 @@ abstract class MangaSourcesDao {
|
|||||||
@Query("UPDATE sources SET sort_key = :sortKey WHERE source = :source")
|
@Query("UPDATE sources SET sort_key = :sortKey WHERE source = :source")
|
||||||
abstract suspend fun setSortKey(source: String, sortKey: Int)
|
abstract suspend fun setSortKey(source: String, sortKey: Int)
|
||||||
|
|
||||||
@Query("UPDATE sources SET used_at = :value WHERE source = :source")
|
|
||||||
abstract suspend fun setLastUsed(source: String, value: Long)
|
|
||||||
|
|
||||||
@Query("UPDATE sources SET pinned = :isPinned WHERE source = :source")
|
|
||||||
abstract suspend fun setPinned(source: String, isPinned: Boolean)
|
|
||||||
|
|
||||||
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||||
@Transaction
|
@Transaction
|
||||||
abstract suspend fun insertIfAbsent(entries: Collection<MangaSourceEntity>)
|
abstract suspend fun insertIfAbsent(entries: Collection<MangaSourceEntity>)
|
||||||
@@ -58,14 +45,11 @@ abstract class MangaSourcesDao {
|
|||||||
@Upsert
|
@Upsert
|
||||||
abstract suspend fun upsert(entry: MangaSourceEntity)
|
abstract suspend fun upsert(entry: MangaSourceEntity)
|
||||||
|
|
||||||
@Query("SELECT * FROM sources WHERE pinned = 1")
|
|
||||||
abstract suspend fun findAllPinned(): List<MangaSourceEntity>
|
|
||||||
|
|
||||||
fun observeEnabled(order: SourcesSortOrder): Flow<List<MangaSourceEntity>> {
|
fun observeEnabled(order: SourcesSortOrder): Flow<List<MangaSourceEntity>> {
|
||||||
val orderBy = getOrderBy(order)
|
val orderBy = getOrderBy(order)
|
||||||
|
|
||||||
@Language("RoomSql")
|
@Language("RoomSql")
|
||||||
val query = SimpleSQLiteQuery("SELECT * FROM sources WHERE enabled = 1 ORDER BY pinned DESC, $orderBy")
|
val query = SimpleSQLiteQuery("SELECT * FROM sources WHERE enabled = 1 ORDER BY $orderBy")
|
||||||
return observeImpl(query)
|
return observeImpl(query)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,7 +57,7 @@ abstract class MangaSourcesDao {
|
|||||||
val orderBy = getOrderBy(order)
|
val orderBy = getOrderBy(order)
|
||||||
|
|
||||||
@Language("RoomSql")
|
@Language("RoomSql")
|
||||||
val query = SimpleSQLiteQuery("SELECT * FROM sources WHERE enabled = 1 ORDER BY pinned DESC, $orderBy")
|
val query = SimpleSQLiteQuery("SELECT * FROM sources WHERE enabled = 1 ORDER BY $orderBy")
|
||||||
return findAllImpl(query)
|
return findAllImpl(query)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,9 +68,6 @@ abstract class MangaSourcesDao {
|
|||||||
source = source,
|
source = source,
|
||||||
isEnabled = isEnabled,
|
isEnabled = isEnabled,
|
||||||
sortKey = getMaxSortKey() + 1,
|
sortKey = getMaxSortKey() + 1,
|
||||||
addedIn = BuildConfig.VERSION_CODE,
|
|
||||||
lastUsedAt = 0,
|
|
||||||
isPinned = false,
|
|
||||||
)
|
)
|
||||||
upsert(entity)
|
upsert(entity)
|
||||||
}
|
}
|
||||||
@@ -105,6 +86,5 @@ abstract class MangaSourcesDao {
|
|||||||
SourcesSortOrder.ALPHABETIC -> "source ASC"
|
SourcesSortOrder.ALPHABETIC -> "source ASC"
|
||||||
SourcesSortOrder.POPULARITY -> "(SELECT COUNT(*) FROM manga WHERE source = sources.source) DESC"
|
SourcesSortOrder.POPULARITY -> "(SELECT COUNT(*) FROM manga WHERE source = sources.source) DESC"
|
||||||
SourcesSortOrder.MANUAL -> "sort_key ASC"
|
SourcesSortOrder.MANUAL -> "sort_key ASC"
|
||||||
SourcesSortOrder.LAST_USED -> "used_at DESC"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +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.Query
|
|
||||||
import androidx.room.Upsert
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import org.koitharu.kotatsu.core.db.entity.MangaPrefsEntity
|
import org.koitharu.kotatsu.core.db.entity.MangaPrefsEntity
|
||||||
|
|
||||||
@@ -15,9 +13,6 @@ abstract class PreferencesDao {
|
|||||||
@Query("SELECT * FROM preferences WHERE manga_id = :mangaId")
|
@Query("SELECT * FROM preferences WHERE manga_id = :mangaId")
|
||||||
abstract fun observe(mangaId: Long): Flow<MangaPrefsEntity?>
|
abstract fun observe(mangaId: Long): Flow<MangaPrefsEntity?>
|
||||||
|
|
||||||
@Query("UPDATE preferences SET cf_brightness = 0, cf_contrast = 0, cf_invert = 0, cf_grayscale = 0")
|
|
||||||
abstract suspend fun resetColorFilters()
|
|
||||||
|
|
||||||
@Upsert
|
@Upsert
|
||||||
abstract suspend fun upsert(pref: MangaPrefsEntity)
|
abstract suspend fun upsert(pref: MangaPrefsEntity)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,59 +4,36 @@ import androidx.room.Dao
|
|||||||
import androidx.room.Insert
|
import androidx.room.Insert
|
||||||
import androidx.room.OnConflictStrategy
|
import androidx.room.OnConflictStrategy
|
||||||
import androidx.room.Query
|
import androidx.room.Query
|
||||||
import androidx.room.RawQuery
|
|
||||||
import androidx.room.Transaction
|
import androidx.room.Transaction
|
||||||
import androidx.sqlite.db.SupportSQLiteQuery
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import org.koitharu.kotatsu.core.db.MangaQueryBuilder
|
|
||||||
import org.koitharu.kotatsu.list.domain.ListFilterOption
|
|
||||||
import org.koitharu.kotatsu.tracker.data.TrackLogEntity
|
import org.koitharu.kotatsu.tracker.data.TrackLogEntity
|
||||||
import org.koitharu.kotatsu.tracker.data.TrackLogWithManga
|
import org.koitharu.kotatsu.tracker.data.TrackLogWithManga
|
||||||
|
|
||||||
@Dao
|
@Dao
|
||||||
abstract class TrackLogsDao : MangaQueryBuilder.ConditionCallback {
|
interface TrackLogsDao {
|
||||||
|
|
||||||
fun observeAll(
|
|
||||||
limit: Int,
|
|
||||||
filterOptions: Set<ListFilterOption>,
|
|
||||||
): Flow<List<TrackLogWithManga>> = observeAllImpl(
|
|
||||||
MangaQueryBuilder("track_logs", this)
|
|
||||||
.filters(filterOptions)
|
|
||||||
.limit(limit)
|
|
||||||
.orderBy("created_at DESC")
|
|
||||||
.build(),
|
|
||||||
)
|
|
||||||
|
|
||||||
@Query("SELECT COUNT(*) FROM track_logs WHERE unread = 1")
|
|
||||||
abstract fun observeUnreadCount(): Flow<Int>
|
|
||||||
|
|
||||||
@Query("DELETE FROM track_logs")
|
|
||||||
abstract suspend fun clear()
|
|
||||||
|
|
||||||
@Query("UPDATE track_logs SET unread = 0 WHERE id = :id")
|
|
||||||
abstract suspend fun markAsRead(id: Long)
|
|
||||||
|
|
||||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
|
||||||
abstract suspend fun insert(entity: TrackLogEntity): Long
|
|
||||||
|
|
||||||
@Query("DELETE FROM track_logs WHERE manga_id NOT IN (SELECT manga_id FROM tracks)")
|
|
||||||
abstract suspend fun gc()
|
|
||||||
|
|
||||||
@Query("DELETE FROM track_logs WHERE id IN (SELECT id FROM track_logs ORDER BY created_at DESC LIMIT 0 OFFSET :size)")
|
|
||||||
abstract suspend fun trim(size: Int)
|
|
||||||
|
|
||||||
@Query("SELECT COUNT(*) FROM track_logs")
|
|
||||||
abstract suspend fun count(): Int
|
|
||||||
|
|
||||||
@Transaction
|
@Transaction
|
||||||
@RawQuery(observedEntities = [TrackLogEntity::class])
|
@Query("SELECT * FROM track_logs ORDER BY created_at DESC LIMIT :limit OFFSET 0")
|
||||||
protected abstract fun observeAllImpl(query: SupportSQLiteQuery): Flow<List<TrackLogWithManga>>
|
fun observeAll(limit: Int): Flow<List<TrackLogWithManga>>
|
||||||
|
|
||||||
override fun getCondition(option: ListFilterOption): String? = when (option) {
|
@Query("SELECT COUNT(*) FROM track_logs WHERE unread = 1")
|
||||||
ListFilterOption.Macro.FAVORITE -> "EXISTS(SELECT * FROM favourites WHERE favourites.manga_id = track_logs.manga_id)"
|
fun observeUnreadCount(): Flow<Int>
|
||||||
is ListFilterOption.Favorite -> "EXISTS(SELECT * FROM favourites WHERE favourites.manga_id = track_logs.manga_id AND favourites.category_id = ${option.category.id})"
|
|
||||||
is ListFilterOption.Tag -> "EXISTS(SELECT * FROM manga_tags WHERE manga_tags.manga_id = track_logs.manga_id AND tag_id = ${option.tagId})"
|
@Query("DELETE FROM track_logs")
|
||||||
ListFilterOption.Macro.NSFW -> "(SELECT nsfw FROM manga WHERE manga.manga_id = track_logs.manga_id) = 1"
|
suspend fun clear()
|
||||||
else -> null
|
|
||||||
}
|
@Query("UPDATE track_logs SET unread = 0 WHERE id = :id")
|
||||||
|
suspend fun markAsRead(id: Long)
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun insert(entity: TrackLogEntity): Long
|
||||||
|
|
||||||
|
@Query("DELETE FROM track_logs WHERE manga_id NOT IN (SELECT manga_id FROM tracks)")
|
||||||
|
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")
|
||||||
|
suspend fun count(): Int
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,8 +39,6 @@ fun MangaEntity.toManga(tags: Set<MangaTag>) = Manga(
|
|||||||
|
|
||||||
fun MangaWithTags.toManga() = manga.toManga(tags.toMangaTags())
|
fun MangaWithTags.toManga() = manga.toManga(tags.toMangaTags())
|
||||||
|
|
||||||
fun Collection<MangaWithTags>.toMangaList() = map { it.toManga() }
|
|
||||||
|
|
||||||
// Model to entity
|
// Model to entity
|
||||||
|
|
||||||
fun Manga.toEntity() = MangaEntity(
|
fun Manga.toEntity() = MangaEntity(
|
||||||
@@ -49,7 +47,7 @@ fun Manga.toEntity() = MangaEntity(
|
|||||||
publicUrl = publicUrl,
|
publicUrl = publicUrl,
|
||||||
source = source.name,
|
source = source.name,
|
||||||
largeCoverUrl = largeCoverUrl,
|
largeCoverUrl = largeCoverUrl,
|
||||||
coverUrl = coverUrl.orEmpty(),
|
coverUrl = coverUrl,
|
||||||
altTitle = altTitle,
|
altTitle = altTitle,
|
||||||
rating = rating,
|
rating = rating,
|
||||||
isNsfw = isNsfw,
|
isNsfw = isNsfw,
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ data class MangaEntity(
|
|||||||
@ColumnInfo(name = "url") val url: String,
|
@ColumnInfo(name = "url") val url: String,
|
||||||
@ColumnInfo(name = "public_url") val publicUrl: String,
|
@ColumnInfo(name = "public_url") val publicUrl: String,
|
||||||
@ColumnInfo(name = "rating") val rating: Float, // normalized value [0..1] or -1
|
@ColumnInfo(name = "rating") val rating: Float, // normalized value [0..1] or -1
|
||||||
@ColumnInfo(name = "nsfw") val isNsfw: Boolean, // TODO change to contentRating
|
@ColumnInfo(name = "nsfw") val isNsfw: Boolean,
|
||||||
@ColumnInfo(name = "cover_url") val coverUrl: String,
|
@ColumnInfo(name = "cover_url") val coverUrl: String,
|
||||||
@ColumnInfo(name = "large_cover_url") val largeCoverUrl: String?,
|
@ColumnInfo(name = "large_cover_url") val largeCoverUrl: String?,
|
||||||
@ColumnInfo(name = "state") val state: String?,
|
@ColumnInfo(name = "state") val state: String?,
|
||||||
|
|||||||
@@ -14,7 +14,4 @@ data class MangaSourceEntity(
|
|||||||
val source: String,
|
val source: String,
|
||||||
@ColumnInfo(name = "enabled") val isEnabled: Boolean,
|
@ColumnInfo(name = "enabled") val isEnabled: Boolean,
|
||||||
@ColumnInfo(name = "sort_key", index = true) val sortKey: Int,
|
@ColumnInfo(name = "sort_key", index = true) val sortKey: Int,
|
||||||
@ColumnInfo(name = "added_in") val addedIn: Int,
|
|
||||||
@ColumnInfo(name = "used_at") val lastUsedAt: Long,
|
|
||||||
@ColumnInfo(name = "pinned") val isPinned: Boolean,
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import android.content.Context
|
|||||||
import androidx.preference.PreferenceManager
|
import androidx.preference.PreferenceManager
|
||||||
import androidx.room.migration.Migration
|
import androidx.room.migration.Migration
|
||||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaParserSource
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
|
|
||||||
class Migration16To17(context: Context) : Migration(16, 17) {
|
class Migration16To17(context: Context) : Migration(16, 17) {
|
||||||
|
|
||||||
@@ -15,8 +15,11 @@ class Migration16To17(context: Context) : Migration(16, 17) {
|
|||||||
db.execSQL("CREATE INDEX `index_sources_sort_key` ON `sources` (`sort_key`)")
|
db.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 = MangaParserSource.entries
|
val sources = MangaSource.entries
|
||||||
for (source in sources) {
|
for (source in sources) {
|
||||||
|
if (source == MangaSource.LOCAL) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
val name = source.name
|
val name = source.name
|
||||||
val isHidden = name in hiddenSources
|
val isHidden = name in hiddenSources
|
||||||
var sortKey = order.indexOf(name)
|
var sortKey = order.indexOf(name)
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.db.migrations
|
|
||||||
|
|
||||||
import androidx.room.migration.Migration
|
|
||||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
|
||||||
|
|
||||||
class Migration20To21 : Migration(20, 21) {
|
|
||||||
|
|
||||||
override fun migrate(db: SupportSQLiteDatabase) {
|
|
||||||
db.execSQL("ALTER TABLE tracks ADD COLUMN `last_error` TEXT DEFAULT NULL")
|
|
||||||
db.execSQL("ALTER TABLE sources ADD COLUMN `added_in` 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 Migration21To22 : Migration(21, 22) {
|
|
||||||
|
|
||||||
override fun migrate(db: SupportSQLiteDatabase) {
|
|
||||||
db.execSQL("ALTER TABLE sources ADD COLUMN `used_at` INTEGER NOT NULL DEFAULT 0")
|
|
||||||
db.execSQL("ALTER TABLE sources ADD COLUMN `pinned` INTEGER NOT NULL DEFAULT 0")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.db.migrations
|
|
||||||
|
|
||||||
import androidx.room.migration.Migration
|
|
||||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
|
||||||
|
|
||||||
class Migration22To23 : Migration(22, 23) {
|
|
||||||
|
|
||||||
override fun migrate(db: SupportSQLiteDatabase) {
|
|
||||||
db.execSQL("CREATE TABLE IF NOT EXISTS `local_index` (`manga_id` INTEGER NOT NULL, `path` TEXT NOT NULL, PRIMARY KEY(`manga_id`), FOREIGN KEY(`manga_id`) REFERENCES `manga`(`manga_id`) ON UPDATE NO ACTION ON DELETE CASCADE )")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
package org.koitharu.kotatsu.core.exceptions
|
package org.koitharu.kotatsu.core.exceptions
|
||||||
|
|
||||||
class CaughtException(
|
class CaughtException(cause: Throwable, override val message: String?) : RuntimeException(cause)
|
||||||
override val cause: Throwable
|
|
||||||
) : RuntimeException("${cause.javaClass.simpleName}(${cause.message})", cause)
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package org.koitharu.kotatsu.core.exceptions
|
package org.koitharu.kotatsu.core.exceptions
|
||||||
|
|
||||||
|
import okhttp3.Headers
|
||||||
import okio.IOException
|
import okio.IOException
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.exceptions
|
|
||||||
|
|
||||||
class IncompatiblePluginException(
|
|
||||||
val name: String?,
|
|
||||||
cause: Throwable?,
|
|
||||||
) : RuntimeException(cause)
|
|
||||||
@@ -3,5 +3,5 @@ package org.koitharu.kotatsu.core.exceptions
|
|||||||
import okio.IOException
|
import okio.IOException
|
||||||
|
|
||||||
class NoDataReceivedException(
|
class NoDataReceivedException(
|
||||||
val url: String,
|
private val url: String,
|
||||||
) : IOException("No data has been received from $url")
|
) : IOException("No data has been received from $url")
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.exceptions
|
|
||||||
|
|
||||||
import java.net.ProtocolException
|
|
||||||
|
|
||||||
class ProxyConfigException : ProtocolException("Wrong proxy configuration")
|
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package org.koitharu.kotatsu.core.exceptions
|
||||||
|
|
||||||
|
import okio.IOException
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.temporal.ChronoUnit
|
||||||
|
|
||||||
|
class TooManyRequestExceptions(
|
||||||
|
val url: String,
|
||||||
|
val retryAt: Instant?,
|
||||||
|
) : IOException() {
|
||||||
|
val retryAfter: Long
|
||||||
|
get() = retryAt?.until(Instant.now(), ChronoUnit.MILLIS) ?: 0
|
||||||
|
}
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.exceptions
|
|
||||||
|
|
||||||
import okio.IOException
|
|
||||||
|
|
||||||
class WrapperIOException(override val cause: Exception) : IOException(cause)
|
|
||||||
@@ -8,7 +8,6 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
|||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.core.ui.dialog.ErrorDetailsDialog
|
import org.koitharu.kotatsu.core.ui.dialog.ErrorDetailsDialog
|
||||||
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
||||||
import org.koitharu.kotatsu.core.util.ext.isSerializable
|
|
||||||
import org.koitharu.kotatsu.parsers.exception.ParseException
|
import org.koitharu.kotatsu.parsers.exception.ParseException
|
||||||
|
|
||||||
class DialogErrorObserver(
|
class DialogErrorObserver(
|
||||||
@@ -33,7 +32,7 @@ class DialogErrorObserver(
|
|||||||
dialogBuilder.setPositiveButton(ExceptionResolver.getResolveStringId(value), listener)
|
dialogBuilder.setPositiveButton(ExceptionResolver.getResolveStringId(value), listener)
|
||||||
} else if (value is ParseException) {
|
} else if (value is ParseException) {
|
||||||
val fm = fragmentManager
|
val fm = fragmentManager
|
||||||
if (fm != null && value.isSerializable()) {
|
if (fm != null) {
|
||||||
dialogBuilder.setPositiveButton(R.string.details) { _, _ ->
|
dialogBuilder.setPositiveButton(R.string.details) { _, _ ->
|
||||||
ErrorDetailsDialog.show(fm, value, value.url)
|
ErrorDetailsDialog.show(fm, value, value.url)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,74 +1,61 @@
|
|||||||
package org.koitharu.kotatsu.core.exceptions.resolve
|
package org.koitharu.kotatsu.core.exceptions.resolve
|
||||||
|
|
||||||
import android.content.Context
|
import androidx.activity.result.ActivityResultCallback
|
||||||
import android.widget.Toast
|
import androidx.activity.result.ActivityResultLauncher
|
||||||
import androidx.activity.result.ActivityResultCaller
|
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
import androidx.collection.MutableScatterMap
|
import androidx.collection.ArrayMap
|
||||||
import androidx.fragment.app.FragmentManager
|
import androidx.fragment.app.Fragment
|
||||||
import dagger.assisted.Assisted
|
import androidx.fragment.app.FragmentActivity
|
||||||
import dagger.assisted.AssistedFactory
|
|
||||||
import dagger.assisted.AssistedInject
|
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.alternatives.ui.AlternativesActivity
|
import org.koitharu.kotatsu.alternatives.ui.AlternativesActivity
|
||||||
import org.koitharu.kotatsu.browser.BrowserActivity
|
import org.koitharu.kotatsu.browser.BrowserActivity
|
||||||
import org.koitharu.kotatsu.browser.cloudflare.CloudFlareActivity
|
import org.koitharu.kotatsu.browser.cloudflare.CloudFlareActivity
|
||||||
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
|
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
|
||||||
import org.koitharu.kotatsu.core.exceptions.ProxyConfigException
|
|
||||||
import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException
|
import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException
|
||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
|
||||||
import org.koitharu.kotatsu.core.ui.dialog.ErrorDetailsDialog
|
import org.koitharu.kotatsu.core.ui.dialog.ErrorDetailsDialog
|
||||||
import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog
|
import org.koitharu.kotatsu.core.util.TaggedActivityResult
|
||||||
import org.koitharu.kotatsu.core.util.ext.restartApplication
|
|
||||||
import org.koitharu.kotatsu.parsers.exception.AuthRequiredException
|
import org.koitharu.kotatsu.parsers.exception.AuthRequiredException
|
||||||
import org.koitharu.kotatsu.parsers.exception.NotFoundException
|
import org.koitharu.kotatsu.parsers.exception.NotFoundException
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
import org.koitharu.kotatsu.scrobbling.common.domain.ScrobblerAuthRequiredException
|
|
||||||
import org.koitharu.kotatsu.scrobbling.common.ui.ScrobblerAuthHelper
|
|
||||||
import org.koitharu.kotatsu.settings.SettingsActivity
|
|
||||||
import org.koitharu.kotatsu.settings.sources.auth.SourceAuthActivity
|
import org.koitharu.kotatsu.settings.sources.auth.SourceAuthActivity
|
||||||
import java.security.cert.CertPathValidatorException
|
|
||||||
import javax.inject.Provider
|
|
||||||
import javax.net.ssl.SSLException
|
|
||||||
import kotlin.coroutines.Continuation
|
import kotlin.coroutines.Continuation
|
||||||
import kotlin.coroutines.resume
|
import kotlin.coroutines.resume
|
||||||
import kotlin.coroutines.suspendCoroutine
|
import kotlin.coroutines.suspendCoroutine
|
||||||
|
|
||||||
class ExceptionResolver @AssistedInject constructor(
|
class ExceptionResolver : ActivityResultCallback<TaggedActivityResult> {
|
||||||
@Assisted private val host: Host,
|
|
||||||
private val settings: AppSettings,
|
|
||||||
private val scrobblerAuthHelperProvider: Provider<ScrobblerAuthHelper>,
|
|
||||||
) {
|
|
||||||
private val continuations = MutableScatterMap<String, Continuation<Boolean>>(1)
|
|
||||||
|
|
||||||
private val sourceAuthContract = host.registerForActivityResult(SourceAuthActivity.Contract()) {
|
private val continuations = ArrayMap<String, Continuation<Boolean>>(1)
|
||||||
handleActivityResult(SourceAuthActivity.TAG, it)
|
private val activity: FragmentActivity?
|
||||||
|
private val fragment: Fragment?
|
||||||
|
private val sourceAuthContract: ActivityResultLauncher<MangaSource>
|
||||||
|
private val cloudflareContract: ActivityResultLauncher<CloudFlareProtectedException>
|
||||||
|
|
||||||
|
constructor(activity: FragmentActivity) {
|
||||||
|
this.activity = activity
|
||||||
|
fragment = null
|
||||||
|
sourceAuthContract = activity.registerForActivityResult(SourceAuthActivity.Contract(), this)
|
||||||
|
cloudflareContract = activity.registerForActivityResult(CloudFlareActivity.Contract(), this)
|
||||||
}
|
}
|
||||||
private val cloudflareContract = host.registerForActivityResult(CloudFlareActivity.Contract()) {
|
|
||||||
handleActivityResult(CloudFlareActivity.TAG, it)
|
constructor(fragment: Fragment) {
|
||||||
|
this.fragment = fragment
|
||||||
|
activity = null
|
||||||
|
sourceAuthContract = fragment.registerForActivityResult(SourceAuthActivity.Contract(), this)
|
||||||
|
cloudflareContract = fragment.registerForActivityResult(CloudFlareActivity.Contract(), this)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onActivityResult(result: TaggedActivityResult) {
|
||||||
|
continuations.remove(result.tag)?.resume(result.isSuccess)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun showDetails(e: Throwable, url: String?) {
|
fun showDetails(e: Throwable, url: String?) {
|
||||||
ErrorDetailsDialog.show(host.getChildFragmentManager(), e, url)
|
ErrorDetailsDialog.show(getFragmentManager(), e, url)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun resolve(e: Throwable): Boolean = when (e) {
|
suspend fun resolve(e: Throwable): Boolean = when (e) {
|
||||||
is CloudFlareProtectedException -> resolveCF(e)
|
is CloudFlareProtectedException -> resolveCF(e)
|
||||||
is AuthRequiredException -> resolveAuthException(e.source)
|
is AuthRequiredException -> resolveAuthException(e.source)
|
||||||
is SSLException,
|
|
||||||
is CertPathValidatorException -> {
|
|
||||||
showSslErrorDialog()
|
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|
||||||
is ProxyConfigException -> {
|
|
||||||
host.withContext {
|
|
||||||
startActivity(SettingsActivity.newProxySettingsIntent(this))
|
|
||||||
}
|
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|
||||||
is NotFoundException -> {
|
is NotFoundException -> {
|
||||||
openInBrowser(e.url)
|
openInBrowser(e.url)
|
||||||
false
|
false
|
||||||
@@ -79,20 +66,6 @@ class ExceptionResolver @AssistedInject constructor(
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
is ScrobblerAuthRequiredException -> {
|
|
||||||
val authHelper = scrobblerAuthHelperProvider.get()
|
|
||||||
if (authHelper.isAuthorized(e.scrobbler)) {
|
|
||||||
true
|
|
||||||
} else {
|
|
||||||
host.withContext {
|
|
||||||
authHelper.startAuth(this, e.scrobbler).onFailure {
|
|
||||||
showDetails(it, null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> false
|
else -> false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,68 +79,26 @@ class ExceptionResolver @AssistedInject constructor(
|
|||||||
sourceAuthContract.launch(source)
|
sourceAuthContract.launch(source)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun openInBrowser(url: String) = host.withContext {
|
private fun openInBrowser(url: String) {
|
||||||
startActivity(BrowserActivity.newIntent(this, url, null, null))
|
val context = activity ?: fragment?.activity ?: return
|
||||||
|
context.startActivity(BrowserActivity.newIntent(context, url, null, null))
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun openAlternatives(manga: Manga) = host.withContext {
|
private fun openAlternatives(manga: Manga) {
|
||||||
startActivity(AlternativesActivity.newIntent(this, manga))
|
val context = activity ?: fragment?.activity ?: return
|
||||||
|
context.startActivity(AlternativesActivity.newIntent(context, manga))
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handleActivityResult(tag: String, result: Boolean) {
|
private fun getFragmentManager() = checkNotNull(fragment?.childFragmentManager ?: activity?.supportFragmentManager)
|
||||||
continuations.remove(tag)?.resume(result)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun showSslErrorDialog() {
|
|
||||||
val ctx = host.getContext() ?: return
|
|
||||||
if (settings.isSSLBypassEnabled) {
|
|
||||||
Toast.makeText(ctx, R.string.operation_not_supported, Toast.LENGTH_SHORT).show()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
buildAlertDialog(ctx) {
|
|
||||||
setTitle(R.string.ignore_ssl_errors)
|
|
||||||
setMessage(R.string.ignore_ssl_errors_summary)
|
|
||||||
setPositiveButton(R.string.apply) { _, _ ->
|
|
||||||
settings.isSSLBypassEnabled = true
|
|
||||||
Toast.makeText(ctx, R.string.settings_apply_restart_required, Toast.LENGTH_LONG).show()
|
|
||||||
ctx.restartApplication()
|
|
||||||
}
|
|
||||||
setNegativeButton(android.R.string.cancel, null)
|
|
||||||
}.show()
|
|
||||||
}
|
|
||||||
|
|
||||||
private inline fun Host.withContext(block: Context.() -> Unit) {
|
|
||||||
getContext()?.apply(block)
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Host : ActivityResultCaller {
|
|
||||||
|
|
||||||
fun getChildFragmentManager(): FragmentManager
|
|
||||||
|
|
||||||
fun getContext(): Context?
|
|
||||||
}
|
|
||||||
|
|
||||||
@AssistedFactory
|
|
||||||
interface Factory {
|
|
||||||
|
|
||||||
fun create(host: Host): ExceptionResolver
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
||||||
@StringRes
|
@StringRes
|
||||||
fun getResolveStringId(e: Throwable) = when (e) {
|
fun getResolveStringId(e: Throwable) = when (e) {
|
||||||
is CloudFlareProtectedException -> R.string.captcha_solve
|
is CloudFlareProtectedException -> R.string.captcha_solve
|
||||||
is ScrobblerAuthRequiredException,
|
|
||||||
is AuthRequiredException -> R.string.sign_in
|
is AuthRequiredException -> R.string.sign_in
|
||||||
|
|
||||||
is NotFoundException -> if (e.url.isNotEmpty()) R.string.open_in_browser else 0
|
is NotFoundException -> if (e.url.isNotEmpty()) R.string.open_in_browser else 0
|
||||||
is UnsupportedSourceException -> if (e.manga != null) R.string.alternatives else 0
|
is UnsupportedSourceException -> if (e.manga != null) R.string.alternatives else 0
|
||||||
is SSLException,
|
|
||||||
is CertPathValidatorException -> R.string.fix
|
|
||||||
|
|
||||||
is ProxyConfigException -> R.string.settings
|
|
||||||
|
|
||||||
else -> 0
|
else -> 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import com.google.android.material.snackbar.Snackbar
|
|||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.core.ui.dialog.ErrorDetailsDialog
|
import org.koitharu.kotatsu.core.ui.dialog.ErrorDetailsDialog
|
||||||
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
||||||
import org.koitharu.kotatsu.core.util.ext.isSerializable
|
|
||||||
import org.koitharu.kotatsu.main.ui.owners.BottomNavOwner
|
import org.koitharu.kotatsu.main.ui.owners.BottomNavOwner
|
||||||
import org.koitharu.kotatsu.parsers.exception.ParseException
|
import org.koitharu.kotatsu.parsers.exception.ParseException
|
||||||
|
|
||||||
@@ -34,7 +33,7 @@ class SnackbarErrorObserver(
|
|||||||
}
|
}
|
||||||
} else if (value is ParseException) {
|
} else if (value is ParseException) {
|
||||||
val fm = fragmentManager
|
val fm = fragmentManager
|
||||||
if (fm != null && value.isSerializable()) {
|
if (fm != null) {
|
||||||
snackbar.setAction(R.string.details) {
|
snackbar.setAction(R.string.details) {
|
||||||
ErrorDetailsDialog.show(fm, value, value.url)
|
ErrorDetailsDialog.show(fm, value, value.url)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,31 +1,20 @@
|
|||||||
package org.koitharu.kotatsu.core.fs
|
package org.koitharu.kotatsu.core.fs
|
||||||
|
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import androidx.annotation.RequiresApi
|
import org.koitharu.kotatsu.core.util.iterator.CloseableIterator
|
||||||
import org.koitharu.kotatsu.core.util.CloseableSequence
|
|
||||||
import org.koitharu.kotatsu.core.util.iterator.MappingIterator
|
import org.koitharu.kotatsu.core.util.iterator.MappingIterator
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.nio.file.Files
|
import java.nio.file.Files
|
||||||
import java.nio.file.Path
|
import java.nio.file.Path
|
||||||
|
|
||||||
sealed interface FileSequence : CloseableSequence<File> {
|
class FileSequence(private val dir: File) : Sequence<File> {
|
||||||
|
|
||||||
@RequiresApi(Build.VERSION_CODES.O)
|
override fun iterator(): Iterator<File> {
|
||||||
class StreamImpl(dir: File) : FileSequence {
|
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
val stream = Files.newDirectoryStream(dir.toPath())
|
||||||
private val stream = Files.newDirectoryStream(dir.toPath())
|
CloseableIterator(MappingIterator(stream.iterator(), Path::toFile), stream)
|
||||||
|
} else {
|
||||||
override fun iterator(): Iterator<File> = MappingIterator(stream.iterator(), Path::toFile)
|
dir.listFiles().orEmpty().iterator()
|
||||||
|
}
|
||||||
override fun close() = stream.close()
|
|
||||||
}
|
|
||||||
|
|
||||||
class ListImpl(dir: File) : FileSequence {
|
|
||||||
|
|
||||||
private val list = dir.listFiles().orEmpty()
|
|
||||||
|
|
||||||
override fun iterator(): Iterator<File> = list.iterator()
|
|
||||||
|
|
||||||
override fun close() = Unit
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
package org.koitharu.kotatsu.core.github
|
package org.koitharu.kotatsu.core.github
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
@@ -11,7 +9,6 @@ import okhttp3.Request
|
|||||||
import org.json.JSONArray
|
import org.json.JSONArray
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import org.koitharu.kotatsu.BuildConfig
|
import org.koitharu.kotatsu.BuildConfig
|
||||||
import org.koitharu.kotatsu.R
|
|
||||||
import org.koitharu.kotatsu.core.network.BaseHttpClient
|
import org.koitharu.kotatsu.core.network.BaseHttpClient
|
||||||
import org.koitharu.kotatsu.core.os.AppValidator
|
import org.koitharu.kotatsu.core.os.AppValidator
|
||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
@@ -25,29 +22,22 @@ import javax.inject.Inject
|
|||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
private const val CONTENT_TYPE_APK = "application/vnd.android.package-archive"
|
private const val CONTENT_TYPE_APK = "application/vnd.android.package-archive"
|
||||||
private const val BUILD_TYPE_RELEASE = "release"
|
|
||||||
|
|
||||||
@Singleton
|
@Singleton
|
||||||
class AppUpdateRepository @Inject constructor(
|
class AppUpdateRepository @Inject constructor(
|
||||||
private val appValidator: AppValidator,
|
private val appValidator: AppValidator,
|
||||||
private val settings: AppSettings,
|
private val settings: AppSettings,
|
||||||
@BaseHttpClient private val okHttp: OkHttpClient,
|
@BaseHttpClient private val okHttp: OkHttpClient,
|
||||||
@ApplicationContext context: Context,
|
|
||||||
) {
|
) {
|
||||||
|
|
||||||
private val availableUpdate = MutableStateFlow<AppVersion?>(null)
|
private val availableUpdate = MutableStateFlow<AppVersion?>(null)
|
||||||
private val releasesUrl = buildString {
|
|
||||||
append("https://api.github.com/repos/")
|
|
||||||
append(context.getString(R.string.github_updates_repo))
|
|
||||||
append("/releases?page=1&per_page=10")
|
|
||||||
}
|
|
||||||
|
|
||||||
fun observeAvailableUpdate() = availableUpdate.asStateFlow()
|
fun observeAvailableUpdate() = availableUpdate.asStateFlow()
|
||||||
|
|
||||||
suspend fun getAvailableVersions(): List<AppVersion> {
|
suspend fun getAvailableVersions(): List<AppVersion> {
|
||||||
val request = Request.Builder()
|
val request = Request.Builder()
|
||||||
.get()
|
.get()
|
||||||
.url(releasesUrl)
|
.url("https://api.github.com/repos/KotatsuApp/Kotatsu/releases?page=1&per_page=10")
|
||||||
val jsonArray = okHttp.newCall(request.build()).await().parseJsonArray()
|
val jsonArray = okHttp.newCall(request.build()).await().parseJsonArray()
|
||||||
return jsonArray.mapJSONNotNull { json ->
|
return jsonArray.mapJSONNotNull { json ->
|
||||||
val asset = json.optJSONArray("assets")?.find { jo ->
|
val asset = json.optJSONArray("assets")?.find { jo ->
|
||||||
@@ -84,9 +74,8 @@ class AppUpdateRepository @Inject constructor(
|
|||||||
}.getOrNull()
|
}.getOrNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suppress("KotlinConstantConditions")
|
|
||||||
fun isUpdateSupported(): Boolean {
|
fun isUpdateSupported(): Boolean {
|
||||||
return BuildConfig.BUILD_TYPE != BUILD_TYPE_RELEASE || appValidator.isOriginalApp
|
return BuildConfig.DEBUG || appValidator.isOriginalApp
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getCurrentVersionChangelog(): String? {
|
suspend fun getCurrentVersionChangelog(): String? {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package org.koitharu.kotatsu.core.github
|
package org.koitharu.kotatsu.core.github
|
||||||
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.digits
|
import java.util.*
|
||||||
import java.util.Locale
|
|
||||||
|
|
||||||
data class VersionId(
|
data class VersionId(
|
||||||
val major: Int,
|
val major: Int,
|
||||||
@@ -44,16 +43,6 @@ val VersionId.isStable: Boolean
|
|||||||
get() = variantType.isEmpty()
|
get() = variantType.isEmpty()
|
||||||
|
|
||||||
fun VersionId(versionName: String): VersionId {
|
fun VersionId(versionName: String): VersionId {
|
||||||
if (versionName.startsWith('n', ignoreCase = true)) {
|
|
||||||
// Nightly build
|
|
||||||
return VersionId(
|
|
||||||
major = 0,
|
|
||||||
minor = 0,
|
|
||||||
build = versionName.digits().toIntOrNull() ?: 0,
|
|
||||||
variantType = "n",
|
|
||||||
variantNumber = 0,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
val parts = versionName.substringBeforeLast('-').split('.')
|
val parts = versionName.substringBeforeLast('-').split('.')
|
||||||
val variant = versionName.substringAfterLast('-', "")
|
val variant = versionName.substringAfterLast('-', "")
|
||||||
return VersionId(
|
return VersionId(
|
||||||
|
|||||||
@@ -1,66 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.image
|
|
||||||
|
|
||||||
import android.graphics.Bitmap
|
|
||||||
import coil3.ImageLoader
|
|
||||||
import coil3.asImage
|
|
||||||
import coil3.decode.DecodeResult
|
|
||||||
import coil3.decode.Decoder
|
|
||||||
import coil3.decode.ImageSource
|
|
||||||
import coil3.fetch.SourceFetchResult
|
|
||||||
import coil3.request.Options
|
|
||||||
import com.davemorrissey.labs.subscaleview.decoder.ImageDecodeException
|
|
||||||
import kotlinx.coroutines.runInterruptible
|
|
||||||
import org.aomedia.avif.android.AvifDecoder
|
|
||||||
import org.aomedia.avif.android.AvifDecoder.Info
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.toByteBuffer
|
|
||||||
|
|
||||||
class AvifImageDecoder(
|
|
||||||
private val source: ImageSource,
|
|
||||||
private val options: Options,
|
|
||||||
) : Decoder {
|
|
||||||
|
|
||||||
override suspend fun decode(): DecodeResult = runInterruptible {
|
|
||||||
val bytes = source.source().use {
|
|
||||||
it.inputStream().toByteBuffer()
|
|
||||||
}
|
|
||||||
val info = Info()
|
|
||||||
if (!AvifDecoder.getInfo(bytes, bytes.remaining(), info)) {
|
|
||||||
throw ImageDecodeException(
|
|
||||||
null,
|
|
||||||
"avif",
|
|
||||||
"Requested to decode byte buffer which cannot be handled by AvifDecoder",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
val config = if (info.depth == 8 || info.alphaPresent) Bitmap.Config.ARGB_8888 else Bitmap.Config.RGB_565
|
|
||||||
val bitmap = Bitmap.createBitmap(info.width, info.height, config)
|
|
||||||
if (!AvifDecoder.decode(bytes, bytes.remaining(), bitmap)) {
|
|
||||||
bitmap.recycle()
|
|
||||||
throw ImageDecodeException(null, "avif")
|
|
||||||
}
|
|
||||||
DecodeResult(
|
|
||||||
image = bitmap.asImage(),
|
|
||||||
isSampled = false,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
class Factory : Decoder.Factory {
|
|
||||||
|
|
||||||
override fun create(
|
|
||||||
result: SourceFetchResult,
|
|
||||||
options: Options,
|
|
||||||
imageLoader: ImageLoader
|
|
||||||
): Decoder? = if (isApplicable(result)) {
|
|
||||||
AvifImageDecoder(result.source, options)
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun equals(other: Any?) = other is Factory
|
|
||||||
|
|
||||||
override fun hashCode() = javaClass.hashCode()
|
|
||||||
|
|
||||||
private fun isApplicable(result: SourceFetchResult): Boolean {
|
|
||||||
return result.mimeType == "image/avif"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.image
|
|
||||||
|
|
||||||
import android.graphics.Bitmap
|
|
||||||
import android.graphics.BitmapFactory
|
|
||||||
import android.graphics.ImageDecoder
|
|
||||||
import android.os.Build
|
|
||||||
import android.webkit.MimeTypeMap
|
|
||||||
import androidx.annotation.RequiresApi
|
|
||||||
import com.davemorrissey.labs.subscaleview.decoder.ImageDecodeException
|
|
||||||
import okhttp3.MediaType
|
|
||||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
|
||||||
import org.aomedia.avif.android.AvifDecoder
|
|
||||||
import org.aomedia.avif.android.AvifDecoder.Info
|
|
||||||
import org.jetbrains.annotations.Blocking
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.toByteBuffer
|
|
||||||
import java.io.File
|
|
||||||
import java.io.InputStream
|
|
||||||
import java.nio.ByteBuffer
|
|
||||||
import java.nio.file.Files
|
|
||||||
|
|
||||||
object BitmapDecoderCompat {
|
|
||||||
|
|
||||||
private const val FORMAT_AVIF = "avif"
|
|
||||||
|
|
||||||
@Blocking
|
|
||||||
fun decode(file: File): Bitmap = when (val format = getMimeType(file)?.subtype) {
|
|
||||||
FORMAT_AVIF -> file.inputStream().use { decodeAvif(it.toByteBuffer()) }
|
|
||||||
else -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
|
||||||
ImageDecoder.decodeBitmap(ImageDecoder.createSource(file))
|
|
||||||
} else {
|
|
||||||
checkBitmapNotNull(BitmapFactory.decodeFile(file.absolutePath), format)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Blocking
|
|
||||||
fun decode(stream: InputStream, type: MediaType?, isMutable: Boolean = false): Bitmap {
|
|
||||||
val format = type?.subtype
|
|
||||||
if (format == FORMAT_AVIF) {
|
|
||||||
return decodeAvif(stream.toByteBuffer())
|
|
||||||
}
|
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) {
|
|
||||||
val opts = BitmapFactory.Options()
|
|
||||||
opts.inMutable = isMutable
|
|
||||||
return checkBitmapNotNull(BitmapFactory.decodeStream(stream, null, opts), format)
|
|
||||||
}
|
|
||||||
val byteBuffer = stream.toByteBuffer()
|
|
||||||
return if (AvifDecoder.isAvifImage(byteBuffer)) {
|
|
||||||
decodeAvif(byteBuffer)
|
|
||||||
} else {
|
|
||||||
ImageDecoder.decodeBitmap(ImageDecoder.createSource(byteBuffer), DecoderConfigListener(isMutable))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getMimeType(file: File): MediaType? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
||||||
Files.probeContentType(file.toPath())?.toMediaTypeOrNull()
|
|
||||||
} else {
|
|
||||||
MimeTypeMap.getSingleton().getMimeTypeFromExtension(file.extension)?.toMediaTypeOrNull()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun checkBitmapNotNull(bitmap: Bitmap?, format: String?): Bitmap =
|
|
||||||
bitmap ?: throw ImageDecodeException(null, format)
|
|
||||||
|
|
||||||
private fun decodeAvif(bytes: ByteBuffer): Bitmap {
|
|
||||||
val info = Info()
|
|
||||||
if (!AvifDecoder.getInfo(bytes, bytes.remaining(), info)) {
|
|
||||||
throw ImageDecodeException(
|
|
||||||
null,
|
|
||||||
FORMAT_AVIF,
|
|
||||||
"Requested to decode byte buffer which cannot be handled by AvifDecoder",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
val config = if (info.depth == 8 || info.alphaPresent) Bitmap.Config.ARGB_8888 else Bitmap.Config.RGB_565
|
|
||||||
val bitmap = Bitmap.createBitmap(info.width, info.height, config)
|
|
||||||
if (!AvifDecoder.decode(bytes, bytes.remaining(), bitmap)) {
|
|
||||||
bitmap.recycle()
|
|
||||||
throw ImageDecodeException(null, FORMAT_AVIF)
|
|
||||||
}
|
|
||||||
return bitmap
|
|
||||||
}
|
|
||||||
|
|
||||||
@RequiresApi(Build.VERSION_CODES.P)
|
|
||||||
private class DecoderConfigListener(
|
|
||||||
private val isMutable: Boolean,
|
|
||||||
) : ImageDecoder.OnHeaderDecodedListener {
|
|
||||||
|
|
||||||
override fun onHeaderDecoded(
|
|
||||||
decoder: ImageDecoder,
|
|
||||||
info: ImageDecoder.ImageInfo,
|
|
||||||
source: ImageDecoder.Source
|
|
||||||
) {
|
|
||||||
decoder.isMutableRequired = isMutable
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.image
|
|
||||||
|
|
||||||
import android.net.Uri
|
|
||||||
import android.webkit.MimeTypeMap
|
|
||||||
import coil3.ImageLoader
|
|
||||||
import coil3.decode.DataSource
|
|
||||||
import coil3.decode.ImageSource
|
|
||||||
import coil3.fetch.Fetcher
|
|
||||||
import coil3.fetch.SourceFetchResult
|
|
||||||
import coil3.request.Options
|
|
||||||
import coil3.toAndroidUri
|
|
||||||
import kotlinx.coroutines.runInterruptible
|
|
||||||
import okio.Path.Companion.toPath
|
|
||||||
import okio.openZip
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.isZipUri
|
|
||||||
import coil3.Uri as CoilUri
|
|
||||||
|
|
||||||
class CbzFetcher(
|
|
||||||
private val uri: Uri,
|
|
||||||
private val options: Options,
|
|
||||||
) : Fetcher {
|
|
||||||
|
|
||||||
override suspend fun fetch() = runInterruptible {
|
|
||||||
val filePath = uri.schemeSpecificPart.toPath()
|
|
||||||
val entryName = requireNotNull(uri.fragment)
|
|
||||||
SourceFetchResult(
|
|
||||||
source = ImageSource(entryName.toPath(), options.fileSystem.openZip(filePath)),
|
|
||||||
mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(entryName.substringAfterLast('.', "")),
|
|
||||||
dataSource = DataSource.DISK,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
class Factory : Fetcher.Factory<CoilUri> {
|
|
||||||
|
|
||||||
override fun create(
|
|
||||||
data: CoilUri,
|
|
||||||
options: Options,
|
|
||||||
imageLoader: ImageLoader
|
|
||||||
): Fetcher? {
|
|
||||||
val androidUri = data.toAndroidUri()
|
|
||||||
return if (androidUri.isZipUri()) {
|
|
||||||
CbzFetcher(androidUri, options)
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.image
|
|
||||||
|
|
||||||
import coil3.intercept.Interceptor
|
|
||||||
import coil3.network.httpHeaders
|
|
||||||
import coil3.request.ImageResult
|
|
||||||
import org.koitharu.kotatsu.core.network.CommonHeaders
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.mangaSourceKey
|
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaParserSource
|
|
||||||
|
|
||||||
class MangaSourceHeaderInterceptor : Interceptor {
|
|
||||||
|
|
||||||
override suspend fun intercept(chain: Interceptor.Chain): ImageResult {
|
|
||||||
val mangaSource = chain.request.extras[mangaSourceKey] as? MangaParserSource ?: return chain.proceed()
|
|
||||||
val request = chain.request
|
|
||||||
val newHeaders = request.httpHeaders.newBuilder()
|
|
||||||
.set(CommonHeaders.MANGA_SOURCE, mangaSource.name)
|
|
||||||
.build()
|
|
||||||
val newRequest = request.newBuilder()
|
|
||||||
.httpHeaders(newHeaders)
|
|
||||||
.build()
|
|
||||||
return chain.withRequest(newRequest).proceed()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.io
|
|
||||||
|
|
||||||
import java.io.OutputStream
|
|
||||||
import java.util.Objects
|
|
||||||
|
|
||||||
class NullOutputStream : OutputStream() {
|
|
||||||
|
|
||||||
override fun write(b: Int) = Unit
|
|
||||||
|
|
||||||
override fun write(b: ByteArray, off: Int, len: Int) {
|
|
||||||
Objects.checkFromIndexSize(off, len, b.size)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
148
app/src/main/kotlin/org/koitharu/kotatsu/core/logs/FileLogger.kt
Normal file
148
app/src/main/kotlin/org/koitharu/kotatsu/core/logs/FileLogger.kt
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
package org.koitharu.kotatsu.core.logs
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.annotation.WorkerThread
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.NonCancellable
|
||||||
|
import kotlinx.coroutines.cancelAndJoin
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlinx.coroutines.runInterruptible
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.subdir
|
||||||
|
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileOutputStream
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.time.format.FormatStyle
|
||||||
|
import java.util.Locale
|
||||||
|
import java.util.concurrent.ConcurrentLinkedQueue
|
||||||
|
|
||||||
|
private const val DIR = "logs"
|
||||||
|
private const val FLUSH_DELAY = 2_000L
|
||||||
|
private const val MAX_SIZE_BYTES = 1024 * 1024 // 1 MB
|
||||||
|
|
||||||
|
class FileLogger(
|
||||||
|
context: Context,
|
||||||
|
private val settings: AppSettings,
|
||||||
|
name: String,
|
||||||
|
) {
|
||||||
|
|
||||||
|
val file by lazy {
|
||||||
|
val dir = context.getExternalFilesDir(DIR) ?: context.filesDir.subdir(DIR)
|
||||||
|
File(dir, "$name.log")
|
||||||
|
}
|
||||||
|
val isEnabled: Boolean
|
||||||
|
get() = settings.isLoggingEnabled
|
||||||
|
private val dateTimeFormatter = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT).withLocale(Locale.ROOT)
|
||||||
|
private val buffer = ConcurrentLinkedQueue<String>()
|
||||||
|
private val mutex = Mutex()
|
||||||
|
private var flushJob: Job? = null
|
||||||
|
|
||||||
|
fun log(message: String, e: Throwable? = null) {
|
||||||
|
if (!isEnabled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val text = buildString {
|
||||||
|
append(dateTimeFormatter.format(LocalDateTime.now()))
|
||||||
|
append(": ")
|
||||||
|
if (e != null) {
|
||||||
|
append("E!")
|
||||||
|
}
|
||||||
|
append(message)
|
||||||
|
if (e != null) {
|
||||||
|
append(' ')
|
||||||
|
append(e.stackTraceToString())
|
||||||
|
appendLine()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
buffer.add(text)
|
||||||
|
postFlush()
|
||||||
|
}
|
||||||
|
|
||||||
|
inline fun log(messageProducer: () -> String) {
|
||||||
|
if (isEnabled) {
|
||||||
|
log(messageProducer())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun flush() {
|
||||||
|
if (!isEnabled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
flushJob?.cancelAndJoin()
|
||||||
|
flushImpl()
|
||||||
|
}
|
||||||
|
|
||||||
|
@WorkerThread
|
||||||
|
fun flushBlocking() {
|
||||||
|
if (!isEnabled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
runBlockingSafe { flushJob?.cancelAndJoin() }
|
||||||
|
runBlockingSafe { flushImpl() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun postFlush() {
|
||||||
|
if (flushJob?.isActive == true) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
flushJob = processLifecycleScope.launch(Dispatchers.Default) {
|
||||||
|
delay(FLUSH_DELAY)
|
||||||
|
runCatchingCancellable {
|
||||||
|
flushImpl()
|
||||||
|
}.onFailure {
|
||||||
|
it.printStackTraceDebug()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun flushImpl() = withContext(NonCancellable) {
|
||||||
|
mutex.withLock {
|
||||||
|
if (buffer.isEmpty()) {
|
||||||
|
return@withContext
|
||||||
|
}
|
||||||
|
runInterruptible(Dispatchers.IO) {
|
||||||
|
if (file.length() > MAX_SIZE_BYTES) {
|
||||||
|
rotate()
|
||||||
|
}
|
||||||
|
FileOutputStream(file, true).use {
|
||||||
|
while (true) {
|
||||||
|
val message = buffer.poll() ?: break
|
||||||
|
it.write(message.toByteArray())
|
||||||
|
it.write('\n'.code)
|
||||||
|
}
|
||||||
|
it.flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@WorkerThread
|
||||||
|
private fun rotate() {
|
||||||
|
val length = file.length()
|
||||||
|
val bakFile = File(file.parentFile, file.name + ".bak")
|
||||||
|
file.renameTo(bakFile)
|
||||||
|
bakFile.inputStream().use { input ->
|
||||||
|
input.skip(length - MAX_SIZE_BYTES / 2)
|
||||||
|
file.outputStream().use { output ->
|
||||||
|
input.copyTo(output)
|
||||||
|
output.flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bakFile.delete()
|
||||||
|
}
|
||||||
|
|
||||||
|
private inline fun runBlockingSafe(crossinline block: suspend () -> Unit) = try {
|
||||||
|
runBlocking(NonCancellable) { block() }
|
||||||
|
} catch (_: InterruptedException) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package org.koitharu.kotatsu.core.logs
|
||||||
|
|
||||||
|
import javax.inject.Qualifier
|
||||||
|
|
||||||
|
@Qualifier
|
||||||
|
@Retention(AnnotationRetention.BINARY)
|
||||||
|
annotation class TrackerLogger
|
||||||
|
|
||||||
|
@Qualifier
|
||||||
|
@Retention(AnnotationRetention.BINARY)
|
||||||
|
annotation class SyncLogger
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package org.koitharu.kotatsu.core.logs
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.collection.arraySetOf
|
||||||
|
import dagger.Module
|
||||||
|
import dagger.Provides
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import dagger.multibindings.ElementsIntoSet
|
||||||
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
|
|
||||||
|
@Module
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
object LoggersModule {
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@TrackerLogger
|
||||||
|
fun provideTrackerLogger(
|
||||||
|
@ApplicationContext context: Context,
|
||||||
|
settings: AppSettings,
|
||||||
|
) = FileLogger(context, settings, "tracker")
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@SyncLogger
|
||||||
|
fun provideSyncLogger(
|
||||||
|
@ApplicationContext context: Context,
|
||||||
|
settings: AppSettings,
|
||||||
|
) = FileLogger(context, settings, "sync")
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@ElementsIntoSet
|
||||||
|
fun provideAllLoggers(
|
||||||
|
@TrackerLogger trackerLogger: FileLogger,
|
||||||
|
@SyncLogger syncLogger: FileLogger,
|
||||||
|
): Set<@JvmSuppressWildcards FileLogger> = arraySetOf(
|
||||||
|
trackerLogger,
|
||||||
|
syncLogger,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.model
|
|
||||||
|
|
||||||
import androidx.annotation.StringRes
|
|
||||||
import org.koitharu.kotatsu.R
|
|
||||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
|
||||||
|
|
||||||
@Deprecated("")
|
|
||||||
enum class GenericSortOrder(
|
|
||||||
@StringRes val titleResId: Int,
|
|
||||||
val ascending: SortOrder,
|
|
||||||
val descending: SortOrder,
|
|
||||||
) {
|
|
||||||
|
|
||||||
UPDATED(R.string.updated, SortOrder.UPDATED_ASC, SortOrder.UPDATED),
|
|
||||||
RATING(R.string.by_rating, SortOrder.RATING_ASC, SortOrder.RATING),
|
|
||||||
POPULARITY(R.string.popularity, SortOrder.POPULARITY_ASC, SortOrder.POPULARITY),
|
|
||||||
DATE(R.string.by_date, SortOrder.NEWEST_ASC, SortOrder.NEWEST),
|
|
||||||
NAME(R.string.by_name, SortOrder.ALPHABETICAL, SortOrder.ALPHABETICAL_DESC),
|
|
||||||
;
|
|
||||||
|
|
||||||
operator fun get(direction: SortDirection): SortOrder = when (direction) {
|
|
||||||
SortDirection.ASC -> ascending
|
|
||||||
SortDirection.DESC -> descending
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
|
|
||||||
fun of(order: SortOrder): GenericSortOrder = entries.first { e ->
|
|
||||||
e.ascending == order || e.descending == order
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,23 +1,19 @@
|
|||||||
package org.koitharu.kotatsu.core.model
|
package org.koitharu.kotatsu.core.model
|
||||||
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.text.SpannableStringBuilder
|
|
||||||
import androidx.annotation.DrawableRes
|
import androidx.annotation.DrawableRes
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
import androidx.collection.MutableObjectIntMap
|
import androidx.collection.MutableObjectIntMap
|
||||||
import androidx.core.os.LocaleListCompat
|
import androidx.core.os.LocaleListCompat
|
||||||
import androidx.core.text.buildSpannedString
|
|
||||||
import androidx.core.text.strikeThrough
|
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.core.util.ext.iterator
|
import org.koitharu.kotatsu.core.util.ext.iterator
|
||||||
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
|
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
|
||||||
import org.koitharu.kotatsu.parsers.model.ContentRating
|
import org.koitharu.kotatsu.parsers.model.ContentRating
|
||||||
import org.koitharu.kotatsu.parsers.model.Demographic
|
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaState
|
import org.koitharu.kotatsu.parsers.model.MangaState
|
||||||
import org.koitharu.kotatsu.parsers.util.findById
|
import org.koitharu.kotatsu.parsers.util.formatSimple
|
||||||
import org.koitharu.kotatsu.parsers.util.mapToSet
|
import org.koitharu.kotatsu.parsers.util.mapToSet
|
||||||
import com.google.android.material.R as materialR
|
import com.google.android.material.R as materialR
|
||||||
|
|
||||||
@@ -29,6 +25,8 @@ fun Collection<Manga>.distinctById() = distinctBy { it.id }
|
|||||||
@JvmName("chaptersIds")
|
@JvmName("chaptersIds")
|
||||||
fun Collection<MangaChapter>.ids() = mapToSet { it.id }
|
fun Collection<MangaChapter>.ids() = mapToSet { it.id }
|
||||||
|
|
||||||
|
fun Collection<MangaChapter>.findById(id: Long) = find { x -> x.id == id }
|
||||||
|
|
||||||
fun Collection<ChapterListItem>.countChaptersByBranch(): Int {
|
fun Collection<ChapterListItem>.countChaptersByBranch(): Int {
|
||||||
if (size <= 1) {
|
if (size <= 1) {
|
||||||
return size
|
return size
|
||||||
@@ -71,16 +69,9 @@ val ContentRating.titleResId: Int
|
|||||||
ContentRating.ADULT -> R.string.rating_adult
|
ContentRating.ADULT -> R.string.rating_adult
|
||||||
}
|
}
|
||||||
|
|
||||||
@get:StringRes
|
fun Manga.findChapter(id: Long): MangaChapter? {
|
||||||
val Demographic.titleResId: Int
|
return chapters?.findById(id)
|
||||||
get() = when (this) {
|
}
|
||||||
Demographic.SHOUNEN -> R.string.demographic_shounen
|
|
||||||
Demographic.SHOUJO -> R.string.demographic_shoujo
|
|
||||||
Demographic.SEINEN -> R.string.demographic_seinen
|
|
||||||
Demographic.JOSEI -> R.string.demographic_josei
|
|
||||||
Demographic.KODOMO -> R.string.demographic_kodomo
|
|
||||||
Demographic.NONE -> R.string.none
|
|
||||||
}
|
|
||||||
|
|
||||||
fun Manga.getPreferredBranch(history: MangaHistory?): String? {
|
fun Manga.getPreferredBranch(history: MangaHistory?): String? {
|
||||||
val ch = chapters
|
val ch = chapters
|
||||||
@@ -118,10 +109,7 @@ fun Manga.getPreferredBranch(history: MangaHistory?): String? {
|
|||||||
}
|
}
|
||||||
|
|
||||||
val Manga.isLocal: Boolean
|
val Manga.isLocal: Boolean
|
||||||
get() = source == LocalMangaSource
|
get() = source == MangaSource.LOCAL
|
||||||
|
|
||||||
val Manga.isBroken: Boolean
|
|
||||||
get() = source == UnknownMangaSource
|
|
||||||
|
|
||||||
val Manga.appUrl: Uri
|
val Manga.appUrl: Uri
|
||||||
get() = Uri.parse("https://kotatsu.app/manga").buildUpon()
|
get() = Uri.parse("https://kotatsu.app/manga").buildUpon()
|
||||||
@@ -130,6 +118,12 @@ val Manga.appUrl: Uri
|
|||||||
.appendQueryParameter("url", url)
|
.appendQueryParameter("url", url)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
|
fun MangaChapter.formatNumber(): String? = if (number > 0f) {
|
||||||
|
number.formatSimple()
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
fun Manga.chaptersCount(): Int {
|
fun Manga.chaptersCount(): Int {
|
||||||
if (chapters.isNullOrEmpty()) {
|
if (chapters.isNullOrEmpty()) {
|
||||||
return 0
|
return 0
|
||||||
@@ -145,26 +139,3 @@ fun Manga.chaptersCount(): Int {
|
|||||||
}
|
}
|
||||||
return max
|
return max
|
||||||
}
|
}
|
||||||
|
|
||||||
fun MangaListFilter.getSummary() = buildSpannedString {
|
|
||||||
if (!query.isNullOrEmpty()) {
|
|
||||||
append(query)
|
|
||||||
if (tags.isNotEmpty() || tagsExclude.isNotEmpty()) {
|
|
||||||
append(' ')
|
|
||||||
append('(')
|
|
||||||
appendTagsSummary(this@getSummary)
|
|
||||||
append(')')
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
appendTagsSummary(this@getSummary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun SpannableStringBuilder.appendTagsSummary(filter: MangaListFilter) {
|
|
||||||
filter.tags.joinTo(this) { it.title }
|
|
||||||
if (filter.tagsExclude.isNotEmpty()) {
|
|
||||||
strikeThrough {
|
|
||||||
filter.tagsExclude.joinTo(this) { it.title }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -12,5 +12,4 @@ data class MangaHistory(
|
|||||||
val page: Int,
|
val page: Int,
|
||||||
val scroll: Int,
|
val scroll: Int,
|
||||||
val percent: Float,
|
val percent: Float,
|
||||||
val chaptersCount: Int,
|
|
||||||
) : Parcelable
|
) : Parcelable
|
||||||
|
|||||||
@@ -7,49 +7,26 @@ import android.text.style.ForegroundColorSpan
|
|||||||
import android.text.style.RelativeSizeSpan
|
import android.text.style.RelativeSizeSpan
|
||||||
import android.text.style.SuperscriptSpan
|
import android.text.style.SuperscriptSpan
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
|
import androidx.core.text.buildSpannedString
|
||||||
import androidx.core.text.inSpans
|
import androidx.core.text.inSpans
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.core.parser.external.ExternalMangaSource
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.getDisplayName
|
import org.koitharu.kotatsu.core.util.ext.getDisplayName
|
||||||
import org.koitharu.kotatsu.core.util.ext.getThemeColor
|
import org.koitharu.kotatsu.core.util.ext.getThemeColor
|
||||||
import org.koitharu.kotatsu.core.util.ext.toLocale
|
import org.koitharu.kotatsu.core.util.ext.toLocale
|
||||||
import org.koitharu.kotatsu.parsers.model.ContentType
|
import org.koitharu.kotatsu.parsers.model.ContentType
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaParserSource
|
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
import org.koitharu.kotatsu.parsers.util.splitTwoParts
|
import org.koitharu.kotatsu.parsers.util.toTitleCase
|
||||||
|
import java.util.Locale
|
||||||
import com.google.android.material.R as materialR
|
import com.google.android.material.R as materialR
|
||||||
|
|
||||||
data object LocalMangaSource : MangaSource {
|
fun MangaSource(name: String): MangaSource {
|
||||||
override val name = "LOCAL"
|
MangaSource.entries.forEach {
|
||||||
}
|
|
||||||
|
|
||||||
data object UnknownMangaSource : MangaSource {
|
|
||||||
override val name = "UNKNOWN"
|
|
||||||
}
|
|
||||||
|
|
||||||
fun MangaSource(name: String?): MangaSource {
|
|
||||||
when (name ?: return UnknownMangaSource) {
|
|
||||||
UnknownMangaSource.name -> return UnknownMangaSource
|
|
||||||
|
|
||||||
LocalMangaSource.name -> return LocalMangaSource
|
|
||||||
}
|
|
||||||
if (name.startsWith("content:")) {
|
|
||||||
val parts = name.substringAfter(':').splitTwoParts('/') ?: return UnknownMangaSource
|
|
||||||
return ExternalMangaSource(packageName = parts.first, authority = parts.second)
|
|
||||||
}
|
|
||||||
MangaParserSource.entries.forEach {
|
|
||||||
if (it.name == name) return it
|
if (it.name == name) return it
|
||||||
}
|
}
|
||||||
return UnknownMangaSource
|
return MangaSource.DUMMY
|
||||||
}
|
}
|
||||||
|
|
||||||
fun Collection<String>.toMangaSources() = map(::MangaSource)
|
fun MangaSource.isNsfw() = contentType == ContentType.HENTAI
|
||||||
|
|
||||||
fun MangaSource.isNsfw(): Boolean = when (this) {
|
|
||||||
is MangaSourceInfo -> mangaSource.isNsfw()
|
|
||||||
is MangaParserSource -> contentType == ContentType.HENTAI
|
|
||||||
else -> false
|
|
||||||
}
|
|
||||||
|
|
||||||
@get:StringRes
|
@get:StringRes
|
||||||
val ContentType.titleResId
|
val ContentType.titleResId
|
||||||
@@ -58,42 +35,25 @@ val ContentType.titleResId
|
|||||||
ContentType.HENTAI -> R.string.content_type_hentai
|
ContentType.HENTAI -> R.string.content_type_hentai
|
||||||
ContentType.COMICS -> R.string.content_type_comics
|
ContentType.COMICS -> R.string.content_type_comics
|
||||||
ContentType.OTHER -> R.string.content_type_other
|
ContentType.OTHER -> R.string.content_type_other
|
||||||
ContentType.MANHWA -> R.string.content_type_manhwa
|
|
||||||
ContentType.MANHUA -> R.string.content_type_manhua
|
|
||||||
ContentType.NOVEL -> R.string.content_type_novel
|
|
||||||
ContentType.ONE_SHOT -> R.string.content_type_one_shot
|
|
||||||
ContentType.DOUJINSHI -> R.string.content_type_doujinshi
|
|
||||||
ContentType.IMAGE_SET -> R.string.content_type_image_set
|
|
||||||
ContentType.ARTIST_CG -> R.string.content_type_artist_cg
|
|
||||||
ContentType.GAME_CG -> R.string.content_type_game_cg
|
|
||||||
}
|
}
|
||||||
|
|
||||||
tailrec fun MangaSource.unwrap(): MangaSource = if (this is MangaSourceInfo) {
|
fun MangaSource.getSummary(context: Context): String {
|
||||||
mangaSource.unwrap()
|
val type = context.getString(contentType.titleResId)
|
||||||
|
val locale = locale?.toLocale().getDisplayName(context)
|
||||||
|
return context.getString(R.string.source_summary_pattern, type, locale)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun MangaSource.getTitle(context: Context): CharSequence = if (isNsfw()) {
|
||||||
|
buildSpannedString {
|
||||||
|
append(title)
|
||||||
|
append(' ')
|
||||||
|
appendNsfwLabel(context)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
this
|
title
|
||||||
}
|
}
|
||||||
|
|
||||||
fun MangaSource.getSummary(context: Context): String? = when (val source = unwrap()) {
|
private fun SpannableStringBuilder.appendNsfwLabel(context: Context) = inSpans(
|
||||||
is MangaParserSource -> {
|
|
||||||
val type = context.getString(source.contentType.titleResId)
|
|
||||||
val locale = source.locale.toLocale().getDisplayName(context)
|
|
||||||
context.getString(R.string.source_summary_pattern, type, locale)
|
|
||||||
}
|
|
||||||
|
|
||||||
is ExternalMangaSource -> context.getString(R.string.external_source)
|
|
||||||
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
|
|
||||||
fun MangaSource.getTitle(context: Context): String = when (val source = unwrap()) {
|
|
||||||
is MangaParserSource -> source.title
|
|
||||||
LocalMangaSource -> context.getString(R.string.local_storage)
|
|
||||||
is ExternalMangaSource -> source.resolveName(context)
|
|
||||||
else -> context.getString(R.string.unknown)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun SpannableStringBuilder.appendNsfwLabel(context: Context) = inSpans(
|
|
||||||
ForegroundColorSpan(context.getThemeColor(materialR.attr.colorError, Color.RED)),
|
ForegroundColorSpan(context.getThemeColor(materialR.attr.colorError, Color.RED)),
|
||||||
RelativeSizeSpan(0.74f),
|
RelativeSizeSpan(0.74f),
|
||||||
SuperscriptSpan(),
|
SuperscriptSpan(),
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.model
|
|
||||||
|
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
|
||||||
|
|
||||||
data class MangaSourceInfo(
|
|
||||||
val mangaSource: MangaSource,
|
|
||||||
val isEnabled: Boolean,
|
|
||||||
val isPinned: Boolean,
|
|
||||||
) : MangaSource by mangaSource
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.model
|
|
||||||
|
|
||||||
enum class SortDirection {
|
|
||||||
|
|
||||||
ASC, DESC;
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.model.parcelable
|
|
||||||
|
|
||||||
import android.os.Parcel
|
|
||||||
import kotlinx.parcelize.Parceler
|
|
||||||
import org.koitharu.kotatsu.core.model.MangaSource
|
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
|
||||||
|
|
||||||
class MangaSourceParceler : Parceler<MangaSource> {
|
|
||||||
|
|
||||||
override fun create(parcel: Parcel): MangaSource = MangaSource(parcel.readString())
|
|
||||||
|
|
||||||
override fun MangaSource.write(parcel: Parcel, flags: Int) {
|
|
||||||
parcel.writeString(name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,8 +4,9 @@ import android.os.Parcel
|
|||||||
import android.os.Parcelable
|
import android.os.Parcelable
|
||||||
import kotlinx.parcelize.Parceler
|
import kotlinx.parcelize.Parceler
|
||||||
import kotlinx.parcelize.Parcelize
|
import kotlinx.parcelize.Parcelize
|
||||||
import org.koitharu.kotatsu.core.model.MangaSource
|
import org.koitharu.kotatsu.core.util.ext.readSerializableCompat
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
|
|
||||||
@Parcelize
|
@Parcelize
|
||||||
data class ParcelableChapter(
|
data class ParcelableChapter(
|
||||||
@@ -24,8 +25,8 @@ data class ParcelableChapter(
|
|||||||
scanlator = parcel.readString(),
|
scanlator = parcel.readString(),
|
||||||
uploadDate = parcel.readLong(),
|
uploadDate = parcel.readLong(),
|
||||||
branch = parcel.readString(),
|
branch = parcel.readString(),
|
||||||
source = MangaSource(parcel.readString()),
|
source = parcel.readSerializableCompat() ?: MangaSource.DUMMY,
|
||||||
),
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
override fun ParcelableChapter.write(parcel: Parcel, flags: Int) = with(chapter) {
|
override fun ParcelableChapter.write(parcel: Parcel, flags: Int) = with(chapter) {
|
||||||
@@ -37,7 +38,7 @@ data class ParcelableChapter(
|
|||||||
parcel.writeString(scanlator)
|
parcel.writeString(scanlator)
|
||||||
parcel.writeLong(uploadDate)
|
parcel.writeLong(uploadDate)
|
||||||
parcel.writeString(branch)
|
parcel.writeString(branch)
|
||||||
parcel.writeString(source.name)
|
parcel.writeSerializable(source)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import android.os.Parcelable
|
|||||||
import androidx.core.os.ParcelCompat
|
import androidx.core.os.ParcelCompat
|
||||||
import kotlinx.parcelize.Parceler
|
import kotlinx.parcelize.Parceler
|
||||||
import kotlinx.parcelize.Parcelize
|
import kotlinx.parcelize.Parcelize
|
||||||
import org.koitharu.kotatsu.core.model.MangaSource
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.readParcelableCompat
|
import org.koitharu.kotatsu.core.util.ext.readParcelableCompat
|
||||||
import org.koitharu.kotatsu.core.util.ext.readSerializableCompat
|
import org.koitharu.kotatsu.core.util.ext.readSerializableCompat
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
@@ -13,7 +12,6 @@ import org.koitharu.kotatsu.parsers.model.Manga
|
|||||||
@Parcelize
|
@Parcelize
|
||||||
data class ParcelableManga(
|
data class ParcelableManga(
|
||||||
val manga: Manga,
|
val manga: Manga,
|
||||||
private val withDescription: Boolean = true,
|
|
||||||
) : Parcelable {
|
) : Parcelable {
|
||||||
|
|
||||||
companion object : Parceler<ParcelableManga> {
|
companion object : Parceler<ParcelableManga> {
|
||||||
@@ -28,11 +26,11 @@ data class ParcelableManga(
|
|||||||
ParcelCompat.writeBoolean(parcel, isNsfw)
|
ParcelCompat.writeBoolean(parcel, isNsfw)
|
||||||
parcel.writeString(coverUrl)
|
parcel.writeString(coverUrl)
|
||||||
parcel.writeString(largeCoverUrl)
|
parcel.writeString(largeCoverUrl)
|
||||||
parcel.writeString(description.takeIf { withDescription })
|
parcel.writeString(description)
|
||||||
parcel.writeParcelable(ParcelableMangaTags(tags), flags)
|
parcel.writeParcelable(ParcelableMangaTags(tags), flags)
|
||||||
parcel.writeSerializable(state)
|
parcel.writeSerializable(state)
|
||||||
parcel.writeString(author)
|
parcel.writeString(author)
|
||||||
parcel.writeString(source.name)
|
parcel.writeSerializable(source)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun create(parcel: Parcel) = ParcelableManga(
|
override fun create(parcel: Parcel) = ParcelableManga(
|
||||||
@@ -51,9 +49,8 @@ data class ParcelableManga(
|
|||||||
state = parcel.readSerializableCompat(),
|
state = parcel.readSerializableCompat(),
|
||||||
author = parcel.readString(),
|
author = parcel.readString(),
|
||||||
chapters = null,
|
chapters = null,
|
||||||
source = MangaSource(parcel.readString()),
|
source = requireNotNull(parcel.readSerializableCompat()),
|
||||||
),
|
)
|
||||||
withDescription = true,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user