diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/network/MirrorSwitchInterceptor.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/network/MirrorSwitchInterceptor.kt deleted file mode 100644 index 04c67cd05..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/network/MirrorSwitchInterceptor.kt +++ /dev/null @@ -1,162 +0,0 @@ -package org.koitharu.kotatsu.core.network - -import androidx.collection.ArraySet -import dagger.Lazy -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.runInterruptible -import okhttp3.Interceptor -import okhttp3.Request -import okhttp3.Response -import okhttp3.ResponseBody -import okhttp3.ResponseBody.Companion.toResponseBody -import okhttp3.internal.canParseAsIpAddress -import okhttp3.internal.closeQuietly -import okhttp3.internal.publicsuffix.PublicSuffixDatabase -import org.koitharu.kotatsu.core.parser.MangaRepository -import org.koitharu.kotatsu.core.parser.ParserMangaRepository -import org.koitharu.kotatsu.core.prefs.AppSettings -import org.koitharu.kotatsu.parsers.model.MangaParserSource -import org.koitharu.kotatsu.parsers.model.MangaSource -import java.util.EnumMap -import javax.inject.Inject -import javax.inject.Singleton - -@Singleton -class MirrorSwitchInterceptor @Inject constructor( - private val mangaRepositoryFactoryLazy: Lazy, - private val settings: AppSettings, -) : Interceptor { - - private val locks = EnumMap(MangaParserSource::class.java) - private val blacklist = EnumMap>(MangaParserSource::class.java) - - val isEnabled: Boolean - get() = settings.isMirrorSwitchingAvailable - - override fun intercept(chain: Interceptor.Chain): Response { - val request = chain.request() - if (!isEnabled) { - return chain.proceed(request) - } - return try { - val response = chain.proceed(request) - if (response.isFailed) { - val responseCopy = response.copy() - response.closeQuietly() - trySwitchMirror(request, chain)?.also { - responseCopy.closeQuietly() - } ?: responseCopy - } else { - response - } - } catch (e: Exception) { - trySwitchMirror(request, chain) ?: throw e - } - } - - suspend fun trySwitchMirror(repository: ParserMangaRepository): Boolean = runInterruptible(Dispatchers.Default) { - if (!isEnabled) { - return@runInterruptible false - } - val mirrors = repository.getAvailableMirrors() - if (mirrors.size <= 1) { - return@runInterruptible false - } - synchronized(obtainLock(repository.source)) { - val currentMirror = repository.domain - if (currentMirror !in mirrors) { - return@synchronized false - } - addToBlacklist(repository.source, currentMirror) - val newMirror = mirrors.firstOrNull { x -> - x != currentMirror && !isBlacklisted(repository.source, x) - } ?: return@synchronized false - repository.domain = newMirror - true - } - } - - fun rollback(repository: ParserMangaRepository, oldMirror: String) = synchronized(obtainLock(repository.source)) { - blacklist[repository.source]?.remove(oldMirror) - repository.domain = oldMirror - } - - private fun trySwitchMirror(request: Request, chain: Interceptor.Chain): Response? { - val source = request.tag(MangaSource::class.java) ?: return null - val repository = mangaRepositoryFactoryLazy.get().create(source) as? ParserMangaRepository ?: return null - val mirrors = repository.getAvailableMirrors() - if (mirrors.isEmpty()) { - return null - } - return synchronized(obtainLock(repository.source)) { - tryMirrors(repository, mirrors, chain, request) - } - } - - private fun tryMirrors( - repository: ParserMangaRepository, - mirrors: List, - chain: Interceptor.Chain, - request: Request, - ): Response? { - val url = request.url - val currentDomain = url.topPrivateDomain() - if (currentDomain !in mirrors) { - return null - } - val urlBuilder = url.newBuilder() - for (mirror in mirrors) { - if (mirror == currentDomain || isBlacklisted(repository.source, mirror)) { - continue - } - val newHost = hostOf(url.host, mirror) ?: continue - val newRequest = request.newBuilder() - .url(urlBuilder.host(newHost).build()) - .build() - val response = chain.proceed(newRequest) - if (response.isFailed) { - addToBlacklist(repository.source, mirror) - response.closeQuietly() - } else { - repository.domain = mirror - return response - } - } - return null - } - - private val Response.isFailed: Boolean - get() = code in 400..599 - - private fun hostOf(host: String, newDomain: String): String? { - if (newDomain.canParseAsIpAddress()) { - return newDomain - } - val domain = PublicSuffixDatabase.get().getEffectiveTldPlusOne(host) ?: return null - return host.removeSuffix(domain) + newDomain - } - - private fun Response.copy(): Response { - return newBuilder() - .body(body?.copy()) - .build() - } - - private fun ResponseBody.copy(): ResponseBody { - return source().readByteArray().toResponseBody(contentType()) - } - - private fun obtainLock(source: MangaParserSource): Any = locks.getOrPut(source) { - Any() - } - - private fun isBlacklisted(source: MangaParserSource, domain: String): Boolean { - return blacklist[source]?.contains(domain) == true - } - - private fun addToBlacklist(source: MangaParserSource, domain: String) { - blacklist.getOrPut(source) { - ArraySet(2) - }.add(domain) - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/network/NetworkModule.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/network/NetworkModule.kt index 7711545ea..6c646031d 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/network/NetworkModule.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/network/NetworkModule.kt @@ -93,11 +93,9 @@ interface NetworkModule { fun provideMangaHttpClient( @BaseHttpClient baseClient: OkHttpClient, commonHeadersInterceptor: CommonHeadersInterceptor, - mirrorSwitchInterceptor: MirrorSwitchInterceptor, ): OkHttpClient = baseClient.newBuilder().apply { addNetworkInterceptor(CacheLimitInterceptor()) addInterceptor(commonHeadersInterceptor) - addInterceptor(mirrorSwitchInterceptor) }.build() } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaRepository.kt index 580113c83..3b6431035 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaRepository.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaRepository.kt @@ -8,7 +8,6 @@ import org.koitharu.kotatsu.core.cache.MemoryContentCache import org.koitharu.kotatsu.core.model.LocalMangaSource import org.koitharu.kotatsu.core.model.MangaSourceInfo import org.koitharu.kotatsu.core.model.UnknownMangaSource -import org.koitharu.kotatsu.core.network.MirrorSwitchInterceptor import org.koitharu.kotatsu.core.parser.external.ExternalMangaRepository import org.koitharu.kotatsu.core.parser.external.ExternalMangaSource import org.koitharu.kotatsu.local.data.LocalMangaRepository @@ -25,7 +24,6 @@ import org.koitharu.kotatsu.parsers.model.SortOrder import java.lang.ref.WeakReference import javax.inject.Inject import javax.inject.Singleton -import kotlin.collections.set interface MangaRepository { @@ -60,7 +58,7 @@ interface MangaRepository { private val localMangaRepository: LocalMangaRepository, private val loaderContext: MangaLoaderContext, private val contentCache: MemoryContentCache, - private val mirrorSwitchInterceptor: MirrorSwitchInterceptor, + private val mirrorSwitcher: MirrorSwitcher, ) { private val cache = ArrayMap>() @@ -89,7 +87,7 @@ interface MangaRepository { is MangaParserSource -> ParserMangaRepository( parser = MangaParser(source, loaderContext), cache = contentCache, - mirrorSwitchInterceptor = mirrorSwitchInterceptor, + mirrorSwitcher = mirrorSwitcher, ) is ExternalMangaSource -> if (source.isAvailable(context)) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MirrorSwitcher.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MirrorSwitcher.kt new file mode 100644 index 000000000..d59a804a2 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MirrorSwitcher.kt @@ -0,0 +1,109 @@ +package org.koitharu.kotatsu.core.parser + +import android.util.Log +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import okhttp3.OkHttpClient +import okhttp3.Request +import org.koitharu.kotatsu.BuildConfig +import org.koitharu.kotatsu.core.network.MangaHttpClient +import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.parsers.model.MangaParserSource +import org.koitharu.kotatsu.parsers.util.await +import org.koitharu.kotatsu.parsers.util.runCatchingCancellable +import java.util.EnumSet +import javax.inject.Inject + +class MirrorSwitcher @Inject constructor( + private val settings: AppSettings, + @MangaHttpClient private val okHttpClient: OkHttpClient, +) { + + private val blacklist = EnumSet.noneOf(MangaParserSource::class.java) + private val mutex: Mutex = Mutex() + + val isEnabled: Boolean + get() = settings.isMirrorSwitchingEnabled + + suspend fun trySwitchMirror(repository: ParserMangaRepository, loader: suspend () -> T?): T? { + val source = repository.source + if (!isEnabled || source in blacklist) { + return null + } + val availableMirrors = repository.domains + val currentHost = repository.domain + if (availableMirrors.size <= 1 || currentHost !in availableMirrors) { + return null + } + mutex.withLock { + if (source in blacklist) { + return null + } + logd { "Looking for mirrors for ${source}..." } + findRedirect(repository)?.let { mirror -> + repository.domain = mirror + runCatchingCancellable { + loader()?.takeIfValid() + }.getOrNull()?.let { + logd { "Found redirect for $source: $mirror" } + return it + } + } + for (mirror in availableMirrors) { + repository.domain = mirror + runCatchingCancellable { + loader()?.takeIfValid() + }.getOrNull()?.let { + logd { "Found mirror for $source: $mirror" } + return it + } + } + repository.domain = currentHost // rollback + blacklist.add(source) + logd { "$source blacklisted" } + return null + } + } + + suspend fun findRedirect(repository: ParserMangaRepository): String? { + if (!isEnabled) { + return null + } + val currentHost = repository.domain + val newHost = okHttpClient.newCall( + Request.Builder() + .url("https://$currentHost") + .head() + .build(), + ).await().use { + if (it.isSuccessful) { + it.request.url.host + } else { + null + } + } + return if (newHost != currentHost) { + newHost + } else { + null + } + } + + private fun T.takeIfValid() = takeIf { + when (it) { + is Collection<*> -> it.isNotEmpty() + else -> true + } + } + + private companion object { + + const val TAG = "MirrorSwitcher" + + inline fun logd(message: () -> String) { + if (BuildConfig.DEBUG) { + Log.d(TAG, message()) + } + } + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/ParserMangaRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/ParserMangaRepository.kt index 67a9d1967..d44e017c3 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/ParserMangaRepository.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/ParserMangaRepository.kt @@ -4,11 +4,14 @@ import kotlinx.coroutines.Dispatchers import okhttp3.Interceptor import okhttp3.Response import org.koitharu.kotatsu.core.cache.MemoryContentCache -import org.koitharu.kotatsu.core.network.MirrorSwitchInterceptor +import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException +import org.koitharu.kotatsu.core.exceptions.InteractiveActionRequiredException +import org.koitharu.kotatsu.core.exceptions.ProxyConfigException import org.koitharu.kotatsu.core.prefs.SourceSettings import org.koitharu.kotatsu.parsers.MangaParser import org.koitharu.kotatsu.parsers.MangaParserAuthProvider import org.koitharu.kotatsu.parsers.config.ConfigKey +import org.koitharu.kotatsu.parsers.exception.AuthRequiredException import org.koitharu.kotatsu.parsers.model.Favicons import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaChapter @@ -23,12 +26,12 @@ import org.koitharu.kotatsu.parsers.util.suspendlazy.suspendLazy class ParserMangaRepository( private val parser: MangaParser, - private val mirrorSwitchInterceptor: MirrorSwitchInterceptor, + private val mirrorSwitcher: MirrorSwitcher, cache: MemoryContentCache, ) : CachingMangaRepository(cache), Interceptor { private val filterOptionsLazy = suspendLazy(Dispatchers.Default) { - mirrorSwitchInterceptor.withMirrorSwitching { + withMirrors { parser.getFilterOptions() } } @@ -60,18 +63,18 @@ class ParserMangaRepository( override fun intercept(chain: Interceptor.Chain): Response = parser.intercept(chain) override suspend fun getList(offset: Int, order: SortOrder?, filter: MangaListFilter?): List { - return mirrorSwitchInterceptor.withMirrorSwitching { + return withMirrors { parser.getList(offset, order ?: defaultSortOrder, filter ?: MangaListFilter.EMPTY) } } override suspend fun getPagesImpl( chapter: MangaChapter - ): List = mirrorSwitchInterceptor.withMirrorSwitching { + ): List = withMirrors { parser.getPages(chapter) } - override suspend fun getPageUrl(page: MangaPage): String = mirrorSwitchInterceptor.withMirrorSwitching { + override suspend fun getPageUrl(page: MangaPage): String = withMirrors { parser.getPageUrl(page).also { result -> check(result.isNotEmpty()) { "Page url is empty" } } @@ -79,13 +82,13 @@ class ParserMangaRepository( override suspend fun getFilterOptions(): MangaListFilterOptions = filterOptionsLazy.get() - suspend fun getFavicons(): Favicons = mirrorSwitchInterceptor.withMirrorSwitching { + suspend fun getFavicons(): Favicons = withMirrors { parser.getFavicons() } override suspend fun getRelatedMangaImpl(seed: Manga): List = parser.getRelatedManga(seed) - override suspend fun getDetailsImpl(manga: Manga): Manga = mirrorSwitchInterceptor.withMirrorSwitching { + override suspend fun getDetailsImpl(manga: Manga): Manga = withMirrors { parser.getDetails(manga) } @@ -107,31 +110,34 @@ class ParserMangaRepository( fun getConfig() = parser.config as SourceSettings - private suspend fun MirrorSwitchInterceptor.withMirrorSwitching(block: suspend () -> R): R { - if (!isEnabled) { + private suspend fun withMirrors(block: suspend () -> T): T { + if (!mirrorSwitcher.isEnabled) { return block() } - val initialMirror = domain - val result = runCatchingCancellable { - block() - } - if (result.isValidResult()) { - return result.getOrThrow() - } - return if (trySwitchMirror(this@ParserMangaRepository)) { - val newResult = runCatchingCancellable { - block() - } - if (newResult.isValidResult()) { - return newResult.getOrThrow() - } else { - rollback(this@ParserMangaRepository, initialMirror) - return result.getOrThrow() - } - } else { - result.getOrThrow() + val initialResult = runCatchingCancellable { block() } + if (initialResult.isValidResult()) { + return initialResult.getOrThrow() } + val newResult = mirrorSwitcher.trySwitchMirror(this, block) + return newResult ?: initialResult.getOrThrow() } - private fun Result<*>.isValidResult() = isSuccess && (getOrNull() as? Collection<*>)?.isEmpty() != true + private fun Result.isValidResult() = fold( + onSuccess = { + when (it) { + is Collection<*> -> it.isNotEmpty() + else -> true + } + }, + onFailure = { + when (it.cause) { + is CloudFlareProtectedException, + is AuthRequiredException, + is InteractiveActionRequiredException, + is ProxyConfigException -> true + + else -> false + } + }, + ) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/AppSettings.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/AppSettings.kt index c5849d34a..fc646784d 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/AppSettings.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/AppSettings.kt @@ -263,7 +263,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) { get() = prefs.getBoolean(KEY_PROTECT_APP_BIOMETRIC, true) set(value) = prefs.edit { putBoolean(KEY_PROTECT_APP_BIOMETRIC, value) } - val isMirrorSwitchingAvailable: Boolean + val isMirrorSwitchingEnabled: Boolean get() = prefs.getBoolean(KEY_MIRROR_SWITCHING, false) val isExitConfirmationEnabled: Boolean