Add auto split tall images setting
Also includes some fixes for bad merges in earlier commits Co-authored-by: Saud-97 <Saud-97@users.noreply.github.com> Co-authored-by: AntsyLich <AntsyLich@users.noreply.github.com>
This commit is contained in:
parent
e58945a209
commit
6db2becd30
10 changed files with 217 additions and 48 deletions
|
@ -273,7 +273,7 @@ class Downloader(
|
||||||
|
|
||||||
// Start downloader if needed
|
// Start downloader if needed
|
||||||
if (autoStart && wasEmpty) {
|
if (autoStart && wasEmpty) {
|
||||||
val queuedDownloads = queue.filter { it.source !is UnmeteredSource }.count()
|
val queuedDownloads = queue.count { it.source !is UnmeteredSource }
|
||||||
val maxDownloadsFromSource = queue
|
val maxDownloadsFromSource = queue
|
||||||
.groupBy { it.source }
|
.groupBy { it.source }
|
||||||
.filterKeys { it !is UnmeteredSource }
|
.filterKeys { it !is UnmeteredSource }
|
||||||
|
@ -352,6 +352,7 @@ class Downloader(
|
||||||
.doOnNext { ensureSuccessfulDownload(download, mangaDir, tmpDir, chapterDirname) }
|
.doOnNext { ensureSuccessfulDownload(download, mangaDir, tmpDir, chapterDirname) }
|
||||||
// If the page list threw, it will resume here
|
// If the page list threw, it will resume here
|
||||||
.onErrorReturn { error ->
|
.onErrorReturn { error ->
|
||||||
|
logcat(LogPriority.ERROR, error)
|
||||||
download.status = Download.State.ERROR
|
download.status = Download.State.ERROR
|
||||||
notifier.onError(error.message, download.chapter.name, download.manga.title)
|
notifier.onError(error.message, download.chapter.name, download.manga.title)
|
||||||
download
|
download
|
||||||
|
@ -379,7 +380,7 @@ class Downloader(
|
||||||
tmpFile?.delete()
|
tmpFile?.delete()
|
||||||
|
|
||||||
// Try to find the image file.
|
// Try to find the image file.
|
||||||
val imageFile = tmpDir.listFiles()!!.find { it.name!!.startsWith("$filename.") }
|
val imageFile = tmpDir.listFiles()!!.find { it.name!!.startsWith("$filename.") || it.name!!.contains("${filename}__001") }
|
||||||
|
|
||||||
// If the image is already downloaded, do nothing. Otherwise download from network
|
// If the image is already downloaded, do nothing. Otherwise download from network
|
||||||
val pageObservable = when {
|
val pageObservable = when {
|
||||||
|
@ -389,8 +390,12 @@ class Downloader(
|
||||||
}
|
}
|
||||||
|
|
||||||
return pageObservable
|
return pageObservable
|
||||||
// When the image is ready, set image path, progress (just in case) and status
|
// When the page is ready, set page path, progress (just in case) and status
|
||||||
.doOnNext { file ->
|
.doOnNext { file ->
|
||||||
|
val success = splitTallImageIfNeeded(page, tmpDir)
|
||||||
|
if (success.not()) {
|
||||||
|
notifier.onError(context.getString(R.string.download_notifier_split_failed), download.chapter.name, download.manga.title)
|
||||||
|
}
|
||||||
page.uri = file.uri
|
page.uri = file.uri
|
||||||
page.progress = 100
|
page.progress = 100
|
||||||
download.downloadedImages++
|
download.downloadedImages++
|
||||||
|
@ -401,6 +406,7 @@ class Downloader(
|
||||||
.onErrorReturn {
|
.onErrorReturn {
|
||||||
page.progress = 0
|
page.progress = 0
|
||||||
page.status = Page.ERROR
|
page.status = Page.ERROR
|
||||||
|
notifier.onError(it.message, download.chapter.name, download.manga.title)
|
||||||
page
|
page
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -474,6 +480,26 @@ class Downloader(
|
||||||
return ImageUtil.getExtensionFromMimeType(mime)
|
return ImageUtil.getExtensionFromMimeType(mime)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun splitTallImageIfNeeded(page: Page, tmpDir: UniFile): Boolean {
|
||||||
|
if (!preferences.splitTallImages().get()) return true
|
||||||
|
|
||||||
|
val filename = String.format("%03d", page.number)
|
||||||
|
val imageFile = tmpDir.listFiles()?.find { it.name!!.startsWith(filename) }
|
||||||
|
?: throw Error(context.getString(R.string.download_notifier_split_page_not_found, page.number))
|
||||||
|
val imageFilePath = imageFile.filePath
|
||||||
|
?: throw Error(context.getString(R.string.download_notifier_split_page_path_not_found, page.number))
|
||||||
|
|
||||||
|
// check if the original page was previously splitted before then skip.
|
||||||
|
if (imageFile.name!!.contains("__")) return true
|
||||||
|
|
||||||
|
return try {
|
||||||
|
ImageUtil.splitTallImage(imageFile, imageFilePath)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logcat(LogPriority.ERROR, e)
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks if the download was successful.
|
* Checks if the download was successful.
|
||||||
*
|
*
|
||||||
|
@ -489,16 +515,10 @@ class Downloader(
|
||||||
dirname: String,
|
dirname: String,
|
||||||
) {
|
) {
|
||||||
// Ensure that the chapter folder has all the images.
|
// Ensure that the chapter folder has all the images.
|
||||||
val downloadedImages = tmpDir.listFiles().orEmpty().filterNot { it.name!!.endsWith(".tmp") }
|
val downloadedImages = tmpDir.listFiles().orEmpty().filterNot { it.name!!.endsWith(".tmp") || (it.name!!.contains("__") && !it.name!!.contains("__001.jpg")) }
|
||||||
|
|
||||||
download.status = if (downloadedImages.size == download.pages!!.size) {
|
download.status = if (downloadedImages.size == download.pages!!.size) {
|
||||||
Download.State.DOWNLOADED
|
// Only rename the directory if it's downloaded.
|
||||||
} else {
|
|
||||||
Download.State.ERROR
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only rename the directory if it's downloaded.
|
|
||||||
if (download.status == Download.State.DOWNLOADED) {
|
|
||||||
if (preferences.saveChaptersAsCBZ().get()) {
|
if (preferences.saveChaptersAsCBZ().get()) {
|
||||||
archiveChapter(mangaDir, dirname, tmpDir)
|
archiveChapter(mangaDir, dirname, tmpDir)
|
||||||
} else {
|
} else {
|
||||||
|
@ -507,6 +527,10 @@ class Downloader(
|
||||||
cache.addChapter(dirname, mangaDir, download.manga)
|
cache.addChapter(dirname, mangaDir, download.manga)
|
||||||
|
|
||||||
DiskUtil.createNoMediaFile(tmpDir, context)
|
DiskUtil.createNoMediaFile(tmpDir, context)
|
||||||
|
|
||||||
|
Download.State.DOWNLOADED
|
||||||
|
} else {
|
||||||
|
Download.State.ERROR
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -206,6 +206,8 @@ class PreferencesHelper(val context: Context) {
|
||||||
|
|
||||||
fun saveChaptersAsCBZ() = flowPrefs.getBoolean("save_chapter_as_cbz", true)
|
fun saveChaptersAsCBZ() = flowPrefs.getBoolean("save_chapter_as_cbz", true)
|
||||||
|
|
||||||
|
fun splitTallImages() = flowPrefs.getBoolean("split_tall_images", false)
|
||||||
|
|
||||||
fun folderPerManga() = prefs.getBoolean(Keys.folderPerManga, false)
|
fun folderPerManga() = prefs.getBoolean(Keys.folderPerManga, false)
|
||||||
|
|
||||||
fun numberOfBackups() = flowPrefs.getInt("backup_slots", 2)
|
fun numberOfBackups() = flowPrefs.getInt("backup_slots", 2)
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
package eu.kanade.tachiyomi.data.track.myanimelist
|
package eu.kanade.tachiyomi.data.track.myanimelist
|
||||||
|
|
||||||
import eu.kanade.tachiyomi.network.parseAs
|
import eu.kanade.tachiyomi.network.parseAs
|
||||||
import kotlinx.serialization.decodeFromString
|
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import okhttp3.Interceptor
|
import okhttp3.Interceptor
|
||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
|
|
|
@ -4,6 +4,7 @@ import android.content.Context
|
||||||
import com.github.junrar.Archive
|
import com.github.junrar.Archive
|
||||||
import com.hippo.unifile.UniFile
|
import com.hippo.unifile.UniFile
|
||||||
import eu.kanade.tachiyomi.R
|
import eu.kanade.tachiyomi.R
|
||||||
|
import eu.kanade.tachiyomi.data.cache.CoverCache
|
||||||
import eu.kanade.tachiyomi.source.model.Filter
|
import eu.kanade.tachiyomi.source.model.Filter
|
||||||
import eu.kanade.tachiyomi.source.model.FilterList
|
import eu.kanade.tachiyomi.source.model.FilterList
|
||||||
import eu.kanade.tachiyomi.source.model.MangasPage
|
import eu.kanade.tachiyomi.source.model.MangasPage
|
||||||
|
@ -31,6 +32,8 @@ import logcat.LogPriority
|
||||||
import rx.Observable
|
import rx.Observable
|
||||||
import tachiyomi.source.model.ChapterInfo
|
import tachiyomi.source.model.ChapterInfo
|
||||||
import tachiyomi.source.model.MangaInfo
|
import tachiyomi.source.model.MangaInfo
|
||||||
|
import uy.kohesive.injekt.Injekt
|
||||||
|
import uy.kohesive.injekt.api.get
|
||||||
import uy.kohesive.injekt.injectLazy
|
import uy.kohesive.injekt.injectLazy
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.FileInputStream
|
import java.io.FileInputStream
|
||||||
|
@ -40,6 +43,7 @@ import java.util.zip.ZipFile
|
||||||
|
|
||||||
class LocalSource(
|
class LocalSource(
|
||||||
private val context: Context,
|
private val context: Context,
|
||||||
|
private val coverCache: CoverCache = Injekt.get(),
|
||||||
) : CatalogueSource, UnmeteredSource {
|
) : CatalogueSource, UnmeteredSource {
|
||||||
|
|
||||||
private val json: Json by injectLazy()
|
private val json: Json by injectLazy()
|
||||||
|
|
|
@ -19,6 +19,7 @@ import rx.Observable
|
||||||
import rx.Subscription
|
import rx.Subscription
|
||||||
import rx.android.schedulers.AndroidSchedulers
|
import rx.android.schedulers.AndroidSchedulers
|
||||||
import rx.schedulers.Schedulers
|
import rx.schedulers.Schedulers
|
||||||
|
import java.io.BufferedInputStream
|
||||||
import java.io.ByteArrayInputStream
|
import java.io.ByteArrayInputStream
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
@ -238,7 +239,7 @@ class PagerPageHolder(
|
||||||
.subscribe({}, {})
|
.subscribe({}, {})
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun process(page: ReaderPage, imageStream: InputStream): InputStream {
|
private fun process(page: ReaderPage, imageStream: BufferedInputStream): InputStream {
|
||||||
if (!viewer.config.dualPageSplit) {
|
if (!viewer.config.dualPageSplit) {
|
||||||
return imageStream
|
return imageStream
|
||||||
}
|
}
|
||||||
|
@ -247,7 +248,7 @@ class PagerPageHolder(
|
||||||
return splitInHalf(imageStream)
|
return splitInHalf(imageStream)
|
||||||
}
|
}
|
||||||
|
|
||||||
val isDoublePage = ImageUtil.isDoublePage(imageStream)
|
val isDoublePage = ImageUtil.isWideImage(imageStream)
|
||||||
if (!isDoublePage) {
|
if (!isDoublePage) {
|
||||||
return imageStream
|
return imageStream
|
||||||
}
|
}
|
||||||
|
|
|
@ -23,6 +23,7 @@ import rx.Observable
|
||||||
import rx.Subscription
|
import rx.Subscription
|
||||||
import rx.android.schedulers.AndroidSchedulers
|
import rx.android.schedulers.AndroidSchedulers
|
||||||
import rx.schedulers.Schedulers
|
import rx.schedulers.Schedulers
|
||||||
|
import java.io.BufferedInputStream
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
@ -272,12 +273,12 @@ class WebtoonPageHolder(
|
||||||
addSubscription(readImageHeaderSubscription)
|
addSubscription(readImageHeaderSubscription)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun process(imageStream: InputStream): InputStream {
|
private fun process(imageStream: BufferedInputStream): InputStream {
|
||||||
if (!viewer.config.dualPageSplit) {
|
if (!viewer.config.dualPageSplit) {
|
||||||
return imageStream
|
return imageStream
|
||||||
}
|
}
|
||||||
|
|
||||||
val isDoublePage = ImageUtil.isDoublePage(imageStream)
|
val isDoublePage = ImageUtil.isWideImage(imageStream)
|
||||||
if (!isDoublePage) {
|
if (!isDoublePage) {
|
||||||
return imageStream
|
return imageStream
|
||||||
}
|
}
|
||||||
|
|
|
@ -24,6 +24,7 @@ import eu.kanade.tachiyomi.util.preference.multiSelectListPreference
|
||||||
import eu.kanade.tachiyomi.util.preference.onClick
|
import eu.kanade.tachiyomi.util.preference.onClick
|
||||||
import eu.kanade.tachiyomi.util.preference.preference
|
import eu.kanade.tachiyomi.util.preference.preference
|
||||||
import eu.kanade.tachiyomi.util.preference.preferenceCategory
|
import eu.kanade.tachiyomi.util.preference.preferenceCategory
|
||||||
|
import eu.kanade.tachiyomi.util.preference.summaryRes
|
||||||
import eu.kanade.tachiyomi.util.preference.switchPreference
|
import eu.kanade.tachiyomi.util.preference.switchPreference
|
||||||
import eu.kanade.tachiyomi.util.preference.titleRes
|
import eu.kanade.tachiyomi.util.preference.titleRes
|
||||||
import eu.kanade.tachiyomi.util.system.toast
|
import eu.kanade.tachiyomi.util.system.toast
|
||||||
|
@ -72,6 +73,12 @@ class SettingsDownloadController : SettingsController() {
|
||||||
bindTo(preferences.saveChaptersAsCBZ())
|
bindTo(preferences.saveChaptersAsCBZ())
|
||||||
titleRes = R.string.save_chapter_as_cbz
|
titleRes = R.string.save_chapter_as_cbz
|
||||||
}
|
}
|
||||||
|
switchPreference {
|
||||||
|
bindTo(preferences.splitTallImages())
|
||||||
|
titleRes = R.string.split_tall_images
|
||||||
|
summaryRes = R.string.split_tall_images_summary
|
||||||
|
}
|
||||||
|
|
||||||
preferenceCategory {
|
preferenceCategory {
|
||||||
titleRes = R.string.pref_category_delete_chapters
|
titleRes = R.string.pref_category_delete_chapters
|
||||||
|
|
||||||
|
|
|
@ -47,6 +47,7 @@ import logcat.LogPriority
|
||||||
import uy.kohesive.injekt.Injekt
|
import uy.kohesive.injekt.Injekt
|
||||||
import uy.kohesive.injekt.api.get
|
import uy.kohesive.injekt.api.get
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import kotlin.math.max
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
private const val TABLET_UI_MIN_SCREEN_WIDTH_DP = 720
|
private const val TABLET_UI_MIN_SCREEN_WIDTH_DP = 720
|
||||||
|
@ -166,6 +167,9 @@ fun Context.hasPermission(permission: String) = ContextCompat.checkSelfPermissio
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val getDisplayMaxHeightInPx: Int
|
||||||
|
get() = Resources.getSystem().displayMetrics.let { max(it.heightPixels, it.widthPixels) }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Converts to dp.
|
* Converts to dp.
|
||||||
*/
|
*/
|
||||||
|
@ -258,7 +262,7 @@ fun Context.openInBrowser(uri: Uri, forceDefaultBrowser: Boolean = false) {
|
||||||
}
|
}
|
||||||
|
|
||||||
fun Context.defaultBrowserPackageName(): String? {
|
fun Context.defaultBrowserPackageName(): String? {
|
||||||
val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse("http://"))
|
val browserIntent = Intent(Intent.ACTION_VIEW, "http://".toUri())
|
||||||
return packageManager.resolveActivity(browserIntent, PackageManager.MATCH_DEFAULT_ONLY)
|
return packageManager.resolveActivity(browserIntent, PackageManager.MATCH_DEFAULT_ONLY)
|
||||||
?.activityInfo?.packageName
|
?.activityInfo?.packageName
|
||||||
?.takeUnless { it in DeviceUtil.invalidDefaultBrowsers }
|
?.takeUnless { it in DeviceUtil.invalidDefaultBrowsers }
|
||||||
|
@ -315,8 +319,8 @@ fun Context.isNightMode(): Boolean {
|
||||||
* https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:appcompat/appcompat/src/main/java/androidx/appcompat/app/AppCompatDelegateImpl.java;l=348;drc=e28752c96fc3fb4d3354781469a1af3dbded4898
|
* https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:appcompat/appcompat/src/main/java/androidx/appcompat/app/AppCompatDelegateImpl.java;l=348;drc=e28752c96fc3fb4d3354781469a1af3dbded4898
|
||||||
*/
|
*/
|
||||||
fun Context.createReaderThemeContext(): Context {
|
fun Context.createReaderThemeContext(): Context {
|
||||||
val prefs = Injekt.get<PreferencesHelper>()
|
val preferences = Injekt.get<PreferencesHelper>()
|
||||||
val isDarkBackground = when (prefs.readerTheme().get()) {
|
val isDarkBackground = when (preferences.readerTheme().get()) {
|
||||||
1, 2 -> true // Black, Gray
|
1, 2 -> true // Black, Gray
|
||||||
3 -> applicationContext.isNightMode() // Automatic bg uses activity background by default
|
3 -> applicationContext.isNightMode() // Automatic bg uses activity background by default
|
||||||
else -> false // White
|
else -> false // White
|
||||||
|
@ -329,7 +333,7 @@ fun Context.createReaderThemeContext(): Context {
|
||||||
|
|
||||||
val wrappedContext = ContextThemeWrapper(this, R.style.Theme_Tachiyomi)
|
val wrappedContext = ContextThemeWrapper(this, R.style.Theme_Tachiyomi)
|
||||||
wrappedContext.applyOverrideConfiguration(overrideConf)
|
wrappedContext.applyOverrideConfiguration(overrideConf)
|
||||||
ThemingDelegate.getThemeResIds(prefs.appTheme().get(), prefs.themeDarkAmoled().get())
|
ThemingDelegate.getThemeResIds(preferences.appTheme().get(), preferences.themeDarkAmoled().get())
|
||||||
.forEach { wrappedContext.theme.applyStyle(it, true) }
|
.forEach { wrappedContext.theme.applyStyle(it, true) }
|
||||||
return wrappedContext
|
return wrappedContext
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,6 +4,7 @@ import android.content.Context
|
||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.graphics.BitmapFactory
|
import android.graphics.BitmapFactory
|
||||||
|
import android.graphics.BitmapRegionDecoder
|
||||||
import android.graphics.Color
|
import android.graphics.Color
|
||||||
import android.graphics.Rect
|
import android.graphics.Rect
|
||||||
import android.graphics.drawable.ColorDrawable
|
import android.graphics.drawable.ColorDrawable
|
||||||
|
@ -11,19 +12,27 @@ import android.graphics.drawable.Drawable
|
||||||
import android.graphics.drawable.GradientDrawable
|
import android.graphics.drawable.GradientDrawable
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.webkit.MimeTypeMap
|
import android.webkit.MimeTypeMap
|
||||||
|
import androidx.annotation.ColorInt
|
||||||
import androidx.core.graphics.alpha
|
import androidx.core.graphics.alpha
|
||||||
import androidx.core.graphics.applyCanvas
|
import androidx.core.graphics.applyCanvas
|
||||||
import androidx.core.graphics.blue
|
import androidx.core.graphics.blue
|
||||||
import androidx.core.graphics.createBitmap
|
import androidx.core.graphics.createBitmap
|
||||||
|
import androidx.core.graphics.get
|
||||||
import androidx.core.graphics.green
|
import androidx.core.graphics.green
|
||||||
import androidx.core.graphics.red
|
import androidx.core.graphics.red
|
||||||
|
import com.hippo.unifile.UniFile
|
||||||
|
import logcat.LogPriority
|
||||||
import tachiyomi.decoder.Format
|
import tachiyomi.decoder.Format
|
||||||
import tachiyomi.decoder.ImageDecoder
|
import tachiyomi.decoder.ImageDecoder
|
||||||
|
import java.io.BufferedInputStream
|
||||||
import java.io.ByteArrayInputStream
|
import java.io.ByteArrayInputStream
|
||||||
import java.io.ByteArrayOutputStream
|
import java.io.ByteArrayOutputStream
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileOutputStream
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
import java.net.URLConnection
|
import java.net.URLConnection
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
|
import kotlin.math.min
|
||||||
|
|
||||||
object ImageUtil {
|
object ImageUtil {
|
||||||
|
|
||||||
|
@ -73,8 +82,7 @@ object ImageUtil {
|
||||||
Format.Webp -> type.isAnimated && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P
|
Format.Webp -> type.isAnimated && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P
|
||||||
else -> false
|
else -> false
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) { /* Do Nothing */ }
|
||||||
}
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -106,19 +114,12 @@ object ImageUtil {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check whether the image is a double-page spread
|
* Check whether the image is wide (which we consider a double-page spread).
|
||||||
|
*
|
||||||
* @return true if the width is greater than the height
|
* @return true if the width is greater than the height
|
||||||
*/
|
*/
|
||||||
fun isDoublePage(imageStream: InputStream): Boolean {
|
fun isWideImage(imageStream: BufferedInputStream): Boolean {
|
||||||
imageStream.mark(imageStream.available() + 1)
|
val options = extractImageOptions(imageStream)
|
||||||
|
|
||||||
val imageBytes = imageStream.readBytes()
|
|
||||||
|
|
||||||
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
|
||||||
BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size, options)
|
|
||||||
|
|
||||||
imageStream.reset()
|
|
||||||
|
|
||||||
return options.outWidth > options.outHeight
|
return options.outWidth > options.outHeight
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -185,6 +186,111 @@ object ImageUtil {
|
||||||
RIGHT, LEFT
|
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(imageFile: UniFile, imageFilePath: String): Boolean {
|
||||||
|
if (isAnimatedAndSupported(imageFile.openInputStream()) || !isTallImage(imageFile.openInputStream())) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
val options = extractImageOptions(imageFile.openInputStream(), resetAfterExtraction = false).apply { inJustDecodeBounds = false }
|
||||||
|
// Values are stored as they get modified during split loop
|
||||||
|
val imageHeight = options.outHeight
|
||||||
|
val imageWidth = options.outWidth
|
||||||
|
|
||||||
|
val splitHeight = (getDisplayMaxHeightInPx * 1.5).toInt()
|
||||||
|
// -1 so it doesn't try to split when imageHeight = getDisplayHeightInPx
|
||||||
|
val partCount = (imageHeight - 1) / splitHeight + 1
|
||||||
|
|
||||||
|
val optimalSplitHeight = imageHeight / partCount
|
||||||
|
|
||||||
|
val splitDataList = (0 until partCount).fold(mutableListOf<SplitData>()) { list, index ->
|
||||||
|
list.apply {
|
||||||
|
// Only continue if the list is empty or there is image remaining
|
||||||
|
if (isEmpty() || imageHeight > last().bottomOffset) {
|
||||||
|
val topOffset = index * optimalSplitHeight
|
||||||
|
var outputImageHeight = min(optimalSplitHeight, imageHeight - topOffset)
|
||||||
|
|
||||||
|
val remainingHeight = imageHeight - (topOffset + outputImageHeight)
|
||||||
|
// If remaining height is smaller or equal to 1/3th of
|
||||||
|
// optimal split height then include it in current page
|
||||||
|
if (remainingHeight <= (optimalSplitHeight / 3)) {
|
||||||
|
outputImageHeight += remainingHeight
|
||||||
|
}
|
||||||
|
add(SplitData(index, topOffset, outputImageHeight))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val bitmapRegionDecoder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
BitmapRegionDecoder.newInstance(imageFile.openInputStream())
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
BitmapRegionDecoder.newInstance(imageFile.openInputStream(), false)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bitmapRegionDecoder == null) {
|
||||||
|
logcat { "Failed to create new instance of BitmapRegionDecoder" }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
logcat {
|
||||||
|
"Splitting image with height of $imageHeight into $partCount part " +
|
||||||
|
"with estimated ${optimalSplitHeight}px height per split"
|
||||||
|
}
|
||||||
|
|
||||||
|
return try {
|
||||||
|
splitDataList.forEach { splitData ->
|
||||||
|
val splitPath = splitImagePath(imageFilePath, splitData.index)
|
||||||
|
|
||||||
|
val region = Rect(0, splitData.topOffset, imageWidth, splitData.bottomOffset)
|
||||||
|
|
||||||
|
FileOutputStream(splitPath).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.outputImageHeight} 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 { splitImagePath(imageFilePath, it.index) }
|
||||||
|
.forEach { File(it).delete() }
|
||||||
|
logcat(LogPriority.ERROR, e)
|
||||||
|
false
|
||||||
|
} finally {
|
||||||
|
bitmapRegionDecoder.recycle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun splitImagePath(imageFilePath: String, index: Int) =
|
||||||
|
imageFilePath.substringBeforeLast(".") + "__${"%03d".format(index + 1)}.jpg"
|
||||||
|
|
||||||
|
data class SplitData(
|
||||||
|
val index: Int,
|
||||||
|
val topOffset: Int,
|
||||||
|
val outputImageHeight: Int,
|
||||||
|
) {
|
||||||
|
val bottomOffset = topOffset + outputImageHeight
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Algorithm for determining what background to accompany a comic/manga page
|
* Algorithm for determining what background to accompany a comic/manga page
|
||||||
*/
|
*/
|
||||||
|
@ -209,14 +315,14 @@ object ImageUtil {
|
||||||
val leftOffsetX = left - offsetX
|
val leftOffsetX = left - offsetX
|
||||||
val rightOffsetX = right + offsetX
|
val rightOffsetX = right + offsetX
|
||||||
|
|
||||||
val topLeftPixel = image.getPixel(left, top)
|
val topLeftPixel = image[left, top]
|
||||||
val topRightPixel = image.getPixel(right, top)
|
val topRightPixel = image[right, top]
|
||||||
val midLeftPixel = image.getPixel(left, midY)
|
val midLeftPixel = image[left, midY]
|
||||||
val midRightPixel = image.getPixel(right, midY)
|
val midRightPixel = image[right, midY]
|
||||||
val topCenterPixel = image.getPixel(midX, top)
|
val topCenterPixel = image[midX, top]
|
||||||
val botLeftPixel = image.getPixel(left, bot)
|
val botLeftPixel = image[left, bot]
|
||||||
val bottomCenterPixel = image.getPixel(midX, bot)
|
val bottomCenterPixel = image[midX, bot]
|
||||||
val botRightPixel = image.getPixel(right, bot)
|
val botRightPixel = image[right, bot]
|
||||||
|
|
||||||
val topLeftIsDark = topLeftPixel.isDark()
|
val topLeftIsDark = topLeftPixel.isDark()
|
||||||
val topRightIsDark = topRightPixel.isDark()
|
val topRightIsDark = topRightPixel.isDark()
|
||||||
|
@ -269,8 +375,8 @@ object ImageUtil {
|
||||||
var whiteStreak = false
|
var whiteStreak = false
|
||||||
val notOffset = x == left || x == right
|
val notOffset = x == left || x == right
|
||||||
inner@ for ((index, y) in (0 until image.height step image.height / 25).withIndex()) {
|
inner@ for ((index, y) in (0 until image.height step image.height / 25).withIndex()) {
|
||||||
val pixel = image.getPixel(x, y)
|
val pixel = image[x, y]
|
||||||
val pixelOff = image.getPixel(x + (if (x < image.width / 2) -offsetX else offsetX), y)
|
val pixelOff = image[x + (if (x < image.width / 2) -offsetX else offsetX), y]
|
||||||
if (pixel.isWhite()) {
|
if (pixel.isWhite()) {
|
||||||
whitePixelsStreak++
|
whitePixelsStreak++
|
||||||
whitePixels++
|
whitePixels++
|
||||||
|
@ -361,8 +467,8 @@ object ImageUtil {
|
||||||
val topCornersIsDark = topLeftIsDark && topRightIsDark
|
val topCornersIsDark = topLeftIsDark && topRightIsDark
|
||||||
val botCornersIsDark = botLeftIsDark && botRightIsDark
|
val botCornersIsDark = botLeftIsDark && botRightIsDark
|
||||||
|
|
||||||
val topOffsetCornersIsDark = image.getPixel(leftOffsetX, top).isDark() && image.getPixel(rightOffsetX, top).isDark()
|
val topOffsetCornersIsDark = image[leftOffsetX, top].isDark() && image[rightOffsetX, top].isDark()
|
||||||
val botOffsetCornersIsDark = image.getPixel(leftOffsetX, bot).isDark() && image.getPixel(rightOffsetX, bot).isDark()
|
val botOffsetCornersIsDark = image[leftOffsetX, bot].isDark() && image[rightOffsetX, bot].isDark()
|
||||||
|
|
||||||
val gradient = when {
|
val gradient = when {
|
||||||
darkBG && botCornersIsWhite -> {
|
darkBG && botCornersIsWhite -> {
|
||||||
|
@ -391,15 +497,31 @@ object ImageUtil {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun Int.isDark(): Boolean =
|
private fun @receiver:ColorInt Int.isDark(): Boolean =
|
||||||
red < 40 && blue < 40 && green < 40 && alpha > 200
|
red < 40 && blue < 40 && green < 40 && alpha > 200
|
||||||
|
|
||||||
private fun Int.isCloseTo(other: Int): Boolean =
|
private fun @receiver:ColorInt Int.isCloseTo(other: Int): Boolean =
|
||||||
abs(red - other.red) < 30 && abs(green - other.green) < 30 && abs(blue - other.blue) < 30
|
abs(red - other.red) < 30 && abs(green - other.green) < 30 && abs(blue - other.blue) < 30
|
||||||
|
|
||||||
private fun Int.isWhite(): Boolean =
|
private fun @receiver:ColorInt Int.isWhite(): Boolean =
|
||||||
red + blue + green > 740
|
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
|
||||||
|
}
|
||||||
|
|
||||||
// Android doesn't include some mappings
|
// Android doesn't include some mappings
|
||||||
private val SUPPLEMENTARY_MIMETYPE_MAPPING = mapOf(
|
private val SUPPLEMENTARY_MIMETYPE_MAPPING = mapOf(
|
||||||
// https://issuetracker.google.com/issues/182703810
|
// https://issuetracker.google.com/issues/182703810
|
||||||
|
|
|
@ -410,6 +410,8 @@
|
||||||
<string name="pref_download_new">Download new chapters</string>
|
<string name="pref_download_new">Download new chapters</string>
|
||||||
<string name="pref_download_new_categories_details">Manga in excluded categories will not be downloaded even if they are also in included categories.</string>
|
<string name="pref_download_new_categories_details">Manga in excluded categories will not be downloaded even if they are also in included categories.</string>
|
||||||
<string name="save_chapter_as_cbz">Save as CBZ archive</string>
|
<string name="save_chapter_as_cbz">Save as CBZ archive</string>
|
||||||
|
<string name="split_tall_images">Auto split tall images</string>
|
||||||
|
<string name="split_tall_images_summary">Improves reader performance by splitting tall downloaded images.</string>
|
||||||
|
|
||||||
<!-- Tracking section -->
|
<!-- Tracking section -->
|
||||||
<string name="tracking_guide">Tracking guide</string>
|
<string name="tracking_guide">Tracking guide</string>
|
||||||
|
@ -809,6 +811,9 @@
|
||||||
<string name="download_notifier_no_network">No network connection available</string>
|
<string name="download_notifier_no_network">No network connection available</string>
|
||||||
<string name="download_notifier_download_paused">Download paused</string>
|
<string name="download_notifier_download_paused">Download paused</string>
|
||||||
<string name="download_notifier_download_finish">Download completed</string>
|
<string name="download_notifier_download_finish">Download completed</string>
|
||||||
|
<string name="download_notifier_split_page_not_found">Page %d not found while splitting</string>
|
||||||
|
<string name="download_notifier_split_page_path_not_found">Couldn\'t find file path of page %d</string>
|
||||||
|
<string name="download_notifier_split_failed">Couldn\'t split downloaded image</string>
|
||||||
|
|
||||||
<!-- Notification channels -->
|
<!-- Notification channels -->
|
||||||
<string name="channel_common">Common</string>
|
<string name="channel_common">Common</string>
|
||||||
|
|
Reference in a new issue