Merge remote-tracking branch 'origin/devel' into devel

# Conflicts:
#	app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupSettingsFragment.kt
#	app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupWorker.kt
master
Mac135135 1 year ago
commit 30e43d3bfe

@ -2,12 +2,13 @@
Kotatsu is a free and open-source manga reader for Android with built-in online content sources. Kotatsu is a free and open-source manga reader for Android with built-in online content sources.
[![Sources count](https://img.shields.io/badge/dynamic/yaml?url=https%3A%2F%2Fraw.githubusercontent.com%2FKotatsuApp%2Fkotatsu-parsers%2Frefs%2Fheads%2Fmaster%2F.github%2Fsummary.yaml&query=total&label=manga%20sources&color=%23E9321C)](https://github.com/KotatsuApp/kotatsu-parsers) ![Android 5.0](https://img.shields.io/badge/android-5.0+-brightgreen) [![weblate](https://hosted.weblate.org/widgets/kotatsu/-/strings/svg-badge.svg)](https://hosted.weblate.org/engage/kotatsu/) [![Telegram](https://img.shields.io/badge/chat-telegram-60ACFF)](https://t.me/kotatsuapp) [![Discord](https://img.shields.io/discord/898363402467045416?color=5865f2&label=discord)](https://discord.gg/NNJ5RgVBC5) [![License](https://img.shields.io/github/license/KotatsuApp/Kotatsu)](https://github.com/KotatsuApp/Kotatsu/blob/devel/LICENSE) [![Sources count](https://img.shields.io/badge/dynamic/yaml?url=https%3A%2F%2Fraw.githubusercontent.com%2FKotatsuApp%2Fkotatsu-parsers%2Frefs%2Fheads%2Fmaster%2F.github%2Fsummary.yaml&query=total&label=manga%20sources&color=%23E9321C)](https://github.com/KotatsuApp/kotatsu-parsers) ![F-Droid Version](https://img.shields.io/f-droid/v/org.koitharu.kotatsu) ![Android 5.0](https://img.shields.io/badge/android-5.0+-brightgreen) [![weblate](https://hosted.weblate.org/widgets/kotatsu/-/strings/svg-badge.svg)](https://hosted.weblate.org/engage/kotatsu/) [![Telegram](https://img.shields.io/badge/chat-telegram-60ACFF)](https://t.me/kotatsuapp) [![Discord](https://img.shields.io/discord/898363402467045416?color=5865f2&label=discord)](https://discord.gg/NNJ5RgVBC5) [![License](https://img.shields.io/github/license/KotatsuApp/Kotatsu)](https://github.com/KotatsuApp/Kotatsu/blob/devel/LICENSE)
### Download ### Download
- **Recommended:** Download and install APK from **[GitHub Releases](https://github.com/KotatsuApp/Kotatsu/releases/latest)**. Application has a built-in self-updating feature. - **Recommended:** Download and install APK from **[GitHub Releases](https://github.com/KotatsuApp/Kotatsu/releases/latest)**. Application has a built-in self-updating feature.
- Get it on **[F-Droid](https://f-droid.org/packages/org.koitharu.kotatsu)**. The F-Droid build may be a bit outdated and some fixes might be missing. - Get it on **[F-Droid](https://f-droid.org/packages/org.koitharu.kotatsu)**. The F-Droid build may be a bit outdated and some fixes might be missing.
- Also [nightly builds](https://github.com/KotatsuApp/Kotatsu-nightly/releases) are available (very unstable, use at your own risk).
### Main Features ### Main Features

@ -1,3 +1,5 @@
import java.time.LocalDateTime
plugins { plugins {
id 'com.android.application' id 'com.android.application'
id 'kotlin-android' id 'kotlin-android'
@ -16,8 +18,8 @@ android {
applicationId 'org.koitharu.kotatsu' applicationId 'org.koitharu.kotatsu'
minSdk = 21 minSdk = 21
targetSdk = 35 targetSdk = 35
versionCode = 674 versionCode = 686
versionName = '7.6.1' versionName = '7.7-a7'
generatedDensities = [] generatedDensities = []
testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner' testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner'
ksp { ksp {
@ -37,11 +39,23 @@ android {
shrinkResources true shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
} }
nightly {
initWith release
applicationIdSuffix = '.nightly'
}
} }
buildFeatures { buildFeatures {
viewBinding true viewBinding true
buildConfig true buildConfig true
} }
packagingOptions {
resources {
excludes += [
'META-INF/README.md',
'META-INF/NOTICE.md'
]
}
}
sourceSets { sourceSets {
androidTest.assets.srcDirs += files("$projectDir/schemas".toString()) androidTest.assets.srcDirs += files("$projectDir/schemas".toString())
main.java.srcDirs += 'src/main/kotlin/' main.java.srcDirs += 'src/main/kotlin/'
@ -59,12 +73,12 @@ android {
'-opt-in=kotlinx.coroutines.ExperimentalForInheritanceCoroutinesApi', '-opt-in=kotlinx.coroutines.ExperimentalForInheritanceCoroutinesApi',
'-opt-in=kotlinx.coroutines.FlowPreview', '-opt-in=kotlinx.coroutines.FlowPreview',
'-opt-in=kotlin.contracts.ExperimentalContracts', '-opt-in=kotlin.contracts.ExperimentalContracts',
'-opt-in=coil.annotation.ExperimentalCoilApi', '-opt-in=coil3.annotation.ExperimentalCoilApi',
] ]
} }
lint { lint {
abortOnError true abortOnError true
disable 'MissingTranslation', 'PrivateResource', 'NotifyDataSetChanged', 'SetJavaScriptEnabled' disable 'MissingTranslation', 'PrivateResource', 'SetJavaScriptEnabled', 'SimpleDateFormat'
} }
testOptions { testOptions {
unitTests.includeAndroidResources true unitTests.includeAndroidResources true
@ -73,6 +87,15 @@ android {
freeCompilerArgs += ['-opt-in=org.koitharu.kotatsu.parsers.InternalParsersApi'] freeCompilerArgs += ['-opt-in=org.koitharu.kotatsu.parsers.InternalParsersApi']
} }
} }
applicationVariants.configureEach { variant ->
if (variant.name == 'nightly') {
variant.outputs.each { output ->
def now = LocalDateTime.now()
output.versionCodeOverride = now.format("yyMMdd").toInteger()
output.versionNameOverride = 'N' + now.format("yyyyMMdd")
}
}
}
} }
afterEvaluate { afterEvaluate {
compileDebugKotlin { compileDebugKotlin {
@ -82,87 +105,92 @@ afterEvaluate {
} }
} }
dependencies { dependencies {
//noinspection GradleDependency def parsersVersion = libs.versions.parsers.get()
implementation('com.github.KotatsuApp:kotatsu-parsers:1.1') { if (System.properties.containsKey('parsersVersionOverride')) {
exclude group: 'org.json', module: 'json' // usage:
// -DparsersVersionOverride=$(curl -s https://api.github.com/repos/kotatsuapp/kotatsu-parsers/commits/master -H "Accept: application/vnd.github.sha" | cut -c -10)
parsersVersion = System.getProperty('parsersVersionOverride')
} }
//noinspection UseTomlInstead
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.2' implementation("com.github.KotatsuApp:kotatsu-parsers:$parsersVersion") {
implementation 'org.jetbrains.kotlin:kotlin-stdlib:2.0.20' exclude group: 'org.json', module: 'json'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0'
implementation 'androidx.appcompat:appcompat:1.7.0'
implementation 'androidx.core:core-ktx:1.13.1'
implementation 'androidx.activity:activity-ktx:1.9.2'
implementation 'androidx.fragment:fragment-ktx:1.8.3'
implementation 'androidx.transition:transition-ktx:1.5.1'
implementation 'androidx.collection:collection-ktx:1.4.4'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.6'
implementation 'androidx.lifecycle:lifecycle-service:2.8.6'
implementation 'androidx.lifecycle:lifecycle-process:2.8.6'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
implementation 'androidx.recyclerview:recyclerview:1.3.2'
implementation 'androidx.viewpager2:viewpager2:1.1.0'
implementation 'androidx.preference:preference-ktx:1.2.1'
implementation 'androidx.biometric:biometric-ktx:1.2.0-alpha05'
implementation 'com.google.android.material:material:1.12.0'
implementation 'androidx.lifecycle:lifecycle-common-java8:2.8.6'
implementation 'androidx.webkit:webkit:1.11.0'
implementation 'androidx.work:work-runtime:2.9.1'
//noinspection GradleDependency
implementation('com.google.guava:guava:33.2.1-android') {
exclude group: 'com.google.guava', module: 'failureaccess'
exclude group: 'org.checkerframework', module: 'checker-qual'
exclude group: 'com.google.j2objc', module: 'j2objc-annotations'
} }
implementation 'androidx.room:room-runtime:2.6.1' coreLibraryDesugaring libs.desugar.jdk.libs
implementation 'androidx.room:room-ktx:2.6.1' implementation libs.kotlin.stdlib
ksp 'androidx.room:room-compiler:2.6.1' implementation libs.kotlinx.coroutines.android
implementation libs.kotlinx.coroutines.guava
implementation 'com.squareup.okhttp3:okhttp:4.12.0'
implementation 'com.squareup.okhttp3:okhttp-tls:4.12.0' implementation libs.appcompat
implementation 'com.squareup.okhttp3:okhttp-dnsoverhttps:4.12.0' implementation libs.core.ktx
implementation 'com.squareup.okio:okio:3.9.1' implementation libs.activity.ktx
implementation libs.fragment.ktx
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.2' implementation libs.transition.ktx
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:4.3.2' implementation libs.collection.ktx
implementation libs.lifecycle.viewmodel.ktx
implementation 'com.google.dagger:hilt-android:2.52' implementation libs.lifecycle.service
kapt 'com.google.dagger:hilt-compiler:2.52' implementation libs.lifecycle.process
implementation 'androidx.hilt:hilt-work:1.2.0' implementation libs.androidx.constraintlayout
kapt 'androidx.hilt:hilt-compiler:1.2.0' implementation libs.androidx.swiperefreshlayout
implementation libs.androidx.recyclerview
implementation 'io.coil-kt:coil-base:2.7.0' implementation libs.androidx.viewpager2
implementation 'io.coil-kt:coil-svg:2.7.0' implementation libs.androidx.preference.ktx
implementation 'com.github.KotatsuApp:subsampling-scale-image-view:b2c5a6d5ca' implementation libs.androidx.biometric.ktx
implementation 'com.github.solkin:disk-lru-cache:1.4' implementation libs.material
implementation 'io.noties.markwon:core:4.6.2' implementation libs.androidx.lifecycle.common.java8
implementation libs.androidx.webkit
implementation 'ch.acra:acra-http:5.11.4'
implementation 'ch.acra:acra-dialog:5.11.4' implementation libs.androidx.work.runtime
implementation libs.guava
implementation 'org.conscrypt:conscrypt-android:2.5.2'
implementation libs.androidx.room.runtime
debugImplementation 'com.squareup.leakcanary:leakcanary-android:3.0-alpha-8' implementation libs.androidx.room.ktx
debugImplementation 'com.github.Koitharu:WorkInspector:5778dd1747' ksp libs.androidx.room.compiler
testImplementation 'junit:junit:4.13.2' implementation libs.okhttp
testImplementation 'org.json:json:20240303' implementation libs.okhttp.tls
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0' implementation libs.okhttp.dnsoverhttps
implementation libs.okio
androidTestImplementation 'androidx.test:runner:1.6.1'
androidTestImplementation 'androidx.test:rules:1.6.1' implementation libs.adapterdelegates4.kotlin.dsl
androidTestImplementation 'androidx.test:core-ktx:1.6.1' implementation libs.adapterdelegates4.kotlin.dsl.viewbinding
androidTestImplementation 'androidx.test.ext:junit-ktx:1.2.1'
implementation libs.hilt.android
androidTestImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0' kapt libs.hilt.compiler
implementation libs.androidx.hilt.work
androidTestImplementation 'androidx.room:room-testing:2.6.1' kapt libs.androidx.hilt.compiler
androidTestImplementation 'com.squareup.moshi:moshi-kotlin:1.15.1'
implementation libs.coil.core
androidTestImplementation 'com.google.dagger:hilt-android-testing:2.52' implementation libs.coil.network
kaptAndroidTest 'com.google.dagger:hilt-android-compiler:2.52' implementation libs.coil.gif
implementation libs.coil.svg
implementation libs.avif.decoder
implementation libs.subsampling.scale.image.view
implementation libs.disk.lru.cache
implementation libs.core
implementation libs.acra.http
implementation libs.acra.dialog
implementation libs.conscrypt.android
debugImplementation libs.leakcanary.android
debugImplementation libs.workinspector
testImplementation libs.junit
testImplementation libs.json
testImplementation libs.kotlinx.coroutines.test
androidTestImplementation libs.androidx.runner
androidTestImplementation libs.androidx.rules
androidTestImplementation libs.androidx.core.ktx
androidTestImplementation libs.androidx.junit.ktx
androidTestImplementation libs.kotlinx.coroutines.test
androidTestImplementation libs.androidx.room.testing
androidTestImplementation libs.moshi.kotlin
androidTestImplementation libs.hilt.android.testing
kaptAndroidTest libs.hilt.android.compiler
} }

@ -15,6 +15,7 @@
-dontwarn org.bouncycastle.** -dontwarn org.bouncycastle.**
-dontwarn org.openjsse.** -dontwarn org.openjsse.**
-dontwarn com.google.j2objc.annotations.** -dontwarn com.google.j2objc.annotations.**
-dontwarn coil3.PlatformContext
-keep class org.koitharu.kotatsu.core.exceptions.* { *; } -keep class org.koitharu.kotatsu.core.exceptions.* { *; }
-keep class org.koitharu.kotatsu.settings.NotificationSettingsLegacyFragment -keep class org.koitharu.kotatsu.settings.NotificationSettingsLegacyFragment

@ -1,6 +1,7 @@
package org.koitharu.kotatsu package org.koitharu.kotatsu
import android.content.Context import android.content.Context
import android.os.Build
import android.os.StrictMode import android.os.StrictMode
import androidx.fragment.app.strictmode.FragmentStrictMode import androidx.fragment.app.strictmode.FragmentStrictMode
import org.koitharu.kotatsu.core.BaseApp import org.koitharu.kotatsu.core.BaseApp
@ -18,30 +19,55 @@ class KotatsuApp : BaseApp() {
} }
private fun enableStrictMode() { private fun enableStrictMode() {
val notifier = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
StrictModeNotifier(this)
} else {
null
}
StrictMode.setThreadPolicy( StrictMode.setThreadPolicy(
StrictMode.ThreadPolicy.Builder() StrictMode.ThreadPolicy.Builder().apply {
.detectAll() detectNetwork()
.penaltyLog() detectDiskWrites()
.build(), detectCustomSlowCalls()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) detectUnbufferedIo()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) detectResourceMismatches()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) detectExplicitGc()
penaltyLog()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && notifier != null) {
penaltyListener(notifier.executor, notifier)
}
}.build(),
) )
StrictMode.setVmPolicy( StrictMode.setVmPolicy(
StrictMode.VmPolicy.Builder() StrictMode.VmPolicy.Builder().apply {
.detectAll() detectActivityLeaks()
.setClassInstanceLimit(LocalMangaRepository::class.java, 1) detectLeakedSqlLiteObjects()
.setClassInstanceLimit(PagesCache::class.java, 1) detectLeakedClosableObjects()
.setClassInstanceLimit(MangaLoaderContext::class.java, 1) detectLeakedRegistrationObjects()
.setClassInstanceLimit(PageLoader::class.java, 1) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) detectContentUriWithoutPermission()
.setClassInstanceLimit(ReaderViewModel::class.java, 1) detectFileUriExposure()
.penaltyLog() setClassInstanceLimit(LocalMangaRepository::class.java, 1)
.build(), setClassInstanceLimit(PagesCache::class.java, 1)
setClassInstanceLimit(MangaLoaderContext::class.java, 1)
setClassInstanceLimit(PageLoader::class.java, 1)
setClassInstanceLimit(ReaderViewModel::class.java, 1)
penaltyLog()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && notifier != null) {
penaltyListener(notifier.executor, notifier)
}
}.build()
) )
FragmentStrictMode.defaultPolicy = FragmentStrictMode.Policy.Builder() FragmentStrictMode.defaultPolicy = FragmentStrictMode.Policy.Builder().apply {
.penaltyDeath() detectWrongFragmentContainer()
.detectFragmentReuse() detectFragmentTagUsage()
.detectWrongFragmentContainer() detectRetainInstanceUsage()
.detectRetainInstanceUsage() detectSetUserVisibleHint()
.detectSetUserVisibleHint() detectWrongNestedHierarchy()
.detectFragmentTagUsage() detectFragmentReuse()
.build() penaltyLog()
if (notifier != null) {
penaltyListener(notifier)
}
}.build()
} }
} }

@ -0,0 +1,73 @@
package org.koitharu.kotatsu
import android.app.Notification
import android.app.Notification.BigTextStyle
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.os.Build
import android.os.StrictMode
import android.os.strictmode.Violation
import androidx.annotation.RequiresApi
import androidx.core.app.PendingIntentCompat
import androidx.core.content.getSystemService
import androidx.fragment.app.strictmode.FragmentStrictMode
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.asExecutor
import org.koitharu.kotatsu.core.util.ShareHelper
import kotlin.math.absoluteValue
import androidx.fragment.app.strictmode.Violation as FragmentViolation
@RequiresApi(Build.VERSION_CODES.P)
class StrictModeNotifier(
private val context: Context,
) : StrictMode.OnVmViolationListener, StrictMode.OnThreadViolationListener, FragmentStrictMode.OnViolationListener {
val executor = Dispatchers.Default.asExecutor()
private val notificationManager by lazy {
val nm = checkNotNull(context.getSystemService<NotificationManager>())
val channel = NotificationChannel(
CHANNEL_ID,
context.getString(R.string.strict_mode),
NotificationManager.IMPORTANCE_LOW,
)
nm.createNotificationChannel(channel)
nm
}
override fun onVmViolation(v: Violation) = showNotification(v)
override fun onThreadViolation(v: Violation) = showNotification(v)
override fun onViolation(violation: FragmentViolation) = showNotification(violation)
private fun showNotification(violation: Throwable) = Notification.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_bug)
.setContentTitle(context.getString(R.string.strict_mode))
.setContentText(violation.message)
.setStyle(
BigTextStyle()
.setBigContentTitle(context.getString(R.string.strict_mode))
.setSummaryText(violation.message)
.bigText(violation.stackTraceToString()),
).setShowWhen(true)
.setContentIntent(
PendingIntentCompat.getActivity(
context,
0,
ShareHelper(context).getShareTextIntent(violation.stackTraceToString()),
0,
false,
),
)
.setAutoCancel(true)
.setGroup(CHANNEL_ID)
.build()
.let { notificationManager.notify(CHANNEL_ID, violation.hashCode().absoluteValue, it) }
private companion object {
const val CHANNEL_ID = "strict_mode"
}
}

