From 7efc47724ee192afdb54afe89039816af1da6b84 Mon Sep 17 00:00:00 2001 From: Koitharu Date: Sat, 4 Jan 2025 12:01:48 +0200 Subject: [PATCH] Improve mime-type handling --- app/build.gradle | 1 + .../kotatsu/core/image/BitmapDecoderCompat.kt | 28 +++++++---- .../koitharu/kotatsu/core/image/CbzFetcher.kt | 4 +- .../core/parser/MangaLoaderContextImpl.kt | 14 +++--- .../koitharu/kotatsu/core/util/MimeTypes.kt | 46 +++++++++++++++++++ .../koitharu/kotatsu/core/util/ext/File.kt | 13 ++---- .../kotatsu/core/util/ext/MimeType.kt | 33 +++++++++++++ .../ui/pager/pages/MangaPageFetcher.kt | 9 ++-- .../download/ui/worker/DownloadWorker.kt | 37 ++++++++++++--- .../koitharu/kotatsu/local/data/PagesCache.kt | 11 +++-- .../local/data/input/LocalMangaParser.kt | 18 +++----- .../local/data/output/LocalMangaDirOutput.kt | 14 ++++-- .../local/data/output/LocalMangaOutput.kt | 5 +- .../local/data/output/LocalMangaZipOutput.kt | 10 ++-- .../kotatsu/reader/domain/PageLoader.kt | 11 ++--- .../kotatsu/reader/ui/PageSaveContract.kt | 5 +- .../kotatsu/reader/ui/PageSaveHelper.kt | 26 ++++------- 17 files changed, 192 insertions(+), 93 deletions(-) create mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/core/util/MimeTypes.kt create mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/MimeType.kt diff --git a/app/build.gradle b/app/build.gradle index e80189f59..e5f9a43d8 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -74,6 +74,7 @@ android { '-opt-in=kotlinx.coroutines.FlowPreview', '-opt-in=kotlin.contracts.ExperimentalContracts', '-opt-in=coil3.annotation.ExperimentalCoilApi', + '-opt-in=coil3.annotation.InternalCoilApi', ] } lint { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/image/BitmapDecoderCompat.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/image/BitmapDecoderCompat.kt index 34584ec57..dabb69050 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/image/BitmapDecoderCompat.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/image/BitmapDecoderCompat.kt @@ -4,26 +4,26 @@ import android.graphics.Bitmap import android.graphics.BitmapFactory import android.graphics.ImageDecoder import android.os.Build -import android.webkit.MimeTypeMap import androidx.annotation.RequiresApi 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.MimeTypes +import org.koitharu.kotatsu.core.util.ext.MimeType import org.koitharu.kotatsu.core.util.ext.toByteBuffer +import org.koitharu.kotatsu.core.util.ext.toMimeTypeOrNull +import org.koitharu.kotatsu.parsers.util.runCatchingCancellable 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) { + fun decode(file: File): Bitmap = when (val format = probeMimeType(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)) @@ -33,7 +33,7 @@ object BitmapDecoderCompat { } @Blocking - fun decode(stream: InputStream, type: MediaType?, isMutable: Boolean = false): Bitmap { + fun decode(stream: InputStream, type: MimeType?, isMutable: Boolean = false): Bitmap { val format = type?.subtype if (format == FORMAT_AVIF) { return decodeAvif(stream.toByteBuffer()) @@ -51,12 +51,20 @@ object BitmapDecoderCompat { } } - 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() + @Blocking + fun probeMimeType(file: File): MimeType? { + return MimeTypes.probeMimeType(file) ?: detectBitmapType(file) } + @Blocking + private fun detectBitmapType(file: File): MimeType? = runCatchingCancellable { + val options = BitmapFactory.Options().apply { + inJustDecodeBounds = true + } + BitmapFactory.decodeFile(file.path, options)?.recycle() + return options.outMimeType?.toMimeTypeOrNull() + }.getOrNull() + private fun checkBitmapNotNull(bitmap: Bitmap?, format: String?): Bitmap = bitmap ?: throw ImageDecodeException(null, format) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/image/CbzFetcher.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/image/CbzFetcher.kt index 9f52ff8c0..03a6ac599 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/image/CbzFetcher.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/image/CbzFetcher.kt @@ -1,7 +1,6 @@ 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 @@ -12,6 +11,7 @@ import coil3.toAndroidUri import kotlinx.coroutines.runInterruptible import okio.Path.Companion.toPath import okio.openZip +import org.koitharu.kotatsu.core.util.MimeTypes import org.koitharu.kotatsu.core.util.ext.isZipUri import coil3.Uri as CoilUri @@ -25,7 +25,7 @@ class CbzFetcher( val entryName = requireNotNull(uri.fragment) SourceFetchResult( source = ImageSource(entryName.toPath(), options.fileSystem.openZip(filePath)), - mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(entryName.substringAfterLast('.', "")), + mimeType = MimeTypes.getMimeTypeFromExtension(entryName)?.toString(), dataSource = DataSource.DISK, ) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaLoaderContextImpl.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaLoaderContextImpl.kt index c68c79fec..9d07a1c47 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaLoaderContextImpl.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaLoaderContextImpl.kt @@ -23,6 +23,7 @@ import org.koitharu.kotatsu.core.util.ext.configureForParser import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.sanitizeHeaderValue import org.koitharu.kotatsu.core.util.ext.toList +import org.koitharu.kotatsu.core.util.ext.toMimeType import org.koitharu.kotatsu.core.util.ext.use import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.bitmap.Bitmap @@ -78,13 +79,14 @@ class MangaLoaderContextImpl @Inject constructor( override fun redrawImageResponse(response: Response, redraw: (image: Bitmap) -> Bitmap): Response { return response.map { body -> - BitmapDecoderCompat.decode(body.byteStream(), body.contentType(), isMutable = true).use { bitmap -> - (redraw(BitmapWrapper.create(bitmap)) as BitmapWrapper).use { result -> - Buffer().also { - result.compressTo(it.outputStream()) - }.asResponseBody("image/jpeg".toMediaType()) + BitmapDecoderCompat.decode(body.byteStream(), body.contentType()?.toMimeType(), isMutable = true) + .use { bitmap -> + (redraw(BitmapWrapper.create(bitmap)) as BitmapWrapper).use { result -> + Buffer().also { + result.compressTo(it.outputStream()) + }.asResponseBody("image/jpeg".toMediaType()) + } } - } } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/MimeTypes.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/MimeTypes.kt new file mode 100644 index 000000000..b1f9ae91f --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/MimeTypes.kt @@ -0,0 +1,46 @@ +package org.koitharu.kotatsu.core.util + +import android.os.Build +import android.webkit.MimeTypeMap +import org.jetbrains.annotations.Blocking +import org.koitharu.kotatsu.core.util.ext.MimeType +import org.koitharu.kotatsu.core.util.ext.toMimeTypeOrNull +import org.koitharu.kotatsu.parsers.util.nullIfEmpty +import org.koitharu.kotatsu.parsers.util.removeSuffix +import org.koitharu.kotatsu.parsers.util.runCatchingCancellable +import java.io.File +import java.nio.file.Files +import coil3.util.MimeTypeMap as CoilMimeTypeMap + +object MimeTypes { + + fun getMimeTypeFromExtension(fileName: String): MimeType? { + return CoilMimeTypeMap.getMimeTypeFromExtension(getNormalizedExtension(fileName) ?: return null) + ?.toMimeTypeOrNull() + } + + fun getMimeTypeFromUrl(url: String): MimeType? { + return CoilMimeTypeMap.getMimeTypeFromUrl(url)?.toMimeTypeOrNull() + } + + fun getExtension(mimeType: MimeType?): String? { + return MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType?.toString() ?: return null)?.nullIfEmpty() + } + + @Blocking + fun probeMimeType(file: File): MimeType? { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + runCatchingCancellable { + Files.probeContentType(file.toPath())?.toMimeTypeOrNull() + }.getOrNull()?.let { return it } + } + return getMimeTypeFromExtension(file.name) + } + + fun getNormalizedExtension(name: String): String? = name + .lowercase() + .removeSuffix('~') + .removeSuffix(".tmp") + .substringAfterLast('.', "") + .takeIf { it.length in 2..5 } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/File.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/File.kt index dd6e8e8d9..3a89f87f8 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/File.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/File.kt @@ -7,15 +7,13 @@ 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.MediaType -import okhttp3.MediaType.Companion.toMediaTypeOrNull import org.jetbrains.annotations.Blocking import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.fs.FileSequence +import org.koitharu.kotatsu.core.util.MimeTypes import java.io.BufferedReader import java.io.File import java.nio.file.attribute.BasicFileAttributes @@ -41,12 +39,6 @@ fun ZipFile.readText(entry: ZipEntry) = getInputStream(entry).use { output -> output.bufferedReader().use(BufferedReader::readText) } -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 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { @@ -115,3 +107,6 @@ fun File.walkCompat(includeDirectories: Boolean): Sequence = if (Build.VER val walk = walk() if (includeDirectories) walk else walk.filter { it.isFile } } + +val File.normalizedExtension: String? + get() = MimeTypes.getNormalizedExtension(name) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/MimeType.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/MimeType.kt new file mode 100644 index 000000000..be3a09f22 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/MimeType.kt @@ -0,0 +1,33 @@ +package org.koitharu.kotatsu.core.util.ext + +import okhttp3.MediaType + +private const val TYPE_IMAGE = "image" +private val REGEX_MIME = Regex("^\\w+/([-+.\\w]+|\\*)$", RegexOption.IGNORE_CASE) + +@JvmInline +value class MimeType(private val value: String) { + + val type: String? + get() = value.substringBefore('/', "").takeIfSpecified() + + val subtype: String? + get() = value.substringAfterLast('/', "").takeIfSpecified() + + private fun String.takeIfSpecified(): String? = takeUnless { + it.isEmpty() || it == "*" + } + + override fun toString(): String = value +} + +fun MediaType.toMimeType(): MimeType = MimeType("$type/$subtype") + +fun String.toMimeTypeOrNull(): MimeType? = if (REGEX_MIME.matches(this)) { + MimeType(lowercase()) +} else { + null +} + +val MimeType.isImage: Boolean + get() = type == TYPE_IMAGE diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/pages/MangaPageFetcher.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/pages/MangaPageFetcher.kt index e63304849..6599dd281 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/pages/MangaPageFetcher.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/pages/MangaPageFetcher.kt @@ -1,6 +1,5 @@ package org.koitharu.kotatsu.details.ui.pager.pages -import android.webkit.MimeTypeMap import androidx.core.net.toUri import coil3.ImageLoader import coil3.decode.DataSource @@ -21,8 +20,10 @@ import okio.Path.Companion.toOkioPath 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.MimeTypes import org.koitharu.kotatsu.core.util.ext.fetch import org.koitharu.kotatsu.core.util.ext.isNetworkUri +import org.koitharu.kotatsu.core.util.ext.toMimeTypeOrNull import org.koitharu.kotatsu.local.data.PagesCache import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.util.mimeType @@ -47,7 +48,7 @@ class MangaPageFetcher( pagesCache.get(pageUrl)?.let { file -> return SourceFetchResult( source = ImageSource(file.toOkioPath(), options.fileSystem), - mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(file.extension), + mimeType = MimeTypes.getMimeTypeFromExtension(file.name)?.toString(), dataSource = DataSource.DISK, ) } @@ -67,13 +68,13 @@ class MangaPageFetcher( if (!response.isSuccessful) { throw HttpException(response.toNetworkResponse()) } - val mimeType = response.mimeType + val mimeType = response.mimeType?.toMimeTypeOrNull() val file = response.requireBody().use { pagesCache.put(pageUrl, it.source(), mimeType) } SourceFetchResult( source = ImageSource(file.toOkioPath(), FileSystem.SYSTEM), - mimeType = mimeType, + mimeType = mimeType?.toString(), dataSource = DataSource.NETWORK, ) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadWorker.kt b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadWorker.kt index 1322a6b43..c21a07a75 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadWorker.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadWorker.kt @@ -5,7 +5,6 @@ import android.app.NotificationManager import android.content.Context import android.content.pm.ServiceInfo import android.os.Build -import android.webkit.MimeTypeMap import androidx.core.content.ContextCompat import androidx.hilt.work.HiltWorker import androidx.work.BackoffPolicy @@ -25,6 +24,7 @@ import dagger.assisted.Assisted import dagger.assisted.AssistedInject import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow @@ -32,6 +32,7 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.channelFlow import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch +import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.withContext @@ -42,6 +43,7 @@ import okio.buffer import okio.sink import okio.use import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.image.BitmapDecoderCompat import org.koitharu.kotatsu.core.model.ids import org.koitharu.kotatsu.core.model.isLocal import org.koitharu.kotatsu.core.network.MangaHttpClient @@ -49,7 +51,9 @@ import org.koitharu.kotatsu.core.network.imageproxy.ImageProxyInterceptor import org.koitharu.kotatsu.core.parser.MangaDataRepository import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.core.util.MimeTypes import org.koitharu.kotatsu.core.util.Throttler +import org.koitharu.kotatsu.core.util.ext.MimeType import org.koitharu.kotatsu.core.util.ext.awaitFinishedWorkInfosByTag import org.koitharu.kotatsu.core.util.ext.awaitUpdateWork import org.koitharu.kotatsu.core.util.ext.awaitWorkInfosByTag @@ -62,6 +66,7 @@ import org.koitharu.kotatsu.core.util.ext.getWorkInputData import org.koitharu.kotatsu.core.util.ext.getWorkSpec import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug +import org.koitharu.kotatsu.core.util.ext.toMimeType import org.koitharu.kotatsu.core.util.ext.withTicker import org.koitharu.kotatsu.core.util.ext.writeAllCancellable import org.koitharu.kotatsu.core.util.progress.RealtimeEtaEstimator @@ -132,7 +137,7 @@ class DownloadWorker @AssistedInject constructor( downloadMangaImpl(manga, task, downloadedIds) } Result.success(currentState.toWorkData()) - } catch (e: CancellationException) { + } catch (_: CancellationException) { withContext(NonCancellable) { val notification = notificationFactory.create(currentState.copy(isStopped = true)) notificationManager.notify(id.hashCode(), notification) @@ -201,7 +206,7 @@ class DownloadWorker @AssistedInject constructor( val coverUrl = mangaDetails.largeCoverUrl.ifNullOrEmpty { mangaDetails.coverUrl } if (coverUrl.isNotEmpty()) { downloadFile(coverUrl, destination, repo.source).let { file -> - output.addCover(file, MimeTypeMap.getFileExtensionFromUrl(coverUrl)) + output.addCover(file, getMediaType(coverUrl, file)) file.deleteAwait() } } @@ -230,7 +235,7 @@ class DownloadWorker @AssistedInject constructor( chapter = chapter, file = file, pageNumber = pageIndex, - ext = MimeTypeMap.getFileExtensionFromUrl(url), + type = getMediaType(url, file), ) if (file.extension == "tmp") { file.deleteAwait() @@ -354,6 +359,13 @@ class DownloadWorker @AssistedInject constructor( } } + private suspend fun getMediaType(url: String, file: File): MimeType? = runInterruptible(Dispatchers.IO) { + BitmapDecoderCompat.probeMimeType(file)?.let { + return@runInterruptible it + } + MimeTypes.getMimeTypeFromUrl(url) + } + private suspend fun downloadFile( url: String, destination: File, @@ -364,18 +376,29 @@ class DownloadWorker @AssistedInject constructor( return imageProxyInterceptor.interceptPageRequest(request, okHttp) .ensureSuccess() .use { response -> - val file = File(destination, UUID.randomUUID().toString() + ".tmp") + var file: File? = null try { response.requireBody().use { body -> + file = File( + destination, + buildString { + append(UUID.randomUUID().toString()) + MimeTypes.getExtension(body.contentType()?.toMimeType())?.let { ext -> + append('.') + append(ext) + } + append(".tmp") + }, + ) file.sink(append = false).buffer().use { it.writeAllCancellable(body.source()) } } } catch (e: CancellationException) { - file.delete() + file?.delete() throw e } - file + checkNotNull(file) } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/PagesCache.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/PagesCache.kt index 3b8a9dd11..f9c3a1593 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/PagesCache.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/PagesCache.kt @@ -15,6 +15,8 @@ import okio.sink import okio.use import org.koitharu.kotatsu.core.exceptions.NoDataReceivedException import org.koitharu.kotatsu.core.util.FileSize +import org.koitharu.kotatsu.core.util.MimeTypes +import org.koitharu.kotatsu.core.util.ext.MimeType import org.koitharu.kotatsu.core.util.ext.compressToPNG import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug @@ -59,7 +61,7 @@ class PagesCache @Inject constructor(@ApplicationContext context: Context) { } } - suspend fun put(url: String, source: Source, mimeType: String?): File = withContext(Dispatchers.IO) { + suspend fun put(url: String, source: Source, mimeType: MimeType?): File = withContext(Dispatchers.IO) { val file = createBufferFile(url, mimeType) try { val bytes = file.sink(append = false).buffer().use { @@ -78,7 +80,7 @@ class PagesCache @Inject constructor(@ApplicationContext context: Context) { } suspend fun put(url: String, bitmap: Bitmap): File = withContext(Dispatchers.IO) { - val file = createBufferFile(url, "image/png") + val file = createBufferFile(url, MimeType("image/png")) try { bitmap.compressToPNG(file) val cache = lruCache.get() @@ -107,9 +109,8 @@ class PagesCache @Inject constructor(@ApplicationContext context: Context) { 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" } + private suspend fun createBufferFile(url: String, mimeType: MimeType?): File { + val ext = MimeTypes.getExtension(mimeType) ?: 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 diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/input/LocalMangaParser.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/input/LocalMangaParser.kt index 9c28e7b16..15be26b63 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/input/LocalMangaParser.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/input/LocalMangaParser.kt @@ -1,7 +1,6 @@ package org.koitharu.kotatsu.local.data.input import android.net.Uri -import android.webkit.MimeTypeMap import androidx.core.net.toFile import androidx.core.net.toUri import kotlinx.coroutines.Dispatchers @@ -18,8 +17,10 @@ import okio.openZip import org.jetbrains.annotations.Blocking import org.koitharu.kotatsu.core.model.LocalMangaSource import org.koitharu.kotatsu.core.util.AlphanumComparator +import org.koitharu.kotatsu.core.util.MimeTypes import org.koitharu.kotatsu.core.util.ext.URI_SCHEME_ZIP import org.koitharu.kotatsu.core.util.ext.isFileUri +import org.koitharu.kotatsu.core.util.ext.isImage import org.koitharu.kotatsu.core.util.ext.isRegularFile import org.koitharu.kotatsu.core.util.ext.isZipUri import org.koitharu.kotatsu.core.util.ext.longHashCode @@ -87,7 +88,6 @@ class LocalMangaParser(private val uri: Uri) { } else { val title = rootFile.nameWithoutExtension.replace("_", " ").toCamelCase() val coverEntry = fileSystem.findFirstImage(rootPath) - val mimeTypeMap = MimeTypeMap.getSingleton() Manga( id = rootFile.absolutePath.longHashCode(), title = title, @@ -103,7 +103,7 @@ class LocalMangaParser(private val uri: Uri) { when { path == coverEntry -> null !fileSystem.isRegularFile(path) -> null - mimeTypeMap.isImage(path) -> path.parent + path.isImage() -> path.parent hasZipExtension(path.name) -> path else -> null } @@ -157,10 +157,7 @@ class LocalMangaParser(private val uri: Uri) { val pattern = index.getChapterNamesPattern(chapter) entries.filter { x -> x.name.substringBefore('.').matches(pattern) } } else { - val mimeTypeMap = MimeTypeMap.getSingleton() - entries.filter { x -> - mimeTypeMap.isImage(x) && x.parent == rootPath - } + entries.filter { x -> x.isImage() && x.parent == rootPath } }.toListSorted(compareBy(AlphanumComparator()) { x -> x.toString() }) .map { x -> val entryUri = chapterUri.child(x, resolve = true).toString() @@ -218,21 +215,18 @@ class LocalMangaParser(private val uri: Uri) { rootPath: Path, recursive: Boolean ): Path? = runCatchingCancellable { - val mimeTypeMap = MimeTypeMap.getSingleton() if (recursive) { listRecursively(rootPath) } else { list(rootPath).asSequence() - }.filter { isRegularFile(it) && mimeTypeMap.isImage(it) } + }.filter { isRegularFile(it) && it.isImage() } .toListSorted(compareBy(AlphanumComparator()) { x -> x.toString() }) .firstOrNull() }.onFailure { e -> e.printStackTraceDebug() }.getOrNull() - private fun MimeTypeMap.isImage(path: Path): Boolean = - getMimeTypeFromExtension(path.name.substringAfterLast('.')) - ?.startsWith("image/") == true + private fun Path.isImage(): Boolean = MimeTypes.getMimeTypeFromExtension(name)?.isImage == true private fun Uri.resolve(): Uri = if (isFileUri()) { val file = toFile() diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/output/LocalMangaDirOutput.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/output/LocalMangaDirOutput.kt index 4c1cbff62..da7543fef 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/output/LocalMangaDirOutput.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/output/LocalMangaDirOutput.kt @@ -8,6 +8,8 @@ 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.MimeTypes +import org.koitharu.kotatsu.core.util.ext.MimeType import org.koitharu.kotatsu.core.util.ext.deleteAwait import org.koitharu.kotatsu.core.util.ext.takeIfReadable import org.koitharu.kotatsu.core.zip.ZipOutput @@ -35,10 +37,10 @@ class LocalMangaDirOutput( override suspend fun mergeWithExisting() = Unit - override suspend fun addCover(file: File, ext: String) = mutex.withLock { + override suspend fun addCover(file: File, type: MimeType?) = mutex.withLock { val name = buildString { append("cover") - if (ext.isNotEmpty() && ext.length <= 4) { + MimeTypes.getExtension(type)?.let { ext -> append('.') append(ext) } @@ -50,14 +52,14 @@ class LocalMangaDirOutput( flushIndex() } - override suspend fun addPage(chapter: IndexedValue, file: File, pageNumber: Int, ext: String) = + override suspend fun addPage(chapter: IndexedValue, file: File, pageNumber: Int, type: MimeType?) = mutex.withLock { val output = chaptersOutput.getOrPut(chapter.value) { ZipOutput(File(rootFile, chapterFileName(chapter) + SUFFIX_TMP)) } val name = buildString { append(FILENAME_PATTERN.format(chapter.value.branch.hashCode(), chapter.index + 1, pageNumber)) - if (ext.isNotEmpty() && ext.length <= 4) { + MimeTypes.getExtension(type)?.let { ext -> append('.') append(ext) } @@ -96,7 +98,9 @@ class LocalMangaDirOutput( } suspend fun deleteChapters(ids: Set) = mutex.withLock { - val chapters = checkNotNull((index.getMangaInfo() ?: LocalMangaParser(rootFile).getManga(withDetails = true).manga).chapters) { + val chapters = checkNotNull( + (index.getMangaInfo() ?: LocalMangaParser(rootFile).getManga(withDetails = true).manga).chapters, + ) { "No chapters found" }.withIndex() val victimsIds = ids.toMutableSet() diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/output/LocalMangaOutput.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/output/LocalMangaOutput.kt index 6db94d18d..b3d1b47ee 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/output/LocalMangaOutput.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/output/LocalMangaOutput.kt @@ -6,6 +6,7 @@ import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import okio.Closeable import org.koitharu.kotatsu.core.prefs.DownloadFormat +import org.koitharu.kotatsu.core.util.ext.MimeType import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.local.data.input.LocalMangaParser import org.koitharu.kotatsu.parsers.model.Manga @@ -20,9 +21,9 @@ sealed class LocalMangaOutput( abstract suspend fun mergeWithExisting() - abstract suspend fun addCover(file: File, ext: String) + abstract suspend fun addCover(file: File, type: MimeType?) - abstract suspend fun addPage(chapter: IndexedValue, file: File, pageNumber: Int, ext: String) + abstract suspend fun addPage(chapter: IndexedValue, file: File, pageNumber: Int, type: MimeType?) abstract suspend fun flushChapter(chapter: MangaChapter): Boolean diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/output/LocalMangaZipOutput.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/output/LocalMangaZipOutput.kt index eded64595..56afe27fe 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/output/LocalMangaZipOutput.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/output/LocalMangaZipOutput.kt @@ -7,6 +7,8 @@ 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.MimeTypes +import org.koitharu.kotatsu.core.util.ext.MimeType import org.koitharu.kotatsu.core.util.ext.deleteAwait import org.koitharu.kotatsu.core.util.ext.readText import org.koitharu.kotatsu.core.zip.ZipOutput @@ -39,10 +41,10 @@ class LocalMangaZipOutput( } } - override suspend fun addCover(file: File, ext: String) = mutex.withLock { + override suspend fun addCover(file: File, type: MimeType?) = mutex.withLock { val name = buildString { append(FILENAME_PATTERN.format(0, 0, 0)) - if (ext.isNotEmpty() && ext.length <= 4) { + MimeTypes.getExtension(type)?.let { ext -> append('.') append(ext) } @@ -53,11 +55,11 @@ class LocalMangaZipOutput( index.setCoverEntry(name) } - override suspend fun addPage(chapter: IndexedValue, file: File, pageNumber: Int, ext: String) = + override suspend fun addPage(chapter: IndexedValue, file: File, pageNumber: Int, type: MimeType?) = mutex.withLock { val name = buildString { append(FILENAME_PATTERN.format(chapter.value.branch.hashCode(), chapter.index + 1, pageNumber)) - if (ext.isNotEmpty() && ext.length <= 4) { + MimeTypes.getExtension(type)?.let { ext -> append('.') append(ext) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/PageLoader.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/PageLoader.kt index 04b5759b4..449ce7b9f 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/PageLoader.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/PageLoader.kt @@ -36,6 +36,7 @@ import org.koitharu.kotatsu.core.parser.CachingMangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.util.FileSize +import org.koitharu.kotatsu.core.util.MimeTypes import org.koitharu.kotatsu.core.util.RetainedLifecycleCoroutineScope import org.koitharu.kotatsu.core.util.ext.URI_SCHEME_ZIP import org.koitharu.kotatsu.core.util.ext.cancelChildrenAndJoin @@ -47,9 +48,9 @@ import org.koitharu.kotatsu.core.util.ext.isFileUri import org.koitharu.kotatsu.core.util.ext.isNotEmpty import org.koitharu.kotatsu.core.util.ext.isPowerSaveMode import org.koitharu.kotatsu.core.util.ext.isZipUri -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.toMimeType import org.koitharu.kotatsu.core.util.ext.use import org.koitharu.kotatsu.core.util.ext.withProgress import org.koitharu.kotatsu.core.util.progress.ProgressDeferred @@ -57,7 +58,6 @@ import org.koitharu.kotatsu.download.ui.worker.DownloadSlowdownDispatcher import org.koitharu.kotatsu.local.data.PagesCache 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 @@ -66,7 +66,6 @@ import java.util.LinkedList import java.util.concurrent.atomic.AtomicInteger import java.util.zip.ZipFile import javax.inject.Inject -import kotlin.concurrent.Volatile import kotlin.coroutines.AbstractCoroutineContextElement import kotlin.coroutines.CoroutineContext @@ -146,7 +145,7 @@ class PageLoader @Inject constructor( val entry = zip.getEntry(uri.fragment) context.ensureRamAtLeast(entry.size * 2) zip.getInputStream(entry).use { - BitmapDecoderCompat.decode(it, entry.mimeType) + BitmapDecoderCompat.decode(it, MimeTypes.getMimeTypeFromExtension(entry.name)) } } } @@ -250,7 +249,7 @@ class PageLoader @Inject constructor( val request = createPageRequest(pageUrl, page.source) imageProxyInterceptor.interceptPageRequest(request, okHttp).ensureSuccess().use { response -> response.requireBody().withProgress(progress).use { - cache.put(pageUrl, it.source(), response.mimeType) + cache.put(pageUrl, it.source(), it.contentType()?.toMimeType()) } }.toUri() } @@ -264,7 +263,7 @@ class PageLoader @Inject constructor( private fun Deferred.isValid(): Boolean { return getCompletionResultOrNull()?.map { uri -> uri.exists() && uri.isTargetNotEmpty() - }?.getOrDefault(false) ?: true + }?.getOrDefault(false) != false } private class InternalErrorHandler : AbstractCoroutineContextElement(CoroutineExceptionHandler), diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/PageSaveContract.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/PageSaveContract.kt index 339c71038..1989d2703 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/PageSaveContract.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/PageSaveContract.kt @@ -5,9 +5,9 @@ import android.content.Intent import android.os.Build import android.os.Environment import android.provider.DocumentsContract -import android.webkit.MimeTypeMap import androidx.activity.result.contract.ActivityResultContracts import androidx.core.net.toUri +import org.koitharu.kotatsu.core.util.MimeTypes import org.koitharu.kotatsu.core.util.ext.toUriOrNull import java.io.File @@ -15,8 +15,7 @@ class PageSaveContract : ActivityResultContracts.CreateDocument("image/*") { override fun createIntent(context: Context, input: String): Intent { val intent = super.createIntent(context, input.substringAfterLast(File.separatorChar)) - intent.type = MimeTypeMap.getSingleton() - .getMimeTypeFromExtension(input.substringAfterLast('.')) ?: "image/*" + intent.type = MimeTypes.getMimeTypeFromExtension(input)?.toString() ?: "image/*" if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val defaultUri = input.toUriOrNull()?.run { path?.let { p -> diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/PageSaveHelper.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/PageSaveHelper.kt index d44c258ca..b2de2d613 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/PageSaveHelper.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/PageSaveHelper.kt @@ -1,9 +1,7 @@ package org.koitharu.kotatsu.reader.ui import android.content.Context -import android.graphics.BitmapFactory import android.net.Uri -import android.webkit.MimeTypeMap import androidx.activity.result.ActivityResultCallback import androidx.activity.result.ActivityResultCaller import androidx.activity.result.ActivityResultLauncher @@ -28,7 +26,9 @@ import okio.buffer import okio.openZip import okio.sink import okio.source +import org.koitharu.kotatsu.core.image.BitmapDecoderCompat import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.core.util.MimeTypes import org.koitharu.kotatsu.core.util.ext.isFileUri import org.koitharu.kotatsu.core.util.ext.isZipUri import org.koitharu.kotatsu.core.util.ext.toFileOrNull @@ -99,10 +99,10 @@ class PageSaveHelper @AssistedInject constructor( val pageUri = pageLoader.loadPage(task.page, force = false) val proposedName = task.getFileBaseName() val ext = getPageExtension(pageUrl, pageUri) - val mime = requireNotNull(MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext)) { + val mime = requireNotNull(MimeTypes.getMimeTypeFromExtension("_.$ext")) { "Unknown type of $proposedName" } - val destination = destinationDir.createFile(mime, proposedName.substringBeforeLast('.')) + val destination = destinationDir.createFile(mime.toString(), proposedName) copyImpl(pageUri, destination?.uri ?: throw IOException("Cannot create destination file")) result.add(destination.uri) } @@ -119,12 +119,7 @@ class PageSaveHelper @AssistedInject constructor( ) { "Invalid page url: $url" } var extension = name.substringAfterLast('.', "") if (extension.length !in 2..4) { - val mimeType = fileUri.toFileOrNull()?.let { file -> getImageMimeType(file) } - extension = if (mimeType != null) { - MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType) ?: EXTENSION_FALLBACK - } else { - EXTENSION_FALLBACK - } + extension = fileUri.toFileOrNull()?.let { file -> getImageExtension(file) } ?: EXTENSION_FALLBACK } return extension } @@ -155,8 +150,7 @@ class PageSaveHelper @AssistedInject constructor( if (proposedName == null) { return dir } else { - val ext = proposedName.substringAfterLast('.', "") - val mime = MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext) ?: return null + val mime = MimeTypes.getMimeTypeFromExtension(proposedName)?.toString() ?: return null return dir.createFile(mime, proposedName.substringBeforeLast('.')) } } @@ -179,12 +173,8 @@ class PageSaveHelper @AssistedInject constructor( } } - private suspend fun getImageMimeType(file: File): String? = runInterruptible(Dispatchers.IO) { - val options = BitmapFactory.Options().apply { - inJustDecodeBounds = true - } - BitmapFactory.decodeFile(file.path, options)?.recycle() - options.outMimeType + private suspend fun getImageExtension(file: File): String? = runInterruptible(Dispatchers.IO) { + MimeTypes.getExtension(BitmapDecoderCompat.probeMimeType(file)) } data class Task(