Load local manga pages directly #552
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
fun hasCbzExtension(file: File) = isCbzExtension(file.name)
|
||||
fun hasCbzExtension(file: File) = isCbzExtension(file.extension)
|
||||
|
||||
fun isCbzUri(uri: Uri) = isCbzExtension(uri.scheme)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user