Compare commits

..

1 Commits

Author SHA1 Message Date
Koitharu
e4e4f18066 Move read button to bottom 2024-05-18 18:20:58 +03:00
329 changed files with 2343 additions and 6500 deletions

View File

@@ -2,4 +2,4 @@ blank_issues_enabled: false
contact_links: contact_links:
- name: ⚠️ Source issue - name: ⚠️ Source issue
url: https://github.com/KotatsuApp/kotatsu-parsers/issues/new url: https://github.com/KotatsuApp/kotatsu-parsers/issues/new
about: If you have a problem with a specific **manga source** or want to propose a new one, please open an issue in the kotatsu-parsers repository instead about: If you have troubles with a manga parser or want to propose new manga source, please open an issue in the kotatsu-parsers repository instead

View File

@@ -60,7 +60,7 @@ body:
attributes: attributes:
label: Acknowledgements label: Acknowledgements
options: options:
- label: This is not a duplicate of an existing issue. Please look through the list of [open issues](https://github.com/KotatsuApp/Kotatsu/issues) before creating a new one. - label: I have searched the existing issues and this is a new ticket, **NOT** a duplicate or related to another open issue.
required: true required: true
- label: This is not an issue with a specific manga source. Otherwise, you have to open an issue in the [parsers repository](https://github.com/KotatsuApp/kotatsu-parsers/issues/new/choose). - label: If this is an issue with a parser, I should be opening an issue in the [parsers repository](https://github.com/KotatsuApp/kotatsu-parsers/issues/new/choose).
required: true required: true

View File

@@ -20,5 +20,5 @@ body:
label: Acknowledgements label: Acknowledgements
description: Read this carefully, we will close and ignore your issue if you skimmed through this. description: Read this carefully, we will close and ignore your issue if you skimmed through this.
options: options:
- label: This is not a duplicate of an existing issue. Please look through the list of [open issues](https://github.com/KotatsuApp/Kotatsu/issues) before creating a new one. - label: I have searched the existing issues and this is a new ticket, **NOT** a duplicate or related to another open issue.
required: true required: true

1
.gitignore vendored
View File

@@ -25,4 +25,3 @@
.externalNativeBuild .externalNativeBuild
.cxx .cxx
/.idea/deviceManager.xml /.idea/deviceManager.xml
/.kotlin/

1
.idea/.gitignore generated vendored
View File

@@ -2,4 +2,3 @@
/shelf/ /shelf/
/workspace.xml /workspace.xml
/migrations.xml /migrations.xml
/runConfigurations.xml

View File

@@ -15,9 +15,9 @@ android {
defaultConfig { defaultConfig {
applicationId 'org.koitharu.kotatsu' applicationId 'org.koitharu.kotatsu'
minSdk = 21 minSdk = 21
targetSdk = 35 targetSdk = 34
versionCode = 659 versionCode = 642
versionName = '7.4.2' versionName = '7.0.1'
generatedDensities = [] generatedDensities = []
testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner' testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner'
ksp { ksp {
@@ -82,23 +82,22 @@ afterEvaluate {
} }
dependencies { dependencies {
//noinspection GradleDependency //noinspection GradleDependency
implementation('com.github.KotatsuApp:kotatsu-parsers:ca212ca692') { implementation('com.github.KotatsuApp:kotatsu-parsers:078b59b1e2') {
exclude group: 'org.json', module: 'json' exclude group: 'org.json', module: 'json'
} }
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.4' coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.4'
implementation 'org.jetbrains.kotlin:kotlin-stdlib:2.0.10-RC' implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.9.24'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0-RC' implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1'
implementation 'androidx.appcompat:appcompat:1.7.0' implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'androidx.core:core-ktx:1.13.1' implementation 'androidx.core:core-ktx:1.13.1'
implementation 'androidx.activity:activity-ktx:1.9.1' implementation 'androidx.activity:activity-ktx:1.9.0'
implementation 'androidx.fragment:fragment-ktx:1.8.2' implementation 'androidx.fragment:fragment-ktx:1.7.1'
implementation 'androidx.transition:transition-ktx:1.5.1' implementation 'androidx.collection:collection-ktx:1.4.0'
implementation 'androidx.collection:collection-ktx:1.4.3' implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.0'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.4' implementation 'androidx.lifecycle:lifecycle-service:2.8.0'
implementation 'androidx.lifecycle:lifecycle-service:2.8.4' implementation 'androidx.lifecycle:lifecycle-process:2.8.0'
implementation 'androidx.lifecycle:lifecycle-process:2.8.4'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4' implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0' implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
implementation 'androidx.recyclerview:recyclerview:1.3.2' implementation 'androidx.recyclerview:recyclerview:1.3.2'
@@ -106,10 +105,10 @@ dependencies {
implementation 'androidx.preference:preference-ktx:1.2.1' implementation 'androidx.preference:preference-ktx:1.2.1'
implementation 'androidx.biometric:biometric-ktx:1.2.0-alpha05' implementation 'androidx.biometric:biometric-ktx:1.2.0-alpha05'
implementation 'com.google.android.material:material:1.12.0' implementation 'com.google.android.material:material:1.12.0'
implementation 'androidx.lifecycle:lifecycle-common-java8:2.8.4' implementation 'androidx.lifecycle:lifecycle-common-java8:2.8.0'
implementation 'androidx.webkit:webkit:1.11.0' implementation 'androidx.webkit:webkit:1.11.0'
implementation 'androidx.work:work-runtime:2.9.1' implementation 'androidx.work:work-runtime:2.9.0'
//noinspection GradleDependency //noinspection GradleDependency
implementation('com.google.guava:guava:32.0.1-android') { implementation('com.google.guava:guava:32.0.1-android') {
exclude group: 'com.google.guava', module: 'failureaccess' exclude group: 'com.google.guava', module: 'failureaccess'
@@ -134,9 +133,9 @@ dependencies {
implementation 'androidx.hilt:hilt-work:1.2.0' implementation 'androidx.hilt:hilt-work:1.2.0'
kapt 'androidx.hilt:hilt-compiler:1.2.0' kapt 'androidx.hilt:hilt-compiler:1.2.0'
implementation 'io.coil-kt:coil-base:2.7.0' implementation 'io.coil-kt:coil-base:2.6.0'
implementation 'io.coil-kt:coil-svg:2.7.0' implementation 'io.coil-kt:coil-svg:2.6.0'
implementation 'com.github.KotatsuApp:subsampling-scale-image-view:882bc0620c' implementation 'com.github.KotatsuApp:subsampling-scale-image-view:02e6d6cfe9'
implementation 'com.github.solkin:disk-lru-cache:1.4' implementation 'com.github.solkin:disk-lru-cache:1.4'
implementation 'io.noties.markwon:core:4.6.2' implementation 'io.noties.markwon:core:4.6.2'
@@ -154,10 +153,10 @@ dependencies {
testImplementation 'org.json:json:20240303' testImplementation 'org.json:json:20240303'
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1' testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1'
androidTestImplementation 'androidx.test:runner:1.6.1' androidTestImplementation 'androidx.test:runner:1.5.2'
androidTestImplementation 'androidx.test:rules:1.6.1' androidTestImplementation 'androidx.test:rules:1.5.0'
androidTestImplementation 'androidx.test:core-ktx:1.6.1' androidTestImplementation 'androidx.test:core-ktx:1.5.0'
androidTestImplementation 'androidx.test.ext:junit-ktx:1.2.1' androidTestImplementation 'androidx.test.ext:junit-ktx:1.1.5'
androidTestImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1' androidTestImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1'

View File

@@ -0,0 +1,12 @@
<manifest
xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<activity
android:name=".tracker.ui.debug.TrackerDebugActivity"
android:label="@string/check_for_new_chapters" />
</application>
</manifest>

View File

@@ -1,33 +0,0 @@
package org.koitharu.kotatsu.settings
import android.content.Context
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import androidx.core.view.MenuProvider
import leakcanary.LeakCanary
import org.koitharu.kotatsu.R
import org.koitharu.workinspector.WorkInspector
class SettingsMenuProvider(
private val context: Context,
) : MenuProvider {
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menuInflater.inflate(R.menu.opt_settings, menu)
}
override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) {
R.id.action_leaks -> {
context.startActivity(LeakCanary.newLeakDisplayActivityIntent())
true
}
R.id.action_works -> {
context.startActivity(WorkInspector.getIntent(context))
true
}
else -> false
}
}

View File

@@ -58,7 +58,7 @@ fun trackDebugAD(
append(" - ") append(" - ")
bold { bold {
color(context.getThemeColor(materialR.attr.colorError, Color.RED)) { color(context.getThemeColor(materialR.attr.colorError, Color.RED)) {
append(item.lastError ?: getString(R.string.error)) append(getString(R.string.error))
} }
} }
} }

View File

@@ -11,7 +11,6 @@ data class TrackDebugItem(
val lastCheckTime: Instant?, val lastCheckTime: Instant?,
val lastChapterDate: Instant?, val lastChapterDate: Instant?,
val lastResult: Int, val lastResult: Int,
val lastError: String?,
) : ListModel { ) : ListModel {
override fun areItemsTheSame(other: ListModel): Boolean { override fun areItemsTheSame(other: ListModel): Boolean {

View File

@@ -32,7 +32,6 @@ class TrackerDebugActivity : BaseActivity<ActivityTrackerDebugBinding>(), OnList
val tracksAdapter = BaseListAdapter<TrackDebugItem>() val tracksAdapter = BaseListAdapter<TrackDebugItem>()
.addDelegate(ListItemType.FEED, trackDebugAD(this, coil, this)) .addDelegate(ListItemType.FEED, trackDebugAD(this, coil, this))
with(viewBinding.recyclerView) { with(viewBinding.recyclerView) {
setHasFixedSize(true)
adapter = tracksAdapter adapter = tracksAdapter
addItemDecoration(TypedListSpacingDecoration(context, false)) addItemDecoration(TypedListSpacingDecoration(context, false))
} }

View File

@@ -31,7 +31,6 @@ class TrackerDebugViewModel @Inject constructor(
lastCheckTime = it.track.lastCheckTime.toInstantOrNull(), lastCheckTime = it.track.lastCheckTime.toInstantOrNull(),
lastChapterDate = it.track.lastChapterDate.toInstantOrNull(), lastChapterDate = it.track.lastChapterDate.toInstantOrNull(),
lastResult = it.track.lastResult, lastResult = it.track.lastResult,
lastError = it.track.lastError,
) )
} }
} }

View File

@@ -14,7 +14,6 @@
android:layout_height="40dp" android:layout_height="40dp"
android:layout_marginStart="8dp" android:layout_marginStart="8dp"
android:layout_marginTop="16dp" android:layout_marginTop="16dp"
android:layout_marginBottom="16dp" android:layout_marginBottom="16dp"
android:scaleType="centerCrop" android:scaleType="centerCrop"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"

View File

@@ -8,9 +8,14 @@
android:title="@string/leak_canary_display_activity_label" android:title="@string/leak_canary_display_activity_label"
app:showAsAction="never" /> app:showAsAction="never" />
<item
android:id="@id/action_tracker"
android:title="@string/check_for_new_chapters"
app:showAsAction="never" />
<item <item
android:id="@id/action_works" android:id="@id/action_works"
android:title="@string/wi_lib_name" android:title="Works"
app:showAsAction="never" /> app:showAsAction="never" />
</menu> </menu>

View File

@@ -20,10 +20,6 @@
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" /> <uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission android:name="android.permission.REQUEST_DELETE_PACKAGES"/>
<uses-permission
android:name="android.permission.QUERY_ALL_PACKAGES"
tools:ignore="QueryAllPackagesPermission" />
<uses-permission <uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="29" /> android:maxSdkVersion="29" />
@@ -104,13 +100,6 @@
<intent-filter> <intent-filter>
<action android:name="${applicationId}.action.READ_MANGA" /> <action android:name="${applicationId}.action.READ_MANGA" />
</intent-filter> </intent-filter>
<intent-filter>
<action android:name="com.samsung.android.support.REMOTE_ACTION" />
</intent-filter>
<meta-data
android:name="com.samsung.android.support.REMOTE_ACTION"
android:resource="@xml/remote_action" />
</activity> </activity>
<activity <activity
android:name="org.koitharu.kotatsu.search.ui.SearchActivity" android:name="org.koitharu.kotatsu.search.ui.SearchActivity"
@@ -259,9 +248,6 @@
<activity <activity
android:name="org.koitharu.kotatsu.settings.about.AppUpdateActivity" android:name="org.koitharu.kotatsu.settings.about.AppUpdateActivity"
android:label="@string/app_update_available" /> android:label="@string/app_update_available" />
<activity
android:name="org.koitharu.kotatsu.tracker.ui.debug.TrackerDebugActivity"
android:label="@string/tracker_debug_info" />
<service <service
android:name="androidx.work.impl.foreground.SystemForegroundService" android:name="androidx.work.impl.foreground.SystemForegroundService"

View File

@@ -12,7 +12,6 @@ import org.koitharu.kotatsu.core.util.ext.almostEquals
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaListFilter import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaParserSource
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import javax.inject.Inject import javax.inject.Inject
@@ -58,7 +57,7 @@ class AlternativesUseCase @Inject constructor(
} }
private suspend fun getSources(ref: MangaSource): List<MangaSource> { private suspend fun getSources(ref: MangaSource): List<MangaSource> {
val result = ArrayList<MangaSource>(MangaParserSource.entries.size - 2) val result = ArrayList<MangaSource>(MangaSource.entries.size - 2)
result.addAll(sourcesRepository.getEnabledSources()) result.addAll(sourcesRepository.getEnabledSources())
result.sortByDescending { it.priority(ref) } result.sortByDescending { it.priority(ref) }
result.addAll(sourcesRepository.getDisabledSources().sortedByDescending { it.priority(ref) }) result.addAll(sourcesRepository.getDisabledSources().sortedByDescending { it.priority(ref) })
@@ -79,10 +78,8 @@ class AlternativesUseCase @Inject constructor(
private fun MangaSource.priority(ref: MangaSource): Int { private fun MangaSource.priority(ref: MangaSource): Int {
var res = 0 var res = 0
if (this is MangaParserSource && ref is MangaParserSource) { if (locale == ref.locale) res += 2
if (locale == ref.locale) res += 2 if (contentType == ref.contentType) res++
if (contentType == ref.contentType) res++
}
return res return res
} }
} }

View File

@@ -12,38 +12,29 @@ import org.koitharu.kotatsu.history.data.toMangaHistory
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.scrobbling.common.domain.Scrobbler
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingStatus
import org.koitharu.kotatsu.tracker.data.TrackEntity import org.koitharu.kotatsu.tracker.data.TrackEntity
import javax.inject.Inject import javax.inject.Inject
class MigrateUseCase class MigrateUseCase @Inject constructor(
@Inject
constructor(
private val mangaRepositoryFactory: MangaRepository.Factory, private val mangaRepositoryFactory: MangaRepository.Factory,
private val mangaDataRepository: MangaDataRepository, private val mangaDataRepository: MangaDataRepository,
private val database: MangaDatabase, private val database: MangaDatabase,
private val progressUpdateUseCase: ProgressUpdateUseCase, private val progressUpdateUseCase: ProgressUpdateUseCase,
private val scrobblers: Set<@JvmSuppressWildcards Scrobbler>,
) { ) {
suspend operator fun invoke(
oldManga: Manga, suspend operator fun invoke(oldManga: Manga, newManga: Manga) {
newManga: Manga, val oldDetails = if (oldManga.chapters.isNullOrEmpty()) {
) { runCatchingCancellable {
val oldDetails = mangaRepositoryFactory.create(oldManga.source).getDetails(oldManga)
if (oldManga.chapters.isNullOrEmpty()) { }.getOrDefault(oldManga)
runCatchingCancellable { } else {
mangaRepositoryFactory.create(oldManga.source).getDetails(oldManga) oldManga
}.getOrDefault(oldManga) }
} else { val newDetails = if (newManga.chapters.isNullOrEmpty()) {
oldManga mangaRepositoryFactory.create(newManga.source).getDetails(newManga)
} } else {
val newDetails = newManga
if (newManga.chapters.isNullOrEmpty()) { }
mangaRepositoryFactory.create(newManga.source).getDetails(newManga)
} else {
newManga
}
mangaDataRepository.storeManga(newDetails) mangaDataRepository.storeManga(newDetails)
database.withTransaction { database.withTransaction {
// replace favorites // replace favorites
@@ -52,69 +43,36 @@ constructor(
if (oldFavourites.isNotEmpty()) { if (oldFavourites.isNotEmpty()) {
favoritesDao.delete(oldManga.id) favoritesDao.delete(oldManga.id)
for (f in oldFavourites) { for (f in oldFavourites) {
val e = val e = f.copy(
f.copy( mangaId = newManga.id,
mangaId = newManga.id, )
)
favoritesDao.upsert(e) favoritesDao.upsert(e)
} }
} }
// replace history // replace history
val historyDao = database.getHistoryDao() val historyDao = database.getHistoryDao()
val oldHistory = historyDao.find(oldDetails.id) val oldHistory = historyDao.find(oldDetails.id)
val newHistory = if (oldHistory != null) {
if (oldHistory != null) { val newHistory = makeNewHistory(oldDetails, newDetails, oldHistory)
val newHistory = makeNewHistory(oldDetails, newDetails, oldHistory) historyDao.delete(oldDetails.id)
historyDao.delete(oldDetails.id) historyDao.upsert(newHistory)
historyDao.upsert(newHistory) }
newHistory
} else {
null
}
// track // track
val tracksDao = database.getTracksDao() val tracksDao = database.getTracksDao()
val oldTrack = tracksDao.find(oldDetails.id) val oldTrack = tracksDao.find(oldDetails.id)
if (oldTrack != null) { if (oldTrack != null) {
val lastChapter = newDetails.chapters?.lastOrNull() val lastChapter = newDetails.chapters?.lastOrNull()
val newTrack = val newTrack = TrackEntity(
TrackEntity( mangaId = newDetails.id,
mangaId = newDetails.id, lastChapterId = lastChapter?.id ?: 0L,
lastChapterId = lastChapter?.id ?: 0L, newChapters = 0,
newChapters = 0, lastCheckTime = System.currentTimeMillis(),
lastCheckTime = System.currentTimeMillis(), lastChapterDate = lastChapter?.uploadDate ?: 0L,
lastChapterDate = lastChapter?.uploadDate ?: 0L, lastResult = TrackEntity.RESULT_EXTERNAL_MODIFICATION,
lastResult = TrackEntity.RESULT_EXTERNAL_MODIFICATION, )
lastError = null,
)
tracksDao.delete(oldDetails.id) tracksDao.delete(oldDetails.id)
tracksDao.upsert(newTrack) tracksDao.upsert(newTrack)
} }
// scrobbling
for (scrobbler in scrobblers) {
if (!scrobbler.isEnabled) {
continue
}
val prevInfo = scrobbler.getScrobblingInfoOrNull(oldDetails.id) ?: continue
scrobbler.unregisterScrobbling(oldDetails.id)
scrobbler.linkManga(newDetails.id, prevInfo.targetId)
scrobbler.updateScrobblingInfo(
mangaId = newDetails.id,
rating = prevInfo.rating,
status =
prevInfo.status ?: when {
newHistory == null -> ScrobblingStatus.PLANNED
newHistory.percent == 1f -> ScrobblingStatus.COMPLETED
else -> ScrobblingStatus.READING
},
comment = prevInfo.comment,
)
if (newHistory != null) {
scrobbler.scrobble(
manga = newDetails,
chapterId = newHistory.chapterId,
)
}
}
} }
progressUpdateUseCase(newManga) progressUpdateUseCase(newManga)
} }
@@ -127,12 +85,11 @@ constructor(
if (oldManga.chapters.isNullOrEmpty()) { // probably broken manga/source if (oldManga.chapters.isNullOrEmpty()) { // probably broken manga/source
val branch = newManga.getPreferredBranch(null) val branch = newManga.getPreferredBranch(null)
val chapters = checkNotNull(newManga.getChapters(branch)) val chapters = checkNotNull(newManga.getChapters(branch))
val currentChapter = val currentChapter = if (history.percent in 0f..1f) {
if (history.percent in 0f..1f) { chapters[(chapters.lastIndex * history.percent).toInt()]
chapters[(chapters.lastIndex * history.percent).toInt()] } else {
} else { chapters.first()
chapters.first() }
}
return HistoryEntity( return HistoryEntity(
mangaId = newManga.id, mangaId = newManga.id,
createdAt = history.createdAt, createdAt = history.createdAt,
@@ -142,33 +99,29 @@ constructor(
scroll = history.scroll, scroll = history.scroll,
percent = history.percent, percent = history.percent,
deletedAt = 0, deletedAt = 0,
chaptersCount = chapters.count { it.branch == currentChapter.branch }, chaptersCount = chapters.size,
) )
} }
val branch = oldManga.getPreferredBranch(history.toMangaHistory()) val branch = oldManga.getPreferredBranch(history.toMangaHistory())
val oldChapters = checkNotNull(oldManga.getChapters(branch)) val oldChapters = checkNotNull(oldManga.getChapters(branch))
var index = oldChapters.indexOfFirst { it.id == history.chapterId } var index = oldChapters.indexOfFirst { it.id == history.chapterId }
if (index < 0) { if (index < 0) {
index = index = if (history.percent in 0f..1f) {
if (history.percent in 0f..1f) { (oldChapters.lastIndex * history.percent).toInt()
(oldChapters.lastIndex * history.percent).toInt() } else {
} else { 0
0 }
}
} }
val newChapters = checkNotNull(newManga.chapters).groupBy { it.branch } val newChapters = checkNotNull(newManga.chapters).groupBy { it.branch }
val newBranch = val newBranch = if (newChapters.containsKey(branch)) {
if (newChapters.containsKey(branch)) { branch
branch } else {
} else { newManga.getPreferredBranch(null)
newManga.getPreferredBranch(null) }
} val newChapterId = checkNotNull(newChapters[newBranch]).let {
val newChapterId = val oldChapter = oldChapters[index]
checkNotNull(newChapters[newBranch]) it.findByNumber(oldChapter.volume, oldChapter.number) ?: it.getOrNull(index) ?: it.last()
.let { }.id
val oldChapter = oldChapters[index]
it.findByNumber(oldChapter.volume, oldChapter.number) ?: it.getOrNull(index) ?: it.last()
}.id
return HistoryEntity( return HistoryEntity(
mangaId = newManga.id, mangaId = newManga.id,
@@ -183,13 +136,11 @@ constructor(
) )
} }
private fun List<MangaChapter>.findByNumber( private fun List<MangaChapter>.findByNumber(volume: Int, number: Float): MangaChapter? {
volume: Int, return if (number <= 0f) {
number: Float,
): MangaChapter? =
if (number <= 0f) {
null null
} else { } else {
firstOrNull { it.volume == volume && it.number == number } firstOrNull { it.volume == volume && it.number == number }
} }
}
} }

View File

@@ -10,7 +10,6 @@ import coil.request.ImageRequest
import coil.transform.CircleCropTransformation import coil.transform.CircleCropTransformation
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.getTitle
import org.koitharu.kotatsu.core.parser.favicon.faviconUri import org.koitharu.kotatsu.core.parser.favicon.faviconUri
import org.koitharu.kotatsu.core.ui.image.ChipIconTarget import org.koitharu.kotatsu.core.ui.image.ChipIconTarget
import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver
@@ -62,9 +61,9 @@ fun alternativeAD(
} }
} }
} }
binding.progressView.setProgress(item.progress, ListModelDiffCallback.PAYLOAD_PROGRESS_CHANGED in payloads) binding.progressView.setPercent(item.progress, ListModelDiffCallback.PAYLOAD_PROGRESS_CHANGED in payloads)
binding.chipSource.also { chip -> binding.chipSource.also { chip ->
chip.text = item.manga.source.getTitle(chip.context) chip.text = item.manga.source.title
ImageRequest.Builder(context) ImageRequest.Builder(context)
.data(item.manga.source.faviconUri()) .data(item.manga.source.faviconUri())
.lifecycle(lifecycleOwner) .lifecycle(lifecycleOwner)

View File

@@ -13,7 +13,6 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
import org.koitharu.kotatsu.core.model.getTitle
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.parser.MangaIntent import org.koitharu.kotatsu.core.parser.MangaIntent
import org.koitharu.kotatsu.core.ui.BaseActivity import org.koitharu.kotatsu.core.ui.BaseActivity
@@ -96,9 +95,9 @@ class AlternativesActivity : BaseActivity<ActivityAlternativesBinding>(),
getString( getString(
R.string.migrate_confirmation, R.string.migrate_confirmation,
viewModel.manga.title, viewModel.manga.title,
viewModel.manga.source.getTitle(this), viewModel.manga.source.title,
target.title, target.title,
target.source.getTitle(this), target.source.title,
), ),
) )
.setNegativeButton(android.R.string.cancel, null) .setNegativeButton(android.R.string.cancel, null)

View File

@@ -15,13 +15,11 @@ import org.koitharu.kotatsu.core.model.chaptersCount
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.parser.MangaIntent import org.koitharu.kotatsu.core.parser.MangaIntent
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.ext.call import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.core.util.ext.require import org.koitharu.kotatsu.core.util.ext.require
import org.koitharu.kotatsu.history.data.HistoryRepository import org.koitharu.kotatsu.list.domain.ListExtraProvider
import org.koitharu.kotatsu.list.domain.ReadingProgress
import org.koitharu.kotatsu.list.ui.model.EmptyState import org.koitharu.kotatsu.list.ui.model.EmptyState
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.LoadingFooter import org.koitharu.kotatsu.list.ui.model.LoadingFooter
@@ -36,8 +34,7 @@ class AlternativesViewModel @Inject constructor(
private val mangaRepositoryFactory: MangaRepository.Factory, private val mangaRepositoryFactory: MangaRepository.Factory,
private val alternativesUseCase: AlternativesUseCase, private val alternativesUseCase: AlternativesUseCase,
private val migrateUseCase: MigrateUseCase, private val migrateUseCase: MigrateUseCase,
private val historyRepository: HistoryRepository, private val extraProvider: ListExtraProvider,
private val settings: AppSettings,
) : BaseViewModel() { ) : BaseViewModel() {
val manga = savedStateHandle.require<ParcelableManga>(MangaIntent.KEY_MANGA).manga val manga = savedStateHandle.require<ParcelableManga>(MangaIntent.KEY_MANGA).manga
@@ -56,7 +53,7 @@ class AlternativesViewModel @Inject constructor(
.map { .map {
MangaAlternativeModel( MangaAlternativeModel(
manga = it, manga = it,
progress = getProgress(it.id), progress = extraProvider.getProgress(it.id),
referenceChapters = refCount, referenceChapters = refCount,
) )
}.runningFold<MangaAlternativeModel, List<ListModel>>(listOf(LoadingState)) { acc, item -> }.runningFold<MangaAlternativeModel, List<ListModel>>(listOf(LoadingState)) { acc, item ->
@@ -89,7 +86,13 @@ class AlternativesViewModel @Inject constructor(
} }
} }
private suspend fun getProgress(mangaId: Long): ReadingProgress? { private suspend fun mapList(list: List<Manga>, refCount: Int): List<MangaAlternativeModel> {
return historyRepository.getProgress(mangaId, settings.progressIndicatorMode) return list.map {
MangaAlternativeModel(
manga = it,
progress = extraProvider.getProgress(it.id),
referenceChapters = refCount,
)
}
} }
} }

View File

@@ -1,13 +1,12 @@
package org.koitharu.kotatsu.alternatives.ui package org.koitharu.kotatsu.alternatives.ui
import org.koitharu.kotatsu.core.model.chaptersCount import org.koitharu.kotatsu.core.model.chaptersCount
import org.koitharu.kotatsu.list.domain.ReadingProgress
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
data class MangaAlternativeModel( data class MangaAlternativeModel(
val manga: Manga, val manga: Manga,
val progress: ReadingProgress?, val progress: Float,
private val referenceChapters: Int, private val referenceChapters: Int,
) : ListModel { ) : ListModel {

View File

@@ -37,6 +37,6 @@ fun bookmarkLargeAD(
source(item.manga.source) source(item.manga.source)
enqueueWith(coil) enqueueWith(coil)
} }
binding.progressView.setProgress(item.percent, false) binding.progressView.percent = item.percent
} }
} }

View File

@@ -12,14 +12,13 @@ import androidx.core.graphics.Insets
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import okhttp3.internal.userAgent
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.network.CommonHeaders import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.parser.ParserMangaRepository import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.core.ui.BaseActivity import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.util.ext.configureForParser import org.koitharu.kotatsu.core.util.ext.configureForParser
import org.koitharu.kotatsu.core.util.ext.getSerializableExtraCompat
import org.koitharu.kotatsu.core.util.ext.toUriOrNull import org.koitharu.kotatsu.core.util.ext.toUriOrNull
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
@@ -43,9 +42,10 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
setDisplayHomeAsUpEnabled(true) setDisplayHomeAsUpEnabled(true)
setHomeAsUpIndicator(materialR.drawable.abc_ic_clear_material) setHomeAsUpIndicator(materialR.drawable.abc_ic_clear_material)
} }
val mangaSource = MangaSource(intent?.getStringExtra(EXTRA_SOURCE)) val userAgent = intent?.getSerializableExtraCompat<MangaSource>(EXTRA_SOURCE)?.let { source ->
val repository = mangaRepositoryFactory.create(mangaSource) as? ParserMangaRepository val repository = mangaRepositoryFactory.create(source) as? RemoteMangaRepository
repository?.headers?.get(CommonHeaders.USER_AGENT) repository?.headers?.get(CommonHeaders.USER_AGENT)
}
viewBinding.webView.configureForParser(userAgent) viewBinding.webView.configureForParser(userAgent)
CookieManager.getInstance().setAcceptThirdPartyCookies(viewBinding.webView, true) CookieManager.getInstance().setAcceptThirdPartyCookies(viewBinding.webView, true)
viewBinding.webView.webViewClient = BrowserClient(this) viewBinding.webView.webViewClient = BrowserClient(this)
@@ -108,10 +108,8 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
override fun onDestroy() { override fun onDestroy() {
super.onDestroy() super.onDestroy()
if (hasViewBinding()) { viewBinding.webView.stopLoading()
viewBinding.webView.stopLoading() viewBinding.webView.destroy()
viewBinding.webView.destroy()
}
} }
override fun onLoadingStateChanged(isLoading: Boolean) { override fun onLoadingStateChanged(isLoading: Boolean) {
@@ -147,7 +145,7 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
return Intent(context, BrowserActivity::class.java) return Intent(context, BrowserActivity::class.java)
.setData(Uri.parse(url)) .setData(Uri.parse(url))
.putExtra(EXTRA_TITLE, title) .putExtra(EXTRA_TITLE, title)
.putExtra(EXTRA_SOURCE, source?.name) .putExtra(EXTRA_SOURCE, source)
} }
} }
} }

