Compare commits

...

22 Commits

Author SHA1 Message Date
Koitharu
e2f8d8e022 Fix downloading slowdown 2024-11-04 16:04:18 +02:00
Koitharu
38b342b721 Update dependencies, fix covers restoring 2024-11-04 15:41:16 +02:00
Koitharu
b036a8ed94 Fixes batch 2024-11-04 10:02:08 +02:00
Koitharu
9bb76cc0b2 Update parsers (fix json iterator) 2024-10-26 09:03:50 +03:00
Koitharu
855b55da9d Update parsers 2024-10-23 19:47:27 +03:00
Koitharu
4855b2c160 Fix RegionBitmapDecode usage 2024-10-23 09:10:43 +03:00
Koitharu
89d395178c Support for AVIF images
(cherry picked from commit c15a0ece3e)
2024-10-23 08:38:52 +03:00
Koitharu
9942ad5e56 Fix pages loading issues
(cherry picked from commit 5bccc595a8)
2024-10-23 08:37:50 +03:00
Koitharu
d59b0626bc Fix webtoon page detection #1140
(cherry picked from commit 985b062218)
2024-10-23 08:37:00 +03:00
Marius Albrecht
63054e55d6 Give "Complete" status only to fully completed Manga
Up until now a progress of >= 99.5% would count a Manga as completed (and show the checkmark icon). This causes manga with 200 chapters or more to be marked as completed even if they have at least one unread chapter.

