Merge branch 'master' into devel

This commit is contained in:
Koitharu
2024-11-04 16:30:54 +02:00
13 changed files with 69 additions and 28 deletions

View File

@@ -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'

View File

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

View File

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

View File

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

View File

@@ -21,6 +21,10 @@ inline fun <reified T : Parcelable> Bundle.getParcelableCompat(key: String): T?
return BundleCompat.getParcelable(this, key, T::class.java)
}
inline fun <reified T : Parcelable> Bundle.requireParcelable(key: String): T = checkNotNull(getParcelableCompat(key)) {
"Parcelable of type \"${T::class.java.name}\" not found at \"$key\""
}
inline fun <reified T : Parcelable> Intent.getParcelableExtraCompat(key: String): T? {
return IntentCompat.getParcelableExtra(this, key, T::class.java)
}

View File

@@ -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

View File

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

View File

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

View File

@@ -101,6 +101,7 @@ class DownloadWorker @AssistedInject constructor(
private val mangaRepositoryFactory: MangaRepository.Factory,
private val settings: AppSettings,
@LocalStorageChanges private val localStorageChanges: MutableSharedFlow<LocalManga?>,
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"
}
}

View File

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

View File

@@ -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<Uri, Float> {
private fun loadPageAsyncImpl(
page: MangaPage,
skipCache: Boolean,
isPrefetch: Boolean,
): ProgressDeferred<Uri, Float> {
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<Float>): Uri = semaphore.withPermit {
private suspend fun loadPageImpl(
page: MangaPage,
progress: MutableStateFlow<Float>,
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 {

View File

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

View File

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