From b599cb33ff071767d82f3176297d3051435a4c3c Mon Sep 17 00:00:00 2001 From: Koitharu Date: Fri, 11 Nov 2022 19:43:30 +0200 Subject: [PATCH] Improve pages loading #256 --- README.md | 2 +- app/src/main/AndroidManifest.xml | 1 + .../koitharu/kotatsu/core/zip/ZipOutput.kt | 2 +- .../download/domain/DownloadManager.kt | 8 +-- .../koitharu/kotatsu/local/data/PagesCache.kt | 63 +++++++++++-------- .../local/domain/importer/DirMangaImporter.kt | 6 +- .../local/domain/importer/ZipMangaImporter.kt | 13 ++-- .../kotatsu/reader/domain/PageLoader.kt | 15 ++--- .../kotatsu/reader/ui/PageSaveHelper.kt | 21 ++++--- .../reader/ui/pager/PageHolderDelegate.kt | 15 +++-- .../java/org/koitharu/kotatsu/utils/ext/IO.kt | 27 ++++++++ 11 files changed, 110 insertions(+), 63 deletions(-) create mode 100644 app/src/main/java/org/koitharu/kotatsu/utils/ext/IO.kt diff --git a/README.md b/README.md index a97f2a74c..599df4214 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ Download APK directly from GitHub: * Notifications about new chapters with updates feed * Shikimori integration (manga tracking) * Password/fingerprint protect access to the app -* History and favourites synchronization across devices (coming soon) +* History and favourites [synchronization](https://github.com/KotatsuApp/kotatsu-syncserver) across devices ### Screenshots diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 44357e3d1..4ca5eaf80 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -28,6 +28,7 @@ android:fullBackupOnly="true" android:icon="@mipmap/ic_launcher" android:label="@string/app_name" + android:largeHeap="true" android:networkSecurityConfig="@xml/network_security_config" android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" diff --git a/app/src/main/java/org/koitharu/kotatsu/core/zip/ZipOutput.kt b/app/src/main/java/org/koitharu/kotatsu/core/zip/ZipOutput.kt index d34e753ab..4a3dd8ed5 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/zip/ZipOutput.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/zip/ZipOutput.kt @@ -115,4 +115,4 @@ class ZipOutput( closeEntry() return true } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadManager.kt b/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadManager.kt index 1967bbb2a..931d09ae9 100644 --- a/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadManager.kt +++ b/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadManager.kt @@ -17,7 +17,6 @@ import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch -import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.withContext @@ -36,6 +35,7 @@ import org.koitharu.kotatsu.local.domain.LocalMangaRepository import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.util.await +import org.koitharu.kotatsu.utils.ext.copyToSuspending import org.koitharu.kotatsu.utils.ext.deleteAwait import org.koitharu.kotatsu.utils.ext.printStackTraceDebug import org.koitharu.kotatsu.utils.ext.referer @@ -219,10 +219,8 @@ class DownloadManager @AssistedInject constructor( val call = okHttp.newCall(request) val file = File(destination, tempFileName) val response = call.clone().await() - runInterruptible(Dispatchers.IO) { - file.outputStream().use { out -> - checkNotNull(response.body).byteStream().copyTo(out) - } + file.outputStream().use { out -> + checkNotNull(response.body).byteStream().copyToSuspending(out) } return file } diff --git a/app/src/main/java/org/koitharu/kotatsu/local/data/PagesCache.kt b/app/src/main/java/org/koitharu/kotatsu/local/data/PagesCache.kt index 82bbead60..fb24f496e 100644 --- a/app/src/main/java/org/koitharu/kotatsu/local/data/PagesCache.kt +++ b/app/src/main/java/org/koitharu/kotatsu/local/data/PagesCache.kt @@ -3,15 +3,21 @@ package org.koitharu.kotatsu.local.data import android.content.Context import com.tomclaw.cache.DiskLruCache import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.withContext +import org.koitharu.kotatsu.utils.FileSize +import org.koitharu.kotatsu.utils.ext.copyToSuspending +import org.koitharu.kotatsu.utils.ext.longHashCode +import org.koitharu.kotatsu.utils.ext.subdir +import org.koitharu.kotatsu.utils.ext.takeIfReadable import java.io.File import java.io.InputStream import javax.inject.Inject import javax.inject.Singleton -import kotlinx.coroutines.flow.MutableStateFlow -import org.koitharu.kotatsu.utils.FileSize -import org.koitharu.kotatsu.utils.ext.longHashCode -import org.koitharu.kotatsu.utils.ext.subdir -import org.koitharu.kotatsu.utils.ext.takeIfReadable @Singleton class PagesCache @Inject constructor(@ApplicationContext context: Context) { @@ -26,37 +32,44 @@ class PagesCache @Inject constructor(@ApplicationContext context: Context) { return lruCache.get(url)?.takeIfReadable() } - fun put(url: String, inputStream: InputStream): File { + suspend fun put(url: String, inputStream: InputStream): File = withContext(Dispatchers.IO) { val file = File(cacheDir, url.longHashCode().toString()) - file.outputStream().use { out -> - inputStream.copyTo(out) + try { + file.outputStream().use { out -> + inputStream.copyToSuspending(out) + } + lruCache.put(url, file) + } finally { + file.delete() } - val res = lruCache.put(url, file) - file.delete() - return res } - fun put( + suspend fun put( url: String, inputStream: InputStream, contentLength: Long, progress: MutableStateFlow, - ): File { + ): File = withContext(Dispatchers.IO) { + val job = currentCoroutineContext()[Job] val file = File(cacheDir, url.longHashCode().toString()) - file.outputStream().use { out -> - var bytesCopied: Long = 0 - val buffer = ByteArray(DEFAULT_BUFFER_SIZE) - var bytes = inputStream.read(buffer) - while (bytes >= 0) { - out.write(buffer, 0, bytes) - bytesCopied += bytes - publishProgress(contentLength, bytesCopied, progress) - bytes = inputStream.read(buffer) + try { + file.outputStream().use { out -> + var bytesCopied: Long = 0 + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + var bytes = inputStream.read(buffer) + while (bytes >= 0) { + out.write(buffer, 0, bytes) + bytesCopied += bytes + job?.ensureActive() + publishProgress(contentLength, bytesCopied, progress) + bytes = inputStream.read(buffer) + job?.ensureActive() + } } + lruCache.put(url, file) + } finally { + file.delete() } - val res = lruCache.put(url, file) - file.delete() - return res } private fun publishProgress(contentLength: Long, bytesCopied: Long, progress: MutableStateFlow) { diff --git a/app/src/main/java/org/koitharu/kotatsu/local/domain/importer/DirMangaImporter.kt b/app/src/main/java/org/koitharu/kotatsu/local/domain/importer/DirMangaImporter.kt index 956d19676..d569af6c3 100644 --- a/app/src/main/java/org/koitharu/kotatsu/local/domain/importer/DirMangaImporter.kt +++ b/app/src/main/java/org/koitharu/kotatsu/local/domain/importer/DirMangaImporter.kt @@ -4,7 +4,6 @@ import android.content.Context import android.net.Uri import android.webkit.MimeTypeMap import androidx.documentfile.provider.DocumentFile -import java.io.File import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.withContext import org.koitharu.kotatsu.local.data.LocalStorageManager @@ -14,8 +13,10 @@ import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.RATING_UNKNOWN +import org.koitharu.kotatsu.utils.ext.copyToSuspending import org.koitharu.kotatsu.utils.ext.deleteAwait import org.koitharu.kotatsu.utils.ext.longOf +import java.io.File // TODO: Add support for chapters in cbz // https://github.com/KotatsuApp/Kotatsu/issues/31 @@ -62,6 +63,7 @@ class DirMangaImporter( file.isDirectory -> { addPages(output, file, path + "/" + file.name, state) } + file.isFile -> { val tempFile = file.asTempFile() if (!state.hasCover) { @@ -86,7 +88,7 @@ class DirMangaImporter( "Cannot open input stream for $uri" }.use { input -> file.outputStream().use { output -> - input.copyTo(output) + input.copyToSuspending(output) } } return file diff --git a/app/src/main/java/org/koitharu/kotatsu/local/domain/importer/ZipMangaImporter.kt b/app/src/main/java/org/koitharu/kotatsu/local/domain/importer/ZipMangaImporter.kt index a60d8e39e..fdf24abd1 100644 --- a/app/src/main/java/org/koitharu/kotatsu/local/domain/importer/ZipMangaImporter.kt +++ b/app/src/main/java/org/koitharu/kotatsu/local/domain/importer/ZipMangaImporter.kt @@ -1,8 +1,6 @@ package org.koitharu.kotatsu.local.domain.importer import android.net.Uri -import java.io.File -import java.io.IOException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.withContext @@ -11,7 +9,10 @@ import org.koitharu.kotatsu.local.data.CbzFilter import org.koitharu.kotatsu.local.data.LocalStorageManager import org.koitharu.kotatsu.local.domain.LocalMangaRepository import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.utils.ext.copyToSuspending import org.koitharu.kotatsu.utils.ext.resolveName +import java.io.File +import java.io.IOException class ZipMangaImporter( storageManager: LocalStorageManager, @@ -27,10 +28,10 @@ class ZipMangaImporter( } val dest = File(getOutputDir(), name) runInterruptible { - contentResolver.openInputStream(uri)?.use { source -> - dest.outputStream().use { output -> - source.copyTo(output) - } + contentResolver.openInputStream(uri) + }?.use { source -> + dest.outputStream().use { output -> + source.copyToSuspending(output) } } ?: throw IOException("Cannot open input stream: $uri") localMangaRepository.getFromFile(dest) diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/domain/PageLoader.kt b/app/src/main/java/org/koitharu/kotatsu/reader/domain/PageLoader.kt index da3ad43ee..3c9da9ed8 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/domain/PageLoader.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/domain/PageLoader.kt @@ -179,9 +179,12 @@ class PageLoader @Inject constructor( val uri = Uri.parse(pageUrl) return if (uri.scheme == "cbz") { runInterruptible(Dispatchers.IO) { - val zip = ZipFile(uri.schemeSpecificPart) - val entry = zip.getEntry(uri.fragment) - zip.getInputStream(entry).use { + ZipFile(uri.schemeSpecificPart) + }.use { zip -> + runInterruptible(Dispatchers.IO) { + val entry = zip.getEntry(uri.fragment) + zip.getInputStream(entry) + }.use { cache.put(pageUrl, it) } } @@ -200,10 +203,8 @@ class PageLoader @Inject constructor( val body = checkNotNull(response.body) { "Null response" } - runInterruptible(Dispatchers.IO) { - body.byteStream().use { - cache.put(pageUrl, it, body.contentLength(), progress) - } + body.byteStream().use { + cache.put(pageUrl, it, body.contentLength(), progress) } } } diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/PageSaveHelper.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/PageSaveHelper.kt index 17b95bf05..4fdeeab85 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/PageSaveHelper.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/PageSaveHelper.kt @@ -6,10 +6,6 @@ import android.webkit.MimeTypeMap import androidx.activity.result.ActivityResultLauncher import androidx.core.net.toUri import dagger.hilt.android.qualifiers.ApplicationContext -import java.io.File -import javax.inject.Inject -import kotlin.coroutines.Continuation -import kotlin.coroutines.resume import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.suspendCancellableCoroutine @@ -20,6 +16,11 @@ import org.koitharu.kotatsu.base.domain.MangaDataRepository import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.util.toFileNameSafe import org.koitharu.kotatsu.reader.domain.PageLoader +import org.koitharu.kotatsu.utils.ext.copyToSuspending +import java.io.File +import javax.inject.Inject +import kotlin.coroutines.Continuation +import kotlin.coroutines.resume private const val MAX_FILENAME_LENGTH = 10 private const val EXTENSION_FALLBACK = "png" @@ -48,12 +49,12 @@ class PageSaveHelper @Inject constructor( } } runInterruptible(Dispatchers.IO) { - contentResolver.openOutputStream(destination)?.use { output -> - pageFile.inputStream().use { input -> - input.copyTo(output) - } - } ?: throw IOException("Output stream is null") - } + contentResolver.openOutputStream(destination) + }?.use { output -> + pageFile.inputStream().use { input -> + input.copyToSuspending(output) + } + } ?: throw IOException("Output stream is null") return destination } diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/PageHolderDelegate.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/PageHolderDelegate.kt index fa5aaaa22..5b625fdb2 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/PageHolderDelegate.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/PageHolderDelegate.kt @@ -9,6 +9,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.launchIn @@ -118,18 +119,20 @@ class PageHolderDelegate( } } - private suspend fun CoroutineScope.doLoad(data: MangaPage, force: Boolean) { + private suspend fun doLoad(data: MangaPage, force: Boolean) { state = State.LOADING error = null callback.onLoadingStarted() try { val task = loader.loadPageAsync(data, force) - val progressObserver = observeProgress(this, task.progressAsFlow()) - val file = task.await() - progressObserver.cancel() - this@PageHolderDelegate.file = file + file = coroutineScope { + val progressObserver = observeProgress(this, task.progressAsFlow()) + val file = task.await() + progressObserver.cancel() + file + } state = State.LOADED - callback.onImageReady(file.toUri()) + callback.onImageReady(checkNotNull(file).toUri()) } catch (e: CancellationException) { throw e } catch (e: Throwable) { diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/IO.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/IO.kt new file mode 100644 index 000000000..500f15342 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/IO.kt @@ -0,0 +1,27 @@ +package org.koitharu.kotatsu.utils.ext + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.withContext +import java.io.InputStream +import java.io.OutputStream + +suspend fun InputStream.copyToSuspending( + out: OutputStream, + bufferSize: Int = DEFAULT_BUFFER_SIZE +): Long = withContext(Dispatchers.IO) { + val job = currentCoroutineContext()[Job] + var bytesCopied: Long = 0 + val buffer = ByteArray(bufferSize) + var bytes = read(buffer) + while (bytes >= 0) { + out.write(buffer, 0, bytes) + bytesCopied += bytes + job?.ensureActive() + bytes = read(buffer) + job?.ensureActive() + } + bytesCopied +}