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 25665 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/workspace.xml
/.idea/navEditor.xml
/.idea/ktlint-plugin.xml
/.idea/assetWizardSettings.xml
/.idea/kotlinScripting.xml
/.idea/kotlinc.xml
/.idea/deploymentTargetDropDown.xml
/.idea/androidTestResultsUserPreferences.xml
/.idea/deploymentTargetSelector.xml
/.idea/render.experimental.xml
/.idea/inspectionProfiles/
.DS_Store

3
.idea/gradle.xml generated
View File

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

View File

@@ -1,12 +1,11 @@
## 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 **implement a new feature:** 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.
+ In case you want to **add a new manga source,** refer to the [parsers repository](https://github.com/KotatsuApp/kotatsu-parsers).
- 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.
- 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.
- 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:
+ **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).
+ **Avoid adding new dependencies** unless required. APK size is important.
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.
- Please, do not modify readme and other information files (except for typos).
- 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.
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
* Standard and Webtoon-optimized reader
* 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
* History and favourites [synchronization](https://github.com/KotatsuApp/kotatsu-syncserver) across devices

View File

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

View File

@@ -7,9 +7,7 @@ import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Assert.*
import org.junit.Before
import org.junit.Rule
import org.junit.Test
@@ -63,7 +61,6 @@ class AppBackupAgentTest {
page = 3,
scroll = 40,
percent = 0.2f,
force = false,
)
val history = checkNotNull(historyRepository.getOne(SampleData.manga))
@@ -85,7 +82,7 @@ class AppBackupAgentTest {
assertEquals(history, historyRepository.getOne(SampleData.manga))
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)
}

View File

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

View File

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

View File

