diff --git a/app/build.gradle b/app/build.gradle index f5d16c7ac..3e24f319c 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -16,8 +16,8 @@ android { applicationId 'org.koitharu.kotatsu' minSdk = 21 targetSdk = 34 - versionCode = 598 - versionName = '6.3.1' + versionCode = 599 + versionName = '6.3.2' generatedDensities = [] testInstrumentationRunner "org.koitharu.kotatsu.HiltTestRunner" ksp { @@ -134,7 +134,7 @@ dependencies { implementation 'io.coil-kt:coil-base: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 'io.noties.markwon:core:4.6.2' diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/File.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/File.kt index 444cdc101..77afae296 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/File.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/File.kt @@ -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() diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Uri.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Uri.kt new file mode 100644 index 000000000..f6db7443e --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Uri.kt @@ -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") +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/zip/ZipPool.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/zip/ZipPool.kt deleted file mode 100644 index 13f2ba8ca..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/zip/ZipPool.kt +++ /dev/null @@ -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(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() - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/CbzFilter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/CbzFilter.kt index dd044a9f9..225840bed 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/CbzFilter.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/CbzFilter.kt @@ -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) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/LocalStorageManager.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/LocalStorageManager.kt index c6a81858d..c5001aece 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/LocalStorageManager.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/LocalStorageManager.kt @@ -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) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/PageLoader.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/PageLoader.kt index 1f4fa7761..1f13a10d1 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/PageLoader.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/PageLoader.kt @@ -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>() + private val tasks = LongSparseArray>() private val semaphore = Semaphore(3) private val convertLock = Mutex() private val prefetchLock = Mutex() private var repository: MangaRepository? = null private val prefetchQueue = LinkedList() - 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 { + fun loadPageAsync(page: MangaPage, force: Boolean): ProgressDeferred { 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 { + private fun loadPageAsyncImpl(page: MangaPage, skipCache: Boolean): ProgressDeferred { 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): File = semaphore.withPermit { + private suspend fun loadPageImpl(page: MangaPage, progress: MutableStateFlow): 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.isValid(): Boolean { - return getCompletionResultOrNull()?.map { file -> - file.exists() && file.isNotEmpty() + private fun Deferred.isValid(): Boolean { + return getCompletionResultOrNull()?.map { uri -> + uri.exists() && uri.isTargetNotEmpty() }?.getOrDefault(false) ?: true } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/PageSaveHelper.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/PageSaveHelper.kt index 0b75aedaf..b47a662a9 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/PageSaveHelper.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/PageSaveHelper.kt @@ -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, ): 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 { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/PageHolderDelegate.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/PageHolderDelegate.kt index 5f095dbcb..9c544deea 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/PageHolderDelegate.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/PageHolderDelegate.kt @@ -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) {