diff --git a/app/build.gradle b/app/build.gradle index 64be18d7a..8a8199df8 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -82,7 +82,7 @@ afterEvaluate { } } dependencies { - implementation('com.github.KotatsuApp:kotatsu-parsers:79e1d59482') { + implementation('com.github.KotatsuApp:kotatsu-parsers:f80b586081') { exclude group: 'org.json', module: 'json' } @@ -93,7 +93,7 @@ dependencies { implementation 'androidx.appcompat:appcompat:1.7.0' implementation 'androidx.core:core-ktx:1.13.1' implementation 'androidx.activity:activity-ktx:1.9.3' - implementation 'androidx.fragment:fragment-ktx:1.8.4' + implementation 'androidx.fragment:fragment-ktx:1.8.5' implementation 'androidx.transition:transition-ktx:1.5.1' implementation 'androidx.collection:collection-ktx:1.4.4' implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.6' diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/resolve/DialogErrorObserver.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/resolve/DialogErrorObserver.kt index 1edf3b662..8d2e34b9f 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/resolve/DialogErrorObserver.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/resolve/DialogErrorObserver.kt @@ -8,6 +8,7 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.ui.dialog.ErrorDetailsDialog import org.koitharu.kotatsu.core.util.ext.getDisplayMessage +import org.koitharu.kotatsu.core.util.ext.isSerializable import org.koitharu.kotatsu.parsers.exception.ParseException class DialogErrorObserver( @@ -32,7 +33,7 @@ class DialogErrorObserver( dialogBuilder.setPositiveButton(ExceptionResolver.getResolveStringId(value), listener) } else if (value is ParseException) { val fm = fragmentManager - if (fm != null) { + if (fm != null && value.isSerializable()) { dialogBuilder.setPositiveButton(R.string.details) { _, _ -> ErrorDetailsDialog.show(fm, value, value.url) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/resolve/SnackbarErrorObserver.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/resolve/SnackbarErrorObserver.kt index e39897cfc..d5b55b750 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/resolve/SnackbarErrorObserver.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/resolve/SnackbarErrorObserver.kt @@ -7,6 +7,7 @@ import com.google.android.material.snackbar.Snackbar import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.ui.dialog.ErrorDetailsDialog import org.koitharu.kotatsu.core.util.ext.getDisplayMessage +import org.koitharu.kotatsu.core.util.ext.isSerializable import org.koitharu.kotatsu.main.ui.owners.BottomNavOwner import org.koitharu.kotatsu.parsers.exception.ParseException @@ -33,7 +34,7 @@ class SnackbarErrorObserver( } } else if (value is ParseException) { val fm = fragmentManager - if (fm != null) { + if (fm != null && value.isSerializable()) { snackbar.setAction(R.string.details) { ErrorDetailsDialog.show(fm, value, value.url) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/io/NullOutputStream.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/io/NullOutputStream.kt new file mode 100644 index 000000000..d02cdf97b --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/io/NullOutputStream.kt @@ -0,0 +1,13 @@ +package org.koitharu.kotatsu.core.io + +import java.io.OutputStream +import java.util.Objects + +class NullOutputStream : OutputStream() { + + override fun write(b: Int) = Unit + + override fun write(b: ByteArray, off: Int, len: Int) { + Objects.checkFromIndexSize(off, len, b.size) + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Bundle.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Bundle.kt index 3913abf87..e89ec9053 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Bundle.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Bundle.kt @@ -21,6 +21,10 @@ inline fun Bundle.getParcelableCompat(key: String): T? return BundleCompat.getParcelable(this, key, T::class.java) } +inline fun Bundle.requireParcelable(key: String): T = checkNotNull(getParcelableCompat(key)) { + "Parcelable of type \"${T::class.java.name}\" not found at \"$key\"" +} + inline fun Intent.getParcelableExtraCompat(key: String): T? { return IntentCompat.getParcelableExtra(this, key, T::class.java) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Throwable.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Throwable.kt index d46cb250f..1e43a3055 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Throwable.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Throwable.kt @@ -25,6 +25,7 @@ import org.koitharu.kotatsu.core.exceptions.UnsupportedFileException import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException import org.koitharu.kotatsu.core.exceptions.WrongPasswordException import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver +import org.koitharu.kotatsu.core.io.NullOutputStream import org.koitharu.kotatsu.parsers.ErrorMessages.FILTER_BOTH_LOCALE_GENRES_NOT_SUPPORTED import org.koitharu.kotatsu.parsers.ErrorMessages.FILTER_BOTH_STATES_GENRES_NOT_SUPPORTED import org.koitharu.kotatsu.parsers.ErrorMessages.FILTER_MULTIPLE_GENRES_NOT_SUPPORTED @@ -38,6 +39,7 @@ import org.koitharu.kotatsu.parsers.exception.TooManyRequestExceptions import org.koitharu.kotatsu.scrobbling.common.domain.ScrobblerAuthRequiredException import java.net.ConnectException import java.net.NoRouteToHostException +import java.io.ObjectOutputStream import java.net.SocketTimeoutException import java.net.UnknownHostException import java.util.Locale @@ -203,3 +205,9 @@ fun Throwable.isWebViewUnavailable(): Boolean { @Suppress("FunctionName") fun NoSpaceLeftException() = IOException(MSG_NO_SPACE_LEFT) + +fun Throwable.isSerializable() = runCatching { + val oos = ObjectOutputStream(NullOutputStream()) + oos.writeObject(this) + oos.flush() +}.isSuccess diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsErrorObserver.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsErrorObserver.kt index 482618a0a..745703330 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsErrorObserver.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsErrorObserver.kt @@ -8,6 +8,7 @@ import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver import org.koitharu.kotatsu.core.ui.dialog.ErrorDetailsDialog import org.koitharu.kotatsu.core.util.ext.getDisplayMessage import org.koitharu.kotatsu.core.util.ext.isNetworkError +import org.koitharu.kotatsu.core.util.ext.isSerializable import org.koitharu.kotatsu.parsers.exception.NotFoundException import org.koitharu.kotatsu.parsers.exception.ParseException @@ -38,7 +39,7 @@ class DetailsErrorObserver( value is ParseException -> { val fm = fragmentManager - if (fm != null) { + if (fm != null && value.isSerializable()) { snackbar.setAction(R.string.details) { ErrorDetailsDialog.show(fm, value, value.url) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadSlowdownDispatcher.kt b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadSlowdownDispatcher.kt index 9f2f2e3b2..4df33a736 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadSlowdownDispatcher.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadSlowdownDispatcher.kt @@ -1,16 +1,20 @@ package org.koitharu.kotatsu.download.ui.worker +import android.os.SystemClock import androidx.collection.MutableObjectLongMap import kotlinx.coroutines.delay import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.ParserMangaRepository import org.koitharu.kotatsu.parsers.model.MangaSource +import javax.inject.Inject +import javax.inject.Singleton -class DownloadSlowdownDispatcher( +@Singleton +class DownloadSlowdownDispatcher @Inject constructor( private val mangaRepositoryFactory: MangaRepository.Factory, - private val defaultDelay: Long, ) { private val timeMap = MutableObjectLongMap() + private val defaultDelay = 1_600L suspend fun delay(source: MangaSource) { val repo = mangaRepositoryFactory.create(source) as? ParserMangaRepository ?: return @@ -19,11 +23,11 @@ class DownloadSlowdownDispatcher( } val lastRequest = synchronized(timeMap) { val res = timeMap.getOrDefault(source, 0L) - timeMap[source] = System.currentTimeMillis() + timeMap[source] = SystemClock.elapsedRealtime() res } if (lastRequest != 0L) { - delay(lastRequest + defaultDelay - System.currentTimeMillis()) + delay(lastRequest + defaultDelay - SystemClock.elapsedRealtime()) } } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadWorker.kt b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadWorker.kt index ad70e34e4..d0884544e 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadWorker.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadWorker.kt @@ -101,6 +101,7 @@ class DownloadWorker @AssistedInject constructor( private val mangaRepositoryFactory: MangaRepository.Factory, private val settings: AppSettings, @LocalStorageChanges private val localStorageChanges: MutableSharedFlow, + private val slowdownDispatcher: DownloadSlowdownDispatcher, private val imageProxyInterceptor: ImageProxyInterceptor, notificationFactoryFactory: DownloadNotificationFactory.Factory, ) : CoroutineWorker(appContext, params) { @@ -108,7 +109,6 @@ class DownloadWorker @AssistedInject constructor( private val task = DownloadTask(params.inputData) private val notificationFactory = notificationFactoryFactory.create(uuid = params.id, isSilent = task.isSilent) private val notificationManager = appContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - private val slowdownDispatcher = DownloadSlowdownDispatcher(mangaRepositoryFactory, SLOWDOWN_DELAY) @Volatile private var lastPublishedState: DownloadState? = null @@ -535,7 +535,10 @@ class DownloadWorker @AssistedInject constructor( const val MAX_PAGES_PARALLELISM = 4 const val DOWNLOAD_ERROR_DELAY = 2_000L const val MAX_RETRY_DELAY = 7_200_000L // 2 hours - const val SLOWDOWN_DELAY = 200L + const val MANGA_ID = "manga_id" + const val CHAPTERS_IDS = "chapters" + const val IS_SILENT = "silent" + const val START_PAUSED = "paused" const val TAG = "download" } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/main/domain/CoverRestoreInterceptor.kt b/app/src/main/kotlin/org/koitharu/kotatsu/main/domain/CoverRestoreInterceptor.kt index 73cc2cb81..5e2a2e8cd 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/main/domain/CoverRestoreInterceptor.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/main/domain/CoverRestoreInterceptor.kt @@ -2,11 +2,8 @@ package org.koitharu.kotatsu.main.domain import androidx.collection.ArraySet import coil3.intercept.Interceptor -import coil3.network.HttpException import coil3.request.ErrorResult import coil3.request.ImageResult -import okio.FileNotFoundException -import org.jsoup.HttpStatusException import org.koitharu.kotatsu.bookmarks.domain.Bookmark import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository import org.koitharu.kotatsu.core.model.findById @@ -17,13 +14,10 @@ import org.koitharu.kotatsu.core.util.ext.bookmarkKey import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty import org.koitharu.kotatsu.core.util.ext.mangaKey import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug -import org.koitharu.kotatsu.parsers.exception.ParseException import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.util.runCatchingCancellable -import java.net.UnknownHostException import java.util.Collections import javax.inject.Inject -import javax.net.ssl.SSLException class CoverRestoreInterceptor @Inject constructor( private val dataRepository: MangaDataRepository, @@ -118,11 +112,6 @@ class CoverRestoreInterceptor @Inject constructor( } private fun Throwable.shouldRestore(): Boolean { - return this is HttpException - || this is HttpStatusException - || this is SSLException - || this is ParseException - || this is UnknownHostException - || this is FileNotFoundException + return this is Exception // any Exception but not Error } } 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 9de3dffdb..6ad489c49 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 @@ -53,6 +53,7 @@ import org.koitharu.kotatsu.core.util.ext.ramAvailable import org.koitharu.kotatsu.core.util.ext.use import org.koitharu.kotatsu.core.util.ext.withProgress import org.koitharu.kotatsu.core.util.progress.ProgressDeferred +import org.koitharu.kotatsu.download.ui.worker.DownloadSlowdownDispatcher import org.koitharu.kotatsu.local.data.PagesCache import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaSource @@ -78,6 +79,7 @@ class PageLoader @Inject constructor( private val settings: AppSettings, private val mangaRepositoryFactory: MangaRepository.Factory, private val imageProxyInterceptor: ImageProxyInterceptor, + private val downloadSlowdownDispatcher: DownloadSlowdownDispatcher, ) { val loaderScope = RetainedLifecycleCoroutineScope(lifecycle) + InternalErrorHandler() + Dispatchers.Default @@ -126,7 +128,7 @@ class PageLoader @Inject constructor( } else if (task?.isCancelled == false) { return task } - task = loadPageAsyncImpl(page, force) + task = loadPageAsyncImpl(page, skipCache = force, isPrefetch = false) synchronized(tasks) { tasks[page.id] = task } @@ -185,7 +187,7 @@ class PageLoader @Inject constructor( val page = prefetchQueue.pollFirst() ?: return@launch if (cache.get(page.url) == null) { synchronized(tasks) { - tasks[page.id] = loadPageAsyncImpl(page, false) + tasks[page.id] = loadPageAsyncImpl(page, skipCache = false, isPrefetch = true) } return@launch } @@ -193,7 +195,11 @@ class PageLoader @Inject constructor( } } - private fun loadPageAsyncImpl(page: MangaPage, skipCache: Boolean): ProgressDeferred { + private fun loadPageAsyncImpl( + page: MangaPage, + skipCache: Boolean, + isPrefetch: Boolean, + ): ProgressDeferred { val progress = MutableStateFlow(PROGRESS_UNDEFINED) val deferred = loaderScope.async { if (!skipCache) { @@ -201,7 +207,7 @@ class PageLoader @Inject constructor( } counter.incrementAndGet() try { - loadPageImpl(page, progress) + loadPageImpl(page, progress, isPrefetch) } finally { if (counter.decrementAndGet() == 0) { onIdle() @@ -221,7 +227,11 @@ class PageLoader @Inject constructor( } } - private suspend fun loadPageImpl(page: MangaPage, progress: MutableStateFlow): Uri = semaphore.withPermit { + private suspend fun loadPageImpl( + page: MangaPage, + progress: MutableStateFlow, + isPrefetch: Boolean, + ): Uri = semaphore.withPermit { val pageUrl = getPageUrl(page) check(pageUrl.isNotBlank()) { "Cannot obtain full image url for $page" } val uri = Uri.parse(pageUrl) @@ -234,6 +244,9 @@ class PageLoader @Inject constructor( uri.isFileUri() -> uri else -> { + if (isPrefetch) { + downloadSlowdownDispatcher.delay(page.source) + } val request = createPageRequest(pageUrl, page.source) imageProxyInterceptor.interceptPageRequest(request, okHttp).ensureSuccess().use { response -> response.requireBody().withProgress(progress).use { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/standard/PageHolder.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/standard/PageHolder.kt index 21a0d5639..e901d9079 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/standard/PageHolder.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/standard/PageHolder.kt @@ -17,6 +17,7 @@ import org.koitharu.kotatsu.core.os.NetworkState import org.koitharu.kotatsu.core.ui.widgets.ZoomControl import org.koitharu.kotatsu.core.util.ext.getDisplayMessage import org.koitharu.kotatsu.core.util.ext.isLowRamDevice +import org.koitharu.kotatsu.core.util.ext.isSerializable import org.koitharu.kotatsu.databinding.ItemPageBinding import org.koitharu.kotatsu.parsers.util.ifZero import org.koitharu.kotatsu.reader.domain.PageLoader @@ -154,6 +155,7 @@ open class PageHolder( bindingInfo.buttonRetry.setText( ExceptionResolver.getResolveStringId(e).ifZero { R.string.try_again }, ) + bindingInfo.buttonErrorDetails.isVisible = e.isSerializable() bindingInfo.layoutError.isVisible = true bindingInfo.progressBar.hide() } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonHolder.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonHolder.kt index e2629e4a1..31501e97d 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonHolder.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonHolder.kt @@ -11,6 +11,7 @@ import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver import org.koitharu.kotatsu.core.os.NetworkState import org.koitharu.kotatsu.core.util.GoneOnInvisibleListener import org.koitharu.kotatsu.core.util.ext.getDisplayMessage +import org.koitharu.kotatsu.core.util.ext.isSerializable import org.koitharu.kotatsu.databinding.ItemPageWebtoonBinding import org.koitharu.kotatsu.parsers.util.ifZero import org.koitharu.kotatsu.reader.domain.PageLoader @@ -128,6 +129,7 @@ class WebtoonHolder( bindingInfo.buttonRetry.setText( ExceptionResolver.getResolveStringId(e).ifZero { R.string.try_again }, ) + bindingInfo.buttonErrorDetails.isVisible = e.isSerializable() bindingInfo.layoutError.isVisible = true bindingInfo.progressBar.hide() }