@ -2,6 +2,7 @@ package org.koitharu.kotatsu.core.network
import android.util.Log import android.util.Log
import okhttp3.Interceptor import okhttp3.Interceptor
import okhttp3.Request
import okhttp3.Response import okhttp3.Response
import okio.Buffer import okio.Buffer
import org.koitharu.kotatsu.core.network.CommonHeaders.ACCEPT_ENCODING import org.koitharu.kotatsu.core.network.CommonHeaders.ACCEPT_ENCODING
@ -12,8 +13,11 @@ class CurlLoggingInterceptor(
private val escapeRegex = Regex("([\\[\\]\"])") private val escapeRegex = Regex("([\\[\\]\"])")
override fun intercept(chain: Interceptor.Chain): Response { override fun intercept(chain: Interceptor.Chain): Response = chain.proceed(chain.request()).also {
val request = chain.request() logRequest(it.networkResponse?.request ?: it.request)
}
private fun logRequest(request: Request) {
var isCompressed = false var isCompressed = false
val curlCmd = StringBuilder() val curlCmd = StringBuilder()
@ -46,16 +50,11 @@ class CurlLoggingInterceptor(
log("---cURL (" + request.url + ")") log("---cURL (" + request.url + ")")
log(curlCmd.toString()) log(curlCmd.toString())
return chain.proceed(request)
} }
private fun String.escape() = replace(escapeRegex) { match -> private fun String.escape() = replace(escapeRegex) { match ->
"\\" + match.value "\\" + match.value
} }
// .replace("\"", "\\\"")
// .replace("[", "\\[")
// .replace("]", "\\]")
private fun log(msg: String) { private fun log(msg: String) {
Log.d("CURL", msg) Log.d("CURL", msg)

@ -0,0 +1,15 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFF">
<group android:scaleX="0.98150784"
android:scaleY="0.98150784"
android:translateX="0.22190611"
android:translateY="-0.2688478">
<path
android:fillColor="@android:color/white"
android:pathData="M20,8h-2.81c-0.45,-0.78 -1.07,-1.45 -1.82,-1.96L17,4.41 15.59,3l-2.17,2.17C12.96,5.06 12.49,5 12,5c-0.49,0 -0.96,0.06 -1.41,0.17L8.41,3 7,4.41l1.62,1.63C7.88,6.55 7.26,7.22 6.81,8L4,8v2h2.09c-0.05,0.33 -0.09,0.66 -0.09,1v1L4,12v2h2v1c0,0.34 0.04,0.67 0.09,1L4,16v2h2.81c1.04,1.79 2.97,3 5.19,3s4.15,-1.21 5.19,-3L20,18v-2h-2.09c0.05,-0.33 0.09,-0.66 0.09,-1v-1h2v-2h-2v-1c0,-0.34 -0.04,-0.67 -0.09,-1L20,10L20,8zM14,16h-4v-2h4v2zM14,12h-4v-2h4v2z"/>
</group>
</vector>

Binary file not shown.

After

Width:  |  Height:  |  Size: 417 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 308 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 480 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 792 B

@ -1,3 +1,4 @@
<resources> <resources>
<string name="app_name" translatable="false">Kotatsu Dev</string> <string name="app_name" translatable="false">Kotatsu Dev</string>
<string name="strict_mode">Strict mode</string>
</resources> </resources>

@ -266,19 +266,26 @@
tools:node="merge" /> tools:node="merge" />
<service <service
android:name="org.koitharu.kotatsu.local.ui.LocalChaptersRemoveService" android:name="org.koitharu.kotatsu.local.ui.LocalChaptersRemoveService"
android:foregroundServiceType="dataSync" /> android:foregroundServiceType="dataSync"
android:label="@string/local_manga_processing" />
<service <service
android:name="org.koitharu.kotatsu.local.ui.ImportService" android:name="org.koitharu.kotatsu.settings.backup.PeriodicalBackupService"
android:foregroundServiceType="dataSync" /> android:foregroundServiceType="dataSync"
android:label="@string/periodic_backups" />
<service <service
android:name="org.koitharu.kotatsu.alternatives.ui.AutoFixService" android:name="org.koitharu.kotatsu.alternatives.ui.AutoFixService"
android:foregroundServiceType="dataSync" /> android:foregroundServiceType="dataSync"
<service android:name="org.koitharu.kotatsu.local.ui.LocalIndexUpdateService" /> android:label="@string/fixing_manga" />
<service
android:name="org.koitharu.kotatsu.local.ui.LocalIndexUpdateService"
android:label="@string/local_manga_processing" />
<service <service
android:name="org.koitharu.kotatsu.widget.shelf.ShelfWidgetService" android:name="org.koitharu.kotatsu.widget.shelf.ShelfWidgetService"
android:label="@string/manga_shelf"
android:permission="android.permission.BIND_REMOTEVIEWS" /> android:permission="android.permission.BIND_REMOTEVIEWS" />
<service <service
android:name="org.koitharu.kotatsu.widget.recent.RecentWidgetService" android:name="org.koitharu.kotatsu.widget.recent.RecentWidgetService"
android:label="@string/recent_manga"
android:permission="android.permission.BIND_REMOTEVIEWS" /> android:permission="android.permission.BIND_REMOTEVIEWS" />
<service <service
android:name="org.koitharu.kotatsu.sync.ui.SyncAuthenticatorService" android:name="org.koitharu.kotatsu.sync.ui.SyncAuthenticatorService"
@ -315,7 +322,8 @@
</service> </service>
<service <service
android:name="org.koitharu.kotatsu.details.service.MangaPrefetchService" android:name="org.koitharu.kotatsu.details.service.MangaPrefetchService"
android:exported="false" /> android:exported="false"
android:label="@string/prefetch_content" />
<provider <provider
android:name="org.koitharu.kotatsu.search.ui.MangaSuggestionsProvider" android:name="org.koitharu.kotatsu.search.ui.MangaSuggestionsProvider"

@ -5,9 +5,16 @@ import androidx.core.content.ContextCompat
import androidx.core.text.buildSpannedString import androidx.core.text.buildSpannedString
import androidx.core.text.inSpans import androidx.core.text.inSpans
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import coil.ImageLoader import coil3.ImageLoader
import coil.request.ImageRequest import coil3.request.ImageRequest
import coil.transform.RoundedCornersTransformation import coil3.request.allowRgb565
import coil3.request.crossfade
import coil3.request.error
import coil3.request.fallback
import coil3.request.lifecycle
import coil3.request.placeholder
import coil3.request.transformations
import coil3.transform.RoundedCornersTransformation
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.getTitle import org.koitharu.kotatsu.core.model.getTitle
@ -19,8 +26,9 @@ import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.util.ext.defaultPlaceholders import org.koitharu.kotatsu.core.util.ext.defaultPlaceholders
import org.koitharu.kotatsu.core.util.ext.enqueueWith import org.koitharu.kotatsu.core.util.ext.enqueueWith
import org.koitharu.kotatsu.core.util.ext.mangaExtra
import org.koitharu.kotatsu.core.util.ext.mangaSourceExtra
import org.koitharu.kotatsu.core.util.ext.newImageRequest import org.koitharu.kotatsu.core.util.ext.newImageRequest
import org.koitharu.kotatsu.core.util.ext.source
import org.koitharu.kotatsu.databinding.ItemMangaAlternativeBinding import org.koitharu.kotatsu.databinding.ItemMangaAlternativeBinding
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
@ -74,7 +82,7 @@ fun alternativeAD(
.placeholder(R.drawable.ic_web) .placeholder(R.drawable.ic_web)
.fallback(R.drawable.ic_web) .fallback(R.drawable.ic_web)
.error(R.drawable.ic_web) .error(R.drawable.ic_web)
.source(item.manga.source) .mangaSourceExtra(item.manga.source)
.transformations(RoundedCornersTransformation(context.resources.getDimension(R.dimen.chip_icon_corner))) .transformations(RoundedCornersTransformation(context.resources.getDimension(R.dimen.chip_icon_corner)))
.allowRgb565(true) .allowRgb565(true)
.enqueueWith(coil) .enqueueWith(coil)
@ -84,8 +92,7 @@ fun alternativeAD(
defaultPlaceholders(context) defaultPlaceholders(context)
transformations(TrimTransformation()) transformations(TrimTransformation())
allowRgb565(true) allowRgb565(true)
tag(item.manga) mangaExtra(item.manga)
source(item.manga.source)
enqueueWith(coil) enqueueWith(coil)
} }
} }

@ -8,7 +8,7 @@ import android.widget.Toast
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.core.graphics.Insets import androidx.core.graphics.Insets
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
import coil.ImageLoader import coil3.ImageLoader
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver

@ -12,8 +12,8 @@ import androidx.core.app.NotificationManagerCompat
import androidx.core.app.PendingIntentCompat import androidx.core.app.PendingIntentCompat
import androidx.core.app.ServiceCompat import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import coil.ImageLoader import coil3.ImageLoader
import coil.request.ImageRequest import coil3.request.ImageRequest
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
@ -23,6 +23,7 @@ import org.koitharu.kotatsu.core.model.getTitle
import org.koitharu.kotatsu.core.ui.CoroutineIntentService import org.koitharu.kotatsu.core.ui.CoroutineIntentService
import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
import org.koitharu.kotatsu.core.util.ext.mangaSourceExtra
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.toBitmapOrNull import org.koitharu.kotatsu.core.util.ext.toBitmapOrNull
import org.koitharu.kotatsu.details.ui.DetailsActivity import org.koitharu.kotatsu.details.ui.DetailsActivity
@ -47,25 +48,21 @@ class AutoFixService : CoroutineIntentService() {
notificationManager = NotificationManagerCompat.from(applicationContext) notificationManager = NotificationManagerCompat.from(applicationContext)
} }
override suspend fun processIntent(startId: Int, intent: Intent) { override suspend fun IntentJobContext.processIntent(intent: Intent) {
val ids = requireNotNull(intent.getLongArrayExtra(DATA_IDS)) val ids = requireNotNull(intent.getLongArrayExtra(DATA_IDS))
startForeground(startId) startForeground(this)
try { for (mangaId in ids) {
for (mangaId in ids) { val result = runCatchingCancellable {
val result = runCatchingCancellable { autoFixUseCase.invoke(mangaId)
autoFixUseCase.invoke(mangaId) }
} if (applicationContext.checkNotificationPermission(CHANNEL_ID)) {
if (applicationContext.checkNotificationPermission(CHANNEL_ID)) { val notification = buildNotification(result)
val notification = buildNotification(result) notificationManager.notify(TAG, startId, notification)
notificationManager.notify(TAG, startId, notification)
}
} }
} finally {
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
} }
} }
override fun onError(startId: Int, error: Throwable) { override fun IntentJobContext.onError(error: Throwable) {
if (applicationContext.checkNotificationPermission(CHANNEL_ID)) { if (applicationContext.checkNotificationPermission(CHANNEL_ID)) {
val notification = runBlocking { buildNotification(Result.failure(error)) } val notification = runBlocking { buildNotification(Result.failure(error)) }
notificationManager.notify(TAG, startId, notification) notificationManager.notify(TAG, startId, notification)
@ -73,7 +70,7 @@ class AutoFixService : CoroutineIntentService() {
} }
@SuppressLint("InlinedApi") @SuppressLint("InlinedApi")
private fun startForeground(startId: Int) { private fun startForeground(jobContext: IntentJobContext) {
val title = applicationContext.getString(R.string.fixing_manga) val title = applicationContext.getString(R.string.fixing_manga)
val channel = NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_MIN) val channel = NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_MIN)
.setName(title) .setName(title)
@ -97,12 +94,11 @@ class AutoFixService : CoroutineIntentService() {
.addAction( .addAction(
materialR.drawable.material_ic_clear_black_24dp, materialR.drawable.material_ic_clear_black_24dp,
applicationContext.getString(android.R.string.cancel), applicationContext.getString(android.R.string.cancel),
getCancelIntent(startId), jobContext.getCancelIntent(),
) )
.build() .build()
ServiceCompat.startForeground( jobContext.setForeground(
this,
FOREGROUND_NOTIFICATION_ID, FOREGROUND_NOTIFICATION_ID,
notification, notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC,
@ -121,7 +117,7 @@ class AutoFixService : CoroutineIntentService() {
coil.execute( coil.execute(
ImageRequest.Builder(applicationContext) ImageRequest.Builder(applicationContext)
.data(replacement.coverUrl) .data(replacement.coverUrl)
.tag(replacement.source) .mangaSourceExtra(replacement.source)
.build(), .build(),
).toBitmapOrNull(), ).toBitmapOrNull(),
) )
@ -165,13 +161,14 @@ class AutoFixService : CoroutineIntentService() {
} else { } else {
error.getDisplayMessage(applicationContext.resources) error.getDisplayMessage(applicationContext.resources)
}, },
) ).setSmallIcon(android.R.drawable.stat_notify_error)
.setSmallIcon(android.R.drawable.stat_notify_error) ErrorReporterReceiver.getPendingIntent(applicationContext, error)?.let { reportIntent ->
.addAction( notification.addAction(
R.drawable.ic_alert_outline, R.drawable.ic_alert_outline,
applicationContext.getString(R.string.report), applicationContext.getString(R.string.report),
ErrorReporterReceiver.getPendingIntent(applicationContext, error), reportIntent,
) )
}
} }
return notification.build() return notification.build()
} }

@ -17,9 +17,6 @@ data class Bookmark(
val percent: Float, val percent: Float,
) : ListModel { ) : ListModel {
val directImageUrl: String?
get() = if (isImageUrlDirect()) imageUrl else null
val imageLoadData: Any val imageLoadData: Any
get() = if (isImageUrlDirect()) imageUrl else toMangaPage() get() = if (isImageUrlDirect()) imageUrl else toMangaPage()

@ -14,7 +14,7 @@ import androidx.core.view.updateLayoutParams
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
import androidx.fragment.app.viewModels import androidx.fragment.app.viewModels
import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.GridLayoutManager
import coil.ImageLoader import coil3.ImageLoader
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.bookmarks.domain.Bookmark import org.koitharu.kotatsu.bookmarks.domain.Bookmark

@ -1,17 +1,18 @@
package org.koitharu.kotatsu.bookmarks.ui.adapter package org.koitharu.kotatsu.bookmarks.ui.adapter
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import coil.ImageLoader import coil3.ImageLoader
import coil3.request.allowRgb565
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.bookmarks.domain.Bookmark import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver
import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.util.ext.bookmarkExtra
import org.koitharu.kotatsu.core.util.ext.decodeRegion import org.koitharu.kotatsu.core.util.ext.decodeRegion
import org.koitharu.kotatsu.core.util.ext.defaultPlaceholders import org.koitharu.kotatsu.core.util.ext.defaultPlaceholders
import org.koitharu.kotatsu.core.util.ext.enqueueWith import org.koitharu.kotatsu.core.util.ext.enqueueWith
import org.koitharu.kotatsu.core.util.ext.newImageRequest import org.koitharu.kotatsu.core.util.ext.newImageRequest
import org.koitharu.kotatsu.core.util.ext.source
import org.koitharu.kotatsu.databinding.ItemBookmarkLargeBinding import org.koitharu.kotatsu.databinding.ItemBookmarkLargeBinding
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
@ -29,9 +30,8 @@ fun bookmarkLargeAD(
size(CoverSizeResolver(binding.imageViewThumb)) size(CoverSizeResolver(binding.imageViewThumb))
defaultPlaceholders(context) defaultPlaceholders(context)
allowRgb565(true) allowRgb565(true)
tag(item) bookmarkExtra(item)
decodeRegion(item.scroll) decodeRegion(item.scroll)
source(item.manga.source)
enqueueWith(coil) enqueueWith(coil)
} }
binding.progressView.setProgress(item.percent, false) binding.progressView.setProgress(item.percent, false)

@ -1,19 +1,21 @@
package org.koitharu.kotatsu.bookmarks.ui.adapter package org.koitharu.kotatsu.bookmarks.ui.adapter
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import coil.ImageLoader import coil3.ImageLoader
import coil3.request.allowRgb565
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.bookmarks.domain.Bookmark import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver
import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.util.ext.bookmarkExtra
import org.koitharu.kotatsu.core.util.ext.decodeRegion import org.koitharu.kotatsu.core.util.ext.decodeRegion
import org.koitharu.kotatsu.core.util.ext.defaultPlaceholders import org.koitharu.kotatsu.core.util.ext.defaultPlaceholders
import org.koitharu.kotatsu.core.util.ext.enqueueWith import org.koitharu.kotatsu.core.util.ext.enqueueWith
import org.koitharu.kotatsu.core.util.ext.newImageRequest import org.koitharu.kotatsu.core.util.ext.newImageRequest
import org.koitharu.kotatsu.core.util.ext.source
import org.koitharu.kotatsu.databinding.ItemBookmarkBinding import org.koitharu.kotatsu.databinding.ItemBookmarkBinding
// TODO check usages
fun bookmarkListAD( fun bookmarkListAD(
coil: ImageLoader, coil: ImageLoader,
lifecycleOwner: LifecycleOwner, lifecycleOwner: LifecycleOwner,
@ -28,9 +30,8 @@ fun bookmarkListAD(
size(CoverSizeResolver(binding.imageViewThumb)) size(CoverSizeResolver(binding.imageViewThumb))
defaultPlaceholders(context) defaultPlaceholders(context)
allowRgb565(true) allowRgb565(true)
tag(item) bookmarkExtra(item)
decodeRegion(item.scroll) decodeRegion(item.scroll)
source(item.manga.source)
enqueueWith(coil) enqueueWith(coil)
} }
} }

@ -2,7 +2,7 @@ package org.koitharu.kotatsu.bookmarks.ui.adapter
import android.content.Context import android.content.Context
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import coil.ImageLoader import coil3.ImageLoader
import org.koitharu.kotatsu.bookmarks.domain.Bookmark import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.core.ui.BaseListAdapter import org.koitharu.kotatsu.core.ui.BaseListAdapter
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener

@ -12,7 +12,6 @@ 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.model.MangaSource
import org.koitharu.kotatsu.core.network.CommonHeaders import org.koitharu.kotatsu.core.network.CommonHeaders
@ -45,7 +44,7 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
} }
val mangaSource = MangaSource(intent?.getStringExtra(EXTRA_SOURCE)) val mangaSource = MangaSource(intent?.getStringExtra(EXTRA_SOURCE))
val repository = mangaRepositoryFactory.create(mangaSource) as? ParserMangaRepository val repository = mangaRepositoryFactory.create(mangaSource) as? ParserMangaRepository
repository?.getRequestHeaders()?.get(CommonHeaders.USER_AGENT) val userAgent = repository?.getRequestHeaders()?.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)

@ -9,9 +9,10 @@ import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import androidx.core.app.PendingIntentCompat import androidx.core.app.PendingIntentCompat
import androidx.core.net.toUri import androidx.core.net.toUri
import coil.EventListener import coil3.EventListener
import coil.request.ErrorResult import coil3.Extras
import coil.request.ImageRequest import coil3.request.ErrorResult
import coil3.request.ImageRequest
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
import org.koitharu.kotatsu.core.model.getTitle import org.koitharu.kotatsu.core.model.getTitle
@ -21,14 +22,14 @@ import org.koitharu.kotatsu.parsers.model.MangaSource
class CaptchaNotifier( class CaptchaNotifier(
private val context: Context, private val context: Context,
) : EventListener { ) : EventListener() {
fun notify(exception: CloudFlareProtectedException) { fun notify(exception: CloudFlareProtectedException) {
if (!context.checkNotificationPermission(CHANNEL_ID)) { if (!context.checkNotificationPermission(CHANNEL_ID)) {
return return
} }
val manager = NotificationManagerCompat.from(context) val manager = NotificationManagerCompat.from(context)
val channel = NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_DEFAULT) val channel = NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_LOW)
.setName(context.getString(R.string.captcha_required)) .setName(context.getString(R.string.captcha_required))
.setShowBadge(true) .setShowBadge(true)
.setVibrationEnabled(false) .setVibrationEnabled(false)
@ -41,8 +42,8 @@ class CaptchaNotifier(
.setData(exception.url.toUri()) .setData(exception.url.toUri())
val notification = NotificationCompat.Builder(context, CHANNEL_ID) val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setContentTitle(channel.name) .setContentTitle(channel.name)
.setPriority(NotificationCompat.PRIORITY_DEFAULT) .setPriority(NotificationCompat.PRIORITY_LOW)
.setDefaults(NotificationCompat.DEFAULT_SOUND) .setDefaults(0)
.setSmallIcon(android.R.drawable.stat_notify_error) .setSmallIcon(android.R.drawable.stat_notify_error)
.setGroup(GROUP_CAPTCHA) .setGroup(GROUP_CAPTCHA)
.setAutoCancel(true) .setAutoCancel(true)
@ -84,20 +85,19 @@ class CaptchaNotifier(
override fun onError(request: ImageRequest, result: ErrorResult) { override fun onError(request: ImageRequest, result: ErrorResult) {
super.onError(request, result) super.onError(request, result)
val e = result.throwable val e = result.throwable
if (e is CloudFlareProtectedException && request.parameters.value<Boolean>(PARAM_IGNORE_CAPTCHA) != true) { if (e is CloudFlareProtectedException && request.extras[ignoreCaptchaKey] != true) {
notify(e) notify(e)
} }
} }
companion object { companion object {
fun ImageRequest.Builder.ignoreCaptchaErrors() = setParameter( fun ImageRequest.Builder.ignoreCaptchaErrors() = apply {
key = PARAM_IGNORE_CAPTCHA, extras[ignoreCaptchaKey] = true
value = true, }
memoryCacheKey = null,
) val ignoreCaptchaKey = Extras.Key(false)
private const val PARAM_IGNORE_CAPTCHA = "ignore_captcha"
private const val CHANNEL_ID = "captcha" private const val CHANNEL_ID = "captcha"
private const val TAG = CHANNEL_ID private const val TAG = CHANNEL_ID
private const val GROUP_CAPTCHA = "org.koitharu.kotatsu.CAPTCHA" private const val GROUP_CAPTCHA = "org.koitharu.kotatsu.CAPTCHA"

@ -32,6 +32,7 @@ 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.databinding.ActivityBrowserBinding import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.network.CloudFlareHelper
import javax.inject.Inject import javax.inject.Inject
import com.google.android.material.R as materialR import com.google.android.material.R as materialR
@ -175,8 +176,7 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
private suspend fun clearCfCookies(url: HttpUrl) = runInterruptible(Dispatchers.Default) { private suspend fun clearCfCookies(url: HttpUrl) = runInterruptible(Dispatchers.Default) {
cookieJar.removeCookies(url) { cookie -> cookieJar.removeCookies(url) { cookie ->
val name = cookie.name CloudFlareHelper.isCloudFlareCookie(cookie.name)
name.startsWith("cf_") || name.startsWith("_cf") || name.startsWith("__cf") || name == "csrftoken"
} }
} }

@ -2,11 +2,10 @@ package org.koitharu.kotatsu.browser.cloudflare
import android.graphics.Bitmap import android.graphics.Bitmap
import android.webkit.WebView import android.webkit.WebView
import okhttp3.HttpUrl.Companion.toHttpUrl
import org.koitharu.kotatsu.browser.BrowserClient import org.koitharu.kotatsu.browser.BrowserClient
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
import org.koitharu.kotatsu.parsers.network.CloudFlareHelper
private const val CF_CLEARANCE = "cf_clearance"
private const val LOOP_COUNTER = 3 private const val LOOP_COUNTER = 3
class CloudFlareClient( class CloudFlareClient(
@ -50,8 +49,5 @@ class CloudFlareClient(
} }
} }
private fun getClearance(): String? { private fun getClearance() = CloudFlareHelper.getClearanceCookie(cookieJar, targetUrl)
return cookieJar.loadForRequest(targetUrl.toHttpUrl())
.find { it.name == CF_CLEARANCE }?.value
}
} }

@ -1,18 +1,22 @@
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.os.Build
import android.provider.SearchRecentSuggestions import android.provider.SearchRecentSuggestions
import android.text.Html import android.text.Html
import androidx.collection.arraySetOf import androidx.collection.arraySetOf
import androidx.room.InvalidationTracker import androidx.room.InvalidationTracker
import androidx.work.WorkManager import androidx.work.WorkManager
import coil.ComponentRegistry import coil3.ImageLoader
import coil.ImageLoader import coil3.disk.DiskCache
import coil.decode.SvgDecoder import coil3.disk.directory
import coil.disk.DiskCache import coil3.gif.AnimatedImageDecoder
import coil.util.DebugLogger import coil3.gif.GifDecoder
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import coil3.request.allowRgb565
import coil3.svg.SvgDecoder
import coil3.util.DebugLogger
import dagger.Binds import dagger.Binds
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
@ -28,6 +32,9 @@ import okhttp3.OkHttpClient
import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.browser.cloudflare.CaptchaNotifier import org.koitharu.kotatsu.browser.cloudflare.CaptchaNotifier
import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.image.AvifImageDecoder
import org.koitharu.kotatsu.core.image.CbzFetcher
import org.koitharu.kotatsu.core.image.MangaSourceHeaderInterceptor
import org.koitharu.kotatsu.core.network.MangaHttpClient import org.koitharu.kotatsu.core.network.MangaHttpClient
import org.koitharu.kotatsu.core.network.imageproxy.ImageProxyInterceptor import org.koitharu.kotatsu.core.network.imageproxy.ImageProxyInterceptor
import org.koitharu.kotatsu.core.os.AppShortcutManager import org.koitharu.kotatsu.core.os.AppShortcutManager
@ -44,7 +51,6 @@ import org.koitharu.kotatsu.core.util.ext.isLowRamDevice
import org.koitharu.kotatsu.details.ui.pager.pages.MangaPageFetcher import org.koitharu.kotatsu.details.ui.pager.pages.MangaPageFetcher
import org.koitharu.kotatsu.details.ui.pager.pages.MangaPageKeyer import org.koitharu.kotatsu.details.ui.pager.pages.MangaPageKeyer
import org.koitharu.kotatsu.local.data.CacheDir import org.koitharu.kotatsu.local.data.CacheDir
import org.koitharu.kotatsu.local.data.CbzFetcher
import org.koitharu.kotatsu.local.data.LocalStorageChanges import org.koitharu.kotatsu.local.data.LocalStorageChanges
import org.koitharu.kotatsu.local.domain.model.LocalManga import org.koitharu.kotatsu.local.domain.model.LocalManga
import org.koitharu.kotatsu.main.domain.CoverRestoreInterceptor import org.koitharu.kotatsu.main.domain.CoverRestoreInterceptor
@ -81,9 +87,7 @@ interface AppModule {
@Singleton @Singleton
fun provideMangaDatabase( fun provideMangaDatabase(
@ApplicationContext context: Context, @ApplicationContext context: Context,
): MangaDatabase { ): MangaDatabase = MangaDatabase(context)
return MangaDatabase(context)
}
@Provides @Provides
@Singleton @Singleton
@ -94,6 +98,7 @@ interface AppModule {
imageProxyInterceptor: ImageProxyInterceptor, imageProxyInterceptor: ImageProxyInterceptor,
pageFetcherFactory: MangaPageFetcher.Factory, pageFetcherFactory: MangaPageFetcher.Factory,
coverRestoreInterceptor: CoverRestoreInterceptor, coverRestoreInterceptor: CoverRestoreInterceptor,
networkStateProvider: Provider<NetworkState>,
): ImageLoader { ): ImageLoader {
val diskCacheFactory = { val diskCacheFactory = {
val rootDir = context.externalCacheDir ?: context.cacheDir val rootDir = context.externalCacheDir ?: context.cacheDir
@ -105,36 +110,39 @@ interface AppModule {
okHttpClientProvider.get().newBuilder().cache(null).build() okHttpClientProvider.get().newBuilder().cache(null).build()
} }
return ImageLoader.Builder(context) return ImageLoader.Builder(context)
.okHttpClient { okHttpClientLazy.value } .interceptorCoroutineContext(Dispatchers.Default)
.interceptorDispatcher(Dispatchers.Default)
.fetcherDispatcher(Dispatchers.Default)
.decoderDispatcher(Dispatchers.IO)
.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))
.components( .components {
ComponentRegistry.Builder() add(
.add(SvgDecoder.Factory()) OkHttpNetworkFetcherFactory(
.add(CbzFetcher.Factory()) callFactory = okHttpClientLazy::value,
.add(FaviconFetcher.Factory(context, okHttpClientLazy, mangaRepositoryFactory)) connectivityChecker = { networkStateProvider.get() },
.add(MangaPageKeyer()) ),
.add(pageFetcherFactory) )
.add(imageProxyInterceptor) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
.add(coverRestoreInterceptor) add(AnimatedImageDecoder.Factory())
.build(), } else {
).build() add(GifDecoder.Factory())
}
add(SvgDecoder.Factory())
add(CbzFetcher.Factory())
add(AvifImageDecoder.Factory())
add(FaviconFetcher.Factory(mangaRepositoryFactory))
add(MangaPageKeyer())
add(pageFetcherFactory)
add(imageProxyInterceptor)
add(coverRestoreInterceptor)
add(MangaSourceHeaderInterceptor())
}.build()
} }
@Provides @Provides
fun provideSearchSuggestions( fun provideSearchSuggestions(
@ApplicationContext context: Context, @ApplicationContext context: Context,
): SearchRecentSuggestions { ): SearchRecentSuggestions = MangaSuggestionsProvider.createSuggestions(context)
return MangaSuggestionsProvider.createSuggestions(context)
}
@Provides @Provides
@ElementsIntoSet @ElementsIntoSet

@ -5,9 +5,11 @@ import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.os.BadParcelableException
import androidx.core.app.PendingIntentCompat import androidx.core.app.PendingIntentCompat
import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.util.ext.getSerializableExtraCompat import org.koitharu.kotatsu.core.util.ext.getSerializableExtraCompat
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.report import org.koitharu.kotatsu.core.util.ext.report
class ErrorReporterReceiver : BroadcastReceiver() { class ErrorReporterReceiver : BroadcastReceiver() {
@ -22,12 +24,15 @@ class ErrorReporterReceiver : BroadcastReceiver() {
private const val EXTRA_ERROR = "err" private const val EXTRA_ERROR = "err"
private const val ACTION_REPORT = "${BuildConfig.APPLICATION_ID}.action.REPORT_ERROR" private const val ACTION_REPORT = "${BuildConfig.APPLICATION_ID}.action.REPORT_ERROR"
fun getPendingIntent(context: Context, e: Throwable): PendingIntent { fun getPendingIntent(context: Context, e: Throwable): PendingIntent? = try {
val intent = Intent(context, ErrorReporterReceiver::class.java) val intent = Intent(context, ErrorReporterReceiver::class.java)
intent.setAction(ACTION_REPORT) intent.setAction(ACTION_REPORT)
intent.setData(Uri.parse("err://${e.hashCode()}")) intent.setData(Uri.parse("err://${e.hashCode()}"))
intent.putExtra(EXTRA_ERROR, e) intent.putExtra(EXTRA_ERROR, e)
return checkNotNull(PendingIntentCompat.getBroadcast(context, 0, intent, 0, false)) PendingIntentCompat.getBroadcast(context, 0, intent, 0, false)
} catch (e: BadParcelableException) {
e.printStackTraceDebug()
null
} }
} }
} }

