Compare commits

..

1 Commits

Author SHA1 Message Date
Koitharu
5df55d1fe9 Draft double reader implementation 2023-10-10 09:16:16 +03:00
739 changed files with 8311 additions and 25667 deletions

2
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1,2 @@
ko_fi: xtimms
custom: ["https://yoomoney.ru/to/410012543938752"]

2
.gitignore vendored
View File

@@ -10,13 +10,11 @@
/.idea/compiler.xml /.idea/compiler.xml
/.idea/workspace.xml /.idea/workspace.xml
/.idea/navEditor.xml /.idea/navEditor.xml
/.idea/ktlint-plugin.xml
/.idea/assetWizardSettings.xml /.idea/assetWizardSettings.xml
/.idea/kotlinScripting.xml /.idea/kotlinScripting.xml
/.idea/kotlinc.xml /.idea/kotlinc.xml
/.idea/deploymentTargetDropDown.xml /.idea/deploymentTargetDropDown.xml
/.idea/androidTestResultsUserPreferences.xml /.idea/androidTestResultsUserPreferences.xml
/.idea/deploymentTargetSelector.xml
/.idea/render.experimental.xml /.idea/render.experimental.xml
/.idea/inspectionProfiles/ /.idea/inspectionProfiles/
.DS_Store .DS_Store

3
.idea/gradle.xml generated
View File

@@ -4,8 +4,9 @@
<component name="GradleSettings"> <component name="GradleSettings">
<option name="linkedExternalProjectsSettings"> <option name="linkedExternalProjectsSettings">
<GradleProjectSettings> <GradleProjectSettings>
<option name="testRunner" value="GRADLE" />
<option name="externalProjectPath" value="$PROJECT_DIR$" /> <option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" /> <option name="gradleJvm" value="jbr-17" />
<option name="modules"> <option name="modules">
<set> <set>
<option value="$PROJECT_DIR$" /> <option value="$PROJECT_DIR$" />

View File