https://github.com/KotatsuApp/Kotatsu/issues/1105
(cherry picked from commit b6f57e5656)
2024-10-23 08:36:52 +03:00
Koitharu
486daf69bf Update link resolver
(cherry picked from commit c1d577bdf3)
2024-10-23 08:36:38 +03:00
Koitharu
af209d7048 Fix external plugin communication
(cherry picked from commit 2214c20742)
2024-10-23 08:36:26 +03:00
Koitharu
d739e30c84 Improve filter 2024-10-13 16:05:52 +03:00
Koitharu
32eb273fa9 Update parsers 2024-10-13 15:47:43 +03:00
Koitharu
8c5231bb3d Fix read chapters deletion 2024-10-13 14:09:03 +03:00
Koitharu
be4fb3e873 Fix saving cover 2024-10-13 14:09:03 +03:00
Koitharu
d28eff7a75 Fix zip closing 2024-10-13 14:09:03 +03:00
Koitharu
e515069b53 Fix zip closing
(cherry picked from commit 144e66bedb)
2024-10-11 17:16:57 +03:00
Koitharu
05d22167c4 Fix skipping download errors 2024-10-11 10:15:29 +03:00
Koitharu
e5c765dd2f Update parsers 2024-10-11 09:57:58 +03:00
Koitharu
9ea1122ca0 Fix CloudFlare protection detection (close #1129) 2024-10-07 15:24:02 +03:00
Koitharu
4faef85086 Fix sources suggestion 2024-10-07 14:40:29 +03:00
62 changed files with 755 additions and 363 deletions

View File

@@ -16,8 +16,8 @@ android {
applicationId 'org.koitharu.kotatsu'
minSdk = 21
targetSdk = 35
versionCode = 675
versionName = '7.6.2'
versionCode = 680
versionName = '7.6.7'
generatedDensities = []
testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner'
ksp {
@@ -82,7 +82,7 @@ afterEvaluate {
}
}
dependencies {
implementation('com.github.KotatsuApp:kotatsu-parsers:6f7e1fcfb2') {
implementation('com.github.KotatsuApp:kotatsu-parsers:f80b586081') {
exclude group: 'org.json', module: 'json'
}
@@ -92,8 +92,8 @@ dependencies {
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.4'
implementation 'androidx.activity:activity-ktx:1.9.3'
implementation 'androidx.fragment:fragment-ktx:1.8.5'
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'
@@ -136,7 +136,8 @@ dependencies {
implementation 'io.coil-kt:coil-base:2.7.0'
implementation 'io.coil-kt:coil-svg:2.7.0'
implementation 'com.github.KotatsuApp:subsampling-scale-image-view:e04098de68'
implementation 'org.aomedia.avif.android:avif:1.1.1.14d8e3c4'
implementation 'com.github.KotatsuApp:subsampling-scale-image-view:d1d10a6975'
implementation 'com.github.solkin:disk-lru-cache:1.4'
implementation 'io.noties.markwon:core:4.6.2'

View File

@@ -9,11 +9,12 @@ 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.ErrorReporterReceiver
import org.koitharu.kotatsu.core.util.ShareHelper
import kotlin.math.absoluteValue
import androidx.fragment.app.strictmode.Violation as FragmentViolation
@@ -42,7 +43,7 @@ class StrictModeNotifier(
override fun onViolation(violation: FragmentViolation) = showNotification(violation)
private fun showNotification(violation: Throwable) = Notification.Builder(context, CHANNEL_ID)
.setSmallIcon(android.R.drawable.stat_notify_error)
.setSmallIcon(R.drawable.ic_bug)
.setContentTitle(context.getString(R.string.strict_mode))
.setContentText(violation.message)
.setStyle(
@@ -51,7 +52,15 @@ class StrictModeNotifier(
.setSummaryText(violation.message)
.bigText(violation.stackTraceToString()),
).setShowWhen(true)
.setContentIntent(ErrorReporterReceiver.getPendingIntent(context, violation))
.setContentIntent(
PendingIntentCompat.getActivity(
context,
0,
ShareHelper(context).getShareTextIntent(violation.stackTraceToString()),
0,
false,
),
)
.setAutoCancel(true)
.setGroup(CHANNEL_ID)
.build()

View File

@@ -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

View File

@@ -28,7 +28,7 @@ class CaptchaNotifier(
return
}
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))
.setShowBadge(true)
.setVibrationEnabled(false)
@@ -41,8 +41,8 @@ class CaptchaNotifier(
.setData(exception.url.toUri())
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setContentTitle(channel.name)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setDefaults(NotificationCompat.DEFAULT_SOUND)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setDefaults(0)
.setSmallIcon(android.R.drawable.stat_notify_error)
.setGroup(GROUP_CAPTCHA)
.setAutoCancel(true)

View File

@@ -32,6 +32,7 @@ import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.util.ext.configureForParser
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.network.CloudFlareHelper
import javax.inject.Inject
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) {
cookieJar.removeCookies(url) { cookie ->
val name = cookie.name
name.startsWith("cf_") || name.startsWith("_cf") || name.startsWith("__cf") || name == "csrftoken"
CloudFlareHelper.isCloudFlareCookie(cookie.name)
}
}

View File

@@ -2,11 +2,10 @@ package org.koitharu.kotatsu.browser.cloudflare
import android.graphics.Bitmap
import android.webkit.WebView
import okhttp3.HttpUrl.Companion.toHttpUrl
import org.koitharu.kotatsu.browser.BrowserClient
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
class CloudFlareClient(
@@ -50,8 +49,5 @@ class CloudFlareClient(
}
}
private fun getClearance(): String? {
return cookieJar.loadForRequest(targetUrl.toHttpUrl())
.find { it.name == CF_CLEARANCE }?.value
}
private fun getClearance() = CloudFlareHelper.getClearanceCookie(cookieJar, targetUrl)
}

View File

@@ -27,6 +27,7 @@ import okhttp3.OkHttpClient
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.browser.cloudflare.CaptchaNotifier
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.image.AvifImageDecoder
import org.koitharu.kotatsu.core.network.MangaHttpClient
import org.koitharu.kotatsu.core.network.imageproxy.ImageProxyInterceptor
import org.koitharu.kotatsu.core.os.AppShortcutManager
@@ -80,9 +81,7 @@ interface AppModule {
@Singleton
fun provideMangaDatabase(
@ApplicationContext context: Context,
): MangaDatabase {
return MangaDatabase(context)
}
): MangaDatabase = MangaDatabase(context)
@Provides
@Singleton
@@ -119,6 +118,7 @@ interface AppModule {
ComponentRegistry.Builder()
.add(SvgDecoder.Factory())
.add(CbzFetcher.Factory())
.add(AvifImageDecoder.Factory())
.add(FaviconFetcher.Factory(context, okHttpClientLazy, mangaRepositoryFactory))
.add(MangaPageKeyer())
.add(pageFetcherFactory)

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,67 @@
package org.koitharu.kotatsu.core.image
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import androidx.core.graphics.drawable.toDrawable
import coil.ImageLoader
import coil.decode.DecodeResult
import coil.decode.Decoder
import coil.decode.ImageSource
import coil.fetch.SourceResult
import coil.request.Options
import com.davemorrissey.labs.subscaleview.decoder.ImageDecodeException
import kotlinx.coroutines.sync.Semaphore
import org.aomedia.avif.android.AvifDecoder
import org.aomedia.avif.android.AvifDecoder.Info
import org.koitharu.kotatsu.core.util.ext.toByteBuffer
class AvifImageDecoder(source: ImageSource, options: Options, parallelismLock: Semaphore) :
BaseCoilDecoder(source, options, parallelismLock) {
override fun BitmapFactory.Options.decode(): DecodeResult {
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")
}
return DecodeResult(
drawable = bitmap.toDrawable(options.context.resources),
isSampled = false,
)
}
class Factory : Decoder.Factory {
private val parallelismLock = Semaphore(DEFAULT_PARALLELISM)
override fun create(
result: SourceResult,
options: Options,
imageLoader: ImageLoader
): Decoder? = if (isApplicable(result)) {
AvifImageDecoder(result.source, options, parallelismLock)
} else {
null
}
override fun equals(other: Any?) = other is Factory
override fun hashCode() = javaClass.hashCode()
private fun isApplicable(result: SourceResult): Boolean {
return result.mimeType == "image/avif"
}
}
}

View File

@@ -0,0 +1,50 @@
package org.koitharu.kotatsu.core.image
import android.graphics.BitmapFactory
import coil.decode.DecodeResult
import coil.decode.Decoder
import coil.decode.ImageSource
import coil.request.Options
import coil.size.Dimension
import coil.size.Scale
import coil.size.Size
import coil.size.isOriginal
import coil.size.pxOrElse
import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import org.jetbrains.annotations.Blocking
abstract class BaseCoilDecoder(
protected val source: ImageSource,
protected val options: Options,
private val parallelismLock: Semaphore,
) : Decoder {
final override suspend fun decode(): DecodeResult = parallelismLock.withPermit {
runInterruptible { BitmapFactory.Options().decode() }
}
@Blocking
protected abstract fun BitmapFactory.Options.decode(): DecodeResult
protected companion object {
const val DEFAULT_PARALLELISM = 4
inline fun Size.widthPx(scale: Scale, original: () -> Int): Int {
return if (isOriginal) original() else width.toPx(scale)
}
inline fun Size.heightPx(scale: Scale, original: () -> Int): Int {
return if (isOriginal) original() else height.toPx(scale)
}
fun Dimension.toPx(scale: Scale) = pxOrElse {
when (scale) {
Scale.FILL -> Int.MIN_VALUE
Scale.FIT -> Int.MAX_VALUE
}
}
}
}

View File

@@ -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
}
}

View File

@@ -1,4 +1,4 @@
package org.koitharu.kotatsu.core.ui.image
package org.koitharu.kotatsu.core.image
import android.graphics.Bitmap
import android.graphics.BitmapFactory
@@ -13,27 +13,14 @@ import coil.decode.Decoder
import coil.decode.ImageSource
import coil.fetch.SourceResult
import coil.request.Options
import coil.size.Dimension
import coil.size.Scale
import coil.size.Size
import coil.size.isOriginal
import coil.size.pxOrElse
import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import kotlin.math.roundToInt
class RegionBitmapDecoder(
private val source: ImageSource,
private val options: Options,
private val parallelismLock: Semaphore,
) : Decoder {
source: ImageSource, options: Options, parallelismLock: Semaphore
) : BaseCoilDecoder(source, options, parallelismLock) {
override suspend fun decode() = parallelismLock.withPermit {
runInterruptible { BitmapFactory.Options().decode() }
}
private fun BitmapFactory.Options.decode(): DecodeResult {
override fun BitmapFactory.Options.decode(): DecodeResult {
val regionDecoder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
BitmapRegionDecoder.newInstance(source.source().inputStream())
} else {
@@ -55,29 +42,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]. */
private fun BitmapFactory.Options.configureScale(srcWidth: Int, srcHeight: Int): Rect {
val dstWidth = options.size.widthPx(options.scale) { srcWidth }
@@ -142,19 +106,38 @@ class RegionBitmapDecoder(
return rect
}
class Factory(
maxParallelism: Int = DEFAULT_MAX_PARALLELISM,
) : Decoder.Factory {
private fun BitmapFactory.Options.configureConfig() {
var config = options.config
@Suppress("NEWER_VERSION_IN_SINCE_KOTLIN")
@SinceKotlin("999.9") // Only public in Java.
constructor() : this()
inMutable = false
private val parallelismLock = Semaphore(maxParallelism)
override fun create(result: SourceResult, options: Options, imageLoader: ImageLoader): Decoder {
return RegionBitmapDecoder(result.source, options, parallelismLock)
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
}
object Factory : Decoder.Factory {
private val parallelismLock = Semaphore(DEFAULT_PARALLELISM)
override fun create(
result: SourceResult,
options: Options,
imageLoader: ImageLoader
): Decoder = RegionBitmapDecoder(result.source, options, parallelismLock)
override fun equals(other: Any?) = other is Factory
@@ -165,21 +148,5 @@ class RegionBitmapDecoder(
const val PARAM_SCROLL = "scroll"
const val SCROLL_UNDEFINED = -1
private const val DEFAULT_MAX_PARALLELISM = 4
private inline fun Size.widthPx(scale: Scale, original: () -> Int): Int {
return if (isOriginal) original() else width.toPx(scale)
}
private inline fun Size.heightPx(scale: Scale, original: () -> Int): Int {
return if (isOriginal) original() else height.toPx(scale)
}
private fun Dimension.toPx(scale: Scale) = pxOrElse {
when (scale) {
Scale.FILL -> Int.MIN_VALUE
Scale.FIT -> Int.MAX_VALUE
}
}
}
}

View File

@@ -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)
}
}

View File

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

View File

@@ -9,6 +9,7 @@ import okhttp3.Request
import okhttp3.Response
import okio.IOException
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.model.UnknownMangaSource
import org.koitharu.kotatsu.core.parser.MangaLoaderContextImpl
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.parser.ParserMangaRepository
@@ -29,13 +30,13 @@ class CommonHeadersInterceptor @Inject constructor(
override fun intercept(chain: Chain): Response {
val request = chain.request()
val source = request.tag(MangaSource::class.java)
val repository = if (source != null) {
mangaRepositoryFactoryLazy.get().create(source) as? ParserMangaRepository
} else {
if (BuildConfig.DEBUG) {
val repository = if (source == null || source == UnknownMangaSource) {
if (BuildConfig.DEBUG && source == null) {
Log.w("Http", "Request without source tag: ${request.url}")
}
null
} else {
mangaRepositoryFactoryLazy.get().create(source) as? ParserMangaRepository
}
val headersBuilder = request.headers.newBuilder()
repository?.getRequestHeaders()?.let {

View File

@@ -7,7 +7,7 @@ import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.model.UnknownMangaSource
import org.koitharu.kotatsu.core.model.isNsfw
import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.exception.NotFoundException
import org.koitharu.kotatsu.parsers.model.Manga
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.levenshteinDistance
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.parsers.util.toRelativeUrl
import javax.inject.Inject
@Reusable
class MangaLinkResolver @Inject constructor(
private val repositoryFactory: MangaRepository.Factory,
private val sourcesRepository: MangaSourcesRepository,
private val dataRepository: MangaDataRepository,
private val context: MangaLoaderContext,
) {
suspend fun resolve(uri: Uri): Manga {
return if (uri.scheme == "kotatsu" || uri.host == "kotatsu.app") {
resolveAppLink(uri)
} else {
resolveExternalLink(uri)
resolveExternalLink(uri.toString())
} ?: throw NotFoundException("Cannot resolve link", uri.toString())
}
@@ -45,18 +44,11 @@ class MangaLinkResolver @Inject constructor(
)
}
private suspend fun resolveExternalLink(uri: Uri): Manga? {
dataRepository.findMangaByPublicUrl(uri.toString())?.let {
private suspend fun resolveExternalLink(uri: String): Manga? {
dataRepository.findMangaByPublicUrl(uri)?.let {
return it
}
val host = uri.host ?: return null
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)
return context.newLinkResolver(uri).getManga()
}
private suspend fun MangaRepository.findExact(url: String?, title: String?): Manga? {
@@ -85,12 +77,10 @@ class MangaLinkResolver @Inject constructor(
}.getOrThrow()
}
private suspend fun MangaRepository.getDetailsNoCache(manga: Manga): Manga {
return if (this is ParserMangaRepository) {
getDetails(manga, CachePolicy.READ_ONLY)
} else {
getDetails(manga)
}
private suspend fun MangaRepository.getDetailsNoCache(manga: Manga): Manga = if (this is CachingMangaRepository) {
getDetails(manga, CachePolicy.READ_ONLY)
} else {
getDetails(manga)
}
private fun getSeedManga(source: MangaSource, url: String, title: String?) = Manga(

View File

@@ -141,7 +141,7 @@ class ExternalPluginContentSource(
@Blocking
@WorkerThread
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)
.build()
return contentResolver.query(uri, null, null, null, null)

View File

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

View File

@@ -20,6 +20,10 @@ inline fun <reified T : Parcelable> Bundle.getParcelableCompat(key: String): T?
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? {
return IntentCompat.getParcelableExtra(this, key, T::class.java)
}

View File

@@ -14,8 +14,8 @@ import coil.request.SuccessResult
import coil.util.CoilUtils
import com.google.android.material.progressindicator.BaseProgressIndicator
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.image.RegionBitmapDecoder
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.parsers.model.MangaSource
import com.google.android.material.R as materialR
@@ -63,7 +63,7 @@ fun ImageRequest.Builder.indicator(indicators: List<BaseProgressIndicator<*>>):
fun ImageRequest.Builder.decodeRegion(
scroll: Int = RegionBitmapDecoder.SCROLL_UNDEFINED,
): ImageRequest.Builder = decoderFactory(RegionBitmapDecoder.Factory())
): ImageRequest.Builder = decoderFactory(RegionBitmapDecoder.Factory)
.setParameter(RegionBitmapDecoder.PARAM_SCROLL, scroll)
@Suppress("SpellCheckingInspection")

View File

@@ -7,15 +7,17 @@ import android.os.Build
import android.os.Environment
import android.os.storage.StorageManager
import android.provider.OpenableColumns
import android.webkit.MimeTypeMap
import androidx.core.database.getStringOrNull
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
import okhttp3.internal.closeQuietly
import okhttp3.MediaType
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import org.jetbrains.annotations.Blocking
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.fs.FileSequence
import java.io.BufferedReader
import java.io.File
import java.io.InputStream
import java.nio.file.attribute.BasicFileAttributes
import java.util.zip.ZipEntry
import java.util.zip.ZipFile
@@ -35,17 +37,15 @@ fun File.takeIfWriteable() = takeIf { it.exists() && it.canWrite() }
fun File.isNotEmpty() = length() != 0L
@Blocking
fun ZipFile.readText(entry: ZipEntry) = getInputStream(entry).bufferedReader().use {
it.readText()
fun ZipFile.readText(entry: ZipEntry) = getInputStream(entry).use { output ->
output.bufferedReader().use(BufferedReader::readText)
}
@Blocking
fun ZipFile.getInputStreamOrClose(entry: ZipEntry): InputStream = try {
getInputStream(entry)
} catch (e: Throwable) {
closeQuietly()
throw e
}
val ZipEntry.mimeType: MediaType?
get() {
val ext = name.substringAfterLast('.')
return MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext)?.toMediaTypeOrNull()
}
fun File.getStorageName(context: Context): String = runCatching {
val manager = context.getSystemService(Context.STORAGE_SERVICE) as StorageManager

View File

@@ -10,6 +10,9 @@ import okio.BufferedSink
import okio.Source
import org.koitharu.kotatsu.core.util.CancellableSource
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 {
return ProgressResponseBody(this, progressState)
@@ -23,3 +26,10 @@ suspend fun Source.cancellable(): Source {
suspend fun BufferedSink.writeAllCancellable(source: Source) = withContext(Dispatchers.IO) {
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
}

View File

@@ -37,13 +37,6 @@ val RecyclerView.visibleItemCount: Int
findLastVisibleItemPosition() - findFirstVisibleItemPosition()
} ?: 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? {
val rawItem = when (this) {
is AdapterDelegateViewBindingViewHolder<*, *> -> item

View File

@@ -24,6 +24,7 @@ import org.koitharu.kotatsu.core.exceptions.UnsupportedFileException
import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException
import org.koitharu.kotatsu.core.exceptions.WrongPasswordException
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_STATES_GENRES_NOT_SUPPORTED
import org.koitharu.kotatsu.parsers.ErrorMessages.FILTER_MULTIPLE_GENRES_NOT_SUPPORTED
@@ -35,6 +36,7 @@ import org.koitharu.kotatsu.parsers.exception.NotFoundException
import org.koitharu.kotatsu.parsers.exception.ParseException
import org.koitharu.kotatsu.parsers.exception.TooManyRequestExceptions
import org.koitharu.kotatsu.scrobbling.common.domain.ScrobblerAuthRequiredException
import java.io.ObjectOutputStream
import java.net.SocketTimeoutException
import java.net.UnknownHostException
@@ -181,3 +183,9 @@ fun Throwable.isWebViewUnavailable(): Boolean {
@Suppress("FunctionName")
fun NoSpaceLeftException() = IOException(MSG_NO_SPACE_LEFT)
fun Throwable.isSerializable() = runCatching {
val oos = ObjectOutputStream(NullOutputStream())
oos.writeObject(this)
oos.flush()
}.isSuccess

View File

@@ -2,6 +2,7 @@ package org.koitharu.kotatsu.core.util.ext
import android.net.Uri
import androidx.core.net.toFile
import okhttp3.internal.closeQuietly
import okio.Source
import okio.source
import okio.use
@@ -40,8 +41,13 @@ 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)
try {
val entry = zip.getEntry(fragment)
zip.getInputStream(entry).source().withExtraCloseable(zip)
} catch (e: Throwable) {
zip.closeQuietly()
throw e
}
}
else -> unsupportedUri(this)

View File

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

View File

@@ -103,8 +103,11 @@ class ZipOutput(
}
val zipEntry = ZipEntry(name)
putNextEntry(zipEntry)
fis.copyTo(this)
closeEntry()
try {
fis.copyTo(this)
} finally {
closeEntry()
}
}
}
return true
@@ -117,8 +120,11 @@ class ZipOutput(
}
val zipEntry = ZipEntry(name)
putNextEntry(zipEntry)
content.byteInputStream().copyTo(this)
closeEntry()
try {
content.byteInputStream().copyTo(this)
} finally {
closeEntry()
}
return true
}
}

View File

@@ -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.util.ext.getDisplayMessage
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.ParseException
@@ -38,7 +39,7 @@ class DetailsErrorObserver(
value is ParseException -> {
val fm = fragmentManager
if (fm != null) {
if (fm != null && value.isSerializable()) {
snackbar.setAction(R.string.details) {
ErrorDetailsDialog.show(fm, value, value.url)
}

View File

@@ -16,13 +16,13 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
import okhttp3.OkHttpClient
import okhttp3.internal.closeQuietly
import okio.Path.Companion.toOkioPath
import okio.buffer
import okio.source
import org.koitharu.kotatsu.core.network.MangaHttpClient
import org.koitharu.kotatsu.core.network.imageproxy.ImageProxyInterceptor
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.util.ext.getInputStreamOrClose
import org.koitharu.kotatsu.local.data.PagesCache
import org.koitharu.kotatsu.local.data.isFileUri
import org.koitharu.kotatsu.local.data.isZipUri
@@ -67,17 +67,22 @@ class MangaPageFetcher(
return when {
uri.isZipUri() -> runInterruptible(Dispatchers.IO) {
val zip = ZipFile(uri.schemeSpecificPart)
val entry = zip.getEntry(uri.fragment)
SourceResult(
source = ImageSource(
source = zip.getInputStreamOrClose(entry).source().withExtraCloseable(zip).buffer(),
context = context,
metadata = MangaPageMetadata(page),
),
mimeType = MimeTypeMap.getSingleton()
.getMimeTypeFromExtension(entry.name.substringAfterLast('.', "")),
dataSource = DataSource.DISK,
)
try {
val entry = zip.getEntry(uri.fragment)
SourceResult(
source = ImageSource(
source = zip.getInputStream(entry).source().withExtraCloseable(zip).buffer(),
context = context,
metadata = MangaPageMetadata(page),
),
mimeType = MimeTypeMap.getSingleton()
.getMimeTypeFromExtension(entry.name.substringAfterLast('.', "")),
dataSource = DataSource.DISK,
)
} catch (e: Throwable) {
zip.closeQuietly()
throw e
}
}
uri.isFileUri() -> runInterruptible(Dispatchers.IO) {
@@ -99,10 +104,9 @@ class MangaPageFetcher(
if (!response.isSuccessful) {
throw HttpException(response)
}
val body = response.requireBody()
val mimeType = response.mimeType
val file = body.use {
pagesCache.put(pageUrl, it.source())
val file = response.requireBody().use {
pagesCache.put(pageUrl, it.source(), mimeType)
}
SourceResult(
source = ImageSource(

View File

@@ -1,16 +1,20 @@
package org.koitharu.kotatsu.download.ui.worker
import android.os.SystemClock
import androidx.collection.MutableObjectLongMap
import kotlinx.coroutines.delay
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.parser.ParserMangaRepository
import org.koitharu.kotatsu.parsers.model.MangaSource
import javax.inject.Inject
import javax.inject.Singleton
class DownloadSlowdownDispatcher(
@Singleton
class DownloadSlowdownDispatcher @Inject constructor(
private val mangaRepositoryFactory: MangaRepository.Factory,
private val defaultDelay: Long,
) {
private val timeMap = MutableObjectLongMap<MangaSource>()
private val defaultDelay = 1_600L
suspend fun delay(source: MangaSource) {
val repo = mangaRepositoryFactory.create(source) as? ParserMangaRepository ?: return
@@ -19,11 +23,11 @@ class DownloadSlowdownDispatcher(
}
val lastRequest = synchronized(timeMap) {
val res = timeMap.getOrDefault(source, 0L)
timeMap[source] = System.currentTimeMillis()
timeMap[source] = SystemClock.elapsedRealtime()
res
}
if (lastRequest != 0L) {
delay(lastRequest + defaultDelay - System.currentTimeMillis())
delay(lastRequest + defaultDelay - SystemClock.elapsedRealtime())
}
}
}

View File

@@ -101,6 +101,7 @@ class DownloadWorker @AssistedInject constructor(
private val mangaRepositoryFactory: MangaRepository.Factory,
private val settings: AppSettings,
@LocalStorageChanges private val localStorageChanges: MutableSharedFlow<LocalManga?>,
private val slowdownDispatcher: DownloadSlowdownDispatcher,
private val imageProxyInterceptor: ImageProxyInterceptor,
notificationFactoryFactory: DownloadNotificationFactory.Factory,
) : CoroutineWorker(appContext, params) {
@@ -110,7 +111,6 @@ class DownloadWorker @AssistedInject constructor(
isSilent = params.inputData.getBoolean(IS_SILENT, false),
)
private val notificationManager = appContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
private val slowdownDispatcher = DownloadSlowdownDispatcher(mangaRepositoryFactory, SLOWDOWN_DELAY)
@Volatile
private var lastPublishedState: DownloadState? = null
@@ -311,6 +311,10 @@ class DownloadWorker @AssistedInject constructor(
DOWNLOAD_ERROR_DELAY
}
if (countDown <= 0 || retryDelay < 0 || retryDelay > MAX_RETRY_DELAY) {
val pausingHandle = PausingHandle.current()
if (pausingHandle.skipAllErrors()) {
return null
}
publishState(
currentState.copy(
isPaused = true,
@@ -321,7 +325,6 @@ class DownloadWorker @AssistedInject constructor(
),
)
countDown = MAX_FAILSAFE_ATTEMPTS
val pausingHandle = PausingHandle.current()
pausingHandle.pause()
try {
pausingHandle.awaitResumed()
@@ -566,7 +569,6 @@ class DownloadWorker @AssistedInject constructor(
const val MAX_PAGES_PARALLELISM = 4
const val DOWNLOAD_ERROR_DELAY = 2_000L
const val MAX_RETRY_DELAY = 7_200_000L // 2 hours
const val SLOWDOWN_DELAY = 200L
const val MANGA_ID = "manga_id"
const val CHAPTERS_IDS = "chapters"
const val IS_SILENT = "silent"

View File

@@ -53,7 +53,9 @@ class PausingHandle : AbstractCoroutineContextElement(PausingHandle) {
}
}
fun skipCurrentError(): Boolean = skipError.compareAndSet(expect = true, update = skipAllErrors)
fun skipAllErrors(): Boolean = skipAllErrors
fun skipCurrentError(): Boolean = skipError.compareAndSet(expect = true, update = false)
companion object : CoroutineContext.Key<PausingHandle> {

View File

@@ -332,6 +332,15 @@ class FilterCoordinator @Inject constructor(
}
}
fun toggleDemographic(value: Demographic, isSelected: Boolean) {
currentListFilter.update { oldValue ->
oldValue.copy(
demographics = if (isSelected) oldValue.demographics + value else oldValue.demographics - value,
query = oldValue.takeQueryIfSupported(),
)
}
}
fun toggleContentType(value: ContentType, isSelected: Boolean) {
currentListFilter.update { oldValue ->
oldValue.copy(

View File

@@ -8,6 +8,7 @@ import android.view.View
import android.widget.RelativeLayout
import android.widget.TextView
import androidx.annotation.AttrRes
import androidx.annotation.StringRes
import androidx.core.content.ContextCompat
import androidx.core.content.withStyledAttributes
import androidx.core.view.isInvisible
@@ -68,6 +69,10 @@ class FilterFieldLayout @JvmOverloads constructor(
}
}
fun setTitle(@StringRes titleResId: Int) {
binding.textViewTitle.setText(titleResId)
}
fun setError(errorMessage: String?) {
if (errorMessage == null && errorView == null) {
return

View File

@@ -16,7 +16,13 @@ import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.databinding.FragmentFilterHeaderBinding
import org.koitharu.kotatsu.filter.ui.model.FilterHeaderModel
import org.koitharu.kotatsu.filter.ui.tags.TagsCatalogSheet
import org.koitharu.kotatsu.parsers.model.ContentRating
import org.koitharu.kotatsu.parsers.model.ContentType
import org.koitharu.kotatsu.parsers.model.Demographic
import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.YEAR_UNKNOWN
import java.util.Locale
import javax.inject.Inject
@AndroidEntryPoint
@@ -55,6 +61,13 @@ class FilterHeaderFragment : BaseFragment<FragmentFilterHeaderBinding>(), ChipsV
override fun onChipCloseClick(chip: Chip, data: Any?) {
when (data) {
is String -> filter.setQuery(null)
is ContentRating -> filter.toggleContentRating(data, false)
is Demographic -> filter.toggleDemographic(data, false)
is ContentType -> filter.toggleContentType(data, false)
is MangaState -> filter.toggleState(data, false)
is Locale -> filter.setLocale(null)
is Int -> filter.setYear(YEAR_UNKNOWN)
is IntRange -> filter.setYearRange(YEAR_UNKNOWN, YEAR_UNKNOWN)
}
}

View File

@@ -3,12 +3,15 @@ package org.koitharu.kotatsu.filter.ui
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.titleResId
import org.koitharu.kotatsu.core.ui.widgets.ChipsView
import org.koitharu.kotatsu.filter.ui.model.FilterHeaderModel
import org.koitharu.kotatsu.filter.ui.model.FilterProperty
import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaListFilterCapabilities
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.util.toTitleCase
import org.koitharu.kotatsu.search.domain.MangaSearchRepository
import javax.inject.Inject
import com.google.android.material.R as materialR
@@ -18,15 +21,14 @@ class FilterHeaderProducer @Inject constructor(
) {
fun observeHeader(filterCoordinator: FilterCoordinator): Flow<FilterHeaderModel> {
return combine(filterCoordinator.tags, filterCoordinator.query) { tags, query ->
createChipsList(
return combine(filterCoordinator.tags, filterCoordinator.observe()) { tags, snapshot ->
val chipList = createChipsList(
source = filterCoordinator.mangaSource,
capabilities = filterCoordinator.capabilities,
property = tags,
query = query,
tagsProperty = tags,
snapshot = snapshot.listFilter,
limit = 8,
)
}.combine(filterCoordinator.observe()) { chipList, snapshot ->
FilterHeaderModel(
chips = chipList,
sortOrder = snapshot.sortOrder,
@@ -38,20 +40,20 @@ class FilterHeaderProducer @Inject constructor(
private suspend fun createChipsList(
source: MangaSource,
capabilities: MangaListFilterCapabilities,
property: FilterProperty<MangaTag>,
query: String?,
tagsProperty: FilterProperty<MangaTag>,
snapshot: MangaListFilter,
limit: Int,
): List<ChipsView.ChipModel> {
val result = ArrayDeque<ChipsView.ChipModel>(limit + 3)
if (query.isNullOrEmpty() || capabilities.isSearchWithFiltersSupported) {
val selectedTags = property.selectedItems.toMutableSet()
if (snapshot.query.isNullOrEmpty() || capabilities.isSearchWithFiltersSupported) {
val selectedTags = tagsProperty.selectedItems.toMutableSet()
var tags = if (selectedTags.isEmpty()) {
searchRepository.getTagsSuggestion("", limit, source)
} else {
searchRepository.getTagsSuggestion(selectedTags).take(limit)
}
if (tags.size < limit) {
tags = tags + property.availableItems.take(limit - tags.size)
tags = tags + tagsProperty.availableItems.take(limit - tags.size)
}
if (tags.isEmpty() && selectedTags.isEmpty()) {
return emptyList()
@@ -77,13 +79,59 @@ class FilterHeaderProducer @Inject constructor(
result.addFirst(model)
}
}
if (!query.isNullOrEmpty()) {
snapshot.locale?.let {
result.addFirst(
ChipsView.ChipModel(
title = query,
title = it.getDisplayName(it).toTitleCase(it),
icon = R.drawable.ic_language,
isCloseable = true,
data = it,
),
)
}
snapshot.types.forEach {
result.addFirst(
ChipsView.ChipModel(
titleResId = it.titleResId,
isCloseable = true,
data = it,
),
)
}
snapshot.demographics.forEach {
result.addFirst(
ChipsView.ChipModel(
titleResId = it.titleResId,
isCloseable = true,
data = it,
),
)
}
snapshot.contentRating.forEach {
result.addFirst(
ChipsView.ChipModel(
titleResId = it.titleResId,
isCloseable = true,
data = it,
),
)
}
snapshot.states.forEach {
result.addFirst(
ChipsView.ChipModel(
titleResId = it.titleResId,
isCloseable = true,
data = it,
),
)
}
if (!snapshot.query.isNullOrEmpty()) {
result.addFirst(
ChipsView.ChipModel(
title = snapshot.query,
icon = materialR.drawable.abc_ic_search_api_material,
isCloseable = true,
data = query,
data = snapshot.query,
),
)
}
@@ -97,6 +145,5 @@ class FilterHeaderProducer @Inject constructor(
private fun moreTagsChip() = ChipsView.ChipModel(
titleResId = R.string.more,
isDropdown = true,
// icon = materialR.drawable.abc_ic_menu_overflow_material,
)
}

View File

@@ -68,12 +68,20 @@ class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
filter.year.observe(viewLifecycleOwner, this::onYearChanged)
filter.yearRange.observe(viewLifecycleOwner, this::onYearRangeChanged)
binding.layoutGenres.setTitle(
if (filter.capabilities.isMultipleTagsSupported) {
R.string.genres
} else {
R.string.genre
},
)
binding.spinnerLocale.onItemSelectedListener = this
binding.spinnerOriginalLocale.onItemSelectedListener = this
binding.spinnerOrder.onItemSelectedListener = this
binding.chipsState.onChipClickListener = this
binding.chipsTypes.onChipClickListener = this
binding.chipsContentRating.onChipClickListener = this
binding.chipsDemographics.onChipClickListener = this
binding.chipsGenres.onChipClickListener = this
binding.chipsGenresExclude.onChipClickListener = this
binding.sliderYear.addOnChangeListener(this::onSliderValueChange)
@@ -143,6 +151,7 @@ class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
is ContentType -> filter.toggleContentType(data, !chip.isChecked)
is ContentRating -> filter.toggleContentRating(data, !chip.isChecked)
is Demographic -> filter.toggleDemographic(data, !chip.isChecked)
null -> TagsCatalogSheet.show(getChildFragmentManager(), chip.parentView?.id == R.id.chips_genresExclude)
}
}

View File

@@ -12,6 +12,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
@@ -36,7 +37,7 @@ class ImageViewModel @Inject constructor(
.memoryCachePolicy(CachePolicy.READ_ONLY)
.data(savedStateHandle.require<Uri>(BaseActivity.EXTRA_DATA))
.memoryCachePolicy(CachePolicy.DISABLED)
.source(savedStateHandle[ImageActivity.EXTRA_SOURCE])
.source(MangaSource(savedStateHandle[ImageActivity.EXTRA_SOURCE]))
.build()
val bitmap = coil.execute(request).getDrawableOrThrow().toBitmap()
runInterruptible(Dispatchers.IO) {

View File

@@ -38,7 +38,7 @@ data class ReadingProgress(
companion object {
const val PROGRESS_NONE = -1f
const val PROGRESS_COMPLETED = 0.995f
const val PROGRESS_COMPLETED = 1f
fun isValid(percent: Float) = percent in 0f..1f

View File

@@ -10,9 +10,9 @@ import coil.fetch.SourceResult
import coil.request.Options
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
import okhttp3.internal.closeQuietly
import okio.buffer
import okio.source
import org.koitharu.kotatsu.core.util.ext.getInputStreamOrClose
import org.koitharu.kotatsu.local.data.util.withExtraCloseable
import java.util.zip.ZipFile
@@ -23,18 +23,23 @@ class CbzFetcher(
override suspend fun fetch() = runInterruptible(Dispatchers.IO) {
val zip = ZipFile(uri.schemeSpecificPart)
val entry = zip.getEntry(uri.fragment)
val ext = MimeTypeMap.getFileExtensionFromUrl(entry.name)
val bufferedSource = zip.getInputStreamOrClose(entry).source().withExtraCloseable(zip).buffer()
SourceResult(
source = ImageSource(
source = bufferedSource,
context = options.context,
metadata = CbzMetadata(uri),
),
mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext),
dataSource = DataSource.DISK,
)
try {
val entry = zip.getEntry(uri.fragment)
val ext = MimeTypeMap.getFileExtensionFromUrl(entry.name)
val bufferedSource = zip.getInputStream(entry).source().withExtraCloseable(zip).buffer()
SourceResult(
source = ImageSource(
source = bufferedSource,
context = options.context,
metadata = CbzMetadata(uri),
),
mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext),
dataSource = DataSource.DISK,
)
} catch (e: Throwable) {
zip.closeQuietly()
throw e
}
}
class Factory : Fetcher.Factory<Uri> {

View File

@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.local.data
import android.content.Context
import android.graphics.Bitmap
import android.os.StatFs
import android.webkit.MimeTypeMap
import com.tomclaw.cache.DiskLruCache
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
@@ -15,7 +16,7 @@ import okio.use
import org.koitharu.kotatsu.core.exceptions.NoDataReceivedException
import org.koitharu.kotatsu.core.util.FileSize
import org.koitharu.kotatsu.core.util.ext.compressToPNG
import org.koitharu.kotatsu.core.util.ext.longHashCode
import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.subdir
import org.koitharu.kotatsu.core.util.ext.takeIfReadable
@@ -24,6 +25,7 @@ import org.koitharu.kotatsu.core.util.ext.writeAllCancellable
import org.koitharu.kotatsu.parsers.util.SuspendLazy
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import java.io.File
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
@@ -50,15 +52,15 @@ class PagesCache @Inject constructor(@ApplicationContext context: Context) {
}.getOrThrow()
}
suspend fun get(url: String): File? {
suspend fun get(url: String): File? = withContext(Dispatchers.IO) {
val cache = lruCache.get()
return runInterruptible(Dispatchers.IO) {
runInterruptible {
cache.get(url)?.takeIfReadable()
}
}
suspend fun put(url: String, source: Source): File = withContext(Dispatchers.IO) {
val file = File(cacheDir.get().parentFile, url.longHashCode().toString())
suspend fun put(url: String, source: Source, mimeType: String?): File = withContext(Dispatchers.IO) {
val file = createBufferFile(url, mimeType)
try {
val bytes = file.sink(append = false).buffer().use {
it.writeAllCancellable(source)
@@ -66,17 +68,23 @@ class PagesCache @Inject constructor(@ApplicationContext context: Context) {
if (bytes == 0L) {
throw NoDataReceivedException(url)
}
lruCache.get().put(url, file)
val cache = lruCache.get()
runInterruptible {
cache.put(url, file)
}
} finally {
file.delete()
}
}
suspend fun put(url: String, bitmap: Bitmap): File = withContext(Dispatchers.IO) {
val file = File(cacheDir.get().parentFile, url.longHashCode().toString())
val file = createBufferFile(url, "image/png")
try {
bitmap.compressToPNG(file)
lruCache.get().put(url, file)
val cache = lruCache.get()
runInterruptible {
cache.put(url, file)
}
} finally {
file.delete()
}
@@ -90,12 +98,24 @@ class PagesCache @Inject constructor(@ApplicationContext context: Context) {
}
private suspend fun getAvailableSize(): Long = runCatchingCancellable {
val statFs = StatFs(cacheDir.get().absolutePath)
statFs.availableBytes
val dir = cacheDir.get()
runInterruptible(Dispatchers.IO) {
val statFs = StatFs(dir.absolutePath)
statFs.availableBytes
}
}.onFailure {
it.printStackTraceDebug()
}.getOrDefault(SIZE_DEFAULT)
private suspend fun createBufferFile(url: String, mimeType: String?): File {
val ext = mimeType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) }
?: MimeTypeMap.getFileExtensionFromUrl(url).ifNullOrEmpty { "dat" }
val cacheDir = cacheDir.get()
val rootDir = checkNotNull(cacheDir.parentFile) { "Cannot get parent for ${cacheDir.absolutePath}" }
val name = UUID.randomUUID().toString() + "." + ext
return File(rootDir, name)
}
private companion object {
val SIZE_MIN

View File

@@ -6,6 +6,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import okhttp3.internal.closeQuietly
import org.koitharu.kotatsu.core.model.isLocal
import org.koitharu.kotatsu.core.util.ext.deleteAwait
import org.koitharu.kotatsu.core.util.ext.takeIfReadable
@@ -90,7 +91,7 @@ class LocalMangaDirOutput(
override fun close() {
for (output in chaptersOutput.values) {
output.close()
output.closeQuietly()
}
}
@@ -119,10 +120,21 @@ class LocalMangaDirOutput(
}
private suspend fun ZipOutput.flushAndFinish() = runInterruptible(Dispatchers.IO) {
finish()
close()
val resFile = File(file.absolutePath.removeSuffix(SUFFIX_TMP))
file.renameTo(resFile)
val e: Throwable? = try {
finish()
null
} catch (e: Throwable) {
e
} finally {
close()
}
if (e == null) {
val resFile = File(file.absolutePath.removeSuffix(SUFFIX_TMP))
file.renameTo(resFile)
} else {
file.delete()
throw e
}
}
private fun chapterFileName(chapter: IndexedValue<MangaChapter>): String {

View File

@@ -2,8 +2,6 @@ package org.koitharu.kotatsu.local.data.output
import androidx.core.net.toFile
import androidx.core.net.toUri
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
import org.koitharu.kotatsu.core.model.isLocal
import org.koitharu.kotatsu.parsers.model.Manga
@@ -16,26 +14,14 @@ class LocalMangaUtil(
}
suspend fun deleteChapters(ids: Set<Long>) {
newOutput().use { output ->
when (output) {
is LocalMangaZipOutput -> runInterruptible(Dispatchers.IO) {
LocalMangaZipOutput.filterChapters(output, ids)
}
is LocalMangaDirOutput -> {
output.deleteChapters(ids)
output.finish()
}
}
}
}
private suspend fun newOutput(): LocalMangaOutput = runInterruptible(Dispatchers.IO) {
val file = manga.url.toUri().toFile()
if (file.isDirectory) {
LocalMangaDirOutput(file, manga)
LocalMangaDirOutput(file, manga).use { output ->
output.deleteChapters(ids)
output.finish()
}
} else {
LocalMangaZipOutput(file, manga)
LocalMangaZipOutput.filterChapters(file, manga, ids)
}
}
}

View File

@@ -5,6 +5,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import okhttp3.internal.closeQuietly
import org.koitharu.kotatsu.core.model.isLocal
import org.koitharu.kotatsu.core.util.ext.deleteAwait
import org.koitharu.kotatsu.core.util.ext.readText
@@ -52,27 +53,29 @@ class LocalMangaZipOutput(
index.setCoverEntry(name)
}
override suspend fun addPage(chapter: IndexedValue<MangaChapter>, file: File, pageNumber: Int, ext: String) = mutex.withLock {
val name = buildString {
append(FILENAME_PATTERN.format(chapter.value.branch.hashCode(), chapter.index + 1, pageNumber))
if (ext.isNotEmpty() && ext.length <= 4) {
append('.')
append(ext)
override suspend fun addPage(chapter: IndexedValue<MangaChapter>, file: File, pageNumber: Int, ext: String) =
mutex.withLock {
val name = buildString {
append(FILENAME_PATTERN.format(chapter.value.branch.hashCode(), chapter.index + 1, pageNumber))
if (ext.isNotEmpty() && ext.length <= 4) {
append('.')
append(ext)
}
}
runInterruptible(Dispatchers.IO) {
output.put(name, file)
}
index.addChapter(chapter, null)
}
runInterruptible(Dispatchers.IO) {
output.put(name, file)
}
index.addChapter(chapter, null)
}
override suspend fun flushChapter(chapter: MangaChapter): Boolean = false
override suspend fun finish() = mutex.withLock {
runInterruptible(Dispatchers.IO) {
output.put(ENTRY_NAME_INDEX, index.toString())
output.finish()
output.close()
output.use { output ->
output.put(ENTRY_NAME_INDEX, index.toString())
output.finish()
}
}
rootFile.deleteAwait()
output.file.renameTo(rootFile)
@@ -115,42 +118,53 @@ class LocalMangaZipOutput(
private const val FILENAME_PATTERN = "%08d_%03d%03d"
@WorkerThread
fun filterChapters(subject: LocalMangaZipOutput, idsToRemove: Set<Long>) {
ZipFile(subject.rootFile).use { zip ->
val index = MangaIndex(zip.readText(zip.getEntry(ENTRY_NAME_INDEX)))
idsToRemove.forEach { id -> index.removeChapter(id) }
val patterns = requireNotNull(index.getMangaInfo()?.chapters).map {
index.getChapterNamesPattern(it)
}
val coverEntryName = index.getCoverEntry()
for (entry in zip.entries()) {
when {
entry.name == ENTRY_NAME_INDEX -> {
subject.output.put(ENTRY_NAME_INDEX, index.toString())
suspend fun filterChapters(file: File, manga: Manga, idsToRemove: Set<Long>) =
runInterruptible(Dispatchers.IO) {
val subject = LocalMangaZipOutput(file, manga)
try {
ZipFile(subject.rootFile).use { zip ->
val index = MangaIndex(zip.readText(zip.getEntry(ENTRY_NAME_INDEX)))
idsToRemove.forEach { id -> index.removeChapter(id) }
val patterns = requireNotNull(index.getMangaInfo()?.chapters).map {
index.getChapterNamesPattern(it)
}
val coverEntryName = index.getCoverEntry()
for (entry in zip.entries()) {
when {
entry.name == ENTRY_NAME_INDEX -> {
subject.output.put(ENTRY_NAME_INDEX, index.toString())
}
entry.isDirectory -> {
subject.output.addDirectory(entry.name)
}
entry.isDirectory -> {
subject.output.addDirectory(entry.name)
}
entry.name == coverEntryName -> {
subject.output.copyEntryFrom(zip, entry)
}
entry.name == coverEntryName -> {
subject.output.copyEntryFrom(zip, entry)
}
else -> {
val name = entry.name.substringBefore('.')
if (patterns.any { it.matches(name) }) {
subject.output.copyEntryFrom(zip, entry)
else -> {
val name = entry.name.substringBefore('.')
if (patterns.any { it.matches(name) }) {
subject.output.copyEntryFrom(zip, entry)
}
}
}
}
subject.output.finish()
subject.output.close()
subject.rootFile.delete()
subject.output.file.renameTo(subject.rootFile)
}
} catch (e: Throwable) {
subject.closeQuietly()
try {
subject.output.file.delete()
} catch (e2: Throwable) {
e.addSuppressed(e2)
}
throw e
}
subject.output.finish()
subject.output.close()
subject.rootFile.delete()
subject.output.file.renameTo(subject.rootFile)
}
}
}
}

View File

@@ -10,18 +10,22 @@ import kotlinx.coroutines.launch
import org.koitharu.kotatsu.core.model.findById
import org.koitharu.kotatsu.core.model.ids
import org.koitharu.kotatsu.core.model.isLocal
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.history.data.HistoryRepository
import org.koitharu.kotatsu.local.data.LocalMangaRepository
import org.koitharu.kotatsu.local.data.LocalStorageChanges
import org.koitharu.kotatsu.local.domain.model.LocalManga
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.util.recoverCatchingCancellable
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import javax.inject.Inject
class DeleteReadChaptersUseCase @Inject constructor(
private val localMangaRepository: LocalMangaRepository,
private val historyRepository: HistoryRepository,
private val mangaRepositoryFactory: MangaRepository.Factory,
@LocalStorageChanges private val localStorageChanges: MutableSharedFlow<LocalManga?>,
) {
@@ -68,8 +72,8 @@ class DeleteReadChaptersUseCase @Inject constructor(
private suspend fun getDeletionTask(manga: LocalManga): DeletionTask? {
val history = historyRepository.getOne(manga.manga) ?: return null
val chapters = manga.manga.chapters ?: localMangaRepository.getDetails(manga.manga).chapters
if (chapters.isNullOrEmpty()) {
val chapters = getAllChapters(manga)
if (chapters.isEmpty()) {
return null
}
val branch = (chapters.findById(history.chapterId) ?: return null).branch
@@ -89,6 +93,21 @@ class DeleteReadChaptersUseCase @Inject constructor(
localStorageChanges.emit(subject.copy(manga = updated))
}
private suspend fun getAllChapters(manga: LocalManga): List<MangaChapter> = runCatchingCancellable {
val remoteManga = checkNotNull(localMangaRepository.getRemoteManga(manga.manga))
checkNotNull(mangaRepositoryFactory.create(remoteManga.source).getDetails(remoteManga).chapters)
}.recoverCatchingCancellable {
checkNotNull(
manga.manga.chapters.let {
if (it.isNullOrEmpty()) {
localMangaRepository.getDetails(manga.manga).chapters
} else {
it
}
},
)
}.getOrDefault(manga.manga.chapters.orEmpty())
private class DeletionTask(
val manga: LocalManga,
val chaptersIds: Set<Long>,

View File

@@ -2,11 +2,8 @@ package org.koitharu.kotatsu.main.domain
import androidx.collection.ArraySet
import coil.intercept.Interceptor
import coil.network.HttpException
import coil.request.ErrorResult
import coil.request.ImageResult
import okio.FileNotFoundException
import org.jsoup.HttpStatusException
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository
import org.koitharu.kotatsu.core.model.findById
@@ -15,13 +12,10 @@ import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.parsers.exception.ParseException
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import java.net.UnknownHostException
import java.util.Collections
import javax.inject.Inject
import javax.net.ssl.SSLException
class CoverRestoreInterceptor @Inject constructor(
private val dataRepository: MangaDataRepository,
@@ -116,11 +110,6 @@ class CoverRestoreInterceptor @Inject constructor(
}
private fun Throwable.shouldRestore(): Boolean {
return this is HttpException
|| this is HttpStatusException
|| this is SSLException
|| this is ParseException
|| this is UnknownHostException
|| this is FileNotFoundException
return this is Exception // any Exception but not Error
}
}

View File

@@ -1,8 +1,6 @@
package org.koitharu.kotatsu.reader.domain
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Rect
import android.net.Uri
import androidx.annotation.AnyThread
@@ -29,6 +27,7 @@ import kotlinx.coroutines.sync.withPermit
import okhttp3.OkHttpClient
import okhttp3.Request
import okio.use
import org.koitharu.kotatsu.core.image.BitmapDecoderCompat
import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.core.network.MangaHttpClient
import org.koitharu.kotatsu.core.network.imageproxy.ImageProxyInterceptor
@@ -46,16 +45,19 @@ import org.koitharu.kotatsu.core.util.ext.exists
import org.koitharu.kotatsu.core.util.ext.getCompletionResultOrNull
import org.koitharu.kotatsu.core.util.ext.isPowerSaveMode
import org.koitharu.kotatsu.core.util.ext.isTargetNotEmpty
import org.koitharu.kotatsu.core.util.ext.mimeType
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.ramAvailable
import org.koitharu.kotatsu.core.util.ext.use
import org.koitharu.kotatsu.core.util.ext.withProgress
import org.koitharu.kotatsu.core.util.progress.ProgressDeferred
import org.koitharu.kotatsu.download.ui.worker.DownloadSlowdownDispatcher
import org.koitharu.kotatsu.local.data.PagesCache
import org.koitharu.kotatsu.local.data.isFileUri
import org.koitharu.kotatsu.local.data.isZipUri
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.mimeType
import org.koitharu.kotatsu.parsers.util.requireBody
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
@@ -76,6 +78,7 @@ class PageLoader @Inject constructor(
private val settings: AppSettings,
private val mangaRepositoryFactory: MangaRepository.Factory,
private val imageProxyInterceptor: ImageProxyInterceptor,
private val downloadSlowdownDispatcher: DownloadSlowdownDispatcher,
) {
val loaderScope = RetainedLifecycleCoroutineScope(lifecycle) + InternalErrorHandler() + Dispatchers.Default
@@ -124,7 +127,7 @@ class PageLoader @Inject constructor(
} else if (task?.isCancelled == false) {
return task
}
task = loadPageAsyncImpl(page, force)
task = loadPageAsyncImpl(page, skipCache = force, isPrefetch = false)
synchronized(tasks) {
tasks[page.id] = task
}
@@ -141,17 +144,17 @@ class PageLoader @Inject constructor(
ZipFile(uri.schemeSpecificPart).use { zip ->
val entry = zip.getEntry(uri.fragment)
context.ensureRamAtLeast(entry.size * 2)
zip.getInputStream(zip.getEntry(uri.fragment)).use {
checkBitmapNotNull(BitmapFactory.decodeStream(it))
zip.getInputStream(entry).use {
BitmapDecoderCompat.decode(it, entry.mimeType)
}
}
}
cache.put(uri.toString(), bitmap).toUri()
} else {
val file = uri.toFile()
context.ensureRamAtLeast(file.length() * 2)
runInterruptible(Dispatchers.IO) {
checkBitmapNotNull(BitmapFactory.decodeFile(file.absolutePath))
context.ensureRamAtLeast(file.length() * 2)
BitmapDecoderCompat.decode(file)
}.use { image ->
image.compressToPNG(file)
}
@@ -183,7 +186,7 @@ class PageLoader @Inject constructor(
val page = prefetchQueue.pollFirst() ?: return@launch
if (cache.get(page.url) == null) {
synchronized(tasks) {
tasks[page.id] = loadPageAsyncImpl(page, false)
tasks[page.id] = loadPageAsyncImpl(page, skipCache = false, isPrefetch = true)
}
return@launch
}
@@ -191,7 +194,11 @@ class PageLoader @Inject constructor(
}
}
private fun loadPageAsyncImpl(page: MangaPage, skipCache: Boolean): ProgressDeferred<Uri, Float> {
private fun loadPageAsyncImpl(
page: MangaPage,
skipCache: Boolean,
isPrefetch: Boolean,
): ProgressDeferred<Uri, Float> {
val progress = MutableStateFlow(PROGRESS_UNDEFINED)
val deferred = loaderScope.async {
if (!skipCache) {
@@ -199,7 +206,7 @@ class PageLoader @Inject constructor(
}
counter.incrementAndGet()
try {
loadPageImpl(page, progress)
loadPageImpl(page, progress, isPrefetch)
} finally {
if (counter.decrementAndGet() == 0) {
onIdle()
@@ -219,7 +226,11 @@ class PageLoader @Inject constructor(
}
}
private suspend fun loadPageImpl(page: MangaPage, progress: MutableStateFlow<Float>): Uri = semaphore.withPermit {
private suspend fun loadPageImpl(
page: MangaPage,
progress: MutableStateFlow<Float>,
isPrefetch: Boolean,
): Uri = semaphore.withPermit {
val pageUrl = getPageUrl(page)
check(pageUrl.isNotBlank()) { "Cannot obtain full image url for $page" }
val uri = Uri.parse(pageUrl)
@@ -232,10 +243,13 @@ class PageLoader @Inject constructor(
uri.isFileUri() -> uri
else -> {
if (isPrefetch) {
downloadSlowdownDispatcher.delay(page.source)
}
val request = createPageRequest(pageUrl, page.source)
imageProxyInterceptor.interceptPageRequest(request, okHttp).ensureSuccess().use { response ->
response.requireBody().withProgress(progress).use {
cache.put(pageUrl, it.source())
cache.put(pageUrl, it.source(), response.mimeType)
}
}.toUri()
}
@@ -246,8 +260,6 @@ class PageLoader @Inject constructor(
return context.ramAvailable <= FileSize.MEGABYTES.convert(PREFETCH_MIN_RAM_MB, FileSize.BYTES)
}
private fun checkBitmapNotNull(bitmap: Bitmap?): Bitmap = checkNotNull(bitmap) { "Cannot decode bitmap" }
private fun Deferred<Uri>.isValid(): Boolean {
return getCompletionResultOrNull()?.map { uri ->
uri.exists() && uri.isTargetNotEmpty()

View File

@@ -152,6 +152,7 @@ class PageHolderDelegate(
} catch (ce: CancellationException) {
throw ce
} catch (e2: Throwable) {
e2.printStackTrace()
e.addSuppressed(e2)
state = State.ERROR
callback.onError(e)

View File

@@ -17,6 +17,7 @@ import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.core.ui.widgets.ZoomControl
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
import org.koitharu.kotatsu.core.util.ext.isLowRamDevice
import org.koitharu.kotatsu.core.util.ext.isSerializable
import org.koitharu.kotatsu.databinding.ItemPageBinding
import org.koitharu.kotatsu.parsers.util.ifZero
import org.koitharu.kotatsu.reader.domain.PageLoader
@@ -154,6 +155,7 @@ open class PageHolder(
bindingInfo.buttonRetry.setText(
ExceptionResolver.getResolveStringId(e).ifZero { R.string.try_again },
)
bindingInfo.buttonErrorDetails.isVisible = e.isSerializable()
bindingInfo.layoutError.isVisible = true
bindingInfo.progressBar.hide()
}

View File

@@ -11,6 +11,7 @@ import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.core.util.GoneOnInvisibleListener
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
import org.koitharu.kotatsu.core.util.ext.isSerializable
import org.koitharu.kotatsu.databinding.ItemPageWebtoonBinding
import org.koitharu.kotatsu.parsers.util.ifZero
import org.koitharu.kotatsu.reader.domain.PageLoader
@@ -128,6 +129,7 @@ class WebtoonHolder(
bindingInfo.buttonRetry.setText(
ExceptionResolver.getResolveStringId(e).ifZero { R.string.try_again },
)
bindingInfo.buttonErrorDetails.isVisible = e.isSerializable()
bindingInfo.layoutError.isVisible = true
bindingInfo.progressBar.hide()
}

View File

@@ -4,6 +4,7 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.ViewGroup
import android.view.animation.DecelerateInterpolator
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.coroutineScope
@@ -13,7 +14,6 @@ import kotlinx.coroutines.yield
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.core.ui.list.lifecycle.RecyclerViewLifecycleDispatcher
import org.koitharu.kotatsu.core.util.ext.findCenterViewPosition
import org.koitharu.kotatsu.core.util.ext.firstVisibleItemPosition
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.removeItemDecoration
@@ -127,14 +127,13 @@ class WebtoonReaderFragment : BaseReaderFragment<FragmentReaderWebtoonBinding>()
}
override fun getCurrentState(): ReaderState? = viewBinding?.run {
val currentItem = recyclerView.findCenterViewPosition()
val currentItem = recyclerView.findCurrentPagePosition()
val adapter = recyclerView.adapter as? BaseReaderAdapter<*>
val page = adapter?.getItemOrNull(currentItem) ?: return@run null
ReaderState(
chapterId = page.chapterId,
page = page.index,
scroll = (recyclerView.findViewHolderForAdapterPosition(currentItem) as? WebtoonHolder)
?.getScrollY() ?: 0,
scroll = (recyclerView.findViewHolderForAdapterPosition(currentItem) as? WebtoonHolder)?.getScrollY() ?: 0,
)
}
@@ -168,4 +167,14 @@ class WebtoonReaderFragment : BaseReaderFragment<FragmentReaderWebtoonBinding>()
}
return true
}
private fun RecyclerView.findCurrentPagePosition(): Int {
val centerX = width / 2f
val centerY = height - resources.getDimension(R.dimen.webtoon_pages_gap)
if (centerY <= 0) {
return RecyclerView.NO_POSITION
}
val view = findChildViewUnder(centerX, centerY) ?: return RecyclerView.NO_POSITION
return getChildAdapterPosition(view)
}
}

View File

@@ -25,6 +25,7 @@ import org.koitharu.kotatsu.core.util.ext.sizeOrZero
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.search.domain.MangaSearchRepository
import org.koitharu.kotatsu.search.ui.suggestion.model.SearchSuggestionItem
import javax.inject.Inject
@@ -103,7 +104,7 @@ class SearchSuggestionViewModel @Inject constructor(
suggestionJob?.cancel()
suggestionJob = combine(
query.debounce(DEBOUNCE_TIMEOUT),
sourcesRepository.observeEnabledSources().map { it.toSet() },
sourcesRepository.observeEnabledSources().map { it.mapToSet { x -> x.name } },
settings.observeAsFlow(AppSettings.KEY_SEARCH_SUGGESTION_TYPES) { searchSuggestionTypes },
::Triple,
).mapLatest { (searchQuery, enabledSources, types) ->
@@ -116,7 +117,7 @@ class SearchSuggestionViewModel @Inject constructor(
private suspend fun buildSearchSuggestion(
searchQuery: String,
enabledSources: Set<MangaSource>,
enabledSources: Set<String>,
types: Set<SearchSuggestionType>,
): List<SearchSuggestionItem> = coroutineScope {
val queriesDeferred = if (SearchSuggestionType.QUERIES_RECENT in types) {
@@ -169,7 +170,7 @@ class SearchSuggestionViewModel @Inject constructor(
if (!mangaList.isNullOrEmpty()) {
add(SearchSuggestionItem.MangaList(mangaList))
}
sources?.mapTo(this) { SearchSuggestionItem.Source(it, it in enabledSources) }
sources?.mapTo(this) { SearchSuggestionItem.Source(it, it.name in enabledSources) }
queries?.mapTo(this) { SearchSuggestionItem.RecentQuery(it) }
authors?.mapTo(this) { SearchSuggestionItem.Author(it) }
hints?.mapTo(this) { SearchSuggestionItem.Hint(it) }

View File

@@ -14,6 +14,7 @@ import org.koitharu.kotatsu.core.backup.CompositeResult
import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.toUriOrNull
import org.koitharu.kotatsu.parsers.util.SuspendLazy
import java.io.File
@@ -71,7 +72,11 @@ class RestoreViewModel @Inject constructor(
override fun onCleared() {
super.onCleared()
backupInput.peek()?.cleanupAsync()
runCatching {
backupInput.peek()?.closeAndDelete()
}.onFailure {
it.printStackTraceDebug()
}
}
fun onItemClick(item: BackupEntryModel) {

View File

@@ -9,9 +9,15 @@
android:title="@string/_import"
app:showAsAction="never" />
<item
android:id="@+id/action_filter"
android:orderInCategory="30"
android:title="@string/filter"
app:showAsAction="never" />
<item
android:id="@+id/action_directories"
android:orderInCategory="96"
android:orderInCategory="80"
android:title="@string/directories"
app:showAsAction="never" />

View File

@@ -741,4 +741,5 @@
<string name="start_download">Start download</string>
<string name="save_manga_confirm">Save selected manga? This may consume traffic and disk space</string>
<string name="save_manga">Save manga</string>
<string name="genre">Genre</string>
</resources>