@ -0,0 +1,12 @@
package org.koitharu.kotatsu.core.backup
import android.net.Uri
import java.util.Date
data class BackupFile(
val uri: Uri,
val dateTime: Date,
): Comparable<BackupFile> {
override fun compareTo(other: BackupFile): Int = compareValues(dateTime, other.dateTime)
}

@ -6,7 +6,7 @@ import org.json.JSONObject
import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.parsers.util.json.JSONIterator import org.koitharu.kotatsu.parsers.util.json.asTypedList
import org.koitharu.kotatsu.parsers.util.json.getLongOrDefault import org.koitharu.kotatsu.parsers.util.json.getLongOrDefault
import org.koitharu.kotatsu.parsers.util.json.mapJSON import org.koitharu.kotatsu.parsers.util.json.mapJSON
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
@ -130,7 +130,7 @@ class BackupRepository @Inject constructor(
suspend fun restoreHistory(entry: BackupEntry): CompositeResult { suspend fun restoreHistory(entry: BackupEntry): CompositeResult {
val result = CompositeResult() val result = CompositeResult()
for (item in entry.data.JSONIterator()) { for (item in entry.data.asTypedList<JSONObject>()) {
val mangaJson = item.getJSONObject("manga") val mangaJson = item.getJSONObject("manga")
val manga = JsonDeserializer(mangaJson).toMangaEntity() val manga = JsonDeserializer(mangaJson).toMangaEntity()
val tags = mangaJson.getJSONArray("tags").mapJSON { val tags = mangaJson.getJSONArray("tags").mapJSON {
@ -150,7 +150,7 @@ class BackupRepository @Inject constructor(
suspend fun restoreCategories(entry: BackupEntry): CompositeResult { suspend fun restoreCategories(entry: BackupEntry): CompositeResult {
val result = CompositeResult() val result = CompositeResult()
for (item in entry.data.JSONIterator()) { for (item in entry.data.asTypedList<JSONObject>()) {
val category = JsonDeserializer(item).toFavouriteCategoryEntity() val category = JsonDeserializer(item).toFavouriteCategoryEntity()
result += runCatchingCancellable { result += runCatchingCancellable {
db.getFavouriteCategoriesDao().upsert(category) db.getFavouriteCategoriesDao().upsert(category)
@ -161,7 +161,7 @@ class BackupRepository @Inject constructor(
suspend fun restoreFavourites(entry: BackupEntry): CompositeResult { suspend fun restoreFavourites(entry: BackupEntry): CompositeResult {
val result = CompositeResult() val result = CompositeResult()
for (item in entry.data.JSONIterator()) { for (item in entry.data.asTypedList<JSONObject>()) {
val mangaJson = item.getJSONObject("manga") val mangaJson = item.getJSONObject("manga")
val manga = JsonDeserializer(mangaJson).toMangaEntity() val manga = JsonDeserializer(mangaJson).toMangaEntity()
val tags = mangaJson.getJSONArray("tags").mapJSON { val tags = mangaJson.getJSONArray("tags").mapJSON {
@ -181,7 +181,7 @@ class BackupRepository @Inject constructor(
suspend fun restoreBookmarks(entry: BackupEntry): CompositeResult { suspend fun restoreBookmarks(entry: BackupEntry): CompositeResult {
val result = CompositeResult() val result = CompositeResult()
for (item in entry.data.JSONIterator()) { for (item in entry.data.asTypedList<JSONObject>()) {
val mangaJson = item.getJSONObject("manga") val mangaJson = item.getJSONObject("manga")
val manga = JsonDeserializer(mangaJson).toMangaEntity() val manga = JsonDeserializer(mangaJson).toMangaEntity()
val tags = item.getJSONArray("tags").mapJSON { val tags = item.getJSONArray("tags").mapJSON {
@ -203,7 +203,7 @@ class BackupRepository @Inject constructor(
suspend fun restoreSources(entry: BackupEntry): CompositeResult { suspend fun restoreSources(entry: BackupEntry): CompositeResult {
val result = CompositeResult() val result = CompositeResult()
for (item in entry.data.JSONIterator()) { for (item in entry.data.asTypedList<JSONObject>()) {
val source = JsonDeserializer(item).toMangaSourceEntity() val source = JsonDeserializer(item).toMangaSourceEntity()
result += runCatchingCancellable { result += runCatchingCancellable {
db.getSourcesDao().upsert(source) db.getSourcesDao().upsert(source)
@ -214,7 +214,7 @@ class BackupRepository @Inject constructor(
fun restoreSettings(entry: BackupEntry): CompositeResult { fun restoreSettings(entry: BackupEntry): CompositeResult {
val result = CompositeResult() val result = CompositeResult()
for (item in entry.data.JSONIterator()) { for (item in entry.data.asTypedList<JSONObject>()) {
result += runCatchingCancellable { result += runCatchingCancellable {
settings.upsertAll(JsonDeserializer(item).toMap()) settings.upsertAll(JsonDeserializer(item).toMap())
} }

@ -1,14 +1,11 @@
package org.koitharu.kotatsu.core.backup package org.koitharu.kotatsu.core.backup
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.runInterruptible
import okhttp3.internal.closeQuietly import okhttp3.internal.closeQuietly
import okio.Closeable import okio.Closeable
import org.json.JSONArray import org.json.JSONArray
import org.koitharu.kotatsu.core.exceptions.BadBackupFormatException import org.koitharu.kotatsu.core.exceptions.BadBackupFormatException
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
import java.io.File import java.io.File
import java.util.EnumSet import java.util.EnumSet
import java.util.zip.ZipException import java.util.zip.ZipException
@ -36,13 +33,9 @@ class BackupZipInput private constructor(val file: File) : Closeable {
zipFile.close() zipFile.close()
} }
fun cleanupAsync() { fun closeAndDelete() {
processLifecycleScope.launch(Dispatchers.IO, CoroutineStart.ATOMIC) { closeQuietly()
runCatching { file.delete()
closeQuietly()
file.delete()
}
}
} }
companion object { companion object {
@ -55,7 +48,7 @@ class BackupZipInput private constructor(val file: File) : Closeable {
throw BadBackupFormatException(null) throw BadBackupFormatException(null)
} }
res res
} catch (exception: Exception) { } catch (exception: Throwable) {
res?.closeQuietly() res?.closeQuietly()
throw if (exception is ZipException) { throw if (exception is ZipException) {
BadBackupFormatException(exception) BadBackupFormatException(exception)

@ -5,10 +5,12 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.runInterruptible
import okio.Closeable import okio.Closeable
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.zip.ZipOutput import org.koitharu.kotatsu.core.zip.ZipOutput
import java.io.File import java.io.File
import java.time.LocalDate import java.text.ParseException
import java.time.format.DateTimeFormatter import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale import java.util.Locale
import java.util.zip.Deflater import java.util.zip.Deflater
@ -27,20 +29,32 @@ class BackupZipOutput(val file: File) : Closeable {
override fun close() { override fun close() {
output.close() output.close()
} }
}
const val DIR_BACKUPS = "backups"
suspend fun BackupZipOutput(context: Context): BackupZipOutput = runInterruptible(Dispatchers.IO) { companion object {
val dir = context.run {
getExternalFilesDir(DIR_BACKUPS) ?: File(filesDir, DIR_BACKUPS) const val DIR_BACKUPS = "backups"
} private val dateTimeFormat = SimpleDateFormat("yyyyMMdd-HHmm")
dir.mkdirs()
val filename = buildString { fun generateFileName(context: Context) = buildString {
append(context.getString(R.string.app_name).replace(' ', '_').lowercase(Locale.ROOT)) append(context.getString(R.string.app_name).replace(' ', '_').lowercase(Locale.ROOT))
append('_') append('_')
append(LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd"))) append(dateTimeFormat.format(Date()))
append(".bk.zip") append(".bk.zip")
}
fun parseBackupDateTime(fileName: String): Date? = try {
dateTimeFormat.parse(fileName.substringAfterLast('_').substringBefore('.'))
} catch (e: ParseException) {
e.printStackTraceDebug()
null
}
suspend fun createTemp(context: Context): BackupZipOutput = runInterruptible(Dispatchers.IO) {
val dir = context.run {
getExternalFilesDir(DIR_BACKUPS) ?: File(filesDir, DIR_BACKUPS)
}
dir.mkdirs()
BackupZipOutput(File(dir, generateFileName(context)))
}
} }
BackupZipOutput(File(dir, filename))
} }

@ -0,0 +1,75 @@
package org.koitharu.kotatsu.core.backup
import android.content.Context
import android.net.Uri
import androidx.documentfile.provider.DocumentFile
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
import okio.IOException
import okio.buffer
import okio.sink
import okio.source
import org.jetbrains.annotations.Blocking
import org.koitharu.kotatsu.core.prefs.AppSettings
import java.io.File
import javax.inject.Inject
class ExternalBackupStorage @Inject constructor(
@ApplicationContext private val context: Context,
private val settings: AppSettings,
) {
suspend fun list(): List<BackupFile> = runInterruptible(Dispatchers.IO) {
getRoot().listFiles().mapNotNull {
if (it.isFile && it.canRead()) {
BackupFile(
uri = it.uri,
dateTime = it.name?.let { fileName ->
BackupZipOutput.parseBackupDateTime(fileName)
} ?: return@mapNotNull null,
)
} else {
null
}
}.sortedDescending()
}
suspend fun put(file: File): Uri = runInterruptible(Dispatchers.IO) {
val out = checkNotNull(getRoot().createFile("application/zip", file.nameWithoutExtension)) {
"Cannot create target backup file"
}
checkNotNull(context.contentResolver.openOutputStream(out.uri, "wt")).sink().use { sink ->
file.source().buffer().use { src ->
src.readAll(sink)
}
}
out.uri
}
suspend fun delete(victim: BackupFile) = runInterruptible(Dispatchers.IO) {
val df = checkNotNull(DocumentFile.fromSingleUri(context, victim.uri)) {
"${victim.uri} cannot be resolved to the DocumentFile"
}
if (!df.delete()) {
throw IOException("Cannot delete ${df.uri}")
}
}
suspend fun getLastBackupDate() = list().maxByOrNull { it.dateTime }?.dateTime
suspend fun trim(maxCount: Int) {
list().drop(maxCount).forEach {
delete(it)
}
}
@Blocking
private fun getRoot(): DocumentFile {
val uri = checkNotNull(settings.periodicalBackupDirectory) {
"Backup directory is not specified"
}
val root = DocumentFile.fromTreeUri(context, uri)
return checkNotNull(root) { "Cannot obtain DocumentFile from $uri" }
}
}

@ -1,6 +1,5 @@
package org.koitharu.kotatsu.core.exceptions package org.koitharu.kotatsu.core.exceptions
import okhttp3.Headers
import okio.IOException import okio.IOException
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource

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

@ -8,6 +8,7 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.dialog.ErrorDetailsDialog import org.koitharu.kotatsu.core.ui.dialog.ErrorDetailsDialog
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
import org.koitharu.kotatsu.core.util.ext.isSerializable
import org.koitharu.kotatsu.parsers.exception.ParseException import org.koitharu.kotatsu.parsers.exception.ParseException
class DialogErrorObserver( class DialogErrorObserver(
@ -32,7 +33,7 @@ class DialogErrorObserver(
dialogBuilder.setPositiveButton(ExceptionResolver.getResolveStringId(value), listener) dialogBuilder.setPositiveButton(ExceptionResolver.getResolveStringId(value), listener)
} else if (value is ParseException) { } else if (value is ParseException) {
val fm = fragmentManager val fm = fragmentManager
if (fm != null) { if (fm != null && value.isSerializable()) {
dialogBuilder.setPositiveButton(R.string.details) { _, _ -> dialogBuilder.setPositiveButton(R.string.details) { _, _ ->
ErrorDetailsDialog.show(fm, value, value.url) ErrorDetailsDialog.show(fm, value, value.url)
} }

@ -6,7 +6,6 @@ import androidx.activity.result.ActivityResultCaller
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.collection.MutableScatterMap import androidx.collection.MutableScatterMap
import androidx.fragment.app.FragmentManager import androidx.fragment.app.FragmentManager
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
@ -19,7 +18,8 @@ import org.koitharu.kotatsu.core.exceptions.ProxyConfigException
import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.dialog.ErrorDetailsDialog import org.koitharu.kotatsu.core.ui.dialog.ErrorDetailsDialog
import org.koitharu.kotatsu.core.util.ext.findActivity import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog
import org.koitharu.kotatsu.core.util.ext.restartApplication
import org.koitharu.kotatsu.parsers.exception.AuthRequiredException import org.koitharu.kotatsu.parsers.exception.AuthRequiredException
import org.koitharu.kotatsu.parsers.exception.NotFoundException import org.koitharu.kotatsu.parsers.exception.NotFoundException
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
@ -124,15 +124,16 @@ class ExceptionResolver @AssistedInject constructor(
Toast.makeText(ctx, R.string.operation_not_supported, Toast.LENGTH_SHORT).show() Toast.makeText(ctx, R.string.operation_not_supported, Toast.LENGTH_SHORT).show()
return return
} }
MaterialAlertDialogBuilder(ctx) buildAlertDialog(ctx) {
.setTitle(R.string.ignore_ssl_errors) setTitle(R.string.ignore_ssl_errors)
.setMessage(R.string.ignore_ssl_errors_summary) setMessage(R.string.ignore_ssl_errors_summary)
.setPositiveButton(R.string.apply) { _, _ -> setPositiveButton(R.string.apply) { _, _ ->
settings.isSSLBypassEnabled = true settings.isSSLBypassEnabled = true
Toast.makeText(ctx, R.string.settings_apply_restart_required, Toast.LENGTH_SHORT).show() Toast.makeText(ctx, R.string.settings_apply_restart_required, Toast.LENGTH_LONG).show()
ctx.findActivity()?.finishAffinity() ctx.restartApplication()
}.setNegativeButton(android.R.string.cancel, null) }
.show() setNegativeButton(android.R.string.cancel, null)
}.show()
} }
private inline fun Host.withContext(block: Context.() -> Unit) { private inline fun Host.withContext(block: Context.() -> Unit) {

@ -7,6 +7,7 @@ import com.google.android.material.snackbar.Snackbar
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.dialog.ErrorDetailsDialog import org.koitharu.kotatsu.core.ui.dialog.ErrorDetailsDialog
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
import org.koitharu.kotatsu.core.util.ext.isSerializable
import org.koitharu.kotatsu.main.ui.owners.BottomNavOwner import org.koitharu.kotatsu.main.ui.owners.BottomNavOwner
import org.koitharu.kotatsu.parsers.exception.ParseException import org.koitharu.kotatsu.parsers.exception.ParseException
@ -33,7 +34,7 @@ class SnackbarErrorObserver(
} }
} else if (value is ParseException) { } else if (value is ParseException) {
val fm = fragmentManager val fm = fragmentManager
if (fm != null) { if (fm != null && value.isSerializable()) {
snackbar.setAction(R.string.details) { snackbar.setAction(R.string.details) {
ErrorDetailsDialog.show(fm, value, value.url) ErrorDetailsDialog.show(fm, value, value.url)
} }

@ -1,20 +1,31 @@
package org.koitharu.kotatsu.core.fs package org.koitharu.kotatsu.core.fs
import android.os.Build import android.os.Build
import org.koitharu.kotatsu.core.util.iterator.CloseableIterator import androidx.annotation.RequiresApi
import org.koitharu.kotatsu.core.util.CloseableSequence
import org.koitharu.kotatsu.core.util.iterator.MappingIterator import org.koitharu.kotatsu.core.util.iterator.MappingIterator
import java.io.File import java.io.File
import java.nio.file.Files import java.nio.file.Files
import java.nio.file.Path import java.nio.file.Path
class FileSequence(private val dir: File) : Sequence<File> { sealed interface FileSequence : CloseableSequence<File> {
override fun iterator(): Iterator<File> { @RequiresApi(Build.VERSION_CODES.O)
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { class StreamImpl(dir: File) : FileSequence {
val stream = Files.newDirectoryStream(dir.toPath())
CloseableIterator(MappingIterator(stream.iterator(), Path::toFile), stream) private val stream = Files.newDirectoryStream(dir.toPath())
} else {
dir.listFiles().orEmpty().iterator() override fun iterator(): Iterator<File> = MappingIterator(stream.iterator(), Path::toFile)
}
override fun close() = stream.close()
}
class ListImpl(dir: File) : FileSequence {
private val list = dir.listFiles().orEmpty()
override fun iterator(): Iterator<File> = list.iterator()
override fun close() = Unit
} }
} }

@ -1,5 +1,7 @@
package org.koitharu.kotatsu.core.github package org.koitharu.kotatsu.core.github
import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@ -9,6 +11,7 @@ import okhttp3.Request
import org.json.JSONArray import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.network.BaseHttpClient import org.koitharu.kotatsu.core.network.BaseHttpClient
import org.koitharu.kotatsu.core.os.AppValidator import org.koitharu.kotatsu.core.os.AppValidator
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
@ -22,22 +25,29 @@ import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
private const val CONTENT_TYPE_APK = "application/vnd.android.package-archive" private const val CONTENT_TYPE_APK = "application/vnd.android.package-archive"
private const val BUILD_TYPE_RELEASE = "release"
@Singleton @Singleton
class AppUpdateRepository @Inject constructor( class AppUpdateRepository @Inject constructor(
private val appValidator: AppValidator, private val appValidator: AppValidator,
private val settings: AppSettings, private val settings: AppSettings,
@BaseHttpClient private val okHttp: OkHttpClient, @BaseHttpClient private val okHttp: OkHttpClient,
@ApplicationContext context: Context,
) { ) {
private val availableUpdate = MutableStateFlow<AppVersion?>(null) private val availableUpdate = MutableStateFlow<AppVersion?>(null)
private val releasesUrl = buildString {
append("https://api.github.com/repos/")
append(context.getString(R.string.github_updates_repo))
append("/releases?page=1&per_page=10")
}
fun observeAvailableUpdate() = availableUpdate.asStateFlow() fun observeAvailableUpdate() = availableUpdate.asStateFlow()
suspend fun getAvailableVersions(): List<AppVersion> { suspend fun getAvailableVersions(): List<AppVersion> {
val request = Request.Builder() val request = Request.Builder()
.get() .get()
.url("https://api.github.com/repos/KotatsuApp/Kotatsu/releases?page=1&per_page=10") .url(releasesUrl)
val jsonArray = okHttp.newCall(request.build()).await().parseJsonArray() val jsonArray = okHttp.newCall(request.build()).await().parseJsonArray()
return jsonArray.mapJSONNotNull { json -> return jsonArray.mapJSONNotNull { json ->
val asset = json.optJSONArray("assets")?.find { jo -> val asset = json.optJSONArray("assets")?.find { jo ->
@ -74,8 +84,9 @@ class AppUpdateRepository @Inject constructor(
}.getOrNull() }.getOrNull()
} }
@Suppress("KotlinConstantConditions")
fun isUpdateSupported(): Boolean { fun isUpdateSupported(): Boolean {
return BuildConfig.DEBUG || appValidator.isOriginalApp return BuildConfig.BUILD_TYPE != BUILD_TYPE_RELEASE || appValidator.isOriginalApp
} }
suspend fun getCurrentVersionChangelog(): String? { suspend fun getCurrentVersionChangelog(): String? {

@ -1,6 +1,7 @@
package org.koitharu.kotatsu.core.github package org.koitharu.kotatsu.core.github
import java.util.* import org.koitharu.kotatsu.core.util.ext.digits
import java.util.Locale
data class VersionId( data class VersionId(
val major: Int, val major: Int,
@ -43,6 +44,16 @@ val VersionId.isStable: Boolean
get() = variantType.isEmpty() get() = variantType.isEmpty()
fun VersionId(versionName: String): VersionId { fun VersionId(versionName: String): VersionId {
if (versionName.startsWith('n', ignoreCase = true)) {
// Nightly build
return VersionId(
major = 0,
minor = 0,
build = versionName.digits().toIntOrNull() ?: 0,
variantType = "n",
variantNumber = 0,
)
}
val parts = versionName.substringBeforeLast('-').split('.') val parts = versionName.substringBeforeLast('-').split('.')
val variant = versionName.substringAfterLast('-', "") val variant = versionName.substringAfterLast('-', "")
return VersionId( return VersionId(

@ -0,0 +1,66 @@
package org.koitharu.kotatsu.core.image
import android.graphics.Bitmap
import coil3.ImageLoader
import coil3.asImage
import coil3.decode.DecodeResult
import coil3.decode.Decoder
import coil3.decode.ImageSource
import coil3.fetch.SourceFetchResult
import coil3.request.Options
import com.davemorrissey.labs.subscaleview.decoder.ImageDecodeException
import kotlinx.coroutines.runInterruptible
import org.aomedia.avif.android.AvifDecoder
import org.aomedia.avif.android.AvifDecoder.Info
import org.koitharu.kotatsu.core.util.ext.toByteBuffer
class AvifImageDecoder(
private val source: ImageSource,
private val options: Options,
) : Decoder {
override suspend fun decode(): DecodeResult = runInterruptible {
val bytes = source.source().use {
it.inputStream().toByteBuffer()
}
val info = Info()
if (!AvifDecoder.getInfo(bytes, bytes.remaining(), info)) {
throw ImageDecodeException(
null,
"avif",
"Requested to decode byte buffer which cannot be handled by AvifDecoder",
)
}
val config = if (info.depth == 8 || info.alphaPresent) Bitmap.Config.ARGB_8888 else Bitmap.Config.RGB_565
val bitmap = Bitmap.createBitmap(info.width, info.height, config)
if (!AvifDecoder.decode(bytes, bytes.remaining(), bitmap)) {
bitmap.recycle()
throw ImageDecodeException(null, "avif")
}
DecodeResult(
image = bitmap.asImage(),
isSampled = false,
)
}
class Factory : Decoder.Factory {
override fun create(
result: SourceFetchResult,
options: Options,
imageLoader: ImageLoader
): Decoder? = if (isApplicable(result)) {
AvifImageDecoder(result.source, options)
} else {
null
}
override fun equals(other: Any?) = other is Factory
override fun hashCode() = javaClass.hashCode()
private fun isApplicable(result: SourceFetchResult): Boolean {
return result.mimeType == "image/avif"
}
}
}

@ -0,0 +1,77 @@
package org.koitharu.kotatsu.core.image
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.ImageDecoder
import android.os.Build
import android.webkit.MimeTypeMap
import com.davemorrissey.labs.subscaleview.decoder.ImageDecodeException
import okhttp3.MediaType
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import org.aomedia.avif.android.AvifDecoder
import org.aomedia.avif.android.AvifDecoder.Info
import org.jetbrains.annotations.Blocking
import org.koitharu.kotatsu.core.util.ext.toByteBuffer
import java.io.File
import java.io.InputStream
import java.nio.ByteBuffer
import java.nio.file.Files
object BitmapDecoderCompat {
private const val FORMAT_AVIF = "avif"
@Blocking
fun decode(file: File): Bitmap = when (val format = getMimeType(file)?.subtype) {
FORMAT_AVIF -> file.inputStream().use { decodeAvif(it.toByteBuffer()) }
else -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
ImageDecoder.decodeBitmap(ImageDecoder.createSource(file))
} else {
checkBitmapNotNull(BitmapFactory.decodeFile(file.absolutePath), format)
}
}
@Blocking
fun decode(stream: InputStream, type: MediaType?): Bitmap {
val format = type?.subtype
if (format == FORMAT_AVIF) {
return decodeAvif(stream.toByteBuffer())
}
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) {
return checkBitmapNotNull(BitmapFactory.decodeStream(stream), format)
}
val byteBuffer = stream.toByteBuffer()
return if (AvifDecoder.isAvifImage(byteBuffer)) {
decodeAvif(byteBuffer)
} else {
ImageDecoder.decodeBitmap(ImageDecoder.createSource(byteBuffer))
}
}
private fun getMimeType(file: File): MediaType? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
Files.probeContentType(file.toPath())?.toMediaTypeOrNull()
} else {
MimeTypeMap.getSingleton().getMimeTypeFromExtension(file.extension)?.toMediaTypeOrNull()
}
private fun checkBitmapNotNull(bitmap: Bitmap?, format: String?): Bitmap =
bitmap ?: throw ImageDecodeException(null, format)
private fun decodeAvif(bytes: ByteBuffer): Bitmap {
val info = Info()
if (!AvifDecoder.getInfo(bytes, bytes.remaining(), info)) {
throw ImageDecodeException(
null,
FORMAT_AVIF,
"Requested to decode byte buffer which cannot be handled by AvifDecoder",
)
}
val config = if (info.depth == 8 || info.alphaPresent) Bitmap.Config.ARGB_8888 else Bitmap.Config.RGB_565
val bitmap = Bitmap.createBitmap(info.width, info.height, config)
if (!AvifDecoder.decode(bytes, bytes.remaining(), bitmap)) {
bitmap.recycle()
throw ImageDecodeException(null, FORMAT_AVIF)
}
return bitmap
}
}

@ -0,0 +1,48 @@
package org.koitharu.kotatsu.core.image
import android.net.Uri
import android.webkit.MimeTypeMap
import coil3.ImageLoader
import coil3.decode.DataSource
import coil3.decode.ImageSource
import coil3.fetch.Fetcher
import coil3.fetch.SourceFetchResult
import coil3.request.Options
import coil3.toAndroidUri
import kotlinx.coroutines.runInterruptible
import okio.Path.Companion.toPath
import okio.openZip
import org.koitharu.kotatsu.core.util.ext.isZipUri
import coil3.Uri as CoilUri
class CbzFetcher(
private val uri: Uri,
private val options: Options,
) : Fetcher {
override suspend fun fetch() = runInterruptible {
val filePath = uri.schemeSpecificPart.toPath()
val entryName = requireNotNull(uri.fragment)
SourceFetchResult(
source = ImageSource(entryName.toPath(), options.fileSystem.openZip(filePath)),
mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(entryName.substringAfterLast('.', "")),
dataSource = DataSource.DISK,
)
}
class Factory : Fetcher.Factory<CoilUri> {
override fun create(
data: CoilUri,
options: Options,
imageLoader: ImageLoader
): Fetcher? {
val androidUri = data.toAndroidUri()
return if (androidUri.isZipUri()) {
CbzFetcher(androidUri, options)
} else {
null
}
}
}
}

@ -0,0 +1,23 @@
package org.koitharu.kotatsu.core.image
import coil3.intercept.Interceptor
import coil3.network.httpHeaders
import coil3.request.ImageResult
import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.core.util.ext.mangaSourceKey
import org.koitharu.kotatsu.parsers.model.MangaParserSource
class MangaSourceHeaderInterceptor : Interceptor {
override suspend fun intercept(chain: Interceptor.Chain): ImageResult {
val mangaSource = chain.request.extras[mangaSourceKey] as? MangaParserSource ?: return chain.proceed()
val request = chain.request
val newHeaders = request.httpHeaders.newBuilder()
.set(CommonHeaders.MANGA_SOURCE, mangaSource.name)
.build()
val newRequest = request.newBuilder()
.httpHeaders(newHeaders)
.build()
return chain.withRequest(newRequest).proceed()
}
}

@ -1,39 +1,39 @@
package org.koitharu.kotatsu.core.ui.image package org.koitharu.kotatsu.core.image
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.graphics.BitmapRegionDecoder import android.graphics.BitmapRegionDecoder
import android.graphics.Rect import android.graphics.Rect
import android.os.Build import android.os.Build
import androidx.core.graphics.drawable.toDrawable import coil3.Extras
import coil.ImageLoader import coil3.ImageLoader
import coil.decode.DecodeResult import coil3.asImage
import coil.decode.DecodeUtils import coil3.decode.DecodeResult
import coil.decode.Decoder import coil3.decode.DecodeUtils
import coil.decode.ImageSource import coil3.decode.Decoder
import coil.fetch.SourceResult import coil3.decode.ImageSource
import coil.request.Options import coil3.fetch.SourceFetchResult
import coil.size.Dimension import coil3.getExtra
import coil.size.Scale import coil3.request.Options
import coil.size.Size import coil3.request.allowRgb565
import coil.size.isOriginal import coil3.request.bitmapConfig
import coil.size.pxOrElse import coil3.request.colorSpace
import coil3.request.premultipliedAlpha
import coil3.size.Dimension
import coil3.size.Precision
import coil3.size.Scale
import coil3.size.Size
import coil3.size.isOriginal
import coil3.size.pxOrElse
import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import kotlin.math.roundToInt import kotlin.math.roundToInt
class RegionBitmapDecoder( class RegionBitmapDecoder(
private val source: ImageSource, private val source: ImageSource,
private val options: Options, private val options: Options,
private val parallelismLock: Semaphore,
) : Decoder { ) : Decoder {
override suspend fun decode() = parallelismLock.withPermit { override suspend fun decode(): DecodeResult = runInterruptible {
runInterruptible { BitmapFactory.Options().decode() }
}
private fun BitmapFactory.Options.decode(): DecodeResult {
val regionDecoder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { val regionDecoder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
BitmapRegionDecoder.newInstance(source.source().inputStream()) BitmapRegionDecoder.newInstance(source.source().inputStream())
} else { } else {
@ -41,13 +41,14 @@ class RegionBitmapDecoder(
BitmapRegionDecoder.newInstance(source.source().inputStream(), false) BitmapRegionDecoder.newInstance(source.source().inputStream(), false)
} }
checkNotNull(regionDecoder) checkNotNull(regionDecoder)
val bitmapOptions = BitmapFactory.Options()
try { try {
val rect = configureScale(regionDecoder.width, regionDecoder.height) val rect = bitmapOptions.configureScale(regionDecoder.width, regionDecoder.height)
configureConfig() bitmapOptions.configureConfig()
val bitmap = regionDecoder.decodeRegion(rect, this) val bitmap = regionDecoder.decodeRegion(rect, bitmapOptions)
bitmap.density = options.context.resources.displayMetrics.densityDpi bitmap.density = options.context.resources.displayMetrics.densityDpi
return DecodeResult( DecodeResult(
drawable = bitmap.toDrawable(options.context.resources), image = bitmap.asImage(),
isSampled = true, isSampled = true,
) )
} finally { } finally {
@ -55,29 +56,6 @@ class RegionBitmapDecoder(
} }
} }
private fun BitmapFactory.Options.configureConfig() {
var config = options.config
inMutable = false
if (Build.VERSION.SDK_INT >= 26 && options.colorSpace != null) {
inPreferredColorSpace = options.colorSpace
}
inPremultiplied = options.premultipliedAlpha
// Decode the image as RGB_565 as an optimization if allowed.
if (options.allowRgb565 && config == Bitmap.Config.ARGB_8888 && outMimeType == "image/jpeg") {
config = Bitmap.Config.RGB_565
}
// High color depth images must be decoded as either RGBA_F16 or HARDWARE.
if (Build.VERSION.SDK_INT >= 26 && outConfig == Bitmap.Config.RGBA_F16 && config != Bitmap.Config.HARDWARE) {
config = Bitmap.Config.RGBA_F16
}
inPreferredConfig = config
}
/** Compute and set the scaling properties for [BitmapFactory.Options]. */ /** Compute and set the scaling properties for [BitmapFactory.Options]. */
private fun BitmapFactory.Options.configureScale(srcWidth: Int, srcHeight: Int): Rect { private fun BitmapFactory.Options.configureScale(srcWidth: Int, srcHeight: Int): Rect {
val dstWidth = options.size.widthPx(options.scale) { srcWidth } val dstWidth = options.size.widthPx(options.scale) { srcWidth }
@ -91,7 +69,7 @@ class RegionBitmapDecoder(
} else { } else {
Rect(0, 0, (srcHeight / dstRatio).toInt().coerceAtLeast(1), srcHeight) Rect(0, 0, (srcHeight / dstRatio).toInt().coerceAtLeast(1), srcHeight)
} }
val scroll = options.parameters.value(PARAM_SCROLL) ?: SCROLL_UNDEFINED val scroll = options.getExtra(regionScrollKey)
if (scroll == SCROLL_UNDEFINED) { if (scroll == SCROLL_UNDEFINED) {
rect.offsetTo( rect.offsetTo(
(srcWidth - rect.width()) / 2, (srcWidth - rect.width()) / 2,
@ -123,7 +101,7 @@ class RegionBitmapDecoder(
) )
// Only upscale the image if the options require an exact size. // Only upscale the image if the options require an exact size.
if (options.allowInexactSize) { if (options.precision == Precision.INEXACT) {
scale = scale.coerceAtMost(1.0) scale = scale.coerceAtMost(1.0)
} }
@ -142,20 +120,37 @@ class RegionBitmapDecoder(
return rect return rect
} }
class Factory( private fun BitmapFactory.Options.configureConfig() {
maxParallelism: Int = DEFAULT_MAX_PARALLELISM, var config = options.bitmapConfig
) : Decoder.Factory {
@Suppress("NEWER_VERSION_IN_SINCE_KOTLIN") inMutable = false
@SinceKotlin("999.9") // Only public in Java.
constructor() : this()
private val parallelismLock = Semaphore(maxParallelism) if (Build.VERSION.SDK_INT >= 26 && options.colorSpace != null) {
inPreferredColorSpace = options.colorSpace
}
inPremultiplied = options.premultipliedAlpha
override fun create(result: SourceResult, options: Options, imageLoader: ImageLoader): Decoder { // Decode the image as RGB_565 as an optimization if allowed.
return RegionBitmapDecoder(result.source, options, parallelismLock) if (options.allowRgb565 && config == Bitmap.Config.ARGB_8888 && outMimeType == "image/jpeg") {
config = Bitmap.Config.RGB_565
} }
// High color depth images must be decoded as either RGBA_F16 or HARDWARE.
if (Build.VERSION.SDK_INT >= 26 && outConfig == Bitmap.Config.RGBA_F16 && config != Bitmap.Config.HARDWARE) {
config = Bitmap.Config.RGBA_F16
}
inPreferredConfig = config
}
object Factory : Decoder.Factory {
override fun create(
result: SourceFetchResult,
options: Options,
imageLoader: ImageLoader
): Decoder = RegionBitmapDecoder(result.source, options)
override fun equals(other: Any?) = other is Factory override fun equals(other: Any?) = other is Factory
override fun hashCode() = javaClass.hashCode() override fun hashCode() = javaClass.hashCode()
@ -163,9 +158,8 @@ class RegionBitmapDecoder(
companion object { companion object {
const val PARAM_SCROLL = "scroll"
const val SCROLL_UNDEFINED = -1 const val SCROLL_UNDEFINED = -1
private const val DEFAULT_MAX_PARALLELISM = 4 val regionScrollKey = Extras.Key(SCROLL_UNDEFINED)
private inline fun Size.widthPx(scale: Scale, original: () -> Int): Int { private inline fun Size.widthPx(scale: Scale, original: () -> Int): Int {
return if (isOriginal) original() else width.toPx(scale) return if (isOriginal) original() else width.toPx(scale)

@ -0,0 +1,13 @@
package org.koitharu.kotatsu.core.io
import java.io.OutputStream
import java.util.Objects
class NullOutputStream : OutputStream() {
override fun write(b: Int) = Unit
override fun write(b: ByteArray, off: Int, len: Int) {
Objects.checkFromIndexSize(off, len, b.size)
}
}

@ -2,41 +2,43 @@ package org.koitharu.kotatsu.core.network
import okhttp3.Interceptor import okhttp3.Interceptor
import okhttp3.Response import okhttp3.Response
import okhttp3.internal.closeQuietly import okio.IOException
import org.jsoup.Jsoup
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.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import java.net.HttpURLConnection.HTTP_FORBIDDEN import org.koitharu.kotatsu.parsers.network.CloudFlareHelper
import java.net.HttpURLConnection.HTTP_UNAVAILABLE
class CloudFlareInterceptor : Interceptor { class CloudFlareInterceptor : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response { override fun intercept(chain: Interceptor.Chain): Response {
val response = chain.proceed(chain.request()) val request = chain.request()
if (response.code == HTTP_FORBIDDEN || response.code == HTTP_UNAVAILABLE) { val response = chain.proceed(request)
val content = response.body?.let { response.peekBody(Long.MAX_VALUE) }?.byteStream()?.use { return when (CloudFlareHelper.checkResponseForProtection(response)) {
Jsoup.parse(it, Charsets.UTF_8.name(), response.request.url.toString()) CloudFlareHelper.PROTECTION_BLOCKED -> response.closeThrowing(
} ?: return response CloudFlareBlockedException(
val hasCaptcha = content.getElementById("challenge-error-title") != null url = request.url.toString(),
val isBlocked = content.selectFirst("h2[data-translate=\"blocked_why_headline\"]") != null source = request.tag(MangaSource::class.java),
if (hasCaptcha || isBlocked) { ),
val request = response.request )
response.closeQuietly()
if (isBlocked) { CloudFlareHelper.PROTECTION_CAPTCHA -> response.closeThrowing(
throw CloudFlareBlockedException( CloudFlareProtectedException(
url = request.url.toString(), url = request.url.toString(),
source = request.tag(MangaSource::class.java), source = request.tag(MangaSource::class.java),
) headers = request.headers,
} else { ),
throw CloudFlareProtectedException( )
url = request.url.toString(),
source = request.tag(MangaSource::class.java), else -> response
headers = request.headers, }
) }
}
} private fun Response.closeThrowing(error: IOException): Nothing {
try {
close()
} catch (e: Exception) {
error.addSuppressed(e)
} }
return response throw error
} }
} }

@ -16,6 +16,7 @@ object CommonHeaders {
const val CACHE_CONTROL = "Cache-Control" const val CACHE_CONTROL = "Cache-Control"
const val PROXY_AUTHORIZATION = "Proxy-Authorization" const val PROXY_AUTHORIZATION = "Proxy-Authorization"
const val RETRY_AFTER = "Retry-After" const val RETRY_AFTER = "Retry-After"
const val MANGA_SOURCE = "X-Manga-Source"
val CACHE_CONTROL_NO_STORE: CacheControl val CACHE_CONTROL_NO_STORE: CacheControl
get() = CacheControl.Builder().noStore().build() get() = CacheControl.Builder().noStore().build()

@ -9,10 +9,12 @@ import okhttp3.Request
import okhttp3.Response import okhttp3.Response
import okio.IOException import okio.IOException
import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.model.MangaSource
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.ParserMangaRepository
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
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.mergeWith import org.koitharu.kotatsu.parsers.util.mergeWith
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
@ -29,15 +31,17 @@ class CommonHeadersInterceptor @Inject constructor(
override fun intercept(chain: Chain): Response { override fun intercept(chain: 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) { ?: request.headers[CommonHeaders.MANGA_SOURCE]?.let { MangaSource(it) }
val repository = if (source is MangaParserSource) {
mangaRepositoryFactoryLazy.get().create(source) as? ParserMangaRepository mangaRepositoryFactoryLazy.get().create(source) as? ParserMangaRepository
} else { } else {
if (BuildConfig.DEBUG) { if (BuildConfig.DEBUG && source == null) {
Log.w("Http", "Request without source tag: ${request.url}") Log.w("Http", "Request without source tag: ${request.url}")
} }
null null
} }
val headersBuilder = request.headers.newBuilder() val headersBuilder = request.headers.newBuilder()
.removeAll(CommonHeaders.MANGA_SOURCE)
repository?.getRequestHeaders()?.let { repository?.getRequestHeaders()?.let {
headersBuilder.mergeWith(it, replaceExisting = false) headersBuilder.mergeWith(it, replaceExisting = false)
} }

@ -2,12 +2,12 @@ package org.koitharu.kotatsu.core.network.imageproxy
import android.util.Log import android.util.Log
import androidx.collection.ArraySet import androidx.collection.ArraySet
import coil.intercept.Interceptor import coil3.intercept.Interceptor
import coil.network.HttpException import coil3.network.HttpException
import coil.request.ErrorResult import coil3.request.ErrorResult
import coil.request.ImageRequest import coil3.request.ImageRequest
import coil.request.ImageResult import coil3.request.ImageResult
import coil.request.SuccessResult import coil3.request.SuccessResult
import okhttp3.HttpUrl import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
@ -35,14 +35,14 @@ abstract class BaseImageProxyInterceptor : ImageProxyInterceptor {
else -> null else -> null
} }
if (url == null || !url.isHttpOrHttps || url.host in blacklist) { if (url == null || !url.isHttpOrHttps || url.host in blacklist) {
return chain.proceed(request) return chain.proceed()
} }
val newRequest = onInterceptImageRequest(request, url) val newRequest = onInterceptImageRequest(request, url)
return when (val result = chain.proceed(newRequest)) { return when (val result = chain.withRequest(newRequest).proceed()) {
is SuccessResult -> result is SuccessResult -> result
is ErrorResult -> { is ErrorResult -> {
logDebug(result.throwable, newRequest.data) logDebug(result.throwable, newRequest.data)
chain.proceed(request).also { chain.proceed().also {
if (it is SuccessResult && result.throwable.isBlockedByServer()) { if (it is SuccessResult && result.throwable.isBlockedByServer()) {
blacklist.add(url.host) blacklist.add(url.host)
} }

@ -1,6 +1,6 @@
package org.koitharu.kotatsu.core.network.imageproxy package org.koitharu.kotatsu.core.network.imageproxy
import coil.intercept.Interceptor import coil3.intercept.Interceptor
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.Response import okhttp3.Response

@ -1,7 +1,7 @@
package org.koitharu.kotatsu.core.network.imageproxy package org.koitharu.kotatsu.core.network.imageproxy
import coil.intercept.Interceptor import coil3.intercept.Interceptor
import coil.request.ImageResult import coil3.request.ImageResult
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.plus import kotlinx.coroutines.plus
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
@ -26,7 +26,7 @@ class RealImageProxyInterceptor @Inject constructor(
) )
override suspend fun intercept(chain: Interceptor.Chain): ImageResult { override suspend fun intercept(chain: Interceptor.Chain): ImageResult {
return delegate.value?.intercept(chain) ?: chain.proceed(chain.request) return delegate.value?.intercept(chain) ?: chain.proceed()
} }
override suspend fun interceptPageRequest(request: Request, okHttp: OkHttpClient): Response { override suspend fun interceptPageRequest(request: Request, okHttp: OkHttpClient): Response {

@ -1,8 +1,8 @@
package org.koitharu.kotatsu.core.network.imageproxy package org.koitharu.kotatsu.core.network.imageproxy
import coil.request.ImageRequest import coil3.request.ImageRequest
import coil.size.Dimension import coil3.size.Dimension
import coil.size.isOriginal import coil3.size.isOriginal
import okhttp3.HttpUrl import okhttp3.HttpUrl
import okhttp3.Request import okhttp3.Request

@ -1,6 +1,6 @@
package org.koitharu.kotatsu.core.network.imageproxy package org.koitharu.kotatsu.core.network.imageproxy
import coil.request.ImageRequest import coil3.request.ImageRequest
import okhttp3.HttpUrl import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Request import okhttp3.Request

@ -10,10 +10,11 @@ import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.graphics.drawable.IconCompat import androidx.core.graphics.drawable.IconCompat
import androidx.core.graphics.drawable.toBitmap import androidx.core.graphics.drawable.toBitmap
import androidx.room.InvalidationTracker import androidx.room.InvalidationTracker
import coil.ImageLoader import coil3.ImageLoader
import coil.request.ImageRequest import coil3.request.ImageRequest
import coil.size.Scale import coil3.request.transformations
import coil.size.Size import coil3.size.Scale
import coil3.size.Size
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@ -27,9 +28,9 @@ import org.koitharu.kotatsu.core.parser.favicon.faviconUri
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.image.ThumbnailTransformation import org.koitharu.kotatsu.core.ui.image.ThumbnailTransformation
import org.koitharu.kotatsu.core.util.ext.getDrawableOrThrow import org.koitharu.kotatsu.core.util.ext.getDrawableOrThrow
import org.koitharu.kotatsu.core.util.ext.mangaSourceExtra
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
import org.koitharu.kotatsu.core.util.ext.source
import org.koitharu.kotatsu.history.data.HistoryRepository import org.koitharu.kotatsu.history.data.HistoryRepository
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
@ -138,7 +139,7 @@ class AppShortcutManager @Inject constructor(
ImageRequest.Builder(context) ImageRequest.Builder(context)
.data(manga.coverUrl) .data(manga.coverUrl)
.size(iconSize) .size(iconSize)
.source(manga.source) .mangaSourceExtra(manga.source)
.scale(Scale.FILL) .scale(Scale.FILL)
.transformations(ThumbnailTransformation()) .transformations(ThumbnailTransformation())
.build(), .build(),

@ -6,6 +6,7 @@ import android.net.Network
import android.net.NetworkCapabilities import android.net.NetworkCapabilities
import android.net.NetworkRequest import android.net.NetworkRequest
import android.os.Build import android.os.Build
import coil3.network.ConnectivityChecker
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.MediatorStateFlow import org.koitharu.kotatsu.core.util.MediatorStateFlow
@ -13,13 +14,17 @@ import org.koitharu.kotatsu.core.util.MediatorStateFlow
class NetworkState( class NetworkState(
private val connectivityManager: ConnectivityManager, private val connectivityManager: ConnectivityManager,
private val settings: AppSettings, private val settings: AppSettings,
) : MediatorStateFlow<Boolean>(connectivityManager.isOnline(settings)) { ) : MediatorStateFlow<Boolean>(connectivityManager.isOnline(settings)), ConnectivityChecker {
private val callback = NetworkCallbackImpl() private val callback = NetworkCallbackImpl()
override val value: Boolean override val value: Boolean
get() = connectivityManager.isOnline(settings) get() = connectivityManager.isOnline(settings)
override fun isOnline(): Boolean {
return connectivityManager.isOnline(settings)
}
@Synchronized @Synchronized
override fun onActive() { override fun onActive() {
invalidate() invalidate()

@ -2,7 +2,7 @@ package org.koitharu.kotatsu.core.parser
import android.util.Log import android.util.Log
import androidx.collection.MutableLongSet import androidx.collection.MutableLongSet
import coil.request.CachePolicy import coil3.request.CachePolicy
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers

@ -1,13 +1,13 @@
package org.koitharu.kotatsu.core.parser package org.koitharu.kotatsu.core.parser
import android.net.Uri import android.net.Uri
import coil.request.CachePolicy import coil3.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.UnknownMangaSource
import org.koitharu.kotatsu.core.model.isNsfw 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.parsers.MangaLoaderContext
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.MangaListFilter import org.koitharu.kotatsu.parsers.model.MangaListFilter
@ -15,21 +15,20 @@ import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.almostEquals import org.koitharu.kotatsu.parsers.util.almostEquals
import org.koitharu.kotatsu.parsers.util.levenshteinDistance import org.koitharu.kotatsu.parsers.util.levenshteinDistance
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.parsers.util.toRelativeUrl
import javax.inject.Inject import javax.inject.Inject
@Reusable @Reusable
class MangaLinkResolver @Inject constructor( class MangaLinkResolver @Inject constructor(
private val repositoryFactory: MangaRepository.Factory, private val repositoryFactory: MangaRepository.Factory,
private val sourcesRepository: MangaSourcesRepository,
private val dataRepository: MangaDataRepository, private val dataRepository: MangaDataRepository,
private val context: MangaLoaderContext,
) { ) {
suspend fun resolve(uri: Uri): Manga { suspend fun resolve(uri: Uri): Manga {
return if (uri.scheme == "kotatsu" || uri.host == "kotatsu.app") { return if (uri.scheme == "kotatsu" || uri.host == "kotatsu.app") {
resolveAppLink(uri) resolveAppLink(uri)
} else { } else {
resolveExternalLink(uri) resolveExternalLink(uri.toString())
} ?: throw NotFoundException("Cannot resolve link", uri.toString()) } ?: throw NotFoundException("Cannot resolve link", uri.toString())
} }
@ -45,18 +44,11 @@ class MangaLinkResolver @Inject constructor(
) )
} }
private suspend fun resolveExternalLink(uri: Uri): Manga? { private suspend fun resolveExternalLink(uri: String): Manga? {
dataRepository.findMangaByPublicUrl(uri.toString())?.let { dataRepository.findMangaByPublicUrl(uri)?.let {
return it return it
} }
val host = uri.host ?: return null return context.newLinkResolver(uri).getManga()
val repo = sourcesRepository.allMangaSources.asSequence()
.map { source ->
repositoryFactory.create(source) as ParserMangaRepository
}.find { repo ->
host in repo.domains
} ?: return null
return repo.findExact(uri.toString().toRelativeUrl(host), null)
} }
private suspend fun MangaRepository.findExact(url: String?, title: String?): Manga? { private suspend fun MangaRepository.findExact(url: String?, title: String?): Manga? {
@ -85,12 +77,10 @@ class MangaLinkResolver @Inject constructor(
}.getOrThrow() }.getOrThrow()
} }
private suspend fun MangaRepository.getDetailsNoCache(manga: Manga): Manga { private suspend fun MangaRepository.getDetailsNoCache(manga: Manga): Manga = if (this is CachingMangaRepository) {
return if (this is ParserMangaRepository) { getDetails(manga, CachePolicy.READ_ONLY)
getDetails(manga, CachePolicy.READ_ONLY) } else {
} else { getDetails(manga)
getDetails(manga)
}
} }
private fun getSeedManga(source: MangaSource, url: String, title: String?) = Manga( private fun getSeedManga(source: MangaSource, url: String, title: String?) = Manga(

@ -7,6 +7,7 @@ import android.util.Base64
import android.webkit.WebView import android.webkit.WebView
import androidx.annotation.MainThread import androidx.annotation.MainThread
import androidx.core.os.LocaleListCompat import androidx.core.os.LocaleListCompat
import com.davemorrissey.labs.subscaleview.decoder.ImageDecodeException
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
@ -30,6 +31,7 @@ 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
import org.koitharu.kotatsu.parsers.util.map import org.koitharu.kotatsu.parsers.util.map
import org.koitharu.kotatsu.parsers.util.mimeType
import java.lang.ref.WeakReference import java.lang.ref.WeakReference
import java.util.Locale import java.util.Locale
import javax.inject.Inject import javax.inject.Inject
@ -86,7 +88,7 @@ class MangaLoaderContextImpl @Inject constructor(
result.compressTo(it.outputStream()) result.compressTo(it.outputStream())
}.asResponseBody("image/jpeg".toMediaType()) }.asResponseBody("image/jpeg".toMediaType())
} }
} ?: error("Cannot decode bitmap") } ?: throw ImageDecodeException(response.request.url.toString(), response.mimeType)
} }
} }

@ -8,7 +8,6 @@ import org.koitharu.kotatsu.core.prefs.SourceSettings
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
import org.koitharu.kotatsu.parsers.model.ContentRating
import org.koitharu.kotatsu.parsers.model.Favicons import org.koitharu.kotatsu.parsers.model.Favicons
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
@ -17,13 +16,10 @@ import org.koitharu.kotatsu.parsers.model.MangaListFilterCapabilities
import org.koitharu.kotatsu.parsers.model.MangaListFilterOptions import org.koitharu.kotatsu.parsers.model.MangaListFilterOptions
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.MangaParserSource
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.model.SortOrder
import org.koitharu.kotatsu.parsers.util.SuspendLazy import org.koitharu.kotatsu.parsers.util.SuspendLazy
import org.koitharu.kotatsu.parsers.util.domain import org.koitharu.kotatsu.parsers.util.domain
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import java.util.Locale
class ParserMangaRepository( class ParserMangaRepository(
private val parser: MangaParser, private val parser: MangaParser,

@ -17,7 +17,7 @@ import org.koitharu.kotatsu.parsers.util.SuspendLazy
import java.util.EnumSet import java.util.EnumSet
class ExternalMangaRepository( class ExternalMangaRepository(
private val contentResolver: ContentResolver, contentResolver: ContentResolver,
override val source: ExternalMangaSource, override val source: ExternalMangaSource,
cache: MemoryContentCache, cache: MemoryContentCache,
) : CachingMangaRepository(cache) { ) : CachingMangaRepository(cache) {
@ -42,7 +42,7 @@ class ExternalMangaRepository(
override var defaultSortOrder: SortOrder override var defaultSortOrder: SortOrder
get() = capabilities?.availableSortOrders?.firstOrNull() ?: SortOrder.ALPHABETICAL get() = capabilities?.availableSortOrders?.firstOrNull() ?: SortOrder.ALPHABETICAL
set(value) = Unit set(_) = Unit
override suspend fun getFilterOptions(): MangaListFilterOptions = filterOptions.get() override suspend fun getFilterOptions(): MangaListFilterOptions = filterOptions.get()

@ -8,6 +8,7 @@ import androidx.core.net.toUri
import org.jetbrains.annotations.Blocking import org.jetbrains.annotations.Blocking
import org.koitharu.kotatsu.core.exceptions.IncompatiblePluginException import org.koitharu.kotatsu.core.exceptions.IncompatiblePluginException
import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty
import org.koitharu.kotatsu.parsers.exception.NotFoundException
import org.koitharu.kotatsu.parsers.model.ContentRating import org.koitharu.kotatsu.parsers.model.ContentRating
import org.koitharu.kotatsu.parsers.model.ContentType import org.koitharu.kotatsu.parsers.model.ContentType
import org.koitharu.kotatsu.parsers.model.Demographic import org.koitharu.kotatsu.parsers.model.Demographic
@ -141,7 +142,7 @@ class ExternalPluginContentSource(
@Blocking @Blocking
@WorkerThread @WorkerThread
fun getPageUrl(url: String): String { fun getPageUrl(url: String): String {
val uri = "content://${source.authority}/pages/0".toUri().buildUpon() val uri = "content://${source.authority}/manga/pages/0".toUri().buildUpon()
.appendQueryParameter("url", url) .appendQueryParameter("url", url)
.build() .build()
return contentResolver.query(uri, null, null, null, null) return contentResolver.query(uri, null, null, null, null)

@ -1,6 +1,5 @@
package org.koitharu.kotatsu.core.parser.favicon package org.koitharu.kotatsu.core.parser.favicon
import android.content.Context
import android.graphics.Color import android.graphics.Color
import android.graphics.drawable.AdaptiveIconDrawable import android.graphics.drawable.AdaptiveIconDrawable
import android.graphics.drawable.ColorDrawable import android.graphics.drawable.ColorDrawable
@ -8,232 +7,130 @@ import android.graphics.drawable.Drawable
import android.graphics.drawable.LayerDrawable import android.graphics.drawable.LayerDrawable
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.webkit.MimeTypeMap import coil3.ImageLoader
import coil.ImageLoader import coil3.asImage
import coil.decode.DataSource import coil3.decode.DataSource
import coil.decode.ImageSource import coil3.fetch.FetchResult
import coil.disk.DiskCache import coil3.fetch.Fetcher
import coil.fetch.DrawableResult import coil3.fetch.ImageFetchResult
import coil.fetch.FetchResult import coil3.request.Options
import coil.fetch.Fetcher import coil3.size.pxOrElse
import coil.fetch.SourceResult import coil3.toAndroidUri
import coil.network.HttpException
import coil.request.Options
import coil.size.Size
import coil.size.pxOrElse
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ensureActive import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.runInterruptible
import okhttp3.OkHttpClient import okio.IOException
import okhttp3.Request import org.koitharu.kotatsu.R
import okhttp3.Response
import okhttp3.ResponseBody
import okhttp3.internal.closeQuietly
import okio.Closeable
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.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.ParserMangaRepository
import org.koitharu.kotatsu.core.parser.external.ExternalMangaRepository import org.koitharu.kotatsu.core.parser.external.ExternalMangaRepository
import org.koitharu.kotatsu.core.util.ext.writeAllCancellable import org.koitharu.kotatsu.core.util.ext.fetch
import org.koitharu.kotatsu.local.data.CacheDir import org.koitharu.kotatsu.local.data.LocalMangaRepository
import org.koitharu.kotatsu.local.data.util.withExtraCloseable
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.await
import org.koitharu.kotatsu.parsers.util.requireBody
import java.net.HttpURLConnection
import kotlin.coroutines.coroutineContext import kotlin.coroutines.coroutineContext
import coil3.Uri as CoilUri
private const val FALLBACK_SIZE = 9999 // largest icon
class FaviconFetcher( class FaviconFetcher(
private val okHttpClient: OkHttpClient, private val uri: Uri,
private val diskCache: Lazy<DiskCache?>,
private val mangaSource: MangaSource,
private val options: Options, private val options: Options,
private val imageLoader: ImageLoader,
private val mangaRepositoryFactory: MangaRepository.Factory, private val mangaRepositoryFactory: MangaRepository.Factory,
) : Fetcher { ) : Fetcher {
private val diskCacheKey override suspend fun fetch(): FetchResult? {
get() = options.diskCacheKey ?: "${mangaSource.name}x${options.size.toCacheKey()}" val mangaSource = MangaSource(uri.schemeSpecificPart)
private val fileSystem
get() = checkNotNull(diskCache.value).fileSystem
override suspend fun fetch(): FetchResult {
getCached(options)?.let { return it }
return when (val repo = mangaRepositoryFactory.create(mangaSource)) { return when (val repo = mangaRepositoryFactory.create(mangaSource)) {
is ParserMangaRepository -> fetchParserFavicon(repo) is ParserMangaRepository -> fetchParserFavicon(repo)
is ExternalMangaRepository -> fetchPluginIcon(repo) is ExternalMangaRepository -> fetchPluginIcon(repo)
is EmptyMangaRepository -> DrawableResult( is EmptyMangaRepository -> ImageFetchResult(
drawable = ColorDrawable(Color.WHITE), image = ColorDrawable(Color.WHITE).asImage(),
isSampled = false, isSampled = false,
dataSource = DataSource.MEMORY, dataSource = DataSource.MEMORY,
) )
else -> throw IllegalArgumentException("") is LocalMangaRepository -> imageLoader.fetch(R.drawable.ic_storage, options)
else -> throw IllegalArgumentException("Unsupported repo ${repo.javaClass.simpleName}")
} }
} }
private suspend fun fetchParserFavicon(repo: ParserMangaRepository): FetchResult { private suspend fun fetchParserFavicon(repository: 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 },
) )
var favicons = repo.getFavicons() var favicons = repository.getFavicons()
var lastError: Exception? = null var lastError: Exception? = null
while (favicons.isNotEmpty()) { while (favicons.isNotEmpty()) {
coroutineContext.ensureActive() coroutineContext.ensureActive()
val icon = favicons.find(sizePx) ?: throwNSEE(lastError) val icon = favicons.find(sizePx) ?: throwNSEE(lastError)
val response = try { try {
loadIcon(icon.url, mangaSource) val result = imageLoader.fetch(icon.url, options)
if (result != null) {
return result
} else {
favicons -= icon
}
} catch (e: CloudFlareProtectedException) { } catch (e: CloudFlareProtectedException) {
throw e throw e
} catch (e: HttpException) { } catch (e: IOException) {
lastError = e lastError = e
favicons -= icon favicons -= icon
continue
} }
val responseBody = response.requireBody()
val source = writeToDiskCache(responseBody)?.toImageSource()?.also {
response.closeQuietly()
} ?: responseBody.toImageSource(response)
return SourceResult(
source = source,
mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(icon.type),
dataSource = response.toDataSource(),
)
} }
throwNSEE(lastError) throwNSEE(lastError)
} }
private suspend fun loadIcon(url: String, source: MangaSource): Response {
val request = Request.Builder()
.url(url)
.get()
.tag(MangaSource::class.java, source)
request.tag(MangaSource::class.java, source)
@Suppress("UNCHECKED_CAST")
options.tags.asMap().forEach { request.tag(it.key as Class<Any>, it.value) }
val response = okHttpClient.newCall(request.build()).await()
if (!response.isSuccessful && response.code != HttpURLConnection.HTTP_NOT_MODIFIED) {
response.closeQuietly()
throw HttpException(response)
}
return response
}
private suspend fun fetchPluginIcon(repository: ExternalMangaRepository): FetchResult { private suspend fun fetchPluginIcon(repository: ExternalMangaRepository): FetchResult {
val source = repository.source val source = repository.source
val pm = options.context.packageManager val pm = options.context.packageManager
val icon = runInterruptible(Dispatchers.IO) { val icon = runInterruptible {
val provider = pm.resolveContentProvider(source.authority, 0) val provider = pm.resolveContentProvider(source.authority, 0)
provider?.loadIcon(pm) ?: pm.getApplicationIcon(source.packageName) provider?.loadIcon(pm) ?: pm.getApplicationIcon(source.packageName)
} }
return DrawableResult( return ImageFetchResult(
drawable = icon.nonAdaptive(), image = icon.nonAdaptive().asImage(),
isSampled = false, isSampled = false,
dataSource = DataSource.DISK, dataSource = DataSource.DISK,
) )
} }
private fun getCached(options: Options): SourceResult? { class Factory(
if (!options.diskCachePolicy.readEnabled) { private val mangaRepositoryFactory: MangaRepository.Factory,
return null ) : Fetcher.Factory<CoilUri> {
}
val snapshot = diskCache.value?.openSnapshot(diskCacheKey) ?: return null override fun create(
return SourceResult( data: CoilUri,
source = snapshot.toImageSource(), options: Options,
mimeType = null, imageLoader: ImageLoader
dataSource = DataSource.DISK, ): Fetcher? = if (data.scheme == URI_SCHEME_FAVICON) {
) FaviconFetcher(data.toAndroidUri(), options, imageLoader, mangaRepositoryFactory)
}
private suspend fun writeToDiskCache(body: ResponseBody): DiskCache.Snapshot? {
if (!options.diskCachePolicy.writeEnabled || body.contentLength() == 0L) {
return null
}
val editor = diskCache.value?.openEditor(diskCacheKey) ?: return null
try {
fileSystem.write(editor.data) {
writeAllCancellable(body.source())
}
return editor.commitAndOpenSnapshot()
} catch (e: Throwable) {
try {
editor.abort()
} catch (abortingError: Throwable) {
e.addSuppressed(abortingError)
}
body.closeQuietly()
throw e
} finally {
body.closeQuietly()
}
}
private fun DiskCache.Snapshot.toImageSource(): ImageSource {
return ImageSource(data, fileSystem, diskCacheKey, this)
}
private fun ResponseBody.toImageSource(response: Closeable): ImageSource {
return ImageSource(
source().withExtraCloseable(response).buffer(),
options.context,
FaviconMetadata(mangaSource),
)
}
private fun Response.toDataSource(): DataSource {
return if (networkResponse != null) DataSource.NETWORK else DataSource.DISK
}
private fun Size.toCacheKey() = buildString {
append(width.toString())
append('x')
append(height.toString())
}
private fun throwNSEE(lastError: Exception?): Nothing {
if (lastError != null) {
throw lastError
} else { } else {
throw NoSuchElementException("No favicons found") null
} }
} }
private fun Drawable.nonAdaptive() = private companion object {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && this is AdaptiveIconDrawable) {
LayerDrawable(arrayOf(background, foreground))
} else {
this
}
class Factory( const val FALLBACK_SIZE = 9999 // largest icon
context: Context,
okHttpClientLazy: Lazy<OkHttpClient>,
private val mangaRepositoryFactory: MangaRepository.Factory,
) : Fetcher.Factory<Uri> {
private val okHttpClient by okHttpClientLazy
private val diskCache = lazy {
val rootDir = context.externalCacheDir ?: context.cacheDir
DiskCache.Builder()
.directory(rootDir.resolve(CacheDir.FAVICONS.dir))
.build()
}
override fun create(data: Uri, options: Options, imageLoader: ImageLoader): Fetcher? { private fun throwNSEE(lastError: Exception?): Nothing {
return if (data.scheme == URI_SCHEME_FAVICON) { if (lastError != null) {
val mangaSource = MangaSource(data.schemeSpecificPart) throw lastError
FaviconFetcher(okHttpClient, diskCache, mangaSource, options, mangaRepositoryFactory)
} else { } else {
null throw NoSuchElementException("No favicons found")
} }
} }
}
class FaviconMetadata(val source: MangaSource) : ImageSource.Metadata() private fun Drawable.nonAdaptive() =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && this is AdaptiveIconDrawable) {
LayerDrawable(arrayOf(background, foreground))
} else {
this
}
}
} }

@ -2,6 +2,7 @@ package org.koitharu.kotatsu.core.prefs
import android.content.Context import android.content.Context
import android.content.SharedPreferences import android.content.SharedPreferences
import android.content.pm.ActivityInfo
import android.net.ConnectivityManager import android.net.ConnectivityManager
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
@ -11,6 +12,7 @@ import androidx.appcompat.app.AppCompatDelegate
import androidx.collection.ArraySet import androidx.collection.ArraySet
import androidx.core.content.edit import androidx.core.content.edit
import androidx.core.os.LocaleListCompat import androidx.core.os.LocaleListCompat
import androidx.core.util.TimeUtils
import androidx.documentfile.provider.DocumentFile import androidx.documentfile.provider.DocumentFile
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
@ -33,6 +35,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.concurrent.TimeUnit
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@ -127,6 +130,10 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
get() = prefs.getBoolean(KEY_READER_DOUBLE_PAGES, false) get() = prefs.getBoolean(KEY_READER_DOUBLE_PAGES, false)
set(value) = prefs.edit { putBoolean(KEY_READER_DOUBLE_PAGES, value) } set(value) = prefs.edit { putBoolean(KEY_READER_DOUBLE_PAGES, value) }
val readerScreenOrientation: Int
get() = prefs.getString(KEY_READER_ORIENTATION, null)?.toIntOrNull()
?: ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
val isReaderVolumeButtonsEnabled: Boolean val isReaderVolumeButtonsEnabled: Boolean
get() = prefs.getBoolean(KEY_READER_VOLUME_BUTTONS, false) get() = prefs.getBoolean(KEY_READER_VOLUME_BUTTONS, false)
@ -142,10 +149,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val isReaderOptimizationEnabled: Boolean val isReaderOptimizationEnabled: Boolean
get() = prefs.getBoolean(KEY_READER_OPTIMIZE, false) get() = prefs.getBoolean(KEY_READER_OPTIMIZE, false)
var isTrafficWarningEnabled: Boolean
get() = prefs.getBoolean(KEY_TRAFFIC_WARNING, true)
set(value) = prefs.edit { putBoolean(KEY_TRAFFIC_WARNING, value) }
val isOfflineCheckDisabled: Boolean val isOfflineCheckDisabled: Boolean
get() = prefs.getBoolean(KEY_OFFLINE_DISABLED, false) get() = prefs.getBoolean(KEY_OFFLINE_DISABLED, false)
@ -336,8 +339,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
} }
} }
val isDownloadsWiFiOnly: Boolean var allowDownloadOnMeteredNetwork: TriStateOption
get() = prefs.getBoolean(KEY_DOWNLOADS_WIFI, false) get() = prefs.getEnumValue(KEY_DOWNLOADS_METERED_NETWORK, TriStateOption.ASK)
set(value) = prefs.edit { putEnumValue(KEY_DOWNLOADS_METERED_NETWORK, value) }
val preferredDownloadFormat: DownloadFormat val preferredDownloadFormat: DownloadFormat
get() = prefs.getEnumValue(KEY_DOWNLOADS_FORMAT, DownloadFormat.AUTOMATIC) get() = prefs.getEnumValue(KEY_DOWNLOADS_FORMAT, DownloadFormat.AUTOMATIC)
@ -477,9 +481,12 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
get() = prefs.getBoolean(KEY_BACKUP_PERIODICAL_ENABLED, false) get() = prefs.getBoolean(KEY_BACKUP_PERIODICAL_ENABLED, false)
val periodicalBackupFrequency: Long val periodicalBackupFrequency: Long
get() = prefs.getString(KEY_BACKUP_PERIODICAL_FREQUENCY, null)?.toLongOrNull() ?: 7L get() = TimeUnit.DAYS.toMillis(prefs.getString(KEY_BACKUP_PERIODICAL_FREQUENCY, null)?.toLongOrNull() ?: 7L)
val periodicalBackupMaxCount: Int
get() = prefs.getInt(KEY_BACKUP_PERIODICAL_COUNT, 10)
var periodicalBackupOutput: Uri? var periodicalBackupDirectory: Uri?
get() = prefs.getString(KEY_BACKUP_PERIODICAL_OUTPUT, null)?.toUriOrNull() get() = prefs.getString(KEY_BACKUP_PERIODICAL_OUTPUT, null)?.toUriOrNull()
set(value) = prefs.edit { putString(KEY_BACKUP_PERIODICAL_OUTPUT, value?.toString()) } set(value) = prefs.edit { putString(KEY_BACKUP_PERIODICAL_OUTPUT, value?.toString()) }
@ -581,7 +588,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_THEME = "theme" const val KEY_THEME = "theme"
const val KEY_COLOR_THEME = "color_theme" const val KEY_COLOR_THEME = "color_theme"
const val KEY_THEME_AMOLED = "amoled_theme" const val KEY_THEME_AMOLED = "amoled_theme"
const val KEY_TRAFFIC_WARNING = "traffic_warning"
const val KEY_OFFLINE_DISABLED = "no_offline" const val KEY_OFFLINE_DISABLED = "no_offline"
const val KEY_PAGES_CACHE_CLEAR = "pages_cache_clear" const val KEY_PAGES_CACHE_CLEAR = "pages_cache_clear"
const val KEY_HTTP_CACHE_CLEAR = "http_cache_clear" const val KEY_HTTP_CACHE_CLEAR = "http_cache_clear"
@ -600,6 +606,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_READER_CONTROL_LTR = "reader_taps_ltr" const val KEY_READER_CONTROL_LTR = "reader_taps_ltr"
const val KEY_READER_FULLSCREEN = "reader_fullscreen" const val KEY_READER_FULLSCREEN = "reader_fullscreen"
const val KEY_READER_VOLUME_BUTTONS = "reader_volume_buttons" const val KEY_READER_VOLUME_BUTTONS = "reader_volume_buttons"
const val KEY_READER_ORIENTATION = "reader_orientation"
const val KEY_TRACKER_ENABLED = "tracker_enabled" const val KEY_TRACKER_ENABLED = "tracker_enabled"
const val KEY_TRACKER_WIFI_ONLY = "tracker_wifi" const val KEY_TRACKER_WIFI_ONLY = "tracker_wifi"
const val KEY_TRACKER_FREQUENCY = "tracker_freq" const val KEY_TRACKER_FREQUENCY = "tracker_freq"
@ -627,6 +634,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_RESTORE = "restore" const val KEY_RESTORE = "restore"
const val KEY_BACKUP_PERIODICAL_ENABLED = "backup_periodic" const val KEY_BACKUP_PERIODICAL_ENABLED = "backup_periodic"
const val KEY_BACKUP_PERIODICAL_FREQUENCY = "backup_periodic_freq" const val KEY_BACKUP_PERIODICAL_FREQUENCY = "backup_periodic_freq"
const val KEY_BACKUP_PERIODICAL_COUNT = "backup_periodic_count"
const val KEY_BACKUP_PERIODICAL_OUTPUT = "backup_periodic_output" const val KEY_BACKUP_PERIODICAL_OUTPUT = "backup_periodic_output"
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"
@ -647,7 +655,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_ANILIST = "anilist" const val KEY_ANILIST = "anilist"
const val KEY_MAL = "mal" const val KEY_MAL = "mal"
const val KEY_KITSU = "kitsu" const val KEY_KITSU = "kitsu"
const val KEY_DOWNLOADS_WIFI = "downloads_wifi" const val KEY_DOWNLOADS_METERED_NETWORK = "downloads_metered_network"
const val KEY_DOWNLOADS_FORMAT = "downloads_format" const val KEY_DOWNLOADS_FORMAT = "downloads_format"
const val KEY_ALL_FAVOURITES_VISIBLE = "all_favourites_visible" const val KEY_ALL_FAVOURITES_VISIBLE = "all_favourites_visible"
const val KEY_DOH = "doh" const val KEY_DOH = "doh"
@ -718,7 +726,8 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_LINK_TELEGRAM = "about_telegram" const val KEY_LINK_TELEGRAM = "about_telegram"
const val KEY_LINK_GITHUB = "about_github" const val KEY_LINK_GITHUB = "about_github"
const val KEY_LINK_MANUAL = "about_help" const val KEY_LINK_MANUAL = "about_help"
const val PROXY_TEST = "proxy_test" const val KEY_PROXY_TEST = "proxy_test"
const val KEY_OPEN_BROWSER = "open_browser"
// old keys are for migration only // old keys are for migration only
private const val KEY_IMAGES_PROXY_OLD = "images_proxy" private const val KEY_IMAGES_PROXY_OLD = "images_proxy"

@ -0,0 +1,9 @@
package org.koitharu.kotatsu.core.prefs
import androidx.annotation.Keep
@Keep
enum class TriStateOption {
ENABLED, ASK, DISABLED;
}

@ -9,7 +9,10 @@ import androidx.annotation.CallSuper
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.core.graphics.Insets import androidx.core.graphics.Insets
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat import androidx.preference.PreferenceFragmentCompat
import androidx.preference.PreferenceScreen
import androidx.preference.get
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
@ -20,9 +23,11 @@ import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.util.RecyclerViewOwner import org.koitharu.kotatsu.core.ui.util.RecyclerViewOwner
import org.koitharu.kotatsu.core.ui.util.WindowInsetsDelegate import org.koitharu.kotatsu.core.ui.util.WindowInsetsDelegate
import org.koitharu.kotatsu.core.util.ext.getThemeColor import org.koitharu.kotatsu.core.util.ext.getThemeColor
import org.koitharu.kotatsu.core.util.ext.getThemeDrawable
import org.koitharu.kotatsu.core.util.ext.parentView import org.koitharu.kotatsu.core.util.ext.parentView
import org.koitharu.kotatsu.settings.SettingsActivity import org.koitharu.kotatsu.settings.SettingsActivity
import javax.inject.Inject import javax.inject.Inject
import com.google.android.material.R as materialR
@AndroidEntryPoint @AndroidEntryPoint
abstract class BasePreferenceFragment(@StringRes private val titleId: Int) : abstract class BasePreferenceFragment(@StringRes private val titleId: Int) :
@ -67,6 +72,10 @@ abstract class BasePreferenceFragment(@StringRes private val titleId: Int) :
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
setTitle(if (titleId != 0) getString(titleId) else null) setTitle(if (titleId != 0) getString(titleId) else null)
arguments?.getString(SettingsActivity.ARG_PREF_KEY)?.let {
focusPreference(it)
arguments?.remove(SettingsActivity.ARG_PREF_KEY)
}
} }
@CallSuper @CallSuper
@ -87,4 +96,31 @@ abstract class BasePreferenceFragment(@StringRes private val titleId: Int) :
Snackbar.make(listView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT).show() Snackbar.make(listView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT).show()
false false
} }
private fun focusPreference(key: String) {
val pref = findPreference<Preference>(key)
if (pref == null) {
scrollToPreference(key)
return
}
scrollToPreference(pref)
val prefIndex = preferenceScreen.indexOf(key)
val view = if (prefIndex >= 0) {
listView.findViewHolderForAdapterPosition(prefIndex)?.itemView ?: return
} else {
return
}
view.context.getThemeDrawable(materialR.attr.colorTertiaryContainer)?.let {
view.background = it
}
}
private fun PreferenceScreen.indexOf(key: String): Int {
for (i in 0 until preferenceCount) {
if (get(i).key == key) {
return i
}
}
return -1
}
} }

@ -1,5 +1,6 @@
package org.koitharu.kotatsu.core.ui package org.koitharu.kotatsu.core.ui
import android.app.Notification
import android.app.PendingIntent import android.app.PendingIntent
import android.content.BroadcastReceiver import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
@ -9,11 +10,10 @@ import android.os.PatternMatcher
import androidx.annotation.AnyThread import androidx.annotation.AnyThread
import androidx.annotation.WorkerThread import androidx.annotation.WorkerThread
import androidx.core.app.PendingIntentCompat import androidx.core.app.PendingIntentCompat
import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -21,60 +21,104 @@ import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import kotlin.coroutines.CoroutineContext
abstract class CoroutineIntentService : BaseService() { abstract class CoroutineIntentService : BaseService() {
private val mutex = Mutex() private val mutex = Mutex()
protected open val dispatcher: CoroutineDispatcher = Dispatchers.Default
final override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { final override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
super.onStartCommand(intent, flags, startId) super.onStartCommand(intent, flags, startId)
val job = launchCoroutine(intent, startId) launchCoroutine(intent, startId)
val receiver = CancelReceiver(job)
ContextCompat.registerReceiver(
this,
receiver,
createIntentFilter(this, startId),
ContextCompat.RECEIVER_NOT_EXPORTED,
)
job.invokeOnCompletion { unregisterReceiver(receiver) }
return START_REDELIVER_INTENT return START_REDELIVER_INTENT
} }
private fun launchCoroutine(intent: Intent?, startId: Int) = lifecycleScope.launch(errorHandler(startId)) { private fun launchCoroutine(intent: Intent?, startId: Int) = lifecycleScope.launch {
val intentJobContext = IntentJobContextImpl(startId, coroutineContext)
mutex.withLock { mutex.withLock {
try { try {
if (intent != null) { if (intent != null) {
withContext(dispatcher) { withContext(Dispatchers.Default) {
processIntent(startId, intent) intentJobContext.processIntent(intent)
} }
} }
} catch (e: Throwable) { } catch (e: Throwable) {
e.printStackTraceDebug() e.printStackTraceDebug()
onError(startId, e) intentJobContext.onError(e)
} finally { } finally {
stopSelf(startId) intentJobContext.stop()
} }
} }
} }
@WorkerThread @WorkerThread
protected abstract suspend fun processIntent(startId: Int, intent: Intent) protected abstract suspend fun IntentJobContext.processIntent(intent: Intent)
@AnyThread @AnyThread
protected abstract fun onError(startId: Int, error: Throwable) protected abstract fun IntentJobContext.onError(error: Throwable)
protected fun getCancelIntent(startId: Int) = PendingIntentCompat.getBroadcast( interface IntentJobContext {
this,
0, val startId: Int
createCancelIntent(this, startId),
PendingIntent.FLAG_UPDATE_CURRENT, fun getCancelIntent(): PendingIntent?
false,
) fun setForeground(id: Int, notification: Notification, serviceType: Int)
}
private fun errorHandler(startId: Int) = CoroutineExceptionHandler { _, throwable ->
throwable.printStackTraceDebug() protected inner class IntentJobContextImpl(
onError(startId, throwable) override val startId: Int,
private val coroutineContext: CoroutineContext,
) : IntentJobContext {
private var cancelReceiver: CancelReceiver? = null
private var isStopped = false
private var isForeground = false
override fun getCancelIntent(): PendingIntent? {
ensureHasCancelReceiver()
return PendingIntentCompat.getBroadcast(
applicationContext,
0,
createCancelIntent(this@CoroutineIntentService, startId),
PendingIntent.FLAG_UPDATE_CURRENT,
false,
)
}
override fun setForeground(id: Int, notification: Notification, serviceType: Int) {
ServiceCompat.startForeground(this@CoroutineIntentService, id, notification, serviceType)
isForeground = true
}
fun stop() {
synchronized(this) {
cancelReceiver?.let { unregisterReceiver(it) }
isStopped = true
}
if (isForeground) {
ServiceCompat.stopForeground(this@CoroutineIntentService, ServiceCompat.STOP_FOREGROUND_REMOVE)
}
stopSelf(startId)
}
private fun ensureHasCancelReceiver() {
if (cancelReceiver == null && !isStopped) {
synchronized(this) {
if (cancelReceiver == null && !isStopped) {
val job = coroutineContext[Job] ?: return
cancelReceiver = CancelReceiver(job).also { receiver ->
ContextCompat.registerReceiver(
applicationContext,
receiver,
createIntentFilter(this@CoroutineIntentService, startId),
ContextCompat.RECEIVER_NOT_EXPORTED,
)
}
}
}
}
}
} }
private class CancelReceiver( private class CancelReceiver(

@ -9,7 +9,7 @@ import kotlinx.coroutines.flow.FlowCollector
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.list.ui.adapter.ListItemType import org.koitharu.kotatsu.list.ui.adapter.ListItemType
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import java.util.Collections import org.koitharu.kotatsu.parsers.util.move
import java.util.LinkedList import java.util.LinkedList
open class ReorderableListAdapter<T : ListModel> : ListDelegationAdapter<List<T>>(), FlowCollector<List<T>?> { open class ReorderableListAdapter<T : ListModel> : ListDelegationAdapter<List<T>>(), FlowCollector<List<T>?> {
@ -28,13 +28,17 @@ open class ReorderableListAdapter<T : ListModel> : ListDelegationAdapter<List<T>
listListeners.forEach { it.onCurrentListChanged(oldList, newList) } listListeners.forEach { it.onCurrentListChanged(oldList, newList) }
} }
@Deprecated("Use emit() to dispatch list updates", level = DeprecationLevel.ERROR) @Deprecated(
override fun setItems(items: List<T>?) { message = "Use emit() to dispatch list updates",
super.setItems(items) level = DeprecationLevel.ERROR,
} replaceWith = ReplaceWith("emit(items)"),
)
override fun setItems(items: List<T>?) = super.setItems(items)
fun reorderItems(oldPos: Int, newPos: Int) { fun reorderItems(oldPos: Int, newPos: Int) {
Collections.swap(items ?: return, oldPos, newPos) val reordered = items?.toMutableList() ?: return
reordered.move(oldPos, newPos)
super.setItems(reordered)
notifyItemMoved(oldPos, newPos) notifyItemMoved(oldPos, newPos)
} }

@ -6,12 +6,13 @@ import android.view.LayoutInflater
import androidx.annotation.DrawableRes import androidx.annotation.DrawableRes
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.core.view.isGone
import androidx.core.view.isVisible import androidx.core.view.isVisible
import com.google.android.material.button.MaterialButton import com.google.android.material.button.MaterialButton
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.koitharu.kotatsu.databinding.DialogTwoButtonsBinding import org.koitharu.kotatsu.databinding.DialogTwoButtonsBinding
class TwoButtonsAlertDialog private constructor( class BigButtonsAlertDialog private constructor(
private val delegate: AlertDialog private val delegate: AlertDialog
) : DialogInterface by delegate { ) : DialogInterface by delegate {
@ -51,14 +52,44 @@ class TwoButtonsAlertDialog private constructor(
@StringRes textId: Int, @StringRes textId: Int,
listener: DialogInterface.OnClickListener? = null listener: DialogInterface.OnClickListener? = null
): Builder { ): Builder {
initButton(binding.button2, DialogInterface.BUTTON_NEGATIVE, textId, listener) initButton(binding.button3, DialogInterface.BUTTON_NEGATIVE, textId, listener)
return this return this
} }
fun create(): TwoButtonsAlertDialog { fun setNeutralButton(
@StringRes textId: Int,
listener: DialogInterface.OnClickListener? = null
): Builder {
initButton(binding.button2, DialogInterface.BUTTON_NEUTRAL, textId, listener)
return this
}
fun create(): BigButtonsAlertDialog {
with(binding) {
button1.adjustCorners(isFirst = true, isLast = button2.isGone && button3.isGone)
button2.adjustCorners(isFirst = button1.isGone, isLast = button3.isGone)
button3.adjustCorners(isFirst = button1.isGone && button2.isGone, isLast = true)
}
val dialog = delegate.create() val dialog = delegate.create()
binding.root.tag = dialog binding.root.tag = dialog
return TwoButtonsAlertDialog(dialog) return BigButtonsAlertDialog(dialog)
}
private fun MaterialButton.adjustCorners(isFirst: Boolean, isLast: Boolean) {
if (!isVisible) {
return
}
shapeAppearanceModel = shapeAppearanceModel.toBuilder().apply {
if (!isFirst) {
setTopLeftCornerSize(0f)
setTopRightCornerSize(0f)
}
if (!isLast) {
setBottomLeftCornerSize(0f)
setBottomRightCornerSize(0f)
}
}.build()
} }
private fun initButton( private fun initButton(

@ -0,0 +1,58 @@
package org.koitharu.kotatsu.core.ui.dialog
import android.content.Context
import android.content.DialogInterface
import androidx.annotation.UiContext
import androidx.core.net.ConnectivityManagerCompat
import dagger.Lazy
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.TriStateOption
import org.koitharu.kotatsu.core.util.ext.connectivityManager
import javax.inject.Inject
class CommonAlertDialogs @Inject constructor(
private val settings: Lazy<AppSettings>,
) {
fun askForDownloadOverMeteredNetwork(
@UiContext context: Context,
onConfirmed: (allow: Boolean) -> Unit
) {
when (settings.get().allowDownloadOnMeteredNetwork) {
TriStateOption.ENABLED -> onConfirmed(true)
TriStateOption.DISABLED -> onConfirmed(false)
TriStateOption.ASK -> {
if (!ConnectivityManagerCompat.isActiveNetworkMetered(context.connectivityManager)) {
onConfirmed(true)
return
}
val listener = DialogInterface.OnClickListener { _, which ->
when (which) {
DialogInterface.BUTTON_POSITIVE -> {
settings.get().allowDownloadOnMeteredNetwork = TriStateOption.ENABLED
onConfirmed(true)
}
DialogInterface.BUTTON_NEUTRAL -> {
onConfirmed(true)
}
DialogInterface.BUTTON_NEGATIVE -> {
settings.get().allowDownloadOnMeteredNetwork = TriStateOption.DISABLED
onConfirmed(false)
}
}
}
BigButtonsAlertDialog.Builder(context)
.setIcon(R.drawable.ic_network_cellular)
.setTitle(R.string.download_cellular_confirm)
.setPositiveButton(R.string.allow_always, listener)
.setNeutralButton(R.string.allow_once, listener)
.setNegativeButton(R.string.dont_allow, listener)
.create()
.show()
}
}
}
}

@ -1,7 +1,7 @@
package org.koitharu.kotatsu.core.ui.image package org.koitharu.kotatsu.core.ui.image
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import coil.target.GenericViewTarget import coil3.target.GenericViewTarget
import com.google.android.material.chip.Chip import com.google.android.material.chip.Chip
class ChipIconTarget(override val view: Chip) : GenericViewTarget<Chip>() { class ChipIconTarget(override val view: Chip) : GenericViewTarget<Chip>() {

@ -4,10 +4,12 @@ import android.content.Context
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import android.text.Html import android.text.Html
import androidx.annotation.WorkerThread import androidx.annotation.WorkerThread
import coil.ImageLoader import coil3.ImageLoader
import coil.executeBlocking import coil3.executeBlocking
import coil.request.ImageRequest import coil3.request.ImageRequest
import coil3.request.allowHardware
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import org.koitharu.kotatsu.core.util.ext.drawable
import javax.inject.Inject import javax.inject.Inject
class CoilImageGetter @Inject constructor( class CoilImageGetter @Inject constructor(

@ -4,9 +4,9 @@ import android.view.View
import android.view.View.OnLayoutChangeListener import android.view.View.OnLayoutChangeListener
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.ImageView import android.widget.ImageView
import coil.size.Dimension import coil3.size.Dimension
import coil.size.Size import coil3.size.Size
import coil.size.ViewSizeResolver import coil3.size.ViewSizeResolver
import kotlinx.coroutines.CancellableContinuation import kotlinx.coroutines.CancellableContinuation
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.suspendCancellableCoroutine
import kotlin.coroutines.resume import kotlin.coroutines.resume

@ -2,11 +2,11 @@ package org.koitharu.kotatsu.core.ui.image
import android.graphics.Bitmap import android.graphics.Bitmap
import android.media.ThumbnailUtils import android.media.ThumbnailUtils
import coil.size.Size import coil3.size.Size
import coil.size.pxOrElse import coil3.size.pxOrElse
import coil.transform.Transformation import coil3.transform.Transformation
class ThumbnailTransformation : Transformation { class ThumbnailTransformation : Transformation() {
override val cacheKey: String = javaClass.name override val cacheKey: String = javaClass.name
@ -17,8 +17,4 @@ class ThumbnailTransformation : Transformation {
size.height.pxOrElse { input.height }, size.height.pxOrElse { input.height },
) )
} }
override fun equals(other: Any?) = other is ThumbnailTransformation
override fun hashCode() = javaClass.hashCode()
} }

@ -2,13 +2,13 @@ package org.koitharu.kotatsu.core.ui.image
import android.graphics.Bitmap import android.graphics.Bitmap
import androidx.core.graphics.get import androidx.core.graphics.get
import coil.size.Size import coil3.size.Size
import coil.transform.Transformation import coil3.transform.Transformation
import org.koitharu.kotatsu.reader.domain.EdgeDetector.Companion.isColorTheSame import org.koitharu.kotatsu.reader.domain.EdgeDetector.Companion.isColorTheSame
class TrimTransformation( class TrimTransformation(
private val tolerance: Int = 20, private val tolerance: Int = 20,
) : Transformation { ) : Transformation() {
override val cacheKey: String = "${javaClass.name}-$tolerance" override val cacheKey: String = "${javaClass.name}-$tolerance"
@ -92,12 +92,4 @@ class TrimTransformation(
input input
} }
} }
override fun equals(other: Any?): Boolean {
return this === other || (other is TrimTransformation && other.tolerance == tolerance)
}
override fun hashCode(): Int {
return tolerance
}
} }

@ -28,6 +28,8 @@ class AdapterDelegateClickListenerAdapter<I, O>(
private fun mappedItem(): O = itemMapper.apply(adapterDelegate.item) private fun mappedItem(): O = itemMapper.apply(adapterDelegate.item)
fun attach() = attach(adapterDelegate.itemView)
fun attach(itemView: View) { fun attach(itemView: View) {
itemView.setOnClickListener(this) itemView.setOnClickListener(this)
itemView.setOnLongClickListener(this) itemView.setOnLongClickListener(this)

@ -8,10 +8,16 @@ import androidx.annotation.ColorRes
import androidx.annotation.DrawableRes import androidx.annotation.DrawableRes
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.core.view.children import androidx.core.view.children
import coil.ImageLoader import coil3.ImageLoader
import coil.request.Disposable import coil3.request.Disposable
import coil.request.ImageRequest import coil3.request.ImageRequest
import coil.transform.RoundedCornersTransformation import coil3.request.allowRgb565
import coil3.request.crossfade
import coil3.request.error
import coil3.request.fallback
import coil3.request.placeholder
import coil3.request.transformations
import coil3.transform.RoundedCornersTransformation
import com.google.android.material.chip.Chip import com.google.android.material.chip.Chip
import com.google.android.material.chip.ChipDrawable import com.google.android.material.chip.ChipDrawable
import com.google.android.material.chip.ChipGroup import com.google.android.material.chip.ChipGroup

@ -11,11 +11,13 @@ import android.graphics.drawable.ShapeDrawable
import android.graphics.drawable.shapes.RoundRectShape import android.graphics.drawable.shapes.RoundRectShape
import android.util.AttributeSet import android.util.AttributeSet
import android.view.LayoutInflater import android.view.LayoutInflater
import android.widget.Checkable
import android.widget.LinearLayout import android.widget.LinearLayout
import androidx.annotation.AttrRes import androidx.annotation.AttrRes
import androidx.annotation.DrawableRes import androidx.annotation.DrawableRes
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.content.withStyledAttributes import androidx.core.content.withStyledAttributes
import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams import androidx.core.view.updateLayoutParams
import androidx.core.widget.ImageViewCompat import androidx.core.widget.ImageViewCompat
import androidx.core.widget.TextViewCompat import androidx.core.widget.TextViewCompat
@ -23,6 +25,7 @@ import com.google.android.material.ripple.RippleUtils
import com.google.android.material.shape.MaterialShapeDrawable import com.google.android.material.shape.MaterialShapeDrawable
import com.google.android.material.shape.ShapeAppearanceModel import com.google.android.material.shape.ShapeAppearanceModel
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ext.getDrawableCompat
import org.koitharu.kotatsu.core.util.ext.resolveDp import org.koitharu.kotatsu.core.util.ext.resolveDp
import org.koitharu.kotatsu.core.util.ext.textAndVisible import org.koitharu.kotatsu.core.util.ext.textAndVisible
import org.koitharu.kotatsu.databinding.ViewTwoLinesItemBinding import org.koitharu.kotatsu.databinding.ViewTwoLinesItemBinding
@ -32,7 +35,7 @@ class TwoLinesItemView @JvmOverloads constructor(
context: Context, context: Context,
attrs: AttributeSet? = null, attrs: AttributeSet? = null,
@AttrRes defStyleAttr: Int = 0, @AttrRes defStyleAttr: Int = 0,
) : LinearLayout(context, attrs, defStyleAttr) { ) : LinearLayout(context, attrs, defStyleAttr), Checkable {
private val binding = ViewTwoLinesItemBinding.inflate(LayoutInflater.from(context), this) private val binding = ViewTwoLinesItemBinding.inflate(LayoutInflater.from(context), this)
@ -48,6 +51,12 @@ class TwoLinesItemView @JvmOverloads constructor(
binding.subtitle.textAndVisible = value binding.subtitle.textAndVisible = value
} }
var isButtonEnabled: Boolean
get() = binding.button.isEnabled
set(value) {
binding.button.isEnabled = value
}
init { init {
var textColors: ColorStateList? = null var textColors: ColorStateList? = null
context.withStyledAttributes( context.withStyledAttributes(
@ -68,7 +77,7 @@ class TwoLinesItemView @JvmOverloads constructor(
binding.layoutText.updateLayoutParams<MarginLayoutParams> { marginStart = drawablePadding } binding.layoutText.updateLayoutParams<MarginLayoutParams> { marginStart = drawablePadding }
setIconResource(getResourceId(R.styleable.TwoLinesItemView_icon, 0)) setIconResource(getResourceId(R.styleable.TwoLinesItemView_icon, 0))
binding.title.text = getText(R.styleable.TwoLinesItemView_title) binding.title.text = getText(R.styleable.TwoLinesItemView_title)
binding.subtitle.text = getText(R.styleable.TwoLinesItemView_subtitle) binding.subtitle.textAndVisible = getText(R.styleable.TwoLinesItemView_subtitle)
textColors = getColorStateList(R.styleable.TwoLinesItemView_android_textColor) textColors = getColorStateList(R.styleable.TwoLinesItemView_android_textColor)
val textAppearanceFallback = androidx.appcompat.R.style.TextAppearance_AppCompat val textAppearanceFallback = androidx.appcompat.R.style.TextAppearance_AppCompat
TextViewCompat.setTextAppearance( TextViewCompat.setTextAppearance(
@ -79,6 +88,10 @@ class TwoLinesItemView @JvmOverloads constructor(
binding.subtitle, binding.subtitle,
getResourceId(R.styleable.TwoLinesItemView_subtitleTextAppearance, textAppearanceFallback), getResourceId(R.styleable.TwoLinesItemView_subtitleTextAppearance, textAppearanceFallback),
) )
binding.icon.isChecked = getBoolean(R.styleable.TwoLinesItemView_android_checked, false)
val button = getDrawableCompat(context, R.styleable.TwoLinesItemView_android_button)
binding.button.setImageDrawable(button)
binding.button.isVisible = button != null
} }
if (textColors == null) { if (textColors == null) {
textColors = binding.title.textColors textColors = binding.title.textColors
@ -88,6 +101,16 @@ class TwoLinesItemView @JvmOverloads constructor(
ImageViewCompat.setImageTintList(binding.icon, textColors) ImageViewCompat.setImageTintList(binding.icon, textColors)
} }
override fun isChecked() = binding.icon.isChecked
override fun toggle() = binding.icon.toggle()
override fun setChecked(checked: Boolean) {
binding.icon.isChecked = checked
}
fun setOnButtonClickListener(listener: OnClickListener?) = binding.button.setOnClickListener(listener)
fun setIconResource(@DrawableRes resId: Int) { fun setIconResource(@DrawableRes resId: Int) {
binding.icon.setImageResource(resId) binding.icon.setImageResource(resId)
} }

@ -0,0 +1,3 @@
package org.koitharu.kotatsu.core.util
interface CloseableSequence<T> : Sequence<T>, AutoCloseable

@ -1,6 +1,7 @@
package org.koitharu.kotatsu.core.util package org.koitharu.kotatsu.core.util
import android.content.Context import android.content.Context
import android.content.Intent
import android.net.Uri import android.net.Uri
import androidx.core.app.ShareCompat import androidx.core.app.ShareCompat
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
@ -75,11 +76,9 @@ class ShareHelper(private val context: Context) {
.startChooser() .startChooser()
} }
fun shareText(text: String) { fun getShareTextIntent(text: String): Intent = ShareCompat.IntentBuilder(context)
ShareCompat.IntentBuilder(context) .setText(text)
.setText(text) .setType(TYPE_TEXT)
.setType(TYPE_TEXT) .setChooserTitle(R.string.share)
.setChooserTitle(R.string.share) .createChooserIntent()
.startChooser()
}
} }

@ -7,10 +7,12 @@ import android.app.ActivityManager
import android.app.ActivityManager.MemoryInfo import android.app.ActivityManager.MemoryInfo
import android.app.ActivityOptions import android.app.ActivityOptions
import android.app.LocaleConfig import android.app.LocaleConfig
import android.content.ComponentName
import android.content.Context import android.content.Context
import android.content.Context.ACTIVITY_SERVICE import android.content.Context.ACTIVITY_SERVICE
import android.content.Context.POWER_SERVICE import android.content.Context.POWER_SERVICE
import android.content.ContextWrapper import android.content.ContextWrapper
import android.content.Intent
import android.content.OperationApplicationException import android.content.OperationApplicationException
import android.content.SharedPreferences import android.content.SharedPreferences
import android.content.SyncResult import android.content.SyncResult
@ -33,6 +35,7 @@ import androidx.annotation.IntegerRes
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.app.AppCompatDelegate import androidx.appcompat.app.AppCompatDelegate
import androidx.appcompat.app.AppCompatDialog import androidx.appcompat.app.AppCompatDialog
import androidx.core.app.ActivityCompat
import androidx.core.app.ActivityOptionsCompat import androidx.core.app.ActivityOptionsCompat
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
@ -61,6 +64,7 @@ import okio.use
import org.json.JSONException import org.json.JSONException
import org.jsoup.internal.StringUtil.StringJoiner import org.jsoup.internal.StringUtil.StringJoiner
import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.main.ui.MainActivity
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.xmlpull.v1.XmlPullParser import org.xmlpull.v1.XmlPullParser
import org.xmlpull.v1.XmlPullParserException import org.xmlpull.v1.XmlPullParserException
@ -274,3 +278,10 @@ fun WebView.configureForParser(userAgentOverride: String?) = with(settings) {
userAgentString = userAgentOverride userAgentString = userAgentOverride
} }
} }
fun Context.restartApplication() {
val activity = findActivity()
val intent = Intent.makeRestartActivityTask(ComponentName(this, MainActivity::class.java))
startActivity(intent)
activity?.finishAndRemoveTask()
}

@ -14,12 +14,17 @@ import androidx.lifecycle.SavedStateHandle
import java.io.Serializable import java.io.Serializable
import java.util.EnumSet import java.util.EnumSet
// https://issuetracker.google.com/issues/240585930 // https://issuetracker.google.com/issues/240585930
inline fun <reified T : Parcelable> Bundle.getParcelableCompat(key: String): T? { inline fun <reified T : Parcelable> Bundle.getParcelableCompat(key: String): T? {
return BundleCompat.getParcelable(this, key, T::class.java) return BundleCompat.getParcelable(this, key, T::class.java)
} }
inline fun <reified T : Parcelable> Bundle.requireParcelable(key: String): T = checkNotNull(getParcelableCompat(key)) {
"Parcelable of type \"${T::class.java.name}\" not found at \"$key\""
}
inline fun <reified T : Parcelable> Intent.getParcelableExtraCompat(key: String): T? { inline fun <reified T : Parcelable> Intent.getParcelableExtraCompat(key: String): T? {
return IntentCompat.getParcelableExtra(this, key, T::class.java) return IntentCompat.getParcelableExtra(this, key, T::class.java)
} }
@ -84,3 +89,24 @@ fun <T> SavedStateHandle.require(key: String): T {
"Value $key not found in SavedStateHandle or has a wrong type" "Value $key not found in SavedStateHandle or has a wrong type"
} }
} }
fun Parcelable.marshall(): ByteArray {
val parcel = Parcel.obtain()
return try {
this.writeToParcel(parcel, 0)
parcel.marshall()
} finally {
parcel.recycle()
}
}
fun <T : Parcelable> Parcelable.Creator<T>.unmarshall(bytes: ByteArray): T {
val parcel = Parcel.obtain()
return try {
parcel.unmarshall(bytes, 0, bytes.size)
parcel.setDataPosition(0)
createFromParcel(parcel)
} finally {
parcel.recycle()
}
}

@ -2,21 +2,37 @@ package org.koitharu.kotatsu.core.util.ext
import android.content.Context import android.content.Context
import android.graphics.drawable.ColorDrawable import android.graphics.drawable.ColorDrawable
import android.graphics.drawable.Drawable
import android.widget.ImageView import android.widget.ImageView
import androidx.core.graphics.ColorUtils import androidx.core.graphics.ColorUtils
import androidx.core.graphics.drawable.toBitmap
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import coil.ImageLoader import coil3.Extras
import coil.request.ErrorResult import coil3.ImageLoader
import coil.request.ImageRequest import coil3.asDrawable
import coil.request.ImageResult import coil3.fetch.FetchResult
import coil.request.SuccessResult import coil3.request.ErrorResult
import coil.util.CoilUtils import coil3.request.ImageRequest
import coil3.request.ImageResult
import coil3.request.Options
import coil3.request.SuccessResult
import coil3.request.bitmapConfig
import coil3.request.crossfade
import coil3.request.error
import coil3.request.fallback
import coil3.request.lifecycle
import coil3.request.placeholder
import coil3.request.target
import coil3.size.Scale
import coil3.size.ViewSizeResolver
import coil3.toBitmap
import coil3.util.CoilUtils
import com.google.android.material.progressindicator.BaseProgressIndicator import com.google.android.material.progressindicator.BaseProgressIndicator
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.core.image.RegionBitmapDecoder
import org.koitharu.kotatsu.core.ui.image.AnimatedPlaceholderDrawable import org.koitharu.kotatsu.core.ui.image.AnimatedPlaceholderDrawable
import org.koitharu.kotatsu.core.ui.image.RegionBitmapDecoder
import org.koitharu.kotatsu.core.util.progress.ImageRequestIndicatorListener import org.koitharu.kotatsu.core.util.progress.ImageRequestIndicatorListener
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import com.google.android.material.R as materialR import com.google.android.material.R as materialR
@ -32,6 +48,8 @@ fun ImageView.newImageRequest(lifecycleOwner: LifecycleOwner, data: Any?): Image
.data(data?.takeUnless { it == "" || it == 0 }) .data(data?.takeUnless { it == "" || it == 0 })
.lifecycle(lifecycleOwner) .lifecycle(lifecycleOwner)
.crossfade(context) .crossfade(context)
.size(ViewSizeResolver(this))
.scale(scaleType.toCoilScale())
.target(this) .target(this)
} }
@ -43,13 +61,16 @@ fun ImageView.disposeImageRequest() {
fun ImageRequest.Builder.enqueueWith(loader: ImageLoader) = loader.enqueue(build()) fun ImageRequest.Builder.enqueueWith(loader: ImageLoader) = loader.enqueue(build())
fun ImageResult.getDrawableOrThrow() = when (this) { fun ImageResult.getDrawableOrThrow() = when (this) {
is SuccessResult -> drawable is SuccessResult -> image.asDrawable(request.context.resources)
is ErrorResult -> throw throwable is ErrorResult -> throw throwable
} }
val ImageResult.drawable: Drawable?
get() = image?.asDrawable(request.context.resources)
fun ImageResult.toBitmapOrNull() = when (this) { fun ImageResult.toBitmapOrNull() = when (this) {
is SuccessResult -> try { is SuccessResult -> try {
drawable.toBitmap() image.toBitmap(image.width, image.height, request.bitmapConfig)
} catch (_: Throwable) { } catch (_: Throwable) {
null null
} }
@ -63,8 +84,10 @@ fun ImageRequest.Builder.indicator(indicators: List<BaseProgressIndicator<*>>):
fun ImageRequest.Builder.decodeRegion( fun ImageRequest.Builder.decodeRegion(
scroll: Int = RegionBitmapDecoder.SCROLL_UNDEFINED, scroll: Int = RegionBitmapDecoder.SCROLL_UNDEFINED,
): ImageRequest.Builder = decoderFactory(RegionBitmapDecoder.Factory()) ): ImageRequest.Builder = apply {
.setParameter(RegionBitmapDecoder.PARAM_SCROLL, scroll) decoderFactory(RegionBitmapDecoder.Factory)
extras[RegionBitmapDecoder.regionScrollKey] = scroll
}
@Suppress("SpellCheckingInspection") @Suppress("SpellCheckingInspection")
fun ImageRequest.Builder.crossfade(context: Context): ImageRequest.Builder { fun ImageRequest.Builder.crossfade(context: Context): ImageRequest.Builder {
@ -72,8 +95,18 @@ fun ImageRequest.Builder.crossfade(context: Context): ImageRequest.Builder {
return crossfade(duration.toInt()) return crossfade(duration.toInt())
} }
fun ImageRequest.Builder.source(source: MangaSource?): ImageRequest.Builder { fun ImageRequest.Builder.mangaSourceExtra(source: MangaSource?): ImageRequest.Builder = apply {
return tag(MangaSource::class.java, source) extras[mangaSourceKey] = source
}
fun ImageRequest.Builder.mangaExtra(manga: Manga): ImageRequest.Builder = apply {
extras[mangaKey] = manga
mangaSourceExtra(manga.source)
}
fun ImageRequest.Builder.bookmarkExtra(bookmark: Bookmark): ImageRequest.Builder = apply {
extras[bookmarkKey] = bookmark
mangaSourceExtra(bookmark.manga.source)
} }
fun ImageRequest.Builder.defaultPlaceholders(context: Context): ImageRequest.Builder { fun ImageRequest.Builder.defaultPlaceholders(context: Context): ImageRequest.Builder {
@ -87,6 +120,12 @@ fun ImageRequest.Builder.defaultPlaceholders(context: Context): ImageRequest.Bui
.error(ColorDrawable(errorColor)) .error(ColorDrawable(errorColor))
} }
private fun ImageView.ScaleType.toCoilScale(): Scale = if (this == ImageView.ScaleType.CENTER_CROP) {
Scale.FILL
} else {
Scale.FIT
}
fun ImageRequest.Builder.addListener(listener: ImageRequest.Listener): ImageRequest.Builder { fun ImageRequest.Builder.addListener(listener: ImageRequest.Listener): ImageRequest.Builder {
val existing = build().listener val existing = build().listener
return listener( return listener(
@ -98,6 +137,12 @@ fun ImageRequest.Builder.addListener(listener: ImageRequest.Listener): ImageRequ
) )
} }
suspend fun ImageLoader.fetch(data: Any, options: Options): FetchResult? {
val mappedData = components.map(data, options)
val fetcher = components.newFetcher(mappedData, options, this)?.first
return fetcher?.fetch()
}
private class CompositeImageRequestListener( private class CompositeImageRequestListener(
private val delegates: Array<ImageRequest.Listener>, private val delegates: Array<ImageRequest.Listener>,
) : ImageRequest.Listener { ) : ImageRequest.Listener {
@ -113,3 +158,7 @@ private class CompositeImageRequestListener(
operator fun plus(other: ImageRequest.Listener) = CompositeImageRequestListener(delegates + other) operator fun plus(other: ImageRequest.Listener) = CompositeImageRequestListener(delegates + other)
} }
val mangaKey = Extras.Key<Manga?>(null)
val bookmarkKey = Extras.Key<Bookmark?>(null)
val mangaSourceKey = Extras.Key<MangaSource?>(null)

@ -92,7 +92,7 @@ fun LongSet.toLongArray(): LongArray {
return result return result
} }
fun LongSet.toSet(): Set<Long> = toCollection(ArraySet<Long>(size)) fun LongSet.toSet(): Set<Long> = toCollection(ArraySet(size))
fun <R : MutableCollection<Long>> LongSet.toCollection(out: R): R = out.also { result -> fun <R : MutableCollection<Long>> LongSet.toCollection(out: R): R = out.also { result ->
forEach(result::add) forEach(result::add)

@ -7,16 +7,17 @@ import android.os.Build
import android.os.Environment import android.os.Environment
import android.os.storage.StorageManager import android.os.storage.StorageManager
import android.provider.OpenableColumns import android.provider.OpenableColumns
import android.webkit.MimeTypeMap
import androidx.core.database.getStringOrNull import androidx.core.database.getStringOrNull
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.runInterruptible
import okhttp3.internal.closeQuietly import okhttp3.MediaType
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import org.jetbrains.annotations.Blocking import org.jetbrains.annotations.Blocking
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.fs.FileSequence import org.koitharu.kotatsu.core.fs.FileSequence
import java.io.BufferedReader
import java.io.File import java.io.File
import java.io.FileFilter
import java.io.InputStream
import java.nio.file.attribute.BasicFileAttributes import java.nio.file.attribute.BasicFileAttributes
import java.util.zip.ZipEntry import java.util.zip.ZipEntry
import java.util.zip.ZipFile import java.util.zip.ZipFile
@ -36,17 +37,15 @@ fun File.takeIfWriteable() = takeIf { it.exists() && it.canWrite() }
fun File.isNotEmpty() = length() != 0L fun File.isNotEmpty() = length() != 0L
@Blocking @Blocking
fun ZipFile.readText(entry: ZipEntry) = getInputStream(entry).bufferedReader().use { fun ZipFile.readText(entry: ZipEntry) = getInputStream(entry).use { output ->
it.readText() output.bufferedReader().use(BufferedReader::readText)
} }
@Blocking val ZipEntry.mimeType: MediaType?
fun ZipFile.getInputStreamOrClose(entry: ZipEntry): InputStream = try { get() {
getInputStream(entry) val ext = name.substringAfterLast('.')
} catch (e: Throwable) { return MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext)?.toMediaTypeOrNull()
closeQuietly() }
throw e
}
fun File.getStorageName(context: Context): String = runCatching { fun File.getStorageName(context: Context): String = runCatching {
val manager = context.getSystemService(Context.STORAGE_SERVICE) as StorageManager val manager = context.getSystemService(Context.STORAGE_SERVICE) as StorageManager
@ -62,7 +61,7 @@ fun File.getStorageName(context: Context): String = runCatching {
} }
}.getOrNull() ?: context.getString(R.string.other_storage) }.getOrNull() ?: context.getString(R.string.other_storage)
fun Uri.toFileOrNull() = if (scheme == URI_SCHEME_FILE) path?.let(::File) else null fun Uri.toFileOrNull() = if (isFileUri()) path?.let(::File) else null
suspend fun File.deleteAwait() = runInterruptible(Dispatchers.IO) { suspend fun File.deleteAwait() = runInterruptible(Dispatchers.IO) {
delete() || deleteRecursively() delete() || deleteRecursively()
@ -87,9 +86,13 @@ suspend fun File.computeSize(): Long = runInterruptible(Dispatchers.IO) {
walkCompat(includeDirectories = false).sumOf { it.length() } walkCompat(includeDirectories = false).sumOf { it.length() }
} }
fun File.children() = FileSequence(this) inline fun <R> File.withChildren(block: (children: Sequence<File>) -> R): R = FileSequence(this).use(block)
fun Sequence<File>.filterWith(filter: FileFilter): Sequence<File> = filter { f -> filter.accept(f) } fun FileSequence(dir: File): FileSequence = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
FileSequence.StreamImpl(dir)
} else {
FileSequence.ListImpl(dir)
}
val File.creationTime val File.creationTime
get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {

@ -17,7 +17,6 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.transform import kotlinx.coroutines.flow.transform
import kotlinx.coroutines.flow.transformLatest import kotlinx.coroutines.flow.transformLatest
import kotlinx.coroutines.flow.transformWhile import kotlinx.coroutines.flow.transformWhile
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.parsers.util.SuspendLazy import org.koitharu.kotatsu.parsers.util.SuspendLazy
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicInteger

@ -7,9 +7,15 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.ResponseBody import okhttp3.ResponseBody
import okio.BufferedSink import okio.BufferedSink
import okio.FileSystem
import okio.IOException
import okio.Path
import okio.Source import okio.Source
import org.koitharu.kotatsu.core.util.CancellableSource import org.koitharu.kotatsu.core.util.CancellableSource
import org.koitharu.kotatsu.core.util.progress.ProgressResponseBody import org.koitharu.kotatsu.core.util.progress.ProgressResponseBody
import java.io.ByteArrayOutputStream
import java.io.InputStream
import java.nio.ByteBuffer
fun ResponseBody.withProgress(progressState: MutableStateFlow<Float>): ResponseBody { fun ResponseBody.withProgress(progressState: MutableStateFlow<Float>): ResponseBody {
return ProgressResponseBody(this, progressState) return ProgressResponseBody(this, progressState)
@ -23,3 +29,22 @@ suspend fun Source.cancellable(): Source {
suspend fun BufferedSink.writeAllCancellable(source: Source) = withContext(Dispatchers.IO) { suspend fun BufferedSink.writeAllCancellable(source: Source) = withContext(Dispatchers.IO) {
writeAll(source.cancellable()) writeAll(source.cancellable())
} }
fun InputStream.toByteBuffer(): ByteBuffer {
val outStream = ByteArrayOutputStream(available())
copyTo(outStream)
val bytes = outStream.toByteArray()
return ByteBuffer.allocateDirect(bytes.size).put(bytes).position(0) as ByteBuffer
}
fun FileSystem.isDirectory(path: Path) = try {
metadataOrNull(path)?.isDirectory == true
} catch (_: IOException) {
false
}
fun FileSystem.isRegularFile(path: Path) = try {
metadataOrNull(path)?.isRegularFile == true
} catch (_: IOException) {
false
}

@ -37,13 +37,6 @@ val RecyclerView.visibleItemCount: Int
findLastVisibleItemPosition() - findFirstVisibleItemPosition() findLastVisibleItemPosition() - findFirstVisibleItemPosition()
} ?: 0 } ?: 0
fun RecyclerView.findCenterViewPosition(): Int {
val centerX = width / 2f
val centerY = height / 2f
val view = findChildViewUnder(centerX, centerY) ?: return RecyclerView.NO_POSITION
return getChildAdapterPosition(view)
}
fun <T> RecyclerView.ViewHolder.getItem(clazz: Class<T>): T? { fun <T> RecyclerView.ViewHolder.getItem(clazz: Class<T>): T? {
val rawItem = when (this) { val rawItem = when (this) {
is AdapterDelegateViewBindingViewHolder<*, *> -> item is AdapterDelegateViewBindingViewHolder<*, *> -> item

@ -28,6 +28,8 @@ fun String.toUUIDOrNull(): UUID? = try {
null null
} }
fun String.digits() = filter { it.isDigit() }
/** /**
* @param threshold 0 = exact match * @param threshold 0 = exact match
*/ */

@ -29,7 +29,7 @@ fun Context.getThemeColor(
@Px @Px
fun Context.getThemeDimensionPixelSize( fun Context.getThemeDimensionPixelSize(
@AttrRes resId: Int, @AttrRes resId: Int,
@ColorInt fallback: Int = 0, @Px fallback: Int = 0,
) = obtainStyledAttributes(intArrayOf(resId)).use { ) = obtainStyledAttributes(intArrayOf(resId)).use {
it.getDimensionPixelSize(0, fallback) it.getDimensionPixelSize(0, fallback)
} }
@ -37,7 +37,7 @@ fun Context.getThemeDimensionPixelSize(
@Px @Px
fun Context.getThemeDimensionPixelOffset( fun Context.getThemeDimensionPixelOffset(
@AttrRes resId: Int, @AttrRes resId: Int,
@ColorInt fallback: Int = 0, @Px fallback: Int = 0,
) = obtainStyledAttributes(intArrayOf(resId)).use { ) = obtainStyledAttributes(intArrayOf(resId)).use {
it.getDimensionPixelOffset(0, fallback) it.getDimensionPixelOffset(0, fallback)
} }

@ -3,8 +3,9 @@ package org.koitharu.kotatsu.core.util.ext
import android.content.ActivityNotFoundException import android.content.ActivityNotFoundException
import android.content.res.Resources import android.content.res.Resources
import androidx.annotation.DrawableRes import androidx.annotation.DrawableRes
import coil.network.HttpException import coil3.network.HttpException
import com.davemorrissey.labs.subscaleview.decoder.ImageDecodeException import com.davemorrissey.labs.subscaleview.decoder.ImageDecodeException
import okhttp3.Response
import okio.FileNotFoundException import okio.FileNotFoundException
import okio.IOException import okio.IOException
import okio.ProtocolException import okio.ProtocolException
@ -24,6 +25,7 @@ import org.koitharu.kotatsu.core.exceptions.UnsupportedFileException
import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException
import org.koitharu.kotatsu.core.exceptions.WrongPasswordException import org.koitharu.kotatsu.core.exceptions.WrongPasswordException
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.io.NullOutputStream
import org.koitharu.kotatsu.parsers.ErrorMessages.FILTER_BOTH_LOCALE_GENRES_NOT_SUPPORTED import org.koitharu.kotatsu.parsers.ErrorMessages.FILTER_BOTH_LOCALE_GENRES_NOT_SUPPORTED
import org.koitharu.kotatsu.parsers.ErrorMessages.FILTER_BOTH_STATES_GENRES_NOT_SUPPORTED import org.koitharu.kotatsu.parsers.ErrorMessages.FILTER_BOTH_STATES_GENRES_NOT_SUPPORTED
import org.koitharu.kotatsu.parsers.ErrorMessages.FILTER_MULTIPLE_GENRES_NOT_SUPPORTED import org.koitharu.kotatsu.parsers.ErrorMessages.FILTER_MULTIPLE_GENRES_NOT_SUPPORTED
@ -35,13 +37,20 @@ import org.koitharu.kotatsu.parsers.exception.NotFoundException
import org.koitharu.kotatsu.parsers.exception.ParseException import org.koitharu.kotatsu.parsers.exception.ParseException
import org.koitharu.kotatsu.parsers.exception.TooManyRequestExceptions import org.koitharu.kotatsu.parsers.exception.TooManyRequestExceptions
import org.koitharu.kotatsu.scrobbling.common.domain.ScrobblerAuthRequiredException import org.koitharu.kotatsu.scrobbling.common.domain.ScrobblerAuthRequiredException
import java.net.ConnectException
import java.net.NoRouteToHostException
import java.io.ObjectOutputStream
import java.net.SocketTimeoutException import java.net.SocketTimeoutException
import java.net.UnknownHostException import java.net.UnknownHostException
import java.util.Locale
private const val MSG_NO_SPACE_LEFT = "No space left on device" private const val MSG_NO_SPACE_LEFT = "No space left on device"
private const val IMAGE_FORMAT_NOT_SUPPORTED = "Image format not supported" private const val IMAGE_FORMAT_NOT_SUPPORTED = "Image format not supported"
fun Throwable.getDisplayMessage(resources: Resources): String = when (this) { fun Throwable.getDisplayMessage(resources: Resources): String = getDisplayMessageOrNull(resources)
?: resources.getString(R.string.error_occurred)
private fun Throwable.getDisplayMessageOrNull(resources: Resources): String? = when (this) {
is ScrobblerAuthRequiredException -> resources.getString( is ScrobblerAuthRequiredException -> resources.getString(
R.string.scrobbler_auth_required, R.string.scrobbler_auth_required,
resources.getString(scrobbler.titleResId), resources.getString(scrobbler.titleResId),
@ -78,12 +87,28 @@ fun Throwable.getDisplayMessage(resources: Resources): String = when (this) {
is ContentUnavailableException -> message is ContentUnavailableException -> message
is ParseException -> shortMessage is ParseException -> shortMessage
is ConnectException,
is UnknownHostException, is UnknownHostException,
is NoRouteToHostException,
is SocketTimeoutException -> resources.getString(R.string.network_error) is SocketTimeoutException -> resources.getString(R.string.network_error)
is ImageDecodeException -> resources.getString(R.string.error_corrupted_file) is ImageDecodeException -> {
val type = format?.substringBefore('/')
val formatString = format.ifNullOrEmpty { resources.getString(R.string.unknown).lowercase(Locale.getDefault()) }
if (type.isNullOrEmpty() || type == "image") {
resources.getString(R.string.error_image_format, formatString)
} else {
resources.getString(R.string.error_not_image, formatString)
}
}
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 IncompatiblePluginException -> {
cause?.getDisplayMessageOrNull(resources)?.let {
resources.getString(R.string.plugin_incompatible_with_cause, it)
} ?: 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)
@ -92,9 +117,7 @@ fun Throwable.getDisplayMessage(resources: Resources): String = when (this) {
is HttpStatusException -> getHttpDisplayMessage(statusCode, resources) is HttpStatusException -> getHttpDisplayMessage(statusCode, resources)
else -> getDisplayMessage(message, resources) ?: message else -> getDisplayMessage(message, resources) ?: message
}.ifNullOrEmpty { }.takeUnless { it.isNullOrBlank() }
resources.getString(R.string.error_occurred)
}
@DrawableRes @DrawableRes
fun Throwable.getDisplayIcon() = when (this) { fun Throwable.getDisplayIcon() = when (this) {
@ -102,6 +125,8 @@ fun Throwable.getDisplayIcon() = when (this) {
is CloudFlareProtectedException -> R.drawable.ic_bot_large is CloudFlareProtectedException -> R.drawable.ic_bot_large
is UnknownHostException, is UnknownHostException,
is SocketTimeoutException, is SocketTimeoutException,
is ConnectException,
is NoRouteToHostException,
is ProtocolException -> R.drawable.ic_plug_large is ProtocolException -> R.drawable.ic_plug_large
is CloudFlareBlockedException -> R.drawable.ic_denied_large is CloudFlareBlockedException -> R.drawable.ic_denied_large
@ -109,8 +134,21 @@ fun Throwable.getDisplayIcon() = when (this) {
else -> R.drawable.ic_error_large else -> R.drawable.ic_error_large
} }
fun Throwable.getCauseUrl(): String? = when (this) {
is ParseException -> url
is NotFoundException -> url
is TooManyRequestExceptions -> url
is CaughtException -> cause?.getCauseUrl()
is CloudFlareBlockedException -> url
is CloudFlareProtectedException -> url
is HttpStatusException -> url
is HttpException -> (response.delegate as? Response)?.request?.url?.toString()
else -> null
}
private fun getHttpDisplayMessage(statusCode: Int, resources: Resources): String? = when (statusCode) { private fun getHttpDisplayMessage(statusCode: Int, resources: Resources): String? = when (statusCode) {
404 -> resources.getString(R.string.not_found_404) 404 -> resources.getString(R.string.not_found_404)
403 -> resources.getString(R.string.access_denied_403)
in 500..599 -> resources.getString(R.string.server_error, statusCode) in 500..599 -> resources.getString(R.string.server_error, statusCode)
else -> null else -> null
} }
@ -143,6 +181,8 @@ fun Throwable.isReportable(): Boolean {
|| this is CloudFlareProtectedException || this is CloudFlareProtectedException
|| this is BadBackupFormatException || this is BadBackupFormatException
|| this is WrongPasswordException || this is WrongPasswordException
|| this is TooManyRequestExceptions
|| this is HttpStatusException
) { ) {
return false return false
} }
@ -165,3 +205,9 @@ fun Throwable.isWebViewUnavailable(): Boolean {
@Suppress("FunctionName") @Suppress("FunctionName")
fun NoSpaceLeftException() = IOException(MSG_NO_SPACE_LEFT) fun NoSpaceLeftException() = IOException(MSG_NO_SPACE_LEFT)
fun Throwable.isSerializable() = runCatching {
val oos = ObjectOutputStream(NullOutputStream())
oos.writeObject(this)
oos.flush()
}.isSuccess

@ -1,56 +1,38 @@
package org.koitharu.kotatsu.core.util.ext package org.koitharu.kotatsu.core.util.ext
import android.net.Uri import android.net.Uri
import androidx.core.net.toFile import androidx.core.net.toUri
import okio.Source import okio.Path
import okio.source
import okio.use
import org.jetbrains.annotations.Blocking
import org.koitharu.kotatsu.local.data.util.withExtraCloseable
import java.io.File import java.io.File
import java.util.zip.ZipFile
const val URI_SCHEME_FILE = "file"
const val URI_SCHEME_ZIP = "file+zip" const val URI_SCHEME_ZIP = "file+zip"
private const val URI_SCHEME_FILE = "file"
@Blocking private const val URI_SCHEME_HTTP = "http"
fun Uri.exists(): Boolean = when (scheme) { private const val URI_SCHEME_HTTPS = "https"
URI_SCHEME_FILE -> toFile().exists() private const val URI_SCHEME_LEGACY_CBZ = "cbz"
URI_SCHEME_ZIP -> { private const val URI_SCHEME_LEGACY_ZIP = "zip"
val file = File(requireNotNull(schemeSpecificPart))
file.exists() && ZipFile(file).use { it.getEntry(fragment) != null } fun Uri.isZipUri() = scheme.let {
} it == URI_SCHEME_ZIP || it == URI_SCHEME_LEGACY_CBZ || it == URI_SCHEME_LEGACY_ZIP
else -> unsupportedUri(this)
} }
@Blocking fun Uri.isFileUri() = scheme == URI_SCHEME_FILE
fun Uri.isTargetNotEmpty(): Boolean = when (scheme) {
URI_SCHEME_FILE -> toFile().isNotEmpty()
URI_SCHEME_ZIP -> {
val file = File(requireNotNull(schemeSpecificPart))
file.exists() && ZipFile(file).use { (it.getEntry(fragment)?.size ?: 0L) != 0L }
}
else -> unsupportedUri(this) fun Uri.isNetworkUri() = scheme.let {
it == URI_SCHEME_HTTP || it == URI_SCHEME_HTTPS
} }
@Blocking fun File.toZipUri(entryPath: String): Uri = Uri.parse("$URI_SCHEME_ZIP://$absolutePath#$entryPath")
fun Uri.source(): Source = when (scheme) {
URI_SCHEME_FILE -> toFile().source()
URI_SCHEME_ZIP -> {
val zip = ZipFile(schemeSpecificPart)
val entry = zip.getEntry(fragment)
zip.getInputStreamOrClose(entry).source().withExtraCloseable(zip)
}
else -> unsupportedUri(this)
}
fun File.toZipUri(entryName: String): Uri = Uri.parse("$URI_SCHEME_ZIP://$absolutePath#$entryName") fun File.toZipUri(entryPath: Path?): Uri =
toZipUri(entryPath?.toString()?.removePrefix(Path.DIRECTORY_SEPARATOR).orEmpty())
fun String.toUriOrNull() = if (isEmpty()) null else Uri.parse(this) fun String.toUriOrNull() = if (isEmpty()) null else Uri.parse(this)
private fun unsupportedUri(uri: Uri): Nothing { fun File.toUri(fragment: String?): Uri = toUri().run {
throw IllegalArgumentException("Bad uri $uri: only schemes $URI_SCHEME_FILE and $URI_SCHEME_ZIP are supported") if (fragment != null) {
buildUpon().fragment(fragment).build()
} else {
this
}
} }

@ -6,9 +6,9 @@ import androidx.work.WorkInfo
import androidx.work.WorkManager import androidx.work.WorkManager
import androidx.work.WorkQuery import androidx.work.WorkQuery
import androidx.work.WorkRequest import androidx.work.WorkRequest
import androidx.work.await
import androidx.work.impl.WorkManagerImpl import androidx.work.impl.WorkManagerImpl
import androidx.work.impl.model.WorkSpec import androidx.work.impl.model.WorkSpec
import kotlinx.coroutines.guava.await
import java.util.UUID import java.util.UUID
import kotlin.coroutines.resume import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException import kotlin.coroutines.resumeWithException
@ -63,7 +63,7 @@ suspend fun WorkManager.awaitWorkInfoById(id: UUID): WorkInfo? {
@SuppressLint("RestrictedApi") @SuppressLint("RestrictedApi")
suspend fun WorkManager.awaitUniqueWorkInfoByName(name: String): List<WorkInfo> { suspend fun WorkManager.awaitUniqueWorkInfoByName(name: String): List<WorkInfo> {
return getWorkInfosForUniqueWork(name).await().orEmpty() return getWorkInfosForUniqueWork(name).await()
} }
@SuppressLint("RestrictedApi") @SuppressLint("RestrictedApi")

@ -1,36 +0,0 @@
package org.koitharu.kotatsu.core.util.iterator
import okhttp3.internal.closeQuietly
import okio.Closeable
class CloseableIterator<T>(
private val upstream: Iterator<T>,
private val closeable: Closeable,
) : Iterator<T>, Closeable {
private var isClosed = false
override fun hasNext(): Boolean {
val result = upstream.hasNext()
if (!result) {
close()
}
return result
}
override fun next(): T {
try {
return upstream.next()
} catch (e: NoSuchElementException) {
close()
throw e
}
}
override fun close() {
if (!isClosed) {
closeable.closeQuietly()
isClosed = true
}
}
}

@ -1,8 +1,8 @@
package org.koitharu.kotatsu.core.util.progress package org.koitharu.kotatsu.core.util.progress
import coil.request.ErrorResult import coil3.request.ErrorResult
import coil.request.ImageRequest import coil3.request.ImageRequest
import coil.request.SuccessResult import coil3.request.SuccessResult
import com.google.android.material.progressindicator.BaseProgressIndicator import com.google.android.material.progressindicator.BaseProgressIndicator
class ImageRequestIndicatorListener( class ImageRequestIndicatorListener(

@ -26,8 +26,10 @@ class ProgressResponseBody(
override fun contentType(): MediaType? = delegate.contentType() override fun contentType(): MediaType? = delegate.contentType()
override fun source(): BufferedSource { override fun source(): BufferedSource {
return bufferedSource ?: ProgressSource(delegate.source(), contentLength(), progressState).buffer().also { return bufferedSource ?: synchronized(this) {
bufferedSource = it bufferedSource ?: ProgressSource(delegate.source(), contentLength(), progressState).buffer().also {
bufferedSource = it
}
} }
} }

@ -2,10 +2,13 @@ package org.koitharu.kotatsu.core.zip
import androidx.annotation.WorkerThread import androidx.annotation.WorkerThread
import androidx.collection.ArraySet import androidx.collection.ArraySet
import okhttp3.internal.closeQuietly
import okio.Closeable import okio.Closeable
import org.koitharu.kotatsu.core.util.ext.children import org.jetbrains.annotations.Blocking
import org.koitharu.kotatsu.core.util.ext.withChildren
import java.io.File import java.io.File
import java.io.FileInputStream import java.io.FileInputStream
import java.io.FileOutputStream
import java.util.zip.Deflater import java.util.zip.Deflater
import java.util.zip.ZipEntry import java.util.zip.ZipEntry
import java.util.zip.ZipFile import java.util.zip.ZipFile
@ -13,26 +16,23 @@ import java.util.zip.ZipOutputStream
class ZipOutput( class ZipOutput(
val file: File, val file: File,
compressionLevel: Int = Deflater.DEFAULT_COMPRESSION, private val compressionLevel: Int = Deflater.DEFAULT_COMPRESSION,
) : Closeable { ) : Closeable {
private val entryNames = ArraySet<String>() private val entryNames = ArraySet<String>()
private var isClosed = false private var cachedOutput: ZipOutputStream? = null
private val output = ZipOutputStream(file.outputStream()).apply {
setLevel(compressionLevel)
}
@WorkerThread @Blocking
fun put(name: String, file: File): Boolean { fun put(name: String, file: File): Boolean = withOutput { output ->
return output.appendFile(file, name) output.appendFile(file, name)
} }
@WorkerThread @Blocking
fun put(name: String, content: String): Boolean { fun put(name: String, content: String): Boolean = withOutput { output ->
return output.appendText(content, name) output.appendText(content, name)
} }
@WorkerThread @Blocking
fun addDirectory(name: String): Boolean { fun addDirectory(name: String): Boolean {
val entry = if (name.endsWith("/")) { val entry = if (name.endsWith("/")) {
ZipEntry(name) ZipEntry(name)
@ -40,25 +40,29 @@ class ZipOutput(
ZipEntry("$name/") ZipEntry("$name/")
} }
return if (entryNames.add(entry.name)) { return if (entryNames.add(entry.name)) {
output.putNextEntry(entry) withOutput { output ->
output.closeEntry() output.putNextEntry(entry)
output.closeEntry()
}
true true
} else { } else {
false false
} }
} }
@WorkerThread @Blocking
fun copyEntryFrom(other: ZipFile, entry: ZipEntry): Boolean { fun copyEntryFrom(other: ZipFile, entry: ZipEntry): Boolean {
return if (entryNames.add(entry.name)) { return if (entryNames.add(entry.name)) {
val zipEntry = ZipEntry(entry.name) val zipEntry = ZipEntry(entry.name)
output.putNextEntry(zipEntry) withOutput { output ->
try { output.putNextEntry(zipEntry)
other.getInputStream(entry).use { input -> try {
input.copyTo(output) other.getInputStream(entry).use { input ->
input.copyTo(output)
}
} finally {
output.closeEntry()
} }
} finally {
output.closeEntry()
} }
true true
} else { } else {
@ -66,16 +70,15 @@ class ZipOutput(
} }
} }
fun finish() { @Blocking
fun finish() = withOutput { output ->
output.finish() output.finish()
output.flush()
} }
@Synchronized
override fun close() { override fun close() {
if (!isClosed) { cachedOutput?.close()
output.close() cachedOutput = null
isClosed = true
}
} }
@WorkerThread @WorkerThread
@ -91,8 +94,10 @@ class ZipOutput(
} }
putNextEntry(entry) putNextEntry(entry)
closeEntry() closeEntry()
fileToZip.children().forEach { childFile -> fileToZip.withChildren { children ->
appendFile(childFile, "$name/${childFile.name}") children.forEach { childFile ->
appendFile(childFile, "$name/${childFile.name}")
}
} }
} else { } else {
FileInputStream(fileToZip).use { fis -> FileInputStream(fileToZip).use { fis ->
@ -101,8 +106,11 @@ class ZipOutput(
} }
val zipEntry = ZipEntry(name) val zipEntry = ZipEntry(name)
putNextEntry(zipEntry) putNextEntry(zipEntry)
fis.copyTo(this) try {
closeEntry() fis.copyTo(this)
} finally {
closeEntry()
}
} }
} }
return true return true
@ -115,8 +123,25 @@ class ZipOutput(
} }
val zipEntry = ZipEntry(name) val zipEntry = ZipEntry(name)
putNextEntry(zipEntry) putNextEntry(zipEntry)
content.byteInputStream().copyTo(this) try {
closeEntry() content.byteInputStream().copyTo(this)
} finally {
closeEntry()
}
return true return true
} }
@Synchronized
private fun <T> withOutput(block: (ZipOutputStream) -> T): T {
val output = cachedOutput ?: newOutput(append = false)
val res = block(output)
output.flush()
return res
}
private fun newOutput(append: Boolean) = ZipOutputStream(FileOutputStream(file, append)).also {
it.setLevel(compressionLevel)
cachedOutput?.closeQuietly()
cachedOutput = it
}
} }

@ -34,7 +34,7 @@ class MangaPrefetchService : CoroutineIntentService() {
@Inject @Inject
lateinit var historyRepository: HistoryRepository lateinit var historyRepository: HistoryRepository
override suspend fun processIntent(startId: Int, intent: Intent) { override suspend fun IntentJobContext.processIntent(intent: Intent) {
when (intent.action) { when (intent.action) {
ACTION_PREFETCH_DETAILS -> prefetchDetails( ACTION_PREFETCH_DETAILS -> prefetchDetails(
manga = intent.getParcelableExtraCompat<ParcelableManga>(EXTRA_MANGA)?.manga manga = intent.getParcelableExtraCompat<ParcelableManga>(EXTRA_MANGA)?.manga
@ -50,7 +50,7 @@ class MangaPrefetchService : CoroutineIntentService() {
} }
} }
override fun onError(startId: Int, error: Throwable) = Unit override fun IntentJobContext.onError(error: Throwable) = Unit
private suspend fun prefetchDetails(manga: Manga) { private suspend fun prefetchDetails(manga: Manga) {
val source = mangaRepositoryFactory.create(manga.source) val source = mangaRepositoryFactory.create(manga.source)

@ -26,11 +26,20 @@ import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams import androidx.core.view.updateLayoutParams
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
import androidx.swiperefreshlayout.widget.CircularProgressDrawable import androidx.swiperefreshlayout.widget.CircularProgressDrawable
import coil.ImageLoader import coil3.ImageLoader
import coil.request.ImageRequest import coil3.request.ImageRequest
import coil.request.SuccessResult import coil3.request.SuccessResult
import coil.transform.RoundedCornersTransformation import coil3.request.allowRgb565
import coil.util.CoilUtils import coil3.request.crossfade
import coil3.request.error
import coil3.request.fallback
import coil3.request.lifecycle
import coil3.request.placeholder
import coil3.request.target
import coil3.request.transformations
import coil3.size.Scale
import coil3.transform.RoundedCornersTransformation
import coil3.util.CoilUtils
import com.google.android.material.chip.Chip import com.google.android.material.chip.Chip
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
@ -65,18 +74,19 @@ import org.koitharu.kotatsu.core.ui.widgets.ChipsView
import org.koitharu.kotatsu.core.util.FileSize import org.koitharu.kotatsu.core.util.FileSize
import org.koitharu.kotatsu.core.util.ext.crossfade import org.koitharu.kotatsu.core.util.ext.crossfade
import org.koitharu.kotatsu.core.util.ext.defaultPlaceholders import org.koitharu.kotatsu.core.util.ext.defaultPlaceholders
import org.koitharu.kotatsu.core.util.ext.drawable
import org.koitharu.kotatsu.core.util.ext.enqueueWith import org.koitharu.kotatsu.core.util.ext.enqueueWith
import org.koitharu.kotatsu.core.util.ext.getThemeColor import org.koitharu.kotatsu.core.util.ext.getThemeColor
import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty
import org.koitharu.kotatsu.core.util.ext.isTextTruncated import org.koitharu.kotatsu.core.util.ext.isTextTruncated
import org.koitharu.kotatsu.core.util.ext.joinToStringWithLimit import org.koitharu.kotatsu.core.util.ext.joinToStringWithLimit
import org.koitharu.kotatsu.core.util.ext.mangaSourceExtra
import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.parentView import org.koitharu.kotatsu.core.util.ext.parentView
import org.koitharu.kotatsu.core.util.ext.scaleUpActivityOptionsOf import org.koitharu.kotatsu.core.util.ext.scaleUpActivityOptionsOf
import org.koitharu.kotatsu.core.util.ext.setNavigationBarTransparentCompat import org.koitharu.kotatsu.core.util.ext.setNavigationBarTransparentCompat
import org.koitharu.kotatsu.core.util.ext.setOnContextClickListenerCompat import org.koitharu.kotatsu.core.util.ext.setOnContextClickListenerCompat
import org.koitharu.kotatsu.core.util.ext.source
import org.koitharu.kotatsu.core.util.ext.textAndVisible import org.koitharu.kotatsu.core.util.ext.textAndVisible
import org.koitharu.kotatsu.databinding.ActivityDetailsBinding import org.koitharu.kotatsu.databinding.ActivityDetailsBinding
import org.koitharu.kotatsu.details.data.MangaDetails import org.koitharu.kotatsu.details.data.MangaDetails
@ -88,6 +98,7 @@ import org.koitharu.kotatsu.details.ui.pager.ChaptersPagesSheet
import org.koitharu.kotatsu.details.ui.related.RelatedMangaActivity import org.koitharu.kotatsu.details.ui.related.RelatedMangaActivity
import org.koitharu.kotatsu.details.ui.scrobbling.ScrobblingItemDecoration import org.koitharu.kotatsu.details.ui.scrobbling.ScrobblingItemDecoration
import org.koitharu.kotatsu.details.ui.scrobbling.ScrollingInfoAdapter import org.koitharu.kotatsu.details.ui.scrobbling.ScrollingInfoAdapter
import org.koitharu.kotatsu.download.ui.dialog.DownloadDialogFragment
import org.koitharu.kotatsu.download.ui.worker.DownloadStartedObserver import org.koitharu.kotatsu.download.ui.worker.DownloadStartedObserver
import org.koitharu.kotatsu.favourites.ui.categories.select.FavoriteSheet import org.koitharu.kotatsu.favourites.ui.categories.select.FavoriteSheet
import org.koitharu.kotatsu.image.ui.ImageActivity import org.koitharu.kotatsu.image.ui.ImageActivity
@ -195,6 +206,7 @@ class DetailsActivity :
.filterNot { ChaptersPagesSheet.isShown(supportFragmentManager) } .filterNot { ChaptersPagesSheet.isShown(supportFragmentManager) }
.observeEvent(this, DownloadStartedObserver(viewBinding.scrollView)) .observeEvent(this, DownloadStartedObserver(viewBinding.scrollView))
DownloadDialogFragment.registerCallback(this, viewBinding.scrollView)
menuProvider = DetailsMenuProvider( menuProvider = DetailsMenuProvider(
activity = this, activity = this,
viewModel = viewModel, viewModel = viewModel,
@ -210,7 +222,10 @@ class DetailsActivity :
when (v.id) { when (v.id) {
R.id.button_read -> openReader(isIncognitoMode = false) R.id.button_read -> openReader(isIncognitoMode = false)
R.id.chip_branch -> showBranchPopupMenu(v) R.id.chip_branch -> showBranchPopupMenu(v)
R.id.button_download -> DownloadDialogHelper(v, viewModel).show(menuProvider) R.id.button_download -> {
val manga = viewModel.manga.value ?: return
DownloadDialogFragment.show(supportFragmentManager, listOf(manga))
}
R.id.chip_author -> { R.id.chip_author -> {
val manga = viewModel.manga.value ?: return val manga = viewModel.manga.value ?: return
@ -480,7 +495,7 @@ class DetailsActivity :
.placeholder(R.drawable.ic_web) .placeholder(R.drawable.ic_web)
.fallback(R.drawable.ic_web) .fallback(R.drawable.ic_web)
.error(R.drawable.ic_web) .error(R.drawable.ic_web)
.source(manga.source) .mangaSourceExtra(manga.source)
.transformations(RoundedCornersTransformation(resources.getDimension(R.dimen.chip_icon_corner))) .transformations(RoundedCornersTransformation(resources.getDimension(R.dimen.chip_icon_corner)))
.allowRgb565(true) .allowRgb565(true)
.enqueueWith(coil) .enqueueWith(coil)
@ -616,8 +631,9 @@ class DetailsActivity :
val request = ImageRequest.Builder(this) val request = ImageRequest.Builder(this)
.target(viewBinding.imageViewCover) .target(viewBinding.imageViewCover)
.size(CoverSizeResolver(viewBinding.imageViewCover)) .size(CoverSizeResolver(viewBinding.imageViewCover))
.scale(Scale.FILL)
.data(imageUrl) .data(imageUrl)
.tag(manga.source) .mangaSourceExtra(manga.source)
.crossfade(this) .crossfade(this)
.lifecycle(this) .lifecycle(this)
.placeholderMemoryCacheKey(manga.coverUrl) .placeholderMemoryCacheKey(manga.coverUrl)

@ -8,6 +8,7 @@ import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.ui.dialog.ErrorDetailsDialog import org.koitharu.kotatsu.core.ui.dialog.ErrorDetailsDialog
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
import org.koitharu.kotatsu.core.util.ext.isNetworkError import org.koitharu.kotatsu.core.util.ext.isNetworkError
import org.koitharu.kotatsu.core.util.ext.isSerializable
import org.koitharu.kotatsu.parsers.exception.NotFoundException import org.koitharu.kotatsu.parsers.exception.NotFoundException
import org.koitharu.kotatsu.parsers.exception.ParseException import org.koitharu.kotatsu.parsers.exception.ParseException
@ -38,7 +39,7 @@ class DetailsErrorObserver(
value is ParseException -> { value is ParseException -> {
val fm = fragmentManager val fm = fragmentManager
if (fm != null) { if (fm != null && value.isSerializable()) {
snackbar.setAction(R.string.details) { snackbar.setAction(R.string.details) {
ErrorDetailsDialog.show(fm, value, value.url) ErrorDetailsDialog.show(fm, value, value.url)
} }

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

Loading…
Cancel
Save