@@ -1,12 +1,10 @@
package org.koitharu.kotatsu.core.parser
import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaParser
import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaTag
@@ -19,20 +17,29 @@ import java.util.EnumSet
class DummyParser(context: MangaLoaderContext) : MangaParser(context, MangaSource.DUMMY) {
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)
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)
private fun stub(manga: Manga?): Nothing {
throw UnsupportedSourceException("Usage of Dummy parser", manga)
override suspend fun getTags(): Set<MangaTag> {
TODO("Not yet implemented")
}
}

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.parsers.model.Manga
import java.time.Instant
import java.util.Date
fun BookmarkEntity.toBookmark(manga: Manga) = Bookmark(
manga = manga,
@@ -11,7 +11,7 @@ fun BookmarkEntity.toBookmark(manga: Manga) = Bookmark(
page = page,
scroll = scroll,
imageUrl = imageUrl,
createdAt = Instant.ofEpochMilli(createdAt),
createdAt = Date(createdAt),
percent = percent,
)
@@ -22,7 +22,7 @@ fun Bookmark.toEntity() = BookmarkEntity(
page = page,
scroll = scroll,
imageUrl = imageUrl,
createdAt = createdAt.toEpochMilli(),
createdAt = createdAt.time,
percent = percent,
)

View File

@@ -1,10 +1,10 @@
package org.koitharu.kotatsu.bookmarks.domain
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.MangaPage
import java.time.Instant
import java.util.Date
data class Bookmark(
val manga: Manga,
@@ -13,7 +13,7 @@ data class Bookmark(
val page: Int,
val scroll: Int,
val imageUrl: String,
val createdAt: Instant,
val createdAt: Date,
val percent: Float,
) : ListModel {
@@ -38,6 +38,7 @@ data class Bookmark(
)
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?> {
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>> {
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>>> {
return db.getBookmarksDao().observe().map { map ->
return db.bookmarksDao.observe().map { map ->
val res = LinkedHashMap<Manga, List<Bookmark>>(map.size)
for ((k, v) in map) {
val manga = k.toManga()
@@ -46,9 +46,9 @@ class BookmarksRepository @Inject constructor(
suspend fun addBookmark(bookmark: Bookmark) {
db.withTransaction {
val tags = bookmark.manga.tags.toEntities()
db.getTagsDao().upsert(tags)
db.getMangaDao().upsert(bookmark.manga.toEntity(), tags)
db.getBookmarksDao().insert(bookmark.toEntity())
db.tagsDao.upsert(tags)
db.mangaDao.upsert(bookmark.manga.toEntity(), tags)
db.bookmarksDao.insert(bookmark.toEntity())
}
}
@@ -56,11 +56,11 @@ class BookmarksRepository @Inject constructor(
val entity = bookmark.toEntity().copy(
imageUrl = imageUrl,
)
db.getBookmarksDao().upsert(listOf(entity))
db.bookmarksDao.upsert(listOf(entity))
}
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"
}
}
@@ -72,7 +72,7 @@ class BookmarksRepository @Inject constructor(
suspend fun removeBookmarks(ids: Set<Long>): ReversibleHandle {
val entities = ArrayList<BookmarkEntity>(ids.size)
db.withTransaction {
val dao = db.getBookmarksDao()
val dao = db.bookmarksDao
for (pageId in ids) {
val e = dao.find(pageId)
if (e != null) {
@@ -92,7 +92,7 @@ class BookmarksRepository @Inject constructor(
db.withTransaction {
for (e in entities) {
try {
db.getBookmarksDao().insert(e)
db.bookmarksDao.insert(e)
} catch (e: SQLException) {
e.printStackTraceDebug()
}

View File

@@ -34,8 +34,8 @@ class BookmarksActivity :
val fm = supportFragmentManager
if (fm.findFragmentById(R.id.container) == null) {
fm.commit {
setReorderingAllowed(true)
replace(R.id.container, BookmarksFragment::class.java, null)
val fragment = BookmarksFragment.newInstance()
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.loadingFooterAD
import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD
import org.koitharu.kotatsu.list.ui.model.ListHeader
import org.koitharu.kotatsu.list.ui.model.ListModel
class BookmarksAdapter(
@@ -31,6 +32,13 @@ class BookmarksAdapter(
}
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
import android.annotation.SuppressLint
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
@@ -12,39 +13,30 @@ import androidx.core.graphics.Insets
import androidx.core.view.isVisible
import androidx.core.view.updatePadding
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.util.ext.configureForParser
import org.koitharu.kotatsu.core.util.ext.getSerializableExtraCompat
import org.koitharu.kotatsu.core.util.ext.toUriOrNull
import org.koitharu.kotatsu.core.util.ext.catchingWebViewUnavailability
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
import org.koitharu.kotatsu.parsers.model.MangaSource
import javax.inject.Inject
import org.koitharu.kotatsu.parsers.network.UserAgents
import com.google.android.material.R as materialR
@SuppressLint("SetJavaScriptEnabled")
class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback {
private lateinit var onBackPressedCallback: WebViewBackPressedCallback
@Inject
lateinit var mangaRepositoryFactory: MangaRepository.Factory
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (!setContentViewWebViewSafe { ActivityBrowserBinding.inflate(layoutInflater) }) {
if (!catchingWebViewUnavailability { setContentView(ActivityBrowserBinding.inflate(layoutInflater)) }) {
return
}
supportActionBar?.run {
setDisplayHomeAsUpEnabled(true)
setHomeAsUpIndicator(materialR.drawable.abc_ic_clear_material)
}
val userAgent = intent?.getSerializableExtraCompat<MangaSource>(EXTRA_SOURCE)?.let { source ->
val repository = mangaRepositoryFactory.create(source) as? RemoteMangaRepository
repository?.headers?.get(CommonHeaders.USER_AGENT)
with(viewBinding.webView.settings) {
javaScriptEnabled = true
userAgentString = UserAgents.CHROME_MOBILE
}
viewBinding.webView.configureForParser(userAgent)
CookieManager.getInstance().setAcceptThirdPartyCookies(viewBinding.webView, true)
viewBinding.webView.webViewClient = BrowserClient(this)
viewBinding.webView.webChromeClient = ProgressChromeClient(viewBinding.progressBar)
@@ -65,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 {
super.onCreateOptionsMenu(menu)
menuInflater.inflate(R.menu.opt_browser, menu)
@@ -79,14 +81,11 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
}
R.id.action_browser -> {
val url = viewBinding.webView.url?.toUriOrNull()
if (url != null) {
val intent = Intent(Intent.ACTION_VIEW)
intent.data = url
try {
startActivity(Intent.createChooser(intent, item.title))
} catch (_: ActivityNotFoundException) {
}
val intent = Intent(Intent.ACTION_VIEW)
intent.data = Uri.parse(viewBinding.webView.url)
try {
startActivity(Intent.createChooser(intent, item.title))
} catch (_: ActivityNotFoundException) {
}
true
}
@@ -137,13 +136,11 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
companion object {
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)
.setData(Uri.parse(url))
.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.util.ext.checkNotificationPermission
import org.koitharu.kotatsu.parsers.model.ContentType
import org.koitharu.kotatsu.parsers.model.MangaSource
class CaptchaNotifier(
private val context: Context,
) : EventListener {
fun notify(exception: CloudFlareProtectedException) {
if (!context.checkNotificationPermission(CHANNEL_ID)) {
if (!context.checkNotificationPermission()) {
return
}
val manager = NotificationManagerCompat.from(context)
@@ -59,27 +58,16 @@ class CaptchaNotifier(
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) {
super.onError(request, result)
val e = result.throwable
if (e is CloudFlareProtectedException && request.parameters.value<Boolean>(PARAM_IGNORE_CAPTCHA) != true) {
if (e is CloudFlareProtectedException) {
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 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.ui.BaseActivity
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.parsers.network.UserAgents
import javax.inject.Inject
import com.google.android.material.R as materialR
@@ -40,12 +41,17 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
@Inject
lateinit var cookieJar: MutableCookieJar
private lateinit var cfClient: CloudFlareClient
private var onBackPressedCallback: WebViewBackPressedCallback? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (!setContentViewWebViewSafe { ActivityBrowserBinding.inflate(layoutInflater) }) {
if (!catchingWebViewUnavailability {
setContentView(
ActivityBrowserBinding.inflate(
layoutInflater
)
)
}) {
return
}
supportActionBar?.run {
@@ -53,9 +59,13 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
setHomeAsUpIndicator(materialR.drawable.abc_ic_clear_material)
}
val url = intent?.dataString.orEmpty()
cfClient = CloudFlareClient(cookieJar, this, url)
viewBinding.webView.configureForParser(intent?.getStringExtra(ARG_UA))
viewBinding.webView.webViewClient = cfClient
with(viewBinding.webView.settings) {
javaScriptEnabled = true
domStorageEnabled = true
databaseEnabled = true
userAgentString = intent?.getStringExtra(ARG_UA) ?: UserAgents.CHROME_MOBILE
}
viewBinding.webView.webViewClient = CloudFlareClient(cookieJar, this, url)
onBackPressedCallback = WebViewBackPressedCallback(viewBinding.webView).also {
onBackPressedDispatcher.addCallback(it)
}
@@ -72,15 +82,23 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
}
override fun onDestroy() {
runCatching {
viewBinding.webView
}.onSuccess {
it.stopLoading()
it.destroy()
viewBinding.webView.run {
stopLoading()
destroy()
}
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 {
menuInflater.inflate(R.menu.opt_captcha, menu)
return super.onCreateOptionsMenu(menu)
@@ -105,7 +123,15 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
}
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
}
@@ -131,10 +157,6 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
viewBinding.progressBar.isInvisible = true
}
override fun onLoopDetected() {
restartCheck()
}
override fun onCheckPassed() {
pendingResult = RESULT_OK
finishAfterTransition()
@@ -154,23 +176,10 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
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) {
cookieJar.removeCookies(url) { cookie ->
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 onCheckPassed()
fun onLoopDetected()
}

View File

@@ -7,7 +7,6 @@ import org.koitharu.kotatsu.browser.BrowserClient
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
private const val CF_CLEARANCE = "cf_clearance"
private const val LOOP_COUNTER = 3
class CloudFlareClient(
private val cookieJar: MutableCookieJar,
@@ -16,7 +15,6 @@ class CloudFlareClient(
) : BrowserClient(callback) {
private val oldClearance = getClearance()
private var counter = 0
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
super.onPageStarted(view, url, favicon)
@@ -33,20 +31,10 @@ class CloudFlareClient(
callback.onPageLoaded()
}
fun reset() {
counter = 0
}
private fun checkClearance() {
val clearance = getClearance()
if (clearance != null && clearance != oldClearance) {
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.content.Context
import android.os.Build
import androidx.annotation.WorkerThread
import androidx.appcompat.app.AppCompatDelegate
import androidx.hilt.work.HiltWorkerFactory
@@ -12,7 +11,6 @@ import androidx.work.WorkManager
import dagger.hilt.android.HiltAndroidApp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.acra.ACRA
import org.acra.ReportField
import org.acra.config.dialog
@@ -20,7 +18,6 @@ import org.acra.config.httpSender
import org.acra.data.StringFormat
import org.acra.ktx.initAcra
import org.acra.sender.HttpSender
import org.conscrypt.Conscrypt
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R
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.ext.processLifecycleScope
import org.koitharu.kotatsu.settings.work.WorkScheduleManager
import java.security.Security
import javax.inject.Inject
import javax.inject.Provider
@@ -43,7 +39,7 @@ open class BaseApp : Application(), Configuration.Provider {
lateinit var activityLifecycleCallbacks: Set<@JvmSuppressWildcards ActivityLifecycleCallbacks>
@Inject
lateinit var database: Provider<MangaDatabase>
lateinit var database: MangaDatabase
@Inject
lateinit var settings: AppSettings
@@ -60,26 +56,12 @@ open class BaseApp : Application(), Configuration.Provider {
@Inject
lateinit var workManagerProvider: Provider<WorkManager>
override val workManagerConfiguration: Configuration
get() = Configuration.Builder()
.setWorkerFactory(workerFactory)
.build()
override fun onCreate() {
super.onCreate()
ACRA.errorReporter.putCustomData("isOriginalApp", appValidator.isOriginalApp.toString())
AppCompatDelegate.setDefaultNightMode(settings.theme)
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()
processLifecycleScope.launch {
val isOriginalApp = withContext(Dispatchers.Default) {
appValidator.isOriginalApp
}
ACRA.errorReporter.putCustomData("isOriginalApp", isOriginalApp.toString())
}
processLifecycleScope.launch(Dispatchers.Default) {
setupDatabaseObservers()
}
@@ -92,6 +74,13 @@ open class BaseApp : Application(), Configuration.Provider {
initAcra {
buildConfigClass = BuildConfig::class.java
reportFormat = StringFormat.JSON
excludeMatchingSharedPreferencesKeys = listOf(
"sources_\\w+",
AppSettings.KEY_APP_PASSWORD,
AppSettings.KEY_PROXY_LOGIN,
AppSettings.KEY_PROXY_ADDRESS,
AppSettings.KEY_PROXY_PASSWORD,
)
httpSender {
uri = getString(R.string.url_error_report)
basicAuthLogin = getString(R.string.acra_login)
@@ -108,6 +97,7 @@ open class BaseApp : Application(), Configuration.Provider {
ReportField.STACK_TRACE,
ReportField.CRASH_CONFIGURATION,
ReportField.CUSTOM_DATA,
ReportField.SHARED_PREFERENCES,
)
dialog {
@@ -120,9 +110,15 @@ open class BaseApp : Application(), Configuration.Provider {
}
}
override fun getWorkManagerConfiguration(): Configuration {
return Configuration.Builder()
.setWorkerFactory(workerFactory)
.build()
}
@WorkerThread
private fun setupDatabaseObservers() {
val tracker = database.get().invalidationTracker
val tracker = database.invalidationTracker
databaseObservers.forEach {
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
class BackupEntry(
val name: Name,
val name: String,
val data: JSONArray
) {
enum class Name(
val key: String,
) {
companion object Names {
INDEX("index"),
HISTORY("history"),
CATEGORIES("categories"),
FAVOURITES("favourites"),
SETTINGS("settings"),
BOOKMARKS("bookmarks"),
SOURCES("sources"),
const val INDEX = "index"
const val HISTORY = "history"
const val CATEGORIES = "categories"
const val FAVOURITES = "favourites"
const val SETTINGS = "settings"
const val BOOKMARKS = "bookmarks"
}
}

View File

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

View File

@@ -1,44 +1,25 @@
package org.koitharu.kotatsu.core.backup
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.runInterruptible
import okio.Closeable
import org.json.JSONArray
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
import java.io.File
import java.util.EnumSet
import java.util.zip.ZipFile
class BackupZipInput(val file: File) : Closeable {
private val zipFile = ZipFile(file)
suspend fun getEntry(name: BackupEntry.Name): BackupEntry? = runInterruptible(Dispatchers.IO) {
val entry = zipFile.getEntry(name.key) ?: return@runInterruptible null
suspend fun getEntry(name: String): BackupEntry? = runInterruptible(Dispatchers.IO) {
val entry = zipFile.getEntry(name) ?: return@runInterruptible null
val json = zipFile.getInputStream(entry).use {
JSONArray(it.bufferedReader().readText())
}
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() {
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 okio.Closeable
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ext.format
import org.koitharu.kotatsu.core.zip.ZipOutput
import java.io.File
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import java.util.Date
import java.util.Locale
import java.util.zip.Deflater
@@ -17,7 +17,7 @@ class BackupZipOutput(val file: File) : Closeable {
private val output = ZipOutput(file, Deflater.BEST_COMPRESSION)
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) {
@@ -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) {
val dir = context.run {
@@ -39,7 +39,7 @@ suspend fun BackupZipOutput(context: Context): BackupZipOutput = runInterruptibl
val filename = buildString {
append(context.getString(R.string.app_name).replace(' ', '_').lowercase(Locale.ROOT))
append('_')
append(LocalDate.now().format(DateTimeFormatter.ofPattern("ddMMyyyy")))
append(Date().format("ddMMyyyy"))
append(".bk.zip")
}
BackupZipOutput(File(dir, filename))

View File

@@ -3,7 +3,6 @@ package org.koitharu.kotatsu.core.backup
import org.json.JSONObject
import org.koitharu.kotatsu.bookmarks.data.BookmarkEntity
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.favourites.data.FavouriteCategoryEntity
import org.koitharu.kotatsu.favourites.data.FavouriteEntity
@@ -54,7 +53,6 @@ class JsonDeserializer(private val json: JSONObject) {
page = json.getInt("page"),
scroll = json.getDouble("scroll").toFloat(),
percent = json.getFloatOrDefault("percent", -1f),
chaptersCount = json.getIntOrDefault("chapters", -1),
deletedAt = 0L,
)
@@ -80,12 +78,6 @@ class JsonDeserializer(private val json: JSONObject) {
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?> {
val map = mutableMapOf<String, Any?>()
val keys = json.keys()

View File

@@ -3,7 +3,6 @@ package org.koitharu.kotatsu.core.backup
import org.json.JSONObject
import org.koitharu.kotatsu.bookmarks.data.BookmarkEntity
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.favourites.data.FavouriteCategoryEntity
import org.koitharu.kotatsu.favourites.data.FavouriteEntity
@@ -41,7 +40,6 @@ class JsonSerializer private constructor(private val json: JSONObject) {
put("page", e.page)
put("scroll", e.scroll)
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(
JSONObject(m),
)

View File

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

View File

@@ -7,14 +7,12 @@ class ExpiringLruCache<T>(
val maxSize: Int,
private val lifetime: Long,
private val timeUnit: TimeUnit,
) : Iterable<ContentCache.Key> {
) {
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? {
val value = cache[key] ?: return null
val value = cache.get(key) ?: return null
if (value.isExpired) {
cache.remove(key)
}
@@ -32,8 +30,4 @@ class ExpiringLruCache<T>(
fun trimToSize(size: Int) {
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
}
override fun clear(source: MangaSource) {
clearCache(detailsCache, source)
clearCache(pagesCache, source)
clearCache(relatedMangaCache, source)
}
override fun onConfigurationChanged(newConfig: Configuration) = Unit
override fun onLowMemory() = Unit
@@ -73,12 +67,4 @@ class MemoryContentCache(application: Application) : ContentCache, ComponentCall
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 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.Migration15To16
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.Migration2To3
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.scrobbling.common.data.ScrobblingDao
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.SuggestionEntity
import org.koitharu.kotatsu.tracker.data.TrackEntity
import org.koitharu.kotatsu.tracker.data.TrackLogEntity
import org.koitharu.kotatsu.tracker.data.TracksDao
const val DATABASE_VERSION = 19
const val DATABASE_VERSION = 17
@Database(
entities = [
MangaEntity::class, TagEntity::class, HistoryEntity::class, MangaTagsEntity::class,
FavouriteCategoryEntity::class, FavouriteEntity::class, MangaPrefsEntity::class,
TrackEntity::class, TrackLogEntity::class, SuggestionEntity::class, BookmarkEntity::class,
ScrobblingEntity::class, MangaSourceEntity::class, StatsEntity::class,
ScrobblingEntity::class, MangaSourceEntity::class,
],
version = DATABASE_VERSION,
)
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 fun getStatsDao(): StatsDao
abstract val sourcesDao: MangaSourcesDao
}
fun getDatabaseMigrations(context: Context): Array<Migration> = arrayOf(
@@ -114,8 +108,6 @@ fun getDatabaseMigrations(context: Context): Array<Migration> = arrayOf(
Migration14To15(),
Migration15To16(),
Migration16To17(context),
Migration17To18(),
Migration18To19(),
)
fun MangaDatabase(context: Context): MangaDatabase = Room

View File

@@ -1,7 +1,6 @@
package org.koitharu.kotatsu.core.db.dao
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
@@ -24,10 +23,6 @@ abstract class MangaDao {
@Query("SELECT * FROM manga WHERE public_url = :publicUrl")
abstract suspend fun findByPublicUrl(publicUrl: String): MangaWithTags?
@Transaction
@Query("SELECT * FROM manga WHERE source = :source")
abstract suspend fun findAllBySource(source: String): List<MangaWithTags>
@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")
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")
abstract suspend fun clearTagRelation(mangaId: Long)
@Transaction
@Delete
abstract suspend fun delete(subjects: Collection<MangaEntity>)
@Transaction
open suspend fun upsert(manga: MangaEntity, tags: Iterable<TagEntity>? = null) {
upsert(manga)

View File

@@ -4,15 +4,10 @@ import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.RawQuery
import androidx.room.Transaction
import androidx.room.Upsert
import androidx.sqlite.db.SimpleSQLiteQuery
import androidx.sqlite.db.SupportSQLiteQuery
import kotlinx.coroutines.flow.Flow
import org.intellij.lang.annotations.Language
import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity
import org.koitharu.kotatsu.explore.data.SourcesSortOrder
@Dao
abstract class MangaSourcesDao {
@@ -20,18 +15,15 @@ abstract class MangaSourcesDao {
@Query("SELECT * FROM sources ORDER BY sort_key")
abstract suspend fun findAll(): List<MangaSourceEntity>
@Query("SELECT * FROM sources WHERE enabled = 0 ORDER BY sort_key")
abstract suspend fun findAllDisabled(): List<MangaSourceEntity>
@Query("SELECT * FROM sources WHERE enabled = 1 ORDER BY sort_key")
abstract suspend fun findAllEnabled(): List<MangaSourceEntity>
@Query("SELECT * FROM sources WHERE enabled = 0")
abstract fun observeDisabled(): Flow<List<MangaSourceEntity>>
@Query("SELECT * FROM sources WHERE enabled = 1 ORDER BY sort_key")
abstract fun observeEnabled(): Flow<List<MangaSourceEntity>>
@Query("SELECT * FROM sources ORDER BY sort_key")
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")
abstract suspend fun getMaxSortKey(): Int
@@ -48,22 +40,6 @@ abstract class MangaSourcesDao {
@Upsert
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
open suspend fun setEnabled(source: String, isEnabled: Boolean) {
if (updateIsEnabled(source, isEnabled) == 0) {
@@ -78,16 +54,4 @@ abstract class MangaSourcesDao {
@Query("UPDATE sources SET enabled = :isEnabled WHERE source = :source")
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>
@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(
"""SELECT tags.* FROM tags
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_contrast") val cfContrast: Float,
@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) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL(
"""
CREATE TABLE IF NOT EXISTS `bookmarks` (
`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 )
""".trimIndent()
)
db.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_manga_id` ON `bookmarks` (`manga_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) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL(
"""
CREATE TABLE IF NOT EXISTS `scrobblings` (
`scrobbler` INTEGER NOT NULL,
@@ -21,7 +21,7 @@ class Migration11To12 : Migration(11, 12) {
)
""".trimIndent()
)
db.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 history 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) {
override fun migrate(db: SupportSQLiteDatabase) {
db.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")
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE favourite_categories ADD COLUMN `show_in_lib` INTEGER NOT NULL DEFAULT 1")
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) {
override fun migrate(db: SupportSQLiteDatabase) {
db.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")
db.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")
db.execSQL("ALTER TABLE preferences ADD COLUMN `cf_contrast` REAL NOT NULL DEFAULT 0")
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE favourite_categories ADD COLUMN `deleted_at` INTEGER NOT NULL DEFAULT 0")
database.execSQL("ALTER TABLE favourites ADD COLUMN `deleted_at` INTEGER NOT NULL DEFAULT 0")
database.execSQL("ALTER TABLE history ADD COLUMN `deleted_at` INTEGER NOT NULL DEFAULT 0")
database.execSQL("ALTER TABLE preferences ADD COLUMN `cf_brightness` 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) {
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) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE preferences ADD COLUMN `cf_invert` INTEGER NOT NULL DEFAULT 0")
override fun migrate(database: SupportSQLiteDatabase) {
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)
override fun migrate(db: SupportSQLiteDatabase) {
db.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`)")
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("CREATE TABLE `sources` (`source` TEXT NOT NULL, `enabled` INTEGER NOT NULL, `sort_key` INTEGER NOT NULL, PRIMARY KEY(`source`))")
database.execSQL("CREATE INDEX `index_sources_sort_key` ON `sources` (`sort_key`)")
val hiddenSources = prefs.getStringSet("sources_hidden", null).orEmpty()
val order = prefs.getString("sources_order_2", null)?.split('|').orEmpty()
val sources = MangaSource.entries
@@ -30,7 +30,7 @@ class Migration16To17(context: Context) : Migration(16, 17) {
continue
}
}
db.execSQL(
database.execSQL(
"INSERT INTO `sources` (`source`, `enabled`, `sort_key`) VALUES (?, ?, ?)",
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
*/
override fun migrate(db: SupportSQLiteDatabase) {
override fun migrate(database: SupportSQLiteDatabase) {
/* manga_tags */
db.execSQL(
database.execSQL(
"CREATE TABLE IF NOT EXISTS manga_tags_tmp (manga_id INTEGER NOT NULL, tag_id INTEGER NOT NULL, " +
"PRIMARY KEY(manga_id, tag_id), " +
"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 )"
)
db.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)")
db.execSQL("INSERT INTO manga_tags_tmp (manga_id, tag_id) SELECT manga_id, tag_id FROM manga_tags")
db.execSQL("DROP TABLE manga_tags")
db.execSQL("ALTER TABLE manga_tags_tmp RENAME TO manga_tags")
database.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_tag_id ON manga_tags_tmp (tag_id)")
database.execSQL("INSERT INTO manga_tags_tmp (manga_id, tag_id) SELECT manga_id, tag_id FROM manga_tags")
database.execSQL("DROP TABLE manga_tags")
database.execSQL("ALTER TABLE manga_tags_tmp RENAME TO manga_tags")
/* 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, " +
"PRIMARY KEY(manga_id, category_id), " +
"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 )"
)
db.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)")
db.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")
db.execSQL("ALTER TABLE favourites_tmp RENAME TO favourites")
database.execSQL("CREATE INDEX IF NOT EXISTS index_favourites_manga_id ON favourites_tmp (manga_id)")
database.execSQL("CREATE INDEX IF NOT EXISTS index_favourites_category_id ON favourites_tmp (category_id)")
database.execSQL("INSERT INTO favourites_tmp (manga_id, category_id, created_at) SELECT manga_id, category_id, created_at FROM favourites")
database.execSQL("DROP TABLE favourites")
database.execSQL("ALTER TABLE favourites_tmp RENAME TO favourites")
/* 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, " +
"PRIMARY KEY(manga_id), " +
"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")
db.execSQL("DROP TABLE history")
db.execSQL("ALTER TABLE history_tmp RENAME TO 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")
database.execSQL("DROP TABLE history")
database.execSQL("ALTER TABLE history_tmp RENAME TO history")
/* preferences */
db.execSQL(
database.execSQL(
"CREATE TABLE IF NOT EXISTS preferences_tmp (manga_id INTEGER NOT NULL, mode INTEGER NOT NULL," +
" PRIMARY KEY(manga_id), " +
"FOREIGN KEY(manga_id) REFERENCES manga(manga_id) ON UPDATE NO ACTION ON DELETE CASCADE )"
)
db.execSQL("INSERT INTO preferences_tmp (manga_id, mode) SELECT manga_id, mode FROM preferences")
db.execSQL("DROP TABLE preferences")
db.execSQL("ALTER TABLE preferences_tmp RENAME TO preferences")
database.execSQL("INSERT INTO preferences_tmp (manga_id, mode) SELECT manga_id, mode FROM preferences")
database.execSQL("DROP TABLE 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) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE history ADD COLUMN scroll REAL NOT NULL DEFAULT 0")
override fun migrate(database: SupportSQLiteDatabase) {
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) {
override fun migrate(db: 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 )")
override fun migrate(database: SupportSQLiteDatabase) {
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) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE favourite_categories ADD COLUMN sort_key INTEGER NOT NULL DEFAULT 0")
override fun migrate(database: SupportSQLiteDatabase) {
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) {
override fun migrate(db: 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)")
db.execSQL("CREATE INDEX IF NOT EXISTS index_track_logs_manga_id ON track_logs (manga_id)")
override fun migrate(database: SupportSQLiteDatabase) {
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)")
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) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE manga ADD COLUMN public_url TEXT NOT NULL DEFAULT ''")
override fun migrate(database: SupportSQLiteDatabase) {
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) {
override fun migrate(db: SupportSQLiteDatabase) {
db.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 )")
db.execSQL("CREATE INDEX IF NOT EXISTS index_suggestions_manga_id ON suggestions (manga_id)")
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE manga ADD COLUMN nsfw INTEGER NOT NULL DEFAULT 0")
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 )")
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) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE favourite_categories ADD COLUMN `order` TEXT NOT NULL DEFAULT ${SortOrder.NEWEST.name}")
override fun migrate(database: SupportSQLiteDatabase) {
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) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE favourite_categories ADD COLUMN `track` INTEGER NOT NULL DEFAULT 1")
override fun migrate(database: SupportSQLiteDatabase) {
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
import okio.IOException
import java.time.Instant
import java.time.temporal.ChronoUnit
import java.util.Date
class TooManyRequestExceptions(
val url: String,
val retryAt: Instant?,
val retryAt: Date?,
) : IOException() {
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>?,
) : FlowCollector<Throwable> {
protected open val activity = host.context.findActivity()
protected val activity = host.context.findActivity()
private val lifecycleScope: LifecycleCoroutineScope
get() = checkNotNull(fragment?.viewLifecycleScope ?: (activity as? LifecycleOwner)?.lifecycle?.coroutineScope)
@@ -36,7 +36,7 @@ abstract class ErrorObserver(
private fun isAlive(): Boolean {
return when {
fragment != null -> fragment.view != null
activity != null -> activity?.isDestroyed == false
activity != null -> !activity.isDestroyed
else -> true
}
}

View File

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

View File

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

View File

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

View File

@@ -1,22 +1,13 @@
package org.koitharu.kotatsu.core.model
import android.net.Uri
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.collection.MutableObjectIntMap
import androidx.core.os.LocaleListCompat
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ext.iterator
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.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaState
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")
fun Collection<Manga>.ids() = mapToSet { it.id }
@@ -32,44 +23,14 @@ fun Collection<ChapterListItem>.countChaptersByBranch(): Int {
if (size <= 1) {
return size
}
val acc = MutableObjectIntMap<String?>()
val acc = HashMap<String?, Int>()
for (item in this) {
val branch = item.chapter.branch
acc[branch] = acc.getOrDefault(branch, 0) + 1
acc[branch] = (acc[branch] ?: 0) + 1
}
var max = 0
acc.forEachValue { x -> if (x > max) max = x }
return max
return acc.values.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? {
return chapters?.findById(id)
}
@@ -118,32 +79,3 @@ val Manga.appUrl: Uri
.appendQueryParameter("name", title)
.appendQueryParameter("url", url)
.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,14 +2,14 @@ package org.koitharu.kotatsu.core.model
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
import java.time.Instant
import java.util.*
@Parcelize
data class MangaHistory(
val createdAt: Instant,
val updatedAt: Instant,
val createdAt: Date,
val updatedAt: Date,
val chapterId: Long,
val page: Int,
val scroll: Int,
val percent: Float,
) : Parcelable
) : Parcelable

View File

@@ -1,23 +1,14 @@
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.MangaSource
import org.koitharu.kotatsu.parsers.util.toTitleCase
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 {
MangaSource.entries.forEach {
@@ -27,36 +18,3 @@ fun MangaSource(name: String): MangaSource {
}
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(
id = parcel.readLong(),
name = parcel.readString().orEmpty(),
number = parcel.readFloat(),
volume = parcel.readInt(),
number = parcel.readInt(),
url = parcel.readString().orEmpty(),
scanlator = parcel.readString(),
uploadDate = parcel.readLong(),
@@ -32,8 +31,7 @@ data class ParcelableChapter(
override fun ParcelableChapter.write(parcel: Parcel, flags: Int) = with(chapter) {
parcel.writeLong(id)
parcel.writeString(name)
parcel.writeFloat(number)
parcel.writeInt(volume)
parcel.writeInt(number)
parcel.writeString(url)
parcel.writeString(scanlator)
parcel.writeLong(uploadDate)

View File

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

View File

@@ -7,11 +7,11 @@ import okhttp3.Interceptor
import okhttp3.Request
import okhttp3.Response
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.RemoteMangaRepository
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.network.UserAgents
import org.koitharu.kotatsu.parsers.util.mergeWith
import java.net.IDN
import javax.inject.Inject
@@ -20,7 +20,6 @@ import javax.inject.Singleton
@Singleton
class CommonHeadersInterceptor @Inject constructor(
private val mangaRepositoryFactoryLazy: Lazy<MangaRepository.Factory>,
private val mangaLoaderContextLazy: Lazy<MangaLoaderContextImpl>,
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
@@ -39,7 +38,7 @@ class CommonHeadersInterceptor @Inject constructor(
headersBuilder.mergeWith(it, replaceExisting = false)
}
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) {
val idn = IDN.toASCII(repository.domain)

View File

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

View File

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

View File

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

View File

@@ -4,11 +4,15 @@ import okhttp3.Interceptor
import okhttp3.Response
import okhttp3.internal.closeQuietly
import org.koitharu.kotatsu.core.exceptions.TooManyRequestExceptions
import java.time.Instant
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.concurrent.TimeUnit
class RateLimitInterceptor : Interceptor {
private val dateFormat = SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss ZZZ", Locale.ENGLISH)
override fun intercept(chain: Interceptor.Chain): Response {
val response = chain.proceed(chain.request())
if (response.code == 429) {
@@ -23,8 +27,10 @@ class RateLimitInterceptor : Interceptor {
return response
}
private fun String.parseRetryDate(): Instant? {
return toLongOrNull()?.let { Instant.now().plusSeconds(it) }
?: ZonedDateTime.parse(this, DateTimeFormatter.RFC_1123_DATE_TIME).toInstant()
private fun String.parseRetryDate(): Date? {
toIntOrNull()?.let {
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
import android.annotation.SuppressLint
import android.content.Context
import android.content.pm.PackageManager
import androidx.core.content.pm.PackageInfoCompat
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.Singleton
@@ -11,13 +18,29 @@ import javax.inject.Singleton
class AppValidator @Inject constructor(
@ApplicationContext private val context: Context,
) {
@Suppress("NewApi")
val isOriginalApp by lazy {
val certificates = mapOf(CERT_SHA256.hexToByteArray() to PackageManager.CERT_INPUT_SHA256)
PackageInfoCompat.hasSignatures(context.packageManager, context.packageName, certificates, false)
getCertificateSHA1Fingerprint() == CERT_SHA1
}
@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 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
import androidx.core.net.toUri
import androidx.room.withTransaction
import dagger.Reusable
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.toManga
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.util.ext.toFileOrNull
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaTag
@@ -31,16 +28,16 @@ class MangaDataRepository @Inject constructor(
suspend fun saveReaderMode(manga: Manga, mode: ReaderMode) {
db.withTransaction {
storeManga(manga)
val entity = db.getPreferencesDao().find(manga.id) ?: newEntity(manga.id)
db.getPreferencesDao().upsert(entity.copy(mode = mode.id))
val entity = db.preferencesDao.find(manga.id) ?: newEntity(manga.id)
db.preferencesDao.upsert(entity.copy(mode = mode.id))
}
}
suspend fun saveColorFilter(manga: Manga, colorFilter: ReaderColorFilter?) {
db.withTransaction {
storeManga(manga)
val entity = db.getPreferencesDao().find(manga.id) ?: newEntity(manga.id)
db.getPreferencesDao().upsert(
val entity = db.preferencesDao.find(manga.id) ?: newEntity(manga.id)
db.preferencesDao.upsert(
entity.copy(
cfBrightness = colorFilter?.brightness ?: 0f,
cfContrast = colorFilter?.contrast ?: 0f,
@@ -51,25 +48,25 @@ class MangaDataRepository @Inject constructor(
}
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? {
return db.getPreferencesDao().find(mangaId)?.getColorFilterOrNull()
return db.preferencesDao.find(mangaId)?.getColorFilterOrNull()
}
fun observeColorFilter(mangaId: Long): Flow<ReaderColorFilter?> {
return db.getPreferencesDao().observe(mangaId)
return db.preferencesDao.observe(mangaId)
.map { it?.getColorFilterOrNull() }
.distinctUntilChanged()
}
suspend fun findMangaById(mangaId: Long): Manga? {
return db.getMangaDao().find(mangaId)?.toManga()
return db.mangaDao.find(mangaId)?.toManga()
}
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 {
@@ -80,37 +77,20 @@ class MangaDataRepository @Inject constructor(
}
suspend fun storeManga(manga: Manga) {
val tags = manga.tags.toEntities()
db.withTransaction {
// avoid storing local manga if remote one is already stored
val existing = if (manga.isLocal) {
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)
}
db.tagsDao.upsert(tags)
db.mangaDao.upsert(manga.toEntity(), tags)
}
}
suspend fun findTags(source: MangaSource): Set<MangaTag> {
return db.getTagsDao().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 })
}
return db.tagsDao.findTags(source.name).toMangaTags()
}
private fun MangaPrefsEntity.getColorFilterOrNull(): ReaderColorFilter? {
return if (cfBrightness != 0f || cfContrast != 0f || cfInvert || cfGrayscale) {
ReaderColorFilter(cfBrightness, cfContrast, cfInvert, cfGrayscale)
return if (cfBrightness != 0f || cfContrast != 0f || cfInvert) {
ReaderColorFilter(cfBrightness, cfContrast, cfInvert)
} else {
null
}
@@ -122,6 +102,5 @@ class MangaDataRepository @Inject constructor(
cfBrightness = 0f,
cfContrast = 0f,
cfInvert = false,
cfGrayscale = false,
)
}

View File

@@ -43,7 +43,5 @@ class MangaIntent private constructor(
const val KEY_MANGA = "manga"
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
import android.net.Uri
import coil.request.CachePolicy
import dagger.Reusable
import org.koitharu.kotatsu.core.model.MangaSource
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.model.ContentType
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.almostEquals
import org.koitharu.kotatsu.parsers.util.levenshteinDistance
@@ -25,14 +23,14 @@ class MangaLinkResolver @Inject constructor(
) {
suspend fun resolve(uri: Uri): Manga {
return if (uri.scheme == "kotatsu" || uri.host == "kotatsu.app") {
return if (uri.host == "kotatsu.app") {
resolveAppLink(uri)
} else {
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" }
val sourceName = requireNotNull(uri.getQueryParameter("source")) { "Source is not specified" }
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 {
return it
}
@@ -60,7 +58,7 @@ class MangaLinkResolver @Inject constructor(
private suspend fun MangaRepository.findExact(url: String?, title: String?): Manga? {
if (!title.isNullOrEmpty()) {
val list = getList(0, MangaListFilter.Search(title))
val list = getList(0, title)
if (url != null) {
list.find { it.url == url }?.let {
return it
@@ -79,14 +77,14 @@ class MangaLinkResolver @Inject constructor(
}.ifNullOrEmpty {
seed.author
} ?: return@runCatchingCancellable null
val seedList = getList(0, MangaListFilter.Search(seedTitle))
val seedList = getList(0, seedTitle)
seedList.first { x -> x.url == url }
}.getOrThrow()
}
private suspend fun MangaRepository.getDetailsNoCache(manga: Manga): Manga {
return if (this is RemoteMangaRepository) {
getDetails(manga, CachePolicy.READ_ONLY)
getDetails(manga, withCache = false)
} else {
getDetails(manga)
}

View File

@@ -4,25 +4,18 @@ import android.annotation.SuppressLint
import android.content.Context
import android.util.Base64
import android.webkit.WebView
import androidx.annotation.MainThread
import androidx.core.os.LocaleListCompat
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import org.koitharu.kotatsu.core.network.MangaHttpClient
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
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.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.config.MangaSourceConfig
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.util.Locale
import javax.inject.Inject
@@ -39,15 +32,12 @@ class MangaLoaderContextImpl @Inject constructor(
private var webViewCached: WeakReference<WebView>? = null
private val userAgentLazy = SuspendLazy {
withContext(Dispatchers.Main) {
obtainWebView().settings.userAgentString
}.sanitizeHeaderValue()
}
@SuppressLint("SetJavaScriptEnabled")
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 ->
webView.evaluateJavascript(script) { result ->
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 {
return SourceSettings(androidContext, source)
}
@@ -78,12 +60,4 @@ class MangaLoaderContextImpl @Inject constructor(
override fun getPreferredLocales(): List<Locale> {
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.local.data.LocalMangaRepository
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.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaPage
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.SortOrder
import java.lang.ref.WeakReference
import java.util.EnumMap
import java.util.Locale
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.collections.set
@@ -27,19 +23,11 @@ interface MangaRepository {
val sortOrders: Set<SortOrder>
val states: Set<MangaState>
val contentRatings: Set<ContentRating>
var defaultSortOrder: SortOrder
val isMultipleTagsSupported: Boolean
suspend fun getList(offset: Int, query: String): List<Manga>
val isTagsExclusionSupported: Boolean
val isSearchSupported: Boolean
suspend fun getList(offset: Int, filter: MangaListFilter?): List<Manga>
suspend fun getList(offset: Int, tags: Set<MangaTag>?, sortOrder: SortOrder?): List<Manga>
suspend fun getDetails(manga: Manga): Manga
@@ -49,8 +37,6 @@ interface MangaRepository {
suspend fun getTags(): Set<MangaTag>
suspend fun getLocales(): Set<Locale>
suspend fun getRelated(seed: Manga): List<Manga>
@Singleton

View File

@@ -1,8 +1,6 @@
package org.koitharu.kotatsu.core.parser
import android.util.Log
import androidx.collection.MutableLongSet
import coil.request.CachePolicy
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -22,19 +20,15 @@ import org.koitharu.kotatsu.parsers.MangaParser
import org.koitharu.kotatsu.parsers.MangaParserAuthProvider
import org.koitharu.kotatsu.parsers.config.ConfigKey
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.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.util.domain
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import java.util.Locale
class RemoteMangaRepository(
private val parser: MangaParser,
@@ -46,13 +40,7 @@ class RemoteMangaRepository(
get() = parser.source
override val sortOrders: Set<SortOrder>
get() = parser.availableSortOrders
override val states: Set<MangaState>
get() = parser.availableStates
override val contentRatings: Set<ContentRating>
get() = parser.availableContentRating
get() = parser.sortOrders
override var defaultSortOrder: SortOrder
get() = getConfig().defaultSortOrder ?: sortOrders.first()
@@ -60,15 +48,6 @@ class RemoteMangaRepository(
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
get() = parser.domain
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 {
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> {
cache.getPages(source, chapter.url)?.let { return it }
@@ -113,11 +98,7 @@ class RemoteMangaRepository(
}
override suspend fun getTags(): Set<MangaTag> = mirrorSwitchInterceptor.withMirrorSwitching {
parser.getAvailableTags()
}
override suspend fun getLocales(): Set<Locale> {
return parser.getAvailableLocales()
parser.getTags()
}
suspend fun getFavicons(): Favicons = mirrorSwitchInterceptor.withMirrorSwitching {
@@ -133,27 +114,22 @@ class RemoteMangaRepository(
return related.await()
}
suspend fun getDetails(manga: Manga, cachePolicy: CachePolicy): Manga {
if (cachePolicy.readEnabled) {
cache.getDetails(source, manga.url)?.let { return it }
suspend fun getDetails(manga: Manga, withCache: Boolean): Manga {
if (!withCache) {
return parser.getDetails(manga)
}
cache.getDetails(source, manga.url)?.let { return it }
val details = asyncSafe {
mirrorSwitchInterceptor.withMirrorSwitching {
parser.getDetails(manga)
}
}
if (cachePolicy.writeEnabled) {
cache.putDetails(source, manga.url, details)
}
cache.putDetails(source, manga.url, details)
return details.await()
}
suspend fun peekDetails(manga: Manga): Manga? {
return cache.getDetails(source, manga.url)
}
suspend fun find(manga: Manga): Manga? {
val list = getList(0, MangaListFilter.Search(manga.title))
val list = getList(0, manga.title)
return list.find { x -> x.id == manga.id }
}
@@ -167,15 +143,7 @@ class RemoteMangaRepository(
return parser.configKeyDomain.presetValues.toList()
}
fun isSlowdownEnabled(): Boolean {
return getConfig().isSlowdownEnabled
}
fun invalidateCache() {
cache.clear(source)
}
fun getConfig() = parser.config as SourceSettings
private fun getConfig() = parser.config as SourceSettings
private suspend fun <T> asyncSafe(block: suspend CoroutineScope.() -> T): SafeDeferred<T> {
var dispatcher = currentCoroutineContext()[CoroutineDispatcher.Key]
@@ -194,7 +162,7 @@ class RemoteMangaRepository(
return emptyList()
}
val result = ArrayList<MangaPage>(size)
val set = MutableLongSet(size)
val set = HashSet<Long>(size)
for (page in this) {
if (set.add(page.id)) {
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.core.content.edit
import androidx.core.os.LocaleListCompat
import androidx.documentfile.provider.DocumentFile
import androidx.preference.PreferenceManager
import dagger.hilt.android.qualifiers.ApplicationContext
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.takeIfReadable
import org.koitharu.kotatsu.core.util.ext.toUriOrNull
import org.koitharu.kotatsu.explore.data.SourcesSortOrder
import org.koitharu.kotatsu.list.domain.ListSortOrder
import org.koitharu.kotatsu.history.domain.model.HistoryOrder
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.util.find
import org.koitharu.kotatsu.parsers.util.mapNotNullToSet
import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.reader.domain.ReaderColorFilter
import java.io.File
import java.net.Proxy
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
get() = prefs.getInt(KEY_GRID_SIZE, 100)
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
get() = prefs.getBoolean(KEY_DISABLE_NSFW, false)
set(value) = prefs.edit { putBoolean(KEY_DISABLE_NSFW, value) }
@@ -105,24 +87,14 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
}
}
var isReaderDoubleOnLandscape: Boolean
get() = prefs.getBoolean(KEY_READER_DOUBLE_PAGES, false)
set(value) = prefs.edit { putBoolean(KEY_READER_DOUBLE_PAGES, value) }
val isReaderVolumeButtonsEnabled: Boolean
get() = prefs.getBoolean(KEY_READER_VOLUME_BUTTONS, false)
val readerPageSwitch: Set<String>
get() = prefs.getStringSet(KEY_READER_SWITCHERS, null) ?: setOf(PAGE_SWITCH_TAPS)
val isReaderZoomButtonsEnabled: Boolean
get() = prefs.getBoolean(KEY_READER_ZOOM_BUTTONS, false)
val isReaderControlAlwaysLTR: Boolean
get() = prefs.getBoolean(KEY_READER_CONTROL_LTR, false)
val isReaderFullscreenEnabled: Boolean
get() = prefs.getBoolean(KEY_READER_FULLSCREEN, true)
val isReaderOptimizationEnabled: Boolean
get() = prefs.getBoolean(KEY_READER_OPTIMIZE, false)
val isReaderTapsAdaptive: Boolean
get() = !prefs.getBoolean(KEY_READER_TAPS_LTR, false)
var isTrafficWarningEnabled: Boolean
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)
set(value) = prefs.edit { putBoolean(KEY_INCOGNITO_MODE, value) }
var isChaptersReverse: Boolean
var chaptersReverse: Boolean
get() = prefs.getBoolean(KEY_REVERSE_CHAPTERS, false)
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
get() = prefs.getEnumValue(KEY_ZOOM_MODE, ZoomMode.FIT_CENTER)
@@ -195,13 +163,11 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
var appPassword: String?
get() = prefs.getString(KEY_APP_PASSWORD, null)
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
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) }
val isMirrorSwitchingAvailable: Boolean
get() = prefs.getBoolean(KEY_MIRROR_SWITCHING, false)
get() = prefs.getBoolean(KEY_MIRROR_SWITCHING, true)
val isExitConfirmationEnabled: Boolean
get() = prefs.getBoolean(KEY_EXIT_CONFIRM, false)
@@ -221,16 +187,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val isUnstableUpdatesAllowed: Boolean
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
get() {
if (isBackgroundNetworkRestricted()) {
@@ -241,17 +197,10 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
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
get() = prefs.getBoolean(KEY_SOURCES_GRID, false)
set(value) = prefs.edit { putBoolean(KEY_SOURCES_GRID, value) }
val isNewSourcesTipEnabled: Boolean
get() = prefs.getBoolean(KEY_SOURCES_NEW, true)
val isPagesNumbersEnabled: Boolean
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
get() = prefs.getBoolean(KEY_DOWNLOADS_WIFI, false)
val preferredDownloadFormat: DownloadFormat
get() = prefs.getEnumValue(KEY_DOWNLOADS_FORMAT, DownloadFormat.AUTOMATIC)
var isSuggestionsEnabled: Boolean
get() = prefs.getBoolean(KEY_SUGGESTIONS, false)
set(value) = prefs.edit { putBoolean(KEY_SUGGESTIONS, value) }
@@ -324,24 +273,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val isReaderKeepScreenOn: Boolean
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
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)
set(value) = prefs.edit { putEnumValue(KEY_LOCAL_LIST_ORDER, value) }
var historySortOrder: ListSortOrder
get() = prefs.getEnumValue(KEY_HISTORY_ORDER, ListSortOrder.LAST_READ)
var historySortOrder: HistoryOrder
get() = prefs.getEnumValue(KEY_HISTORY_ORDER, HistoryOrder.UPDATED)
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
get() = prefs.getBoolean(KEY_RELATED_MANGA, true)
val isWebtoonZoomEnable: Boolean
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)
var readerAutoscrollSpeed: Float
get() = prefs.getFloat(KEY_READER_AUTOSCROLL_SPEED, 0f)
@@ -413,31 +336,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
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 {
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) }
}
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) {
prefs.registerOnSharedPreferenceChangeListener(listener)
}
@@ -505,15 +394,13 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
companion object {
const val PAGE_SWITCH_TAPS = "taps"
const val PAGE_SWITCH_VOLUME_KEYS = "volume"
const val TRACK_HISTORY = "history"
const val TRACK_FAVOURITES = "favourites"
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_COLOR_THEME = "color_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_HTTP_CACHE_CLEAR = "http_cache_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_SEARCH_HISTORY_CLEAR = "search_history_clear"
const val KEY_UPDATES_FEED_CLEAR = "updates_feed_clear"
const val KEY_GRID_SIZE = "grid_size"
const val KEY_REMOTE_SOURCES = "remote_sources"
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_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_WIFI_ONLY = "tracker_wifi"
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_DETECT = "reader_mode_detect"
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_BIOMETRIC = "protect_app_bio"
const val KEY_APP_VERSION = "app_version"
const val KEY_ZOOM_MODE = "zoom_mode"
const val KEY_BACKUP = "backup"
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_READING_INDICATORS = "reading_indicators"
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_PAGES_NUMBERS = "pages_numbers"
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_ANILIST = "anilist"
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_FORMAT = "downloads_format"
const val KEY_ALL_FAVOURITES_VISIBLE = "all_favourites_visible"
const val KEY_DOH = "doh"
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_SCREEN_ON = "reader_screen_on"
const val KEY_SHORTCUTS = "dynamic_shortcuts"
const val KEY_READER_TAP_ACTIONS = "reader_tap_actions"
const val KEY_READER_OPTIMIZE = "reader_optimize"
const val KEY_READER_TAPS_LTR = "reader_taps_ltr"
const val KEY_LOCAL_LIST_ORDER = "local_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_OUT = "webtoon_zoom_out"
const val KEY_PREFETCH_CONTENT = "prefetch_content"
const val KEY_APP_LOCALE = "app_locale"
const val KEY_LOGGING_ENABLED = "logging"
const val KEY_LOGS_SHARE = "logs_share"
const val KEY_SOURCES_GRID = "sources_grid"
const val KEY_SOURCES_NEW = "sources_new"
const val KEY_UPDATES_UNSTABLE = "updates_unstable"
const val KEY_TIPS_CLOSED = "tips_closed"
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_RELATED_MANGA = "related_manga"
const val KEY_NAV_MAIN = "nav_main"
const val KEY_NAV_LABELS = "nav_labels"
const val KEY_32BIT_COLOR = "enhanced_colors"
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"
// About
const val KEY_APP_UPDATE = "app_update"
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.StringRes
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.list.ui.model.ListModel
enum class NavItem(
@IdRes val id: Int,
@StringRes val title: Int,
@DrawableRes val icon: Int,
) {
) : ListModel {
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),
@@ -20,6 +21,10 @@ enum class NavItem(
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) {
SUGGESTIONS -> settings.isSuggestionsEnabled
FEED -> settings.isTrackerEnabled

View File

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

View File

@@ -1,18 +1,17 @@
package org.koitharu.kotatsu.core.prefs
import android.content.Context
import android.content.SharedPreferences.OnSharedPreferenceChangeListener
import androidx.core.content.edit
import okhttp3.internal.isSensitiveHeader
import org.koitharu.kotatsu.core.util.ext.getEnumValue
import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty
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.MangaSourceConfig
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.SortOrder
private const val KEY_SORT_ORDER = "sort_order"
class SourceSettings(context: Context, source: MangaSource) : MangaSourceConfig {
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)
set(value) = prefs.edit { putEnumValue(KEY_SORT_ORDER, value) }
val isSlowdownEnabled: Boolean
get() = prefs.getBoolean(KEY_SLOWDOWN, false)
@Suppress("UNCHECKED_CAST")
override fun <T> get(key: ConfigKey<T>): T {
return when (key) {
is ConfigKey.UserAgent -> prefs.getString(key.key, key.defaultValue)
.ifNullOrEmpty { key.defaultValue }
.sanitizeHeaderValue()
is ConfigKey.UserAgent -> 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.SplitByTranslations -> prefs.getBoolean(key.key, key.defaultValue)
} as T
}
@@ -41,22 +33,7 @@ class SourceSettings(context: Context, source: MangaSource) : MangaSourceConfig
when (key) {
is ConfigKey.Domain -> putString(key.key, value as String?)
is ConfigKey.ShowSuspiciousContent -> putBoolean(key.key, value as Boolean)
is ConfigKey.UserAgent -> putString(key.key, (value as String?)?.sanitizeHeaderValue())
is ConfigKey.SplitByTranslations -> putBoolean(key.key, value as Boolean)
is ConfigKey.UserAgent -> putString(key.key, value as String?)
}
}
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.Bundle
import android.view.KeyEvent
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.annotation.CallSuper
import androidx.appcompat.app.AppCompatActivity
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.WindowInsetsDelegate
import org.koitharu.kotatsu.core.util.ext.getThemeColor
import org.koitharu.kotatsu.core.util.ext.isWebViewUnavailable
@Suppress("LeakingThis")
abstract class BaseActivity<B : ViewBinding> :
@@ -60,11 +59,11 @@ abstract class BaseActivity<B : ViewBinding> :
if (isAmoledTheme) {
setTheme(R.style.ThemeOverlay_Kotatsu_Amoled)
}
putDataToExtras(intent)
super.onCreate(savedInstanceState)
WindowCompat.setDecorFitsSystemWindows(window, false)
insetsDelegate.handleImeInsets = true
insetsDelegate.addInsetsListener(this)
putDataToExtras(intent)
}
override fun onPostCreate(savedInstanceState: Bundle?) {
@@ -97,10 +96,10 @@ abstract class BaseActivity<B : ViewBinding> :
insetsDelegate.onViewCreated(binding.root)
}
override fun onSupportNavigateUp(): Boolean {
dispatchNavigateUp()
return true
}
override fun onOptionsItemSelected(item: MenuItem) = if (item.itemId == android.R.id.home) {
onBackPressed()
true
} else super.onOptionsItemSelected(item)
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
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) {
ColorUtils.compositeColors(
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 {
ContextCompat.getColor(this, R.color.kotatsu_m3_background)
ContextCompat.getColor(this, R.color.kotatsu_secondaryContainer)
}
val insets = ViewCompat.getRootWindowInsets(viewBinding.root)
?.getInsets(WindowInsetsCompat.Type.systemBars()) ?: return
@@ -151,36 +150,10 @@ abstract class BaseActivity<B : ViewBinding> :
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?) {
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 {
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
}
}
// insetsControllerCompat.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
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.list.ui.ListModelDiffCallback
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 kotlin.coroutines.suspendCoroutine
@@ -29,23 +28,11 @@ open class BaseListAdapter<T : ListModel> : AsyncListDifferDelegationAdapter<T>(
return this
}
fun addListListener(listListener: ListListener<T>): BaseListAdapter<T> {
fun addListListener(listListener: ListListener<T>) {
differ.addListListener(listListener)
return this
}
fun removeListListener(listListener: ListListener<T>) {
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
import android.content.ActivityNotFoundException
import android.content.Intent
import android.os.Bundle
import android.view.View
import androidx.annotation.CallSuper
@@ -10,9 +8,7 @@ import androidx.core.graphics.Insets
import androidx.core.view.updatePadding
import androidx.preference.PreferenceFragmentCompat
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.util.RecyclerViewOwner
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?) {
(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