diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/AppModule.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/AppModule.kt index 8eb440a59..1aad04a76 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/AppModule.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/AppModule.kt @@ -45,7 +45,6 @@ import org.koitharu.kotatsu.list.domain.ListExtraProviderImpl import org.koitharu.kotatsu.local.data.CacheDir import org.koitharu.kotatsu.local.data.CbzFetcher import org.koitharu.kotatsu.local.data.LocalStorageChanges -import org.koitharu.kotatsu.local.data.PagesCache import org.koitharu.kotatsu.local.domain.model.LocalManga import org.koitharu.kotatsu.main.ui.protect.AppProtectHelper import org.koitharu.kotatsu.parsers.MangaLoaderContext @@ -91,7 +90,8 @@ interface AppModule { @ApplicationContext context: Context, @MangaHttpClient okHttpClient: OkHttpClient, mangaRepositoryFactory: MangaRepository.Factory, - pagesCache: PagesCache, + imageProxyInterceptor: ImageProxyInterceptor, + pageFetcherFactory: MangaPageFetcher.Factory, ): ImageLoader { val diskCacheFactory = { val rootDir = context.externalCacheDir ?: context.cacheDir @@ -113,7 +113,8 @@ interface AppModule { .add(SvgDecoder.Factory()) .add(CbzFetcher.Factory()) .add(FaviconFetcher.Factory(context, okHttpClient, mangaRepositoryFactory)) - .add(MangaPageFetcher.Factory(context, okHttpClient, pagesCache, mangaRepositoryFactory)) + .add(pageFetcherFactory) + .add(imageProxyInterceptor) .build(), ).build() } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/network/ImageProxyInterceptor.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/network/ImageProxyInterceptor.kt new file mode 100644 index 000000000..54423ba67 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/network/ImageProxyInterceptor.kt @@ -0,0 +1,103 @@ +package org.koitharu.kotatsu.core.network + +import android.util.Log +import androidx.collection.ArraySet +import coil.intercept.Interceptor +import coil.request.ErrorResult +import coil.request.ImageResult +import coil.request.SuccessResult +import coil.size.Dimension +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import org.koitharu.kotatsu.BuildConfig +import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.core.util.ext.ensureSuccess +import org.koitharu.kotatsu.core.util.ext.isHttpOrHttps +import org.koitharu.kotatsu.parsers.util.await +import org.koitharu.kotatsu.parsers.util.runCatchingCancellable +import java.util.Collections +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class ImageProxyInterceptor @Inject constructor( + private val settings: AppSettings, +) : Interceptor { + + private val blacklist = Collections.synchronizedSet(ArraySet()) + + override suspend fun intercept(chain: Interceptor.Chain): ImageResult { + val request = chain.request + if (!settings.isImagesProxyEnabled) { + return chain.proceed(request) + } + val url: HttpUrl? = when (val data = request.data) { + is HttpUrl -> data + is String -> data.toHttpUrlOrNull() + else -> null + } + if (url == null || !url.isHttpOrHttps || url.host in blacklist) { + return chain.proceed(request) + } + val newUrl = HttpUrl.Builder() + .scheme("https") + .host("wsrv.nl") + .addQueryParameter("url", url.toString()) + .addQueryParameter("fit", "outside") + .addQueryParameter("we", null) + val size = request.sizeResolver.size() + (size.height as? Dimension.Pixels)?.let { newUrl.addQueryParameter("h", it.toString()) } + (size.width as? Dimension.Pixels)?.let { newUrl.addQueryParameter("w", it.toString()) } + + val newRequest = request.newBuilder() + .data(newUrl.build()) + .build() + val result = chain.proceed(newRequest) + return if (result is SuccessResult) { + result + } else { + logDebug((result as? ErrorResult)?.throwable) + chain.proceed(request).also { + if (it is SuccessResult) { + blacklist.add(url.host) + } + } + } + } + + suspend fun interceptPageRequest(request: Request, okHttp: OkHttpClient): Response { + if (!settings.isImagesProxyEnabled) { + return okHttp.newCall(request).await() + } + val sourceUrl = request.url + val targetUrl = HttpUrl.Builder() + .scheme("https") + .host("wsrv.nl") + .addQueryParameter("url", sourceUrl.toString()) + .addQueryParameter("we", null) + val newRequest = request.newBuilder() + .url(targetUrl.build()) + .build() + return runCatchingCancellable { + okHttp.doCall(newRequest) + }.recover { + logDebug(it) + okHttp.doCall(request).also { + blacklist.add(sourceUrl.host) + } + }.getOrThrow() + } + + private suspend fun OkHttpClient.doCall(request: Request): Response { + return newCall(request).await().ensureSuccess() + } + + private fun logDebug(e: Throwable?) { + if (BuildConfig.DEBUG) { + Log.w("ImageProxy", e.toString()) + } + } +} 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 09c883755..0f04f1123 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 @@ -277,6 +277,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) { val isReaderSliderEnabled: Boolean get() = prefs.getBoolean(KEY_READER_SLIDER, true) + val isImagesProxyEnabled: Boolean + get() = prefs.getBoolean(KEY_IMAGES_PROXY, false) + val dnsOverHttps: DoHProvider get() = prefs.getEnumValue(KEY_DOH, DoHProvider.NONE) @@ -448,6 +451,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) { const val KEY_PROXY_TYPE = "proxy_type" const val KEY_PROXY_ADDRESS = "proxy_address" const val KEY_PROXY_PORT = "proxy_port" + const val KEY_IMAGES_PROXY = "images_proxy" // About const val KEY_APP_UPDATE = "app_update" diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Http.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Http.kt index f5a23453e..45463f045 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Http.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Http.kt @@ -1,5 +1,6 @@ package org.koitharu.kotatsu.core.util.ext +import okhttp3.HttpUrl import okhttp3.MediaType.Companion.toMediaType import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.Response @@ -23,3 +24,17 @@ fun Response.parseJsonOrNull(): JSONObject? { closeQuietly() } } + +val HttpUrl.isHttpOrHttps: Boolean + get() { + val s = scheme.lowercase() + return s == "https" || s == "http" + } + +fun Response.ensureSuccess() = apply { + if (!isSuccessful || code == HttpURLConnection.HTTP_NO_CONTENT) { + val message = "Invalid response: $code $message at ${request.url}" + closeQuietly() + throw IllegalStateException(message) + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/DetectReaderModeUseCase.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/DetectReaderModeUseCase.kt index fe4ad571f..08a3e345b 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/DetectReaderModeUseCase.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/DetectReaderModeUseCase.kt @@ -7,6 +7,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runInterruptible import okhttp3.OkHttpClient import org.koitharu.kotatsu.core.model.findChapter +import org.koitharu.kotatsu.core.network.ImageProxyInterceptor import org.koitharu.kotatsu.core.network.MangaHttpClient import org.koitharu.kotatsu.core.parser.MangaDataRepository import org.koitharu.kotatsu.core.parser.MangaRepository @@ -14,7 +15,6 @@ import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.ReaderMode import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaPage -import org.koitharu.kotatsu.parsers.util.await import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.reader.ui.ReaderState import org.koitharu.kotatsu.util.ext.printStackTraceDebug @@ -28,6 +28,7 @@ class DetectReaderModeUseCase @Inject constructor( private val settings: AppSettings, private val mangaRepositoryFactory: MangaRepository.Factory, @MangaHttpClient private val okHttpClient: OkHttpClient, + private val imageProxyInterceptor: ImageProxyInterceptor, ) { suspend operator fun invoke(manga: Manga, state: ReaderState?): ReaderMode { @@ -70,7 +71,7 @@ class DetectReaderModeUseCase @Inject constructor( } } else { val request = PageLoader.createPageRequest(page, url) - okHttpClient.newCall(request).await().use { + imageProxyInterceptor.interceptPageRequest(request, okHttpClient).use { runInterruptible(Dispatchers.IO) { getBitmapSize(it.body?.byteStream()) } 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 49701829a..19bf8bda9 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 @@ -22,18 +22,19 @@ import okhttp3.OkHttpClient import okhttp3.Request import okio.source import org.koitharu.kotatsu.core.network.CommonHeaders +import org.koitharu.kotatsu.core.network.ImageProxyInterceptor import org.koitharu.kotatsu.core.network.MangaHttpClient import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.RemoteMangaRepository import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.util.RetainedLifecycleCoroutineScope +import org.koitharu.kotatsu.core.util.ext.ensureSuccess import org.koitharu.kotatsu.core.util.ext.withProgress import org.koitharu.kotatsu.core.util.progress.ProgressDeferred import org.koitharu.kotatsu.local.data.CbzFilter import org.koitharu.kotatsu.local.data.PagesCache import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaSource -import org.koitharu.kotatsu.parsers.util.await import org.koitharu.kotatsu.reader.ui.pager.ReaderPage import org.koitharu.kotatsu.util.ext.printStackTraceDebug import java.io.File @@ -51,6 +52,7 @@ class PageLoader @Inject constructor( private val cache: PagesCache, private val settings: AppSettings, private val mangaRepositoryFactory: MangaRepository.Factory, + private val imageProxyInterceptor: ImageProxyInterceptor, ) : RetainedLifecycle.OnClearedListener { init { @@ -191,10 +193,7 @@ class PageLoader @Inject constructor( } } else { val request = createPageRequest(page, pageUrl) - okHttp.newCall(request).await().use { response -> - check(response.isSuccessful) { - "Invalid response: ${response.code} ${response.message} at $pageUrl" - } + imageProxyInterceptor.interceptPageRequest(request, okHttp).ensureSuccess().use { response -> val body = checkNotNull(response.body) { "Null response" } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/thumbnails/MangaPageFetcher.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/thumbnails/MangaPageFetcher.kt index 3246d0a99..393f6398d 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/thumbnails/MangaPageFetcher.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/thumbnails/MangaPageFetcher.kt @@ -9,21 +9,24 @@ import coil.fetch.FetchResult import coil.fetch.Fetcher import coil.fetch.SourceResult import coil.request.Options +import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runInterruptible import okhttp3.OkHttpClient import okio.Path.Companion.toOkioPath import okio.buffer import okio.source +import org.koitharu.kotatsu.core.network.ImageProxyInterceptor +import org.koitharu.kotatsu.core.network.MangaHttpClient import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.local.data.CbzFilter import org.koitharu.kotatsu.local.data.PagesCache import org.koitharu.kotatsu.local.data.util.withExtraCloseable import org.koitharu.kotatsu.parsers.model.MangaPage -import org.koitharu.kotatsu.parsers.util.await import org.koitharu.kotatsu.parsers.util.mimeType import org.koitharu.kotatsu.reader.domain.PageLoader import java.util.zip.ZipFile +import javax.inject.Inject class MangaPageFetcher( private val context: Context, @@ -32,6 +35,7 @@ class MangaPageFetcher( private val options: Options, private val page: MangaPage, private val mangaRepositoryFactory: MangaRepository.Factory, + private val imageProxyInterceptor: ImageProxyInterceptor, ) : Fetcher { override suspend fun fetch(): FetchResult { @@ -66,7 +70,7 @@ class MangaPageFetcher( ) } else { val request = PageLoader.createPageRequest(page, pageUrl) - okHttpClient.newCall(request).await().use { response -> + imageProxyInterceptor.interceptPageRequest(request, okHttpClient).use { response -> check(response.isSuccessful) { "Invalid response: ${response.code} ${response.message} at $pageUrl" } @@ -89,11 +93,12 @@ class MangaPageFetcher( } } - class Factory( - private val context: Context, - private val okHttpClient: OkHttpClient, + class Factory @Inject constructor( + @ApplicationContext private val context: Context, + @MangaHttpClient private val okHttpClient: OkHttpClient, private val pagesCache: PagesCache, private val mangaRepositoryFactory: MangaRepository.Factory, + private val imageProxyInterceptor: ImageProxyInterceptor, ) : Fetcher.Factory { override fun create(data: MangaPage, options: Options, imageLoader: ImageLoader): Fetcher { @@ -104,6 +109,7 @@ class MangaPageFetcher( page = data, context = context, mangaRepositoryFactory = mangaRepositoryFactory, + imageProxyInterceptor = imageProxyInterceptor, ) } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e063c5d10..cc5268fde 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -426,4 +426,6 @@ Invalid value %1$s (%2$s) Downloaded + Images optimization proxy + Use the wsrv.nl service to reduce traffic usage and speed up image loading if possible diff --git a/app/src/main/res/xml/pref_content.xml b/app/src/main/res/xml/pref_content.xml index 228b40729..ccf022e00 100644 --- a/app/src/main/res/xml/pref_content.xml +++ b/app/src/main/res/xml/pref_content.xml @@ -37,6 +37,12 @@ app:allowDividerAbove="true" app:useSimpleSummaryProvider="true" /> + +