New mirror switching approach
This commit is contained in:
@@ -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<MangaRepository.Factory>,
|
||||
private val settings: AppSettings,
|
||||
) : Interceptor {
|
||||
|
||||
private val locks = EnumMap<MangaParserSource, Any>(MangaParserSource::class.java)
|
||||
private val blacklist = EnumMap<MangaParserSource, MutableSet<String>>(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<String>,
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
}
|
||||
|
||||
@@ -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<MangaSource, WeakReference<MangaRepository>>()
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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 <T : Any> 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 : Any> 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())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Manga> {
|
||||
return mirrorSwitchInterceptor.withMirrorSwitching {
|
||||
return withMirrors {
|
||||
parser.getList(offset, order ?: defaultSortOrder, filter ?: MangaListFilter.EMPTY)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getPagesImpl(
|
||||
chapter: MangaChapter
|
||||
): List<MangaPage> = mirrorSwitchInterceptor.withMirrorSwitching {
|
||||
): List<MangaPage> = 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<Manga> = 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 <R> MirrorSwitchInterceptor.withMirrorSwitching(block: suspend () -> R): R {
|
||||
if (!isEnabled) {
|
||||
private suspend fun <T : Any> 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<Any>.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
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user