Load local manga pages directly #552

This commit is contained in:
Koitharu
2023-11-24 18:27:35 +02:00
parent 0c839ce49a
commit 880dd6da27
9 changed files with 94 additions and 78 deletions

View File

@@ -19,8 +19,8 @@ import java.nio.file.attribute.BasicFileAttributes
import java.util.zip.ZipEntry
import java.util.zip.ZipFile
import kotlin.io.path.ExperimentalPathApi
import kotlin.io.path.walk
import kotlin.io.path.readAttributes
import kotlin.io.path.walk
fun File.subdir(name: String) = File(this, name).also {
if (!it.exists()) it.mkdirs()
@@ -50,7 +50,7 @@ fun File.getStorageName(context: Context): String = runCatching {
}
}.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) {
delete() || deleteRecursively()

View File

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

View File

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

View File

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

View File

@@ -14,6 +14,7 @@ import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.withContext
import okhttp3.Cache
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.getStorageName
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) {
if (uri.scheme == "file") {
if (uri.scheme == URI_SCHEME_FILE) {
uri.toFile()
} else {
uri.resolveFile(context)

View File

@@ -7,6 +7,7 @@ import android.net.Uri
import androidx.annotation.AnyThread
import androidx.collection.LongSparseArray
import androidx.collection.set
import androidx.core.net.toUri
import dagger.hilt.android.ActivityRetainedLifecycle
import dagger.hilt.android.lifecycle.RetainedLifecycle
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.util.FileSize
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.exists
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.isTargetNotEmpty
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.ramAvailable
import org.koitharu.kotatsu.core.util.ext.withProgress
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.isCbzUri
import org.koitharu.kotatsu.parsers.model.MangaPage
@@ -71,13 +73,12 @@ class PageLoader @Inject constructor(
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 convertLock = Mutex()
private val prefetchLock = Mutex()
private var repository: MangaRepository? = null
private val prefetchQueue = LinkedList<MangaPage>()
private val zipPool = ZipPool(2)
private val counter = AtomicInteger(0)
private var prefetchQueueLimit = PREFETCH_LIMIT_DEFAULT // TODO adaptive
@@ -85,7 +86,6 @@ class PageLoader @Inject constructor(
synchronized(tasks) {
tasks.clear()
}
zipPool.evictAll()
}
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() }
if (force) {
task?.cancel()
@@ -127,7 +127,7 @@ class PageLoader @Inject constructor(
return task
}
suspend fun loadPage(page: MangaPage, force: Boolean): File {
suspend fun loadPage(page: MangaPage, force: Boolean): Uri {
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 deferred = loaderScope.async {
if (!skipCache) {
cache.get(page.url)?.let { return@async it }
cache.get(page.url)?.let { return@async it.toUri() }
}
counter.incrementAndGet()
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)
check(pageUrl.isNotBlank()) { "Cannot obtain full image url" }
val uri = Uri.parse(pageUrl)
return if (isCbzUri(uri)) {
runInterruptible(Dispatchers.IO) {
zipPool[uri]
}.use {
cache.put(pageUrl, it)
}
uri.buildUpon().scheme(URI_SCHEME_ZIP).build()
} else {
val request = createPageRequest(page, pageUrl)
imageProxyInterceptor.interceptPageRequest(request, okHttp).ensureSuccess().use { response ->
val body = checkNotNull(response.body) {
"Null response"
}
val body = checkNotNull(response.body) { "Null response body" }
body.withProgress(progress).use {
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)
}
private fun Deferred<File>.isValid(): Boolean {
return getCompletionResultOrNull()?.map { file ->
file.exists() && file.isNotEmpty()
private fun Deferred<Uri>.isValid(): Boolean {
return getCompletionResultOrNull()?.map { uri ->
uri.exists() && uri.isTargetNotEmpty()
}?.getOrDefault(false) ?: true
}

View File

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

View File

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