@@ -1,12 +1,11 @@
## Kotatsu contribution guidelines ## Kotatsu contribution guidelines
+ If you want to **fix bugs** or **implement new features** that **already have an [issue card](https://github.com/KotatsuApp/Kotatsu/issues):** please assign this issue to you and/or comment about it. - If you want to fix bug or implement a new feature, that already mention in the [issues](https://github.com/KotatsuApp/Kotatsu/issues), please, assign this issue to you and/or comment about it.
+ If you want to **implement a new feature:** open an issue or discussion regarding it to ensure it will be accepted. - Whether you have to implement new feature, please, open an issue or discussion regarding it to ensure it will be accepted.
+ **Translations** have to be managed using the [Weblate](https://hosted.weblate.org/engage/kotatsu/) platform. - Translations have to be managed using the [Weblate](https://hosted.weblate.org/engage/kotatsu/) platform.
+ In case you want to **add a new manga source,** refer to the [parsers repository](https://github.com/KotatsuApp/kotatsu-parsers). - In case you want to add a new manga source, refer to the [parsers repository](https://github.com/KotatsuApp/kotatsu-parsers).
**Refactoring** or some **dev-faces improvements** might also be accepted. However, please stick to the following principles: Refactoring or some dev-faces improvements are also might be accepted, however please stick to the following principles:
- Performance matters. In the case of choosing between source code beauty and performance, performance should be a priority.
+ **Performance matters.** In the case of choosing between source code beauty and performance, performance should be a priority. - Please, do not modify readme and other information files (except for typos).
+ Please, **do not modify readme and other information files** (except for typos). - Avoid adding new dependencies unless required. APK size is important.
+ **Avoid adding new dependencies** unless required. APK size is important.

53
LICENSE
View File

@@ -619,3 +619,56 @@ Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee. copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.

View File

@@ -19,7 +19,7 @@ Kotatsu is a free and open source manga reader for Android.
* Tablet-optimized Material You UI * Tablet-optimized Material You UI
* Standard and Webtoon-optimized reader * Standard and Webtoon-optimized reader
* Notifications about new chapters with updates feed * Notifications about new chapters with updates feed
* Integration with manga tracking services: Shikimori, AniList, MyAnimeList, Kitsu * Integration with manga tracking services: Shikimori, AniList, MyAnimeList
* Password/fingerprint protect access to the app * Password/fingerprint protect access to the app
* History and favourites [synchronization](https://github.com/KotatsuApp/kotatsu-syncserver) across devices * History and favourites [synchronization](https://github.com/KotatsuApp/kotatsu-syncserver) across devices

View File

@@ -16,13 +16,12 @@ android {
applicationId 'org.koitharu.kotatsu' applicationId 'org.koitharu.kotatsu'
minSdk = 21 minSdk = 21
targetSdk = 34 targetSdk = 34
versionCode = 633 versionCode = 584
versionName = '6.8.3' versionName = '6.1.6'
generatedDensities = [] generatedDensities = []
testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner' testInstrumentationRunner "org.koitharu.kotatsu.HiltTestRunner"
ksp { ksp {
arg('room.generateKotlin', 'true') arg("room.schemaLocation", "$projectDir/schemas")
arg('room.schemaLocation', "$projectDir/schemas")
} }
androidResources { androidResources {
generateLocaleConfig true generateLocaleConfig true
@@ -33,6 +32,7 @@ android {
applicationIdSuffix = '.debug' applicationIdSuffix = '.debug'
} }
release { release {
multiDexEnabled false
minifyEnabled true minifyEnabled true
shrinkResources true shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
@@ -47,12 +47,11 @@ android {
main.java.srcDirs += 'src/main/kotlin/' main.java.srcDirs += 'src/main/kotlin/'
} }
compileOptions { compileOptions {
coreLibraryDesugaringEnabled true sourceCompatibility JavaVersion.VERSION_17
sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_1_8
} }
kotlinOptions { kotlinOptions {
jvmTarget = JavaVersion.VERSION_1_8.toString() jvmTarget = JavaVersion.VERSION_17.toString()
freeCompilerArgs += [ freeCompilerArgs += [
'-opt-in=kotlin.ExperimentalStdlibApi', '-opt-in=kotlin.ExperimentalStdlibApi',
'-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi', '-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi',
@@ -82,33 +81,32 @@ afterEvaluate {
} }
dependencies { dependencies {
//noinspection GradleDependency //noinspection GradleDependency
implementation('com.github.KotatsuApp:kotatsu-parsers:44ea9fe709') { implementation('com.github.KotatsuApp:kotatsu-parsers:400a90464e') {
exclude group: 'org.json', module: 'json' exclude group: 'org.json', module: 'json'
} }
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.4' implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.9.10'
implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.9.23' implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.0'
implementation 'androidx.appcompat:appcompat:1.6.1' implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'androidx.core:core-ktx:1.12.0' implementation 'androidx.core:core-ktx:1.12.0'
implementation 'androidx.activity:activity-ktx:1.8.2' implementation 'androidx.activity:activity-ktx:1.8.0'
implementation 'androidx.fragment:fragment-ktx:1.6.2' implementation 'androidx.fragment:fragment-ktx:1.6.1'
implementation 'androidx.collection:collection-ktx:1.4.0' implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.2'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0' implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.6.2'
implementation 'androidx.lifecycle:lifecycle-service:2.7.0' implementation 'androidx.lifecycle:lifecycle-service:2.6.2'
implementation 'androidx.lifecycle:lifecycle-process:2.7.0' implementation 'androidx.lifecycle:lifecycle-process:2.6.2'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4' implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0' implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
implementation 'androidx.recyclerview:recyclerview:1.3.2' implementation 'androidx.recyclerview:recyclerview:1.3.1'
implementation 'androidx.viewpager2:viewpager2:1.1.0-beta02' implementation 'androidx.viewpager2:viewpager2:1.1.0-beta02'
implementation 'androidx.preference:preference-ktx:1.2.1' implementation 'androidx.preference:preference-ktx:1.2.1'
implementation 'androidx.biometric:biometric-ktx:1.2.0-alpha05' implementation 'androidx.biometric:biometric-ktx:1.2.0-alpha05'
implementation 'com.google.android.material:material:1.12.0-beta01' implementation 'com.google.android.material:material:1.10.0'
implementation 'androidx.lifecycle:lifecycle-common-java8:2.7.0' implementation 'androidx.lifecycle:lifecycle-common-java8:2.6.2'
implementation 'androidx.webkit:webkit:1.10.0'
implementation 'androidx.work:work-runtime:2.9.0' // TODO https://issuetracker.google.com/issues/254846063
implementation 'androidx.work:work-runtime-ktx:2.8.1'
//noinspection GradleDependency //noinspection GradleDependency
implementation('com.google.guava:guava:32.0.1-android') { implementation('com.google.guava:guava:32.0.1-android') {
exclude group: 'com.google.guava', module: 'failureaccess' exclude group: 'com.google.guava', module: 'failureaccess'
@@ -116,51 +114,47 @@ dependencies {
exclude group: 'com.google.j2objc', module: 'j2objc-annotations' exclude group: 'com.google.j2objc', module: 'j2objc-annotations'
} }
implementation 'androidx.room:room-runtime:2.6.1' implementation 'androidx.room:room-runtime:2.5.2'
implementation 'androidx.room:room-ktx:2.6.1' implementation 'androidx.room:room-ktx:2.5.2'
ksp 'androidx.room:room-compiler:2.6.1' ksp 'androidx.room:room-compiler:2.5.2'
implementation 'com.squareup.okhttp3:okhttp:4.12.0' implementation 'com.squareup.okhttp3:okhttp:4.11.0'
implementation 'com.squareup.okhttp3:okhttp-dnsoverhttps:4.12.0' implementation 'com.squareup.okhttp3:okhttp-dnsoverhttps:4.11.0'
implementation 'com.squareup.okio:okio:3.9.0' implementation 'com.squareup.okio:okio:3.6.0'
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.2' implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.2'
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:4.3.2' implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:4.3.2'
implementation 'com.google.dagger:hilt-android:2.51.1' implementation 'com.google.dagger:hilt-android:2.48.1'
kapt 'com.google.dagger:hilt-compiler:2.51.1' kapt 'com.google.dagger:hilt-compiler:2.48.1'
implementation 'androidx.hilt:hilt-work:1.2.0' implementation 'androidx.hilt:hilt-work:1.0.0'
kapt 'androidx.hilt:hilt-compiler:1.2.0' kapt 'androidx.hilt:hilt-compiler:1.0.0'
implementation 'io.coil-kt:coil-base:2.6.0' implementation 'io.coil-kt:coil-base:2.4.0'
implementation 'io.coil-kt:coil-svg:2.6.0' implementation 'io.coil-kt:coil-svg:2.4.0'
implementation 'com.github.KotatsuApp:subsampling-scale-image-view:02e6d6cfe9' implementation 'com.github.KotatsuApp:subsampling-scale-image-view:169806d928'
implementation 'com.github.solkin:disk-lru-cache:1.4' implementation 'com.github.solkin:disk-lru-cache:1.4'
implementation 'io.noties.markwon:core:4.6.2' implementation 'io.noties.markwon:core:4.6.2'
implementation 'ch.acra:acra-http:5.11.3' implementation 'ch.acra:acra-http:5.11.2'
implementation 'ch.acra:acra-dialog:5.11.3' implementation 'ch.acra:acra-dialog:5.11.2'
compileOnly 'com.google.auto.service:auto-service-annotations:1.1.1'
ksp 'dev.zacsweers.autoservice:auto-service-ksp:1.1.0'
implementation 'org.conscrypt:conscrypt-android:2.5.2' debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.12'
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.13'
testImplementation 'junit:junit:4.13.2' testImplementation 'junit:junit:4.13.2'
testImplementation 'org.json:json:20240303' testImplementation 'org.json:json:20230618'
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.0' testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3'
androidTestImplementation 'androidx.test:runner:1.5.2' androidTestImplementation 'androidx.test:runner:1.5.2'
androidTestImplementation 'androidx.test:rules:1.5.0' androidTestImplementation 'androidx.test:rules:1.5.0'
androidTestImplementation 'androidx.test:core-ktx:1.5.0' androidTestImplementation 'androidx.test:core-ktx:1.5.0'
androidTestImplementation 'androidx.test.ext:junit-ktx:1.1.5' androidTestImplementation 'androidx.test.ext:junit-ktx:1.1.5'
androidTestImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.0' androidTestImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3'
androidTestImplementation 'androidx.room:room-testing:2.6.1' androidTestImplementation 'androidx.room:room-testing:2.5.2'
androidTestImplementation 'com.squareup.moshi:moshi-kotlin:1.15.1' androidTestImplementation 'com.squareup.moshi:moshi-kotlin:1.15.0'
androidTestImplementation 'com.google.dagger:hilt-android-testing:2.51.1' androidTestImplementation 'com.google.dagger:hilt-android-testing:2.48.1'
kaptAndroidTest 'com.google.dagger:hilt-android-compiler:2.51.1' kaptAndroidTest 'com.google.dagger:hilt-android-compiler:2.48.1'
} }

View File

@@ -18,6 +18,3 @@
-keep class org.koitharu.kotatsu.core.exceptions.* { *; } -keep class org.koitharu.kotatsu.core.exceptions.* { *; }
-keep class org.koitharu.kotatsu.settings.NotificationSettingsLegacyFragment -keep class org.koitharu.kotatsu.settings.NotificationSettingsLegacyFragment
-keep class org.koitharu.kotatsu.core.prefs.ScreenshotsPolicy { *; } -keep class org.koitharu.kotatsu.core.prefs.ScreenshotsPolicy { *; }
-keep class org.koitharu.kotatsu.settings.backup.PeriodicalBackupSettingsFragment { *; }
-keep class org.jsoup.parser.Tag
-keep class org.jsoup.internal.StringUtil

View File

@@ -57,7 +57,6 @@ class AppShortcutManagerTest {
page = 4, page = 4,
scroll = 2, scroll = 2,
percent = 0.3f, percent = 0.3f,
force = false,
) )
awaitUpdate() awaitUpdate()

View File

@@ -7,9 +7,7 @@ import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest import dagger.hilt.android.testing.HiltAndroidTest
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals import org.junit.Assert.*
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before import org.junit.Before
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
@@ -63,7 +61,6 @@ class AppBackupAgentTest {
page = 3, page = 3,
scroll = 40, scroll = 40,
percent = 0.2f, percent = 0.2f,
force = false,
) )
val history = checkNotNull(historyRepository.getOne(SampleData.manga)) val history = checkNotNull(historyRepository.getOne(SampleData.manga))
@@ -85,7 +82,7 @@ class AppBackupAgentTest {
assertEquals(history, historyRepository.getOne(SampleData.manga)) assertEquals(history, historyRepository.getOne(SampleData.manga))
assertEquals(listOf(SampleData.manga), favouritesRepository.getManga(category.id)) assertEquals(listOf(SampleData.manga), favouritesRepository.getManga(category.id))
val allTags = database.getTagsDao().findTags(SampleData.tag.source.name).toMangaTags() val allTags = database.tagsDao.findTags(SampleData.tag.source.name).toMangaTags()
assertTrue(SampleData.tag in allTags) assertTrue(SampleData.tag in allTags)
} }

View File

@@ -36,7 +36,7 @@ class KotatsuApp : BaseApp() {
FragmentStrictMode.defaultPolicy = FragmentStrictMode.Policy.Builder() FragmentStrictMode.defaultPolicy = FragmentStrictMode.Policy.Builder()
.penaltyDeath() .penaltyDeath()
.detectFragmentReuse() .detectFragmentReuse()
.detectWrongFragmentContainer() // .detectWrongFragmentContainer() FIXME: migrate to ViewPager2
.detectRetainInstanceUsage() .detectRetainInstanceUsage()
.detectSetUserVisibleHint() .detectSetUserVisibleHint()
.detectFragmentTagUsage() .detectFragmentTagUsage()

View File

@@ -10,8 +10,6 @@ class CurlLoggingInterceptor(
private val curlOptions: String? = null private val curlOptions: String? = null
) : Interceptor { ) : Interceptor {
private val escapeRegex = Regex("([\\[\\]\"])")
override fun intercept(chain: Interceptor.Chain): Response { override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request() val request = chain.request()
var isCompressed = false var isCompressed = false
@@ -42,7 +40,7 @@ class CurlLoggingInterceptor(
if (isCompressed) { if (isCompressed) {
curlCmd.append(" --compressed") curlCmd.append(" --compressed")
} }
curlCmd.append(" \"").append(request.url.toString().escape()).append('"') curlCmd.append(" \"").append(request.url).append('"')
log("---cURL (" + request.url + ")") log("---cURL (" + request.url + ")")
log(curlCmd.toString()) log(curlCmd.toString())
@@ -50,12 +48,7 @@ class CurlLoggingInterceptor(
return chain.proceed(request) return chain.proceed(request)
} }
private fun String.escape() = replace(escapeRegex) { match -> private fun String.escape() = replace("\"", "\\\"")
"\\" + match.value
}
// .replace("\"", "\\\"")
// .replace("[", "\\[")
// .replace("]", "\\]")
private fun log(msg: String) { private fun log(msg: String) {
Log.d("CURL", msg) Log.d("CURL", msg)

View File

@@ -1,12 +1,10 @@
package org.koitharu.kotatsu.core.parser package org.koitharu.kotatsu.core.parser
import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException
import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaParser import org.koitharu.kotatsu.parsers.MangaParser
import org.koitharu.kotatsu.parsers.config.ConfigKey import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
@@ -19,20 +17,29 @@ import java.util.EnumSet
class DummyParser(context: MangaLoaderContext) : MangaParser(context, MangaSource.DUMMY) { class DummyParser(context: MangaLoaderContext) : MangaParser(context, MangaSource.DUMMY) {
override val configKeyDomain: ConfigKey.Domain override val configKeyDomain: ConfigKey.Domain
get() = ConfigKey.Domain("localhost") get() = ConfigKey.Domain("")
override val availableSortOrders: Set<SortOrder> override val sortOrders: Set<SortOrder>
get() = EnumSet.allOf(SortOrder::class.java) get() = EnumSet.allOf(SortOrder::class.java)
override suspend fun getDetails(manga: Manga): Manga = stub(manga) override suspend fun getDetails(manga: Manga): Manga {
TODO("Not yet implemented")
}
override suspend fun getList(offset: Int, filter: MangaListFilter?): List<Manga> = stub(null) override suspend fun getList(
offset: Int,
query: String?,
tags: Set<MangaTag>?,
sortOrder: SortOrder,
): List<Manga> {
TODO("Not yet implemented")
}
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> = stub(null) override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
TODO("Not yet implemented")
}
override suspend fun getAvailableTags(): Set<MangaTag> = stub(null) override suspend fun getTags(): Set<MangaTag> {
TODO("Not yet implemented")
private fun stub(manga: Manga?): Nothing {
throw UnsupportedSourceException("Usage of Dummy parser", manga)
} }
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,85 +0,0 @@
package org.koitharu.kotatsu.alternatives.domain
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.util.ext.almostEquals
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import javax.inject.Inject
private const val MAX_PARALLELISM = 4
private const val MATCH_THRESHOLD = 0.2f
class AlternativesUseCase @Inject constructor(
private val sourcesRepository: MangaSourcesRepository,
private val mangaRepositoryFactory: MangaRepository.Factory,
) {
suspend operator fun invoke(manga: Manga): Flow<Manga> {
val sources = getSources(manga.source)
if (sources.isEmpty()) {
return emptyFlow()
}
val semaphore = Semaphore(MAX_PARALLELISM)
return channelFlow {
for (source in sources) {
val repository = mangaRepositoryFactory.create(source)
if (!repository.isSearchSupported) {
continue
}
launch {
val list = runCatchingCancellable {
semaphore.withPermit {
repository.getList(offset = 0, filter = MangaListFilter.Search(manga.title))
}
}.getOrDefault(emptyList())
for (item in list) {
if (item.matches(manga)) {
send(item)
}
}
}
}
}.map {
runCatchingCancellable {
mangaRepositoryFactory.create(it.source).getDetails(it)
}.getOrDefault(it)
}
}
private suspend fun getSources(ref: MangaSource): List<MangaSource> {
val result = ArrayList<MangaSource>(MangaSource.entries.size - 2)
result.addAll(sourcesRepository.getEnabledSources())
result.sortByDescending { it.priority(ref) }
result.addAll(sourcesRepository.getDisabledSources().sortedByDescending { it.priority(ref) })
return result
}
private fun Manga.matches(ref: Manga): Boolean {
return matchesTitles(title, ref.title) ||
matchesTitles(title, ref.altTitle) ||
matchesTitles(altTitle, ref.title) ||
matchesTitles(altTitle, ref.altTitle)
}
private fun matchesTitles(a: String?, b: String?): Boolean {
return !a.isNullOrEmpty() && !b.isNullOrEmpty() && a.almostEquals(b, MATCH_THRESHOLD)
}
private fun MangaSource.priority(ref: MangaSource): Int {
var res = 0
if (locale == ref.locale) res += 2
if (contentType == ref.contentType) res++
return res
}
}

View File

@@ -1,129 +0,0 @@
package org.koitharu.kotatsu.alternatives.domain
import androidx.room.withTransaction
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.model.getPreferredBranch
import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.details.domain.ProgressUpdateUseCase
import org.koitharu.kotatsu.history.data.HistoryEntity
import org.koitharu.kotatsu.history.data.PROGRESS_NONE
import org.koitharu.kotatsu.history.data.toMangaHistory
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import javax.inject.Inject
class MigrateUseCase @Inject constructor(
private val mangaRepositoryFactory: MangaRepository.Factory,
private val mangaDataRepository: MangaDataRepository,
private val database: MangaDatabase,
private val progressUpdateUseCase: ProgressUpdateUseCase,
) {
suspend operator fun invoke(oldManga: Manga, newManga: Manga) {
val oldDetails = if (oldManga.chapters.isNullOrEmpty()) {
runCatchingCancellable {
mangaRepositoryFactory.create(oldManga.source).getDetails(oldManga)
}.getOrDefault(oldManga)
} else {
oldManga
}
val newDetails = if (newManga.chapters.isNullOrEmpty()) {
mangaRepositoryFactory.create(newManga.source).getDetails(newManga)
} else {
newManga
}
mangaDataRepository.storeManga(newDetails)
database.withTransaction {
// replace favorites
val favoritesDao = database.getFavouritesDao()
val oldFavourites = favoritesDao.findAllRaw(oldDetails.id)
if (oldFavourites.isNotEmpty()) {
favoritesDao.delete(oldManga.id)
for (f in oldFavourites) {
val e = f.copy(
mangaId = newManga.id,
)
favoritesDao.upsert(e)
}
}
// replace history
val historyDao = database.getHistoryDao()
val oldHistory = historyDao.find(oldDetails.id)
if (oldHistory != null) {
val newHistory = makeNewHistory(oldDetails, newDetails, oldHistory)
historyDao.delete(oldDetails.id)
historyDao.upsert(newHistory)
}
}
progressUpdateUseCase(newManga)
}
private fun makeNewHistory(
oldManga: Manga,
newManga: Manga,
history: HistoryEntity,
): HistoryEntity {
if (oldManga.chapters.isNullOrEmpty()) { // probably broken manga/source
val branch = newManga.getPreferredBranch(null)
val chapters = checkNotNull(newManga.getChapters(branch))
val currentChapter = if (history.percent in 0f..1f) {
chapters[(chapters.lastIndex * history.percent).toInt()]
} else {
chapters.first()
}
return HistoryEntity(
mangaId = newManga.id,
createdAt = history.createdAt,
updatedAt = System.currentTimeMillis(),
chapterId = currentChapter.id,
page = history.page,
scroll = history.scroll,
percent = history.percent,
deletedAt = 0,
chaptersCount = chapters.size,
)
}
val branch = oldManga.getPreferredBranch(history.toMangaHistory())
val oldChapters = checkNotNull(oldManga.getChapters(branch))
var index = oldChapters.indexOfFirst { it.id == history.chapterId }
if (index < 0) {
index = if (history.percent in 0f..1f) {
(oldChapters.lastIndex * history.percent).toInt()
} else {
0
}
}
val newChapters = checkNotNull(newManga.chapters).groupBy { it.branch }
val newBranch = if (newChapters.containsKey(branch)) {
branch
} else {
newManga.getPreferredBranch(null)
}
val newChapterId = checkNotNull(newChapters[newBranch]).let {
val oldChapter = oldChapters[index]
it.findByNumber(oldChapter.volume, oldChapter.number) ?: it.getOrNull(index) ?: it.last()
}.id
return HistoryEntity(
mangaId = newManga.id,
createdAt = history.createdAt,
updatedAt = System.currentTimeMillis(),
chapterId = newChapterId,
page = history.page,
scroll = history.scroll,
percent = PROGRESS_NONE,
deletedAt = 0,
chaptersCount = checkNotNull(newChapters[newBranch]).size,
)
}
private fun List<MangaChapter>.findByNumber(volume: Int, number: Float): MangaChapter? {
return if (number <= 0f) {
null
} else {
firstOrNull { it.volume == volume && it.number == number }
}
}
}

View File

@@ -1,92 +0,0 @@
package org.koitharu.kotatsu.alternatives.ui
import android.text.style.ForegroundColorSpan
import androidx.core.content.ContextCompat
import androidx.core.text.buildSpannedString
import androidx.core.text.inSpans
import androidx.lifecycle.LifecycleOwner
import coil.ImageLoader
import coil.request.ImageRequest
import coil.transform.CircleCropTransformation
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.parser.favicon.faviconUri
import org.koitharu.kotatsu.core.ui.image.ChipIconTarget
import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver
import org.koitharu.kotatsu.core.ui.image.TrimTransformation
import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.util.ext.enqueueWith
import org.koitharu.kotatsu.core.util.ext.newImageRequest
import org.koitharu.kotatsu.core.util.ext.source
import org.koitharu.kotatsu.databinding.ItemMangaAlternativeBinding
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
import org.koitharu.kotatsu.list.ui.model.ListModel
import kotlin.math.sign
import com.google.android.material.R as materialR
fun alternativeAD(
coil: ImageLoader,
lifecycleOwner: LifecycleOwner,
listener: OnListItemClickListener<MangaAlternativeModel>,
) = adapterDelegateViewBinding<MangaAlternativeModel, ListModel, ItemMangaAlternativeBinding>(
{ inflater, parent -> ItemMangaAlternativeBinding.inflate(inflater, parent, false) },
) {
val colorGreen = ContextCompat.getColor(context, R.color.common_green)
val colorRed = ContextCompat.getColor(context, R.color.common_red)
val clickListener = AdapterDelegateClickListenerAdapter(this, listener)
itemView.setOnClickListener(clickListener)
binding.buttonMigrate.setOnClickListener(clickListener)
binding.chipSource.setOnClickListener(clickListener)
bind { payloads ->
binding.textViewTitle.text = item.manga.title
binding.textViewSubtitle.text = buildSpannedString {
if (item.chaptersCount > 0) {
append(context.resources.getQuantityString(R.plurals.chapters, item.chaptersCount, item.chaptersCount))
} else {
append(context.getString(R.string.no_chapters))
}
when (item.chaptersDiff.sign) {
-1 -> inSpans(ForegroundColorSpan(colorRed)) {
append("")
append(item.chaptersDiff.toString())
}
1 -> inSpans(ForegroundColorSpan(colorGreen)) {
append(" ▲ +")
append(item.chaptersDiff.toString())
}
}
}
binding.progressView.setPercent(item.progress, ListModelDiffCallback.PAYLOAD_PROGRESS_CHANGED in payloads)
binding.chipSource.also { chip ->
chip.text = item.manga.source.title
ImageRequest.Builder(context)
.data(item.manga.source.faviconUri())
.lifecycle(lifecycleOwner)
.crossfade(false)
.size(context.resources.getDimensionPixelSize(materialR.dimen.m3_chip_icon_size))
.target(ChipIconTarget(chip))
.placeholder(R.drawable.ic_web)
.fallback(R.drawable.ic_web)
.error(R.drawable.ic_web)
.source(item.manga.source)
.transformations(CircleCropTransformation())
.allowRgb565(true)
.enqueueWith(coil)
}
binding.imageViewCover.newImageRequest(lifecycleOwner, item.manga.coverUrl)?.run {
size(CoverSizeResolver(binding.imageViewCover))
placeholder(R.drawable.ic_placeholder)
fallback(R.drawable.ic_placeholder)
error(R.drawable.ic_error_placeholder)
transformations(TrimTransformation())
allowRgb565(true)
tag(item.manga)
source(item.manga.source)
enqueueWith(coil)
}
}
}

View File

@@ -1,114 +0,0 @@
package org.koitharu.kotatsu.alternatives.ui
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.View
import android.widget.Toast
import androidx.activity.viewModels
import androidx.core.graphics.Insets
import androidx.core.view.updatePadding
import coil.ImageLoader
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.parser.MangaIntent
import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.ui.BaseListAdapter
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.util.ext.DIALOG_THEME_CENTERED
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.databinding.ActivityAlternativesBinding
import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
import org.koitharu.kotatsu.list.ui.adapter.emptyStateListAD
import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD
import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.search.ui.SearchActivity
import javax.inject.Inject
@AndroidEntryPoint
class AlternativesActivity : BaseActivity<ActivityAlternativesBinding>(),
OnListItemClickListener<MangaAlternativeModel> {
@Inject
lateinit var coil: ImageLoader
private val viewModel by viewModels<AlternativesViewModel>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(ActivityAlternativesBinding.inflate(layoutInflater))
supportActionBar?.run {
setDisplayHomeAsUpEnabled(true)
subtitle = viewModel.manga.title
}
val listAdapter = BaseListAdapter<ListModel>()
.addDelegate(ListItemType.MANGA_LIST_DETAILED, alternativeAD(coil, this, this))
.addDelegate(ListItemType.STATE_EMPTY, emptyStateListAD(coil, this, null))
.addDelegate(ListItemType.FOOTER_LOADING, loadingFooterAD())
.addDelegate(ListItemType.STATE_LOADING, loadingStateAD())
with(viewBinding.recyclerView) {
setHasFixedSize(true)
addItemDecoration(TypedListSpacingDecoration(context, addHorizontalPadding = false))
adapter = listAdapter
}
viewModel.onError.observeEvent(this, SnackbarErrorObserver(viewBinding.recyclerView, null))
viewModel.content.observe(this, listAdapter)
viewModel.onMigrated.observeEvent(this) {
Toast.makeText(this, R.string.migration_completed, Toast.LENGTH_SHORT).show()
startActivity(DetailsActivity.newIntent(this, it))
finishAfterTransition()
}
}
override fun onWindowInsetsChanged(insets: Insets) {
viewBinding.root.updatePadding(
left = insets.left,
right = insets.right,
)
viewBinding.recyclerView.updatePadding(
bottom = insets.bottom + viewBinding.recyclerView.paddingTop,
)
}
override fun onItemClick(item: MangaAlternativeModel, view: View) {
when (view.id) {
R.id.chip_source -> startActivity(SearchActivity.newIntent(this, item.manga.source, viewModel.manga.title))
R.id.button_migrate -> confirmMigration(item.manga)
else -> startActivity(DetailsActivity.newIntent(this, item.manga))
}
}
private fun confirmMigration(target: Manga) {
MaterialAlertDialogBuilder(this, DIALOG_THEME_CENTERED)
.setIcon(R.drawable.ic_replace)
.setTitle(R.string.manga_migration)
.setMessage(
getString(
R.string.migrate_confirmation,
viewModel.manga.title,
viewModel.manga.source.title,
target.title,
target.source.title,
),
)
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(R.string.migrate) { _, _ ->
viewModel.migrate(target)
}.show()
}
companion object {
fun newIntent(context: Context, manga: Manga) = Intent(context, AlternativesActivity::class.java)
.putExtra(MangaIntent.KEY_MANGA, ParcelableManga(manga))
}
}

View File

@@ -1,98 +0,0 @@
package org.koitharu.kotatsu.alternatives.ui
import androidx.lifecycle.SavedStateHandle
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEmpty
import kotlinx.coroutines.flow.runningFold
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.alternatives.domain.AlternativesUseCase
import org.koitharu.kotatsu.alternatives.domain.MigrateUseCase
import org.koitharu.kotatsu.core.model.chaptersCount
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.parser.MangaIntent
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.core.util.ext.require
import org.koitharu.kotatsu.list.domain.ListExtraProvider
import org.koitharu.kotatsu.list.ui.model.EmptyState
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.LoadingFooter
import org.koitharu.kotatsu.list.ui.model.LoadingState
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import javax.inject.Inject
@HiltViewModel
class AlternativesViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val mangaRepositoryFactory: MangaRepository.Factory,
private val alternativesUseCase: AlternativesUseCase,
private val migrateUseCase: MigrateUseCase,
private val extraProvider: ListExtraProvider,
) : BaseViewModel() {
val manga = savedStateHandle.require<ParcelableManga>(MangaIntent.KEY_MANGA).manga
val onMigrated = MutableEventFlow<Manga>()
val content = MutableStateFlow<List<ListModel>>(listOf(LoadingState))
private var migrationJob: Job? = null
init {
launchJob(Dispatchers.Default) {
val ref = runCatchingCancellable {
mangaRepositoryFactory.create(manga.source).getDetails(manga)
}.getOrDefault(manga)
val refCount = ref.chaptersCount()
alternativesUseCase(ref)
.map {
MangaAlternativeModel(
manga = it,
progress = extraProvider.getProgress(it.id),
referenceChapters = refCount,
)
}.runningFold<MangaAlternativeModel, List<ListModel>>(listOf(LoadingState)) { acc, item ->
acc.filterIsInstance<MangaAlternativeModel>() + item + LoadingFooter()
}.onEmpty {
emit(
listOf(
EmptyState(
icon = R.drawable.ic_empty_common,
textPrimary = R.string.nothing_found,
textSecondary = R.string.text_search_holder_secondary,
actionStringRes = 0,
),
),
)
}.collect {
content.value = it
}
content.value = content.value.filterNot { it is LoadingFooter }
}
}
fun migrate(target: Manga) {
if (migrationJob?.isActive == true) {
return
}
migrationJob = launchLoadingJob(Dispatchers.Default) {
migrateUseCase(manga, target)
onMigrated.call(target)
}
}
private suspend fun mapList(list: List<Manga>, refCount: Int): List<MangaAlternativeModel> {
return list.map {
MangaAlternativeModel(
manga = it,
progress = extraProvider.getProgress(it.id),
referenceChapters = refCount,
)
}
}
}

View File

@@ -1,21 +0,0 @@
package org.koitharu.kotatsu.alternatives.ui
import org.koitharu.kotatsu.core.model.chaptersCount
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.model.Manga
data class MangaAlternativeModel(
val manga: Manga,
val progress: Float,
private val referenceChapters: Int,
) : ListModel {
val chaptersCount = manga.chaptersCount()
val chaptersDiff: Int
get() = if (referenceChapters == 0 || chaptersCount == 0) 0 else chaptersCount - referenceChapters
override fun areItemsTheSame(other: ListModel): Boolean {
return other is MangaAlternativeModel && other.manga.id == manga.id
}
}

View File

@@ -2,7 +2,7 @@ package org.koitharu.kotatsu.bookmarks.data
import org.koitharu.kotatsu.bookmarks.domain.Bookmark import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import java.time.Instant import java.util.Date
fun BookmarkEntity.toBookmark(manga: Manga) = Bookmark( fun BookmarkEntity.toBookmark(manga: Manga) = Bookmark(
manga = manga, manga = manga,
@@ -11,7 +11,7 @@ fun BookmarkEntity.toBookmark(manga: Manga) = Bookmark(
page = page, page = page,
scroll = scroll, scroll = scroll,
imageUrl = imageUrl, imageUrl = imageUrl,
createdAt = Instant.ofEpochMilli(createdAt), createdAt = Date(createdAt),
percent = percent, percent = percent,
) )
@@ -22,7 +22,7 @@ fun Bookmark.toEntity() = BookmarkEntity(
page = page, page = page,
scroll = scroll, scroll = scroll,
imageUrl = imageUrl, imageUrl = imageUrl,
createdAt = createdAt.toEpochMilli(), createdAt = createdAt.time,
percent = percent, percent = percent,
) )

View File

@@ -1,10 +1,10 @@
package org.koitharu.kotatsu.bookmarks.domain package org.koitharu.kotatsu.bookmarks.domain
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.local.data.hasImageExtension import org.koitharu.kotatsu.local.data.ImageFileFilter
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaPage
import java.time.Instant import java.util.Date
data class Bookmark( data class Bookmark(
val manga: Manga, val manga: Manga,
@@ -13,7 +13,7 @@ data class Bookmark(
val page: Int, val page: Int,
val scroll: Int, val scroll: Int,
val imageUrl: String, val imageUrl: String,
val createdAt: Instant, val createdAt: Date,
val percent: Float, val percent: Float,
) : ListModel { ) : ListModel {
@@ -38,6 +38,7 @@ data class Bookmark(
) )
private fun isImageUrlDirect(): Boolean { private fun isImageUrlDirect(): Boolean {
return hasImageExtension(imageUrl) val extension = imageUrl.substringAfterLast('.')
return extension.isNotEmpty() && ImageFileFilter().isExtensionValid(extension)
} }
} }

View File

@@ -25,15 +25,15 @@ class BookmarksRepository @Inject constructor(
) { ) {
fun observeBookmark(manga: Manga, chapterId: Long, page: Int): Flow<Bookmark?> { fun observeBookmark(manga: Manga, chapterId: Long, page: Int): Flow<Bookmark?> {
return db.getBookmarksDao().observe(manga.id, chapterId, page).map { it?.toBookmark(manga) } return db.bookmarksDao.observe(manga.id, chapterId, page).map { it?.toBookmark(manga) }
} }
fun observeBookmarks(manga: Manga): Flow<List<Bookmark>> { fun observeBookmarks(manga: Manga): Flow<List<Bookmark>> {
return db.getBookmarksDao().observe(manga.id).mapItems { it.toBookmark(manga) } return db.bookmarksDao.observe(manga.id).mapItems { it.toBookmark(manga) }
} }
fun observeBookmarks(): Flow<Map<Manga, List<Bookmark>>> { fun observeBookmarks(): Flow<Map<Manga, List<Bookmark>>> {
return db.getBookmarksDao().observe().map { map -> return db.bookmarksDao.observe().map { map ->
val res = LinkedHashMap<Manga, List<Bookmark>>(map.size) val res = LinkedHashMap<Manga, List<Bookmark>>(map.size)
for ((k, v) in map) { for ((k, v) in map) {
val manga = k.toManga() val manga = k.toManga()
@@ -46,9 +46,9 @@ class BookmarksRepository @Inject constructor(
suspend fun addBookmark(bookmark: Bookmark) { suspend fun addBookmark(bookmark: Bookmark) {
db.withTransaction { db.withTransaction {
val tags = bookmark.manga.tags.toEntities() val tags = bookmark.manga.tags.toEntities()
db.getTagsDao().upsert(tags) db.tagsDao.upsert(tags)
db.getMangaDao().upsert(bookmark.manga.toEntity(), tags) db.mangaDao.upsert(bookmark.manga.toEntity(), tags)
db.getBookmarksDao().insert(bookmark.toEntity()) db.bookmarksDao.insert(bookmark.toEntity())
} }
} }
@@ -56,11 +56,11 @@ class BookmarksRepository @Inject constructor(
val entity = bookmark.toEntity().copy( val entity = bookmark.toEntity().copy(
imageUrl = imageUrl, imageUrl = imageUrl,
) )
db.getBookmarksDao().upsert(listOf(entity)) db.bookmarksDao.upsert(listOf(entity))
} }
suspend fun removeBookmark(mangaId: Long, chapterId: Long, page: Int) { suspend fun removeBookmark(mangaId: Long, chapterId: Long, page: Int) {
check(db.getBookmarksDao().delete(mangaId, chapterId, page) != 0) { check(db.bookmarksDao.delete(mangaId, chapterId, page) != 0) {
"Bookmark not found" "Bookmark not found"
} }
} }
@@ -72,7 +72,7 @@ class BookmarksRepository @Inject constructor(
suspend fun removeBookmarks(ids: Set<Long>): ReversibleHandle { suspend fun removeBookmarks(ids: Set<Long>): ReversibleHandle {
val entities = ArrayList<BookmarkEntity>(ids.size) val entities = ArrayList<BookmarkEntity>(ids.size)
db.withTransaction { db.withTransaction {
val dao = db.getBookmarksDao() val dao = db.bookmarksDao
for (pageId in ids) { for (pageId in ids) {
val e = dao.find(pageId) val e = dao.find(pageId)
if (e != null) { if (e != null) {
@@ -92,7 +92,7 @@ class BookmarksRepository @Inject constructor(
db.withTransaction { db.withTransaction {
for (e in entities) { for (e in entities) {
try { try {
db.getBookmarksDao().insert(e) db.bookmarksDao.insert(e)
} catch (e: SQLException) { } catch (e: SQLException) {
e.printStackTraceDebug() e.printStackTraceDebug()
} }

View File

@@ -34,8 +34,8 @@ class BookmarksActivity :
val fm = supportFragmentManager val fm = supportFragmentManager
if (fm.findFragmentById(R.id.container) == null) { if (fm.findFragmentById(R.id.container) == null) {
fm.commit { fm.commit {
setReorderingAllowed(true) val fragment = BookmarksFragment.newInstance()
replace(R.id.container, BookmarksFragment::class.java, null) replace(R.id.container, fragment)
} }
} }
} }

View File

@@ -13,6 +13,7 @@ import org.koitharu.kotatsu.list.ui.adapter.emptyStateListAD
import org.koitharu.kotatsu.list.ui.adapter.listHeaderAD import org.koitharu.kotatsu.list.ui.adapter.listHeaderAD
import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD 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.ListHeader
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
class BookmarksAdapter( class BookmarksAdapter(
@@ -31,6 +32,13 @@ class BookmarksAdapter(
} }
override fun getSectionText(context: Context, position: Int): CharSequence? { override fun getSectionText(context: Context, position: Int): CharSequence? {
return findHeader(position)?.getText(context) val list = items
for (i in (0..position).reversed()) {
val item = list.getOrNull(i) ?: continue
if (item is ListHeader) {
return item.getText(context)
}
}
return null
} }
} }

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.browser package org.koitharu.kotatsu.browser
import android.annotation.SuppressLint
import android.content.ActivityNotFoundException import android.content.ActivityNotFoundException
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
@@ -11,42 +12,31 @@ import android.webkit.CookieManager
import androidx.core.graphics.Insets import androidx.core.graphics.Insets
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.core.ui.BaseActivity import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.util.ext.configureForParser import org.koitharu.kotatsu.core.util.ext.catchingWebViewUnavailability
import org.koitharu.kotatsu.core.util.ext.getSerializableExtraCompat
import org.koitharu.kotatsu.core.util.ext.toUriOrNull
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.network.UserAgents
import javax.inject.Inject
import com.google.android.material.R as materialR import com.google.android.material.R as materialR
@AndroidEntryPoint @SuppressLint("SetJavaScriptEnabled")
class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback { class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback {
private lateinit var onBackPressedCallback: WebViewBackPressedCallback private lateinit var onBackPressedCallback: WebViewBackPressedCallback
@Inject
lateinit var mangaRepositoryFactory: MangaRepository.Factory
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
if (!setContentViewWebViewSafe { ActivityBrowserBinding.inflate(layoutInflater) }) { if (!catchingWebViewUnavailability { setContentView(ActivityBrowserBinding.inflate(layoutInflater)) }) {
return return
} }
supportActionBar?.run { supportActionBar?.run {
setDisplayHomeAsUpEnabled(true) setDisplayHomeAsUpEnabled(true)
setHomeAsUpIndicator(materialR.drawable.abc_ic_clear_material) setHomeAsUpIndicator(materialR.drawable.abc_ic_clear_material)
} }
val userAgent = intent?.getSerializableExtraCompat<MangaSource>(EXTRA_SOURCE)?.let { source -> with(viewBinding.webView.settings) {
val repository = mangaRepositoryFactory.create(source) as? RemoteMangaRepository javaScriptEnabled = true
repository?.headers?.get(CommonHeaders.USER_AGENT) userAgentString = UserAgents.CHROME_MOBILE
} }
viewBinding.webView.configureForParser(userAgent)
CookieManager.getInstance().setAcceptThirdPartyCookies(viewBinding.webView, true) CookieManager.getInstance().setAcceptThirdPartyCookies(viewBinding.webView, true)
viewBinding.webView.webViewClient = BrowserClient(this) viewBinding.webView.webViewClient = BrowserClient(this)
viewBinding.webView.webChromeClient = ProgressChromeClient(viewBinding.progressBar) viewBinding.webView.webChromeClient = ProgressChromeClient(viewBinding.progressBar)
@@ -67,6 +57,16 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
} }
} }
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
viewBinding.webView.saveState(outState)
}
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
super.onRestoreInstanceState(savedInstanceState)
viewBinding.webView.restoreState(savedInstanceState)
}
override fun onCreateOptionsMenu(menu: Menu): Boolean { override fun onCreateOptionsMenu(menu: Menu): Boolean {
super.onCreateOptionsMenu(menu) super.onCreateOptionsMenu(menu)
menuInflater.inflate(R.menu.opt_browser, menu) menuInflater.inflate(R.menu.opt_browser, menu)
@@ -81,14 +81,11 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
} }
R.id.action_browser -> { R.id.action_browser -> {
val url = viewBinding.webView.url?.toUriOrNull() val intent = Intent(Intent.ACTION_VIEW)
if (url != null) { intent.data = Uri.parse(viewBinding.webView.url)
val intent = Intent(Intent.ACTION_VIEW) try {
intent.data = url startActivity(Intent.createChooser(intent, item.title))
try { } catch (_: ActivityNotFoundException) {
startActivity(Intent.createChooser(intent, item.title))
} catch (_: ActivityNotFoundException) {
}
} }
true true
} }
@@ -139,13 +136,11 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
companion object { companion object {
private const val EXTRA_TITLE = "title" private const val EXTRA_TITLE = "title"
private const val EXTRA_SOURCE = "source"
fun newIntent(context: Context, url: String, source: MangaSource?, title: String?): Intent { fun newIntent(context: Context, url: String, title: String?): Intent {
return Intent(context, BrowserActivity::class.java) return Intent(context, BrowserActivity::class.java)
.setData(Uri.parse(url)) .setData(Uri.parse(url))
.putExtra(EXTRA_TITLE, title) .putExtra(EXTRA_TITLE, title)
.putExtra(EXTRA_SOURCE, source)
} }
} }
} }

View File

@@ -13,14 +13,13 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission
import org.koitharu.kotatsu.parsers.model.ContentType import org.koitharu.kotatsu.parsers.model.ContentType
import org.koitharu.kotatsu.parsers.model.MangaSource
class CaptchaNotifier( class CaptchaNotifier(
private val context: Context, private val context: Context,
) : EventListener { ) : EventListener {
fun notify(exception: CloudFlareProtectedException) { fun notify(exception: CloudFlareProtectedException) {
if (!context.checkNotificationPermission(CHANNEL_ID)) { if (!context.checkNotificationPermission()) {
return return
} }
val manager = NotificationManagerCompat.from(context) val manager = NotificationManagerCompat.from(context)
@@ -59,27 +58,16 @@ class CaptchaNotifier(
manager.notify(TAG, exception.source.hashCode(), notification) manager.notify(TAG, exception.source.hashCode(), notification)
} }
fun dismiss(source: MangaSource) {
NotificationManagerCompat.from(context).cancel(TAG, source.hashCode())
}
override fun onError(request: ImageRequest, result: ErrorResult) { override fun onError(request: ImageRequest, result: ErrorResult) {
super.onError(request, result) super.onError(request, result)
val e = result.throwable val e = result.throwable
if (e is CloudFlareProtectedException && request.parameters.value<Boolean>(PARAM_IGNORE_CAPTCHA) != true) { if (e is CloudFlareProtectedException) {
notify(e) notify(e)
} }
} }
companion object { private companion object {
fun ImageRequest.Builder.ignoreCaptchaErrors() = setParameter(
key = PARAM_IGNORE_CAPTCHA,
value = true,
memoryCacheKey = null,
)
private const val PARAM_IGNORE_CAPTCHA = "ignore_captcha"
private const val CHANNEL_ID = "captcha" private const val CHANNEL_ID = "captcha"
private const val TAG = CHANNEL_ID private const val TAG = CHANNEL_ID
} }

View File

@@ -27,8 +27,9 @@ import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
import org.koitharu.kotatsu.core.ui.BaseActivity import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.util.TaggedActivityResult import org.koitharu.kotatsu.core.util.TaggedActivityResult
import org.koitharu.kotatsu.core.util.ext.configureForParser import org.koitharu.kotatsu.core.util.ext.catchingWebViewUnavailability
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
import org.koitharu.kotatsu.parsers.network.UserAgents
import javax.inject.Inject import javax.inject.Inject
import com.google.android.material.R as materialR import com.google.android.material.R as materialR
@@ -40,12 +41,17 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
@Inject @Inject
lateinit var cookieJar: MutableCookieJar lateinit var cookieJar: MutableCookieJar
private lateinit var cfClient: CloudFlareClient
private var onBackPressedCallback: WebViewBackPressedCallback? = null private var onBackPressedCallback: WebViewBackPressedCallback? = null
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
if (!setContentViewWebViewSafe { ActivityBrowserBinding.inflate(layoutInflater) }) { if (!catchingWebViewUnavailability {
setContentView(
ActivityBrowserBinding.inflate(
layoutInflater
)
)
}) {
return return
} }
supportActionBar?.run { supportActionBar?.run {
@@ -53,9 +59,13 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
setHomeAsUpIndicator(materialR.drawable.abc_ic_clear_material) setHomeAsUpIndicator(materialR.drawable.abc_ic_clear_material)
} }
val url = intent?.dataString.orEmpty() val url = intent?.dataString.orEmpty()
cfClient = CloudFlareClient(cookieJar, this, url) with(viewBinding.webView.settings) {
viewBinding.webView.configureForParser(intent?.getStringExtra(ARG_UA)) javaScriptEnabled = true
viewBinding.webView.webViewClient = cfClient domStorageEnabled = true
databaseEnabled = true
userAgentString = intent?.getStringExtra(ARG_UA) ?: UserAgents.CHROME_MOBILE
}
viewBinding.webView.webViewClient = CloudFlareClient(cookieJar, this, url)
onBackPressedCallback = WebViewBackPressedCallback(viewBinding.webView).also { onBackPressedCallback = WebViewBackPressedCallback(viewBinding.webView).also {
onBackPressedDispatcher.addCallback(it) onBackPressedDispatcher.addCallback(it)
} }
@@ -72,15 +82,23 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
} }
override fun onDestroy() { override fun onDestroy() {
runCatching { viewBinding.webView.run {
viewBinding.webView stopLoading()
}.onSuccess { destroy()
it.stopLoading()
it.destroy()
} }
super.onDestroy() super.onDestroy()
} }
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
viewBinding.webView.saveState(outState)
}
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
super.onRestoreInstanceState(savedInstanceState)
viewBinding.webView.restoreState(savedInstanceState)
}
override fun onCreateOptionsMenu(menu: Menu?): Boolean { override fun onCreateOptionsMenu(menu: Menu?): Boolean {
menuInflater.inflate(R.menu.opt_captcha, menu) menuInflater.inflate(R.menu.opt_captcha, menu)
return super.onCreateOptionsMenu(menu) return super.onCreateOptionsMenu(menu)
@@ -105,7 +123,15 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
} }
R.id.action_retry -> { R.id.action_retry -> {
restartCheck() lifecycleScope.launch {
viewBinding.webView.stopLoading()
yield()
val targetUrl = intent?.dataString?.toHttpUrlOrNull()
if (targetUrl != null) {
clearCfCookies(targetUrl)
viewBinding.webView.loadUrl(targetUrl.toString())
}
}
true true
} }
@@ -131,10 +157,6 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
viewBinding.progressBar.isInvisible = true viewBinding.progressBar.isInvisible = true
} }
override fun onLoopDetected() {
restartCheck()
}
override fun onCheckPassed() { override fun onCheckPassed() {
pendingResult = RESULT_OK pendingResult = RESULT_OK
finishAfterTransition() finishAfterTransition()
@@ -154,23 +176,10 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
subtitle?.toString()?.toHttpUrlOrNull()?.topPrivateDomain() ?: subtitle subtitle?.toString()?.toHttpUrlOrNull()?.topPrivateDomain() ?: subtitle
} }
private fun restartCheck() {
lifecycleScope.launch {
viewBinding.webView.stopLoading()
yield()
cfClient.reset()
val targetUrl = intent?.dataString?.toHttpUrlOrNull()
if (targetUrl != null) {
clearCfCookies(targetUrl)
viewBinding.webView.loadUrl(targetUrl.toString())
}
}
}
private suspend fun clearCfCookies(url: HttpUrl) = runInterruptible(Dispatchers.Default) { private suspend fun clearCfCookies(url: HttpUrl) = runInterruptible(Dispatchers.Default) {
cookieJar.removeCookies(url) { cookie -> cookieJar.removeCookies(url) { cookie ->
val name = cookie.name val name = cookie.name
name.startsWith("cf_") || name.startsWith("_cf") || name.startsWith("__cf") || name == "csrftoken" name.startsWith("cf_") || name.startsWith("_cf") || name.startsWith("__cf")
} }
} }

View File

@@ -11,6 +11,4 @@ interface CloudFlareCallback : BrowserCallback {
fun onPageLoaded() fun onPageLoaded()
fun onCheckPassed() fun onCheckPassed()
fun onLoopDetected()
} }

View File

@@ -7,7 +7,6 @@ import org.koitharu.kotatsu.browser.BrowserClient
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
private const val CF_CLEARANCE = "cf_clearance" private const val CF_CLEARANCE = "cf_clearance"
private const val LOOP_COUNTER = 3
class CloudFlareClient( class CloudFlareClient(
private val cookieJar: MutableCookieJar, private val cookieJar: MutableCookieJar,
@@ -16,7 +15,6 @@ class CloudFlareClient(
) : BrowserClient(callback) { ) : BrowserClient(callback) {
private val oldClearance = getClearance() private val oldClearance = getClearance()
private var counter = 0
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) { override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
super.onPageStarted(view, url, favicon) super.onPageStarted(view, url, favicon)
@@ -33,20 +31,10 @@ class CloudFlareClient(
callback.onPageLoaded() callback.onPageLoaded()
} }
fun reset() {
counter = 0
}
private fun checkClearance() { private fun checkClearance() {
val clearance = getClearance() val clearance = getClearance()
if (clearance != null && clearance != oldClearance) { if (clearance != null && clearance != oldClearance) {
callback.onCheckPassed() callback.onCheckPassed()
} else {
counter++
if (counter >= LOOP_COUNTER) {
reset()
callback.onLoopDetected()
}
} }
} }

View File

@@ -2,7 +2,6 @@ package org.koitharu.kotatsu.core
import android.app.Application import android.app.Application
import android.content.Context import android.content.Context
import android.os.Build
import androidx.annotation.WorkerThread import androidx.annotation.WorkerThread
import androidx.appcompat.app.AppCompatDelegate import androidx.appcompat.app.AppCompatDelegate
import androidx.hilt.work.HiltWorkerFactory import androidx.hilt.work.HiltWorkerFactory
@@ -12,7 +11,6 @@ import androidx.work.WorkManager
import dagger.hilt.android.HiltAndroidApp import dagger.hilt.android.HiltAndroidApp
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.acra.ACRA import org.acra.ACRA
import org.acra.ReportField import org.acra.ReportField
import org.acra.config.dialog import org.acra.config.dialog
@@ -20,7 +18,6 @@ import org.acra.config.httpSender
import org.acra.data.StringFormat import org.acra.data.StringFormat
import org.acra.ktx.initAcra import org.acra.ktx.initAcra
import org.acra.sender.HttpSender import org.acra.sender.HttpSender
import org.conscrypt.Conscrypt
import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.MangaDatabase
@@ -29,7 +26,6 @@ import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.WorkServiceStopHelper import org.koitharu.kotatsu.core.util.WorkServiceStopHelper
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
import org.koitharu.kotatsu.settings.work.WorkScheduleManager import org.koitharu.kotatsu.settings.work.WorkScheduleManager
import java.security.Security
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Provider import javax.inject.Provider
@@ -43,7 +39,7 @@ open class BaseApp : Application(), Configuration.Provider {
lateinit var activityLifecycleCallbacks: Set<@JvmSuppressWildcards ActivityLifecycleCallbacks> lateinit var activityLifecycleCallbacks: Set<@JvmSuppressWildcards ActivityLifecycleCallbacks>
@Inject @Inject
lateinit var database: Provider<MangaDatabase> lateinit var database: MangaDatabase
@Inject @Inject
lateinit var settings: AppSettings lateinit var settings: AppSettings
@@ -60,26 +56,12 @@ open class BaseApp : Application(), Configuration.Provider {
@Inject @Inject
lateinit var workManagerProvider: Provider<WorkManager> lateinit var workManagerProvider: Provider<WorkManager>
override val workManagerConfiguration: Configuration
get() = Configuration.Builder()
.setWorkerFactory(workerFactory)
.build()
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
ACRA.errorReporter.putCustomData("isOriginalApp", appValidator.isOriginalApp.toString())
AppCompatDelegate.setDefaultNightMode(settings.theme) AppCompatDelegate.setDefaultNightMode(settings.theme)
AppCompatDelegate.setApplicationLocales(settings.appLocales) AppCompatDelegate.setApplicationLocales(settings.appLocales)
// TLS 1.3 support for Android < 10
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
Security.insertProviderAt(Conscrypt.newProvider(), 1)
}
setupActivityLifecycleCallbacks() setupActivityLifecycleCallbacks()
processLifecycleScope.launch {
val isOriginalApp = withContext(Dispatchers.Default) {
appValidator.isOriginalApp
}
ACRA.errorReporter.putCustomData("isOriginalApp", isOriginalApp.toString())
}
processLifecycleScope.launch(Dispatchers.Default) { processLifecycleScope.launch(Dispatchers.Default) {
setupDatabaseObservers() setupDatabaseObservers()
} }
@@ -92,6 +74,13 @@ open class BaseApp : Application(), Configuration.Provider {
initAcra { initAcra {
buildConfigClass = BuildConfig::class.java buildConfigClass = BuildConfig::class.java
reportFormat = StringFormat.JSON reportFormat = StringFormat.JSON
excludeMatchingSharedPreferencesKeys = listOf(
"sources_\\w+",
AppSettings.KEY_APP_PASSWORD,
AppSettings.KEY_PROXY_LOGIN,
AppSettings.KEY_PROXY_ADDRESS,
AppSettings.KEY_PROXY_PASSWORD,
)
httpSender { httpSender {
uri = getString(R.string.url_error_report) uri = getString(R.string.url_error_report)
basicAuthLogin = getString(R.string.acra_login) basicAuthLogin = getString(R.string.acra_login)
@@ -108,6 +97,7 @@ open class BaseApp : Application(), Configuration.Provider {
ReportField.STACK_TRACE, ReportField.STACK_TRACE,
ReportField.CRASH_CONFIGURATION, ReportField.CRASH_CONFIGURATION,
ReportField.CUSTOM_DATA, ReportField.CUSTOM_DATA,
ReportField.SHARED_PREFERENCES,
) )
dialog { dialog {
@@ -120,9 +110,15 @@ open class BaseApp : Application(), Configuration.Provider {
} }
} }
override fun getWorkManagerConfiguration(): Configuration {
return Configuration.Builder()
.setWorkerFactory(workerFactory)
.build()
}
@WorkerThread @WorkerThread
private fun setupDatabaseObservers() { private fun setupDatabaseObservers() {
val tracker = database.get().invalidationTracker val tracker = database.invalidationTracker
databaseObservers.forEach { databaseObservers.forEach {
tracker.addObserver(it) tracker.addObserver(it)
} }

View File

@@ -1,33 +0,0 @@
package org.koitharu.kotatsu.core
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.core.app.PendingIntentCompat
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.util.ext.getSerializableExtraCompat
import org.koitharu.kotatsu.core.util.ext.report
class ErrorReporterReceiver : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
val e = intent?.getSerializableExtraCompat<Throwable>(EXTRA_ERROR) ?: return
e.report()
}
companion object {
private const val EXTRA_ERROR = "err"
private const val ACTION_REPORT = "${BuildConfig.APPLICATION_ID}.action.REPORT_ERROR"
fun getPendingIntent(context: Context, e: Throwable): PendingIntent {
val intent = Intent(context, ErrorReporterReceiver::class.java)
intent.setAction(ACTION_REPORT)
intent.setData(Uri.parse("err://${e.hashCode()}"))
intent.putExtra(EXTRA_ERROR, e)
return checkNotNull(PendingIntentCompat.getBroadcast(context, 0, intent, 0, false))
}
}
}

View File

@@ -1,24 +0,0 @@
package org.koitharu.kotatsu.core
import android.content.Context
import com.google.auto.service.AutoService
import org.acra.builder.ReportBuilder
import org.acra.config.CoreConfiguration
import org.acra.config.ReportingAdministrator
@AutoService(ReportingAdministrator::class)
class ErrorReportingAdmin : ReportingAdministrator {
override fun shouldStartCollecting(
context: Context,
config: CoreConfiguration,
reportBuilder: ReportBuilder
): Boolean {
return reportBuilder.exception?.isDeadOs() != true
}
private fun Throwable.isDeadOs(): Boolean {
val className = javaClass.simpleName
return className == "DeadSystemException" || className == "DeadSystemRuntimeException" || cause?.isDeadOs() == true
}
}

View File

@@ -3,20 +3,17 @@ package org.koitharu.kotatsu.core.backup
import org.json.JSONArray import org.json.JSONArray
class BackupEntry( class BackupEntry(
val name: Name, val name: String,
val data: JSONArray val data: JSONArray
) { ) {
enum class Name( companion object Names {
val key: String,
) {
INDEX("index"), const val INDEX = "index"
HISTORY("history"), const val HISTORY = "history"
CATEGORIES("categories"), const val CATEGORIES = "categories"
FAVOURITES("favourites"), const val FAVOURITES = "favourites"
SETTINGS("settings"), const val SETTINGS = "settings"
BOOKMARKS("bookmarks"), const val BOOKMARKS = "bookmarks"
SOURCES("sources"),
} }
} }

View File

@@ -7,10 +7,8 @@ import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.parsers.util.json.JSONIterator import org.koitharu.kotatsu.parsers.util.json.JSONIterator
import org.koitharu.kotatsu.parsers.util.json.getLongOrDefault
import org.koitharu.kotatsu.parsers.util.json.mapJSON import org.koitharu.kotatsu.parsers.util.json.mapJSON
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import java.util.Date
import javax.inject.Inject import javax.inject.Inject
private const val PAGE_SIZE = 10 private const val PAGE_SIZE = 10
@@ -22,9 +20,9 @@ class BackupRepository @Inject constructor(
suspend fun dumpHistory(): BackupEntry { suspend fun dumpHistory(): BackupEntry {
var offset = 0 var offset = 0
val entry = BackupEntry(BackupEntry.Name.HISTORY, JSONArray()) val entry = BackupEntry(BackupEntry.HISTORY, JSONArray())
while (true) { while (true) {
val history = db.getHistoryDao().findAll(offset, PAGE_SIZE) val history = db.historyDao.findAll(offset, PAGE_SIZE)
if (history.isEmpty()) { if (history.isEmpty()) {
break break
} }
@@ -43,8 +41,8 @@ class BackupRepository @Inject constructor(
} }
suspend fun dumpCategories(): BackupEntry { suspend fun dumpCategories(): BackupEntry {
val entry = BackupEntry(BackupEntry.Name.CATEGORIES, JSONArray()) val entry = BackupEntry(BackupEntry.CATEGORIES, JSONArray())
val categories = db.getFavouriteCategoriesDao().findAll() val categories = db.favouriteCategoriesDao.findAll()
for (item in categories) { for (item in categories) {
entry.data.put(JsonSerializer(item).toJson()) entry.data.put(JsonSerializer(item).toJson())
} }
@@ -53,9 +51,9 @@ class BackupRepository @Inject constructor(
suspend fun dumpFavourites(): BackupEntry { suspend fun dumpFavourites(): BackupEntry {
var offset = 0 var offset = 0
val entry = BackupEntry(BackupEntry.Name.FAVOURITES, JSONArray()) val entry = BackupEntry(BackupEntry.FAVOURITES, JSONArray())
while (true) { while (true) {
val favourites = db.getFavouritesDao().findAllRaw(offset, PAGE_SIZE) val favourites = db.favouritesDao.findAll(offset, PAGE_SIZE)
if (favourites.isEmpty()) { if (favourites.isEmpty()) {
break break
} }
@@ -74,8 +72,8 @@ class BackupRepository @Inject constructor(
} }
suspend fun dumpBookmarks(): BackupEntry { suspend fun dumpBookmarks(): BackupEntry {
val entry = BackupEntry(BackupEntry.Name.BOOKMARKS, JSONArray()) val entry = BackupEntry(BackupEntry.BOOKMARKS, JSONArray())
val all = db.getBookmarksDao().findAll() val all = db.bookmarksDao.findAll()
for ((m, b) in all) { for ((m, b) in all) {
val json = JSONObject() val json = JSONObject()
val manga = JsonSerializer(m.manga).toJson() val manga = JsonSerializer(m.manga).toJson()
@@ -92,7 +90,7 @@ class BackupRepository @Inject constructor(
} }
fun dumpSettings(): BackupEntry { fun dumpSettings(): BackupEntry {
val entry = BackupEntry(BackupEntry.Name.SETTINGS, JSONArray()) val entry = BackupEntry(BackupEntry.SETTINGS, JSONArray())
val settingsDump = settings.getAllValues().toMutableMap() val settingsDump = settings.getAllValues().toMutableMap()
settingsDump.remove(AppSettings.KEY_APP_PASSWORD) settingsDump.remove(AppSettings.KEY_APP_PASSWORD)
settingsDump.remove(AppSettings.KEY_PROXY_PASSWORD) settingsDump.remove(AppSettings.KEY_PROXY_PASSWORD)
@@ -103,18 +101,8 @@ class BackupRepository @Inject constructor(
return entry return entry
} }
suspend fun dumpSources(): BackupEntry {
val entry = BackupEntry(BackupEntry.Name.SOURCES, JSONArray())
val all = db.getSourcesDao().findAll()
for (source in all) {
val json = JsonSerializer(source).toJson()
entry.data.put(json)
}
return entry
}
fun createIndex(): BackupEntry { fun createIndex(): BackupEntry {
val entry = BackupEntry(BackupEntry.Name.INDEX, JSONArray()) val entry = BackupEntry(BackupEntry.INDEX, JSONArray())
val json = JSONObject() val json = JSONObject()
json.put("app_id", BuildConfig.APPLICATION_ID) json.put("app_id", BuildConfig.APPLICATION_ID)
json.put("app_version", BuildConfig.VERSION_CODE) json.put("app_version", BuildConfig.VERSION_CODE)
@@ -123,11 +111,6 @@ class BackupRepository @Inject constructor(
return entry return entry
} }
fun getBackupDate(entry: BackupEntry?): Date? {
val timestamp = entry?.data?.optJSONObject(0)?.getLongOrDefault("created_at", 0) ?: 0
return if (timestamp == 0L) null else Date(timestamp)
}
suspend fun restoreHistory(entry: BackupEntry): CompositeResult { suspend fun restoreHistory(entry: BackupEntry): CompositeResult {
val result = CompositeResult() val result = CompositeResult()
for (item in entry.data.JSONIterator()) { for (item in entry.data.JSONIterator()) {
@@ -139,9 +122,9 @@ class BackupRepository @Inject constructor(
val history = JsonDeserializer(item).toHistoryEntity() val history = JsonDeserializer(item).toHistoryEntity()
result += runCatchingCancellable { result += runCatchingCancellable {
db.withTransaction { db.withTransaction {
db.getTagsDao().upsert(tags) db.tagsDao.upsert(tags)
db.getMangaDao().upsert(manga, tags) db.mangaDao.upsert(manga, tags)
db.getHistoryDao().upsert(history) db.historyDao.upsert(history)
} }
} }
} }
@@ -153,7 +136,7 @@ class BackupRepository @Inject constructor(
for (item in entry.data.JSONIterator()) { for (item in entry.data.JSONIterator()) {
val category = JsonDeserializer(item).toFavouriteCategoryEntity() val category = JsonDeserializer(item).toFavouriteCategoryEntity()
result += runCatchingCancellable { result += runCatchingCancellable {
db.getFavouriteCategoriesDao().upsert(category) db.favouriteCategoriesDao.upsert(category)
} }
} }
return result return result
@@ -170,9 +153,9 @@ class BackupRepository @Inject constructor(
val favourite = JsonDeserializer(item).toFavouriteEntity() val favourite = JsonDeserializer(item).toFavouriteEntity()
result += runCatchingCancellable { result += runCatchingCancellable {
db.withTransaction { db.withTransaction {
db.getTagsDao().upsert(tags) db.tagsDao.upsert(tags)
db.getMangaDao().upsert(manga, tags) db.mangaDao.upsert(manga, tags)
db.getFavouritesDao().upsert(favourite) db.favouritesDao.upsert(favourite)
} }
} }
} }
@@ -192,26 +175,15 @@ class BackupRepository @Inject constructor(
} }
result += runCatchingCancellable { result += runCatchingCancellable {
db.withTransaction { db.withTransaction {
db.getTagsDao().upsert(tags) db.tagsDao.upsert(tags)
db.getMangaDao().upsert(manga, tags) db.mangaDao.upsert(manga, tags)
db.getBookmarksDao().upsert(bookmarks) db.bookmarksDao.upsert(bookmarks)
} }
} }
} }
return result return result
} }
suspend fun restoreSources(entry: BackupEntry): CompositeResult {
val result = CompositeResult()
for (item in entry.data.JSONIterator()) {
val source = JsonDeserializer(item).toMangaSourceEntity()
result += runCatchingCancellable {
db.getSourcesDao().upsert(source)
}
}
return result
}
fun restoreSettings(entry: BackupEntry): CompositeResult { fun restoreSettings(entry: BackupEntry): CompositeResult {
val result = CompositeResult() val result = CompositeResult()
for (item in entry.data.JSONIterator()) { for (item in entry.data.JSONIterator()) {

View File

@@ -1,44 +1,25 @@
package org.koitharu.kotatsu.core.backup package org.koitharu.kotatsu.core.backup
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.runInterruptible
import okio.Closeable import okio.Closeable
import org.json.JSONArray import org.json.JSONArray
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
import java.io.File import java.io.File
import java.util.EnumSet
import java.util.zip.ZipFile import java.util.zip.ZipFile
class BackupZipInput(val file: File) : Closeable { class BackupZipInput(val file: File) : Closeable {
private val zipFile = ZipFile(file) private val zipFile = ZipFile(file)
suspend fun getEntry(name: BackupEntry.Name): BackupEntry? = runInterruptible(Dispatchers.IO) { suspend fun getEntry(name: String): BackupEntry? = runInterruptible(Dispatchers.IO) {
val entry = zipFile.getEntry(name.key) ?: return@runInterruptible null val entry = zipFile.getEntry(name) ?: return@runInterruptible null
val json = zipFile.getInputStream(entry).use { val json = zipFile.getInputStream(entry).use {
JSONArray(it.bufferedReader().readText()) JSONArray(it.bufferedReader().readText())
} }
BackupEntry(name, json) BackupEntry(name, json)
} }
suspend fun entries(): Set<BackupEntry.Name> = runInterruptible(Dispatchers.IO) {
zipFile.entries().toList().mapNotNullTo(EnumSet.noneOf(BackupEntry.Name::class.java)) { ze ->
BackupEntry.Name.entries.find { it.key == ze.name }
}
}
override fun close() { override fun close() {
zipFile.close() zipFile.close()
} }
fun cleanupAsync() {
processLifecycleScope.launch(Dispatchers.IO, CoroutineStart.ATOMIC) {
runCatching {
close()
file.delete()
}
}
}
} }

View File

@@ -5,10 +5,10 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.runInterruptible
import okio.Closeable import okio.Closeable
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ext.format
import org.koitharu.kotatsu.core.zip.ZipOutput import org.koitharu.kotatsu.core.zip.ZipOutput
import java.io.File import java.io.File
import java.time.LocalDate import java.util.Date
import java.time.format.DateTimeFormatter
import java.util.Locale import java.util.Locale
import java.util.zip.Deflater import java.util.zip.Deflater
@@ -17,7 +17,7 @@ class BackupZipOutput(val file: File) : Closeable {
private val output = ZipOutput(file, Deflater.BEST_COMPRESSION) private val output = ZipOutput(file, Deflater.BEST_COMPRESSION)
suspend fun put(entry: BackupEntry) = runInterruptible(Dispatchers.IO) { suspend fun put(entry: BackupEntry) = runInterruptible(Dispatchers.IO) {
output.put(entry.name.key, entry.data.toString(2)) output.put(entry.name, entry.data.toString(2))
} }
suspend fun finish() = runInterruptible(Dispatchers.IO) { suspend fun finish() = runInterruptible(Dispatchers.IO) {
@@ -29,7 +29,7 @@ class BackupZipOutput(val file: File) : Closeable {
} }
} }
const val DIR_BACKUPS = "backups" private const val DIR_BACKUPS = "backups"
suspend fun BackupZipOutput(context: Context): BackupZipOutput = runInterruptible(Dispatchers.IO) { suspend fun BackupZipOutput(context: Context): BackupZipOutput = runInterruptible(Dispatchers.IO) {
val dir = context.run { val dir = context.run {
@@ -39,7 +39,7 @@ suspend fun BackupZipOutput(context: Context): BackupZipOutput = runInterruptibl
val filename = buildString { val filename = buildString {
append(context.getString(R.string.app_name).replace(' ', '_').lowercase(Locale.ROOT)) append(context.getString(R.string.app_name).replace(' ', '_').lowercase(Locale.ROOT))
append('_') append('_')
append(LocalDate.now().format(DateTimeFormatter.ofPattern("ddMMyyyy"))) append(Date().format("ddMMyyyy"))
append(".bk.zip") append(".bk.zip")
} }
BackupZipOutput(File(dir, filename)) BackupZipOutput(File(dir, filename))

View File

@@ -3,7 +3,6 @@ package org.koitharu.kotatsu.core.backup
import org.json.JSONObject import org.json.JSONObject
import org.koitharu.kotatsu.bookmarks.data.BookmarkEntity import org.koitharu.kotatsu.bookmarks.data.BookmarkEntity
import org.koitharu.kotatsu.core.db.entity.MangaEntity import org.koitharu.kotatsu.core.db.entity.MangaEntity
import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity
import org.koitharu.kotatsu.core.db.entity.TagEntity import org.koitharu.kotatsu.core.db.entity.TagEntity
import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity
import org.koitharu.kotatsu.favourites.data.FavouriteEntity import org.koitharu.kotatsu.favourites.data.FavouriteEntity
@@ -54,7 +53,6 @@ class JsonDeserializer(private val json: JSONObject) {
page = json.getInt("page"), page = json.getInt("page"),
scroll = json.getDouble("scroll").toFloat(), scroll = json.getDouble("scroll").toFloat(),
percent = json.getFloatOrDefault("percent", -1f), percent = json.getFloatOrDefault("percent", -1f),
chaptersCount = json.getIntOrDefault("chapters", -1),
deletedAt = 0L, deletedAt = 0L,
) )
@@ -80,12 +78,6 @@ class JsonDeserializer(private val json: JSONObject) {
percent = json.getDouble("percent").toFloat(), percent = json.getDouble("percent").toFloat(),
) )
fun toMangaSourceEntity() = MangaSourceEntity(
source = json.getString("source"),
isEnabled = json.getBoolean("enabled"),
sortKey = json.getInt("sort_key"),
)
fun toMap(): Map<String, Any?> { fun toMap(): Map<String, Any?> {
val map = mutableMapOf<String, Any?>() val map = mutableMapOf<String, Any?>()
val keys = json.keys() val keys = json.keys()

View File

@@ -3,7 +3,6 @@ package org.koitharu.kotatsu.core.backup
import org.json.JSONObject import org.json.JSONObject
import org.koitharu.kotatsu.bookmarks.data.BookmarkEntity import org.koitharu.kotatsu.bookmarks.data.BookmarkEntity
import org.koitharu.kotatsu.core.db.entity.MangaEntity import org.koitharu.kotatsu.core.db.entity.MangaEntity
import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity
import org.koitharu.kotatsu.core.db.entity.TagEntity import org.koitharu.kotatsu.core.db.entity.TagEntity
import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity
import org.koitharu.kotatsu.favourites.data.FavouriteEntity import org.koitharu.kotatsu.favourites.data.FavouriteEntity
@@ -41,7 +40,6 @@ class JsonSerializer private constructor(private val json: JSONObject) {
put("page", e.page) put("page", e.page)
put("scroll", e.scroll) put("scroll", e.scroll)
put("percent", e.percent) put("percent", e.percent)
put("chapters", e.chaptersCount)
}, },
) )
@@ -84,14 +82,6 @@ class JsonSerializer private constructor(private val json: JSONObject) {
}, },
) )
constructor(e: MangaSourceEntity) : this(
JSONObject().apply {
put("source", e.source)
put("enabled", e.isEnabled)
put("sort_key", e.sortKey)
},
)
constructor(m: Map<String, *>) : this( constructor(m: Map<String, *>) : this(
JSONObject(m), JSONObject(m),
) )

View File

@@ -20,8 +20,6 @@ interface ContentCache {
fun putRelatedManga(source: MangaSource, url: String, related: SafeDeferred<List<Manga>>) fun putRelatedManga(source: MangaSource, url: String, related: SafeDeferred<List<Manga>>)
fun clear(source: MangaSource)
data class Key( data class Key(
val source: MangaSource, val source: MangaSource,
val url: String, val url: String,

View File

@@ -7,14 +7,12 @@ class ExpiringLruCache<T>(
val maxSize: Int, val maxSize: Int,
private val lifetime: Long, private val lifetime: Long,
private val timeUnit: TimeUnit, private val timeUnit: TimeUnit,
) : Iterable<ContentCache.Key> { ) {
private val cache = LruCache<ContentCache.Key, ExpiringValue<T>>(maxSize) private val cache = LruCache<ContentCache.Key, ExpiringValue<T>>(maxSize)
override fun iterator(): Iterator<ContentCache.Key> = cache.snapshot().keys.iterator()
operator fun get(key: ContentCache.Key): T? { operator fun get(key: ContentCache.Key): T? {
val value = cache[key] ?: return null val value = cache.get(key) ?: return null
if (value.isExpired) { if (value.isExpired) {
cache.remove(key) cache.remove(key)
} }
@@ -32,8 +30,4 @@ class ExpiringLruCache<T>(
fun trimToSize(size: Int) { fun trimToSize(size: Int) {
cache.trimToSize(size) cache.trimToSize(size)
} }
fun remove(key: ContentCache.Key) {
cache.remove(key)
}
} }

View File

@@ -44,12 +44,6 @@ class MemoryContentCache(application: Application) : ContentCache, ComponentCall
relatedMangaCache[ContentCache.Key(source, url)] = related relatedMangaCache[ContentCache.Key(source, url)] = related
} }
override fun clear(source: MangaSource) {
clearCache(detailsCache, source)
clearCache(pagesCache, source)
clearCache(relatedMangaCache, source)
}
override fun onConfigurationChanged(newConfig: Configuration) = Unit override fun onConfigurationChanged(newConfig: Configuration) = Unit
override fun onLowMemory() = Unit override fun onLowMemory() = Unit
@@ -73,12 +67,4 @@ class MemoryContentCache(application: Application) : ContentCache, ComponentCall
else -> cache.trimToSize(cache.maxSize / 2) else -> cache.trimToSize(cache.maxSize / 2)
} }
} }
private fun clearCache(cache: ExpiringLruCache<*>, source: MangaSource) {
cache.forEach { key ->
if (key.source == source) {
cache.remove(key)
}
}
}
} }

View File

@@ -19,6 +19,4 @@ class StubContentCache : ContentCache {
override suspend fun getRelatedManga(source: MangaSource, url: String): List<Manga>? = null override suspend fun getRelatedManga(source: MangaSource, url: String): List<Manga>? = null
override fun putRelatedManga(source: MangaSource, url: String, related: SafeDeferred<List<Manga>>) = Unit override fun putRelatedManga(source: MangaSource, url: String, related: SafeDeferred<List<Manga>>) = Unit
override fun clear(source: MangaSource) = Unit
} }

View File

@@ -29,8 +29,6 @@ import org.koitharu.kotatsu.core.db.migrations.Migration13To14
import org.koitharu.kotatsu.core.db.migrations.Migration14To15 import org.koitharu.kotatsu.core.db.migrations.Migration14To15
import org.koitharu.kotatsu.core.db.migrations.Migration15To16 import org.koitharu.kotatsu.core.db.migrations.Migration15To16
import org.koitharu.kotatsu.core.db.migrations.Migration16To17 import org.koitharu.kotatsu.core.db.migrations.Migration16To17
import org.koitharu.kotatsu.core.db.migrations.Migration17To18
import org.koitharu.kotatsu.core.db.migrations.Migration18To19
import org.koitharu.kotatsu.core.db.migrations.Migration1To2 import org.koitharu.kotatsu.core.db.migrations.Migration1To2
import org.koitharu.kotatsu.core.db.migrations.Migration2To3 import org.koitharu.kotatsu.core.db.migrations.Migration2To3
import org.koitharu.kotatsu.core.db.migrations.Migration3To4 import org.koitharu.kotatsu.core.db.migrations.Migration3To4
@@ -49,52 +47,48 @@ import org.koitharu.kotatsu.history.data.HistoryDao
import org.koitharu.kotatsu.history.data.HistoryEntity import org.koitharu.kotatsu.history.data.HistoryEntity
import org.koitharu.kotatsu.scrobbling.common.data.ScrobblingDao import org.koitharu.kotatsu.scrobbling.common.data.ScrobblingDao
import org.koitharu.kotatsu.scrobbling.common.data.ScrobblingEntity import org.koitharu.kotatsu.scrobbling.common.data.ScrobblingEntity
import org.koitharu.kotatsu.stats.data.StatsDao
import org.koitharu.kotatsu.stats.data.StatsEntity
import org.koitharu.kotatsu.suggestions.data.SuggestionDao import org.koitharu.kotatsu.suggestions.data.SuggestionDao
import org.koitharu.kotatsu.suggestions.data.SuggestionEntity import org.koitharu.kotatsu.suggestions.data.SuggestionEntity
import org.koitharu.kotatsu.tracker.data.TrackEntity import org.koitharu.kotatsu.tracker.data.TrackEntity
import org.koitharu.kotatsu.tracker.data.TrackLogEntity import org.koitharu.kotatsu.tracker.data.TrackLogEntity
import org.koitharu.kotatsu.tracker.data.TracksDao import org.koitharu.kotatsu.tracker.data.TracksDao
const val DATABASE_VERSION = 19 const val DATABASE_VERSION = 17
@Database( @Database(
entities = [ entities = [
MangaEntity::class, TagEntity::class, HistoryEntity::class, MangaTagsEntity::class, MangaEntity::class, TagEntity::class, HistoryEntity::class, MangaTagsEntity::class,
FavouriteCategoryEntity::class, FavouriteEntity::class, MangaPrefsEntity::class, FavouriteCategoryEntity::class, FavouriteEntity::class, MangaPrefsEntity::class,
TrackEntity::class, TrackLogEntity::class, SuggestionEntity::class, BookmarkEntity::class, TrackEntity::class, TrackLogEntity::class, SuggestionEntity::class, BookmarkEntity::class,
ScrobblingEntity::class, MangaSourceEntity::class, StatsEntity::class, ScrobblingEntity::class, MangaSourceEntity::class,
], ],
version = DATABASE_VERSION, version = DATABASE_VERSION,
) )
abstract class MangaDatabase : RoomDatabase() { abstract class MangaDatabase : RoomDatabase() {
abstract fun getHistoryDao(): HistoryDao abstract val historyDao: HistoryDao
abstract fun getTagsDao(): TagsDao abstract val tagsDao: TagsDao
abstract fun getMangaDao(): MangaDao abstract val mangaDao: MangaDao
abstract fun getFavouritesDao(): FavouritesDao abstract val favouritesDao: FavouritesDao
abstract fun getPreferencesDao(): PreferencesDao abstract val preferencesDao: PreferencesDao
abstract fun getFavouriteCategoriesDao(): FavouriteCategoriesDao abstract val favouriteCategoriesDao: FavouriteCategoriesDao
abstract fun getTracksDao(): TracksDao abstract val tracksDao: TracksDao
abstract fun getTrackLogsDao(): TrackLogsDao abstract val trackLogsDao: TrackLogsDao
abstract fun getSuggestionDao(): SuggestionDao abstract val suggestionDao: SuggestionDao
abstract fun getBookmarksDao(): BookmarksDao abstract val bookmarksDao: BookmarksDao
abstract fun getScrobblingDao(): ScrobblingDao abstract val scrobblingDao: ScrobblingDao
abstract fun getSourcesDao(): MangaSourcesDao abstract val sourcesDao: MangaSourcesDao
abstract fun getStatsDao(): StatsDao
} }
fun getDatabaseMigrations(context: Context): Array<Migration> = arrayOf( fun getDatabaseMigrations(context: Context): Array<Migration> = arrayOf(
@@ -114,8 +108,6 @@ fun getDatabaseMigrations(context: Context): Array<Migration> = arrayOf(
Migration14To15(), Migration14To15(),
Migration15To16(), Migration15To16(),
Migration16To17(context), Migration16To17(context),
Migration17To18(),
Migration18To19(),
) )
fun MangaDatabase(context: Context): MangaDatabase = Room fun MangaDatabase(context: Context): MangaDatabase = Room

View File

@@ -1,7 +1,6 @@
package org.koitharu.kotatsu.core.db.dao package org.koitharu.kotatsu.core.db.dao
import androidx.room.Dao import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert import androidx.room.Insert
import androidx.room.OnConflictStrategy import androidx.room.OnConflictStrategy
import androidx.room.Query import androidx.room.Query
@@ -24,10 +23,6 @@ abstract class MangaDao {
@Query("SELECT * FROM manga WHERE public_url = :publicUrl") @Query("SELECT * FROM manga WHERE public_url = :publicUrl")
abstract suspend fun findByPublicUrl(publicUrl: String): MangaWithTags? abstract suspend fun findByPublicUrl(publicUrl: String): MangaWithTags?
@Transaction
@Query("SELECT * FROM manga WHERE source = :source")
abstract suspend fun findAllBySource(source: String): List<MangaWithTags>
@Transaction @Transaction
@Query("SELECT * FROM manga WHERE (title LIKE :query OR alt_title LIKE :query) AND manga_id IN (SELECT manga_id FROM favourites UNION SELECT manga_id FROM history) LIMIT :limit") @Query("SELECT * FROM manga WHERE (title LIKE :query OR alt_title LIKE :query) AND manga_id IN (SELECT manga_id FROM favourites UNION SELECT manga_id FROM history) LIMIT :limit")
abstract suspend fun searchByTitle(query: String, limit: Int): List<MangaWithTags> abstract suspend fun searchByTitle(query: String, limit: Int): List<MangaWithTags>
@@ -48,10 +43,6 @@ abstract class MangaDao {
@Query("DELETE FROM manga_tags WHERE manga_id = :mangaId") @Query("DELETE FROM manga_tags WHERE manga_id = :mangaId")
abstract suspend fun clearTagRelation(mangaId: Long) abstract suspend fun clearTagRelation(mangaId: Long)
@Transaction
@Delete
abstract suspend fun delete(subjects: Collection<MangaEntity>)
@Transaction @Transaction
open suspend fun upsert(manga: MangaEntity, tags: Iterable<TagEntity>? = null) { open suspend fun upsert(manga: MangaEntity, tags: Iterable<TagEntity>? = null) {
upsert(manga) upsert(manga)

View File

@@ -4,15 +4,10 @@ import androidx.room.Dao
import androidx.room.Insert import androidx.room.Insert
import androidx.room.OnConflictStrategy import androidx.room.OnConflictStrategy
import androidx.room.Query import androidx.room.Query
import androidx.room.RawQuery
import androidx.room.Transaction import androidx.room.Transaction
import androidx.room.Upsert import androidx.room.Upsert
import androidx.sqlite.db.SimpleSQLiteQuery
import androidx.sqlite.db.SupportSQLiteQuery
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import org.intellij.lang.annotations.Language
import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity
import org.koitharu.kotatsu.explore.data.SourcesSortOrder
@Dao @Dao
abstract class MangaSourcesDao { abstract class MangaSourcesDao {
@@ -20,18 +15,15 @@ abstract class MangaSourcesDao {
@Query("SELECT * FROM sources ORDER BY sort_key") @Query("SELECT * FROM sources ORDER BY sort_key")
abstract suspend fun findAll(): List<MangaSourceEntity> abstract suspend fun findAll(): List<MangaSourceEntity>
@Query("SELECT * FROM sources WHERE enabled = 0 ORDER BY sort_key") @Query("SELECT * FROM sources WHERE enabled = 1 ORDER BY sort_key")
abstract suspend fun findAllDisabled(): List<MangaSourceEntity> abstract suspend fun findAllEnabled(): List<MangaSourceEntity>
@Query("SELECT * FROM sources WHERE enabled = 0") @Query("SELECT * FROM sources WHERE enabled = 1 ORDER BY sort_key")
abstract fun observeDisabled(): Flow<List<MangaSourceEntity>> abstract fun observeEnabled(): Flow<List<MangaSourceEntity>>
@Query("SELECT * FROM sources ORDER BY sort_key") @Query("SELECT * FROM sources ORDER BY sort_key")
abstract fun observeAll(): Flow<List<MangaSourceEntity>> abstract fun observeAll(): Flow<List<MangaSourceEntity>>
@Query("SELECT enabled FROM sources WHERE source = :source")
abstract fun observeIsEnabled(source: String): Flow<Boolean>
@Query("SELECT IFNULL(MAX(sort_key),0) FROM sources") @Query("SELECT IFNULL(MAX(sort_key),0) FROM sources")
abstract suspend fun getMaxSortKey(): Int abstract suspend fun getMaxSortKey(): Int
@@ -48,22 +40,6 @@ abstract class MangaSourcesDao {
@Upsert @Upsert
abstract suspend fun upsert(entry: MangaSourceEntity) abstract suspend fun upsert(entry: MangaSourceEntity)
fun observeEnabled(order: SourcesSortOrder): Flow<List<MangaSourceEntity>> {
val orderBy = getOrderBy(order)
@Language("RoomSql")
val query = SimpleSQLiteQuery("SELECT * FROM sources WHERE enabled = 1 ORDER BY $orderBy")
return observeImpl(query)
}
suspend fun findAllEnabled(order: SourcesSortOrder): List<MangaSourceEntity> {
val orderBy = getOrderBy(order)
@Language("RoomSql")
val query = SimpleSQLiteQuery("SELECT * FROM sources WHERE enabled = 1 ORDER BY $orderBy")
return findAllImpl(query)
}
@Transaction @Transaction
open suspend fun setEnabled(source: String, isEnabled: Boolean) { open suspend fun setEnabled(source: String, isEnabled: Boolean) {
if (updateIsEnabled(source, isEnabled) == 0) { if (updateIsEnabled(source, isEnabled) == 0) {
@@ -78,16 +54,4 @@ abstract class MangaSourcesDao {
@Query("UPDATE sources SET enabled = :isEnabled WHERE source = :source") @Query("UPDATE sources SET enabled = :isEnabled WHERE source = :source")
protected abstract suspend fun updateIsEnabled(source: String, isEnabled: Boolean): Int protected abstract suspend fun updateIsEnabled(source: String, isEnabled: Boolean): Int
@RawQuery(observedEntities = [MangaSourceEntity::class])
protected abstract fun observeImpl(query: SupportSQLiteQuery): Flow<List<MangaSourceEntity>>
@RawQuery
protected abstract suspend fun findAllImpl(query: SupportSQLiteQuery): List<MangaSourceEntity>
private fun getOrderBy(order: SourcesSortOrder) = when (order) {
SourcesSortOrder.ALPHABETIC -> "source ASC"
SourcesSortOrder.POPULARITY -> "(SELECT COUNT(*) FROM manga WHERE source = sources.source) DESC"
SourcesSortOrder.MANUAL -> "sort_key ASC"
}
} }

View File

@@ -31,16 +31,6 @@ abstract class TagsDao {
) )
abstract suspend fun findPopularTags(source: String, limit: Int): List<TagEntity> abstract suspend fun findPopularTags(source: String, limit: Int): List<TagEntity>
@Query(
"""SELECT tags.* FROM tags
LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id
WHERE tags.source = :source
GROUP BY tags.title
ORDER BY COUNT(manga_id) ASC
LIMIT :limit""",
)
abstract suspend fun findRareTags(source: String, limit: Int): List<TagEntity>
@Query( @Query(
"""SELECT tags.* FROM tags """SELECT tags.* FROM tags
LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id

View File

@@ -24,5 +24,4 @@ data class MangaPrefsEntity(
@ColumnInfo(name = "cf_brightness") val cfBrightness: Float, @ColumnInfo(name = "cf_brightness") val cfBrightness: Float,
@ColumnInfo(name = "cf_contrast") val cfContrast: Float, @ColumnInfo(name = "cf_contrast") val cfContrast: Float,
@ColumnInfo(name = "cf_invert") val cfInvert: Boolean, @ColumnInfo(name = "cf_invert") val cfInvert: Boolean,
@ColumnInfo(name = "cf_grayscale") val cfGrayscale: Boolean,
) )

View File

@@ -5,8 +5,8 @@ import androidx.sqlite.db.SupportSQLiteDatabase
class Migration10To11 : Migration(10, 11) { class Migration10To11 : Migration(10, 11) {
override fun migrate(db: SupportSQLiteDatabase) { override fun migrate(database: SupportSQLiteDatabase) {
db.execSQL( database.execSQL(
""" """
CREATE TABLE IF NOT EXISTS `bookmarks` ( CREATE TABLE IF NOT EXISTS `bookmarks` (
`manga_id` INTEGER NOT NULL, `manga_id` INTEGER NOT NULL,
@@ -20,7 +20,7 @@ class Migration10To11 : Migration(10, 11) {
FOREIGN KEY(`manga_id`) REFERENCES `manga`(`manga_id`) ON UPDATE NO ACTION ON DELETE CASCADE ) FOREIGN KEY(`manga_id`) REFERENCES `manga`(`manga_id`) ON UPDATE NO ACTION ON DELETE CASCADE )
""".trimIndent() """.trimIndent()
) )
db.execSQL("CREATE INDEX IF NOT EXISTS `index_bookmarks_manga_id` ON `bookmarks` (`manga_id`)") database.execSQL("CREATE INDEX IF NOT EXISTS `index_bookmarks_manga_id` ON `bookmarks` (`manga_id`)")
db.execSQL("CREATE INDEX IF NOT EXISTS `index_bookmarks_page_id` ON `bookmarks` (`page_id`)") database.execSQL("CREATE INDEX IF NOT EXISTS `index_bookmarks_page_id` ON `bookmarks` (`page_id`)")
} }
} }

View File

@@ -5,8 +5,8 @@ import androidx.sqlite.db.SupportSQLiteDatabase
class Migration11To12 : Migration(11, 12) { class Migration11To12 : Migration(11, 12) {
override fun migrate(db: SupportSQLiteDatabase) { override fun migrate(database: SupportSQLiteDatabase) {
db.execSQL( database.execSQL(
""" """
CREATE TABLE IF NOT EXISTS `scrobblings` ( CREATE TABLE IF NOT EXISTS `scrobblings` (
`scrobbler` INTEGER NOT NULL, `scrobbler` INTEGER NOT NULL,
@@ -21,7 +21,7 @@ class Migration11To12 : Migration(11, 12) {
) )
""".trimIndent() """.trimIndent()
) )
db.execSQL("ALTER TABLE history ADD COLUMN `percent` REAL NOT NULL DEFAULT -1") database.execSQL("ALTER TABLE history ADD COLUMN `percent` REAL NOT NULL DEFAULT -1")
db.execSQL("ALTER TABLE bookmarks ADD COLUMN `percent` REAL NOT NULL DEFAULT -1") database.execSQL("ALTER TABLE bookmarks ADD COLUMN `percent` REAL NOT NULL DEFAULT -1")
} }
} }

View File

@@ -5,8 +5,8 @@ import androidx.sqlite.db.SupportSQLiteDatabase
class Migration12To13 : Migration(12, 13) { class Migration12To13 : Migration(12, 13) {
override fun migrate(db: SupportSQLiteDatabase) { override fun migrate(database: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE favourite_categories ADD COLUMN `show_in_lib` INTEGER NOT NULL DEFAULT 1") database.execSQL("ALTER TABLE favourite_categories ADD COLUMN `show_in_lib` INTEGER NOT NULL DEFAULT 1")
db.execSQL("ALTER TABLE favourites ADD COLUMN `sort_key` INTEGER NOT NULL DEFAULT 0") database.execSQL("ALTER TABLE favourites ADD COLUMN `sort_key` INTEGER NOT NULL DEFAULT 0")
} }
} }

View File

@@ -5,11 +5,11 @@ import androidx.sqlite.db.SupportSQLiteDatabase
class Migration13To14 : Migration(13, 14) { class Migration13To14 : Migration(13, 14) {
override fun migrate(db: SupportSQLiteDatabase) { override fun migrate(database: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE favourite_categories ADD COLUMN `deleted_at` INTEGER NOT NULL DEFAULT 0") database.execSQL("ALTER TABLE favourite_categories ADD COLUMN `deleted_at` INTEGER NOT NULL DEFAULT 0")
db.execSQL("ALTER TABLE favourites ADD COLUMN `deleted_at` INTEGER NOT NULL DEFAULT 0") database.execSQL("ALTER TABLE favourites ADD COLUMN `deleted_at` INTEGER NOT NULL DEFAULT 0")
db.execSQL("ALTER TABLE history ADD COLUMN `deleted_at` INTEGER NOT NULL DEFAULT 0") database.execSQL("ALTER TABLE history ADD COLUMN `deleted_at` INTEGER NOT NULL DEFAULT 0")
db.execSQL("ALTER TABLE preferences ADD COLUMN `cf_brightness` REAL NOT NULL DEFAULT 0") database.execSQL("ALTER TABLE preferences ADD COLUMN `cf_brightness` REAL NOT NULL DEFAULT 0")
db.execSQL("ALTER TABLE preferences ADD COLUMN `cf_contrast` REAL NOT NULL DEFAULT 0") database.execSQL("ALTER TABLE preferences ADD COLUMN `cf_contrast` REAL NOT NULL DEFAULT 0")
} }
} }

View File

@@ -5,5 +5,5 @@ import androidx.sqlite.db.SupportSQLiteDatabase
class Migration14To15 : Migration(14, 15) { class Migration14To15 : Migration(14, 15) {
override fun migrate(db: SupportSQLiteDatabase) = Unit override fun migrate(database: SupportSQLiteDatabase) = Unit
} }

View File

@@ -5,7 +5,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase
class Migration15To16 : Migration(15, 16) { class Migration15To16 : Migration(15, 16) {
override fun migrate(db: SupportSQLiteDatabase) { override fun migrate(database: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE preferences ADD COLUMN `cf_invert` INTEGER NOT NULL DEFAULT 0") database.execSQL("ALTER TABLE preferences ADD COLUMN `cf_invert` INTEGER NOT NULL DEFAULT 0")
} }
} }

View File

@@ -10,9 +10,9 @@ class Migration16To17(context: Context) : Migration(16, 17) {
private val prefs = PreferenceManager.getDefaultSharedPreferences(context) private val prefs = PreferenceManager.getDefaultSharedPreferences(context)
override fun migrate(db: SupportSQLiteDatabase) { override fun migrate(database: SupportSQLiteDatabase) {
db.execSQL("CREATE TABLE `sources` (`source` TEXT NOT NULL, `enabled` INTEGER NOT NULL, `sort_key` INTEGER NOT NULL, PRIMARY KEY(`source`))") database.execSQL("CREATE TABLE `sources` (`source` TEXT NOT NULL, `enabled` INTEGER NOT NULL, `sort_key` INTEGER NOT NULL, PRIMARY KEY(`source`))")
db.execSQL("CREATE INDEX `index_sources_sort_key` ON `sources` (`sort_key`)") database.execSQL("CREATE INDEX `index_sources_sort_key` ON `sources` (`sort_key`)")
val hiddenSources = prefs.getStringSet("sources_hidden", null).orEmpty() val hiddenSources = prefs.getStringSet("sources_hidden", null).orEmpty()
val order = prefs.getString("sources_order_2", null)?.split('|').orEmpty() val order = prefs.getString("sources_order_2", null)?.split('|').orEmpty()
val sources = MangaSource.entries val sources = MangaSource.entries
@@ -30,7 +30,7 @@ class Migration16To17(context: Context) : Migration(16, 17) {
continue continue
} }
} }
db.execSQL( database.execSQL(
"INSERT INTO `sources` (`source`, `enabled`, `sort_key`) VALUES (?, ?, ?)", "INSERT INTO `sources` (`source`, `enabled`, `sort_key`) VALUES (?, ?, ?)",
arrayOf(name, (!isHidden).toInt(), sortKey), arrayOf(name, (!isHidden).toInt(), sortKey),
) )

View File

@@ -1,11 +0,0 @@
package org.koitharu.kotatsu.core.db.migrations
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
class Migration17To18 : Migration(17, 18) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE preferences ADD COLUMN `cf_grayscale` INTEGER NOT NULL DEFAULT 0")
}
}

View File

@@ -1,12 +0,0 @@
package org.koitharu.kotatsu.core.db.migrations
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
class Migration18To19 : Migration(18, 19) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE history ADD COLUMN `chapters` INTEGER NOT NULL DEFAULT -1")
db.execSQL("CREATE TABLE IF NOT EXISTS `stats` (`manga_id` INTEGER NOT NULL, `started_at` INTEGER NOT NULL, `duration` INTEGER NOT NULL, `pages` INTEGER NOT NULL, PRIMARY KEY(`manga_id`, `started_at`), FOREIGN KEY(`manga_id`) REFERENCES `history`(`manga_id`) ON UPDATE NO ACTION ON DELETE CASCADE )")
}
}

View File

@@ -7,48 +7,48 @@ class Migration1To2 : Migration(1, 2) {
/** /**
* Adding foreign keys * Adding foreign keys
*/ */
override fun migrate(db: SupportSQLiteDatabase) { override fun migrate(database: SupportSQLiteDatabase) {
/* manga_tags */ /* manga_tags */
db.execSQL( database.execSQL(
"CREATE TABLE IF NOT EXISTS manga_tags_tmp (manga_id INTEGER NOT NULL, tag_id INTEGER NOT NULL, " + "CREATE TABLE IF NOT EXISTS manga_tags_tmp (manga_id INTEGER NOT NULL, tag_id INTEGER NOT NULL, " +
"PRIMARY KEY(manga_id, tag_id), " + "PRIMARY KEY(manga_id, tag_id), " +
"FOREIGN KEY(manga_id) REFERENCES manga(manga_id) ON UPDATE NO ACTION ON DELETE CASCADE, " + "FOREIGN KEY(manga_id) REFERENCES manga(manga_id) ON UPDATE NO ACTION ON DELETE CASCADE, " +
"FOREIGN KEY(tag_id) REFERENCES tags(tag_id) ON UPDATE NO ACTION ON DELETE CASCADE )" "FOREIGN KEY(tag_id) REFERENCES tags(tag_id) ON UPDATE NO ACTION ON DELETE CASCADE )"
) )
db.execSQL("CREATE INDEX IF NOT EXISTS index_manga_tags_manga_id ON manga_tags_tmp (manga_id)") database.execSQL("CREATE INDEX IF NOT EXISTS index_manga_tags_manga_id ON manga_tags_tmp (manga_id)")
db.execSQL("CREATE INDEX IF NOT EXISTS index_manga_tags_tag_id ON manga_tags_tmp (tag_id)") database.execSQL("CREATE INDEX IF NOT EXISTS index_manga_tags_tag_id ON manga_tags_tmp (tag_id)")
db.execSQL("INSERT INTO manga_tags_tmp (manga_id, tag_id) SELECT manga_id, tag_id FROM manga_tags") database.execSQL("INSERT INTO manga_tags_tmp (manga_id, tag_id) SELECT manga_id, tag_id FROM manga_tags")
db.execSQL("DROP TABLE manga_tags") database.execSQL("DROP TABLE manga_tags")
db.execSQL("ALTER TABLE manga_tags_tmp RENAME TO manga_tags") database.execSQL("ALTER TABLE manga_tags_tmp RENAME TO manga_tags")
/* favourites */ /* favourites */
db.execSQL( database.execSQL(
"CREATE TABLE IF NOT EXISTS favourites_tmp (manga_id INTEGER NOT NULL, category_id INTEGER NOT NULL, created_at INTEGER NOT NULL, " + "CREATE TABLE IF NOT EXISTS favourites_tmp (manga_id INTEGER NOT NULL, category_id INTEGER NOT NULL, created_at INTEGER NOT NULL, " +
"PRIMARY KEY(manga_id, category_id), " + "PRIMARY KEY(manga_id, category_id), " +
"FOREIGN KEY(manga_id) REFERENCES manga(manga_id) ON UPDATE NO ACTION ON DELETE CASCADE , " + "FOREIGN KEY(manga_id) REFERENCES manga(manga_id) ON UPDATE NO ACTION ON DELETE CASCADE , " +
"FOREIGN KEY(category_id) REFERENCES favourite_categories(category_id) ON UPDATE NO ACTION ON DELETE CASCADE )" "FOREIGN KEY(category_id) REFERENCES favourite_categories(category_id) ON UPDATE NO ACTION ON DELETE CASCADE )"
) )
db.execSQL("CREATE INDEX IF NOT EXISTS index_favourites_manga_id ON favourites_tmp (manga_id)") database.execSQL("CREATE INDEX IF NOT EXISTS index_favourites_manga_id ON favourites_tmp (manga_id)")
db.execSQL("CREATE INDEX IF NOT EXISTS index_favourites_category_id ON favourites_tmp (category_id)") database.execSQL("CREATE INDEX IF NOT EXISTS index_favourites_category_id ON favourites_tmp (category_id)")
db.execSQL("INSERT INTO favourites_tmp (manga_id, category_id, created_at) SELECT manga_id, category_id, created_at FROM favourites") database.execSQL("INSERT INTO favourites_tmp (manga_id, category_id, created_at) SELECT manga_id, category_id, created_at FROM favourites")
db.execSQL("DROP TABLE favourites") database.execSQL("DROP TABLE favourites")
db.execSQL("ALTER TABLE favourites_tmp RENAME TO favourites") database.execSQL("ALTER TABLE favourites_tmp RENAME TO favourites")
/* history */ /* history */
db.execSQL( database.execSQL(
"CREATE TABLE IF NOT EXISTS history_tmp (manga_id INTEGER NOT NULL, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, chapter_id INTEGER NOT NULL, page INTEGER NOT NULL, " + "CREATE TABLE IF NOT EXISTS history_tmp (manga_id INTEGER NOT NULL, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, chapter_id INTEGER NOT NULL, page INTEGER NOT NULL, " +
"PRIMARY KEY(manga_id), " + "PRIMARY KEY(manga_id), " +
"FOREIGN KEY(manga_id) REFERENCES manga(manga_id) ON UPDATE NO ACTION ON DELETE CASCADE )" "FOREIGN KEY(manga_id) REFERENCES manga(manga_id) ON UPDATE NO ACTION ON DELETE CASCADE )"
) )
db.execSQL("INSERT INTO history_tmp (manga_id, created_at, updated_at, chapter_id, page) SELECT manga_id, created_at, updated_at, chapter_id, page FROM history") database.execSQL("INSERT INTO history_tmp (manga_id, created_at, updated_at, chapter_id, page) SELECT manga_id, created_at, updated_at, chapter_id, page FROM history")
db.execSQL("DROP TABLE history") database.execSQL("DROP TABLE history")
db.execSQL("ALTER TABLE history_tmp RENAME TO history") database.execSQL("ALTER TABLE history_tmp RENAME TO history")
/* preferences */ /* preferences */
db.execSQL( database.execSQL(
"CREATE TABLE IF NOT EXISTS preferences_tmp (manga_id INTEGER NOT NULL, mode INTEGER NOT NULL," + "CREATE TABLE IF NOT EXISTS preferences_tmp (manga_id INTEGER NOT NULL, mode INTEGER NOT NULL," +
" PRIMARY KEY(manga_id), " + " PRIMARY KEY(manga_id), " +
"FOREIGN KEY(manga_id) REFERENCES manga(manga_id) ON UPDATE NO ACTION ON DELETE CASCADE )" "FOREIGN KEY(manga_id) REFERENCES manga(manga_id) ON UPDATE NO ACTION ON DELETE CASCADE )"
) )
db.execSQL("INSERT INTO preferences_tmp (manga_id, mode) SELECT manga_id, mode FROM preferences") database.execSQL("INSERT INTO preferences_tmp (manga_id, mode) SELECT manga_id, mode FROM preferences")
db.execSQL("DROP TABLE preferences") database.execSQL("DROP TABLE preferences")
db.execSQL("ALTER TABLE preferences_tmp RENAME TO preferences") database.execSQL("ALTER TABLE preferences_tmp RENAME TO preferences")
} }
} }

View File

@@ -5,7 +5,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase
class Migration2To3 : Migration(2, 3) { class Migration2To3 : Migration(2, 3) {
override fun migrate(db: SupportSQLiteDatabase) { override fun migrate(database: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE history ADD COLUMN scroll REAL NOT NULL DEFAULT 0") database.execSQL("ALTER TABLE history ADD COLUMN scroll REAL NOT NULL DEFAULT 0")
} }
} }

View File

@@ -5,7 +5,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase
class Migration3To4 : Migration(3, 4) { class Migration3To4 : Migration(3, 4) {
override fun migrate(db: SupportSQLiteDatabase) { override fun migrate(database: SupportSQLiteDatabase) {
db.execSQL("CREATE TABLE IF NOT EXISTS tracks (manga_id INTEGER NOT NULL, chapters_total INTEGER NOT NULL, last_chapter_id INTEGER NOT NULL, chapters_new INTEGER NOT NULL, last_check INTEGER NOT NULL, last_notified_id INTEGER NOT NULL, PRIMARY KEY(manga_id), FOREIGN KEY(manga_id) REFERENCES manga(manga_id) ON UPDATE NO ACTION ON DELETE CASCADE )") database.execSQL("CREATE TABLE IF NOT EXISTS tracks (manga_id INTEGER NOT NULL, chapters_total INTEGER NOT NULL, last_chapter_id INTEGER NOT NULL, chapters_new INTEGER NOT NULL, last_check INTEGER NOT NULL, last_notified_id INTEGER NOT NULL, PRIMARY KEY(manga_id), FOREIGN KEY(manga_id) REFERENCES manga(manga_id) ON UPDATE NO ACTION ON DELETE CASCADE )")
} }
} }

View File

@@ -5,7 +5,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase
class Migration4To5 : Migration(4, 5) { class Migration4To5 : Migration(4, 5) {
override fun migrate(db: SupportSQLiteDatabase) { override fun migrate(database: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE favourite_categories ADD COLUMN sort_key INTEGER NOT NULL DEFAULT 0") database.execSQL("ALTER TABLE favourite_categories ADD COLUMN sort_key INTEGER NOT NULL DEFAULT 0")
} }
} }

View File

@@ -5,8 +5,8 @@ import androidx.sqlite.db.SupportSQLiteDatabase
class Migration5To6 : Migration(5, 6) { class Migration5To6 : Migration(5, 6) {
override fun migrate(db: SupportSQLiteDatabase) { override fun migrate(database: SupportSQLiteDatabase) {
db.execSQL("CREATE TABLE IF NOT EXISTS track_logs (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, manga_id INTEGER NOT NULL, chapters TEXT NOT NULL, created_at INTEGER NOT NULL, FOREIGN KEY(manga_id) REFERENCES manga(manga_id) ON UPDATE NO ACTION ON DELETE CASCADE)") database.execSQL("CREATE TABLE IF NOT EXISTS track_logs (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, manga_id INTEGER NOT NULL, chapters TEXT NOT NULL, created_at INTEGER NOT NULL, FOREIGN KEY(manga_id) REFERENCES manga(manga_id) ON UPDATE NO ACTION ON DELETE CASCADE)")
db.execSQL("CREATE INDEX IF NOT EXISTS index_track_logs_manga_id ON track_logs (manga_id)") database.execSQL("CREATE INDEX IF NOT EXISTS index_track_logs_manga_id ON track_logs (manga_id)")
} }
} }

View File

@@ -5,7 +5,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase
class Migration6To7 : Migration(6, 7) { class Migration6To7 : Migration(6, 7) {
override fun migrate(db: SupportSQLiteDatabase) { override fun migrate(database: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE manga ADD COLUMN public_url TEXT NOT NULL DEFAULT ''") database.execSQL("ALTER TABLE manga ADD COLUMN public_url TEXT NOT NULL DEFAULT ''")
} }
} }

View File

@@ -5,9 +5,9 @@ import androidx.sqlite.db.SupportSQLiteDatabase
class Migration7To8 : Migration(7, 8) { class Migration7To8 : Migration(7, 8) {
override fun migrate(db: SupportSQLiteDatabase) { override fun migrate(database: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE manga ADD COLUMN nsfw INTEGER NOT NULL DEFAULT 0") database.execSQL("ALTER TABLE manga ADD COLUMN nsfw INTEGER NOT NULL DEFAULT 0")
db.execSQL("CREATE TABLE IF NOT EXISTS suggestions (manga_id INTEGER NOT NULL, relevance REAL NOT NULL, created_at INTEGER NOT NULL, PRIMARY KEY(manga_id), FOREIGN KEY(manga_id) REFERENCES manga(manga_id) ON UPDATE NO ACTION ON DELETE CASCADE )") database.execSQL("CREATE TABLE IF NOT EXISTS suggestions (manga_id INTEGER NOT NULL, relevance REAL NOT NULL, created_at INTEGER NOT NULL, PRIMARY KEY(manga_id), FOREIGN KEY(manga_id) REFERENCES manga(manga_id) ON UPDATE NO ACTION ON DELETE CASCADE )")
db.execSQL("CREATE INDEX IF NOT EXISTS index_suggestions_manga_id ON suggestions (manga_id)") database.execSQL("CREATE INDEX IF NOT EXISTS index_suggestions_manga_id ON suggestions (manga_id)")
} }
} }

View File

@@ -6,7 +6,7 @@ import org.koitharu.kotatsu.parsers.model.SortOrder
class Migration8To9 : Migration(8, 9) { class Migration8To9 : Migration(8, 9) {
override fun migrate(db: SupportSQLiteDatabase) { override fun migrate(database: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE favourite_categories ADD COLUMN `order` TEXT NOT NULL DEFAULT ${SortOrder.NEWEST.name}") database.execSQL("ALTER TABLE favourite_categories ADD COLUMN `order` TEXT NOT NULL DEFAULT ${SortOrder.NEWEST.name}")
} }
} }

View File

@@ -5,7 +5,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase
class Migration9To10 : Migration(9, 10) { class Migration9To10 : Migration(9, 10) {
override fun migrate(db: SupportSQLiteDatabase) { override fun migrate(database: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE favourite_categories ADD COLUMN `track` INTEGER NOT NULL DEFAULT 1") database.execSQL("ALTER TABLE favourite_categories ADD COLUMN `track` INTEGER NOT NULL DEFAULT 1")
} }
} }

View File

@@ -0,0 +1,8 @@
package org.koitharu.kotatsu.core.exceptions
import org.koitharu.kotatsu.parsers.util.mapNotNullToSet
class CompositeException(val errors: Collection<Throwable>) : Exception() {
override val message: String = errors.mapNotNullToSet { it.message }.joinToString()
}

View File

@@ -1,7 +0,0 @@
package org.koitharu.kotatsu.core.exceptions
import okio.IOException
class NoDataReceivedException(
private val url: String,
) : IOException("No data has been received from $url")

View File

@@ -1,13 +1,13 @@
package org.koitharu.kotatsu.core.exceptions package org.koitharu.kotatsu.core.exceptions
import okio.IOException import okio.IOException
import java.time.Instant import java.util.Date
import java.time.temporal.ChronoUnit
class TooManyRequestExceptions( class TooManyRequestExceptions(
val url: String, val url: String,
val retryAt: Instant?, val retryAt: Date?,
) : IOException() { ) : IOException() {
val retryAfter: Long val retryAfter: Long
get() = retryAt?.until(Instant.now(), ChronoUnit.MILLIS) ?: 0 get() = if (retryAt == null) 0 else (retryAt.time - System.currentTimeMillis()).coerceAtLeast(0)
} }

View File

@@ -1,8 +0,0 @@
package org.koitharu.kotatsu.core.exceptions
import org.koitharu.kotatsu.parsers.model.Manga
class UnsupportedSourceException(
message: String?,
val manga: Manga?,
) : IllegalArgumentException(message)

View File

@@ -21,7 +21,7 @@ abstract class ErrorObserver(
private val onResolved: Consumer<Boolean>?, private val onResolved: Consumer<Boolean>?,
) : FlowCollector<Throwable> { ) : FlowCollector<Throwable> {
protected open val activity = host.context.findActivity() protected val activity = host.context.findActivity()
private val lifecycleScope: LifecycleCoroutineScope private val lifecycleScope: LifecycleCoroutineScope
get() = checkNotNull(fragment?.viewLifecycleScope ?: (activity as? LifecycleOwner)?.lifecycle?.coroutineScope) get() = checkNotNull(fragment?.viewLifecycleScope ?: (activity as? LifecycleOwner)?.lifecycle?.coroutineScope)
@@ -36,7 +36,7 @@ abstract class ErrorObserver(
private fun isAlive(): Boolean { private fun isAlive(): Boolean {
return when { return when {
fragment != null -> fragment.view != null fragment != null -> fragment.view != null
activity != null -> activity?.isDestroyed == false activity != null -> !activity.isDestroyed
else -> true else -> true
} }
} }

View File

@@ -8,16 +8,13 @@ import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity import androidx.fragment.app.FragmentActivity
import okhttp3.Headers import okhttp3.Headers
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.alternatives.ui.AlternativesActivity
import org.koitharu.kotatsu.browser.BrowserActivity import org.koitharu.kotatsu.browser.BrowserActivity
import org.koitharu.kotatsu.browser.cloudflare.CloudFlareActivity import org.koitharu.kotatsu.browser.cloudflare.CloudFlareActivity
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException
import org.koitharu.kotatsu.core.ui.dialog.ErrorDetailsDialog import org.koitharu.kotatsu.core.ui.dialog.ErrorDetailsDialog
import org.koitharu.kotatsu.core.util.TaggedActivityResult import org.koitharu.kotatsu.core.util.TaggedActivityResult
import org.koitharu.kotatsu.parsers.exception.AuthRequiredException import org.koitharu.kotatsu.parsers.exception.AuthRequiredException
import org.koitharu.kotatsu.parsers.exception.NotFoundException import org.koitharu.kotatsu.parsers.exception.NotFoundException
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.settings.sources.auth.SourceAuthActivity import org.koitharu.kotatsu.settings.sources.auth.SourceAuthActivity
import kotlin.coroutines.Continuation import kotlin.coroutines.Continuation
@@ -62,11 +59,6 @@ class ExceptionResolver : ActivityResultCallback<TaggedActivityResult> {
false false
} }
is UnsupportedSourceException -> {
e.manga?.let { openAlternatives(it) }
false
}
else -> false else -> false
} }
@@ -82,12 +74,7 @@ class ExceptionResolver : ActivityResultCallback<TaggedActivityResult> {
private fun openInBrowser(url: String) { private fun openInBrowser(url: String) {
val context = activity ?: fragment?.activity ?: return val context = activity ?: fragment?.activity ?: return
context.startActivity(BrowserActivity.newIntent(context, url, null, null)) context.startActivity(BrowserActivity.newIntent(context, url, null))
}
private fun openAlternatives(manga: Manga) {
val context = activity ?: fragment?.activity ?: return
context.startActivity(AlternativesActivity.newIntent(context, manga))
} }
private fun getFragmentManager() = checkNotNull(fragment?.childFragmentManager ?: activity?.supportFragmentManager) private fun getFragmentManager() = checkNotNull(fragment?.childFragmentManager ?: activity?.supportFragmentManager)
@@ -99,7 +86,6 @@ class ExceptionResolver : ActivityResultCallback<TaggedActivityResult> {
is CloudFlareProtectedException -> R.string.captcha_solve is CloudFlareProtectedException -> R.string.captcha_solve
is AuthRequiredException -> R.string.sign_in is AuthRequiredException -> R.string.sign_in
is NotFoundException -> if (e.url.isNotEmpty()) R.string.open_in_browser else 0 is NotFoundException -> if (e.url.isNotEmpty()) R.string.open_in_browser else 0
is UnsupportedSourceException -> if (e.manga != null) R.string.alternatives else 0
else -> 0 else -> 0
} }

View File

@@ -20,9 +20,8 @@ import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import java.io.File import java.io.File
import java.io.FileOutputStream import java.io.FileOutputStream
import java.time.LocalDateTime import java.text.SimpleDateFormat
import java.time.format.DateTimeFormatter import java.util.Date
import java.time.format.FormatStyle
import java.util.Locale import java.util.Locale
import java.util.concurrent.ConcurrentLinkedQueue import java.util.concurrent.ConcurrentLinkedQueue
@@ -42,7 +41,11 @@ class FileLogger(
} }
val isEnabled: Boolean val isEnabled: Boolean
get() = settings.isLoggingEnabled get() = settings.isLoggingEnabled
private val dateTimeFormatter = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT).withLocale(Locale.ROOT) private val dateFormat = SimpleDateFormat.getDateTimeInstance(
SimpleDateFormat.SHORT,
SimpleDateFormat.SHORT,
Locale.ROOT,
)
private val buffer = ConcurrentLinkedQueue<String>() private val buffer = ConcurrentLinkedQueue<String>()
private val mutex = Mutex() private val mutex = Mutex()
private var flushJob: Job? = null private var flushJob: Job? = null
@@ -52,7 +55,7 @@ class FileLogger(
return return
} }
val text = buildString { val text = buildString {
append(dateTimeFormatter.format(LocalDateTime.now())) append(dateFormat.format(Date()))
append(": ") append(": ")
if (e != null) { if (e != null) {
append("E!") append("E!")

View File

@@ -2,18 +2,18 @@ package org.koitharu.kotatsu.core.model
import android.os.Parcelable import android.os.Parcelable
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import org.koitharu.kotatsu.list.domain.ListSortOrder
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import java.time.Instant import org.koitharu.kotatsu.parsers.model.SortOrder
import java.util.Date
@Parcelize @Parcelize
data class FavouriteCategory( data class FavouriteCategory(
val id: Long, val id: Long,
val title: String, val title: String,
val sortKey: Int, val sortKey: Int,
val order: ListSortOrder, val order: SortOrder,
val createdAt: Instant, val createdAt: Date,
val isTrackingEnabled: Boolean, val isTrackingEnabled: Boolean,
val isVisibleInLibrary: Boolean, val isVisibleInLibrary: Boolean,
) : Parcelable, ListModel { ) : Parcelable, ListModel {

View File

@@ -1,22 +1,13 @@
package org.koitharu.kotatsu.core.model package org.koitharu.kotatsu.core.model
import android.net.Uri import android.net.Uri
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.collection.MutableObjectIntMap
import androidx.core.os.LocaleListCompat import androidx.core.os.LocaleListCompat
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ext.iterator import org.koitharu.kotatsu.core.util.ext.iterator
import org.koitharu.kotatsu.details.ui.model.ChapterListItem import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.parsers.model.ContentRating
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.util.mapToSet import org.koitharu.kotatsu.parsers.util.mapToSet
import java.text.DecimalFormat
import java.text.DecimalFormatSymbols
import com.google.android.material.R as materialR
@JvmName("mangaIds") @JvmName("mangaIds")
fun Collection<Manga>.ids() = mapToSet { it.id } fun Collection<Manga>.ids() = mapToSet { it.id }
@@ -32,44 +23,14 @@ fun Collection<ChapterListItem>.countChaptersByBranch(): Int {
if (size <= 1) { if (size <= 1) {
return size return size
} }
val acc = MutableObjectIntMap<String?>() val acc = HashMap<String?, Int>()
for (item in this) { for (item in this) {
val branch = item.chapter.branch val branch = item.chapter.branch
acc[branch] = acc.getOrDefault(branch, 0) + 1 acc[branch] = (acc[branch] ?: 0) + 1
} }
var max = 0 return acc.values.max()
acc.forEachValue { x -> if (x > max) max = x }
return max
} }
@get:StringRes
val MangaState.titleResId: Int
get() = when (this) {
MangaState.ONGOING -> R.string.state_ongoing
MangaState.FINISHED -> R.string.state_finished
MangaState.ABANDONED -> R.string.state_abandoned
MangaState.PAUSED -> R.string.state_paused
MangaState.UPCOMING -> R.string.state_upcoming
}
@get:DrawableRes
val MangaState.iconResId: Int
get() = when (this) {
MangaState.ONGOING -> R.drawable.ic_play
MangaState.FINISHED -> R.drawable.ic_state_finished
MangaState.ABANDONED -> R.drawable.ic_state_abandoned
MangaState.PAUSED -> R.drawable.ic_action_pause
MangaState.UPCOMING -> materialR.drawable.ic_clock_black_24dp
}
@get:StringRes
val ContentRating.titleResId: Int
get() = when (this) {
ContentRating.SAFE -> R.string.rating_safe
ContentRating.SUGGESTIVE -> R.string.rating_suggestive
ContentRating.ADULT -> R.string.rating_adult
}
fun Manga.findChapter(id: Long): MangaChapter? { fun Manga.findChapter(id: Long): MangaChapter? {
return chapters?.findById(id) return chapters?.findById(id)
} }
@@ -118,32 +79,3 @@ val Manga.appUrl: Uri
.appendQueryParameter("name", title) .appendQueryParameter("name", title)
.appendQueryParameter("url", url) .appendQueryParameter("url", url)
.build() .build()
private val chaptersNumberFormat = DecimalFormat("#.#").also { f ->
f.decimalFormatSymbols = DecimalFormatSymbols.getInstance().also {
it.decimalSeparator = '.'
}
}
fun MangaChapter.formatNumber(): String? {
if (number <= 0f) {
return null
}
return chaptersNumberFormat.format(number.toDouble())
}
fun Manga.chaptersCount(): Int {
if (chapters.isNullOrEmpty()) {
return 0
}
val counters = MutableObjectIntMap<String?>()
var max = 0
chapters?.forEach { x ->
val c = counters.getOrDefault(x.branch, 0) + 1
counters[x.branch] = c
if (max < c) {
max = c
}
}
return max
}

View File

@@ -2,12 +2,12 @@ package org.koitharu.kotatsu.core.model
import android.os.Parcelable import android.os.Parcelable
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import java.time.Instant import java.util.*
@Parcelize @Parcelize
data class MangaHistory( data class MangaHistory(
val createdAt: Instant, val createdAt: Date,
val updatedAt: Instant, val updatedAt: Date,
val chapterId: Long, val chapterId: Long,
val page: Int, val page: Int,
val scroll: Int, val scroll: Int,

View File

@@ -1,23 +1,14 @@
package org.koitharu.kotatsu.core.model package org.koitharu.kotatsu.core.model
import android.content.Context
import android.graphics.Color
import android.text.SpannableStringBuilder
import android.text.style.ForegroundColorSpan
import android.text.style.RelativeSizeSpan
import android.text.style.SuperscriptSpan
import androidx.annotation.StringRes
import androidx.core.text.buildSpannedString
import androidx.core.text.inSpans
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ext.getDisplayName
import org.koitharu.kotatsu.core.util.ext.getThemeColor
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.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.toTitleCase import org.koitharu.kotatsu.parsers.util.toTitleCase
import java.util.Locale import java.util.Locale
import com.google.android.material.R as materialR
fun MangaSource.getLocaleTitle(): String? {
val lc = Locale(locale ?: return null)
return lc.getDisplayLanguage(lc).toTitleCase(lc)
}
fun MangaSource(name: String): MangaSource { fun MangaSource(name: String): MangaSource {
MangaSource.entries.forEach { MangaSource.entries.forEach {
@@ -27,36 +18,3 @@ fun MangaSource(name: String): MangaSource {
} }
fun MangaSource.isNsfw() = contentType == ContentType.HENTAI fun MangaSource.isNsfw() = contentType == ContentType.HENTAI
@get:StringRes
val ContentType.titleResId
get() = when (this) {
ContentType.MANGA -> R.string.content_type_manga
ContentType.HENTAI -> R.string.content_type_hentai
ContentType.COMICS -> R.string.content_type_comics
ContentType.OTHER -> R.string.content_type_other
}
fun MangaSource.getSummary(context: Context): String {
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 {
title
}
private fun SpannableStringBuilder.appendNsfwLabel(context: Context) = inSpans(
ForegroundColorSpan(context.getThemeColor(materialR.attr.colorError, Color.RED)),
RelativeSizeSpan(0.74f),
SuperscriptSpan(),
) {
append(context.getString(R.string.nsfw))
}

View File

@@ -19,8 +19,7 @@ data class ParcelableChapter(
MangaChapter( MangaChapter(
id = parcel.readLong(), id = parcel.readLong(),
name = parcel.readString().orEmpty(), name = parcel.readString().orEmpty(),
number = parcel.readFloat(), number = parcel.readInt(),
volume = parcel.readInt(),
url = parcel.readString().orEmpty(), url = parcel.readString().orEmpty(),
scanlator = parcel.readString(), scanlator = parcel.readString(),
uploadDate = parcel.readLong(), uploadDate = parcel.readLong(),
@@ -32,8 +31,7 @@ data class ParcelableChapter(
override fun ParcelableChapter.write(parcel: Parcel, flags: Int) = with(chapter) { override fun ParcelableChapter.write(parcel: Parcel, flags: Int) = with(chapter) {
parcel.writeLong(id) parcel.writeLong(id)
parcel.writeString(name) parcel.writeString(name)
parcel.writeFloat(number) parcel.writeInt(number)
parcel.writeInt(volume)
parcel.writeString(url) parcel.writeString(url)
parcel.writeString(scanlator) parcel.writeString(scanlator)
parcel.writeLong(uploadDate) parcel.writeLong(uploadDate)

View File

@@ -14,8 +14,8 @@ class CloudFlareInterceptor : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response { override fun intercept(chain: Interceptor.Chain): Response {
val response = chain.proceed(chain.request()) val response = chain.proceed(chain.request())
if (response.code == HTTP_FORBIDDEN || response.code == HTTP_UNAVAILABLE) { if (response.code == HTTP_FORBIDDEN || response.code == HTTP_UNAVAILABLE) {
val content = response.body?.let { response.peekBody(Long.MAX_VALUE) }?.byteStream()?.use { val content = response.body?.source()?.peek()?.use {
Jsoup.parse(it, Charsets.UTF_8.name(), response.request.url.toString()) Jsoup.parse(it.inputStream(), Charsets.UTF_8.name(), response.request.url.toString())
} ?: return response } ?: return response
if (content.getElementById("challenge-error-title") != null) { if (content.getElementById("challenge-error-title") != null) {
val request = response.request val request = response.request

View File

@@ -7,11 +7,11 @@ import okhttp3.Interceptor
import okhttp3.Request import okhttp3.Request
import okhttp3.Response import okhttp3.Response
import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.parser.MangaLoaderContextImpl
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.network.UserAgents
import org.koitharu.kotatsu.parsers.util.mergeWith import org.koitharu.kotatsu.parsers.util.mergeWith
import java.net.IDN import java.net.IDN
import javax.inject.Inject import javax.inject.Inject
@@ -20,7 +20,6 @@ import javax.inject.Singleton
@Singleton @Singleton
class CommonHeadersInterceptor @Inject constructor( class CommonHeadersInterceptor @Inject constructor(
private val mangaRepositoryFactoryLazy: Lazy<MangaRepository.Factory>, private val mangaRepositoryFactoryLazy: Lazy<MangaRepository.Factory>,
private val mangaLoaderContextLazy: Lazy<MangaLoaderContextImpl>,
) : Interceptor { ) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response { override fun intercept(chain: Interceptor.Chain): Response {
@@ -39,7 +38,7 @@ class CommonHeadersInterceptor @Inject constructor(
headersBuilder.mergeWith(it, replaceExisting = false) headersBuilder.mergeWith(it, replaceExisting = false)
} }
if (headersBuilder[CommonHeaders.USER_AGENT] == null) { if (headersBuilder[CommonHeaders.USER_AGENT] == null) {
headersBuilder[CommonHeaders.USER_AGENT] = mangaLoaderContextLazy.get().getDefaultUserAgent() headersBuilder[CommonHeaders.USER_AGENT] = UserAgents.CHROME_MOBILE
} }
if (headersBuilder[CommonHeaders.REFERER] == null && repository != null) { if (headersBuilder[CommonHeaders.REFERER] == null && repository != null) {
val idn = IDN.toASCII(repository.domain) val idn = IDN.toASCII(repository.domain)

View File

@@ -2,7 +2,6 @@ package org.koitharu.kotatsu.core.network
import okhttp3.Interceptor import okhttp3.Interceptor
import okhttp3.Response import okhttp3.Response
import okio.IOException
import org.koitharu.kotatsu.core.network.CommonHeaders.CONTENT_ENCODING import org.koitharu.kotatsu.core.network.CommonHeaders.CONTENT_ENCODING
class GZipInterceptor : Interceptor { class GZipInterceptor : Interceptor {
@@ -10,10 +9,6 @@ class GZipInterceptor : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response { override fun intercept(chain: Interceptor.Chain): Response {
val newRequest = chain.request().newBuilder() val newRequest = chain.request().newBuilder()
newRequest.addHeader(CONTENT_ENCODING, "gzip") newRequest.addHeader(CONTENT_ENCODING, "gzip")
return try { return chain.proceed(newRequest.build())
chain.proceed(newRequest.build())
} catch (e: NullPointerException) {
throw IOException(e)
}
} }
} }

View File

@@ -7,7 +7,6 @@ import coil.request.ErrorResult
import coil.request.ImageResult import coil.request.ImageResult
import coil.request.SuccessResult import coil.request.SuccessResult
import coil.size.Dimension import coil.size.Dimension
import coil.size.isOriginal
import okhttp3.HttpUrl import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
@@ -47,13 +46,11 @@ class ImageProxyInterceptor @Inject constructor(
.scheme("https") .scheme("https")
.host("wsrv.nl") .host("wsrv.nl")
.addQueryParameter("url", url.toString()) .addQueryParameter("url", url.toString())
.addQueryParameter("fit", "outside")
.addQueryParameter("we", null) .addQueryParameter("we", null)
val size = request.sizeResolver.size() val size = request.sizeResolver.size()
if (!size.isOriginal) { (size.height as? Dimension.Pixels)?.let { newUrl.addQueryParameter("h", it.toString()) }
newUrl.addQueryParameter("crop", "cover") (size.width as? Dimension.Pixels)?.let { newUrl.addQueryParameter("w", it.toString()) }
(size.height as? Dimension.Pixels)?.let { newUrl.addQueryParameter("h", it.toString()) }
(size.width as? Dimension.Pixels)?.let { newUrl.addQueryParameter("w", it.toString()) }
}
val newRequest = request.newBuilder() val newRequest = request.newBuilder()
.data(newUrl.build()) .data(newUrl.build())

View File

@@ -63,9 +63,6 @@ class MirrorSwitchInterceptor @Inject constructor(
} }
synchronized(obtainLock(repository.source)) { synchronized(obtainLock(repository.source)) {
val currentMirror = repository.domain val currentMirror = repository.domain
if (currentMirror !in mirrors) {
return@synchronized false
}
addToBlacklist(repository.source, currentMirror) addToBlacklist(repository.source, currentMirror)
val newMirror = mirrors.firstOrNull { x -> val newMirror = mirrors.firstOrNull { x ->
x != currentMirror && !isBlacklisted(repository.source, x) x != currentMirror && !isBlacklisted(repository.source, x)

View File

@@ -4,11 +4,15 @@ import okhttp3.Interceptor
import okhttp3.Response import okhttp3.Response
import okhttp3.internal.closeQuietly import okhttp3.internal.closeQuietly
import org.koitharu.kotatsu.core.exceptions.TooManyRequestExceptions import org.koitharu.kotatsu.core.exceptions.TooManyRequestExceptions
import java.time.Instant import java.text.SimpleDateFormat
import java.time.ZonedDateTime import java.util.Date
import java.time.format.DateTimeFormatter import java.util.Locale
import java.util.concurrent.TimeUnit
class RateLimitInterceptor : Interceptor { class RateLimitInterceptor : Interceptor {
private val dateFormat = SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss ZZZ", Locale.ENGLISH)
override fun intercept(chain: Interceptor.Chain): Response { override fun intercept(chain: Interceptor.Chain): Response {
val response = chain.proceed(chain.request()) val response = chain.proceed(chain.request())
if (response.code == 429) { if (response.code == 429) {
@@ -23,8 +27,10 @@ class RateLimitInterceptor : Interceptor {
return response return response
} }
private fun String.parseRetryDate(): Instant? { private fun String.parseRetryDate(): Date? {
return toLongOrNull()?.let { Instant.now().plusSeconds(it) } toIntOrNull()?.let {
?: ZonedDateTime.parse(this, DateTimeFormatter.RFC_1123_DATE_TIME).toInstant() return Date(System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(it.toLong()))
}
return dateFormat.parse(this)
} }
} }

View File

@@ -1,9 +1,16 @@
package org.koitharu.kotatsu.core.os package org.koitharu.kotatsu.core.os
import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.content.pm.PackageManager import android.content.pm.PackageManager
import androidx.core.content.pm.PackageInfoCompat
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.parsers.util.byte2HexFormatted
import java.io.ByteArrayInputStream
import java.io.InputStream
import java.security.MessageDigest
import java.security.cert.CertificateFactory
import java.security.cert.X509Certificate
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -11,13 +18,29 @@ import javax.inject.Singleton
class AppValidator @Inject constructor( class AppValidator @Inject constructor(
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
) { ) {
@Suppress("NewApi")
val isOriginalApp by lazy { val isOriginalApp by lazy {
val certificates = mapOf(CERT_SHA256.hexToByteArray() to PackageManager.CERT_INPUT_SHA256) getCertificateSHA1Fingerprint() == CERT_SHA1
PackageInfoCompat.hasSignatures(context.packageManager, context.packageName, certificates, false)
} }
@Suppress("DEPRECATION")
@SuppressLint("PackageManagerGetSignatures")
private fun getCertificateSHA1Fingerprint(): String? = runCatching {
val packageInfo = context.packageManager.getPackageInfo(context.packageName, PackageManager.GET_SIGNATURES)
val signatures = requireNotNull(packageInfo?.signatures)
val cert: ByteArray = signatures.first().toByteArray()
val input: InputStream = ByteArrayInputStream(cert)
val cf = CertificateFactory.getInstance("X509")
val c = cf.generateCertificate(input) as X509Certificate
val md: MessageDigest = MessageDigest.getInstance("SHA1")
val publicKey: ByteArray = md.digest(c.encoded)
return publicKey.byte2HexFormatted()
}.onFailure { error ->
error.printStackTraceDebug()
}.getOrNull()
private companion object { private companion object {
private const val CERT_SHA256 = "67e15100bb809301783edcb6348fa3bbf83034d91e62868a91053dbd70db3f18"
private const val CERT_SHA1 = "2C:19:C7:E8:07:61:2B:8E:94:51:1B:FD:72:67:07:64:5D:C2:58:AE"
} }
} }

View File

@@ -1,6 +1,5 @@
package org.koitharu.kotatsu.core.parser package org.koitharu.kotatsu.core.parser
import androidx.core.net.toUri
import androidx.room.withTransaction import androidx.room.withTransaction
import dagger.Reusable import dagger.Reusable
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@@ -12,9 +11,7 @@ import org.koitharu.kotatsu.core.db.entity.toEntities
import org.koitharu.kotatsu.core.db.entity.toEntity import org.koitharu.kotatsu.core.db.entity.toEntity
import org.koitharu.kotatsu.core.db.entity.toManga import org.koitharu.kotatsu.core.db.entity.toManga
import org.koitharu.kotatsu.core.db.entity.toMangaTags import org.koitharu.kotatsu.core.db.entity.toMangaTags
import org.koitharu.kotatsu.core.model.isLocal
import org.koitharu.kotatsu.core.prefs.ReaderMode import org.koitharu.kotatsu.core.prefs.ReaderMode
import org.koitharu.kotatsu.core.util.ext.toFileOrNull
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.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
@@ -31,16 +28,16 @@ class MangaDataRepository @Inject constructor(
suspend fun saveReaderMode(manga: Manga, mode: ReaderMode) { suspend fun saveReaderMode(manga: Manga, mode: ReaderMode) {
db.withTransaction { db.withTransaction {
storeManga(manga) storeManga(manga)
val entity = db.getPreferencesDao().find(manga.id) ?: newEntity(manga.id) val entity = db.preferencesDao.find(manga.id) ?: newEntity(manga.id)
db.getPreferencesDao().upsert(entity.copy(mode = mode.id)) db.preferencesDao.upsert(entity.copy(mode = mode.id))
} }
} }
suspend fun saveColorFilter(manga: Manga, colorFilter: ReaderColorFilter?) { suspend fun saveColorFilter(manga: Manga, colorFilter: ReaderColorFilter?) {
db.withTransaction { db.withTransaction {
storeManga(manga) storeManga(manga)
val entity = db.getPreferencesDao().find(manga.id) ?: newEntity(manga.id) val entity = db.preferencesDao.find(manga.id) ?: newEntity(manga.id)
db.getPreferencesDao().upsert( db.preferencesDao.upsert(
entity.copy( entity.copy(
cfBrightness = colorFilter?.brightness ?: 0f, cfBrightness = colorFilter?.brightness ?: 0f,
cfContrast = colorFilter?.contrast ?: 0f, cfContrast = colorFilter?.contrast ?: 0f,
@@ -51,25 +48,25 @@ class MangaDataRepository @Inject constructor(
} }
suspend fun getReaderMode(mangaId: Long): ReaderMode? { suspend fun getReaderMode(mangaId: Long): ReaderMode? {
return db.getPreferencesDao().find(mangaId)?.let { ReaderMode.valueOf(it.mode) } return db.preferencesDao.find(mangaId)?.let { ReaderMode.valueOf(it.mode) }
} }
suspend fun getColorFilter(mangaId: Long): ReaderColorFilter? { suspend fun getColorFilter(mangaId: Long): ReaderColorFilter? {
return db.getPreferencesDao().find(mangaId)?.getColorFilterOrNull() return db.preferencesDao.find(mangaId)?.getColorFilterOrNull()
} }
fun observeColorFilter(mangaId: Long): Flow<ReaderColorFilter?> { fun observeColorFilter(mangaId: Long): Flow<ReaderColorFilter?> {
return db.getPreferencesDao().observe(mangaId) return db.preferencesDao.observe(mangaId)
.map { it?.getColorFilterOrNull() } .map { it?.getColorFilterOrNull() }
.distinctUntilChanged() .distinctUntilChanged()
} }
suspend fun findMangaById(mangaId: Long): Manga? { suspend fun findMangaById(mangaId: Long): Manga? {
return db.getMangaDao().find(mangaId)?.toManga() return db.mangaDao.find(mangaId)?.toManga()
} }
suspend fun findMangaByPublicUrl(publicUrl: String): Manga? { suspend fun findMangaByPublicUrl(publicUrl: String): Manga? {
return db.getMangaDao().findByPublicUrl(publicUrl)?.toManga() return db.mangaDao.findByPublicUrl(publicUrl)?.toManga()
} }
suspend fun resolveIntent(intent: MangaIntent): Manga? = when { suspend fun resolveIntent(intent: MangaIntent): Manga? = when {
@@ -80,37 +77,20 @@ class MangaDataRepository @Inject constructor(
} }
suspend fun storeManga(manga: Manga) { suspend fun storeManga(manga: Manga) {
val tags = manga.tags.toEntities()
db.withTransaction { db.withTransaction {
// avoid storing local manga if remote one is already stored db.tagsDao.upsert(tags)
val existing = if (manga.isLocal) { db.mangaDao.upsert(manga.toEntity(), tags)
db.getMangaDao().find(manga.id)?.manga
} else {
null
}
if (existing == null || existing.source == manga.source.name) {
val tags = manga.tags.toEntities()
db.getTagsDao().upsert(tags)
db.getMangaDao().upsert(manga.toEntity(), tags)
}
} }
} }
suspend fun findTags(source: MangaSource): Set<MangaTag> { suspend fun findTags(source: MangaSource): Set<MangaTag> {
return db.getTagsDao().findTags(source.name).toMangaTags() return db.tagsDao.findTags(source.name).toMangaTags()
}
suspend fun cleanupLocalManga() {
val dao = db.getMangaDao()
val broken = dao.findAllBySource(MangaSource.LOCAL.name)
.filter { x -> x.manga.url.toUri().toFileOrNull()?.exists() == false }
if (broken.isNotEmpty()) {
dao.delete(broken.map { it.manga })
}
} }
private fun MangaPrefsEntity.getColorFilterOrNull(): ReaderColorFilter? { private fun MangaPrefsEntity.getColorFilterOrNull(): ReaderColorFilter? {
return if (cfBrightness != 0f || cfContrast != 0f || cfInvert || cfGrayscale) { return if (cfBrightness != 0f || cfContrast != 0f || cfInvert) {
ReaderColorFilter(cfBrightness, cfContrast, cfInvert, cfGrayscale) ReaderColorFilter(cfBrightness, cfContrast, cfInvert)
} else { } else {
null null
} }
@@ -122,6 +102,5 @@ class MangaDataRepository @Inject constructor(
cfBrightness = 0f, cfBrightness = 0f,
cfContrast = 0f, cfContrast = 0f,
cfInvert = false, cfInvert = false,
cfGrayscale = false,
) )
} }

View File

@@ -43,7 +43,5 @@ class MangaIntent private constructor(
const val KEY_MANGA = "manga" const val KEY_MANGA = "manga"
const val KEY_ID = "id" const val KEY_ID = "id"
fun of(manga: Manga) = MangaIntent(manga, manga.id, null)
} }
} }

View File

@@ -1,7 +1,6 @@
package org.koitharu.kotatsu.core.parser package org.koitharu.kotatsu.core.parser
import android.net.Uri import android.net.Uri
import coil.request.CachePolicy
import dagger.Reusable import dagger.Reusable
import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty
@@ -9,7 +8,6 @@ import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
import org.koitharu.kotatsu.parsers.exception.NotFoundException import org.koitharu.kotatsu.parsers.exception.NotFoundException
import org.koitharu.kotatsu.parsers.model.ContentType import org.koitharu.kotatsu.parsers.model.ContentType
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.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.almostEquals import org.koitharu.kotatsu.parsers.util.almostEquals
import org.koitharu.kotatsu.parsers.util.levenshteinDistance import org.koitharu.kotatsu.parsers.util.levenshteinDistance
@@ -25,14 +23,14 @@ class MangaLinkResolver @Inject constructor(
) { ) {
suspend fun resolve(uri: Uri): Manga { suspend fun resolve(uri: Uri): Manga {
return if (uri.scheme == "kotatsu" || uri.host == "kotatsu.app") { return if (uri.host == "kotatsu.app") {
resolveAppLink(uri) resolveAppLink(uri)
} else { } else {
resolveExternalLink(uri) resolveExternalLink(uri)
} ?: throw NotFoundException("Cannot resolve link", uri.toString()) } ?: throw NotFoundException("Manga not found", uri.toString())
} }
private suspend fun resolveAppLink(uri: Uri): Manga? { suspend fun resolveAppLink(uri: Uri): Manga? {
require(uri.pathSegments.singleOrNull() == "manga") { "Invalid url" } require(uri.pathSegments.singleOrNull() == "manga") { "Invalid url" }
val sourceName = requireNotNull(uri.getQueryParameter("source")) { "Source is not specified" } val sourceName = requireNotNull(uri.getQueryParameter("source")) { "Source is not specified" }
val source = MangaSource(sourceName) val source = MangaSource(sourceName)
@@ -44,7 +42,7 @@ class MangaLinkResolver @Inject constructor(
) )
} }
private suspend fun resolveExternalLink(uri: Uri): Manga? { suspend fun resolveExternalLink(uri: Uri): Manga? {
dataRepository.findMangaByPublicUrl(uri.toString())?.let { dataRepository.findMangaByPublicUrl(uri.toString())?.let {
return it return it
} }
@@ -60,7 +58,7 @@ class MangaLinkResolver @Inject constructor(
private suspend fun MangaRepository.findExact(url: String?, title: String?): Manga? { private suspend fun MangaRepository.findExact(url: String?, title: String?): Manga? {
if (!title.isNullOrEmpty()) { if (!title.isNullOrEmpty()) {
val list = getList(0, MangaListFilter.Search(title)) val list = getList(0, title)
if (url != null) { if (url != null) {
list.find { it.url == url }?.let { list.find { it.url == url }?.let {
return it return it
@@ -79,14 +77,14 @@ class MangaLinkResolver @Inject constructor(
}.ifNullOrEmpty { }.ifNullOrEmpty {
seed.author seed.author
} ?: return@runCatchingCancellable null } ?: return@runCatchingCancellable null
val seedList = getList(0, MangaListFilter.Search(seedTitle)) val seedList = getList(0, seedTitle)
seedList.first { x -> x.url == url } seedList.first { x -> x.url == url }
}.getOrThrow() }.getOrThrow()
} }
private suspend fun MangaRepository.getDetailsNoCache(manga: Manga): Manga { private suspend fun MangaRepository.getDetailsNoCache(manga: Manga): Manga {
return if (this is RemoteMangaRepository) { return if (this is RemoteMangaRepository) {
getDetails(manga, CachePolicy.READ_ONLY) getDetails(manga, withCache = false)
} else { } else {
getDetails(manga) getDetails(manga)
} }

View File

@@ -4,25 +4,18 @@ import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.util.Base64 import android.util.Base64
import android.webkit.WebView import android.webkit.WebView
import androidx.annotation.MainThread
import androidx.core.os.LocaleListCompat import androidx.core.os.LocaleListCompat
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import org.koitharu.kotatsu.core.network.MangaHttpClient import org.koitharu.kotatsu.core.network.MangaHttpClient
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
import org.koitharu.kotatsu.core.prefs.SourceSettings import org.koitharu.kotatsu.core.prefs.SourceSettings
import org.koitharu.kotatsu.core.util.ext.configureForParser
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.sanitizeHeaderValue
import org.koitharu.kotatsu.core.util.ext.toList import org.koitharu.kotatsu.core.util.ext.toList
import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.config.MangaSourceConfig import org.koitharu.kotatsu.parsers.config.MangaSourceConfig
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.network.UserAgents
import org.koitharu.kotatsu.parsers.util.SuspendLazy
import java.lang.ref.WeakReference import java.lang.ref.WeakReference
import java.util.Locale import java.util.Locale
import javax.inject.Inject import javax.inject.Inject
@@ -39,15 +32,12 @@ class MangaLoaderContextImpl @Inject constructor(
private var webViewCached: WeakReference<WebView>? = null private var webViewCached: WeakReference<WebView>? = null
private val userAgentLazy = SuspendLazy {
withContext(Dispatchers.Main) {
obtainWebView().settings.userAgentString
}.sanitizeHeaderValue()
}
@SuppressLint("SetJavaScriptEnabled") @SuppressLint("SetJavaScriptEnabled")
override suspend fun evaluateJs(script: String): String? = withContext(Dispatchers.Main) { override suspend fun evaluateJs(script: String): String? = withContext(Dispatchers.Main) {
val webView = obtainWebView() val webView = webViewCached?.get() ?: WebView(androidContext).also {
it.settings.javaScriptEnabled = true
webViewCached = WeakReference(it)
}
suspendCoroutine { cont -> suspendCoroutine { cont ->
webView.evaluateJavascript(script) { result -> webView.evaluateJavascript(script) { result ->
cont.resume(result?.takeUnless { it == "null" }) cont.resume(result?.takeUnless { it == "null" })
@@ -55,14 +45,6 @@ class MangaLoaderContextImpl @Inject constructor(
} }
} }
override fun getDefaultUserAgent(): String = runCatching {
runBlocking {
userAgentLazy.get()
}
}.onFailure { e ->
e.printStackTraceDebug()
}.getOrDefault(UserAgents.FIREFOX_MOBILE)
override fun getConfig(source: MangaSource): MangaSourceConfig { override fun getConfig(source: MangaSource): MangaSourceConfig {
return SourceSettings(androidContext, source) return SourceSettings(androidContext, source)
} }
@@ -78,12 +60,4 @@ class MangaLoaderContextImpl @Inject constructor(
override fun getPreferredLocales(): List<Locale> { override fun getPreferredLocales(): List<Locale> {
return LocaleListCompat.getAdjustedDefault().toList() return LocaleListCompat.getAdjustedDefault().toList()
} }
@MainThread
private fun obtainWebView(): WebView {
return webViewCached?.get() ?: WebView(androidContext).also {
it.configureForParser(null)
webViewCached = WeakReference(it)
}
}
} }

View File

@@ -5,18 +5,14 @@ import org.koitharu.kotatsu.core.cache.ContentCache
import org.koitharu.kotatsu.core.network.MirrorSwitchInterceptor import org.koitharu.kotatsu.core.network.MirrorSwitchInterceptor
import org.koitharu.kotatsu.local.data.LocalMangaRepository import org.koitharu.kotatsu.local.data.LocalMangaRepository
import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.model.ContentRating
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
import java.lang.ref.WeakReference import java.lang.ref.WeakReference
import java.util.EnumMap import java.util.EnumMap
import java.util.Locale
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
import kotlin.collections.set import kotlin.collections.set
@@ -27,19 +23,11 @@ interface MangaRepository {
val sortOrders: Set<SortOrder> val sortOrders: Set<SortOrder>
val states: Set<MangaState>
val contentRatings: Set<ContentRating>
var defaultSortOrder: SortOrder var defaultSortOrder: SortOrder
val isMultipleTagsSupported: Boolean suspend fun getList(offset: Int, query: String): List<Manga>
val isTagsExclusionSupported: Boolean suspend fun getList(offset: Int, tags: Set<MangaTag>?, sortOrder: SortOrder?): List<Manga>
val isSearchSupported: Boolean
suspend fun getList(offset: Int, filter: MangaListFilter?): List<Manga>
suspend fun getDetails(manga: Manga): Manga suspend fun getDetails(manga: Manga): Manga
@@ -49,8 +37,6 @@ interface MangaRepository {
suspend fun getTags(): Set<MangaTag> suspend fun getTags(): Set<MangaTag>
suspend fun getLocales(): Set<Locale>
suspend fun getRelated(seed: Manga): List<Manga> suspend fun getRelated(seed: Manga): List<Manga>
@Singleton @Singleton

View File

@@ -1,8 +1,6 @@
package org.koitharu.kotatsu.core.parser package org.koitharu.kotatsu.core.parser
import android.util.Log import android.util.Log
import androidx.collection.MutableLongSet
import coil.request.CachePolicy
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -22,19 +20,15 @@ import org.koitharu.kotatsu.parsers.MangaParser
import org.koitharu.kotatsu.parsers.MangaParserAuthProvider import org.koitharu.kotatsu.parsers.MangaParserAuthProvider
import org.koitharu.kotatsu.parsers.config.ConfigKey import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.exception.ParseException import org.koitharu.kotatsu.parsers.exception.ParseException
import org.koitharu.kotatsu.parsers.model.ContentRating
import org.koitharu.kotatsu.parsers.model.Favicons import org.koitharu.kotatsu.parsers.model.Favicons
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.util.domain import org.koitharu.kotatsu.parsers.util.domain
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import java.util.Locale
class RemoteMangaRepository( class RemoteMangaRepository(
private val parser: MangaParser, private val parser: MangaParser,
@@ -46,13 +40,7 @@ class RemoteMangaRepository(
get() = parser.source get() = parser.source
override val sortOrders: Set<SortOrder> override val sortOrders: Set<SortOrder>
get() = parser.availableSortOrders get() = parser.sortOrders
override val states: Set<MangaState>
get() = parser.availableStates
override val contentRatings: Set<ContentRating>
get() = parser.availableContentRating
override var defaultSortOrder: SortOrder override var defaultSortOrder: SortOrder
get() = getConfig().defaultSortOrder ?: sortOrders.first() get() = getConfig().defaultSortOrder ?: sortOrders.first()
@@ -60,15 +48,6 @@ class RemoteMangaRepository(
getConfig().defaultSortOrder = value getConfig().defaultSortOrder = value
} }
override val isMultipleTagsSupported: Boolean
get() = parser.isMultipleTagsSupported
override val isSearchSupported: Boolean
get() = parser.isSearchSupported
override val isTagsExclusionSupported: Boolean
get() = parser.isTagsExclusionSupported
var domain: String var domain: String
get() = parser.domain get() = parser.domain
set(value) { set(value) {
@@ -89,13 +68,19 @@ class RemoteMangaRepository(
} }
} }
override suspend fun getList(offset: Int, filter: MangaListFilter?): List<Manga> { override suspend fun getList(offset: Int, query: String): List<Manga> {
return mirrorSwitchInterceptor.withMirrorSwitching { return mirrorSwitchInterceptor.withMirrorSwitching {
parser.getList(offset, filter) parser.getList(offset, query)
} }
} }
override suspend fun getDetails(manga: Manga): Manga = getDetails(manga, CachePolicy.ENABLED) override suspend fun getList(offset: Int, tags: Set<MangaTag>?, sortOrder: SortOrder?): List<Manga> {
return mirrorSwitchInterceptor.withMirrorSwitching {
parser.getList(offset, tags, sortOrder)
}
}
override suspend fun getDetails(manga: Manga): Manga = getDetails(manga, withCache = true)
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> { override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
cache.getPages(source, chapter.url)?.let { return it } cache.getPages(source, chapter.url)?.let { return it }
@@ -113,11 +98,7 @@ class RemoteMangaRepository(
} }
override suspend fun getTags(): Set<MangaTag> = mirrorSwitchInterceptor.withMirrorSwitching { override suspend fun getTags(): Set<MangaTag> = mirrorSwitchInterceptor.withMirrorSwitching {
parser.getAvailableTags() parser.getTags()
}
override suspend fun getLocales(): Set<Locale> {
return parser.getAvailableLocales()
} }
suspend fun getFavicons(): Favicons = mirrorSwitchInterceptor.withMirrorSwitching { suspend fun getFavicons(): Favicons = mirrorSwitchInterceptor.withMirrorSwitching {
@@ -133,27 +114,22 @@ class RemoteMangaRepository(
return related.await() return related.await()
} }
suspend fun getDetails(manga: Manga, cachePolicy: CachePolicy): Manga { suspend fun getDetails(manga: Manga, withCache: Boolean): Manga {
if (cachePolicy.readEnabled) { if (!withCache) {
cache.getDetails(source, manga.url)?.let { return it } return parser.getDetails(manga)
} }
cache.getDetails(source, manga.url)?.let { return it }
val details = asyncSafe { val details = asyncSafe {
mirrorSwitchInterceptor.withMirrorSwitching { mirrorSwitchInterceptor.withMirrorSwitching {
parser.getDetails(manga) parser.getDetails(manga)
} }
} }
if (cachePolicy.writeEnabled) { cache.putDetails(source, manga.url, details)
cache.putDetails(source, manga.url, details)
}
return details.await() return details.await()
} }
suspend fun peekDetails(manga: Manga): Manga? {
return cache.getDetails(source, manga.url)
}
suspend fun find(manga: Manga): Manga? { suspend fun find(manga: Manga): Manga? {
val list = getList(0, MangaListFilter.Search(manga.title)) val list = getList(0, manga.title)
return list.find { x -> x.id == manga.id } return list.find { x -> x.id == manga.id }
} }
@@ -167,15 +143,7 @@ class RemoteMangaRepository(
return parser.configKeyDomain.presetValues.toList() return parser.configKeyDomain.presetValues.toList()
} }
fun isSlowdownEnabled(): Boolean { private fun getConfig() = parser.config as SourceSettings
return getConfig().isSlowdownEnabled
}
fun invalidateCache() {
cache.clear(source)
}
fun getConfig() = parser.config as SourceSettings
private suspend fun <T> asyncSafe(block: suspend CoroutineScope.() -> T): SafeDeferred<T> { private suspend fun <T> asyncSafe(block: suspend CoroutineScope.() -> T): SafeDeferred<T> {
var dispatcher = currentCoroutineContext()[CoroutineDispatcher.Key] var dispatcher = currentCoroutineContext()[CoroutineDispatcher.Key]
@@ -194,7 +162,7 @@ class RemoteMangaRepository(
return emptyList() return emptyList()
} }
val result = ArrayList<MangaPage>(size) val result = ArrayList<MangaPage>(size)
val set = MutableLongSet(size) val set = HashSet<Long>(size)
for (page in this) { for (page in this) {
if (set.add(page.id)) { if (set.add(page.id)) {
result.add(page) result.add(page)
@@ -231,5 +199,6 @@ class RemoteMangaRepository(
} }
} }
private fun Result<*>.isValidResult() = isSuccess && (getOrNull() as? Collection<*>)?.isEmpty() != true private fun Result<*>.isValidResult() = exceptionOrNull() !is ParseException
&& (getOrNull() as? Collection<*>)?.isEmpty() != true
} }

View File

@@ -11,7 +11,6 @@ import androidx.appcompat.app.AppCompatDelegate
import androidx.collection.ArraySet import androidx.collection.ArraySet
import androidx.core.content.edit import androidx.core.content.edit
import androidx.core.os.LocaleListCompat import androidx.core.os.LocaleListCompat
import androidx.documentfile.provider.DocumentFile
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import org.json.JSONArray import org.json.JSONArray
@@ -23,13 +22,11 @@ import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.putEnumValue import org.koitharu.kotatsu.core.util.ext.putEnumValue
import org.koitharu.kotatsu.core.util.ext.takeIfReadable import org.koitharu.kotatsu.core.util.ext.takeIfReadable
import org.koitharu.kotatsu.core.util.ext.toUriOrNull import org.koitharu.kotatsu.core.util.ext.toUriOrNull
import org.koitharu.kotatsu.explore.data.SourcesSortOrder import org.koitharu.kotatsu.history.domain.model.HistoryOrder
import org.koitharu.kotatsu.list.domain.ListSortOrder
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.util.find import org.koitharu.kotatsu.parsers.util.find
import org.koitharu.kotatsu.parsers.util.mapNotNullToSet import org.koitharu.kotatsu.parsers.util.mapNotNullToSet
import org.koitharu.kotatsu.parsers.util.mapToSet import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.reader.domain.ReaderColorFilter
import java.io.File import java.io.File
import java.net.Proxy import java.net.Proxy
import java.util.Locale import java.util.Locale
@@ -71,25 +68,10 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
} }
} }
val isNavLabelsVisible: Boolean
get() = prefs.getBoolean(KEY_NAV_LABELS, true)
var gridSize: Int var gridSize: Int
get() = prefs.getInt(KEY_GRID_SIZE, 100) get() = prefs.getInt(KEY_GRID_SIZE, 100)
set(value) = prefs.edit { putInt(KEY_GRID_SIZE, value) } set(value) = prefs.edit { putInt(KEY_GRID_SIZE, value) }
var historyListMode: ListMode
get() = prefs.getEnumValue(KEY_LIST_MODE_HISTORY, listMode)
set(value) = prefs.edit { putEnumValue(KEY_LIST_MODE_HISTORY, value) }
var suggestionsListMode: ListMode
get() = prefs.getEnumValue(KEY_LIST_MODE_SUGGESTIONS, listMode)
set(value) = prefs.edit { putEnumValue(KEY_LIST_MODE_SUGGESTIONS, value) }
var favoritesListMode: ListMode
get() = prefs.getEnumValue(KEY_LIST_MODE_FAVORITES, listMode)
set(value) = prefs.edit { putEnumValue(KEY_LIST_MODE_FAVORITES, value) }
var isNsfwContentDisabled: Boolean var isNsfwContentDisabled: Boolean
get() = prefs.getBoolean(KEY_DISABLE_NSFW, false) get() = prefs.getBoolean(KEY_DISABLE_NSFW, false)
set(value) = prefs.edit { putBoolean(KEY_DISABLE_NSFW, value) } set(value) = prefs.edit { putBoolean(KEY_DISABLE_NSFW, value) }
@@ -105,24 +87,14 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
} }
} }
var isReaderDoubleOnLandscape: Boolean val readerPageSwitch: Set<String>
get() = prefs.getBoolean(KEY_READER_DOUBLE_PAGES, false) get() = prefs.getStringSet(KEY_READER_SWITCHERS, null) ?: setOf(PAGE_SWITCH_TAPS)
set(value) = prefs.edit { putBoolean(KEY_READER_DOUBLE_PAGES, value) }
val isReaderVolumeButtonsEnabled: Boolean
get() = prefs.getBoolean(KEY_READER_VOLUME_BUTTONS, false)
val isReaderZoomButtonsEnabled: Boolean val isReaderZoomButtonsEnabled: Boolean
get() = prefs.getBoolean(KEY_READER_ZOOM_BUTTONS, false) get() = prefs.getBoolean(KEY_READER_ZOOM_BUTTONS, false)
val isReaderControlAlwaysLTR: Boolean val isReaderTapsAdaptive: Boolean
get() = prefs.getBoolean(KEY_READER_CONTROL_LTR, false) get() = !prefs.getBoolean(KEY_READER_TAPS_LTR, false)
val isReaderFullscreenEnabled: Boolean
get() = prefs.getBoolean(KEY_READER_FULLSCREEN, true)
val isReaderOptimizationEnabled: Boolean
get() = prefs.getBoolean(KEY_READER_OPTIMIZE, false)
var isTrafficWarningEnabled: Boolean var isTrafficWarningEnabled: Boolean
get() = prefs.getBoolean(KEY_TRAFFIC_WARNING, true) get() = prefs.getBoolean(KEY_TRAFFIC_WARNING, true)
@@ -178,14 +150,10 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
get() = prefs.getBoolean(KEY_INCOGNITO_MODE, false) get() = prefs.getBoolean(KEY_INCOGNITO_MODE, false)
set(value) = prefs.edit { putBoolean(KEY_INCOGNITO_MODE, value) } set(value) = prefs.edit { putBoolean(KEY_INCOGNITO_MODE, value) }
var isChaptersReverse: Boolean var chaptersReverse: Boolean
get() = prefs.getBoolean(KEY_REVERSE_CHAPTERS, false) get() = prefs.getBoolean(KEY_REVERSE_CHAPTERS, false)
set(value) = prefs.edit { putBoolean(KEY_REVERSE_CHAPTERS, value) } set(value) = prefs.edit { putBoolean(KEY_REVERSE_CHAPTERS, value) }
var isChaptersGridView: Boolean
get() = prefs.getBoolean(KEY_GRID_VIEW_CHAPTERS, false)
set(value) = prefs.edit { putBoolean(KEY_GRID_VIEW_CHAPTERS, value) }
val zoomMode: ZoomMode val zoomMode: ZoomMode
get() = prefs.getEnumValue(KEY_ZOOM_MODE, ZoomMode.FIT_CENTER) get() = prefs.getEnumValue(KEY_ZOOM_MODE, ZoomMode.FIT_CENTER)
@@ -195,13 +163,11 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
var appPassword: String? var appPassword: String?
get() = prefs.getString(KEY_APP_PASSWORD, null) get() = prefs.getString(KEY_APP_PASSWORD, null)
set(value) = prefs.edit { set(value) = prefs.edit {
if (value != null) putString(KEY_APP_PASSWORD, value) else remove(KEY_APP_PASSWORD) if (value != null) putString(KEY_APP_PASSWORD, value) else remove(
KEY_APP_PASSWORD,
)
} }
var isAppPasswordNumeric: Boolean
get() = prefs.getBoolean(KEY_APP_PASSWORD_NUMERIC, false)
set(value) = prefs.edit { putBoolean(KEY_APP_PASSWORD_NUMERIC, value) }
val isLoggingEnabled: Boolean val isLoggingEnabled: Boolean
get() = prefs.getBoolean(KEY_LOGGING_ENABLED, false) get() = prefs.getBoolean(KEY_LOGGING_ENABLED, false)
@@ -210,7 +176,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
set(value) = prefs.edit { putBoolean(KEY_PROTECT_APP_BIOMETRIC, value) } set(value) = prefs.edit { putBoolean(KEY_PROTECT_APP_BIOMETRIC, value) }
val isMirrorSwitchingAvailable: Boolean val isMirrorSwitchingAvailable: Boolean
get() = prefs.getBoolean(KEY_MIRROR_SWITCHING, false) get() = prefs.getBoolean(KEY_MIRROR_SWITCHING, true)
val isExitConfirmationEnabled: Boolean val isExitConfirmationEnabled: Boolean
get() = prefs.getBoolean(KEY_EXIT_CONFIRM, false) get() = prefs.getBoolean(KEY_EXIT_CONFIRM, false)
@@ -221,16 +187,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val isUnstableUpdatesAllowed: Boolean val isUnstableUpdatesAllowed: Boolean
get() = prefs.getBoolean(KEY_UPDATES_UNSTABLE, false) get() = prefs.getBoolean(KEY_UPDATES_UNSTABLE, false)
val isPagesTabEnabled: Boolean
get() = prefs.getBoolean(KEY_PAGES_TAB, true)
val defaultDetailsTab: Int
get() = if (isPagesTabEnabled) {
prefs.getString(KEY_DETAILS_TAB, null)?.toIntOrNull()?.coerceIn(0, 1) ?: 0
} else {
0
}
val isContentPrefetchEnabled: Boolean val isContentPrefetchEnabled: Boolean
get() { get() {
if (isBackgroundNetworkRestricted()) { if (isBackgroundNetworkRestricted()) {
@@ -241,17 +197,10 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
return policy.isNetworkAllowed(connectivityManager) return policy.isNetworkAllowed(connectivityManager)
} }
var sourcesSortOrder: SourcesSortOrder
get() = prefs.getEnumValue(KEY_SOURCES_ORDER, SourcesSortOrder.MANUAL)
set(value) = prefs.edit { putEnumValue(KEY_SOURCES_ORDER, value) }
var isSourcesGridMode: Boolean var isSourcesGridMode: Boolean
get() = prefs.getBoolean(KEY_SOURCES_GRID, false) get() = prefs.getBoolean(KEY_SOURCES_GRID, false)
set(value) = prefs.edit { putBoolean(KEY_SOURCES_GRID, value) } set(value) = prefs.edit { putBoolean(KEY_SOURCES_GRID, value) }
val isNewSourcesTipEnabled: Boolean
get() = prefs.getBoolean(KEY_SOURCES_NEW, true)
val isPagesNumbersEnabled: Boolean val isPagesNumbersEnabled: Boolean
get() = prefs.getBoolean(KEY_PAGES_NUMBERS, false) get() = prefs.getBoolean(KEY_PAGES_NUMBERS, false)
@@ -287,12 +236,12 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
} }
} }
val isDownloadsSlowdownEnabled: Boolean
get() = prefs.getBoolean(KEY_DOWNLOADS_SLOWDOWN, false)
val isDownloadsWiFiOnly: Boolean val isDownloadsWiFiOnly: Boolean
get() = prefs.getBoolean(KEY_DOWNLOADS_WIFI, false) get() = prefs.getBoolean(KEY_DOWNLOADS_WIFI, false)
val preferredDownloadFormat: DownloadFormat
get() = prefs.getEnumValue(KEY_DOWNLOADS_FORMAT, DownloadFormat.AUTOMATIC)
var isSuggestionsEnabled: Boolean var isSuggestionsEnabled: Boolean
get() = prefs.getBoolean(KEY_SUGGESTIONS, false) get() = prefs.getBoolean(KEY_SUGGESTIONS, false)
set(value) = prefs.edit { putBoolean(KEY_SUGGESTIONS, value) } set(value) = prefs.edit { putBoolean(KEY_SUGGESTIONS, value) }
@@ -324,24 +273,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val isReaderKeepScreenOn: Boolean val isReaderKeepScreenOn: Boolean
get() = prefs.getBoolean(KEY_READER_SCREEN_ON, true) get() = prefs.getBoolean(KEY_READER_SCREEN_ON, true)
var readerColorFilter: ReaderColorFilter?
get() = runCatching {
val brightness = prefs.getFloat(KEY_CF_BRIGHTNESS, ReaderColorFilter.EMPTY.brightness)
val contrast = prefs.getFloat(KEY_CF_CONTRAST, ReaderColorFilter.EMPTY.contrast)
val inverted = prefs.getBoolean(KEY_CF_INVERTED, ReaderColorFilter.EMPTY.isInverted)
val grayscale = prefs.getBoolean(KEY_CF_GRAYSCALE, ReaderColorFilter.EMPTY.isGrayscale)
ReaderColorFilter(brightness, contrast, inverted, grayscale).takeUnless { it.isEmpty }
}.getOrNull()
set(value) {
prefs.edit {
val cf = value ?: ReaderColorFilter.EMPTY
putFloat(KEY_CF_BRIGHTNESS, cf.brightness)
putFloat(KEY_CF_CONTRAST, cf.contrast)
putBoolean(KEY_CF_INVERTED, cf.isInverted)
putBoolean(KEY_CF_GRAYSCALE, cf.isGrayscale)
}
}
val isImagesProxyEnabled: Boolean val isImagesProxyEnabled: Boolean
get() = prefs.getBoolean(KEY_IMAGES_PROXY, false) get() = prefs.getBoolean(KEY_IMAGES_PROXY, false)
@@ -373,24 +304,16 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
get() = prefs.getEnumValue(KEY_LOCAL_LIST_ORDER, SortOrder.NEWEST) get() = prefs.getEnumValue(KEY_LOCAL_LIST_ORDER, SortOrder.NEWEST)
set(value) = prefs.edit { putEnumValue(KEY_LOCAL_LIST_ORDER, value) } set(value) = prefs.edit { putEnumValue(KEY_LOCAL_LIST_ORDER, value) }
var historySortOrder: ListSortOrder var historySortOrder: HistoryOrder
get() = prefs.getEnumValue(KEY_HISTORY_ORDER, ListSortOrder.LAST_READ) get() = prefs.getEnumValue(KEY_HISTORY_ORDER, HistoryOrder.UPDATED)
set(value) = prefs.edit { putEnumValue(KEY_HISTORY_ORDER, value) } set(value) = prefs.edit { putEnumValue(KEY_HISTORY_ORDER, value) }
var allFavoritesSortOrder: ListSortOrder
get() = prefs.getEnumValue(KEY_FAVORITES_ORDER, ListSortOrder.NEWEST)
set(value) = prefs.edit { putEnumValue(KEY_FAVORITES_ORDER, value) }
val isRelatedMangaEnabled: Boolean val isRelatedMangaEnabled: Boolean
get() = prefs.getBoolean(KEY_RELATED_MANGA, true) get() = prefs.getBoolean(KEY_RELATED_MANGA, true)
val isWebtoonZoomEnable: Boolean val isWebtoonZoomEnable: Boolean
get() = prefs.getBoolean(KEY_WEBTOON_ZOOM, true) get() = prefs.getBoolean(KEY_WEBTOON_ZOOM, true)
@get:FloatRange(from = 0.0, to = 0.5)
val defaultWebtoonZoomOut: Float
get() = prefs.getInt(KEY_WEBTOON_ZOOM_OUT, 0).coerceIn(0, 50) / 100f
@get:FloatRange(from = 0.0, to = 1.0) @get:FloatRange(from = 0.0, to = 1.0)
var readerAutoscrollSpeed: Float var readerAutoscrollSpeed: Float
get() = prefs.getFloat(KEY_READER_AUTOSCROLL_SPEED, 0f) get() = prefs.getFloat(KEY_READER_AUTOSCROLL_SPEED, 0f)
@@ -413,31 +336,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
return policy.isNetworkAllowed(connectivityManager) return policy.isNetworkAllowed(connectivityManager)
} }
val is32BitColorsEnabled: Boolean
get() = prefs.getBoolean(KEY_32BIT_COLOR, false)
val isPeriodicalBackupEnabled: Boolean
get() = prefs.getBoolean(KEY_BACKUP_PERIODICAL_ENABLED, false)
val periodicalBackupFrequency: Long
get() = prefs.getString(KEY_BACKUP_PERIODICAL_FREQUENCY, null)?.toLongOrNull() ?: 7L
var periodicalBackupOutput: Uri?
get() = prefs.getString(KEY_BACKUP_PERIODICAL_OUTPUT, null)?.toUriOrNull()
set(value) = prefs.edit { putString(KEY_BACKUP_PERIODICAL_OUTPUT, value?.toString()) }
val isReadingTimeEstimationEnabled: Boolean
get() = prefs.getBoolean(KEY_READING_TIME, true)
val isPagesSavingAskEnabled: Boolean
get() = prefs.getBoolean(KEY_PAGES_SAVE_ASK, true)
val isStatsEnabled: Boolean
get() = prefs.getBoolean(KEY_STATS_ENABLED, false)
val isAutoLocalChaptersCleanupEnabled: Boolean
get() = prefs.getBoolean(KEY_CHAPTERS_CLEAR_AUTO, false)
fun isTipEnabled(tip: String): Boolean { fun isTipEnabled(tip: String): Boolean {
return prefs.getStringSet(KEY_TIPS_CLOSED, emptySet())?.contains(tip) != true return prefs.getStringSet(KEY_TIPS_CLOSED, emptySet())?.contains(tip) != true
} }
@@ -450,15 +348,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
prefs.edit { putStringSet(KEY_TIPS_CLOSED, closedTips + tip) } prefs.edit { putStringSet(KEY_TIPS_CLOSED, closedTips + tip) }
} }
fun getPagesSaveDir(context: Context): DocumentFile? =
prefs.getString(KEY_PAGES_SAVE_DIR, null)?.toUriOrNull()?.let {
DocumentFile.fromTreeUri(context, it)?.takeIf { it.canWrite() }
}
fun setPagesSaveDir(uri: Uri?) {
prefs.edit { putString(KEY_PAGES_SAVE_DIR, uri?.toString()) }
}
fun subscribe(listener: SharedPreferences.OnSharedPreferenceChangeListener) { fun subscribe(listener: SharedPreferences.OnSharedPreferenceChangeListener) {
prefs.registerOnSharedPreferenceChangeListener(listener) prefs.registerOnSharedPreferenceChangeListener(listener)
} }
@@ -505,15 +394,13 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
companion object { companion object {
const val PAGE_SWITCH_TAPS = "taps"
const val PAGE_SWITCH_VOLUME_KEYS = "volume" const val PAGE_SWITCH_VOLUME_KEYS = "volume"
const val TRACK_HISTORY = "history" const val TRACK_HISTORY = "history"
const val TRACK_FAVOURITES = "favourites" const val TRACK_FAVOURITES = "favourites"
const val KEY_LIST_MODE = "list_mode_2" const val KEY_LIST_MODE = "list_mode_2"
const val KEY_LIST_MODE_HISTORY = "list_mode_history"
const val KEY_LIST_MODE_FAVORITES = "list_mode_favorites"
const val KEY_LIST_MODE_SUGGESTIONS = "list_mode_suggestions"
const val KEY_THEME = "theme" const val KEY_THEME = "theme"
const val KEY_COLOR_THEME = "color_theme" const val KEY_COLOR_THEME = "color_theme"
const val KEY_THEME_AMOLED = "amoled_theme" const val KEY_THEME_AMOLED = "amoled_theme"
@@ -521,19 +408,14 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_PAGES_CACHE_CLEAR = "pages_cache_clear" const val KEY_PAGES_CACHE_CLEAR = "pages_cache_clear"
const val KEY_HTTP_CACHE_CLEAR = "http_cache_clear" const val KEY_HTTP_CACHE_CLEAR = "http_cache_clear"
const val KEY_COOKIES_CLEAR = "cookies_clear" const val KEY_COOKIES_CLEAR = "cookies_clear"
const val KEY_CHAPTERS_CLEAR = "chapters_clear"
const val KEY_CHAPTERS_CLEAR_AUTO = "chapters_clear_auto"
const val KEY_THUMBS_CACHE_CLEAR = "thumbs_cache_clear" const val KEY_THUMBS_CACHE_CLEAR = "thumbs_cache_clear"
const val KEY_SEARCH_HISTORY_CLEAR = "search_history_clear" const val KEY_SEARCH_HISTORY_CLEAR = "search_history_clear"
const val KEY_UPDATES_FEED_CLEAR = "updates_feed_clear" const val KEY_UPDATES_FEED_CLEAR = "updates_feed_clear"
const val KEY_GRID_SIZE = "grid_size" const val KEY_GRID_SIZE = "grid_size"
const val KEY_REMOTE_SOURCES = "remote_sources" const val KEY_REMOTE_SOURCES = "remote_sources"
const val KEY_LOCAL_STORAGE = "local_storage" const val KEY_LOCAL_STORAGE = "local_storage"
const val KEY_READER_DOUBLE_PAGES = "reader_double_pages" const val KEY_READER_SWITCHERS = "reader_switchers"
const val KEY_READER_ZOOM_BUTTONS = "reader_zoom_buttons" const val KEY_READER_ZOOM_BUTTONS = "reader_zoom_buttons"
const val KEY_READER_CONTROL_LTR = "reader_taps_ltr"
const val KEY_READER_FULLSCREEN = "reader_fullscreen"
const val KEY_READER_VOLUME_BUTTONS = "reader_volume_buttons"
const val KEY_TRACKER_ENABLED = "tracker_enabled" const val KEY_TRACKER_ENABLED = "tracker_enabled"
const val KEY_TRACKER_WIFI_ONLY = "tracker_wifi" const val KEY_TRACKER_WIFI_ONLY = "tracker_wifi"
const val KEY_TRACK_SOURCES = "track_sources" const val KEY_TRACK_SOURCES = "track_sources"
@@ -549,21 +431,15 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_READER_MODE = "reader_mode" const val KEY_READER_MODE = "reader_mode"
const val KEY_READER_MODE_DETECT = "reader_mode_detect" const val KEY_READER_MODE_DETECT = "reader_mode_detect"
const val KEY_APP_PASSWORD = "app_password" const val KEY_APP_PASSWORD = "app_password"
const val KEY_APP_PASSWORD_NUMERIC = "app_password_num"
const val KEY_PROTECT_APP = "protect_app" const val KEY_PROTECT_APP = "protect_app"
const val KEY_PROTECT_APP_BIOMETRIC = "protect_app_bio" const val KEY_PROTECT_APP_BIOMETRIC = "protect_app_bio"
const val KEY_APP_VERSION = "app_version" const val KEY_APP_VERSION = "app_version"
const val KEY_ZOOM_MODE = "zoom_mode" const val KEY_ZOOM_MODE = "zoom_mode"
const val KEY_BACKUP = "backup" const val KEY_BACKUP = "backup"
const val KEY_RESTORE = "restore" const val KEY_RESTORE = "restore"
const val KEY_BACKUP_PERIODICAL_ENABLED = "backup_periodic"
const val KEY_BACKUP_PERIODICAL_FREQUENCY = "backup_periodic_freq"
const val KEY_BACKUP_PERIODICAL_OUTPUT = "backup_periodic_output"
const val KEY_BACKUP_PERIODICAL_LAST = "backup_periodic_last"
const val KEY_HISTORY_GROUPING = "history_grouping" const val KEY_HISTORY_GROUPING = "history_grouping"
const val KEY_READING_INDICATORS = "reading_indicators" const val KEY_READING_INDICATORS = "reading_indicators"
const val KEY_REVERSE_CHAPTERS = "reverse_chapters" const val KEY_REVERSE_CHAPTERS = "reverse_chapters"
const val KEY_GRID_VIEW_CHAPTERS = "grid_view_chapters"
const val KEY_HISTORY_EXCLUDE_NSFW = "history_exclude_nsfw" const val KEY_HISTORY_EXCLUDE_NSFW = "history_exclude_nsfw"
const val KEY_PAGES_NUMBERS = "pages_numbers" const val KEY_PAGES_NUMBERS = "pages_numbers"
const val KEY_SCREENSHOTS_POLICY = "screenshots_policy" const val KEY_SCREENSHOTS_POLICY = "screenshots_policy"
@@ -576,9 +452,8 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_SHIKIMORI = "shikimori" const val KEY_SHIKIMORI = "shikimori"
const val KEY_ANILIST = "anilist" const val KEY_ANILIST = "anilist"
const val KEY_MAL = "mal" const val KEY_MAL = "mal"
const val KEY_KITSU = "kitsu" const val KEY_DOWNLOADS_SLOWDOWN = "downloads_slowdown"
const val KEY_DOWNLOADS_WIFI = "downloads_wifi" const val KEY_DOWNLOADS_WIFI = "downloads_wifi"
const val KEY_DOWNLOADS_FORMAT = "downloads_format"
const val KEY_ALL_FAVOURITES_VISIBLE = "all_favourites_visible" const val KEY_ALL_FAVOURITES_VISIBLE = "all_favourites_visible"
const val KEY_DOH = "doh" const val KEY_DOH = "doh"
const val KEY_EXIT_CONFIRM = "exit_confirm" const val KEY_EXIT_CONFIRM = "exit_confirm"
@@ -590,19 +465,15 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_READER_BACKGROUND = "reader_background" const val KEY_READER_BACKGROUND = "reader_background"
const val KEY_READER_SCREEN_ON = "reader_screen_on" const val KEY_READER_SCREEN_ON = "reader_screen_on"
const val KEY_SHORTCUTS = "dynamic_shortcuts" const val KEY_SHORTCUTS = "dynamic_shortcuts"
const val KEY_READER_TAP_ACTIONS = "reader_tap_actions" const val KEY_READER_TAPS_LTR = "reader_taps_ltr"
const val KEY_READER_OPTIMIZE = "reader_optimize"
const val KEY_LOCAL_LIST_ORDER = "local_order" const val KEY_LOCAL_LIST_ORDER = "local_order"
const val KEY_HISTORY_ORDER = "history_order" const val KEY_HISTORY_ORDER = "history_order"
const val KEY_FAVORITES_ORDER = "fav_order"
const val KEY_WEBTOON_ZOOM = "webtoon_zoom" const val KEY_WEBTOON_ZOOM = "webtoon_zoom"
const val KEY_WEBTOON_ZOOM_OUT = "webtoon_zoom_out"
const val KEY_PREFETCH_CONTENT = "prefetch_content" const val KEY_PREFETCH_CONTENT = "prefetch_content"
const val KEY_APP_LOCALE = "app_locale" const val KEY_APP_LOCALE = "app_locale"
const val KEY_LOGGING_ENABLED = "logging" const val KEY_LOGGING_ENABLED = "logging"
const val KEY_LOGS_SHARE = "logs_share" const val KEY_LOGS_SHARE = "logs_share"
const val KEY_SOURCES_GRID = "sources_grid" const val KEY_SOURCES_GRID = "sources_grid"
const val KEY_SOURCES_NEW = "sources_new"
const val KEY_UPDATES_UNSTABLE = "updates_unstable" const val KEY_UPDATES_UNSTABLE = "updates_unstable"
const val KEY_TIPS_CLOSED = "tips_closed" const val KEY_TIPS_CLOSED = "tips_closed"
const val KEY_SSL_BYPASS = "ssl_bypass" const val KEY_SSL_BYPASS = "ssl_bypass"
@@ -620,21 +491,8 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_DISABLE_NSFW = "no_nsfw" const val KEY_DISABLE_NSFW = "no_nsfw"
const val KEY_RELATED_MANGA = "related_manga" const val KEY_RELATED_MANGA = "related_manga"
const val KEY_NAV_MAIN = "nav_main" const val KEY_NAV_MAIN = "nav_main"
const val KEY_NAV_LABELS = "nav_labels"
const val KEY_32BIT_COLOR = "enhanced_colors" // About
const val KEY_SOURCES_ORDER = "sources_sort_order"
const val KEY_SOURCES_CATALOG = "sources_catalog"
const val KEY_CF_BRIGHTNESS = "cf_brightness"
const val KEY_CF_CONTRAST = "cf_contrast"
const val KEY_CF_INVERTED = "cf_inverted"
const val KEY_CF_GRAYSCALE = "cf_grayscale"
const val KEY_IGNORE_DOZE = "ignore_dose"
const val KEY_PAGES_TAB = "pages_tab"
const val KEY_DETAILS_TAB = "details_tab"
const val KEY_READING_TIME = "reading_time"
const val KEY_PAGES_SAVE_DIR = "pages_dir"
const val KEY_PAGES_SAVE_ASK = "pages_dir_ask"
const val KEY_STATS_ENABLED = "stats_on"
const val KEY_APP_UPDATE = "app_update" const val KEY_APP_UPDATE = "app_update"
const val KEY_APP_TRANSLATION = "about_app_translation" const val KEY_APP_TRANSLATION = "about_app_translation"
} }

View File

@@ -1,8 +0,0 @@
package org.koitharu.kotatsu.core.prefs
enum class DownloadFormat {
AUTOMATIC,
SINGLE_CBZ,
MULTIPLE_CBZ,
}

View File

@@ -4,12 +4,13 @@ import androidx.annotation.DrawableRes
import androidx.annotation.IdRes import androidx.annotation.IdRes
import androidx.annotation.StringRes import androidx.annotation.StringRes
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.list.ui.model.ListModel
enum class NavItem( enum class NavItem(
@IdRes val id: Int, @IdRes val id: Int,
@StringRes val title: Int, @StringRes val title: Int,
@DrawableRes val icon: Int, @DrawableRes val icon: Int,
) { ) : ListModel {
HISTORY(R.id.nav_history, R.string.history, R.drawable.ic_history_selector), HISTORY(R.id.nav_history, R.string.history, R.drawable.ic_history_selector),
FAVORITES(R.id.nav_favorites, R.string.favourites, R.drawable.ic_favourites_selector), FAVORITES(R.id.nav_favorites, R.string.favourites, R.drawable.ic_favourites_selector),
@@ -20,6 +21,10 @@ enum class NavItem(
BOOKMARKS(R.id.nav_bookmarks, R.string.bookmarks, R.drawable.ic_bookmark_selector), BOOKMARKS(R.id.nav_bookmarks, R.string.bookmarks, R.drawable.ic_bookmark_selector),
; ;
override fun areItemsTheSame(other: ListModel): Boolean {
return other is NavItem && ordinal == other.ordinal
}
fun isAvailable(settings: AppSettings): Boolean = when (this) { fun isAvailable(settings: AppSettings): Boolean = when (this) {
SUGGESTIONS -> settings.isSuggestionsEnabled SUGGESTIONS -> settings.isSuggestionsEnabled
FEED -> settings.isTrackerEnabled FEED -> settings.isTrackerEnabled

View File

@@ -4,9 +4,7 @@ enum class ReaderMode(val id: Int) {
STANDARD(1), STANDARD(1),
REVERSED(3), REVERSED(3),
VERTICAL(4), WEBTOON(2);
WEBTOON(2),
;
companion object { companion object {

View File

@@ -1,18 +1,17 @@
package org.koitharu.kotatsu.core.prefs package org.koitharu.kotatsu.core.prefs
import android.content.Context import android.content.Context
import android.content.SharedPreferences.OnSharedPreferenceChangeListener
import androidx.core.content.edit import androidx.core.content.edit
import okhttp3.internal.isSensitiveHeader
import org.koitharu.kotatsu.core.util.ext.getEnumValue import org.koitharu.kotatsu.core.util.ext.getEnumValue
import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty
import org.koitharu.kotatsu.core.util.ext.putEnumValue import org.koitharu.kotatsu.core.util.ext.putEnumValue
import org.koitharu.kotatsu.core.util.ext.sanitizeHeaderValue
import org.koitharu.kotatsu.parsers.config.ConfigKey import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.config.MangaSourceConfig import org.koitharu.kotatsu.parsers.config.MangaSourceConfig
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
private const val KEY_SORT_ORDER = "sort_order"
class SourceSettings(context: Context, source: MangaSource) : MangaSourceConfig { class SourceSettings(context: Context, source: MangaSource) : MangaSourceConfig {
private val prefs = context.getSharedPreferences(source.name, Context.MODE_PRIVATE) private val prefs = context.getSharedPreferences(source.name, Context.MODE_PRIVATE)
@@ -21,19 +20,12 @@ class SourceSettings(context: Context, source: MangaSource) : MangaSourceConfig
get() = prefs.getEnumValue(KEY_SORT_ORDER, SortOrder::class.java) get() = prefs.getEnumValue(KEY_SORT_ORDER, SortOrder::class.java)
set(value) = prefs.edit { putEnumValue(KEY_SORT_ORDER, value) } set(value) = prefs.edit { putEnumValue(KEY_SORT_ORDER, value) }
val isSlowdownEnabled: Boolean
get() = prefs.getBoolean(KEY_SLOWDOWN, false)
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
override fun <T> get(key: ConfigKey<T>): T { override fun <T> get(key: ConfigKey<T>): T {
return when (key) { return when (key) {
is ConfigKey.UserAgent -> prefs.getString(key.key, key.defaultValue) is ConfigKey.UserAgent -> prefs.getString(key.key, key.defaultValue).ifNullOrEmpty { key.defaultValue }
.ifNullOrEmpty { key.defaultValue }
.sanitizeHeaderValue()
is ConfigKey.Domain -> prefs.getString(key.key, key.defaultValue).ifNullOrEmpty { key.defaultValue } is ConfigKey.Domain -> prefs.getString(key.key, key.defaultValue).ifNullOrEmpty { key.defaultValue }
is ConfigKey.ShowSuspiciousContent -> prefs.getBoolean(key.key, key.defaultValue) is ConfigKey.ShowSuspiciousContent -> prefs.getBoolean(key.key, key.defaultValue)
is ConfigKey.SplitByTranslations -> prefs.getBoolean(key.key, key.defaultValue)
} as T } as T
} }
@@ -41,22 +33,7 @@ class SourceSettings(context: Context, source: MangaSource) : MangaSourceConfig
when (key) { when (key) {
is ConfigKey.Domain -> putString(key.key, value as String?) is ConfigKey.Domain -> putString(key.key, value as String?)
is ConfigKey.ShowSuspiciousContent -> putBoolean(key.key, value as Boolean) is ConfigKey.ShowSuspiciousContent -> putBoolean(key.key, value as Boolean)
is ConfigKey.UserAgent -> putString(key.key, (value as String?)?.sanitizeHeaderValue()) is ConfigKey.UserAgent -> putString(key.key, value as String?)
is ConfigKey.SplitByTranslations -> putBoolean(key.key, value as Boolean)
} }
} }
fun subscribe(listener: OnSharedPreferenceChangeListener) {
prefs.registerOnSharedPreferenceChangeListener(listener)
}
fun unsubscribe(listener: OnSharedPreferenceChangeListener) {
prefs.unregisterOnSharedPreferenceChangeListener(listener)
}
companion object {
const val KEY_SORT_ORDER = "sort_order"
const val KEY_SLOWDOWN = "slowdown"
}
} }

View File

@@ -6,9 +6,9 @@ import android.graphics.Color
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.view.KeyEvent import android.view.KeyEvent
import android.view.MenuItem
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.Toast
import androidx.annotation.CallSuper import androidx.annotation.CallSuper
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.view.ActionMode import androidx.appcompat.view.ActionMode
@@ -30,7 +30,6 @@ import org.koitharu.kotatsu.core.ui.util.ActionModeDelegate
import org.koitharu.kotatsu.core.ui.util.BaseActivityEntryPoint import org.koitharu.kotatsu.core.ui.util.BaseActivityEntryPoint
import org.koitharu.kotatsu.core.ui.util.WindowInsetsDelegate import org.koitharu.kotatsu.core.ui.util.WindowInsetsDelegate
import org.koitharu.kotatsu.core.util.ext.getThemeColor import org.koitharu.kotatsu.core.util.ext.getThemeColor
import org.koitharu.kotatsu.core.util.ext.isWebViewUnavailable
@Suppress("LeakingThis") @Suppress("LeakingThis")
abstract class BaseActivity<B : ViewBinding> : abstract class BaseActivity<B : ViewBinding> :
@@ -60,11 +59,11 @@ abstract class BaseActivity<B : ViewBinding> :
if (isAmoledTheme) { if (isAmoledTheme) {
setTheme(R.style.ThemeOverlay_Kotatsu_Amoled) setTheme(R.style.ThemeOverlay_Kotatsu_Amoled)
} }
putDataToExtras(intent)
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
WindowCompat.setDecorFitsSystemWindows(window, false) WindowCompat.setDecorFitsSystemWindows(window, false)
insetsDelegate.handleImeInsets = true insetsDelegate.handleImeInsets = true
insetsDelegate.addInsetsListener(this) insetsDelegate.addInsetsListener(this)
putDataToExtras(intent)
} }
override fun onPostCreate(savedInstanceState: Bundle?) { override fun onPostCreate(savedInstanceState: Bundle?) {
@@ -97,10 +96,10 @@ abstract class BaseActivity<B : ViewBinding> :
insetsDelegate.onViewCreated(binding.root) insetsDelegate.onViewCreated(binding.root)
} }
override fun onSupportNavigateUp(): Boolean { override fun onOptionsItemSelected(item: MenuItem) = if (item.itemId == android.R.id.home) {
dispatchNavigateUp() onBackPressed()
return true true
} } else super.onOptionsItemSelected(item)
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean { override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
if (BuildConfig.DEBUG && keyCode == KeyEvent.KEYCODE_VOLUME_UP) { if (BuildConfig.DEBUG && keyCode == KeyEvent.KEYCODE_VOLUME_UP) {
@@ -127,10 +126,10 @@ abstract class BaseActivity<B : ViewBinding> :
val actionModeColor = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { val actionModeColor = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
ColorUtils.compositeColors( ColorUtils.compositeColors(
ContextCompat.getColor(this, com.google.android.material.R.color.m3_appbar_overlay_color), ContextCompat.getColor(this, com.google.android.material.R.color.m3_appbar_overlay_color),
getThemeColor(R.attr.m3ColorBackground), getThemeColor(com.google.android.material.R.attr.colorSurface),
) )
} else { } else {
ContextCompat.getColor(this, R.color.kotatsu_m3_background) ContextCompat.getColor(this, R.color.kotatsu_secondaryContainer)
} }
val insets = ViewCompat.getRootWindowInsets(viewBinding.root) val insets = ViewCompat.getRootWindowInsets(viewBinding.root)
?.getInsets(WindowInsetsCompat.Type.systemBars()) ?: return ?.getInsets(WindowInsetsCompat.Type.systemBars()) ?: return
@@ -151,36 +150,10 @@ abstract class BaseActivity<B : ViewBinding> :
window.statusBarColor = defaultStatusBarColor window.statusBarColor = defaultStatusBarColor
} }
protected open fun dispatchNavigateUp() {
val upIntent = parentActivityIntent
if (upIntent != null) {
if (!navigateUpTo(upIntent)) {
startActivity(upIntent)
}
} else {
finishAfterTransition()
}
}
private fun putDataToExtras(intent: Intent?) { private fun putDataToExtras(intent: Intent?) {
intent?.putExtra(EXTRA_DATA, intent.data) intent?.putExtra(EXTRA_DATA, intent.data)
} }
protected fun setContentViewWebViewSafe(viewBindingProducer: () -> B): Boolean {
return try {
setContentView(viewBindingProducer())
true
} catch (e: Exception) {
if (e.isWebViewUnavailable()) {
Toast.makeText(this, R.string.web_view_unavailable, Toast.LENGTH_LONG).show()
finishAfterTransition()
false
} else {
throw e
}
}
}
companion object { companion object {
const val EXTRA_DATA = "data" const val EXTRA_DATA = "data"

View File

@@ -29,6 +29,7 @@ abstract class BaseFullscreenActivity<B : ViewBinding> :
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
} }
} }
// insetsControllerCompat.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
systemUiController.setSystemUiVisible(true) systemUiController.setSystemUiVisible(true)
} }
} }

View File

@@ -10,7 +10,6 @@ import kotlinx.coroutines.flow.FlowCollector
import org.koitharu.kotatsu.core.util.ContinuationResumeRunnable import org.koitharu.kotatsu.core.util.ContinuationResumeRunnable
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
import org.koitharu.kotatsu.list.ui.adapter.ListItemType import org.koitharu.kotatsu.list.ui.adapter.ListItemType
import org.koitharu.kotatsu.list.ui.model.ListHeader
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import kotlin.coroutines.suspendCoroutine import kotlin.coroutines.suspendCoroutine
@@ -29,23 +28,11 @@ open class BaseListAdapter<T : ListModel> : AsyncListDifferDelegationAdapter<T>(
return this return this
} }
fun addListListener(listListener: ListListener<T>): BaseListAdapter<T> { fun addListListener(listListener: ListListener<T>) {
differ.addListListener(listListener) differ.addListListener(listListener)
return this
} }
fun removeListListener(listListener: ListListener<T>) { fun removeListListener(listListener: ListListener<T>) {
differ.removeListListener(listListener) differ.removeListListener(listListener)
} }
fun findHeader(position: Int): ListHeader? {
val snapshot = items
for (i in (0..position).reversed()) {
val item = snapshot.getOrNull(i) ?: continue
if (item is ListHeader) {
return item
}
}
return null
}
} }

View File

@@ -1,7 +1,5 @@
package org.koitharu.kotatsu.core.ui package org.koitharu.kotatsu.core.ui
import android.content.ActivityNotFoundException
import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.view.View import android.view.View
import androidx.annotation.CallSuper import androidx.annotation.CallSuper
@@ -10,9 +8,7 @@ import androidx.core.graphics.Insets
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
import androidx.preference.PreferenceFragmentCompat import androidx.preference.PreferenceFragmentCompat
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.util.RecyclerViewOwner import org.koitharu.kotatsu.core.ui.util.RecyclerViewOwner
import org.koitharu.kotatsu.core.ui.util.WindowInsetsDelegate import org.koitharu.kotatsu.core.ui.util.WindowInsetsDelegate
@@ -66,12 +62,4 @@ abstract class BasePreferenceFragment(@StringRes private val titleId: Int) :
protected fun setTitle(title: CharSequence?) { protected fun setTitle(title: CharSequence?) {
(activity as? SettingsActivity)?.setSectionTitle(title) (activity as? SettingsActivity)?.setSectionTitle(title)
} }
protected fun startActivitySafe(intent: Intent) {
try {
startActivity(intent)
} catch (_: ActivityNotFoundException) {
Snackbar.make(listView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT).show()
}
}
} }

Some files were not shown because too many files have changed in this diff Show More