View File

@@ -14,9 +14,8 @@ import coil.request.ErrorResult
import coil.request.ImageRequest import coil.request.ImageRequest
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
import org.koitharu.kotatsu.core.model.getTitle
import org.koitharu.kotatsu.core.model.isNsfw
import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission
import org.koitharu.kotatsu.parsers.model.ContentType
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
class CaptchaNotifier( class CaptchaNotifier(
@@ -47,7 +46,7 @@ class CaptchaNotifier(
.setGroup(GROUP_CAPTCHA) .setGroup(GROUP_CAPTCHA)
.setAutoCancel(true) .setAutoCancel(true)
.setVisibility( .setVisibility(
if (exception.source?.isNsfw() == true) { if (exception.source?.contentType == ContentType.HENTAI) {
NotificationCompat.VISIBILITY_SECRET NotificationCompat.VISIBILITY_SECRET
} else { } else {
NotificationCompat.VISIBILITY_PUBLIC NotificationCompat.VISIBILITY_PUBLIC
@@ -56,7 +55,7 @@ class CaptchaNotifier(
.setContentText( .setContentText(
context.getString( context.getString(
R.string.captcha_required_summary, R.string.captcha_required_summary,
exception.source?.getTitle(context) ?: context.getString(R.string.app_name), exception.source?.title ?: context.getString(R.string.app_name),
), ),
) )
.setContentIntent(PendingIntentCompat.getActivity(context, 0, intent, 0, false)) .setContentIntent(PendingIntentCompat.getActivity(context, 0, intent, 0, false))

View File

@@ -55,11 +55,7 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
setDisplayHomeAsUpEnabled(true) setDisplayHomeAsUpEnabled(true)
setHomeAsUpIndicator(materialR.drawable.abc_ic_clear_material) setHomeAsUpIndicator(materialR.drawable.abc_ic_clear_material)
} }
val url = intent?.dataString val url = intent?.dataString.orEmpty()
if (url.isNullOrEmpty()) {
finishAfterTransition()
return
}
cfClient = CloudFlareClient(cookieJar, this, url) cfClient = CloudFlareClient(cookieJar, this, url)
viewBinding.webView.configureForParser(intent?.getStringExtra(ARG_UA)) viewBinding.webView.configureForParser(intent?.getStringExtra(ARG_UA))
viewBinding.webView.webViewClient = cfClient viewBinding.webView.webViewClient = cfClient
@@ -67,7 +63,12 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
onBackPressedDispatcher.addCallback(it) onBackPressedDispatcher.addCallback(it)
} }
CookieManager.getInstance().setAcceptThirdPartyCookies(viewBinding.webView, true) CookieManager.getInstance().setAcceptThirdPartyCookies(viewBinding.webView, true)
if (savedInstanceState == null) { if (savedInstanceState != null) {
return
}
if (url.isEmpty()) {
finishAfterTransition()
} else {
onTitleChanged(getString(R.string.loading_), url) onTitleChanged(getString(R.string.loading_), url)
viewBinding.webView.loadUrl(url) viewBinding.webView.loadUrl(url)
} }

View File

@@ -1,7 +1,6 @@
package org.koitharu.kotatsu.core package org.koitharu.kotatsu.core
import android.app.Application import android.app.Application
import android.content.ContentResolver
import android.content.Context import android.content.Context
import android.provider.SearchRecentSuggestions import android.provider.SearchRecentSuggestions
import android.text.Html import android.text.Html
@@ -28,8 +27,8 @@ import okhttp3.OkHttpClient
import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.browser.cloudflare.CaptchaNotifier import org.koitharu.kotatsu.browser.cloudflare.CaptchaNotifier
import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.network.ImageProxyInterceptor
import org.koitharu.kotatsu.core.network.MangaHttpClient import org.koitharu.kotatsu.core.network.MangaHttpClient
import org.koitharu.kotatsu.core.network.imageproxy.ImageProxyInterceptor
import org.koitharu.kotatsu.core.os.AppShortcutManager import org.koitharu.kotatsu.core.os.AppShortcutManager
import org.koitharu.kotatsu.core.os.NetworkState import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.core.parser.MangaLoaderContextImpl import org.koitharu.kotatsu.core.parser.MangaLoaderContextImpl
@@ -49,7 +48,6 @@ import org.koitharu.kotatsu.local.data.LocalStorageChanges
import org.koitharu.kotatsu.local.domain.model.LocalManga import org.koitharu.kotatsu.local.domain.model.LocalManga
import org.koitharu.kotatsu.main.domain.CoverRestoreInterceptor import org.koitharu.kotatsu.main.domain.CoverRestoreInterceptor
import org.koitharu.kotatsu.main.ui.protect.AppProtectHelper import org.koitharu.kotatsu.main.ui.protect.AppProtectHelper
import org.koitharu.kotatsu.main.ui.protect.ScreenshotPolicyHelper
import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.search.ui.MangaSuggestionsProvider import org.koitharu.kotatsu.search.ui.MangaSuggestionsProvider
import org.koitharu.kotatsu.settings.backup.BackupObserver import org.koitharu.kotatsu.settings.backup.BackupObserver
@@ -111,8 +109,6 @@ interface AppModule {
.decoderDispatcher(Dispatchers.IO) .decoderDispatcher(Dispatchers.IO)
.transformationDispatcher(Dispatchers.Default) .transformationDispatcher(Dispatchers.Default)
.diskCache(diskCacheFactory) .diskCache(diskCacheFactory)
.respectCacheHeaders(false)
.networkObserverEnabled(false)
.logger(if (BuildConfig.DEBUG) DebugLogger() else null) .logger(if (BuildConfig.DEBUG) DebugLogger() else null)
.allowRgb565(context.isLowRamDevice()) .allowRgb565(context.isLowRamDevice())
.eventListener(CaptchaNotifier(context)) .eventListener(CaptchaNotifier(context))
@@ -156,12 +152,10 @@ interface AppModule {
appProtectHelper: AppProtectHelper, appProtectHelper: AppProtectHelper,
activityRecreationHandle: ActivityRecreationHandle, activityRecreationHandle: ActivityRecreationHandle,
acraScreenLogger: AcraScreenLogger, acraScreenLogger: AcraScreenLogger,
screenshotPolicyHelper: ScreenshotPolicyHelper,
): Set<@JvmSuppressWildcards Application.ActivityLifecycleCallbacks> = arraySetOf( ): Set<@JvmSuppressWildcards Application.ActivityLifecycleCallbacks> = arraySetOf(
appProtectHelper, appProtectHelper,
activityRecreationHandle, activityRecreationHandle,
acraScreenLogger, acraScreenLogger,
screenshotPolicyHelper,
) )
@Provides @Provides

View File

@@ -12,7 +12,6 @@ import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.util.json.getBooleanOrDefault import org.koitharu.kotatsu.parsers.util.json.getBooleanOrDefault
import org.koitharu.kotatsu.parsers.util.json.getFloatOrDefault import org.koitharu.kotatsu.parsers.util.json.getFloatOrDefault
import org.koitharu.kotatsu.parsers.util.json.getIntOrDefault import org.koitharu.kotatsu.parsers.util.json.getIntOrDefault
import org.koitharu.kotatsu.parsers.util.json.getLongOrDefault
import org.koitharu.kotatsu.parsers.util.json.getStringOrNull import org.koitharu.kotatsu.parsers.util.json.getStringOrNull
class JsonDeserializer(private val json: JSONObject) { class JsonDeserializer(private val json: JSONObject) {
@@ -85,9 +84,6 @@ class JsonDeserializer(private val json: JSONObject) {
source = json.getString("source"), source = json.getString("source"),
isEnabled = json.getBoolean("enabled"), isEnabled = json.getBoolean("enabled"),
sortKey = json.getInt("sort_key"), sortKey = json.getInt("sort_key"),
addedIn = json.getIntOrDefault("added_in", 0),
lastUsedAt = json.getLongOrDefault("used_at", 0L),
isPinned = json.getBooleanOrDefault("pinned", false),
) )
fun toMap(): Map<String, Any?> { fun toMap(): Map<String, Any?> {

View File

@@ -89,9 +89,6 @@ class JsonSerializer private constructor(private val json: JSONObject) {
put("source", e.source) put("source", e.source)
put("enabled", e.isEnabled) put("enabled", e.isEnabled)
put("sort_key", e.sortKey) put("sort_key", e.sortKey)
put("added_in", e.addedIn)
put("used_at", e.lastUsedAt)
put("pinned", e.isPinned)
}, },
) )

View File

@@ -16,16 +16,16 @@ class MemoryContentCache @Inject constructor(application: Application) : Compone
private val isLowRam = application.isLowRamDevice() private val isLowRam = application.isLowRamDevice()
init {
application.registerComponentCallbacks(this)
}
private val detailsCache = ExpiringLruCache<SafeDeferred<Manga>>(if (isLowRam) 1 else 4, 5, TimeUnit.MINUTES) private val detailsCache = ExpiringLruCache<SafeDeferred<Manga>>(if (isLowRam) 1 else 4, 5, TimeUnit.MINUTES)
private val pagesCache = private val pagesCache =
ExpiringLruCache<SafeDeferred<List<MangaPage>>>(if (isLowRam) 1 else 4, 10, TimeUnit.MINUTES) ExpiringLruCache<SafeDeferred<List<MangaPage>>>(if (isLowRam) 1 else 4, 10, TimeUnit.MINUTES)
private val relatedMangaCache = private val relatedMangaCache =
ExpiringLruCache<SafeDeferred<List<Manga>>>(if (isLowRam) 1 else 3, 10, TimeUnit.MINUTES) ExpiringLruCache<SafeDeferred<List<Manga>>>(if (isLowRam) 1 else 3, 10, TimeUnit.MINUTES)
init {
application.registerComponentCallbacks(this)
}
suspend fun getDetails(source: MangaSource, url: String): Manga? { suspend fun getDetails(source: MangaSource, url: String): Manga? {
return detailsCache[Key(source, url)]?.awaitOrNull() return detailsCache[Key(source, url)]?.awaitOrNull()
} }

View File

@@ -33,8 +33,6 @@ import org.koitharu.kotatsu.core.db.migrations.Migration17To18
import org.koitharu.kotatsu.core.db.migrations.Migration18To19 import org.koitharu.kotatsu.core.db.migrations.Migration18To19
import org.koitharu.kotatsu.core.db.migrations.Migration19To20 import org.koitharu.kotatsu.core.db.migrations.Migration19To20
import org.koitharu.kotatsu.core.db.migrations.Migration1To2 import org.koitharu.kotatsu.core.db.migrations.Migration1To2
import org.koitharu.kotatsu.core.db.migrations.Migration20To21
import org.koitharu.kotatsu.core.db.migrations.Migration21To22
import org.koitharu.kotatsu.core.db.migrations.Migration2To3 import org.koitharu.kotatsu.core.db.migrations.Migration2To3
import org.koitharu.kotatsu.core.db.migrations.Migration3To4 import org.koitharu.kotatsu.core.db.migrations.Migration3To4
import org.koitharu.kotatsu.core.db.migrations.Migration4To5 import org.koitharu.kotatsu.core.db.migrations.Migration4To5
@@ -60,7 +58,7 @@ import org.koitharu.kotatsu.tracker.data.TrackEntity
import org.koitharu.kotatsu.tracker.data.TrackLogEntity import org.koitharu.kotatsu.tracker.data.TrackLogEntity
import org.koitharu.kotatsu.tracker.data.TracksDao import org.koitharu.kotatsu.tracker.data.TracksDao
const val DATABASE_VERSION = 22 const val DATABASE_VERSION = 20
@Database( @Database(
entities = [ entities = [
@@ -120,8 +118,6 @@ fun getDatabaseMigrations(context: Context): Array<Migration> = arrayOf(
Migration17To18(), Migration17To18(),
Migration18To19(), Migration18To19(),
Migration19To20(), Migration19To20(),
Migration20To21(),
Migration21To22(),
) )
fun MangaDatabase(context: Context): MangaDatabase = Room fun MangaDatabase(context: Context): MangaDatabase = Room

View File

@@ -11,26 +11,19 @@ import androidx.sqlite.db.SimpleSQLiteQuery
import androidx.sqlite.db.SupportSQLiteQuery import androidx.sqlite.db.SupportSQLiteQuery
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import org.intellij.lang.annotations.Language import org.intellij.lang.annotations.Language
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity
import org.koitharu.kotatsu.explore.data.SourcesSortOrder import org.koitharu.kotatsu.explore.data.SourcesSortOrder
@Dao @Dao
abstract class MangaSourcesDao { abstract class MangaSourcesDao {
@Query("SELECT * FROM sources ORDER BY pinned DESC, sort_key") @Query("SELECT * FROM sources ORDER BY sort_key")
abstract suspend fun findAll(): List<MangaSourceEntity> abstract suspend fun findAll(): List<MangaSourceEntity>
@Query("SELECT source FROM sources WHERE enabled = 1") @Query("SELECT source FROM sources WHERE enabled = 1")
abstract suspend fun findAllEnabledNames(): List<String> abstract suspend fun findAllEnabledNames(): List<String>
@Query("SELECT * FROM sources WHERE added_in >= :version") @Query("SELECT * FROM sources ORDER BY sort_key")
abstract suspend fun findAllFromVersion(version: Int): List<MangaSourceEntity>
@Query("SELECT * FROM sources ORDER BY used_at DESC LIMIT :limit")
abstract suspend fun findLastUsed(limit: Int): List<MangaSourceEntity>
@Query("SELECT * FROM sources ORDER BY pinned DESC, sort_key")
abstract fun observeAll(): Flow<List<MangaSourceEntity>> abstract fun observeAll(): Flow<List<MangaSourceEntity>>
@Query("SELECT enabled FROM sources WHERE source = :source") @Query("SELECT enabled FROM sources WHERE source = :source")
@@ -45,12 +38,6 @@ abstract class MangaSourcesDao {
@Query("UPDATE sources SET sort_key = :sortKey WHERE source = :source") @Query("UPDATE sources SET sort_key = :sortKey WHERE source = :source")
abstract suspend fun setSortKey(source: String, sortKey: Int) abstract suspend fun setSortKey(source: String, sortKey: Int)
@Query("UPDATE sources SET used_at = :value WHERE source = :source")
abstract suspend fun setLastUsed(source: String, value: Long)
@Query("UPDATE sources SET pinned = :isPinned WHERE source = :source")
abstract suspend fun setPinned(source: String, isPinned: Boolean)
@Insert(onConflict = OnConflictStrategy.IGNORE) @Insert(onConflict = OnConflictStrategy.IGNORE)
@Transaction @Transaction
abstract suspend fun insertIfAbsent(entries: Collection<MangaSourceEntity>) abstract suspend fun insertIfAbsent(entries: Collection<MangaSourceEntity>)
@@ -58,14 +45,11 @@ abstract class MangaSourcesDao {
@Upsert @Upsert
abstract suspend fun upsert(entry: MangaSourceEntity) abstract suspend fun upsert(entry: MangaSourceEntity)
@Query("SELECT * FROM sources WHERE pinned = 1")
abstract suspend fun findAllPinned(): List<MangaSourceEntity>
fun observeEnabled(order: SourcesSortOrder): Flow<List<MangaSourceEntity>> { fun observeEnabled(order: SourcesSortOrder): Flow<List<MangaSourceEntity>> {
val orderBy = getOrderBy(order) val orderBy = getOrderBy(order)
@Language("RoomSql") @Language("RoomSql")
val query = SimpleSQLiteQuery("SELECT * FROM sources WHERE enabled = 1 ORDER BY pinned DESC, $orderBy") val query = SimpleSQLiteQuery("SELECT * FROM sources WHERE enabled = 1 ORDER BY $orderBy")
return observeImpl(query) return observeImpl(query)
} }
@@ -73,7 +57,7 @@ abstract class MangaSourcesDao {
val orderBy = getOrderBy(order) val orderBy = getOrderBy(order)
@Language("RoomSql") @Language("RoomSql")
val query = SimpleSQLiteQuery("SELECT * FROM sources WHERE enabled = 1 ORDER BY pinned DESC, $orderBy") val query = SimpleSQLiteQuery("SELECT * FROM sources WHERE enabled = 1 ORDER BY $orderBy")
return findAllImpl(query) return findAllImpl(query)
} }
@@ -84,9 +68,6 @@ abstract class MangaSourcesDao {
source = source, source = source,
isEnabled = isEnabled, isEnabled = isEnabled,
sortKey = getMaxSortKey() + 1, sortKey = getMaxSortKey() + 1,
addedIn = BuildConfig.VERSION_CODE,
lastUsedAt = 0,
isPinned = false,
) )
upsert(entity) upsert(entity)
} }
@@ -105,6 +86,5 @@ abstract class MangaSourcesDao {
SourcesSortOrder.ALPHABETIC -> "source ASC" SourcesSortOrder.ALPHABETIC -> "source ASC"
SourcesSortOrder.POPULARITY -> "(SELECT COUNT(*) FROM manga WHERE source = sources.source) DESC" SourcesSortOrder.POPULARITY -> "(SELECT COUNT(*) FROM manga WHERE source = sources.source) DESC"
SourcesSortOrder.MANUAL -> "sort_key ASC" SourcesSortOrder.MANUAL -> "sort_key ASC"
SourcesSortOrder.LAST_USED -> "used_at DESC"
} }
} }

View File

@@ -14,7 +14,4 @@ data class MangaSourceEntity(
val source: String, val source: String,
@ColumnInfo(name = "enabled") val isEnabled: Boolean, @ColumnInfo(name = "enabled") val isEnabled: Boolean,
@ColumnInfo(name = "sort_key", index = true) val sortKey: Int, @ColumnInfo(name = "sort_key", index = true) val sortKey: Int,
@ColumnInfo(name = "added_in") val addedIn: Int,
@ColumnInfo(name = "used_at") val lastUsedAt: Long,
@ColumnInfo(name = "pinned") val isPinned: Boolean,
) )

View File

@@ -4,7 +4,7 @@ import android.content.Context
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import androidx.room.migration.Migration import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase import androidx.sqlite.db.SupportSQLiteDatabase
import org.koitharu.kotatsu.parsers.model.MangaParserSource import org.koitharu.kotatsu.parsers.model.MangaSource
class Migration16To17(context: Context) : Migration(16, 17) { class Migration16To17(context: Context) : Migration(16, 17) {
@@ -15,8 +15,11 @@ class Migration16To17(context: Context) : Migration(16, 17) {
db.execSQL("CREATE INDEX `index_sources_sort_key` ON `sources` (`sort_key`)") db.execSQL("CREATE INDEX `index_sources_sort_key` ON `sources` (`sort_key`)")
val hiddenSources = prefs.getStringSet("sources_hidden", null).orEmpty() val hiddenSources = prefs.getStringSet("sources_hidden", null).orEmpty()
val order = prefs.getString("sources_order_2", null)?.split('|').orEmpty() val order = prefs.getString("sources_order_2", null)?.split('|').orEmpty()
val sources = MangaParserSource.entries val sources = MangaSource.entries
for (source in sources) { for (source in sources) {
if (source == MangaSource.LOCAL) {
continue
}
val name = source.name val name = source.name
val isHidden = name in hiddenSources val isHidden = name in hiddenSources
var sortKey = order.indexOf(name) var sortKey = order.indexOf(name)

View File

@@ -1,12 +0,0 @@
package org.koitharu.kotatsu.core.db.migrations
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
class Migration20To21 : Migration(20, 21) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE tracks ADD COLUMN `last_error` TEXT DEFAULT NULL")
db.execSQL("ALTER TABLE sources ADD COLUMN `added_in` INTEGER NOT NULL DEFAULT 0")
}
}

View File

@@ -1,12 +0,0 @@
package org.koitharu.kotatsu.core.db.migrations
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
class Migration21To22 : Migration(21, 22) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE sources ADD COLUMN `used_at` INTEGER NOT NULL DEFAULT 0")
db.execSQL("ALTER TABLE sources ADD COLUMN `pinned` INTEGER NOT NULL DEFAULT 0")
}
}

View File

@@ -1,6 +0,0 @@
package org.koitharu.kotatsu.core.exceptions
class IncompatiblePluginException(
val name: String?,
cause: Throwable?,
) : RuntimeException(cause)

View File

@@ -1,48 +1,36 @@
package org.koitharu.kotatsu.core.exceptions.resolve package org.koitharu.kotatsu.core.exceptions.resolve
import android.content.Context
import android.widget.Toast
import androidx.activity.result.ActivityResultCallback import androidx.activity.result.ActivityResultCallback
import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.ActivityResultLauncher
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.collection.MutableScatterMap import androidx.collection.ArrayMap
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity import androidx.fragment.app.FragmentActivity
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import dagger.hilt.android.EntryPointAccessors
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.alternatives.ui.AlternativesActivity import org.koitharu.kotatsu.alternatives.ui.AlternativesActivity
import org.koitharu.kotatsu.browser.BrowserActivity import org.koitharu.kotatsu.browser.BrowserActivity
import org.koitharu.kotatsu.browser.cloudflare.CloudFlareActivity import org.koitharu.kotatsu.browser.cloudflare.CloudFlareActivity
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.BaseActivity.BaseActivityEntryPoint
import org.koitharu.kotatsu.core.ui.dialog.ErrorDetailsDialog import org.koitharu.kotatsu.core.ui.dialog.ErrorDetailsDialog
import org.koitharu.kotatsu.core.util.TaggedActivityResult import org.koitharu.kotatsu.core.util.TaggedActivityResult
import org.koitharu.kotatsu.core.util.ext.findActivity
import org.koitharu.kotatsu.parsers.exception.AuthRequiredException import org.koitharu.kotatsu.parsers.exception.AuthRequiredException
import org.koitharu.kotatsu.parsers.exception.NotFoundException import org.koitharu.kotatsu.parsers.exception.NotFoundException
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.settings.sources.auth.SourceAuthActivity import org.koitharu.kotatsu.settings.sources.auth.SourceAuthActivity
import java.security.cert.CertPathValidatorException
import javax.net.ssl.SSLException
import kotlin.coroutines.Continuation import kotlin.coroutines.Continuation
import kotlin.coroutines.resume import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine import kotlin.coroutines.suspendCoroutine
class ExceptionResolver : ActivityResultCallback<TaggedActivityResult> { class ExceptionResolver : ActivityResultCallback<TaggedActivityResult> {
private val continuations = MutableScatterMap<String, Continuation<Boolean>>(1) private val continuations = ArrayMap<String, Continuation<Boolean>>(1)
private val activity: FragmentActivity? private val activity: FragmentActivity?
private val fragment: Fragment? private val fragment: Fragment?
private val sourceAuthContract: ActivityResultLauncher<MangaSource> private val sourceAuthContract: ActivityResultLauncher<MangaSource>
private val cloudflareContract: ActivityResultLauncher<CloudFlareProtectedException> private val cloudflareContract: ActivityResultLauncher<CloudFlareProtectedException>
val context: Context?
get() = activity ?: fragment?.context
constructor(activity: FragmentActivity) { constructor(activity: FragmentActivity) {
this.activity = activity this.activity = activity
fragment = null fragment = null
@@ -68,12 +56,6 @@ class ExceptionResolver : ActivityResultCallback<TaggedActivityResult> {
suspend fun resolve(e: Throwable): Boolean = when (e) { suspend fun resolve(e: Throwable): Boolean = when (e) {
is CloudFlareProtectedException -> resolveCF(e) is CloudFlareProtectedException -> resolveCF(e)
is AuthRequiredException -> resolveAuthException(e.source) is AuthRequiredException -> resolveAuthException(e.source)
is SSLException,
is CertPathValidatorException -> {
showSslErrorDialog()
false
}
is NotFoundException -> { is NotFoundException -> {
openInBrowser(e.url) openInBrowser(e.url)
false false
@@ -98,37 +80,13 @@ class ExceptionResolver : ActivityResultCallback<TaggedActivityResult> {
} }
private fun openInBrowser(url: String) { private fun openInBrowser(url: String) {
context?.run { val context = activity ?: fragment?.activity ?: return
startActivity(BrowserActivity.newIntent(this, url, null, null)) context.startActivity(BrowserActivity.newIntent(context, url, null, null))
}
} }
private fun openAlternatives(manga: Manga) { private fun openAlternatives(manga: Manga) {
context?.run { val context = activity ?: fragment?.activity ?: return
startActivity(AlternativesActivity.newIntent(this, manga)) context.startActivity(AlternativesActivity.newIntent(context, manga))
}
}
private fun showSslErrorDialog() {
val ctx = context ?: return
val settings = getAppSettings(ctx)
if (settings.isSSLBypassEnabled) {
Toast.makeText(ctx, R.string.operation_not_supported, Toast.LENGTH_SHORT).show()
return
}
MaterialAlertDialogBuilder(ctx)
.setTitle(R.string.ignore_ssl_errors)
.setMessage(R.string.ignore_ssl_errors_summary)
.setPositiveButton(R.string.apply) { _, _ ->
settings.isSSLBypassEnabled = true
Toast.makeText(ctx, R.string.settings_apply_restart_required, Toast.LENGTH_SHORT).show()
ctx.findActivity()?.finishAffinity()
}.setNegativeButton(android.R.string.cancel, null)
.show()
}
private fun getAppSettings(context: Context): AppSettings {
return EntryPointAccessors.fromApplication<BaseActivityEntryPoint>(context).settings
} }
private fun getFragmentManager() = checkNotNull(fragment?.childFragmentManager ?: activity?.supportFragmentManager) private fun getFragmentManager() = checkNotNull(fragment?.childFragmentManager ?: activity?.supportFragmentManager)
@@ -141,9 +99,6 @@ class ExceptionResolver : ActivityResultCallback<TaggedActivityResult> {
is AuthRequiredException -> R.string.sign_in is AuthRequiredException -> R.string.sign_in
is NotFoundException -> if (e.url.isNotEmpty()) R.string.open_in_browser else 0 is NotFoundException -> if (e.url.isNotEmpty()) R.string.open_in_browser else 0
is UnsupportedSourceException -> if (e.manga != null) R.string.alternatives else 0 is UnsupportedSourceException -> if (e.manga != null) R.string.alternatives else 0
is SSLException,
is CertPathValidatorException -> R.string.fix
else -> 0 else -> 0
} }

View File

@@ -11,6 +11,7 @@ import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.parsers.model.ContentRating import org.koitharu.kotatsu.parsers.model.ContentRating
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaState import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.util.formatSimple import org.koitharu.kotatsu.parsers.util.formatSimple
import org.koitharu.kotatsu.parsers.util.mapToSet import org.koitharu.kotatsu.parsers.util.mapToSet
@@ -108,7 +109,7 @@ fun Manga.getPreferredBranch(history: MangaHistory?): String? {
} }
val Manga.isLocal: Boolean val Manga.isLocal: Boolean
get() = source == LocalMangaSource get() = source == MangaSource.LOCAL
val Manga.appUrl: Uri val Manga.appUrl: Uri
get() = Uri.parse("https://kotatsu.app/manga").buildUpon() get() = Uri.parse("https://kotatsu.app/manga").buildUpon()

View File

@@ -7,47 +7,26 @@ import android.text.style.ForegroundColorSpan
import android.text.style.RelativeSizeSpan import android.text.style.RelativeSizeSpan
import android.text.style.SuperscriptSpan import android.text.style.SuperscriptSpan
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.core.text.buildSpannedString
import androidx.core.text.inSpans import androidx.core.text.inSpans
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.parser.external.ExternalMangaSource
import org.koitharu.kotatsu.core.util.ext.getDisplayName import org.koitharu.kotatsu.core.util.ext.getDisplayName
import org.koitharu.kotatsu.core.util.ext.getThemeColor import org.koitharu.kotatsu.core.util.ext.getThemeColor
import org.koitharu.kotatsu.core.util.ext.toLocale import org.koitharu.kotatsu.core.util.ext.toLocale
import org.koitharu.kotatsu.parsers.model.ContentType import org.koitharu.kotatsu.parsers.model.ContentType
import org.koitharu.kotatsu.parsers.model.MangaParserSource
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.splitTwoParts import org.koitharu.kotatsu.parsers.util.toTitleCase
import java.util.Locale
import com.google.android.material.R as materialR import com.google.android.material.R as materialR
data object LocalMangaSource : MangaSource { fun MangaSource(name: String): MangaSource {
override val name = "LOCAL" MangaSource.entries.forEach {
}
data object UnknownMangaSource : MangaSource {
override val name = "UNKNOWN"
}
fun MangaSource(name: String?): MangaSource {
when (name ?: return UnknownMangaSource) {
UnknownMangaSource.name -> return UnknownMangaSource
LocalMangaSource.name -> return LocalMangaSource
}
if (name.startsWith("content:")) {
val parts = name.substringAfter(':').splitTwoParts('/') ?: return UnknownMangaSource
return ExternalMangaSource(packageName = parts.first, authority = parts.second)
}
MangaParserSource.entries.forEach {
if (it.name == name) return it if (it.name == name) return it
} }
return UnknownMangaSource return MangaSource.DUMMY
} }
fun MangaSource.isNsfw(): Boolean = when (this) { fun MangaSource.isNsfw() = contentType == ContentType.HENTAI
is MangaSourceInfo -> mangaSource.isNsfw()
is MangaParserSource -> contentType == ContentType.HENTAI
else -> false
}
@get:StringRes @get:StringRes
val ContentType.titleResId val ContentType.titleResId
@@ -58,28 +37,23 @@ val ContentType.titleResId
ContentType.OTHER -> R.string.content_type_other ContentType.OTHER -> R.string.content_type_other
} }
fun MangaSource.getSummary(context: Context): String? = when (this) { fun MangaSource.getSummary(context: Context): String {
is MangaSourceInfo -> mangaSource.getSummary(context) val type = context.getString(contentType.titleResId)
is MangaParserSource -> { val locale = locale?.toLocale().getDisplayName(context)
val type = context.getString(contentType.titleResId) return context.getString(R.string.source_summary_pattern, type, locale)
val locale = locale.toLocale().getDisplayName(context) }
context.getString(R.string.source_summary_pattern, type, locale)
fun MangaSource.getTitle(context: Context): CharSequence = if (isNsfw()) {
buildSpannedString {
append(title)
append(' ')
appendNsfwLabel(context)
} }
} else {
is ExternalMangaSource -> context.getString(R.string.external_source) title
else -> null
} }
fun MangaSource.getTitle(context: Context): String = when (this) { private fun SpannableStringBuilder.appendNsfwLabel(context: Context) = inSpans(
is MangaSourceInfo -> mangaSource.getTitle(context)
is MangaParserSource -> title
LocalMangaSource -> context.getString(R.string.local_storage)
is ExternalMangaSource -> resolveName(context)
else -> context.getString(R.string.unknown)
}
fun SpannableStringBuilder.appendNsfwLabel(context: Context) = inSpans(
ForegroundColorSpan(context.getThemeColor(materialR.attr.colorError, Color.RED)), ForegroundColorSpan(context.getThemeColor(materialR.attr.colorError, Color.RED)),
RelativeSizeSpan(0.74f), RelativeSizeSpan(0.74f),
SuperscriptSpan(), SuperscriptSpan(),

View File

@@ -1,9 +0,0 @@
package org.koitharu.kotatsu.core.model
import org.koitharu.kotatsu.parsers.model.MangaSource
data class MangaSourceInfo(
val mangaSource: MangaSource,
val isEnabled: Boolean,
val isPinned: Boolean,
) : MangaSource by mangaSource

View File

@@ -1,15 +0,0 @@
package org.koitharu.kotatsu.core.model.parcelable
import android.os.Parcel
import kotlinx.parcelize.Parceler
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaSource
class MangaSourceParceler : Parceler<MangaSource> {
override fun create(parcel: Parcel): MangaSource = MangaSource(parcel.readString())
override fun MangaSource.write(parcel: Parcel, flags: Int) {
parcel.writeString(name)
}
}

View File

@@ -4,8 +4,9 @@ import android.os.Parcel
import android.os.Parcelable import android.os.Parcelable
import kotlinx.parcelize.Parceler import kotlinx.parcelize.Parceler
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.util.ext.readSerializableCompat
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaSource
@Parcelize @Parcelize
data class ParcelableChapter( data class ParcelableChapter(
@@ -24,8 +25,8 @@ data class ParcelableChapter(
scanlator = parcel.readString(), scanlator = parcel.readString(),
uploadDate = parcel.readLong(), uploadDate = parcel.readLong(),
branch = parcel.readString(), branch = parcel.readString(),
source = MangaSource(parcel.readString()), source = parcel.readSerializableCompat() ?: MangaSource.DUMMY,
), )
) )
override fun ParcelableChapter.write(parcel: Parcel, flags: Int) = with(chapter) { override fun ParcelableChapter.write(parcel: Parcel, flags: Int) = with(chapter) {
@@ -37,7 +38,7 @@ data class ParcelableChapter(
parcel.writeString(scanlator) parcel.writeString(scanlator)
parcel.writeLong(uploadDate) parcel.writeLong(uploadDate)
parcel.writeString(branch) parcel.writeString(branch)
parcel.writeString(source.name) parcel.writeSerializable(source)
} }
} }
} }

View File

@@ -5,7 +5,6 @@ import android.os.Parcelable
import androidx.core.os.ParcelCompat import androidx.core.os.ParcelCompat
import kotlinx.parcelize.Parceler import kotlinx.parcelize.Parceler
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.util.ext.readParcelableCompat import org.koitharu.kotatsu.core.util.ext.readParcelableCompat
import org.koitharu.kotatsu.core.util.ext.readSerializableCompat import org.koitharu.kotatsu.core.util.ext.readSerializableCompat
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
@@ -31,7 +30,7 @@ data class ParcelableManga(
parcel.writeParcelable(ParcelableMangaTags(tags), flags) parcel.writeParcelable(ParcelableMangaTags(tags), flags)
parcel.writeSerializable(state) parcel.writeSerializable(state)
parcel.writeString(author) parcel.writeString(author)
parcel.writeString(source.name) parcel.writeSerializable(source)
} }
override fun create(parcel: Parcel) = ParcelableManga( override fun create(parcel: Parcel) = ParcelableManga(
@@ -50,8 +49,8 @@ data class ParcelableManga(
state = parcel.readSerializableCompat(), state = parcel.readSerializableCompat(),
author = parcel.readString(), author = parcel.readString(),
chapters = null, chapters = null,
source = MangaSource(parcel.readString()), source = requireNotNull(parcel.readSerializableCompat()),
), )
) )
} }
} }

