Load local manga pages directly #552

pull/569/head
Koitharu 2 years ago
parent 0c839ce49a
commit 880dd6da27
Signed by: Koitharu
GPG Key ID: 676DEE768C17A9D7

@ -16,8 +16,8 @@ android {
applicationId 'org.koitharu.kotatsu' applicationId 'org.koitharu.kotatsu'
minSdk = 21 minSdk = 21
targetSdk = 34 targetSdk = 34
versionCode = 598 versionCode = 599
versionName = '6.3.1' versionName = '6.3.2'
generatedDensities = [] generatedDensities = []
testInstrumentationRunner "org.koitharu.kotatsu.HiltTestRunner" testInstrumentationRunner "org.koitharu.kotatsu.HiltTestRunner"
ksp { ksp {
@ -134,7 +134,7 @@ dependencies {
implementation 'io.coil-kt:coil-base:2.5.0' implementation 'io.coil-kt:coil-base:2.5.0'
implementation 'io.coil-kt:coil-svg:2.5.0' implementation 'io.coil-kt:coil-svg:2.5.0'
implementation 'com.github.KotatsuApp:subsampling-scale-image-view:0fef1a47c9' implementation 'com.github.KotatsuApp:subsampling-scale-image-view:826d7b4512'
implementation 'com.github.solkin:disk-lru-cache:1.4' implementation 'com.github.solkin:disk-lru-cache:1.4'
implementation 'io.noties.markwon:core:4.6.2' implementation 'io.noties.markwon:core:4.6.2'

@ -19,8 +19,8 @@ import java.nio.file.attribute.BasicFileAttributes
import java.util.zip.ZipEntry import java.util.zip.ZipEntry
import java.util.zip.ZipFile import java.util.zip.ZipFile
import kotlin.io.path.ExperimentalPathApi import kotlin.io.path.ExperimentalPathApi
import kotlin.io.path.walk
import kotlin.io.path.readAttributes import kotlin.io.path.readAttributes
import kotlin.io.path.walk
fun File.subdir(name: String) = File(this, name).also { fun File.subdir(name: String) = File(this, name).also {
if (!it.exists()) it.mkdirs() if (!it.exists()) it.mkdirs()
@ -50,7 +50,7 @@ fun File.getStorageName(context: Context): String = runCatching {
} }
}.getOrNull() ?: context.getString(R.string.other_storage) }.getOrNull() ?: context.getString(R.string.other_storage)
fun Uri.toFileOrNull() = if (scheme == "file") path?.let(::File) else null fun Uri.toFileOrNull() = if (scheme == URI_SCHEME_FILE) path?.let(::File) else null
suspend fun File.deleteAwait() = withContext(Dispatchers.IO) { suspend fun File.deleteAwait() = withContext(Dispatchers.IO) {
delete() || deleteRecursively() delete() || deleteRecursively()

@ -0,0 +1,50 @@
package org.koitharu.kotatsu.core.util.ext
import android.net.Uri
import androidx.core.net.toFile
import okio.Source
import okio.source
import okio.use
import org.koitharu.kotatsu.local.data.util.withExtraCloseable
import java.io.File
import java.util.zip.ZipFile
const val URI_SCHEME_FILE = "file"
const val URI_SCHEME_ZIP = "file+zip"
fun Uri.exists(): Boolean = when (scheme) {
URI_SCHEME_FILE -> toFile().exists()
URI_SCHEME_ZIP -> {
val file = File(requireNotNull(schemeSpecificPart))
file.exists() && ZipFile(file).use { it.getEntry(fragment) != null }
}
else -> unsupportedUri(this)
}
fun Uri.isTargetNotEmpty(): Boolean = when (scheme) {
URI_SCHEME_FILE -> toFile().isNotEmpty()
URI_SCHEME_ZIP -> {
val file = File(requireNotNull(schemeSpecificPart))
file.exists() && ZipFile(file).use { (it.getEntry(fragment)?.size ?: 0L) != 0L }
}
else -> unsupportedUri(this)
}
fun Uri.source(): Source = when (scheme) {
URI_SCHEME_FILE -> toFile().source()
URI_SCHEME_ZIP -> {
val zip = ZipFile(schemeSpecificPart)
val entry = zip.getEntry(fragment)
zip.getInputStream(entry).source().withExtraCloseable(zip)
}
else -> unsupportedUri(this)
}
fun File.toZipUri(entryName: String): Uri = Uri.parse("$URI_SCHEME_ZIP://$absolutePath#$entryName")
private fun unsupportedUri(uri: Uri): Nothing {
throw IllegalArgumentException("Bad uri $uri: only schemes $URI_SCHEME_FILE and $URI_SCHEME_ZIP are supported")
}

@ -1,32 +0,0 @@
package org.koitharu.kotatsu.core.zip
import android.net.Uri
import androidx.annotation.WorkerThread
import androidx.collection.LruCache
import okhttp3.internal.closeQuietly
import okio.Source
import okio.source
import java.io.File
import java.util.zip.ZipFile
class ZipPool(maxSize: Int) : LruCache<String, ZipFile>(maxSize) {
override fun entryRemoved(evicted: Boolean, key: String, oldValue: ZipFile, newValue: ZipFile?) {
super.entryRemoved(evicted, key, oldValue, newValue)
oldValue.closeQuietly()
}
override fun create(key: String): ZipFile {
return ZipFile(File(key), ZipFile.OPEN_READ)
}
@Synchronized
@WorkerThread
operator fun get(uri: Uri): Source {
val zip = requireNotNull(get(uri.schemeSpecificPart)) {
"Cannot obtain zip by \"$uri\""
}
val entry = zip.getEntry(uri.fragment)
return zip.getInputStream(entry).source()
}
}

@ -12,6 +12,6 @@ fun hasCbzExtension(string: String): Boolean {
return isCbzExtension(ext) return isCbzExtension(ext)
} }
fun hasCbzExtension(file: File) = isCbzExtension(file.name) fun hasCbzExtension(file: File) = isCbzExtension(file.extension)
fun isCbzUri(uri: Uri) = isCbzExtension(uri.scheme) fun isCbzUri(uri: Uri) = isCbzExtension(uri.scheme)

@ -14,6 +14,7 @@ import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.Cache import okhttp3.Cache
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.ext.URI_SCHEME_FILE
import org.koitharu.kotatsu.core.util.ext.computeSize import org.koitharu.kotatsu.core.util.ext.computeSize
import org.koitharu.kotatsu.core.util.ext.getStorageName import org.koitharu.kotatsu.core.util.ext.getStorageName
import org.koitharu.kotatsu.core.util.ext.resolveFile import org.koitharu.kotatsu.core.util.ext.resolveFile
@ -84,7 +85,7 @@ class LocalStorageManager @Inject constructor(
} }
suspend fun resolveUri(uri: Uri): File? = runInterruptible(Dispatchers.IO) { suspend fun resolveUri(uri: Uri): File? = runInterruptible(Dispatchers.IO) {
if (uri.scheme == "file") { if (uri.scheme == URI_SCHEME_FILE) {
uri.toFile() uri.toFile()
} else { } else {
uri.resolveFile(context) uri.resolveFile(context)

@ -7,6 +7,7 @@ import android.net.Uri
import androidx.annotation.AnyThread import androidx.annotation.AnyThread
import androidx.collection.LongSparseArray import androidx.collection.LongSparseArray
import androidx.collection.set import androidx.collection.set
import androidx.core.net.toUri
import dagger.hilt.android.ActivityRetainedLifecycle import dagger.hilt.android.ActivityRetainedLifecycle
import dagger.hilt.android.lifecycle.RetainedLifecycle import dagger.hilt.android.lifecycle.RetainedLifecycle
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
@ -33,15 +34,16 @@ import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.FileSize import org.koitharu.kotatsu.core.util.FileSize
import org.koitharu.kotatsu.core.util.RetainedLifecycleCoroutineScope import org.koitharu.kotatsu.core.util.RetainedLifecycleCoroutineScope
import org.koitharu.kotatsu.core.util.ext.URI_SCHEME_ZIP
import org.koitharu.kotatsu.core.util.ext.ensureSuccess import org.koitharu.kotatsu.core.util.ext.ensureSuccess
import org.koitharu.kotatsu.core.util.ext.exists
import org.koitharu.kotatsu.core.util.ext.getCompletionResultOrNull import org.koitharu.kotatsu.core.util.ext.getCompletionResultOrNull
import org.koitharu.kotatsu.core.util.ext.isNotEmpty
import org.koitharu.kotatsu.core.util.ext.isPowerSaveMode import org.koitharu.kotatsu.core.util.ext.isPowerSaveMode
import org.koitharu.kotatsu.core.util.ext.isTargetNotEmpty
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.ramAvailable import org.koitharu.kotatsu.core.util.ext.ramAvailable
import org.koitharu.kotatsu.core.util.ext.withProgress import org.koitharu.kotatsu.core.util.ext.withProgress
import org.koitharu.kotatsu.core.util.progress.ProgressDeferred import org.koitharu.kotatsu.core.util.progress.ProgressDeferred
import org.koitharu.kotatsu.core.zip.ZipPool
import org.koitharu.kotatsu.local.data.PagesCache import org.koitharu.kotatsu.local.data.PagesCache
import org.koitharu.kotatsu.local.data.isCbzUri import org.koitharu.kotatsu.local.data.isCbzUri
import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaPage
@ -71,13 +73,12 @@ class PageLoader @Inject constructor(
val loaderScope = RetainedLifecycleCoroutineScope(lifecycle) + InternalErrorHandler() + Dispatchers.Default val loaderScope = RetainedLifecycleCoroutineScope(lifecycle) + InternalErrorHandler() + Dispatchers.Default
private val tasks = LongSparseArray<ProgressDeferred<File, Float>>() private val tasks = LongSparseArray<ProgressDeferred<Uri, Float>>()
private val semaphore = Semaphore(3) private val semaphore = Semaphore(3)
private val convertLock = Mutex() private val convertLock = Mutex()
private val prefetchLock = Mutex() private val prefetchLock = Mutex()
private var repository: MangaRepository? = null private var repository: MangaRepository? = null
private val prefetchQueue = LinkedList<MangaPage>() private val prefetchQueue = LinkedList<MangaPage>()
private val zipPool = ZipPool(2)
private val counter = AtomicInteger(0) private val counter = AtomicInteger(0)
private var prefetchQueueLimit = PREFETCH_LIMIT_DEFAULT // TODO adaptive private var prefetchQueueLimit = PREFETCH_LIMIT_DEFAULT // TODO adaptive
@ -85,7 +86,6 @@ class PageLoader @Inject constructor(
synchronized(tasks) { synchronized(tasks) {
tasks.clear() tasks.clear()
} }
zipPool.evictAll()
} }
fun isPrefetchApplicable(): Boolean { fun isPrefetchApplicable(): Boolean {
@ -113,7 +113,7 @@ class PageLoader @Inject constructor(
} }
} }
fun loadPageAsync(page: MangaPage, force: Boolean): ProgressDeferred<File, Float> { fun loadPageAsync(page: MangaPage, force: Boolean): ProgressDeferred<Uri, Float> {
var task = tasks[page.id]?.takeIf { it.isValid() } var task = tasks[page.id]?.takeIf { it.isValid() }
if (force) { if (force) {
task?.cancel() task?.cancel()
@ -127,7 +127,7 @@ class PageLoader @Inject constructor(
return task return task
} }
suspend fun loadPage(page: MangaPage, force: Boolean): File { suspend fun loadPage(page: MangaPage, force: Boolean): Uri {
return loadPageAsync(page, force).await() return loadPageAsync(page, force).await()
} }
@ -167,11 +167,11 @@ class PageLoader @Inject constructor(
} }
} }
private fun loadPageAsyncImpl(page: MangaPage, skipCache: Boolean): ProgressDeferred<File, Float> { private fun loadPageAsyncImpl(page: MangaPage, skipCache: Boolean): ProgressDeferred<Uri, Float> {
val progress = MutableStateFlow(PROGRESS_UNDEFINED) val progress = MutableStateFlow(PROGRESS_UNDEFINED)
val deferred = loaderScope.async { val deferred = loaderScope.async {
if (!skipCache) { if (!skipCache) {
cache.get(page.url)?.let { return@async it } cache.get(page.url)?.let { return@async it.toUri() }
} }
counter.incrementAndGet() counter.incrementAndGet()
try { try {
@ -195,26 +195,20 @@ class PageLoader @Inject constructor(
} }
} }
private suspend fun loadPageImpl(page: MangaPage, progress: MutableStateFlow<Float>): File = semaphore.withPermit { private suspend fun loadPageImpl(page: MangaPage, progress: MutableStateFlow<Float>): Uri = semaphore.withPermit {
val pageUrl = getPageUrl(page) val pageUrl = getPageUrl(page)
check(pageUrl.isNotBlank()) { "Cannot obtain full image url" } check(pageUrl.isNotBlank()) { "Cannot obtain full image url" }
val uri = Uri.parse(pageUrl) val uri = Uri.parse(pageUrl)
return if (isCbzUri(uri)) { return if (isCbzUri(uri)) {
runInterruptible(Dispatchers.IO) { uri.buildUpon().scheme(URI_SCHEME_ZIP).build()
zipPool[uri]
}.use {
cache.put(pageUrl, it)
}
} else { } else {
val request = createPageRequest(page, pageUrl) val request = createPageRequest(page, pageUrl)
imageProxyInterceptor.interceptPageRequest(request, okHttp).ensureSuccess().use { response -> imageProxyInterceptor.interceptPageRequest(request, okHttp).ensureSuccess().use { response ->
val body = checkNotNull(response.body) { val body = checkNotNull(response.body) { "Null response body" }
"Null response"
}
body.withProgress(progress).use { body.withProgress(progress).use {
cache.put(pageUrl, it.source()) cache.put(pageUrl, it.source())
} }
} }.toUri()
} }
} }
@ -222,9 +216,9 @@ class PageLoader @Inject constructor(
return context.ramAvailable <= FileSize.MEGABYTES.convert(PREFETCH_MIN_RAM_MB, FileSize.BYTES) return context.ramAvailable <= FileSize.MEGABYTES.convert(PREFETCH_MIN_RAM_MB, FileSize.BYTES)
} }
private fun Deferred<File>.isValid(): Boolean { private fun Deferred<Uri>.isValid(): Boolean {
return getCompletionResultOrNull()?.map { file -> return getCompletionResultOrNull()?.map { uri ->
file.exists() && file.isNotEmpty() uri.exists() && uri.isTargetNotEmpty()
}?.getOrDefault(false) ?: true }?.getOrDefault(false) ?: true
} }

@ -15,7 +15,8 @@ import okhttp3.HttpUrl.Companion.toHttpUrl
import okio.IOException import okio.IOException
import okio.buffer import okio.buffer
import okio.sink import okio.sink
import okio.source import org.koitharu.kotatsu.core.util.ext.source
import org.koitharu.kotatsu.core.util.ext.toFileOrNull
import org.koitharu.kotatsu.core.util.ext.writeAllCancellable import org.koitharu.kotatsu.core.util.ext.writeAllCancellable
import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.util.toFileNameSafe import org.koitharu.kotatsu.parsers.util.toFileNameSafe
@ -41,8 +42,8 @@ class PageSaveHelper @Inject constructor(
saveLauncher: ActivityResultLauncher<String>, saveLauncher: ActivityResultLauncher<String>,
): Uri { ): Uri {
val pageUrl = pageLoader.getPageUrl(page) val pageUrl = pageLoader.getPageUrl(page)
val pageFile = pageLoader.loadPage(page, force = false) val pageUri = pageLoader.loadPage(page, force = false)
val proposedName = getProposedFileName(pageUrl, pageFile) val proposedName = getProposedFileName(pageUrl, pageUri)
val destination = withContext(Dispatchers.Main) { val destination = withContext(Dispatchers.Main) {
suspendCancellableCoroutine { cont -> suspendCancellableCoroutine { cont ->
continuation = cont continuation = cont
@ -54,7 +55,7 @@ class PageSaveHelper @Inject constructor(
runInterruptible(Dispatchers.IO) { runInterruptible(Dispatchers.IO) {
contentResolver.openOutputStream(destination)?.sink()?.buffer() contentResolver.openOutputStream(destination)?.sink()?.buffer()
}?.use { output -> }?.use { output ->
pageFile.source().use { input -> pageUri.source().use { input ->
output.writeAllCancellable(input) output.writeAllCancellable(input)
} }
} ?: throw IOException("Output stream is null") } ?: throw IOException("Output stream is null")
@ -65,7 +66,7 @@ class PageSaveHelper @Inject constructor(
resume(uri) resume(uri)
} != null } != null
private suspend fun getProposedFileName(url: String, file: File): String { private suspend fun getProposedFileName(url: String, fileUri: Uri): String {
var name = if (url.startsWith("cbz://")) { var name = if (url.startsWith("cbz://")) {
requireNotNull(url.toUri().fragment) requireNotNull(url.toUri().fragment)
} else { } else {
@ -74,7 +75,7 @@ class PageSaveHelper @Inject constructor(
var extension = name.substringAfterLast('.', "") var extension = name.substringAfterLast('.', "")
name = name.substringBeforeLast('.') name = name.substringBeforeLast('.')
if (extension.length !in 2..4) { if (extension.length !in 2..4) {
val mimeType = getImageMimeType(file) val mimeType = fileUri.toFileOrNull()?.let { file -> getImageMimeType(file) }
extension = if (mimeType != null) { extension = if (mimeType != null) {
MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType) ?: EXTENSION_FALLBACK MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType) ?: EXTENSION_FALLBACK
} else { } else {

@ -1,6 +1,7 @@
package org.koitharu.kotatsu.reader.ui.pager package org.koitharu.kotatsu.reader.ui.pager
import android.net.Uri import android.net.Uri
import androidx.core.net.toFile
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.lifecycle.Observer import androidx.lifecycle.Observer
import com.davemorrissey.labs.subscaleview.DefaultOnImageEventListener import com.davemorrissey.labs.subscaleview.DefaultOnImageEventListener
@ -20,10 +21,10 @@ import kotlinx.coroutines.yield
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.os.NetworkState import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.toFileOrNull
import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.reader.domain.PageLoader import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.reader.ui.config.ReaderSettings import org.koitharu.kotatsu.reader.ui.config.ReaderSettings
import java.io.File
import java.io.IOException import java.io.IOException
class PageHolderDelegate( class PageHolderDelegate(
@ -38,7 +39,7 @@ class PageHolderDelegate(
var state = State.EMPTY var state = State.EMPTY
private set private set
private var job: Job? = null private var job: Job? = null
private var file: File? = null private var uri: Uri? = null
private var error: Throwable? = null private var error: Throwable? = null
init { init {
@ -87,15 +88,15 @@ class PageHolderDelegate(
fun onRecycle() { fun onRecycle() {
state = State.EMPTY state = State.EMPTY
file = null uri = null
error = null error = null
job?.cancel() job?.cancel()
} }
fun reload() { fun reload() {
if (state == State.SHOWN) { if (state == State.SHOWN) {
file?.let { uri?.let {
callback.onImageReady(it.toUri()) callback.onImageReady(it)
} }
} }
} }
@ -114,10 +115,10 @@ class PageHolderDelegate(
override fun onImageLoadError(e: Throwable) { override fun onImageLoadError(e: Throwable) {
e.printStackTraceDebug() e.printStackTraceDebug()
val file = this.file val uri = this.uri
error = e error = e
if (state == State.LOADED && e is IOException && file != null && file.exists()) { if (state == State.LOADED && e is IOException && uri != null && uri.toFileOrNull()?.exists() != false) {
tryConvert(file, e) tryConvert(uri, e)
} else { } else {
state = State.ERROR state = State.ERROR
callback.onError(e) callback.onError(e)
@ -131,12 +132,13 @@ class PageHolderDelegate(
callback.onConfigChanged() callback.onConfigChanged()
} }
private fun tryConvert(file: File, e: Exception) { private fun tryConvert(uri: Uri, e: Exception) {
val prevJob = job val prevJob = job
job = scope.launch { job = scope.launch {
prevJob?.join() prevJob?.join()
state = State.CONVERTING state = State.CONVERTING
try { try {
val file = uri.toFile()
loader.convertInPlace(file) loader.convertInPlace(file)
state = State.CONVERTED state = State.CONVERTED
callback.onImageReady(file.toUri()) callback.onImageReady(file.toUri())
@ -157,14 +159,14 @@ class PageHolderDelegate(
yield() yield()
try { try {
val task = loader.loadPageAsync(data, force) val task = loader.loadPageAsync(data, force)
file = coroutineScope { uri = coroutineScope {
val progressObserver = observeProgress(this, task.progressAsFlow()) val progressObserver = observeProgress(this, task.progressAsFlow())
val file = task.await() val file = task.await()
progressObserver.cancelAndJoin() progressObserver.cancelAndJoin()
file file
} }
state = State.LOADED state = State.LOADED
callback.onImageReady(checkNotNull(file).toUri()) callback.onImageReady(checkNotNull(uri))
} catch (e: CancellationException) { } catch (e: CancellationException) {
throw e throw e
} catch (e: Throwable) { } catch (e: Throwable) {

Loading…
Cancel
Save