Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
af103aa
Fix typo: serizliation -> serialization in Gradle configuration
AgentKush Jan 21, 2026
388ed31
Fix: Implement info menu action in ReaderMenuProvider
AgentKush Jan 21, 2026
67e2bc9
Fix: Add documentation for page count mismatch handling in ReaderView…
AgentKush Jan 21, 2026
0733353
Fix: Replace internal PlatformRegistry API with OkHttp.initialize
AgentKush Jan 21, 2026
129bef3
Implement TODO items: onTrimMemory and TIP spacing
AgentKush Jan 21, 2026
84f7698
Implement adaptive prefetch queue limit based on RAM, network, and po…
AgentKush Jan 21, 2026
aafde60
Localize WebView proxy unsupported error message
AgentKush Jan 21, 2026
0df96fa
Clean up stale TODO/FIXME comments and improve code documentation
AgentKush Jan 21, 2026
17e87f1
Implement adblock CSS element hiding and domain modifiers
AgentKush Jan 21, 2026
e4ef5bc
Implement directory sharing support for local manga
AgentKush Jan 21, 2026
f95e139
Improve UX for missing chapters and clean up remaining TODOs
AgentKush Jan 21, 2026
ac2e165
Update kotatsu-parsers to v1.2
AgentKush Jan 22, 2026
42510b3
Add configurable JS timeout for WebView evaluation
AgentKush Jan 22, 2026
74dba9f
Add configurable compression format options to BitmapWrapper
AgentKush Jan 22, 2026
c906f70
Add persistent blacklist with TTL to MirrorSwitcher
AgentKush Jan 22, 2026
f4dc2ec
Add related manga support for external plugins
AgentKush Jan 22, 2026
4088fb9
Add exponential backoff retry to RateLimitInterceptor
AgentKush Jan 22, 2026
1d56aa7
Add persistent blacklist for image proxy interceptors
AgentKush Jan 22, 2026
e529452
Add configurable TTL for memory content caches
AgentKush Jan 22, 2026
098c31b
Add configurable parallel chapter checking for tracker
AgentKush Jan 22, 2026
0c9ebd9
feat: Implement Scrobbler Offline Queue (#5)
AgentKush Jan 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions app/src/main/kotlin/org/koitharu/kotatsu/core/BaseApp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import dagger.hilt.android.HiltAndroidApp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.launch
import okhttp3.internal.platform.PlatformRegistry
import okhttp3.OkHttp
import org.acra.ACRA
import org.acra.ReportField
import org.acra.config.dialog
Expand Down Expand Up @@ -75,7 +75,7 @@ open class BaseApp : Application(), Configuration.Provider {

override fun onCreate() {
super.onCreate()
PlatformRegistry.applicationContext = this // TODO replace with OkHttp.initialize
OkHttp.initialize(this)
if (ACRA.isACRASenderServiceProcess()) {
return
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package org.koitharu.kotatsu.core.cache
import android.app.Application
import android.content.ComponentCallbacks2
import android.content.res.Configuration
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.ext.isLowRamDevice
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaPage
Expand All @@ -11,16 +12,41 @@ import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Singleton

/**
* In-memory cache for manga content with configurable TTL.
*
* Cache TTL settings can be configured in AppSettings:
* - Details cache: default 5 minutes (configurable 1-60 min)
* - Pages cache: default 10 minutes (configurable 1-120 min)
* - Related manga cache: default 10 minutes (configurable 1-120 min)
*
* Note: Changes to TTL settings require app restart to take effect.
*/
@Singleton
class MemoryContentCache @Inject constructor(application: Application) : ComponentCallbacks2 {
class MemoryContentCache @Inject constructor(
application: Application,
settings: AppSettings,
) : ComponentCallbacks2 {

private val isLowRam = application.isLowRamDevice()

private val detailsCache = ExpiringLruCache<SafeDeferred<Manga>>(if (isLowRam) 1 else 4, 5, TimeUnit.MINUTES)
private val pagesCache =
ExpiringLruCache<SafeDeferred<List<MangaPage>>>(if (isLowRam) 1 else 4, 10, TimeUnit.MINUTES)
private val relatedMangaCache =
ExpiringLruCache<SafeDeferred<List<Manga>>>(if (isLowRam) 1 else 3, 10, TimeUnit.MINUTES)
private val detailsCache = ExpiringLruCache<SafeDeferred<Manga>>(
maxSize = if (isLowRam) 1 else 4,
lifetime = settings.cacheDetailsTtlMinutes.toLong(),
timeUnit = TimeUnit.MINUTES,
)

private val pagesCache = ExpiringLruCache<SafeDeferred<List<MangaPage>>>(
maxSize = if (isLowRam) 1 else 4,
lifetime = settings.cachePagesTtlMinutes.toLong(),
timeUnit = TimeUnit.MINUTES,
)

private val relatedMangaCache = ExpiringLruCache<SafeDeferred<List<Manga>>>(
maxSize = if (isLowRam) 1 else 3,
lifetime = settings.cacheRelatedTtlMinutes.toLong(),
timeUnit = TimeUnit.MINUTES,
)

init {
application.registerComponentCallbacks(this)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package org.koitharu.kotatsu.core.exceptions

/**
* Exception thrown when the device's WebView implementation does not support proxy configuration.
* This typically occurs on older Android versions or devices with outdated WebView providers.
*/
class ProxyWebViewUnsupportedException : IllegalStateException("Proxy for WebView is not supported on this device")
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,8 @@ class CaptchaHandler @Inject constructor(
notify = request.extras[suppressCaptchaKey] != true,
)
) {
coilProvider.get().enqueue(request) // TODO check if ok
// Retry the failed request now that captcha is resolved
coilProvider.get().enqueue(request)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
import org.koitharu.kotatsu.core.exceptions.EmptyMangaException
import org.koitharu.kotatsu.core.exceptions.InteractiveActionRequiredException
import org.koitharu.kotatsu.core.exceptions.ProxyConfigException
import org.koitharu.kotatsu.core.exceptions.ProxyWebViewUnsupportedException
import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException
import org.koitharu.kotatsu.core.nav.AppRouter
import org.koitharu.kotatsu.core.nav.router
Expand Down
38 changes: 35 additions & 3 deletions app/src/main/kotlin/org/koitharu/kotatsu/core/nav/AppRouter.kt
Original file line number Diff line number Diff line change
Expand Up @@ -640,16 +640,45 @@ class AppRouter private constructor(
.startChooser()
}

private fun shareFile(file: File) { // TODO directory sharing support
private fun shareFile(file: File) {
val context = contextOrNull() ?: return
if (file.isDirectory) {
shareDirectory(file, context)
} else {
shareSingleFile(file, context)
}
}

private fun shareSingleFile(file: File, context: Context) {
val mimeType = when {
file.extension.equals("cbz", ignoreCase = true) -> TYPE_CBZ
file.extension.equals("cbr", ignoreCase = true) -> TYPE_CBR
file.extension.equals("zip", ignoreCase = true) -> TYPE_ZIP
else -> TYPE_OCTET_STREAM
}
val intentBuilder = ShareCompat.IntentBuilder(context)
.setType(TYPE_CBZ)
.setType(mimeType)
val uri = FileProvider.getUriForFile(context, "${BuildConfig.APPLICATION_ID}.files", file)
intentBuilder.addStream(uri)
intentBuilder.setChooserTitle(context.getString(R.string.share_s, file.name))
intentBuilder.startChooser()
}

private fun shareDirectory(directory: File, context: Context) {
val files = directory.listFiles { f -> f.isFile && !f.isHidden }
if (files.isNullOrEmpty()) {
return
}
val intentBuilder = ShareCompat.IntentBuilder(context)
.setType(TYPE_IMAGE)
for (file in files) {
val uri = FileProvider.getUriForFile(context, "${BuildConfig.APPLICATION_ID}.files", file)
intentBuilder.addStream(uri)
}
intentBuilder.setChooserTitle(context.getString(R.string.share_s, directory.name))
intentBuilder.startChooser()
}

@UiContext
private fun contextOrNull(): Context? = activity ?: fragment?.context

Expand Down Expand Up @@ -854,8 +883,11 @@ class AppRouter private constructor(
private const val TYPE_TEXT = "text/plain"
private const val TYPE_IMAGE = "image/*"
private const val TYPE_CBZ = "application/x-cbz"
private const val TYPE_CBR = "application/x-cbr"
private const val TYPE_ZIP = "application/zip"
private const val TYPE_OCTET_STREAM = "application/octet-stream"

private fun Class<out Fragment>.fragmentTag() = name // TODO
private fun Class<out Fragment>.fragmentTag() = name

private inline fun <reified F : Fragment> fragmentTag() = F::class.java.fragmentTag()
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,29 +1,115 @@
package org.koitharu.kotatsu.core.network

import android.util.Log
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import okhttp3.Interceptor
import okhttp3.Response
import okhttp3.internal.closeQuietly
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.parsers.exception.TooManyRequestExceptions
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
import java.util.concurrent.TimeUnit
import kotlin.math.min
import kotlin.random.Random

/**
* Interceptor that handles HTTP 429 (Too Many Requests) responses with exponential backoff.
*
* Features:
* - Configurable max retry attempts
* - Exponential backoff with jitter to prevent thundering herd
* - Respects Retry-After header when present
* - Falls back to calculated delay when header is missing
*
* @param maxRetries Maximum number of retry attempts (default: 3)
* @param initialDelayMs Initial delay in milliseconds (default: 1000ms)
* @param maxDelayMs Maximum delay cap in milliseconds (default: 30000ms)
* @param backoffMultiplier Multiplier for exponential growth (default: 2.0)
*/
class RateLimitInterceptor(
private val maxRetries: Int = DEFAULT_MAX_RETRIES,
private val initialDelayMs: Long = DEFAULT_INITIAL_DELAY_MS,
private val maxDelayMs: Long = DEFAULT_MAX_DELAY_MS,
private val backoffMultiplier: Double = DEFAULT_BACKOFF_MULTIPLIER,
) : Interceptor {

class RateLimitInterceptor : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val response = chain.proceed(chain.request())
var response = chain.proceed(chain.request())
var retryCount = 0

while (response.code == 429 && retryCount < maxRetries) {
val request = response.request
val retryAfterMs = response.header(CommonHeaders.RETRY_AFTER)?.parseRetryAfter()
response.closeQuietly()

// Calculate delay: use Retry-After header if available, otherwise exponential backoff
val calculatedDelay = calculateBackoffDelay(retryCount)
val delayMs = retryAfterMs?.coerceAtMost(maxDelayMs) ?: calculatedDelay

logDebug { "Rate limited (429) on ${request.url.host}, retry ${retryCount + 1}/$maxRetries after ${delayMs}ms" }

// Wait before retrying
runBlocking {
delay(delayMs)
}

retryCount++
response = chain.proceed(request)
}

// If still rate limited after all retries, throw exception
if (response.code == 429) {
val request = response.request
val retryAfter = response.header(CommonHeaders.RETRY_AFTER)?.parseRetryAfter() ?: 0L
response.closeQuietly()
logDebug { "Rate limit exceeded after $maxRetries retries on ${request.url}" }
throw TooManyRequestExceptions(
url = request.url.toString(),
retryAfter = response.header(CommonHeaders.RETRY_AFTER)?.parseRetryAfter() ?: 0L,
retryAfter = retryAfter,
)
}

return response
}

private fun String.parseRetryAfter(): Long {
return toLongOrNull()?.let { TimeUnit.SECONDS.toMillis(it) }
?: ZonedDateTime.parse(this, DateTimeFormatter.RFC_1123_DATE_TIME).toInstant().toEpochMilli()
/**
* Calculate exponential backoff delay with jitter.
* Formula: min(maxDelay, initialDelay * (multiplier ^ retryCount)) + random jitter
*/
private fun calculateBackoffDelay(retryCount: Int): Long {
val exponentialDelay = initialDelayMs * Math.pow(backoffMultiplier, retryCount.toDouble()).toLong()
val cappedDelay = min(exponentialDelay, maxDelayMs)
// Add jitter (±25%) to prevent thundering herd
val jitter = (cappedDelay * 0.25 * (Random.nextDouble() - 0.5)).toLong()
return (cappedDelay + jitter).coerceAtLeast(initialDelayMs)
}

private fun String.parseRetryAfter(): Long? {
// Try parsing as seconds first
toLongOrNull()?.let { seconds ->
return TimeUnit.SECONDS.toMillis(seconds)
}
// Try parsing as HTTP date
return runCatching {
val dateTime = ZonedDateTime.parse(this, DateTimeFormatter.RFC_1123_DATE_TIME)
val delayMs = dateTime.toInstant().toEpochMilli() - System.currentTimeMillis()
delayMs.coerceAtLeast(0L)
}.getOrNull()
}

private inline fun logDebug(message: () -> String) {
if (BuildConfig.DEBUG) {
Log.d(TAG, message())
}
}

companion object {
private const val TAG = "RateLimitInterceptor"
private const val DEFAULT_MAX_RETRIES = 3
private const val DEFAULT_INITIAL_DELAY_MS = 1000L
private const val DEFAULT_MAX_DELAY_MS = 30_000L
private const val DEFAULT_BACKOFF_MULTIPLIER = 2.0
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package org.koitharu.kotatsu.core.network.imageproxy

import android.util.Log
import androidx.collection.ArraySet
import coil3.intercept.Interceptor
import coil3.network.HttpException
import coil3.request.ErrorResult
Expand All @@ -21,11 +20,15 @@ import org.koitharu.kotatsu.parsers.util.await
import org.koitharu.kotatsu.parsers.util.isHttpOrHttps
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import java.net.HttpURLConnection
import java.util.Collections

abstract class BaseImageProxyInterceptor : ImageProxyInterceptor {

private val blacklist = Collections.synchronizedSet(ArraySet<String>())
/**
* Base class for image proxy interceptors with persistent blacklist support.
*
* @param blacklistManager Manager for persistent host blacklisting (optional for backward compatibility)
*/
abstract class BaseImageProxyInterceptor(
private val blacklistManager: ProxyBlacklistManager? = null,
) : ImageProxyInterceptor {

final override suspend fun intercept(chain: Interceptor.Chain): ImageResult {
val request = chain.request
Expand All @@ -34,7 +37,7 @@ abstract class BaseImageProxyInterceptor : ImageProxyInterceptor {
is String -> data.toHttpUrlOrNull()
else -> null
}
if (url == null || !url.isHttpOrHttps || url.host in blacklist) {
if (url == null || !url.isHttpOrHttps || isBlacklisted(url.host)) {
return chain.proceed()
}
val newRequest = onInterceptImageRequest(request, url)
Expand All @@ -44,7 +47,7 @@ abstract class BaseImageProxyInterceptor : ImageProxyInterceptor {
logDebug(result.throwable, newRequest.data)
chain.proceed().also {
if (it is SuccessResult && result.throwable.isBlockedByServer()) {
blacklist.add(url.host)
addToBlacklist(url.host)
}
}
}
Expand All @@ -59,7 +62,7 @@ abstract class BaseImageProxyInterceptor : ImageProxyInterceptor {
logDebug(error, newRequest.url)
okHttp.doCall(request).also {
if (error.isBlockedByServer()) {
blacklist.add(request.url.host)
addToBlacklist(request.url.host)
}
}
}.getOrThrow()
Expand All @@ -69,6 +72,14 @@ abstract class BaseImageProxyInterceptor : ImageProxyInterceptor {

protected abstract suspend fun onInterceptPageRequest(request: Request): Request

private fun isBlacklisted(host: String): Boolean {
return blacklistManager?.isBlacklisted(host) == true
}

private fun addToBlacklist(host: String) {
blacklistManager?.addToBlacklist(host)
}

private suspend fun OkHttpClient.doCall(request: Request): Response {
return newCall(request).await().ensureSuccess()
}
Expand Down
Loading