View File

@@ -5,7 +5,7 @@ import android.os.Parcelable
import kotlinx.parcelize.Parceler import kotlinx.parcelize.Parceler
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import kotlinx.parcelize.TypeParceler import kotlinx.parcelize.TypeParceler
import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.util.ext.readSerializableCompat
import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaPage
object MangaPageParceler : Parceler<MangaPage> { object MangaPageParceler : Parceler<MangaPage> {
@@ -13,14 +13,14 @@ object MangaPageParceler : Parceler<MangaPage> {
id = parcel.readLong(), id = parcel.readLong(),
url = requireNotNull(parcel.readString()), url = requireNotNull(parcel.readString()),
preview = parcel.readString(), preview = parcel.readString(),
source = MangaSource(parcel.readString()), source = requireNotNull(parcel.readSerializableCompat()),
) )
override fun MangaPage.write(parcel: Parcel, flags: Int) { override fun MangaPage.write(parcel: Parcel, flags: Int) {
parcel.writeLong(id) parcel.writeLong(id)
parcel.writeString(url) parcel.writeString(url)
parcel.writeString(preview) parcel.writeString(preview)
parcel.writeString(source.name) parcel.writeSerializable(source)
} }
} }

View File

@@ -5,20 +5,20 @@ import android.os.Parcelable
import kotlinx.parcelize.Parceler import kotlinx.parcelize.Parceler
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import kotlinx.parcelize.TypeParceler import kotlinx.parcelize.TypeParceler
import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.util.ext.readSerializableCompat
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
object MangaTagParceler : Parceler<MangaTag> { object MangaTagParceler : Parceler<MangaTag> {
override fun create(parcel: Parcel) = MangaTag( override fun create(parcel: Parcel) = MangaTag(
title = requireNotNull(parcel.readString()), title = requireNotNull(parcel.readString()),
key = requireNotNull(parcel.readString()), key = requireNotNull(parcel.readString()),
source = MangaSource(parcel.readString()), source = requireNotNull(parcel.readSerializableCompat()),
) )
override fun MangaTag.write(parcel: Parcel, flags: Int) { override fun MangaTag.write(parcel: Parcel, flags: Int) {
parcel.writeString(title) parcel.writeString(title)
parcel.writeString(key) parcel.writeString(key)
parcel.writeString(source.name) parcel.writeSerializable(source)
} }
} }

View File

