Improve mime-type handling

This commit is contained in:
Koitharu
2025-01-04 12:01:48 +02:00
parent c51218240e
commit 7efc47724e
17 changed files with 192 additions and 93 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<File> = if (Build.VER
val walk = walk()
if (includeDirectories) walk else walk.filter { it.isFile }
}
val File.normalizedExtension: String?
get() = MimeTypes.getNormalizedExtension(name)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<MangaChapter>, file: File, pageNumber: Int, ext: String) =
override suspend fun addPage(chapter: IndexedValue<MangaChapter>, 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<Long>) = 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()

View File

@@ -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<MangaChapter>, file: File, pageNumber: Int, ext: String)
abstract suspend fun addPage(chapter: IndexedValue<MangaChapter>, file: File, pageNumber: Int, type: MimeType?)
abstract suspend fun flushChapter(chapter: MangaChapter): Boolean

View File

@@ -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<MangaChapter>, file: File, pageNumber: Int, ext: String) =
override suspend fun addPage(chapter: IndexedValue<MangaChapter>, 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)
}

View File

@@ -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<Uri>.isValid(): Boolean {
return getCompletionResultOrNull()?.map { uri ->
uri.exists() && uri.isTargetNotEmpty()
}?.getOrDefault(false) ?: true
}?.getOrDefault(false) != false
}
private class InternalErrorHandler : AbstractCoroutineContextElement(CoroutineExceptionHandler),

View File

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

View File

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