mirror of
https://github.com/mihonapp/mihon.git
synced 2025-06-25 18:47:51 +02:00
Move Local Source to separate module (#9152)
* Move Local Source to separate module * Review changes
This commit is contained in:
@ -27,12 +27,21 @@ dependencies {
|
||||
api(libs.okhttp.dnsoverhttps)
|
||||
api(libs.okio)
|
||||
|
||||
implementation(libs.image.decoder)
|
||||
|
||||
implementation(libs.unifile)
|
||||
|
||||
api(kotlinx.coroutines.core)
|
||||
api(kotlinx.serialization.json)
|
||||
api(kotlinx.serialization.json.okio)
|
||||
|
||||
api(libs.preferencektx)
|
||||
|
||||
implementation(libs.jsoup)
|
||||
|
||||
// Sort
|
||||
implementation(libs.natural.comparator)
|
||||
|
||||
// JavaScript engine
|
||||
implementation(libs.bundles.js.engine)
|
||||
}
|
||||
|
44
core/src/main/java/eu/kanade/tachiyomi/util/lang/Hash.kt
Normal file
44
core/src/main/java/eu/kanade/tachiyomi/util/lang/Hash.kt
Normal file
@ -0,0 +1,44 @@
|
||||
package eu.kanade.tachiyomi.util.lang
|
||||
|
||||
import java.security.MessageDigest
|
||||
|
||||
object Hash {
|
||||
|
||||
private val chars = charArrayOf(
|
||||
'0', '1', '2', '3', '4', '5', '6', '7', '8', '9',
|
||||
'a', 'b', 'c', 'd', 'e', 'f',
|
||||
)
|
||||
|
||||
private val MD5 get() = MessageDigest.getInstance("MD5")
|
||||
|
||||
private val SHA256 get() = MessageDigest.getInstance("SHA-256")
|
||||
|
||||
fun sha256(bytes: ByteArray): String {
|
||||
return encodeHex(SHA256.digest(bytes))
|
||||
}
|
||||
|
||||
fun sha256(string: String): String {
|
||||
return sha256(string.toByteArray())
|
||||
}
|
||||
|
||||
fun md5(bytes: ByteArray): String {
|
||||
return encodeHex(MD5.digest(bytes))
|
||||
}
|
||||
|
||||
fun md5(string: String): String {
|
||||
return md5(string.toByteArray())
|
||||
}
|
||||
|
||||
private fun encodeHex(data: ByteArray): String {
|
||||
val l = data.size
|
||||
val out = CharArray(l shl 1)
|
||||
var i = 0
|
||||
var j = 0
|
||||
while (i < l) {
|
||||
out[j++] = chars[(240 and data[i].toInt()).ushr(4)]
|
||||
out[j++] = chars[15 and data[i].toInt()]
|
||||
i++
|
||||
}
|
||||
return String(out)
|
||||
}
|
||||
}
|
@ -0,0 +1,67 @@
|
||||
package eu.kanade.tachiyomi.util.lang
|
||||
|
||||
import androidx.core.text.parseAsHtml
|
||||
import net.greypanther.natsort.CaseInsensitiveSimpleNaturalComparator
|
||||
import java.nio.charset.StandardCharsets
|
||||
import kotlin.math.floor
|
||||
|
||||
/**
|
||||
* Replaces the given string to have at most [count] characters using [replacement] at its end.
|
||||
* If [replacement] is longer than [count] an exception will be thrown when `length > count`.
|
||||
*/
|
||||
fun String.chop(count: Int, replacement: String = "…"): String {
|
||||
return if (length > count) {
|
||||
take(count - replacement.length) + replacement
|
||||
} else {
|
||||
this
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the given string to have at most [count] characters using [replacement] near the center.
|
||||
* If [replacement] is longer than [count] an exception will be thrown when `length > count`.
|
||||
*/
|
||||
fun String.truncateCenter(count: Int, replacement: String = "..."): String {
|
||||
if (length <= count) {
|
||||
return this
|
||||
}
|
||||
|
||||
val pieceLength: Int = floor((count - replacement.length).div(2.0)).toInt()
|
||||
|
||||
return "${take(pieceLength)}$replacement${takeLast(pieceLength)}"
|
||||
}
|
||||
|
||||
/**
|
||||
* Case-insensitive natural comparator for strings.
|
||||
*/
|
||||
fun String.compareToCaseInsensitiveNaturalOrder(other: String): Int {
|
||||
val comparator = CaseInsensitiveSimpleNaturalComparator.getInstance<String>()
|
||||
return comparator.compare(this, other)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the size of the string as the number of bytes.
|
||||
*/
|
||||
fun String.byteSize(): Int {
|
||||
return toByteArray(StandardCharsets.UTF_8).size
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a string containing the first [n] bytes from this string, or the entire string if this
|
||||
* string is shorter.
|
||||
*/
|
||||
fun String.takeBytes(n: Int): String {
|
||||
val bytes = toByteArray(StandardCharsets.UTF_8)
|
||||
return if (bytes.size <= n) {
|
||||
this
|
||||
} else {
|
||||
bytes.decodeToString(endIndex = n).replace("\uFFFD", "")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* HTML-decode the string
|
||||
*/
|
||||
fun String.htmlDecode(): String {
|
||||
return this.parseAsHtml().toString()
|
||||
}
|
117
core/src/main/java/eu/kanade/tachiyomi/util/storage/DiskUtil.kt
Normal file
117
core/src/main/java/eu/kanade/tachiyomi/util/storage/DiskUtil.kt
Normal file
@ -0,0 +1,117 @@
|
||||
package eu.kanade.tachiyomi.util.storage
|
||||
|
||||
import android.content.Context
|
||||
import android.media.MediaScannerConnection
|
||||
import android.net.Uri
|
||||
import android.os.Environment
|
||||
import android.os.StatFs
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.hippo.unifile.UniFile
|
||||
import eu.kanade.tachiyomi.util.lang.Hash
|
||||
import java.io.File
|
||||
|
||||
object DiskUtil {
|
||||
|
||||
fun hashKeyForDisk(key: String): String {
|
||||
return Hash.md5(key)
|
||||
}
|
||||
|
||||
fun getDirectorySize(f: File): Long {
|
||||
var size: Long = 0
|
||||
if (f.isDirectory) {
|
||||
for (file in f.listFiles().orEmpty()) {
|
||||
size += getDirectorySize(file)
|
||||
}
|
||||
} else {
|
||||
size = f.length()
|
||||
}
|
||||
return size
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the available space for the disk that a file path points to, in bytes.
|
||||
*/
|
||||
fun getAvailableStorageSpace(f: UniFile): Long {
|
||||
return try {
|
||||
val stat = StatFs(f.uri.path)
|
||||
stat.availableBlocksLong * stat.blockSizeLong
|
||||
} catch (_: Exception) {
|
||||
-1L
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the root folders of all the available external storages.
|
||||
*/
|
||||
fun getExternalStorages(context: Context): List<File> {
|
||||
return ContextCompat.getExternalFilesDirs(context, null)
|
||||
.filterNotNull()
|
||||
.mapNotNull {
|
||||
val file = File(it.absolutePath.substringBefore("/Android/"))
|
||||
val state = Environment.getExternalStorageState(file)
|
||||
if (state == Environment.MEDIA_MOUNTED || state == Environment.MEDIA_MOUNTED_READ_ONLY) {
|
||||
file
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Don't display downloaded chapters in gallery apps creating `.nomedia`.
|
||||
*/
|
||||
fun createNoMediaFile(dir: UniFile?, context: Context?) {
|
||||
if (dir != null && dir.exists()) {
|
||||
val nomedia = dir.findFile(NOMEDIA_FILE)
|
||||
if (nomedia == null) {
|
||||
dir.createFile(NOMEDIA_FILE)
|
||||
context?.let { scanMedia(it, dir.uri) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scans the given file so that it can be shown in gallery apps, for example.
|
||||
*/
|
||||
fun scanMedia(context: Context, uri: Uri) {
|
||||
MediaScannerConnection.scanFile(context, arrayOf(uri.path), null, null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Mutate the given filename to make it valid for a FAT filesystem,
|
||||
* replacing any invalid characters with "_". This method doesn't allow hidden files (starting
|
||||
* with a dot), but you can manually add it later.
|
||||
*/
|
||||
fun buildValidFilename(origName: String): String {
|
||||
val name = origName.trim('.', ' ')
|
||||
if (name.isEmpty()) {
|
||||
return "(invalid)"
|
||||
}
|
||||
val sb = StringBuilder(name.length)
|
||||
name.forEach { c ->
|
||||
if (isValidFatFilenameChar(c)) {
|
||||
sb.append(c)
|
||||
} else {
|
||||
sb.append('_')
|
||||
}
|
||||
}
|
||||
// Even though vfat allows 255 UCS-2 chars, we might eventually write to
|
||||
// ext4 through a FUSE layer, so use that limit minus 15 reserved characters.
|
||||
return sb.toString().take(240)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the given character is a valid filename character, false otherwise.
|
||||
*/
|
||||
private fun isValidFatFilenameChar(c: Char): Boolean {
|
||||
if (0x00.toChar() <= c && c <= 0x1f.toChar()) {
|
||||
return false
|
||||
}
|
||||
return when (c) {
|
||||
'"', '*', '/', ':', '<', '>', '?', '\\', '|', 0x7f.toChar() -> false
|
||||
else -> true
|
||||
}
|
||||
}
|
||||
|
||||
const val NOMEDIA_FILE = ".nomedia"
|
||||
}
|
158
core/src/main/java/eu/kanade/tachiyomi/util/storage/EpubFile.kt
Normal file
158
core/src/main/java/eu/kanade/tachiyomi/util/storage/EpubFile.kt
Normal file
@ -0,0 +1,158 @@
|
||||
package eu.kanade.tachiyomi.util.storage
|
||||
|
||||
import org.jsoup.Jsoup
|
||||
import org.jsoup.nodes.Document
|
||||
import java.io.Closeable
|
||||
import java.io.File
|
||||
import java.io.InputStream
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipFile
|
||||
|
||||
/**
|
||||
* Wrapper over ZipFile to load files in epub format.
|
||||
*/
|
||||
class EpubFile(file: File) : Closeable {
|
||||
|
||||
/**
|
||||
* Zip file of this epub.
|
||||
*/
|
||||
private val zip = ZipFile(file)
|
||||
|
||||
/**
|
||||
* Path separator used by this epub.
|
||||
*/
|
||||
private val pathSeparator = getPathSeparator()
|
||||
|
||||
/**
|
||||
* Closes the underlying zip file.
|
||||
*/
|
||||
override fun close() {
|
||||
zip.close()
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an input stream for reading the contents of the specified zip file entry.
|
||||
*/
|
||||
fun getInputStream(entry: ZipEntry): InputStream {
|
||||
return zip.getInputStream(entry)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the zip file entry for the specified name, or null if not found.
|
||||
*/
|
||||
fun getEntry(name: String): ZipEntry? {
|
||||
return zip.getEntry(name)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the path of all the images found in the epub file.
|
||||
*/
|
||||
fun getImagesFromPages(): List<String> {
|
||||
val ref = getPackageHref()
|
||||
val doc = getPackageDocument(ref)
|
||||
val pages = getPagesFromDocument(doc)
|
||||
return getImagesFromPages(pages, ref)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the path to the package document.
|
||||
*/
|
||||
fun getPackageHref(): String {
|
||||
val meta = zip.getEntry(resolveZipPath("META-INF", "container.xml"))
|
||||
if (meta != null) {
|
||||
val metaDoc = zip.getInputStream(meta).use { Jsoup.parse(it, null, "") }
|
||||
val path = metaDoc.getElementsByTag("rootfile").first()?.attr("full-path")
|
||||
if (path != null) {
|
||||
return path
|
||||
}
|
||||
}
|
||||
return resolveZipPath("OEBPS", "content.opf")
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the package document where all the files are listed.
|
||||
*/
|
||||
fun getPackageDocument(ref: String): Document {
|
||||
val entry = zip.getEntry(ref)
|
||||
return zip.getInputStream(entry).use { Jsoup.parse(it, null, "") }
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all the pages from the epub.
|
||||
*/
|
||||
fun getPagesFromDocument(document: Document): List<String> {
|
||||
val pages = document.select("manifest > item")
|
||||
.filter { node -> "application/xhtml+xml" == node.attr("media-type") }
|
||||
.associateBy { it.attr("id") }
|
||||
|
||||
val spine = document.select("spine > itemref").map { it.attr("idref") }
|
||||
return spine.mapNotNull { pages[it] }.map { it.attr("href") }
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all the images contained in every page from the epub.
|
||||
*/
|
||||
private fun getImagesFromPages(pages: List<String>, packageHref: String): List<String> {
|
||||
val result = mutableListOf<String>()
|
||||
val basePath = getParentDirectory(packageHref)
|
||||
pages.forEach { page ->
|
||||
val entryPath = resolveZipPath(basePath, page)
|
||||
val entry = zip.getEntry(entryPath)
|
||||
val document = zip.getInputStream(entry).use { Jsoup.parse(it, null, "") }
|
||||
val imageBasePath = getParentDirectory(entryPath)
|
||||
|
||||
document.allElements.forEach {
|
||||
if (it.tagName() == "img") {
|
||||
result.add(resolveZipPath(imageBasePath, it.attr("src")))
|
||||
} else if (it.tagName() == "image") {
|
||||
result.add(resolveZipPath(imageBasePath, it.attr("xlink:href")))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the path separator used by the epub file.
|
||||
*/
|
||||
private fun getPathSeparator(): String {
|
||||
val meta = zip.getEntry("META-INF\\container.xml")
|
||||
return if (meta != null) {
|
||||
"\\"
|
||||
} else {
|
||||
"/"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a zip path from base and relative components and a path separator.
|
||||
*/
|
||||
private fun resolveZipPath(basePath: String, relativePath: String): String {
|
||||
if (relativePath.startsWith(pathSeparator)) {
|
||||
// Path is absolute, so return as-is.
|
||||
return relativePath
|
||||
}
|
||||
|
||||
var fixedBasePath = basePath.replace(pathSeparator, File.separator)
|
||||
if (!fixedBasePath.startsWith(File.separator)) {
|
||||
fixedBasePath = "${File.separator}$fixedBasePath"
|
||||
}
|
||||
|
||||
val fixedRelativePath = relativePath.replace(pathSeparator, File.separator)
|
||||
val resolvedPath = File(fixedBasePath, fixedRelativePath).canonicalPath
|
||||
return resolvedPath.replace(File.separator, pathSeparator).substring(1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the parent directory of a path.
|
||||
*/
|
||||
private fun getParentDirectory(path: String): String {
|
||||
val separatorIndex = path.lastIndexOf(pathSeparator)
|
||||
return if (separatorIndex >= 0) {
|
||||
path.substring(0, separatorIndex)
|
||||
} else {
|
||||
""
|
||||
}
|
||||
}
|
||||
}
|
593
core/src/main/java/tachiyomi/core/util/system/ImageUtil.kt
Normal file
593
core/src/main/java/tachiyomi/core/util/system/ImageUtil.kt
Normal file
@ -0,0 +1,593 @@
|
||||
package tachiyomi.core.util.system
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import android.content.res.Resources
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.BitmapRegionDecoder
|
||||
import android.graphics.Color
|
||||
import android.graphics.Rect
|
||||
import android.graphics.drawable.ColorDrawable
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.graphics.drawable.GradientDrawable
|
||||
import android.os.Build
|
||||
import android.webkit.MimeTypeMap
|
||||
import androidx.annotation.ColorInt
|
||||
import androidx.core.graphics.alpha
|
||||
import androidx.core.graphics.applyCanvas
|
||||
import androidx.core.graphics.blue
|
||||
import androidx.core.graphics.createBitmap
|
||||
import androidx.core.graphics.get
|
||||
import androidx.core.graphics.green
|
||||
import androidx.core.graphics.red
|
||||
import com.hippo.unifile.UniFile
|
||||
import logcat.LogPriority
|
||||
import tachiyomi.decoder.Format
|
||||
import tachiyomi.decoder.ImageDecoder
|
||||
import java.io.BufferedInputStream
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.InputStream
|
||||
import java.net.URLConnection
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
|
||||
object ImageUtil {
|
||||
|
||||
fun isImage(name: String, openStream: (() -> InputStream)? = null): Boolean {
|
||||
val contentType = try {
|
||||
URLConnection.guessContentTypeFromName(name)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
} ?: openStream?.let { findImageType(it)?.mime }
|
||||
return contentType?.startsWith("image/") ?: false
|
||||
}
|
||||
|
||||
fun findImageType(openStream: () -> InputStream): ImageType? {
|
||||
return openStream().use { findImageType(it) }
|
||||
}
|
||||
|
||||
fun findImageType(stream: InputStream): ImageType? {
|
||||
return try {
|
||||
when (getImageType(stream)?.format) {
|
||||
Format.Avif -> ImageType.AVIF
|
||||
Format.Gif -> ImageType.GIF
|
||||
Format.Heif -> ImageType.HEIF
|
||||
Format.Jpeg -> ImageType.JPEG
|
||||
Format.Jxl -> ImageType.JXL
|
||||
Format.Png -> ImageType.PNG
|
||||
Format.Webp -> ImageType.WEBP
|
||||
else -> null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun getExtensionFromMimeType(mime: String?): String {
|
||||
return MimeTypeMap.getSingleton().getExtensionFromMimeType(mime)
|
||||
?: SUPPLEMENTARY_MIMETYPE_MAPPING[mime]
|
||||
?: "jpg"
|
||||
}
|
||||
|
||||
fun isAnimatedAndSupported(stream: InputStream): Boolean {
|
||||
try {
|
||||
val type = getImageType(stream) ?: return false
|
||||
return when (type.format) {
|
||||
Format.Gif -> true
|
||||
// Coil supports animated WebP on Android 9.0+
|
||||
// https://coil-kt.github.io/coil/getting_started/#supported-image-formats
|
||||
Format.Webp -> type.isAnimated && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P
|
||||
else -> false
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
/* Do Nothing */
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun getImageType(stream: InputStream): tachiyomi.decoder.ImageType? {
|
||||
val bytes = ByteArray(32)
|
||||
|
||||
val length = if (stream.markSupported()) {
|
||||
stream.mark(bytes.size)
|
||||
stream.read(bytes, 0, bytes.size).also { stream.reset() }
|
||||
} else {
|
||||
stream.read(bytes, 0, bytes.size)
|
||||
}
|
||||
|
||||
if (length == -1) {
|
||||
return null
|
||||
}
|
||||
|
||||
return ImageDecoder.findType(bytes)
|
||||
}
|
||||
|
||||
enum class ImageType(val mime: String, val extension: String) {
|
||||
AVIF("image/avif", "avif"),
|
||||
GIF("image/gif", "gif"),
|
||||
HEIF("image/heif", "heif"),
|
||||
JPEG("image/jpeg", "jpg"),
|
||||
JXL("image/jxl", "jxl"),
|
||||
PNG("image/png", "png"),
|
||||
WEBP("image/webp", "webp"),
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether the image is wide (which we consider a double-page spread).
|
||||
*
|
||||
* @return true if the width is greater than the height
|
||||
*/
|
||||
fun isWideImage(imageStream: BufferedInputStream): Boolean {
|
||||
val options = extractImageOptions(imageStream)
|
||||
return options.outWidth > options.outHeight
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the 'side' part from imageStream and return it as InputStream.
|
||||
*/
|
||||
fun splitInHalf(imageStream: InputStream, side: Side): InputStream {
|
||||
val imageBytes = imageStream.readBytes()
|
||||
|
||||
val imageBitmap = BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size)
|
||||
val height = imageBitmap.height
|
||||
val width = imageBitmap.width
|
||||
|
||||
val singlePage = Rect(0, 0, width / 2, height)
|
||||
|
||||
val half = createBitmap(width / 2, height)
|
||||
val part = when (side) {
|
||||
Side.RIGHT -> Rect(width - width / 2, 0, width, height)
|
||||
Side.LEFT -> Rect(0, 0, width / 2, height)
|
||||
}
|
||||
half.applyCanvas {
|
||||
drawBitmap(imageBitmap, part, singlePage, null)
|
||||
}
|
||||
val output = ByteArrayOutputStream()
|
||||
half.compress(Bitmap.CompressFormat.JPEG, 100, output)
|
||||
|
||||
return ByteArrayInputStream(output.toByteArray())
|
||||
}
|
||||
|
||||
/**
|
||||
* Split the image into left and right parts, then merge them into a new image.
|
||||
*/
|
||||
fun splitAndMerge(imageStream: InputStream, upperSide: Side): InputStream {
|
||||
val imageBytes = imageStream.readBytes()
|
||||
|
||||
val imageBitmap = BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size)
|
||||
val height = imageBitmap.height
|
||||
val width = imageBitmap.width
|
||||
|
||||
val result = createBitmap(width / 2, height * 2)
|
||||
result.applyCanvas {
|
||||
// right -> upper
|
||||
val rightPart = when (upperSide) {
|
||||
Side.RIGHT -> Rect(width - width / 2, 0, width, height)
|
||||
Side.LEFT -> Rect(0, 0, width / 2, height)
|
||||
}
|
||||
val upperPart = Rect(0, 0, width / 2, height)
|
||||
drawBitmap(imageBitmap, rightPart, upperPart, null)
|
||||
// left -> bottom
|
||||
val leftPart = when (upperSide) {
|
||||
Side.LEFT -> Rect(width - width / 2, 0, width, height)
|
||||
Side.RIGHT -> Rect(0, 0, width / 2, height)
|
||||
}
|
||||
val bottomPart = Rect(0, height, width / 2, height * 2)
|
||||
drawBitmap(imageBitmap, leftPart, bottomPart, null)
|
||||
}
|
||||
|
||||
val output = ByteArrayOutputStream()
|
||||
result.compress(Bitmap.CompressFormat.JPEG, 100, output)
|
||||
return ByteArrayInputStream(output.toByteArray())
|
||||
}
|
||||
|
||||
enum class Side {
|
||||
RIGHT,
|
||||
LEFT,
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether the image is considered a tall image.
|
||||
*
|
||||
* @return true if the height:width ratio is greater than 3.
|
||||
*/
|
||||
private fun isTallImage(imageStream: InputStream): Boolean {
|
||||
val options = extractImageOptions(imageStream, resetAfterExtraction = false)
|
||||
return (options.outHeight / options.outWidth) > 3
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits tall images to improve performance of reader
|
||||
*/
|
||||
fun splitTallImage(tmpDir: UniFile, imageFile: UniFile, filenamePrefix: String): Boolean {
|
||||
if (isAnimatedAndSupported(imageFile.openInputStream()) || !isTallImage(imageFile.openInputStream())) {
|
||||
return true
|
||||
}
|
||||
|
||||
val bitmapRegionDecoder = getBitmapRegionDecoder(imageFile.openInputStream())
|
||||
if (bitmapRegionDecoder == null) {
|
||||
logcat { "Failed to create new instance of BitmapRegionDecoder" }
|
||||
return false
|
||||
}
|
||||
|
||||
val options = extractImageOptions(imageFile.openInputStream(), resetAfterExtraction = false).apply {
|
||||
inJustDecodeBounds = false
|
||||
}
|
||||
|
||||
val splitDataList = options.splitData
|
||||
|
||||
return try {
|
||||
splitDataList.forEach { splitData ->
|
||||
val splitImageName = splitImageName(filenamePrefix, splitData.index)
|
||||
// Remove pre-existing split if exists (this split shouldn't exist under normal circumstances)
|
||||
tmpDir.findFile(splitImageName)?.delete()
|
||||
|
||||
val splitFile = tmpDir.createFile(splitImageName)
|
||||
|
||||
val region = Rect(0, splitData.topOffset, splitData.splitWidth, splitData.bottomOffset)
|
||||
|
||||
splitFile.openOutputStream().use { outputStream ->
|
||||
val splitBitmap = bitmapRegionDecoder.decodeRegion(region, options)
|
||||
splitBitmap.compress(Bitmap.CompressFormat.JPEG, 100, outputStream)
|
||||
splitBitmap.recycle()
|
||||
}
|
||||
logcat {
|
||||
"Success: Split #${splitData.index + 1} with topOffset=${splitData.topOffset} " +
|
||||
"height=${splitData.splitHeight} bottomOffset=${splitData.bottomOffset}"
|
||||
}
|
||||
}
|
||||
imageFile.delete()
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
// Image splits were not successfully saved so delete them and keep the original image
|
||||
splitDataList
|
||||
.map { splitImageName(filenamePrefix, it.index) }
|
||||
.forEach { tmpDir.findFile(it)?.delete() }
|
||||
logcat(LogPriority.ERROR, e)
|
||||
false
|
||||
} finally {
|
||||
bitmapRegionDecoder.recycle()
|
||||
}
|
||||
}
|
||||
|
||||
private fun splitImageName(filenamePrefix: String, index: Int) = "${filenamePrefix}__${"%03d".format(index + 1)}.jpg"
|
||||
|
||||
/**
|
||||
* Check whether the image is a long Strip that needs splitting
|
||||
* @return true if the image is not animated and it's height is greater than image width and screen height
|
||||
*/
|
||||
fun isStripSplitNeeded(imageStream: BufferedInputStream): Boolean {
|
||||
if (isAnimatedAndSupported(imageStream)) return false
|
||||
|
||||
val options = extractImageOptions(imageStream)
|
||||
val imageHeightIsBiggerThanWidth = options.outHeight > options.outWidth
|
||||
val imageHeightBiggerThanScreenHeight = options.outHeight > optimalImageHeight
|
||||
return imageHeightIsBiggerThanWidth && imageHeightBiggerThanScreenHeight
|
||||
}
|
||||
|
||||
/**
|
||||
* Split the imageStream according to the provided splitData
|
||||
*/
|
||||
fun splitStrip(splitData: SplitData, streamFn: () -> InputStream): InputStream {
|
||||
val bitmapRegionDecoder = getBitmapRegionDecoder(streamFn())
|
||||
?: throw Exception("Failed to create new instance of BitmapRegionDecoder")
|
||||
|
||||
logcat {
|
||||
"WebtoonSplit #${splitData.index} with topOffset=${splitData.topOffset} " +
|
||||
"splitHeight=${splitData.splitHeight} bottomOffset=${splitData.bottomOffset}"
|
||||
}
|
||||
|
||||
val region = Rect(0, splitData.topOffset, splitData.splitWidth, splitData.bottomOffset)
|
||||
|
||||
try {
|
||||
val splitBitmap = bitmapRegionDecoder.decodeRegion(region, null)
|
||||
val outputStream = ByteArrayOutputStream()
|
||||
splitBitmap.compress(Bitmap.CompressFormat.JPEG, 100, outputStream)
|
||||
return ByteArrayInputStream(outputStream.toByteArray())
|
||||
} catch (e: Throwable) {
|
||||
throw e
|
||||
} finally {
|
||||
bitmapRegionDecoder.recycle()
|
||||
}
|
||||
}
|
||||
|
||||
fun getSplitDataForStream(imageStream: InputStream): List<SplitData> {
|
||||
return extractImageOptions(imageStream).splitData
|
||||
}
|
||||
|
||||
private val BitmapFactory.Options.splitData
|
||||
get(): List<SplitData> {
|
||||
val imageHeight = outHeight
|
||||
val imageWidth = outWidth
|
||||
|
||||
// -1 so it doesn't try to split when imageHeight = optimalImageHeight
|
||||
val partCount = (imageHeight - 1) / optimalImageHeight + 1
|
||||
val optimalSplitHeight = imageHeight / partCount
|
||||
|
||||
logcat {
|
||||
"Generating SplitData for image (height: $imageHeight): " +
|
||||
"$partCount parts @ ${optimalSplitHeight}px height per part"
|
||||
}
|
||||
|
||||
return mutableListOf<SplitData>().apply {
|
||||
val range = 0 until partCount
|
||||
for (index in range) {
|
||||
// Only continue if the list is empty or there is image remaining
|
||||
if (isNotEmpty() && imageHeight <= last().bottomOffset) break
|
||||
|
||||
val topOffset = index * optimalSplitHeight
|
||||
var splitHeight = min(optimalSplitHeight, imageHeight - topOffset)
|
||||
|
||||
if (index == range.last) {
|
||||
val remainingHeight = imageHeight - (topOffset + splitHeight)
|
||||
splitHeight += remainingHeight
|
||||
}
|
||||
|
||||
add(SplitData(index, topOffset, splitHeight, imageWidth))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class SplitData(
|
||||
val index: Int,
|
||||
val topOffset: Int,
|
||||
val splitHeight: Int,
|
||||
val splitWidth: Int,
|
||||
) {
|
||||
val bottomOffset = topOffset + splitHeight
|
||||
}
|
||||
|
||||
/**
|
||||
* Algorithm for determining what background to accompany a comic/manga page
|
||||
*/
|
||||
fun chooseBackground(context: Context, imageStream: InputStream): Drawable {
|
||||
val decoder = ImageDecoder.newInstance(imageStream)
|
||||
val image = decoder?.decode()
|
||||
decoder?.recycle()
|
||||
|
||||
val whiteColor = Color.WHITE
|
||||
if (image == null) return ColorDrawable(whiteColor)
|
||||
if (image.width < 50 || image.height < 50) {
|
||||
return ColorDrawable(whiteColor)
|
||||
}
|
||||
|
||||
val top = 5
|
||||
val bot = image.height - 5
|
||||
val left = (image.width * 0.0275).toInt()
|
||||
val right = image.width - left
|
||||
val midX = image.width / 2
|
||||
val midY = image.height / 2
|
||||
val offsetX = (image.width * 0.01).toInt()
|
||||
val leftOffsetX = left - offsetX
|
||||
val rightOffsetX = right + offsetX
|
||||
|
||||
val topLeftPixel = image[left, top]
|
||||
val topRightPixel = image[right, top]
|
||||
val midLeftPixel = image[left, midY]
|
||||
val midRightPixel = image[right, midY]
|
||||
val topCenterPixel = image[midX, top]
|
||||
val botLeftPixel = image[left, bot]
|
||||
val bottomCenterPixel = image[midX, bot]
|
||||
val botRightPixel = image[right, bot]
|
||||
|
||||
val topLeftIsDark = topLeftPixel.isDark()
|
||||
val topRightIsDark = topRightPixel.isDark()
|
||||
val midLeftIsDark = midLeftPixel.isDark()
|
||||
val midRightIsDark = midRightPixel.isDark()
|
||||
val topMidIsDark = topCenterPixel.isDark()
|
||||
val botLeftIsDark = botLeftPixel.isDark()
|
||||
val botRightIsDark = botRightPixel.isDark()
|
||||
|
||||
var darkBG = (topLeftIsDark && (botLeftIsDark || botRightIsDark || topRightIsDark || midLeftIsDark || topMidIsDark)) ||
|
||||
(topRightIsDark && (botRightIsDark || botLeftIsDark || midRightIsDark || topMidIsDark))
|
||||
|
||||
val topAndBotPixels = listOf(topLeftPixel, topCenterPixel, topRightPixel, botRightPixel, bottomCenterPixel, botLeftPixel)
|
||||
val isNotWhiteAndCloseTo = topAndBotPixels.mapIndexed { index, color ->
|
||||
val other = topAndBotPixels[(index + 1) % topAndBotPixels.size]
|
||||
!color.isWhite() && color.isCloseTo(other)
|
||||
}
|
||||
if (isNotWhiteAndCloseTo.all { it }) {
|
||||
return ColorDrawable(topLeftPixel)
|
||||
}
|
||||
|
||||
val cornerPixels = listOf(topLeftPixel, topRightPixel, botLeftPixel, botRightPixel)
|
||||
val numberOfWhiteCorners = cornerPixels.map { cornerPixel -> cornerPixel.isWhite() }
|
||||
.filter { it }
|
||||
.size
|
||||
if (numberOfWhiteCorners > 2) {
|
||||
darkBG = false
|
||||
}
|
||||
|
||||
var blackColor = when {
|
||||
topLeftIsDark -> topLeftPixel
|
||||
topRightIsDark -> topRightPixel
|
||||
botLeftIsDark -> botLeftPixel
|
||||
botRightIsDark -> botRightPixel
|
||||
else -> whiteColor
|
||||
}
|
||||
|
||||
var overallWhitePixels = 0
|
||||
var overallBlackPixels = 0
|
||||
var topBlackStreak = 0
|
||||
var topWhiteStreak = 0
|
||||
var botBlackStreak = 0
|
||||
var botWhiteStreak = 0
|
||||
outer@ for (x in intArrayOf(left, right, leftOffsetX, rightOffsetX)) {
|
||||
var whitePixelsStreak = 0
|
||||
var whitePixels = 0
|
||||
var blackPixelsStreak = 0
|
||||
var blackPixels = 0
|
||||
var blackStreak = false
|
||||
var whiteStreak = false
|
||||
val notOffset = x == left || x == right
|
||||
inner@ for ((index, y) in (0 until image.height step image.height / 25).withIndex()) {
|
||||
val pixel = image[x, y]
|
||||
val pixelOff = image[x + (if (x < image.width / 2) -offsetX else offsetX), y]
|
||||
if (pixel.isWhite()) {
|
||||
whitePixelsStreak++
|
||||
whitePixels++
|
||||
if (notOffset) {
|
||||
overallWhitePixels++
|
||||
}
|
||||
if (whitePixelsStreak > 14) {
|
||||
whiteStreak = true
|
||||
}
|
||||
if (whitePixelsStreak > 6 && whitePixelsStreak >= index - 1) {
|
||||
topWhiteStreak = whitePixelsStreak
|
||||
}
|
||||
} else {
|
||||
whitePixelsStreak = 0
|
||||
if (pixel.isDark() && pixelOff.isDark()) {
|
||||
blackPixels++
|
||||
if (notOffset) {
|
||||
overallBlackPixels++
|
||||
}
|
||||
blackPixelsStreak++
|
||||
if (blackPixelsStreak >= 14) {
|
||||
blackStreak = true
|
||||
}
|
||||
continue@inner
|
||||
}
|
||||
}
|
||||
if (blackPixelsStreak > 6 && blackPixelsStreak >= index - 1) {
|
||||
topBlackStreak = blackPixelsStreak
|
||||
}
|
||||
blackPixelsStreak = 0
|
||||
}
|
||||
if (blackPixelsStreak > 6) {
|
||||
botBlackStreak = blackPixelsStreak
|
||||
} else if (whitePixelsStreak > 6) {
|
||||
botWhiteStreak = whitePixelsStreak
|
||||
}
|
||||
when {
|
||||
blackPixels > 22 -> {
|
||||
if (x == right || x == rightOffsetX) {
|
||||
blackColor = when {
|
||||
topRightIsDark -> topRightPixel
|
||||
botRightIsDark -> botRightPixel
|
||||
else -> blackColor
|
||||
}
|
||||
}
|
||||
darkBG = true
|
||||
overallWhitePixels = 0
|
||||
break@outer
|
||||
}
|
||||
blackStreak -> {
|
||||
darkBG = true
|
||||
if (x == right || x == rightOffsetX) {
|
||||
blackColor = when {
|
||||
topRightIsDark -> topRightPixel
|
||||
botRightIsDark -> botRightPixel
|
||||
else -> blackColor
|
||||
}
|
||||
}
|
||||
if (blackPixels > 18) {
|
||||
overallWhitePixels = 0
|
||||
break@outer
|
||||
}
|
||||
}
|
||||
whiteStreak || whitePixels > 22 -> darkBG = false
|
||||
}
|
||||
}
|
||||
|
||||
val topIsBlackStreak = topBlackStreak > topWhiteStreak
|
||||
val bottomIsBlackStreak = botBlackStreak > botWhiteStreak
|
||||
if (overallWhitePixels > 9 && overallWhitePixels > overallBlackPixels) {
|
||||
darkBG = false
|
||||
}
|
||||
if (topIsBlackStreak && bottomIsBlackStreak) {
|
||||
darkBG = true
|
||||
}
|
||||
|
||||
val isLandscape = context.resources.configuration?.orientation == Configuration.ORIENTATION_LANDSCAPE
|
||||
if (isLandscape) {
|
||||
return when {
|
||||
darkBG -> ColorDrawable(blackColor)
|
||||
else -> ColorDrawable(whiteColor)
|
||||
}
|
||||
}
|
||||
|
||||
val botCornersIsWhite = botLeftPixel.isWhite() && botRightPixel.isWhite()
|
||||
val topCornersIsWhite = topLeftPixel.isWhite() && topRightPixel.isWhite()
|
||||
|
||||
val topCornersIsDark = topLeftIsDark && topRightIsDark
|
||||
val botCornersIsDark = botLeftIsDark && botRightIsDark
|
||||
|
||||
val topOffsetCornersIsDark = image[leftOffsetX, top].isDark() && image[rightOffsetX, top].isDark()
|
||||
val botOffsetCornersIsDark = image[leftOffsetX, bot].isDark() && image[rightOffsetX, bot].isDark()
|
||||
|
||||
val gradient = when {
|
||||
darkBG && botCornersIsWhite -> {
|
||||
intArrayOf(blackColor, blackColor, whiteColor, whiteColor)
|
||||
}
|
||||
darkBG && topCornersIsWhite -> {
|
||||
intArrayOf(whiteColor, whiteColor, blackColor, blackColor)
|
||||
}
|
||||
darkBG -> {
|
||||
return ColorDrawable(blackColor)
|
||||
}
|
||||
topIsBlackStreak || (topCornersIsDark && topOffsetCornersIsDark && (topMidIsDark || overallBlackPixels > 9)) -> {
|
||||
intArrayOf(blackColor, blackColor, whiteColor, whiteColor)
|
||||
}
|
||||
bottomIsBlackStreak || (botCornersIsDark && botOffsetCornersIsDark && (bottomCenterPixel.isDark() || overallBlackPixels > 9)) -> {
|
||||
intArrayOf(whiteColor, whiteColor, blackColor, blackColor)
|
||||
}
|
||||
else -> {
|
||||
return ColorDrawable(whiteColor)
|
||||
}
|
||||
}
|
||||
|
||||
return GradientDrawable(
|
||||
GradientDrawable.Orientation.TOP_BOTTOM,
|
||||
gradient,
|
||||
)
|
||||
}
|
||||
|
||||
private fun @receiver:ColorInt Int.isDark(): Boolean =
|
||||
red < 40 && blue < 40 && green < 40 && alpha > 200
|
||||
|
||||
private fun @receiver:ColorInt Int.isCloseTo(other: Int): Boolean =
|
||||
abs(red - other.red) < 30 && abs(green - other.green) < 30 && abs(blue - other.blue) < 30
|
||||
|
||||
private fun @receiver:ColorInt Int.isWhite(): Boolean =
|
||||
red + blue + green > 740
|
||||
|
||||
/**
|
||||
* Used to check an image's dimensions without loading it in the memory.
|
||||
*/
|
||||
private fun extractImageOptions(
|
||||
imageStream: InputStream,
|
||||
resetAfterExtraction: Boolean = true,
|
||||
): BitmapFactory.Options {
|
||||
imageStream.mark(imageStream.available() + 1)
|
||||
|
||||
val imageBytes = imageStream.readBytes()
|
||||
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size, options)
|
||||
if (resetAfterExtraction) imageStream.reset()
|
||||
return options
|
||||
}
|
||||
|
||||
private fun getBitmapRegionDecoder(imageStream: InputStream): BitmapRegionDecoder? {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
BitmapRegionDecoder.newInstance(imageStream)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
BitmapRegionDecoder.newInstance(imageStream, false)
|
||||
}
|
||||
}
|
||||
|
||||
private val optimalImageHeight = getDisplayMaxHeightInPx * 2
|
||||
|
||||
// Android doesn't include some mappings
|
||||
private val SUPPLEMENTARY_MIMETYPE_MAPPING = mapOf(
|
||||
// https://issuetracker.google.com/issues/182703810
|
||||
"image/jxl" to "jxl",
|
||||
)
|
||||
}
|
||||
|
||||
val getDisplayMaxHeightInPx: Int
|
||||
get() = Resources.getSystem().displayMetrics.let { max(it.heightPixels, it.widthPixels) }
|
Reference in New Issue
Block a user