@@ -4,18 +4,15 @@ import android.util.Log
import dagger.Lazy import dagger.Lazy
import okhttp3.Headers import okhttp3.Headers
import okhttp3.Interceptor import okhttp3.Interceptor
import okhttp3.Interceptor.Chain
import okhttp3.Request import okhttp3.Request
import okhttp3.Response import okhttp3.Response
import okio.IOException
import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.parser.MangaLoaderContextImpl import org.koitharu.kotatsu.core.parser.MangaLoaderContextImpl
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.parser.ParserMangaRepository import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.mergeWith import org.koitharu.kotatsu.parsers.util.mergeWith
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import java.net.IDN import java.net.IDN
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -26,11 +23,11 @@ class CommonHeadersInterceptor @Inject constructor(
private val mangaLoaderContextLazy: Lazy<MangaLoaderContextImpl>, private val mangaLoaderContextLazy: Lazy<MangaLoaderContextImpl>,
) : Interceptor { ) : Interceptor {
override fun intercept(chain: Chain): Response { override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request() val request = chain.request()
val source = request.tag(MangaSource::class.java) val source = request.tag(MangaSource::class.java)
val repository = if (source != null) { val repository = if (source != null) {
mangaRepositoryFactoryLazy.get().create(source) as? ParserMangaRepository mangaRepositoryFactoryLazy.get().create(source) as? RemoteMangaRepository
} else { } else {
if (BuildConfig.DEBUG) { if (BuildConfig.DEBUG) {
Log.w("Http", "Request without source tag: ${request.url}") Log.w("Http", "Request without source tag: ${request.url}")
@@ -49,7 +46,7 @@ class CommonHeadersInterceptor @Inject constructor(
headersBuilder.trySet(CommonHeaders.REFERER, "https://$idn/") headersBuilder.trySet(CommonHeaders.REFERER, "https://$idn/")
} }
val newRequest = request.newBuilder().headers(headersBuilder.build()).build() val newRequest = request.newBuilder().headers(headersBuilder.build()).build()
return repository?.interceptSafe(ProxyChain(chain, newRequest)) ?: chain.proceed(newRequest) return repository?.intercept(ProxyChain(chain, newRequest)) ?: chain.proceed(newRequest)
} }
private fun Headers.Builder.trySet(name: String, value: String) = try { private fun Headers.Builder.trySet(name: String, value: String) = try {
@@ -58,21 +55,10 @@ class CommonHeadersInterceptor @Inject constructor(
e.printStackTraceDebug() e.printStackTraceDebug()
} }
private fun Interceptor.interceptSafe(chain: Chain): Response = runCatchingCancellable {
intercept(chain)
}.getOrElse { e ->
if (e is IOException) {
throw e
} else {
// only IOException can be safely thrown from an Interceptor
throw IOException("Error in interceptor: ${e.message}", e)
}
}
private class ProxyChain( private class ProxyChain(
private val delegate: Chain, private val delegate: Interceptor.Chain,
private val request: Request, private val request: Request,
) : Chain by delegate { ) : Interceptor.Chain by delegate {
override fun request(): Request = request override fun request(): Request = request
} }

View File

@@ -83,11 +83,6 @@ class DoHManager(
tryGetByIp("2a10:50c0::2:ff"), tryGetByIp("2a10:50c0::2:ff"),
), ),
).build() ).build()
DoHProvider.ZERO_MS -> DnsOverHttps.Builder().client(bootstrapClient)
.url("https://2ca4h4crra.cloudflare-gateway.com/dns-query".toHttpUrl())
.resolvePublicAddresses(true)
.build()
} }
private fun tryGetByIp(ip: String): InetAddress? = try { private fun tryGetByIp(ip: String): InetAddress? = try {

View File

@@ -2,5 +2,5 @@ package org.koitharu.kotatsu.core.network
enum class DoHProvider { enum class DoHProvider {
NONE, GOOGLE, CLOUDFLARE, ADGUARD, ZERO_MS NONE, GOOGLE, CLOUDFLARE, ADGUARD
} }

View File

@@ -0,0 +1,106 @@
package org.koitharu.kotatsu.core.network
import android.util.Log
import androidx.collection.ArraySet
import coil.intercept.Interceptor
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
import okhttp3.Request
import okhttp3.Response
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.ext.ensureSuccess
import org.koitharu.kotatsu.core.util.ext.isHttpOrHttps
import org.koitharu.kotatsu.parsers.util.await
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import java.util.Collections
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class ImageProxyInterceptor @Inject constructor(
private val settings: AppSettings,
) : Interceptor {
private val blacklist = Collections.synchronizedSet(ArraySet<String>())
override suspend fun intercept(chain: Interceptor.Chain): ImageResult {
val request = chain.request
if (!settings.isImagesProxyEnabled) {
return chain.proceed(request)
}
val url: HttpUrl? = when (val data = request.data) {
is HttpUrl -> data
is String -> data.toHttpUrlOrNull()
else -> null
}
if (url == null || !url.isHttpOrHttps || url.host in blacklist) {
return chain.proceed(request)
}
val newUrl = HttpUrl.Builder()
.scheme("https")
.host("wsrv.nl")
.addQueryParameter("url", url.toString())
.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()) }
}
val newRequest = request.newBuilder()
.data(newUrl.build())
.build()
val result = chain.proceed(newRequest)
return if (result is SuccessResult) {
result
} else {
logDebug((result as? ErrorResult)?.throwable)
chain.proceed(request).also {
if (it is SuccessResult) {
blacklist.add(url.host)
}
}
}
}
suspend fun interceptPageRequest(request: Request, okHttp: OkHttpClient): Response {
if (!settings.isImagesProxyEnabled) {
return okHttp.newCall(request).await()
}
val sourceUrl = request.url
val targetUrl = HttpUrl.Builder()
.scheme("https")
.host("wsrv.nl")
.addQueryParameter("url", sourceUrl.toString())
.addQueryParameter("we", null)
val newRequest = request.newBuilder()
.url(targetUrl.build())
.build()
return runCatchingCancellable {
okHttp.doCall(newRequest)
}.recover {
logDebug(it)
okHttp.doCall(request).also {
blacklist.add(sourceUrl.host)
}
}.getOrThrow()
}
private suspend fun OkHttpClient.doCall(request: Request): Response {
return newCall(request).await().ensureSuccess()
}
private fun logDebug(e: Throwable?) {
if (BuildConfig.DEBUG) {
Log.w("ImageProxy", e.toString())
}
}
}

View File

@@ -13,9 +13,8 @@ import okhttp3.internal.canParseAsIpAddress
import okhttp3.internal.closeQuietly import okhttp3.internal.closeQuietly
import okhttp3.internal.publicsuffix.PublicSuffixDatabase import okhttp3.internal.publicsuffix.PublicSuffixDatabase
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.parser.ParserMangaRepository import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.parsers.model.MangaParserSource
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import java.util.EnumMap import java.util.EnumMap
import javax.inject.Inject import javax.inject.Inject
@@ -27,8 +26,8 @@ class MirrorSwitchInterceptor @Inject constructor(
private val settings: AppSettings, private val settings: AppSettings,
) : Interceptor { ) : Interceptor {
private val locks = EnumMap<MangaParserSource, Any>(MangaParserSource::class.java) private val locks = EnumMap<MangaSource, Any>(MangaSource::class.java)
private val blacklist = EnumMap<MangaParserSource, MutableSet<String>>(MangaParserSource::class.java) private val blacklist = EnumMap<MangaSource, MutableSet<String>>(MangaSource::class.java)
val isEnabled: Boolean val isEnabled: Boolean
get() = settings.isMirrorSwitchingAvailable get() = settings.isMirrorSwitchingAvailable
@@ -54,7 +53,7 @@ class MirrorSwitchInterceptor @Inject constructor(
} }
} }
suspend fun trySwitchMirror(repository: ParserMangaRepository): Boolean = runInterruptible(Dispatchers.Default) { suspend fun trySwitchMirror(repository: RemoteMangaRepository): Boolean = runInterruptible(Dispatchers.Default) {
if (!isEnabled) { if (!isEnabled) {
return@runInterruptible false return@runInterruptible false
} }
@@ -76,14 +75,14 @@ class MirrorSwitchInterceptor @Inject constructor(
} }
} }
fun rollback(repository: ParserMangaRepository, oldMirror: String) = synchronized(obtainLock(repository.source)) { fun rollback(repository: RemoteMangaRepository, oldMirror: String) = synchronized(obtainLock(repository.source)) {
blacklist[repository.source]?.remove(oldMirror) blacklist[repository.source]?.remove(oldMirror)
repository.domain = oldMirror repository.domain = oldMirror
} }
private fun trySwitchMirror(request: Request, chain: Interceptor.Chain): Response? { private fun trySwitchMirror(request: Request, chain: Interceptor.Chain): Response? {
val source = request.tag(MangaSource::class.java) ?: return null val source = request.tag(MangaSource::class.java) ?: return null
val repository = mangaRepositoryFactoryLazy.get().create(source) as? ParserMangaRepository ?: return null val repository = mangaRepositoryFactoryLazy.get().create(source) as? RemoteMangaRepository ?: return null
val mirrors = repository.getAvailableMirrors() val mirrors = repository.getAvailableMirrors()
if (mirrors.isEmpty()) { if (mirrors.isEmpty()) {
return null return null
@@ -94,7 +93,7 @@ class MirrorSwitchInterceptor @Inject constructor(
} }
private fun tryMirrors( private fun tryMirrors(
repository: ParserMangaRepository, repository: RemoteMangaRepository,
mirrors: List<String>, mirrors: List<String>,
chain: Interceptor.Chain, chain: Interceptor.Chain,
request: Request, request: Request,
@@ -146,15 +145,15 @@ class MirrorSwitchInterceptor @Inject constructor(
return source().readByteArray().toResponseBody(contentType()) return source().readByteArray().toResponseBody(contentType())
} }
private fun obtainLock(source: MangaParserSource): Any = locks.getOrPut(source) { private fun obtainLock(source: MangaSource): Any = locks.getOrPut(source) {
Any() Any()
} }
private fun isBlacklisted(source: MangaParserSource, domain: String): Boolean { private fun isBlacklisted(source: MangaSource, domain: String): Boolean {
return blacklist[source]?.contains(domain) == true return blacklist[source]?.contains(domain) == true
} }
private fun addToBlacklist(source: MangaParserSource, domain: String) { private fun addToBlacklist(source: MangaSource, domain: String) {
blacklist.getOrPut(source) { blacklist.getOrPut(source) {
ArraySet(2) ArraySet(2)
}.add(domain) }.add(domain)

View File

@@ -15,8 +15,6 @@ import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.network.cookies.AndroidCookieJar import org.koitharu.kotatsu.core.network.cookies.AndroidCookieJar
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
import org.koitharu.kotatsu.core.network.cookies.PreferencesCookieJar import org.koitharu.kotatsu.core.network.cookies.PreferencesCookieJar
import org.koitharu.kotatsu.core.network.imageproxy.ImageProxyInterceptor
import org.koitharu.kotatsu.core.network.imageproxy.RealImageProxyInterceptor
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.ext.assertNotInMainThread import org.koitharu.kotatsu.core.util.ext.assertNotInMainThread
import org.koitharu.kotatsu.local.data.LocalStorageManager import org.koitharu.kotatsu.local.data.LocalStorageManager
@@ -31,9 +29,6 @@ interface NetworkModule {
@Binds @Binds
fun bindCookieJar(androidCookieJar: MutableCookieJar): CookieJar fun bindCookieJar(androidCookieJar: MutableCookieJar): CookieJar
@Binds
fun bindImageProxyInterceptor(impl: RealImageProxyInterceptor): ImageProxyInterceptor
companion object { companion object {
@Provides @Provides

View File

@@ -1,87 +0,0 @@
package org.koitharu.kotatsu.core.network.imageproxy
import android.util.Log
import androidx.collection.ArraySet
import coil.intercept.Interceptor
import coil.network.HttpException
import coil.request.ErrorResult
import coil.request.ImageRequest
import coil.request.ImageResult
import coil.request.SuccessResult
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import org.jsoup.HttpStatusException
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.exceptions.CloudFlareBlockedException
import org.koitharu.kotatsu.core.util.ext.ensureSuccess
import org.koitharu.kotatsu.core.util.ext.isHttpOrHttps
import org.koitharu.kotatsu.parsers.util.await
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import java.net.HttpURLConnection
import java.util.Collections
abstract class BaseImageProxyInterceptor : ImageProxyInterceptor {
private val blacklist = Collections.synchronizedSet(ArraySet<String>())
final override suspend fun intercept(chain: Interceptor.Chain): ImageResult {
val request = chain.request
val url: HttpUrl? = when (val data = request.data) {
is HttpUrl -> data
is String -> data.toHttpUrlOrNull()
else -> null
}
if (url == null || !url.isHttpOrHttps || url.host in blacklist) {
return chain.proceed(request)
}
val newRequest = onInterceptImageRequest(request, url)
return when (val result = chain.proceed(newRequest)) {
is SuccessResult -> result
is ErrorResult -> {
logDebug(result.throwable, newRequest.data)
chain.proceed(request).also {
if (it is SuccessResult && result.throwable.isBlockedByServer()) {
blacklist.add(url.host)
}
}
}
}
}
final override suspend fun interceptPageRequest(request: Request, okHttp: OkHttpClient): Response {
val newRequest = onInterceptPageRequest(request)
return runCatchingCancellable {
okHttp.doCall(newRequest)
}.recover { error ->
logDebug(error, newRequest.url)
okHttp.doCall(request).also {
if (error.isBlockedByServer()) {
blacklist.add(request.url.host)
}
}
}.getOrThrow()
}
protected abstract suspend fun onInterceptImageRequest(request: ImageRequest, url: HttpUrl): ImageRequest
protected abstract suspend fun onInterceptPageRequest(request: Request): Request
private suspend fun OkHttpClient.doCall(request: Request): Response {
return newCall(request).await().ensureSuccess()
}
private fun logDebug(e: Throwable, url: Any) {
if (BuildConfig.DEBUG) {
Log.w("ImageProxy", "${e.message}: $url", e)
}
}
private fun Throwable.isBlockedByServer(): Boolean {
return this is CloudFlareBlockedException
|| (this is HttpException && response.code == HttpURLConnection.HTTP_FORBIDDEN)
|| (this is HttpStatusException && statusCode == HttpURLConnection.HTTP_FORBIDDEN)
}
}

View File

@@ -1,11 +0,0 @@
package org.koitharu.kotatsu.core.network.imageproxy
import coil.intercept.Interceptor
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
interface ImageProxyInterceptor : Interceptor {
suspend fun interceptPageRequest(request: Request, okHttp: OkHttpClient): Response
}

View File

@@ -1,42 +0,0 @@
package org.koitharu.kotatsu.core.network.imageproxy
import coil.intercept.Interceptor
import coil.request.ImageResult
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.plus
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.observeAsStateFlow
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
import org.koitharu.kotatsu.parsers.util.await
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class RealImageProxyInterceptor @Inject constructor(
private val settings: AppSettings,
) : ImageProxyInterceptor {
private val delegate = settings.observeAsStateFlow(
scope = processLifecycleScope + Dispatchers.Default,
key = AppSettings.KEY_IMAGES_PROXY,
valueProducer = { createDelegate() },
)
override suspend fun intercept(chain: Interceptor.Chain): ImageResult {
return delegate.value?.intercept(chain) ?: chain.proceed(chain.request)
}
override suspend fun interceptPageRequest(request: Request, okHttp: OkHttpClient): Response {
return delegate.value?.interceptPageRequest(request, okHttp) ?: okHttp.newCall(request).await()
}
private fun createDelegate(): ImageProxyInterceptor? = when (val proxy = settings.imagesProxy) {
-1 -> null
0 -> WsrvNlProxyInterceptor()
1 -> ZeroMsProxyInterceptor()
else -> error("Unsupported images proxy $proxy")
}
}

View File

@@ -1,40 +0,0 @@
package org.koitharu.kotatsu.core.network.imageproxy
import coil.request.ImageRequest
import coil.size.Dimension
import coil.size.isOriginal
import okhttp3.HttpUrl
import okhttp3.Request
class WsrvNlProxyInterceptor : BaseImageProxyInterceptor() {
override suspend fun onInterceptImageRequest(request: ImageRequest, url: HttpUrl): ImageRequest {
val newUrl = HttpUrl.Builder()
.scheme("https")
.host("wsrv.nl")
.addQueryParameter("url", url.toString())
.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()) }
}
return request.newBuilder()
.data(newUrl.build())
.build()
}
override suspend fun onInterceptPageRequest(request: Request): Request {
val sourceUrl = request.url
val targetUrl = HttpUrl.Builder()
.scheme("https")
.host("wsrv.nl")
.addQueryParameter("url", sourceUrl.toString())
.addQueryParameter("we", null)
return request.newBuilder()
.url(targetUrl.build())
.build()
}
}

View File

@@ -1,26 +0,0 @@
package org.koitharu.kotatsu.core.network.imageproxy
import coil.request.ImageRequest
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Request
class ZeroMsProxyInterceptor : BaseImageProxyInterceptor() {
override suspend fun onInterceptImageRequest(request: ImageRequest, url: HttpUrl): ImageRequest {
if (url.host == "x.0ms.dev" || url.host == "0ms.dev") {
return request
}
val newUrl = ("https://x.0ms.dev/q70/$url").toHttpUrl()
return request.newBuilder()
.data(newUrl)
.build()
}
override suspend fun onInterceptPageRequest(request: Request): Request {
val newUrl = ("https://x.0ms.dev/q70/${request.url}").toHttpUrl()
return request.newBuilder()
.url(newUrl)
.build()
}
}

View File

@@ -21,7 +21,6 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.db.TABLE_HISTORY import org.koitharu.kotatsu.core.db.TABLE_HISTORY
import org.koitharu.kotatsu.core.model.getTitle
import org.koitharu.kotatsu.core.parser.MangaDataRepository import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.parser.favicon.faviconUri import org.koitharu.kotatsu.core.parser.favicon.faviconUri
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
@@ -174,10 +173,9 @@ class AppShortcutManager @Inject constructor(
onSuccess = { IconCompat.createWithAdaptiveBitmap(it) }, onSuccess = { IconCompat.createWithAdaptiveBitmap(it) },
onFailure = { IconCompat.createWithResource(context, R.drawable.ic_shortcut_default) }, onFailure = { IconCompat.createWithResource(context, R.drawable.ic_shortcut_default) },
) )
val title = source.getTitle(context)
ShortcutInfoCompat.Builder(context, source.name) ShortcutInfoCompat.Builder(context, source.name)
.setShortLabel(title) .setShortLabel(source.title)
.setLongLabel(title) .setLongLabel(source.title)
.setIcon(icon) .setIcon(icon)
.setLongLived(true) .setLongLived(true)
.setIntent(MangaListActivity.newIntent(context, source)) .setIntent(MangaListActivity.newIntent(context, source))

View File

@@ -1,43 +0,0 @@
package org.koitharu.kotatsu.core.parser
import android.graphics.Canvas
import org.koitharu.kotatsu.parsers.bitmap.Bitmap
import org.koitharu.kotatsu.parsers.bitmap.Rect
import java.io.OutputStream
import android.graphics.Bitmap as AndroidBitmap
import android.graphics.Rect as AndroidRect
class BitmapWrapper private constructor(
private val androidBitmap: AndroidBitmap,
) : Bitmap {
private val canvas by lazy { Canvas(androidBitmap) } // is not always used, so initialized lazily
override val height: Int
get() = androidBitmap.height
override val width: Int
get() = androidBitmap.width
override fun drawBitmap(sourceBitmap: Bitmap, src: Rect, dst: Rect) {
val androidSourceBitmap = (sourceBitmap as BitmapWrapper).androidBitmap
canvas.drawBitmap(androidSourceBitmap, src.toAndroidRect(), dst.toAndroidRect(), null)
}
fun compressTo(output: OutputStream) {
androidBitmap.compress(AndroidBitmap.CompressFormat.PNG, 100, output)
}
companion object {
fun create(width: Int, height: Int): Bitmap = BitmapWrapper(
AndroidBitmap.createBitmap(width, height, AndroidBitmap.Config.ARGB_8888),
)
fun create(bitmap: AndroidBitmap): Bitmap = BitmapWrapper(
if (bitmap.isMutable) bitmap else bitmap.copy(AndroidBitmap.Config.ARGB_8888, true),
)
private fun Rect.toAndroidRect() = AndroidRect(left, top, right, bottom)
}
}

View File

@@ -1,104 +0,0 @@
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
import kotlinx.coroutines.MainCoroutineDispatcher
import kotlinx.coroutines.async
import kotlinx.coroutines.currentCoroutineContext
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.cache.MemoryContentCache
import org.koitharu.kotatsu.core.cache.SafeDeferred
import org.koitharu.kotatsu.core.util.MultiMutex
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
abstract class CachingMangaRepository(
private val cache: MemoryContentCache,
) : MangaRepository {
private val detailsMutex = MultiMutex<Long>()
private val relatedMangaMutex = MultiMutex<Long>()
private val pagesMutex = MultiMutex<Long>()
final override suspend fun getDetails(manga: Manga): Manga = getDetails(manga, CachePolicy.ENABLED)
final override suspend fun getPages(chapter: MangaChapter): List<MangaPage> = pagesMutex.withLock(chapter.id) {
cache.getPages(source, chapter.url)?.let { return it }
val pages = asyncSafe {
getPagesImpl(chapter).distinctById()
}
cache.putPages(source, chapter.url, pages)
pages
}.await()
final override suspend fun getRelated(seed: Manga): List<Manga> = relatedMangaMutex.withLock(seed.id) {
cache.getRelatedManga(source, seed.url)?.let { return it }
val related = asyncSafe {
getRelatedMangaImpl(seed).filterNot { it.id == seed.id }
}
cache.putRelatedManga(source, seed.url, related)
related
}.await()
suspend fun getDetails(manga: Manga, cachePolicy: CachePolicy): Manga = detailsMutex.withLock(manga.id) {
if (cachePolicy.readEnabled) {
cache.getDetails(source, manga.url)?.let { return it }
}
val details = asyncSafe {
getDetailsImpl(manga)
}
if (cachePolicy.writeEnabled) {
cache.putDetails(source, manga.url, details)
}
details
}.await()
suspend fun peekDetails(manga: Manga): Manga? {
return cache.getDetails(source, manga.url)
}
fun invalidateCache() {
cache.clear(source)
}
protected abstract suspend fun getDetailsImpl(manga: Manga): Manga
protected abstract suspend fun getRelatedMangaImpl(seed: Manga): List<Manga>
protected abstract suspend fun getPagesImpl(chapter: MangaChapter): List<MangaPage>
private suspend fun <T> asyncSafe(block: suspend CoroutineScope.() -> T): SafeDeferred<T> {
var dispatcher = currentCoroutineContext()[CoroutineDispatcher.Key]
if (dispatcher == null || dispatcher is MainCoroutineDispatcher) {
dispatcher = Dispatchers.Default
}
return SafeDeferred(
processLifecycleScope.async(dispatcher) {
runCatchingCancellable { block() }
},
)
}
private fun List<MangaPage>.distinctById(): List<MangaPage> {
if (isEmpty()) {
return emptyList()
}
val result = ArrayList<MangaPage>(size)
val set = MutableLongSet(size)
for (page in this) {
if (set.add(page.id)) {
result.add(page)
} else if (BuildConfig.DEBUG) {
Log.w(null, "Duplicate page: $page")
}
}
return result
}
}

View File

@@ -8,7 +8,7 @@ import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaListFilter import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaParserSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
import java.util.EnumSet import java.util.EnumSet
@@ -16,7 +16,7 @@ import java.util.EnumSet
/** /**
* This parser is just for parser development, it should not be used in releases * This parser is just for parser development, it should not be used in releases
*/ */
class DummyParser(context: MangaLoaderContext) : MangaParser(context, MangaParserSource.DUMMY) { class DummyParser(context: MangaLoaderContext) : MangaParser(context, MangaSource.DUMMY) {
override val configKeyDomain: ConfigKey.Domain override val configKeyDomain: ConfigKey.Domain
get() = ConfigKey.Domain("localhost") get() = ConfigKey.Domain("localhost")

View File

@@ -1,51 +0,0 @@
package org.koitharu.kotatsu.core.parser
import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException
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.util.EnumSet
import java.util.Locale
class EmptyMangaRepository(override val source: MangaSource) : MangaRepository {
override val sortOrders: Set<SortOrder>
get() = EnumSet.allOf(SortOrder::class.java)
override val states: Set<MangaState>
get() = emptySet()
override val contentRatings: Set<ContentRating>
get() = emptySet()
override var defaultSortOrder: SortOrder
get() = SortOrder.NEWEST
set(value) = Unit
override val isMultipleTagsSupported: Boolean
get() = false
override val isTagsExclusionSupported: Boolean
get() = false
override val isSearchSupported: Boolean
get() = false
override suspend fun getList(offset: Int, filter: MangaListFilter?): List<Manga> = stub(null)
override suspend fun getDetails(manga: Manga): Manga = stub(manga)
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> = stub(null)
override suspend fun getPageUrl(page: MangaPage): String = stub(null)
override suspend fun getTags(): Set<MangaTag> = stub(null)
override suspend fun getLocales(): Set<Locale> = stub(null)
override suspend fun getRelated(seed: Manga): List<Manga> = stub(seed)
private fun stub(manga: Manga?): Nothing {
throw UnsupportedSourceException("This manga source is not supported", manga)
}
}

View File

@@ -12,7 +12,6 @@ import org.koitharu.kotatsu.core.db.entity.toEntities
import org.koitharu.kotatsu.core.db.entity.toEntity import org.koitharu.kotatsu.core.db.entity.toEntity
import org.koitharu.kotatsu.core.db.entity.toManga import org.koitharu.kotatsu.core.db.entity.toManga
import org.koitharu.kotatsu.core.db.entity.toMangaTags import org.koitharu.kotatsu.core.db.entity.toMangaTags
import org.koitharu.kotatsu.core.model.LocalMangaSource
import org.koitharu.kotatsu.core.model.isLocal import org.koitharu.kotatsu.core.model.isLocal
import org.koitharu.kotatsu.core.prefs.ReaderMode import org.koitharu.kotatsu.core.prefs.ReaderMode
import org.koitharu.kotatsu.core.util.ext.toFileOrNull import org.koitharu.kotatsu.core.util.ext.toFileOrNull
@@ -102,7 +101,7 @@ class MangaDataRepository @Inject constructor(
suspend fun cleanupLocalManga() { suspend fun cleanupLocalManga() {
val dao = db.getMangaDao() val dao = db.getMangaDao()
val broken = dao.findAllBySource(LocalMangaSource.name) val broken = dao.findAllBySource(MangaSource.LOCAL.name)
.filter { x -> x.manga.url.toUri().toFileOrNull()?.exists() == false } .filter { x -> x.manga.url.toUri().toFileOrNull()?.exists() == false }
if (broken.isNotEmpty()) { if (broken.isNotEmpty()) {
dao.delete(broken.map { it.manga }) dao.delete(broken.map { it.manga })

View File

@@ -4,11 +4,10 @@ import android.net.Uri
import coil.request.CachePolicy import coil.request.CachePolicy
import dagger.Reusable import dagger.Reusable
import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.model.UnknownMangaSource
import org.koitharu.kotatsu.core.model.isNsfw
import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
import org.koitharu.kotatsu.parsers.exception.NotFoundException import org.koitharu.kotatsu.parsers.exception.NotFoundException
import org.koitharu.kotatsu.parsers.model.ContentType
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaListFilter import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
@@ -37,7 +36,7 @@ class MangaLinkResolver @Inject constructor(
require(uri.pathSegments.singleOrNull() == "manga") { "Invalid url" } require(uri.pathSegments.singleOrNull() == "manga") { "Invalid url" }
val sourceName = requireNotNull(uri.getQueryParameter("source")) { "Source is not specified" } val sourceName = requireNotNull(uri.getQueryParameter("source")) { "Source is not specified" }
val source = MangaSource(sourceName) val source = MangaSource(sourceName)
require(source != UnknownMangaSource) { "Manga source $sourceName is not supported" } require(source != MangaSource.DUMMY) { "Manga source $sourceName is not supported" }
val repo = repositoryFactory.create(source) val repo = repositoryFactory.create(source)
return repo.findExact( return repo.findExact(
url = uri.getQueryParameter("url"), url = uri.getQueryParameter("url"),
@@ -52,7 +51,7 @@ class MangaLinkResolver @Inject constructor(
val host = uri.host ?: return null val host = uri.host ?: return null
val repo = sourcesRepository.allMangaSources.asSequence() val repo = sourcesRepository.allMangaSources.asSequence()
.map { source -> .map { source ->
repositoryFactory.create(source) as ParserMangaRepository repositoryFactory.create(source) as RemoteMangaRepository
}.find { repo -> }.find { repo ->
host in repo.domains host in repo.domains
} ?: return null } ?: return null
@@ -86,7 +85,7 @@ class MangaLinkResolver @Inject constructor(
} }
private suspend fun MangaRepository.getDetailsNoCache(manga: Manga): Manga { private suspend fun MangaRepository.getDetailsNoCache(manga: Manga): Manga {
return if (this is ParserMangaRepository) { return if (this is RemoteMangaRepository) {
getDetails(manga, CachePolicy.READ_ONLY) getDetails(manga, CachePolicy.READ_ONLY)
} else { } else {
getDetails(manga) getDetails(manga)
@@ -109,7 +108,7 @@ class MangaLinkResolver @Inject constructor(
url = url, url = url,
publicUrl = "", publicUrl = "",
rating = 0.0f, rating = 0.0f,
isNsfw = source.isNsfw(), isNsfw = source.contentType == ContentType.HENTAI,
coverUrl = "", coverUrl = "",
tags = emptySet(), tags = emptySet(),
state = null, state = null,

View File

@@ -2,7 +2,6 @@ package org.koitharu.kotatsu.core.parser
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.graphics.BitmapFactory
import android.util.Base64 import android.util.Base64
import android.webkit.WebView import android.webkit.WebView
import androidx.annotation.MainThread import androidx.annotation.MainThread
@@ -11,21 +10,15 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Response
import okhttp3.ResponseBody.Companion.asResponseBody
import okio.Buffer
import org.koitharu.kotatsu.core.network.MangaHttpClient import org.koitharu.kotatsu.core.network.MangaHttpClient
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
import org.koitharu.kotatsu.core.prefs.SourceSettings import org.koitharu.kotatsu.core.prefs.SourceSettings
import org.koitharu.kotatsu.core.util.ext.configureForParser import org.koitharu.kotatsu.core.util.ext.configureForParser
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.requireBody
import org.koitharu.kotatsu.core.util.ext.sanitizeHeaderValue import org.koitharu.kotatsu.core.util.ext.sanitizeHeaderValue
import org.koitharu.kotatsu.core.util.ext.toList import org.koitharu.kotatsu.core.util.ext.toList
import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.bitmap.Bitmap
import org.koitharu.kotatsu.parsers.config.MangaSourceConfig import org.koitharu.kotatsu.parsers.config.MangaSourceConfig
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.network.UserAgents import org.koitharu.kotatsu.parsers.network.UserAgents
@@ -75,27 +68,6 @@ class MangaLoaderContextImpl @Inject constructor(
return LocaleListCompat.getAdjustedDefault().toList() return LocaleListCompat.getAdjustedDefault().toList()
} }
override fun redrawImageResponse(response: Response, redraw: (image: Bitmap) -> Bitmap): Response {
val image = response.requireBody().byteStream()
val opts = BitmapFactory.Options()
opts.inMutable = true
val bitmap = BitmapFactory.decodeStream(image, null, opts) ?: error("Cannot decode bitmap")
val result = redraw(BitmapWrapper.create(bitmap)) as BitmapWrapper
val body = Buffer().also {
result.compressTo(it.outputStream())
}.asResponseBody("image/jpeg".toMediaType())
return response.newBuilder()
.body(body)
.build()
}
override fun createBitmap(width: Int, height: Int): Bitmap {
return BitmapWrapper.create(width, height)
}
@MainThread @MainThread
private fun obtainWebView(): WebView { private fun obtainWebView(): WebView {
return webViewCached?.get() ?: WebView(androidContext).also { return webViewCached?.get() ?: WebView(androidContext).also {

View File

@@ -2,11 +2,12 @@ package org.koitharu.kotatsu.core.parser
import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaParser import org.koitharu.kotatsu.parsers.MangaParser
import org.koitharu.kotatsu.parsers.model.MangaParserSource import org.koitharu.kotatsu.parsers.model.MangaSource
fun MangaParser(source: MangaParserSource, loaderContext: MangaLoaderContext): MangaParser { fun MangaParser(source: MangaSource, loaderContext: MangaLoaderContext): MangaParser {
return when (source) { return if (source == MangaSource.DUMMY) {
MangaParserSource.DUMMY -> DummyParser(loaderContext) DummyParser(loaderContext)
else -> loaderContext.newParserInstance(source) } else {
loaderContext.newParserInstance(source)
} }
} }

View File

@@ -1,16 +1,8 @@
package org.koitharu.kotatsu.core.parser package org.koitharu.kotatsu.core.parser
import android.content.Context
import androidx.annotation.AnyThread import androidx.annotation.AnyThread
import androidx.collection.ArrayMap
import dagger.hilt.android.qualifiers.ApplicationContext
import org.koitharu.kotatsu.core.cache.MemoryContentCache import org.koitharu.kotatsu.core.cache.MemoryContentCache
import org.koitharu.kotatsu.core.model.LocalMangaSource
import org.koitharu.kotatsu.core.model.MangaSourceInfo
import org.koitharu.kotatsu.core.model.UnknownMangaSource
import org.koitharu.kotatsu.core.network.MirrorSwitchInterceptor import org.koitharu.kotatsu.core.network.MirrorSwitchInterceptor
import org.koitharu.kotatsu.core.parser.external.ExternalMangaRepository
import org.koitharu.kotatsu.core.parser.external.ExternalMangaSource
import org.koitharu.kotatsu.local.data.LocalMangaRepository import org.koitharu.kotatsu.local.data.LocalMangaRepository
import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.model.ContentRating import org.koitharu.kotatsu.parsers.model.ContentRating
@@ -18,12 +10,12 @@ import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaListFilter import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaParserSource
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaState import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
import java.lang.ref.WeakReference import java.lang.ref.WeakReference
import java.util.EnumMap
import java.util.Locale import java.util.Locale
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -61,60 +53,32 @@ interface MangaRepository {
suspend fun getRelated(seed: Manga): List<Manga> suspend fun getRelated(seed: Manga): List<Manga>
suspend fun find(manga: Manga): Manga? {
val list = getList(0, MangaListFilter.Search(manga.title))
return list.find { x -> x.id == manga.id }
}
@Singleton @Singleton
class Factory @Inject constructor( class Factory @Inject constructor(
@ApplicationContext private val context: Context,
private val localMangaRepository: LocalMangaRepository, private val localMangaRepository: LocalMangaRepository,
private val loaderContext: MangaLoaderContext, private val loaderContext: MangaLoaderContext,
private val contentCache: MemoryContentCache, private val contentCache: MemoryContentCache,
private val mirrorSwitchInterceptor: MirrorSwitchInterceptor, private val mirrorSwitchInterceptor: MirrorSwitchInterceptor,
) { ) {
private val cache = ArrayMap<MangaSource, WeakReference<MangaRepository>>() private val cache = EnumMap<MangaSource, WeakReference<RemoteMangaRepository>>(MangaSource::class.java)
@AnyThread @AnyThread
fun create(source: MangaSource): MangaRepository { fun create(source: MangaSource): MangaRepository {
when (source) { if (source == MangaSource.LOCAL) {
is MangaSourceInfo -> return create(source.mangaSource) return localMangaRepository
LocalMangaSource -> return localMangaRepository
UnknownMangaSource -> return EmptyMangaRepository(source)
} }
cache[source]?.get()?.let { return it } cache[source]?.get()?.let { return it }
return synchronized(cache) { return synchronized(cache) {
cache[source]?.get()?.let { return it } cache[source]?.get()?.let { return it }
val repository = createRepository(source) val repository = RemoteMangaRepository(
if (repository != null) { parser = MangaParser(source, loaderContext),
cache[source] = WeakReference(repository)
repository
} else {
EmptyMangaRepository(source)
}
}
}
private fun createRepository(source: MangaSource): MangaRepository? = when (source) {
is MangaParserSource -> ParserMangaRepository(
parser = MangaParser(source, loaderContext),
cache = contentCache,
mirrorSwitchInterceptor = mirrorSwitchInterceptor,
)
is ExternalMangaSource -> if (source.isAvailable(context)) {
ExternalMangaRepository(
contentResolver = context.contentResolver,
source = source,
cache = contentCache, cache = contentCache,
mirrorSwitchInterceptor = mirrorSwitchInterceptor,
) )
} else { cache[source] = WeakReference(repository)
EmptyMangaRepository(source) repository
} }
else -> null
} }
} }
} }

View File

@@ -1,11 +1,24 @@
package org.koitharu.kotatsu.core.parser 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
import kotlinx.coroutines.MainCoroutineDispatcher
import kotlinx.coroutines.async
import kotlinx.coroutines.currentCoroutineContext
import okhttp3.Headers import okhttp3.Headers
import okhttp3.Interceptor import okhttp3.Interceptor
import okhttp3.Response import okhttp3.Response
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.cache.MemoryContentCache import org.koitharu.kotatsu.core.cache.MemoryContentCache
import org.koitharu.kotatsu.core.cache.SafeDeferred
import org.koitharu.kotatsu.core.network.MirrorSwitchInterceptor import org.koitharu.kotatsu.core.network.MirrorSwitchInterceptor
import org.koitharu.kotatsu.core.prefs.SourceSettings import org.koitharu.kotatsu.core.prefs.SourceSettings
import org.koitharu.kotatsu.core.util.MultiMutex
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
import org.koitharu.kotatsu.parsers.MangaParser import org.koitharu.kotatsu.parsers.MangaParser
import org.koitharu.kotatsu.parsers.MangaParserAuthProvider import org.koitharu.kotatsu.parsers.MangaParserAuthProvider
import org.koitharu.kotatsu.parsers.config.ConfigKey import org.koitharu.kotatsu.parsers.config.ConfigKey
@@ -15,7 +28,7 @@ import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaListFilter import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaParserSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaState import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
@@ -23,13 +36,17 @@ import org.koitharu.kotatsu.parsers.util.domain
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import java.util.Locale import java.util.Locale
class ParserMangaRepository( class RemoteMangaRepository(
private val parser: MangaParser, private val parser: MangaParser,
private val cache: MemoryContentCache,
private val mirrorSwitchInterceptor: MirrorSwitchInterceptor, private val mirrorSwitchInterceptor: MirrorSwitchInterceptor,
cache: MemoryContentCache, ) : MangaRepository, Interceptor {
) : CachingMangaRepository(cache), Interceptor {
override val source: MangaParserSource private val detailsMutex = MultiMutex<Long>()
private val relatedMangaMutex = MultiMutex<Long>()
private val pagesMutex = MultiMutex<Long>()
override val source: MangaSource
get() = parser.source get() = parser.source
override val sortOrders: Set<SortOrder> override val sortOrders: Set<SortOrder>
@@ -82,11 +99,18 @@ class ParserMangaRepository(
} }
} }
override suspend fun getPagesImpl( override suspend fun getDetails(manga: Manga): Manga = getDetails(manga, CachePolicy.ENABLED)
chapter: MangaChapter
): List<MangaPage> = mirrorSwitchInterceptor.withMirrorSwitching { override suspend fun getPages(chapter: MangaChapter): List<MangaPage> = pagesMutex.withLock(chapter.id) {
parser.getPages(chapter) cache.getPages(source, chapter.url)?.let { return it }
} val pages = asyncSafe {
mirrorSwitchInterceptor.withMirrorSwitching {
parser.getPages(chapter).distinctById()
}
}
cache.putPages(source, chapter.url, pages)
pages
}.await()
override suspend fun getPageUrl(page: MangaPage): String = mirrorSwitchInterceptor.withMirrorSwitching { override suspend fun getPageUrl(page: MangaPage): String = mirrorSwitchInterceptor.withMirrorSwitching {
parser.getPageUrl(page) parser.getPageUrl(page)
@@ -104,10 +128,37 @@ class ParserMangaRepository(
parser.getFavicons() parser.getFavicons()
} }
override suspend fun getRelatedMangaImpl(seed: Manga): List<Manga> = parser.getRelatedManga(seed) override suspend fun getRelated(seed: Manga): List<Manga> = relatedMangaMutex.withLock(seed.id) {
cache.getRelatedManga(source, seed.url)?.let { return it }
val related = asyncSafe {
parser.getRelatedManga(seed).filterNot { it.id == seed.id }
}
cache.putRelatedManga(source, seed.url, related)
related
}.await()
override suspend fun getDetailsImpl(manga: Manga): Manga = mirrorSwitchInterceptor.withMirrorSwitching { suspend fun getDetails(manga: Manga, cachePolicy: CachePolicy): Manga = detailsMutex.withLock(manga.id) {
parser.getDetails(manga) if (cachePolicy.readEnabled) {
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)
}
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))
return list.find { x -> x.id == manga.id }
} }
fun getAuthProvider(): MangaParserAuthProvider? = parser as? MangaParserAuthProvider fun getAuthProvider(): MangaParserAuthProvider? = parser as? MangaParserAuthProvider
@@ -124,8 +175,40 @@ class ParserMangaRepository(
return getConfig().isSlowdownEnabled return getConfig().isSlowdownEnabled
} }
fun invalidateCache() {
cache.clear(source)
}
fun getConfig() = parser.config as SourceSettings fun getConfig() = parser.config as SourceSettings
private suspend fun <T> asyncSafe(block: suspend CoroutineScope.() -> T): SafeDeferred<T> {
var dispatcher = currentCoroutineContext()[CoroutineDispatcher.Key]
if (dispatcher == null || dispatcher is MainCoroutineDispatcher) {
dispatcher = Dispatchers.Default
}
return SafeDeferred(
processLifecycleScope.async(dispatcher) {
runCatchingCancellable { block() }
},
)
}
private fun List<MangaPage>.distinctById(): List<MangaPage> {
if (isEmpty()) {
return emptyList()
}
val result = ArrayList<MangaPage>(size)
val set = MutableLongSet(size)
for (page in this) {
if (set.add(page.id)) {
result.add(page)
} else if (BuildConfig.DEBUG) {
Log.w(null, "Duplicate page: $page")
}
}
return result
}
private suspend fun <R> MirrorSwitchInterceptor.withMirrorSwitching(block: suspend () -> R): R { private suspend fun <R> MirrorSwitchInterceptor.withMirrorSwitching(block: suspend () -> R): R {
if (!isEnabled) { if (!isEnabled) {
return block() return block()
@@ -137,14 +220,14 @@ class ParserMangaRepository(
if (result.isValidResult()) { if (result.isValidResult()) {
return result.getOrThrow() return result.getOrThrow()
} }
return if (trySwitchMirror(this@ParserMangaRepository)) { return if (trySwitchMirror(this@RemoteMangaRepository)) {
val newResult = runCatchingCancellable { val newResult = runCatchingCancellable {
block() block()
} }
if (newResult.isValidResult()) { if (newResult.isValidResult()) {
return newResult.getOrThrow() return newResult.getOrThrow()
} else { } else {
rollback(this@ParserMangaRepository, initialMirror) rollback(this@RemoteMangaRepository, initialMirror)
return result.getOrThrow() return result.getOrThrow()
} }
} else { } else {

View File

@@ -1,80 +0,0 @@
package org.koitharu.kotatsu.core.parser.external
import android.content.ContentResolver
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
import org.koitharu.kotatsu.core.cache.MemoryContentCache
import org.koitharu.kotatsu.core.parser.CachingMangaRepository
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
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.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder
import java.util.EnumSet
import java.util.Locale
class ExternalMangaRepository(
private val contentResolver: ContentResolver,
override val source: ExternalMangaSource,
cache: MemoryContentCache,
) : CachingMangaRepository(cache) {
private val contentSource = ExternalPluginContentSource(contentResolver, source)
private val capabilities by lazy {
runCatching {
contentSource.getCapabilities()
}.onFailure {
it.printStackTraceDebug()
}.getOrNull()
}
override val sortOrders: Set<SortOrder>
get() = capabilities?.availableSortOrders ?: EnumSet.of(SortOrder.ALPHABETICAL)
override val states: Set<MangaState>
get() = capabilities?.availableStates.orEmpty()
override val contentRatings: Set<ContentRating>
get() = capabilities?.availableContentRating.orEmpty()
override var defaultSortOrder: SortOrder
get() = capabilities?.defaultSortOrder ?: SortOrder.ALPHABETICAL
set(value) = Unit
override val isMultipleTagsSupported: Boolean
get() = capabilities?.isMultipleTagsSupported ?: true
override val isTagsExclusionSupported: Boolean
get() = capabilities?.isTagsExclusionSupported ?: false
override val isSearchSupported: Boolean
get() = capabilities?.isSearchSupported ?: true
override suspend fun getList(offset: Int, filter: MangaListFilter?): List<Manga> =
runInterruptible(Dispatchers.IO) {
contentSource.getList(offset, filter)
}
override suspend fun getDetailsImpl(manga: Manga): Manga = runInterruptible(Dispatchers.IO) {
contentSource.getDetails(manga)
}
override suspend fun getPagesImpl(chapter: MangaChapter): List<MangaPage> = runInterruptible(Dispatchers.IO) {
contentSource.getPages(chapter)
}
override suspend fun getPageUrl(page: MangaPage): String = page.url // TODO
override suspend fun getTags(): Set<MangaTag> = runInterruptible(Dispatchers.IO) {
contentSource.getTags()
}
override suspend fun getLocales(): Set<Locale> = emptySet() // TODO
override suspend fun getRelatedMangaImpl(seed: Manga): List<Manga> = emptyList() // TODO
}

View File

@@ -1,30 +0,0 @@
package org.koitharu.kotatsu.core.parser.external
import android.content.Context
import org.koitharu.kotatsu.parsers.model.MangaSource
data class ExternalMangaSource(
val packageName: String,
val authority: String,
) : MangaSource {
override val name: String
get() = "content:$packageName/$authority"
private var cachedName: String? = null
fun isAvailable(context: Context): Boolean {
return context.packageManager.resolveContentProvider(authority, 0)?.isEnabled == true
}
fun resolveName(context: Context): String {
cachedName?.let {
return it
}
val pm = context.packageManager
val info = pm.resolveContentProvider(authority, 0)
return info?.loadLabel(pm)?.toString()?.also {
cachedName = it
} ?: authority
}
}

View File

@@ -1,291 +0,0 @@
package org.koitharu.kotatsu.core.parser.external
import android.content.ContentResolver
import android.database.Cursor
import androidx.annotation.WorkerThread
import androidx.collection.ArraySet
import androidx.core.net.toUri
import org.jetbrains.annotations.Blocking
import org.koitharu.kotatsu.core.exceptions.IncompatiblePluginException
import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty
import org.koitharu.kotatsu.core.util.ext.toLocale
import org.koitharu.kotatsu.parsers.model.ContentRating
import org.koitharu.kotatsu.parsers.model.ContentType
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.util.find
import org.koitharu.kotatsu.parsers.util.mapNotNullToSet
import org.koitharu.kotatsu.parsers.util.splitTwoParts
import java.util.EnumSet
import java.util.Locale
class ExternalPluginContentSource(
private val contentResolver: ContentResolver,
private val source: ExternalMangaSource,
) {
@Blocking
@WorkerThread
fun getList(offset: Int, filter: MangaListFilter?): List<Manga> = runCatchingCompatibility {
val uri = "content://${source.authority}/manga".toUri().buildUpon()
uri.appendQueryParameter("offset", offset.toString())
when (filter) {
is MangaListFilter.Advanced -> {
filter.tags.forEach { uri.appendQueryParameter("tag_include", it.key) }
filter.tagsExclude.forEach { uri.appendQueryParameter("tag_exclude", it.key) }
filter.states.forEach { uri.appendQueryParameter("state", it.name) }
filter.locale?.let { uri.appendQueryParameter("locale", it.language) }
filter.contentRating.forEach { uri.appendQueryParameter("content_rating", it.name) }
}
is MangaListFilter.Search -> {
uri.appendQueryParameter("query", filter.query)
}
null -> Unit
}
contentResolver.query(uri.build(), null, null, null, filter?.sortOrder?.name)
.safe()
.use { cursor ->
val result = ArrayList<Manga>(cursor.count)
if (cursor.moveToFirst()) {
do {
result += cursor.getManga()
} while (cursor.moveToNext())
}
result
}
}
@Blocking
@WorkerThread
fun getDetails(manga: Manga) = runCatchingCompatibility {
val chapters = queryChapters(manga.url)
val details = queryDetails(manga.url)
Manga(
id = manga.id,
title = details.title.ifBlank { manga.title },
altTitle = details.altTitle.ifNullOrEmpty { manga.altTitle },
url = details.url.ifEmpty { manga.url },
publicUrl = details.publicUrl.ifEmpty { manga.publicUrl },
rating = maxOf(details.rating, manga.rating),
isNsfw = details.isNsfw,
coverUrl = details.coverUrl.ifEmpty { manga.coverUrl },
tags = details.tags + manga.tags,
state = details.state ?: manga.state,
author = details.author.ifNullOrEmpty { manga.author },
largeCoverUrl = details.largeCoverUrl.ifNullOrEmpty { manga.largeCoverUrl },
description = details.description.ifNullOrEmpty { manga.description },
chapters = chapters,
source = source,
)
}
@Blocking
@WorkerThread
fun getPages(chapter: MangaChapter): List<MangaPage> = runCatchingCompatibility {
val uri = "content://${source.authority}/chapters".toUri()
.buildUpon()
.appendPath(chapter.url)
.build()
contentResolver.query(uri, null, null, null, null)
.safe()
.use { cursor ->
val result = ArrayList<MangaPage>(cursor.count)
if (cursor.moveToFirst()) {
do {
result += MangaPage(
id = cursor.getLong(COLUMN_ID),
url = cursor.getString(COLUMN_URL),
preview = cursor.getStringOrNull(COLUMN_PREVIEW),
source = source,
)
} while (cursor.moveToNext())
}
result
}
}
@Blocking
@WorkerThread
fun getTags(): Set<MangaTag> = runCatchingCompatibility {
val uri = "content://${source.authority}/tags".toUri()
contentResolver.query(uri, null, null, null, null)
.safe()
.use { cursor ->
val result = ArraySet<MangaTag>(cursor.count)
if (cursor.moveToFirst()) {
do {
result += MangaTag(
key = cursor.getString(COLUMN_KEY),
title = cursor.getString(COLUMN_TITLE),
source = source,
)
} while (cursor.moveToNext())
}
result
}
}
fun getCapabilities(): MangaSourceCapabilities? {
val uri = "content://${source.authority}/capabilities".toUri()
return contentResolver.query(uri, null, null, null, null)
.safe()
.use { cursor ->
if (cursor.moveToFirst()) {
MangaSourceCapabilities(
availableSortOrders = cursor.getStringOrNull(COLUMN_SORT_ORDERS)
?.split(',')
?.mapNotNullTo(EnumSet.noneOf(SortOrder::class.java)) {
SortOrder.entries.find(it)
}.orEmpty(),
availableStates = cursor.getStringOrNull(COLUMN_STATES)
?.split(',')
?.mapNotNullTo(EnumSet.noneOf(MangaState::class.java)) {
MangaState.entries.find(it)
}.orEmpty(),
availableContentRating = cursor.getStringOrNull(COLUMN_CONTENT_RATING)
?.split(',')
?.mapNotNullTo(EnumSet.noneOf(ContentRating::class.java)) {
ContentRating.entries.find(it)
}.orEmpty(),
isMultipleTagsSupported = cursor.getBooleanOrDefault(COLUMN_MULTIPLE_TAGS_SUPPORTED, true),
isTagsExclusionSupported = cursor.getBooleanOrDefault(COLUMN_TAGS_EXCLUSION_SUPPORTED, false),
isSearchSupported = cursor.getBooleanOrDefault(COLUMN_SEARCH_SUPPORTED, true),
contentType = cursor.getStringOrNull(COLUMN_CONTENT_TYPE)?.let {
ContentType.entries.find(it)
} ?: ContentType.OTHER,
defaultSortOrder = cursor.getStringOrNull(COLUMN_DEFAULT_SORT_ORDER)?.let {
SortOrder.entries.find(it)
} ?: SortOrder.ALPHABETICAL,
sourceLocale = cursor.getStringOrNull(COLUMN_LOCALE)?.toLocale() ?: Locale.ROOT,
)
} else {
null
}
}
}
private fun queryDetails(url: String): Manga {
val uri = "content://${source.authority}/manga".toUri()
.buildUpon()
.appendPath(url)
.build()
return contentResolver.query(uri, null, null, null, null)
.safe()
.use { cursor ->
cursor.moveToFirst()
cursor.getManga()
}
}
private fun queryChapters(url: String): List<MangaChapter> {
val uri = "content://${source.authority}/manga/chapters".toUri()
.buildUpon()
.appendPath(url)
.build()
return contentResolver.query(uri, null, null, null, null)
.safe()
.use { cursor ->
val result = ArrayList<MangaChapter>(cursor.count)
if (cursor.moveToFirst()) {
do {
result += MangaChapter(
id = cursor.getLong(COLUMN_ID),
name = cursor.getString(COLUMN_NAME),
number = cursor.getFloatOrDefault(COLUMN_NUMBER, 0f),
volume = cursor.getIntOrDefault(COLUMN_VOLUME, 0),
url = cursor.getString(COLUMN_URL),
scanlator = cursor.getStringOrNull(COLUMN_SCANLATOR),
uploadDate = cursor.getLongOrDefault(COLUMN_UPLOAD_DATE, 0L),
branch = cursor.getStringOrNull(COLUMN_BRANCH),
source = source,
)
} while (cursor.moveToNext())
}
result
}
}
private fun SafeCursor.getManga() = Manga(
id = getLong(COLUMN_ID),
title = getString(COLUMN_TITLE),
altTitle = getStringOrNull(COLUMN_ALT_TITLE),
url = getString(COLUMN_URL),
publicUrl = getString(COLUMN_PUBLIC_URL),
rating = getFloat(COLUMN_RATING),
isNsfw = getBooleanOrDefault(COLUMN_IS_NSFW, false),
coverUrl = getString(COLUMN_COVER_URL),
tags = getStringOrNull(COLUMN_TAGS)?.split(':')?.mapNotNullToSet {
val parts = it.splitTwoParts('=') ?: return@mapNotNullToSet null
MangaTag(key = parts.first, title = parts.second, source = source)
}.orEmpty(),
state = getStringOrNull(COLUMN_STATE)?.let { MangaState.entries.find(it) },
author = getStringOrNull(COLUMN_AUTHOR),
largeCoverUrl = getStringOrNull(COLUMN_LARGE_COVER_URL),
description = getStringOrNull(COLUMN_DESCRIPTION),
chapters = emptyList(),
source = source,
)
private inline fun <R> runCatchingCompatibility(block: () -> R): R = try {
block()
} catch (e: NoSuchElementException) { // unknown column name
throw IncompatiblePluginException(source.name, e)
} catch (e: IllegalArgumentException) {
throw IncompatiblePluginException(source.name, e)
}
private fun Cursor?.safe() = SafeCursor(this ?: throw IncompatiblePluginException(source.name, null))
class MangaSourceCapabilities(
val availableSortOrders: Set<SortOrder>,
val availableStates: Set<MangaState>,
val availableContentRating: Set<ContentRating>,
val isMultipleTagsSupported: Boolean,
val isTagsExclusionSupported: Boolean,
val isSearchSupported: Boolean,
val contentType: ContentType,
val defaultSortOrder: SortOrder,
val sourceLocale: Locale,
)
private companion object {
const val COLUMN_SORT_ORDERS = "sort_orders"
const val COLUMN_STATES = "states"
const val COLUMN_CONTENT_RATING = "content_rating"
const val COLUMN_MULTIPLE_TAGS_SUPPORTED = "multiple_tags_supported"
const val COLUMN_TAGS_EXCLUSION_SUPPORTED = "tags_exclusion_supported"
const val COLUMN_SEARCH_SUPPORTED = "search_supported"
const val COLUMN_CONTENT_TYPE = "content_type"
const val COLUMN_DEFAULT_SORT_ORDER = "default_sort_order"
const val COLUMN_LOCALE = "locale"
const val COLUMN_ID = "id"
const val COLUMN_NAME = "name"
const val COLUMN_NUMBER = "number"
const val COLUMN_VOLUME = "volume"
const val COLUMN_URL = "url"
const val COLUMN_SCANLATOR = "scanlator"
const val COLUMN_UPLOAD_DATE = "upload_date"
const val COLUMN_BRANCH = "branch"
const val COLUMN_TITLE = "title"
const val COLUMN_ALT_TITLE = "alt_title"
const val COLUMN_PUBLIC_URL = "public_url"
const val COLUMN_RATING = "rating"
const val COLUMN_IS_NSFW = "is_nsfw"
const val COLUMN_COVER_URL = "cover_url"
const val COLUMN_TAGS = "tags"
const val COLUMN_STATE = "state"
const val COLUMN_AUTHOR = "author"
const val COLUMN_LARGE_COVER_URL = "large_cover_url"
const val COLUMN_DESCRIPTION = "description"
const val COLUMN_PREVIEW = "preview"
const val COLUMN_KEY = "key"
}
}

View File

@@ -1,73 +0,0 @@
package org.koitharu.kotatsu.core.parser.external
import android.database.Cursor
import android.database.CursorWrapper
import org.koitharu.kotatsu.core.util.ext.getBoolean
class SafeCursor(cursor: Cursor) : CursorWrapper(cursor) {
fun getString(columnName: String): String {
return getString(getColumnIndexOrThrow(columnName))
}
fun getStringOrNull(columnName: String): String? {
val columnIndex = getColumnIndex(columnName)
return when {
columnIndex < 0 -> null
isNull(columnIndex) -> null
else -> getString(columnIndex)
}
}
fun getBoolean(columnName: String): Boolean {
return getBoolean(getColumnIndexOrThrow(columnName))
}
fun getBooleanOrDefault(columnName: String, defaultValue: Boolean): Boolean {
val columnIndex = getColumnIndex(columnName)
return when {
columnIndex < 0 -> defaultValue
isNull(columnIndex) -> defaultValue
else -> getBoolean(columnIndex)
}
}
fun getInt(columnName: String): Int {
return getInt(getColumnIndexOrThrow(columnName))
}
fun getIntOrDefault(columnName: String, defaultValue: Int): Int {
val columnIndex = getColumnIndex(columnName)
return when {
columnIndex < 0 -> defaultValue
isNull(columnIndex) -> defaultValue
else -> getInt(columnIndex)
}
}
fun getLong(columnName: String): Long {
return getLong(getColumnIndexOrThrow(columnName))
}
fun getLongOrDefault(columnName: String, defaultValue: Long): Long {
val columnIndex = getColumnIndex(columnName)
return when {
columnIndex < 0 -> defaultValue
isNull(columnIndex) -> defaultValue
else -> getLong(columnIndex)
}
}
fun getFloat(columnName: String): Float {
return getFloat(getColumnIndexOrThrow(columnName))
}
fun getFloatOrDefault(columnName: String, defaultValue: Float): Float {
val columnIndex = getColumnIndex(columnName)
return when {
columnIndex < 0 -> defaultValue
isNull(columnIndex) -> defaultValue
else -> getFloat(columnIndex)
}
}
}

View File

@@ -1,19 +1,12 @@
package org.koitharu.kotatsu.core.parser.favicon package org.koitharu.kotatsu.core.parser.favicon
import android.content.Context import android.content.Context
import android.graphics.Color
import android.graphics.drawable.AdaptiveIconDrawable
import android.graphics.drawable.ColorDrawable
import android.graphics.drawable.Drawable
import android.graphics.drawable.LayerDrawable
import android.net.Uri import android.net.Uri
import android.os.Build
import android.webkit.MimeTypeMap import android.webkit.MimeTypeMap
import coil.ImageLoader import coil.ImageLoader
import coil.decode.DataSource import coil.decode.DataSource
import coil.decode.ImageSource import coil.decode.ImageSource
import coil.disk.DiskCache import coil.disk.DiskCache
import coil.fetch.DrawableResult
import coil.fetch.FetchResult import coil.fetch.FetchResult
import coil.fetch.Fetcher import coil.fetch.Fetcher
import coil.fetch.SourceResult import coil.fetch.SourceResult
@@ -21,9 +14,7 @@ import coil.network.HttpException
import coil.request.Options import coil.request.Options
import coil.size.Size import coil.size.Size
import coil.size.pxOrElse import coil.size.pxOrElse
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ensureActive import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.runInterruptible
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.Response import okhttp3.Response
@@ -33,11 +24,8 @@ import okio.Closeable
import okio.buffer import okio.buffer
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.parser.EmptyMangaRepository
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.parser.ParserMangaRepository import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.core.parser.external.ExternalMangaRepository
import org.koitharu.kotatsu.core.util.ext.requireBody
import org.koitharu.kotatsu.core.util.ext.writeAllCancellable import org.koitharu.kotatsu.core.util.ext.writeAllCancellable
import org.koitharu.kotatsu.local.data.CacheDir import org.koitharu.kotatsu.local.data.CacheDir
import org.koitharu.kotatsu.local.data.util.withExtraCloseable import org.koitharu.kotatsu.local.data.util.withExtraCloseable
@@ -57,27 +45,14 @@ class FaviconFetcher(
) : Fetcher { ) : Fetcher {
private val diskCacheKey private val diskCacheKey
get() = options.diskCacheKey ?: "${mangaSource.name}x${options.size.toCacheKey()}" get() = options.diskCacheKey ?: "${mangaSource.name}[${mangaSource.ordinal}]x${options.size.toCacheKey()}"
private val fileSystem private val fileSystem
get() = checkNotNull(diskCache.value).fileSystem get() = checkNotNull(diskCache.value).fileSystem
override suspend fun fetch(): FetchResult { override suspend fun fetch(): FetchResult {
getCached(options)?.let { return it } getCached(options)?.let { return it }
return when (val repo = mangaRepositoryFactory.create(mangaSource)) { val repo = mangaRepositoryFactory.create(mangaSource) as RemoteMangaRepository
is ParserMangaRepository -> fetchParserFavicon(repo)
is ExternalMangaRepository -> fetchPluginIcon(repo)
is EmptyMangaRepository -> DrawableResult(
drawable = ColorDrawable(Color.WHITE),
isSampled = false,
dataSource = DataSource.MEMORY,
)
else -> throw IllegalArgumentException("")
}
}
private suspend fun fetchParserFavicon(repo: ParserMangaRepository): FetchResult {
val sizePx = maxOf( val sizePx = maxOf(
options.size.width.pxOrElse { FALLBACK_SIZE }, options.size.width.pxOrElse { FALLBACK_SIZE },
options.size.height.pxOrElse { FALLBACK_SIZE }, options.size.height.pxOrElse { FALLBACK_SIZE },
@@ -124,20 +99,6 @@ class FaviconFetcher(
return response return response
} }
private suspend fun fetchPluginIcon(repository: ExternalMangaRepository): FetchResult {
val source = repository.source
val pm = options.context.packageManager
val icon = runInterruptible(Dispatchers.IO) {
val provider = pm.resolveContentProvider(source.authority, 0)
provider?.loadIcon(pm) ?: pm.getApplicationIcon(source.packageName)
}
return DrawableResult(
drawable = icon.nonAdaptive(),
isSampled = false,
dataSource = DataSource.DISK,
)
}
private fun getCached(options: Options): SourceResult? { private fun getCached(options: Options): SourceResult? {
if (!options.diskCachePolicy.readEnabled) { if (!options.diskCachePolicy.readEnabled) {
return null return null
@@ -189,6 +150,10 @@ class FaviconFetcher(
return if (networkResponse != null) DataSource.NETWORK else DataSource.DISK return if (networkResponse != null) DataSource.NETWORK else DataSource.DISK
} }
private fun Response.requireBody(): ResponseBody {
return checkNotNull(body) { "response body == null" }
}
private fun Size.toCacheKey() = buildString { private fun Size.toCacheKey() = buildString {
append(width.toString()) append(width.toString())
append('x') append('x')
@@ -203,13 +168,6 @@ class FaviconFetcher(
} }
} }
private fun Drawable.nonAdaptive() =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && this is AdaptiveIconDrawable) {
LayerDrawable(arrayOf(background, foreground))
} else {
this
}
class Factory( class Factory(
context: Context, context: Context,
okHttpClientLazy: Lazy<OkHttpClient>, okHttpClientLazy: Lazy<OkHttpClient>,

View File

@@ -33,6 +33,7 @@ import org.koitharu.kotatsu.reader.domain.ReaderColorFilter
import java.io.File import java.io.File
import java.net.Proxy import java.net.Proxy
import java.util.EnumSet import java.util.EnumSet
import java.util.Locale
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -154,9 +155,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val isTrackerNotificationsEnabled: Boolean val isTrackerNotificationsEnabled: Boolean
get() = prefs.getBoolean(KEY_TRACKER_NOTIFICATIONS, true) get() = prefs.getBoolean(KEY_TRACKER_NOTIFICATIONS, true)
val isTrackerNsfwDisabled: Boolean
get() = prefs.getBoolean(KEY_TRACKER_NO_NSFW, false)
var notificationSound: Uri var notificationSound: Uri
get() = prefs.getString(KEY_NOTIFICATIONS_SOUND, null)?.toUriOrNull() get() = prefs.getString(KEY_NOTIFICATIONS_SOUND, null)?.toUriOrNull()
?: Settings.System.DEFAULT_NOTIFICATION_URI ?: Settings.System.DEFAULT_NOTIFICATION_URI
@@ -192,8 +190,8 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
get() = prefs.getBoolean(KEY_FEED_HEADER, true) get() = prefs.getBoolean(KEY_FEED_HEADER, true)
set(value) = prefs.edit { putBoolean(KEY_FEED_HEADER, value) } set(value) = prefs.edit { putBoolean(KEY_FEED_HEADER, value) }
val progressIndicatorMode: ProgressIndicatorMode val isReadingIndicatorsEnabled: Boolean
get() = prefs.getEnumValue(KEY_PROGRESS_INDICATORS, ProgressIndicatorMode.PERCENT_READ) get() = prefs.getBoolean(KEY_READING_INDICATORS, true)
val isHistoryExcludeNsfw: Boolean val isHistoryExcludeNsfw: Boolean
get() = prefs.getBoolean(KEY_HISTORY_EXCLUDE_NSFW, false) get() = prefs.getBoolean(KEY_HISTORY_EXCLUDE_NSFW, false)
@@ -289,15 +287,17 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
get() = prefs.getBoolean(KEY_SOURCES_GRID, true) get() = prefs.getBoolean(KEY_SOURCES_GRID, true)
set(value) = prefs.edit { putBoolean(KEY_SOURCES_GRID, value) } set(value) = prefs.edit { putBoolean(KEY_SOURCES_GRID, value) }
var sourcesVersion: Int val isNewSourcesTipEnabled: Boolean
get() = prefs.getInt(KEY_SOURCES_VERSION, 0) get() = prefs.getBoolean(KEY_SOURCES_NEW, true)
set(value) = prefs.edit { putInt(KEY_SOURCES_VERSION, value) }
val isPagesNumbersEnabled: Boolean val isPagesNumbersEnabled: Boolean
get() = prefs.getBoolean(KEY_PAGES_NUMBERS, false) get() = prefs.getBoolean(KEY_PAGES_NUMBERS, false)
val screenshotsPolicy: ScreenshotsPolicy val screenshotsPolicy: ScreenshotsPolicy
get() = prefs.getEnumValue(KEY_SCREENSHOTS_POLICY, ScreenshotsPolicy.ALLOW) get() = runCatching {
val key = prefs.getString(KEY_SCREENSHOTS_POLICY, null)?.uppercase(Locale.ROOT)
if (key == null) ScreenshotsPolicy.ALLOW else ScreenshotsPolicy.valueOf(key)
}.getOrDefault(ScreenshotsPolicy.ALLOW)
var userSpecifiedMangaDirectories: Set<File> var userSpecifiedMangaDirectories: Set<File>
get() { get() {
@@ -380,18 +380,14 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
} }
} }
val imagesProxy: Int val isImagesProxyEnabled: Boolean
get() { get() = prefs.getBoolean(KEY_IMAGES_PROXY, false)
val raw = prefs.getString(KEY_IMAGES_PROXY, null)?.toIntOrNull()
return raw ?: if (prefs.getBoolean(KEY_IMAGES_PROXY_OLD, false)) 0 else -1
}
val dnsOverHttps: DoHProvider val dnsOverHttps: DoHProvider
get() = prefs.getEnumValue(KEY_DOH, DoHProvider.NONE) get() = prefs.getEnumValue(KEY_DOH, DoHProvider.NONE)
var isSSLBypassEnabled: Boolean val isSSLBypassEnabled: Boolean
get() = prefs.getBoolean(KEY_SSL_BYPASS, false) get() = prefs.getBoolean(KEY_SSL_BYPASS, false)
set(value) = prefs.edit { putBoolean(KEY_SSL_BYPASS, value) }
val proxyType: Proxy.Type val proxyType: Proxy.Type
get() { get() {
@@ -484,15 +480,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val isAutoLocalChaptersCleanupEnabled: Boolean val isAutoLocalChaptersCleanupEnabled: Boolean
get() = prefs.getBoolean(KEY_CHAPTERS_CLEAR_AUTO, false) get() = prefs.getBoolean(KEY_CHAPTERS_CLEAR_AUTO, false)
fun isPagesCropEnabled(mode: ReaderMode): Boolean {
val rawValue = prefs.getStringSet(KEY_READER_CROP, emptySet())
if (rawValue.isNullOrEmpty()) {
return false
}
val needle = if (mode == ReaderMode.WEBTOON) READER_CROP_WEBTOON else READER_CROP_PAGED
return needle.toString() in rawValue
}
fun isTipEnabled(tip: String): Boolean { fun isTipEnabled(tip: String): Boolean {
return prefs.getStringSet(KEY_TIPS_CLOSED, emptySet())?.contains(tip) != true return prefs.getStringSet(KEY_TIPS_CLOSED, emptySet())?.contains(tip) != true
} }
@@ -560,6 +547,8 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
companion object { companion object {
const val PAGE_SWITCH_VOLUME_KEYS = "volume"
const val TRACK_HISTORY = "history" const val TRACK_HISTORY = "history"
const val TRACK_FAVOURITES = "favourites" const val TRACK_FAVOURITES = "favourites"
@@ -596,7 +585,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_TRACK_CATEGORIES = "track_categories" const val KEY_TRACK_CATEGORIES = "track_categories"
const val KEY_TRACK_WARNING = "track_warning" const val KEY_TRACK_WARNING = "track_warning"
const val KEY_TRACKER_NOTIFICATIONS = "tracker_notifications" const val KEY_TRACKER_NOTIFICATIONS = "tracker_notifications"
const val KEY_TRACKER_NO_NSFW = "tracker_no_nsfw"
const val KEY_NOTIFICATIONS_SETTINGS = "notifications_settings" const val KEY_NOTIFICATIONS_SETTINGS = "notifications_settings"
const val KEY_NOTIFICATIONS_SOUND = "notifications_sound" const val KEY_NOTIFICATIONS_SOUND = "notifications_sound"
const val KEY_NOTIFICATIONS_VIBRATE = "notifications_vibrate" const val KEY_NOTIFICATIONS_VIBRATE = "notifications_vibrate"
@@ -605,11 +593,11 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_READER_ANIMATION = "reader_animation2" const val KEY_READER_ANIMATION = "reader_animation2"
const val KEY_READER_MODE = "reader_mode" const val KEY_READER_MODE = "reader_mode"
const val KEY_READER_MODE_DETECT = "reader_mode_detect" const val KEY_READER_MODE_DETECT = "reader_mode_detect"
const val KEY_READER_CROP = "reader_crop"
const val KEY_APP_PASSWORD = "app_password" const val KEY_APP_PASSWORD = "app_password"
const val KEY_APP_PASSWORD_NUMERIC = "app_password_num" const val KEY_APP_PASSWORD_NUMERIC = "app_password_num"
const val KEY_PROTECT_APP = "protect_app" const val KEY_PROTECT_APP = "protect_app"
const val KEY_PROTECT_APP_BIOMETRIC = "protect_app_bio" const val KEY_PROTECT_APP_BIOMETRIC = "protect_app_bio"
const val KEY_APP_VERSION = "app_version"
const val KEY_ZOOM_MODE = "zoom_mode" const val KEY_ZOOM_MODE = "zoom_mode"
const val KEY_BACKUP = "backup" const val KEY_BACKUP = "backup"
const val KEY_RESTORE = "restore" const val KEY_RESTORE = "restore"
@@ -619,7 +607,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_BACKUP_PERIODICAL_LAST = "backup_periodic_last" const val KEY_BACKUP_PERIODICAL_LAST = "backup_periodic_last"
const val KEY_HISTORY_GROUPING = "history_grouping" const val KEY_HISTORY_GROUPING = "history_grouping"
const val KEY_UPDATED_GROUPING = "updated_grouping" const val KEY_UPDATED_GROUPING = "updated_grouping"
const val KEY_PROGRESS_INDICATORS = "progress_indicators" const val KEY_READING_INDICATORS = "reading_indicators"
const val KEY_REVERSE_CHAPTERS = "reverse_chapters" const val KEY_REVERSE_CHAPTERS = "reverse_chapters"
const val KEY_GRID_VIEW_CHAPTERS = "grid_view_chapters" const val KEY_GRID_VIEW_CHAPTERS = "grid_view_chapters"
const val KEY_HISTORY_EXCLUDE_NSFW = "history_exclude_nsfw" const val KEY_HISTORY_EXCLUDE_NSFW = "history_exclude_nsfw"
@@ -659,7 +647,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_PREFETCH_CONTENT = "prefetch_content" const val KEY_PREFETCH_CONTENT = "prefetch_content"
const val KEY_APP_LOCALE = "app_locale" const val KEY_APP_LOCALE = "app_locale"
const val KEY_LOGGING_ENABLED = "logging" const val KEY_LOGGING_ENABLED = "logging"
const val KEY_LOGS_SHARE = "logs_share"
const val KEY_SOURCES_GRID = "sources_grid" const val KEY_SOURCES_GRID = "sources_grid"
const val KEY_SOURCES_NEW = "sources_new"
const val KEY_UPDATES_UNSTABLE = "updates_unstable" const val KEY_UPDATES_UNSTABLE = "updates_unstable"
const val KEY_TIPS_CLOSED = "tips_closed" const val KEY_TIPS_CLOSED = "tips_closed"
const val KEY_SSL_BYPASS = "ssl_bypass" const val KEY_SSL_BYPASS = "ssl_bypass"
@@ -672,7 +662,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_PROXY_AUTH = "proxy_auth" const val KEY_PROXY_AUTH = "proxy_auth"
const val KEY_PROXY_LOGIN = "proxy_login" const val KEY_PROXY_LOGIN = "proxy_login"
const val KEY_PROXY_PASSWORD = "proxy_password" const val KEY_PROXY_PASSWORD = "proxy_password"
const val KEY_IMAGES_PROXY = "images_proxy_2" const val KEY_IMAGES_PROXY = "images_proxy"
const val KEY_LOCAL_MANGA_DIRS = "local_manga_dirs" const val KEY_LOCAL_MANGA_DIRS = "local_manga_dirs"
const val KEY_DISABLE_NSFW = "no_nsfw" const val KEY_DISABLE_NSFW = "no_nsfw"
const val KEY_RELATED_MANGA = "related_manga" const val KEY_RELATED_MANGA = "related_manga"
@@ -686,6 +676,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_CF_CONTRAST = "cf_contrast" const val KEY_CF_CONTRAST = "cf_contrast"
const val KEY_CF_INVERTED = "cf_inverted" const val KEY_CF_INVERTED = "cf_inverted"
const val KEY_CF_GRAYSCALE = "cf_grayscale" const val KEY_CF_GRAYSCALE = "cf_grayscale"
const val KEY_IGNORE_DOZE = "ignore_dose"
const val KEY_PAGES_TAB = "pages_tab" const val KEY_PAGES_TAB = "pages_tab"
const val KEY_DETAILS_TAB = "details_tab" const val KEY_DETAILS_TAB = "details_tab"
const val KEY_DETAILS_LAST_TAB = "details_last_tab" const val KEY_DETAILS_LAST_TAB = "details_last_tab"
@@ -693,24 +684,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_PAGES_SAVE_DIR = "pages_dir" const val KEY_PAGES_SAVE_DIR = "pages_dir"
const val KEY_PAGES_SAVE_ASK = "pages_dir_ask" const val KEY_PAGES_SAVE_ASK = "pages_dir_ask"
const val KEY_STATS_ENABLED = "stats_on" const val KEY_STATS_ENABLED = "stats_on"
const val KEY_FEED_HEADER = "feed_header"
const val KEY_SEARCH_SUGGESTION_TYPES = "search_suggest_types"
const val KEY_SOURCES_VERSION = "sources_version"
// keys for non-persistent preferences
const val KEY_APP_VERSION = "app_version"
const val KEY_IGNORE_DOZE = "ignore_dose"
const val KEY_TRACKER_DEBUG = "tracker_debug"
const val KEY_LOGS_SHARE = "logs_share"
const val KEY_APP_UPDATE = "app_update" const val KEY_APP_UPDATE = "app_update"
const val KEY_APP_TRANSLATION = "about_app_translation" const val KEY_APP_TRANSLATION = "about_app_translation"
const val PROXY_TEST = "proxy_test" const val KEY_FEED_HEADER = "feed_header"
const val KEY_SEARCH_SUGGESTION_TYPES = "search_suggest_types"
// old keys are for migration only
private const val KEY_IMAGES_PROXY_OLD = "images_proxy"
// values
private const val READER_CROP_PAGED = 1
private const val READER_CROP_WEBTOON = 2
} }
} }

View File

@@ -1,6 +0,0 @@
package org.koitharu.kotatsu.core.prefs
enum class ProgressIndicatorMode {
NONE, PERCENT_READ, PERCENT_LEFT, CHAPTERS_READ, CHAPTERS_LEFT;
}

View File

@@ -3,5 +3,5 @@ package org.koitharu.kotatsu.core.prefs
enum class ScreenshotsPolicy { enum class ScreenshotsPolicy {
// Do not rename this // Do not rename this
ALLOW, BLOCK_NSFW, BLOCK_INCOGNITO, BLOCK_ALL; ALLOW, BLOCK_NSFW, BLOCK_ALL;
} }

View File

@@ -12,6 +12,5 @@ enum class SearchSuggestionType(
QUERIES_SUGGEST(R.string.suggested_queries), QUERIES_SUGGEST(R.string.suggested_queries),
MANGA(R.string.content_type_manga), MANGA(R.string.content_type_manga),
SOURCES(R.string.remote_sources), SOURCES(R.string.remote_sources),
RECENT_SOURCES(R.string.recent_sources),
AUTHORS(R.string.authors), AUTHORS(R.string.authors),
} }

View File

@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.core.prefs
import android.content.Context import android.content.Context
import android.content.SharedPreferences.OnSharedPreferenceChangeListener import android.content.SharedPreferences.OnSharedPreferenceChangeListener
import androidx.core.content.edit import androidx.core.content.edit
import okhttp3.internal.isSensitiveHeader
import org.koitharu.kotatsu.core.util.ext.getEnumValue import org.koitharu.kotatsu.core.util.ext.getEnumValue
import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty
import org.koitharu.kotatsu.core.util.ext.putEnumValue import org.koitharu.kotatsu.core.util.ext.putEnumValue
@@ -11,7 +12,6 @@ import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.config.MangaSourceConfig import org.koitharu.kotatsu.parsers.config.MangaSourceConfig
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.settings.utils.validation.DomainValidator
class SourceSettings(context: Context, source: MangaSource) : MangaSourceConfig { class SourceSettings(context: Context, source: MangaSource) : MangaSourceConfig {
@@ -31,14 +31,9 @@ class SourceSettings(context: Context, source: MangaSource) : MangaSourceConfig
.ifNullOrEmpty { key.defaultValue } .ifNullOrEmpty { key.defaultValue }
.sanitizeHeaderValue() .sanitizeHeaderValue()
is ConfigKey.Domain -> prefs.getString(key.key, key.defaultValue) is ConfigKey.Domain -> prefs.getString(key.key, key.defaultValue).ifNullOrEmpty { key.defaultValue }
?.trim()
?.takeIf { DomainValidator.isValidDomain(it) }
?: key.defaultValue
is ConfigKey.ShowSuspiciousContent -> prefs.getBoolean(key.key, key.defaultValue) is ConfigKey.ShowSuspiciousContent -> prefs.getBoolean(key.key, key.defaultValue)
is ConfigKey.SplitByTranslations -> prefs.getBoolean(key.key, key.defaultValue) is ConfigKey.SplitByTranslations -> prefs.getBoolean(key.key, key.defaultValue)
is ConfigKey.PreferredImageServer -> prefs.getString(key.key, key.defaultValue)?.takeUnless(String::isEmpty)
} as T } as T
} }
@@ -48,7 +43,6 @@ class SourceSettings(context: Context, source: MangaSource) : MangaSourceConfig
is ConfigKey.ShowSuspiciousContent -> putBoolean(key.key, value as Boolean) is ConfigKey.ShowSuspiciousContent -> putBoolean(key.key, value as Boolean)
is ConfigKey.UserAgent -> putString(key.key, (value as String?)?.sanitizeHeaderValue()) is ConfigKey.UserAgent -> putString(key.key, (value as String?)?.sanitizeHeaderValue())
is ConfigKey.SplitByTranslations -> putBoolean(key.key, value as Boolean) is ConfigKey.SplitByTranslations -> putBoolean(key.key, value as Boolean)
is ConfigKey.PreferredImageServer -> putString(key.key, value as String? ?: "")
} }
} }

View File

@@ -3,7 +3,6 @@ package org.koitharu.kotatsu.core.ui
import android.content.Intent import android.content.Intent
import android.content.res.Configuration import android.content.res.Configuration
import android.graphics.Color import android.graphics.Color
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.view.KeyEvent import android.view.KeyEvent
import android.view.View import android.view.View
@@ -19,8 +18,6 @@ import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
@@ -28,12 +25,10 @@ import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.util.ActionModeDelegate import org.koitharu.kotatsu.core.ui.util.ActionModeDelegate
import org.koitharu.kotatsu.core.ui.util.WindowInsetsDelegate import org.koitharu.kotatsu.core.ui.util.WindowInsetsDelegate
import org.koitharu.kotatsu.core.util.ext.isWebViewUnavailable import org.koitharu.kotatsu.core.util.ext.isWebViewUnavailable
import org.koitharu.kotatsu.main.ui.protect.ScreenshotPolicyHelper
@Suppress("LeakingThis") @Suppress("LeakingThis")
abstract class BaseActivity<B : ViewBinding> : abstract class BaseActivity<B : ViewBinding> :
AppCompatActivity(), AppCompatActivity(),
ScreenshotPolicyHelper.ContentContainer,
WindowInsetsDelegate.WindowInsetsListener { WindowInsetsDelegate.WindowInsetsListener {
private var isAmoledTheme = false private var isAmoledTheme = false
@@ -97,20 +92,10 @@ abstract class BaseActivity<B : ViewBinding> :
} }
override fun onSupportNavigateUp(): Boolean { override fun onSupportNavigateUp(): Boolean {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { if (supportFragmentManager.popBackStackImmediate()) {
// TODO fix behavior on Android 14
dispatchNavigateUp()
return true
}
val fm = supportFragmentManager
if (fm.isStateSaved) {
return false return false
} }
if (fm.backStackEntryCount > 0) { dispatchNavigateUp()
fm.popBackStack()
} else {
dispatchNavigateUp()
}
return true return true
} }
@@ -155,8 +140,6 @@ abstract class BaseActivity<B : ViewBinding> :
} }
} }
override fun isNsfwContent(): Flow<Boolean> = flowOf(false)
private fun putDataToExtras(intent: Intent?) { private fun putDataToExtras(intent: Intent?) {
intent?.putExtra(EXTRA_DATA, intent.data) intent?.putExtra(EXTRA_DATA, intent.data)
} }
@@ -176,8 +159,6 @@ abstract class BaseActivity<B : ViewBinding> :
} }
} }
protected fun hasViewBinding() = ::viewBinding.isInitialized
@EntryPoint @EntryPoint
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
interface BaseActivityEntryPoint { interface BaseActivityEntryPoint {

View File

@@ -63,7 +63,7 @@ abstract class BasePreferenceFragment(@StringRes private val titleId: Int) :
) )
} }
protected open fun setTitle(title: CharSequence?) { protected fun setTitle(title: CharSequence?) {
(activity as? SettingsActivity)?.setSectionTitle(title) (activity as? SettingsActivity)?.setSectionTitle(title)
} }

View File

@@ -68,7 +68,7 @@ abstract class BaseViewModel : ViewModel() {
errorEvent.call(error) errorEvent.call(error)
} }
protected inline fun <T> withLoading(block: () -> T): T = try { protected inline suspend fun <T> withLoading(block: () -> T): T = try {
loadingCounter.increment() loadingCounter.increment()
block() block()
} finally { } finally {

View File

@@ -1,72 +0,0 @@
package org.koitharu.kotatsu.core.ui.image
import android.animation.TimeAnimator
import android.content.Context
import android.graphics.Canvas
import android.graphics.drawable.Animatable
import androidx.annotation.StyleRes
import androidx.interpolator.view.animation.FastOutSlowInInterpolator
import com.google.android.material.animation.ArgbEvaluatorCompat
import com.google.android.material.color.MaterialColors
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.KotatsuColors
import org.koitharu.kotatsu.core.util.ext.getAnimationDuration
import kotlin.math.abs
class AnimatedFaviconDrawable(
context: Context,
@StyleRes styleResId: Int,
name: String,
) : FaviconDrawable(context, styleResId, name), Animatable, TimeAnimator.TimeListener {
private val interpolator = FastOutSlowInInterpolator()
private val period = context.getAnimationDuration(R.integer.config_longAnimTime) * 2
private val timeAnimator = TimeAnimator()
private val colorHigh = MaterialColors.harmonize(KotatsuColors.random(name), colorBackground)
private val colorLow = ArgbEvaluatorCompat.getInstance().evaluate(0.3f, colorHigh, colorBackground)
init {
timeAnimator.setTimeListener(this)
updateColor()
}
override fun draw(canvas: Canvas) {
if (!isRunning && period > 0) {
updateColor()
start()
}
super.draw(canvas)
}
override fun setAlpha(alpha: Int) = Unit
override fun getAlpha(): Int = 255
override fun onTimeUpdate(animation: TimeAnimator?, totalTime: Long, deltaTime: Long) {
callback?.also {
updateColor()
it.invalidateDrawable(this)
} ?: stop()
}
override fun start() {
timeAnimator.start()
}
override fun stop() {
timeAnimator.end()
}
override fun isRunning(): Boolean = timeAnimator.isStarted
private fun updateColor() {
if (period <= 0f) {
return
}
val ph = period / 2
val fraction = abs((System.currentTimeMillis() % period) - ph) / ph.toFloat()
colorForeground = ArgbEvaluatorCompat.getInstance()
.evaluate(interpolator.getInterpolation(fraction), colorLow, colorHigh)
}
}

View File

@@ -17,18 +17,18 @@ import com.google.android.material.color.MaterialColors
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.KotatsuColors import org.koitharu.kotatsu.core.util.KotatsuColors
open class FaviconDrawable( class FaviconDrawable(
context: Context, context: Context,
@StyleRes styleResId: Int, @StyleRes styleResId: Int,
name: String, name: String,
) : Drawable() { ) : Drawable() {
private val paint = Paint(Paint.ANTI_ALIAS_FLAG) private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
protected var colorBackground = Color.WHITE private var colorBackground = Color.WHITE
protected var colorForeground = Color.DKGRAY
private var colorStroke = Color.LTGRAY private var colorStroke = Color.LTGRAY
private val letter = name.take(1).uppercase() private val letter = name.take(1).uppercase()
private var cornerSize = 0f private var cornerSize = 0f
private var colorForeground = Color.DKGRAY
private val textBounds = Rect() private val textBounds = Rect()
private val tempRect = Rect() private val tempRect = Rect()
private val boundsF = RectF() private val boundsF = RectF()

View File

@@ -1,10 +1,15 @@
package org.koitharu.kotatsu.core.ui.image package org.koitharu.kotatsu.core.ui.image
import android.graphics.Bitmap import android.graphics.Bitmap
import androidx.annotation.ColorInt
import androidx.core.graphics.alpha
import androidx.core.graphics.blue
import androidx.core.graphics.get import androidx.core.graphics.get
import androidx.core.graphics.green
import androidx.core.graphics.red
import coil.size.Size import coil.size.Size
import coil.transform.Transformation import coil.transform.Transformation
import org.koitharu.kotatsu.reader.domain.EdgeDetector.Companion.isColorTheSame import kotlin.math.abs
class TrimTransformation( class TrimTransformation(
private val tolerance: Int = 20, private val tolerance: Int = 20,
@@ -23,7 +28,7 @@ class TrimTransformation(
var isColBlank = true var isColBlank = true
val prevColor = input[x, 0] val prevColor = input[x, 0]
for (y in 1 until input.height) { for (y in 1 until input.height) {
if (!isColorTheSame(input[x, y], prevColor, tolerance)) { if (!isColorTheSame(input[x, y], prevColor)) {
isColBlank = false isColBlank = false
break break
} }
@@ -42,7 +47,7 @@ class TrimTransformation(
var isColBlank = true var isColBlank = true
val prevColor = input[x, 0] val prevColor = input[x, 0]
for (y in 1 until input.height) { for (y in 1 until input.height) {
if (!isColorTheSame(input[x, y], prevColor, tolerance)) { if (!isColorTheSame(input[x, y], prevColor)) {
isColBlank = false isColBlank = false
break break
} }
@@ -58,7 +63,7 @@ class TrimTransformation(
var isRowBlank = true var isRowBlank = true
val prevColor = input[0, y] val prevColor = input[0, y]
for (x in 1 until input.width) { for (x in 1 until input.width) {
if (!isColorTheSame(input[x, y], prevColor, tolerance)) { if (!isColorTheSame(input[x, y], prevColor)) {
isRowBlank = false isRowBlank = false
break break
} }
@@ -74,7 +79,7 @@ class TrimTransformation(
var isRowBlank = true var isRowBlank = true
val prevColor = input[0, y] val prevColor = input[0, y]
for (x in 1 until input.width) { for (x in 1 until input.width) {
if (!isColorTheSame(input[x, y], prevColor, tolerance)) { if (!isColorTheSame(input[x, y], prevColor)) {
isRowBlank = false isRowBlank = false
break break
} }
@@ -93,6 +98,13 @@ class TrimTransformation(
} }
} }
private fun isColorTheSame(@ColorInt a: Int, @ColorInt b: Int): Boolean {
return abs(a.red - b.red) <= tolerance &&
abs(a.green - b.green) <= tolerance &&
abs(a.blue - b.blue) <= tolerance &&
abs(a.alpha - b.alpha) <= tolerance
}
override fun equals(other: Any?): Boolean { override fun equals(other: Any?): Boolean {
return this === other || (other is TrimTransformation && other.tolerance == tolerance) return this === other || (other is TrimTransformation && other.tolerance == tolerance)
} }

View File

@@ -1,11 +1,11 @@
package org.koitharu.kotatsu.core.ui.list package org.koitharu.kotatsu.core.ui.list
import android.app.Notification.Action
import android.os.Bundle import android.os.Bundle
import android.view.Menu import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
import androidx.appcompat.app.AppCompatDelegate import androidx.appcompat.app.AppCompatDelegate
import androidx.appcompat.view.ActionMode import androidx.appcompat.view.ActionMode
import androidx.collection.LongSet
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
@@ -14,8 +14,6 @@ import androidx.savedstate.SavedStateRegistry
import androidx.savedstate.SavedStateRegistryOwner import androidx.savedstate.SavedStateRegistryOwner
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import org.koitharu.kotatsu.core.ui.list.decor.AbstractSelectionItemDecoration import org.koitharu.kotatsu.core.ui.list.decor.AbstractSelectionItemDecoration
import org.koitharu.kotatsu.core.util.ext.toLongArray
import org.koitharu.kotatsu.core.util.ext.toSet
import kotlin.coroutines.EmptyCoroutineContext import kotlin.coroutines.EmptyCoroutineContext
private const val KEY_SELECTION = "selection" private const val KEY_SELECTION = "selection"
@@ -37,9 +35,11 @@ class ListSelectionController(
registryOwner.lifecycle.addObserver(StateEventObserver()) registryOwner.lifecycle.addObserver(StateEventObserver())
} }
fun snapshot(): Set<Long> = peekCheckedIds().toSet() fun snapshot(): Set<Long> {
return peekCheckedIds().toSet()
}
fun peekCheckedIds(): LongSet { fun peekCheckedIds(): Set<Long> {
return decoration.checkedItemsIds return decoration.checkedItemsIds
} }

View File

@@ -4,8 +4,6 @@ import android.graphics.Canvas
import android.graphics.Rect import android.graphics.Rect
import android.graphics.RectF import android.graphics.RectF
import android.view.View import android.view.View
import androidx.collection.LongSet
import androidx.collection.MutableLongSet
import androidx.core.view.children import androidx.core.view.children
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.NO_ID import androidx.recyclerview.widget.RecyclerView.NO_ID
@@ -14,7 +12,7 @@ abstract class AbstractSelectionItemDecoration : RecyclerView.ItemDecoration() {
private val bounds = Rect() private val bounds = Rect()
private val boundsF = RectF() private val boundsF = RectF()
protected val selection = MutableLongSet() protected val selection = HashSet<Long>()
protected var hasBackground: Boolean = true protected var hasBackground: Boolean = true
protected var hasForeground: Boolean = false protected var hasForeground: Boolean = false
@@ -23,7 +21,7 @@ abstract class AbstractSelectionItemDecoration : RecyclerView.ItemDecoration() {
val checkedItemsCount: Int val checkedItemsCount: Int
get() = selection.size get() = selection.size
val checkedItemsIds: LongSet val checkedItemsIds: Set<Long>
get() = selection get() = selection
fun toggleItemChecked(id: Long) { fun toggleItemChecked(id: Long) {
@@ -41,9 +39,7 @@ abstract class AbstractSelectionItemDecoration : RecyclerView.ItemDecoration() {
} }
fun checkAll(ids: Collection<Long>) { fun checkAll(ids: Collection<Long>) {
for (id in ids) { selection.addAll(ids)
selection.add(id)
}
} }
fun clearSelection() { fun clearSelection() {

View File

@@ -52,16 +52,6 @@ class FastScrollRecyclerView @JvmOverloads constructor(
fastScroller.visibility = if (isFastScrollerEnabled) visibility else GONE fastScroller.visibility = if (isFastScrollerEnabled) visibility else GONE
} }
override fun setPadding(left: Int, top: Int, right: Int, bottom: Int) {
super.setPadding(left, top, right, bottom)
fastScroller.setPadding(left, top, right, bottom)
}
override fun setPaddingRelative(start: Int, top: Int, end: Int, bottom: Int) {
super.setPaddingRelative(start, top, end, bottom)
fastScroller.setPaddingRelative(start, top, end, bottom)
}
override fun onAttachedToWindow() { override fun onAttachedToWindow() {
super.onAttachedToWindow() super.onAttachedToWindow()
fastScroller.attachRecyclerView(this) fastScroller.attachRecyclerView(this)

View File

@@ -1,34 +1,22 @@
package org.koitharu.kotatsu.core.ui.sheet package org.koitharu.kotatsu.core.ui.sheet
import android.annotation.SuppressLint
import android.view.View import android.view.View
import android.view.ViewGroup
import androidx.activity.BackEventCompat
import androidx.activity.OnBackPressedCallback import androidx.activity.OnBackPressedCallback
import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_COLLAPSED import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_COLLAPSED
import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_EXPANDED import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_EXPANDED
import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_HALF_EXPANDED import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_HALF_EXPANDED
import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_HIDDEN
class BottomSheetCollapseCallback( class BottomSheetCollapseCallback(
private val sheet: ViewGroup, private val behavior: BottomSheetBehavior<*>,
private val behavior: BottomSheetBehavior<*> = BottomSheetBehavior.from(sheet), ) : OnBackPressedCallback(behavior.state == STATE_EXPANDED) {
) : OnBackPressedCallback(behavior.state == STATE_EXPANDED || behavior.state == STATE_HALF_EXPANDED) {
init { init {
behavior.addBottomSheetCallback( behavior.addBottomSheetCallback(
object : BottomSheetBehavior.BottomSheetCallback() { object : BottomSheetBehavior.BottomSheetCallback() {
@SuppressLint("SwitchIntDef")
override fun onStateChanged(view: View, state: Int) { override fun onStateChanged(view: View, state: Int) {
when (state) { isEnabled = state == STATE_EXPANDED || state == STATE_HALF_EXPANDED
STATE_EXPANDED,
STATE_HALF_EXPANDED -> isEnabled = true
STATE_COLLAPSED,
STATE_HIDDEN -> isEnabled = false
}
} }
override fun onSlide(p0: View, p1: Float) = Unit override fun onSlide(p0: View, p1: Float) = Unit
@@ -36,11 +24,7 @@ class BottomSheetCollapseCallback(
) )
} }
override fun handleOnBackPressed() = behavior.handleBackInvoked() override fun handleOnBackPressed() {
behavior.state = STATE_COLLAPSED
override fun handleOnBackCancelled() = behavior.cancelBackProgress() }
override fun handleOnBackProgressed(backEvent: BackEventCompat) = behavior.updateBackProgress(backEvent)
override fun handleOnBackStarted(backEvent: BackEventCompat) = behavior.startBackProgress(backEvent)
} }

View File

@@ -12,8 +12,6 @@ import com.google.android.material.chip.ChipGroup
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ext.castOrNull import org.koitharu.kotatsu.core.util.ext.castOrNull
import com.google.android.material.R as materialR
class ChipsView @JvmOverloads constructor( class ChipsView @JvmOverloads constructor(
context: Context, context: Context,
attrs: AttributeSet? = null, attrs: AttributeSet? = null,
@@ -26,9 +24,7 @@ class ChipsView @JvmOverloads constructor(
onChipClickListener?.onChipClick(it as Chip, it.tag) onChipClickListener?.onChipClick(it as Chip, it.tag)
} }
private val chipOnCloseListener = OnClickListener { private val chipOnCloseListener = OnClickListener {
val chip = it as Chip onChipCloseClickListener?.onChipCloseClick(it as Chip, it.tag)
val data = it.tag
onChipCloseClickListener?.onChipCloseClick(chip, data) ?: onChipClickListener?.onChipClick(chip, data)
} }
private val chipStyle: Int private val chipStyle: Int
var onChipClickListener: OnChipClickListener? = null var onChipClickListener: OnChipClickListener? = null
@@ -52,7 +48,7 @@ class ChipsView @JvmOverloads constructor(
if (isInEditMode) { if (isInEditMode) {
setChips( setChips(
List(5) { List(5) {
ChipModel(title = "Chip $it") ChipModel(0, "Chip $it", 0, isCheckable = false, isChecked = false)
}, },
) )
} }
@@ -103,15 +99,6 @@ class ChipsView @JvmOverloads constructor(
chip.isChipIconVisible = true chip.isChipIconVisible = true
} }
chip.isChecked = model.isChecked chip.isChecked = model.isChecked
chip.isCheckedIconVisible = chip.isCheckable && model.icon == 0
chip.isCloseIconVisible = if (onChipCloseClickListener != null || model.isDropdown) {
chip.setCloseIconResource(
if (model.isDropdown) R.drawable.ic_expand_more else materialR.drawable.ic_m3_chip_close,
)
true
} else {
false
}
chip.tag = model.data chip.tag = model.data
} }
@@ -119,11 +106,12 @@ class ChipsView @JvmOverloads constructor(
val chip = Chip(context) val chip = Chip(context)
val drawable = ChipDrawable.createFromAttributes(context, null, 0, chipStyle) val drawable = ChipDrawable.createFromAttributes(context, null, 0, chipStyle)
chip.setChipDrawable(drawable) chip.setChipDrawable(drawable)
chip.isCheckedIconVisible = true
chip.isChipIconVisible = false chip.isChipIconVisible = false
chip.isCloseIconVisible = onChipCloseClickListener != null
chip.setOnCloseIconClickListener(chipOnCloseListener) chip.setOnCloseIconClickListener(chipOnCloseListener)
chip.setEnsureMinTouchTargetSize(false) chip.setEnsureMinTouchTargetSize(false)
chip.setOnClickListener(chipOnClickListener) chip.setOnClickListener(chipOnClickListener)
chip.isElegantTextHeight = false
addView(chip) addView(chip)
return chip return chip
} }
@@ -139,12 +127,11 @@ class ChipsView @JvmOverloads constructor(
} }
data class ChipModel( data class ChipModel(
@ColorRes val tint: Int,
val title: CharSequence, val title: CharSequence,
@DrawableRes val icon: Int = 0, @DrawableRes val icon: Int,
val isCheckable: Boolean = false, val isCheckable: Boolean,
@ColorRes val tint: Int = 0, val isChecked: Boolean,
val isChecked: Boolean = false,
val isDropdown: Boolean = false,
val data: Any? = null, val data: Any? = null,
) )

View File

@@ -23,16 +23,11 @@ class SelectableTextView @JvmOverloads constructor(
private fun fixSelectionRange() { private fun fixSelectionRange() {
if (selectionStart < 0 || selectionEnd < 0) { if (selectionStart < 0 || selectionEnd < 0) {
val spannableText = text as? Spannable ?: return val spannableText = text as? Spannable ?: return
Selection.setSelection(spannableText, spannableText.length) Selection.setSelection(spannableText, text.length)
} }
} }
override fun scrollTo(x: Int, y: Int) { override fun scrollTo(x: Int, y: Int) {
super.scrollTo(0, 0) super.scrollTo(0, 0)
} }
fun selectAll() {
val spannableText = text as? Spannable ?: return
Selection.selectAll(spannableText)
}
} }

View File

@@ -1,27 +1,14 @@
package org.koitharu.kotatsu.core.util package org.koitharu.kotatsu.core.util
import androidx.core.os.LocaleListCompat import androidx.core.os.LocaleListCompat
import org.koitharu.kotatsu.core.util.ext.iterator import org.koitharu.kotatsu.core.util.ext.map
import java.util.Locale import java.util.Locale
class LocaleComparator : Comparator<Locale> { class LocaleComparator : Comparator<Locale> {
private val deviceLocales: List<String> private val deviceLocales = LocaleListCompat.getAdjustedDefault()//LocaleManagerCompat.getSystemLocales(context)
.map { it.language }
init { .distinct()
val localeList = LocaleListCompat.getAdjustedDefault()
deviceLocales = buildList(localeList.size() + 1) {
add("")
val set = HashSet<String>(localeList.size() + 1)
set.add("")
for (locale in localeList) {
val lang = locale.language
if (set.add(lang)) {
add(lang)
}
}
}
}
override fun compare(a: Locale, b: Locale): Int { override fun compare(a: Locale, b: Locale): Int {
val indexA = deviceLocales.indexOf(a.language) val indexA = deviceLocales.indexOf(a.language)

View File

@@ -2,7 +2,6 @@ package org.koitharu.kotatsu.core.util.ext
import androidx.collection.ArrayMap import androidx.collection.ArrayMap
import androidx.collection.ArraySet import androidx.collection.ArraySet
import androidx.collection.LongSet
import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.BuildConfig
import java.util.Collections import java.util.Collections
import java.util.EnumSet import java.util.EnumSet
@@ -70,24 +69,4 @@ fun <T> Iterable<T>.sortedWithSafe(comparator: Comparator<in T>): List<T> = try
} }
} }
fun Collection<*>?.sizeOrZero() = this?.size ?: 0 fun Collection<*>?.sizeOrZero() = if (this == null) 0 else size
@Suppress("UNCHECKED_CAST")
inline fun <T, reified R> Collection<T>.mapToArray(transform: (T) -> R): Array<R> {
val result = arrayOfNulls<R>(size)
forEachIndexed { index, t -> result[index] = transform(t) }
return result as Array<R>
}
fun LongSet.toLongArray(): LongArray {
val result = LongArray(size)
var i = 0
forEach { result[i++] = it }
return result
}
fun LongSet.toSet(): Set<Long> = toCollection(ArraySet<Long>(size))
fun <R : MutableCollection<Long>> LongSet.toCollection(out: R): R = out.also { result ->
forEach(result::add)
}

View File

@@ -12,16 +12,12 @@ import kotlinx.coroutines.CancellableContinuation
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.joinAll
import kotlinx.coroutines.plus import kotlinx.coroutines.plus
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.suspendCancellableCoroutine
import org.koitharu.kotatsu.core.util.AcraCoroutineErrorHandler import org.koitharu.kotatsu.core.util.AcraCoroutineErrorHandler
import org.koitharu.kotatsu.core.util.RetainedLifecycleCoroutineScope import org.koitharu.kotatsu.core.util.RetainedLifecycleCoroutineScope
import org.koitharu.kotatsu.parsers.util.cancelAll
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import kotlin.coroutines.EmptyCoroutineContext import kotlin.coroutines.EmptyCoroutineContext
import kotlin.coroutines.cancellation.CancellationException
import kotlin.coroutines.resume import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException import kotlin.coroutines.resumeWithException
@@ -94,10 +90,3 @@ fun <T> Deferred<T>.peek(): T? = if (isCompleted) {
} else { } else {
null null
} }
@Suppress("SuspendFunctionOnCoroutineScope")
suspend fun CoroutineScope.cancelChildrenAndJoin(cause: CancellationException? = null) {
val jobs = coroutineContext[Job]?.children?.toList() ?: return
jobs.cancelAll(cause)
jobs.joinAll()
}

View File

@@ -37,5 +37,3 @@ fun JSONObject.toContentValues(): ContentValues {
} }
private fun String.escapeName() = "`$this`" private fun String.escapeName() = "`$this`"
fun Cursor.getBoolean(columnIndex: Int) = getInt(columnIndex) > 0

View File

@@ -1,6 +1,5 @@
package org.koitharu.kotatsu.core.util.ext package org.koitharu.kotatsu.core.util.ext
import android.graphics.Bitmap
import android.graphics.Rect import android.graphics.Rect
import kotlin.math.roundToInt import kotlin.math.roundToInt
@@ -12,9 +11,3 @@ fun Rect.scale(factor: Double) {
(height() - newHeight) / 2, (height() - newHeight) / 2,
) )
} }
inline fun <R> Bitmap.use(block: (Bitmap) -> R) = try {
block(this)
} finally {
recycle()
}

View File

@@ -1,12 +1,13 @@
package org.koitharu.kotatsu.core.util.ext package org.koitharu.kotatsu.core.util.ext
import okhttp3.Cookie import okhttp3.Cookie
import okhttp3.Headers
import okhttp3.HttpUrl import okhttp3.HttpUrl
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response import okhttp3.Response
import okhttp3.ResponseBody
import okhttp3.internal.closeQuietly import okhttp3.internal.closeQuietly
import okhttp3.internal.isSensitiveHeader
import okio.IOException import okio.IOException
import org.json.JSONObject import org.json.JSONObject
import org.jsoup.HttpStatusException import org.jsoup.HttpStatusException
@@ -41,8 +42,6 @@ fun Response.ensureSuccess() = apply {
} }
} }
fun Response.requireBody(): ResponseBody = checkNotNull(body) { "Response body is null" }
fun Cookie.newBuilder(): Cookie.Builder = Cookie.Builder().also { c -> fun Cookie.newBuilder(): Cookie.Builder = Cookie.Builder().also { c ->
c.name(name) c.name(name)
c.value(value) c.value(value)

View File

@@ -22,10 +22,11 @@ fun LocaleListCompat.getOrThrow(index: Int) = get(index) ?: throw NoSuchElementE
fun String.toLocale() = Locale(this) fun String.toLocale() = Locale(this)
fun Locale?.getDisplayName(context: Context): String = when (this) { fun Locale?.getDisplayName(context: Context): String {
null -> context.getString(R.string.all_languages) if (this == null) {
Locale.ROOT -> context.getString(R.string.various_languages) return context.getString(R.string.various_languages)
else -> getDisplayLanguage(this).toTitleCase(this) }
return getDisplayLanguage(this).toTitleCase(this)
} }
private class LocaleListCompatIterator(private val list: LocaleListCompat) : ListIterator<Locale> { private class LocaleListCompatIterator(private val list: LocaleListCompat) : ListIterator<Locale> {

View File

@@ -15,7 +15,6 @@ import org.koitharu.kotatsu.core.exceptions.CaughtException
import org.koitharu.kotatsu.core.exceptions.CloudFlareBlockedException import org.koitharu.kotatsu.core.exceptions.CloudFlareBlockedException
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
import org.koitharu.kotatsu.core.exceptions.EmptyHistoryException import org.koitharu.kotatsu.core.exceptions.EmptyHistoryException
import org.koitharu.kotatsu.core.exceptions.IncompatiblePluginException
import org.koitharu.kotatsu.core.exceptions.NoDataReceivedException import org.koitharu.kotatsu.core.exceptions.NoDataReceivedException
import org.koitharu.kotatsu.core.exceptions.SyncApiException import org.koitharu.kotatsu.core.exceptions.SyncApiException
import org.koitharu.kotatsu.core.exceptions.TooManyRequestExceptions import org.koitharu.kotatsu.core.exceptions.TooManyRequestExceptions
@@ -61,7 +60,7 @@ fun Throwable.getDisplayMessage(resources: Resources): String = when (this) {
-> resources.getString(R.string.network_error) -> resources.getString(R.string.network_error)
is NoDataReceivedException -> resources.getString(R.string.error_no_data_received) is NoDataReceivedException -> resources.getString(R.string.error_no_data_received)
is IncompatiblePluginException -> resources.getString(R.string.plugin_incompatible)
is WrongPasswordException -> resources.getString(R.string.wrong_password) is WrongPasswordException -> resources.getString(R.string.wrong_password)
is NotFoundException -> resources.getString(R.string.not_found_404) is NotFoundException -> resources.getString(R.string.not_found_404)
is UnsupportedSourceException -> resources.getString(R.string.unsupported_source) is UnsupportedSourceException -> resources.getString(R.string.unsupported_source)

View File

@@ -55,11 +55,11 @@ class DetailsLoadUseCase @Inject constructor(
try { try {
val details = getDetails(manga) val details = getDetails(manga)
launch { updateTracker(details) } launch { updateTracker(details) }
send(MangaDetails(details, local?.peek(), details.description?.parseAsHtml(withImages = false)?.trim(), false)) send(MangaDetails(details, local?.peek(), details.description?.parseAsHtml(withImages = false), false))
send(MangaDetails(details, local?.await(), details.description?.parseAsHtml(withImages = true)?.trim(), true)) send(MangaDetails(details, local?.await(), details.description?.parseAsHtml(withImages = true), true))
} catch (e: IOException) { } catch (e: IOException) {
local?.await()?.manga?.also { localManga -> local?.await()?.manga?.also { localManga ->
send(MangaDetails(localManga, null, localManga.description?.parseAsHtml(withImages = false)?.trim(), true)) send(MangaDetails(localManga, null, localManga.description?.parseAsHtml(withImages = false), true))
} ?: close(e) } ?: close(e)
} }
} }

View File

@@ -5,9 +5,7 @@ import android.content.Intent
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import dagger.hilt.android.EntryPointAccessors import dagger.hilt.android.EntryPointAccessors
import org.koitharu.kotatsu.core.cache.MemoryContentCache import org.koitharu.kotatsu.core.cache.MemoryContentCache
import org.koitharu.kotatsu.core.model.LocalMangaSource
import org.koitharu.kotatsu.core.model.findById import org.koitharu.kotatsu.core.model.findById
import org.koitharu.kotatsu.core.model.isLocal
import org.koitharu.kotatsu.core.model.parcelable.ParcelableChapter import org.koitharu.kotatsu.core.model.parcelable.ParcelableChapter
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
@@ -64,7 +62,7 @@ class MangaPrefetchService : CoroutineIntentService() {
private suspend fun prefetchLast() { private suspend fun prefetchLast() {
val last = historyRepository.getLastOrNull() ?: return val last = historyRepository.getLastOrNull() ?: return
if (last.isLocal) return if (last.source == MangaSource.LOCAL) return
val repo = mangaRepositoryFactory.create(last.source) val repo = mangaRepositoryFactory.create(last.source)
val details = runCatchingCancellable { repo.getDetails(last) }.getOrNull() ?: return val details = runCatchingCancellable { repo.getDetails(last) }.getOrNull() ?: return
val chapters = details.chapters val chapters = details.chapters
@@ -112,7 +110,7 @@ class MangaPrefetchService : CoroutineIntentService() {
} }
private fun isPrefetchAvailable(context: Context, source: MangaSource?): Boolean { private fun isPrefetchAvailable(context: Context, source: MangaSource?): Boolean {
if (source == LocalMangaSource || context.isPowerSaveMode()) { if (source == MangaSource.LOCAL || context.isPowerSaveMode()) {
return false return false
} }
val entryPoint = EntryPointAccessors.fromApplication( val entryPoint = EntryPointAccessors.fromApplication(

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