diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d52cb090d..1496726f0 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -136,6 +136,13 @@ android { dex { useLegacyPackaging = false } + resources { + excludes += listOf( + "META-INF/DEPENDENCIES", + "com", // Compose Destination + "org", // Apache 5 HC version info + ) + } } dependenciesInfo.includeInApk = false @@ -245,7 +252,7 @@ dependencies { implementation(libs.telephoto.zoomable) - implementation(libs.ktor.client.core) + implementation(libs.bundles.http.client) implementation(libs.bundles.kotlinx.serialization) @@ -255,8 +262,6 @@ dependencies { coreLibraryDesugaring(libs.desugar) - implementation(libs.cronet.embedded) - implementation(libs.androidx.profileinstaller) "baselineProfile"(project(":benchmark")) diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index e8b5054d3..cf63745bd 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -14,7 +14,9 @@ # Ktor logger -dontwarn org.slf4j.impl.StaticLoggerBinder --dontwarn org.conscrypt.Conscrypt +# Conscrypt +-dontwarn com.android.org.conscrypt.SSLParametersImpl +-dontwarn org.apache.harmony.xnet.provider.jsse.SSLParametersImpl # https://issuetracker.google.com/222232895 -dontwarn androidx.window.extensions.** diff --git a/app/src/main/kotlin/com/hippo/ehviewer/EhApplication.kt b/app/src/main/kotlin/com/hippo/ehviewer/EhApplication.kt index 27bbcabbf..5ae25f11c 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/EhApplication.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/EhApplication.kt @@ -43,7 +43,6 @@ import com.hippo.ehviewer.coil.HardwareBitmapInterceptor import com.hippo.ehviewer.coil.MapExtraInfoInterceptor import com.hippo.ehviewer.coil.MergeInterceptor import com.hippo.ehviewer.coil.QrCodeInterceptor -import com.hippo.ehviewer.cronet.cronetHttpClient import com.hippo.ehviewer.dailycheck.checkDawn import com.hippo.ehviewer.dao.SearchDatabase import com.hippo.ehviewer.download.DownloadManager @@ -51,6 +50,7 @@ import com.hippo.ehviewer.download.DownloadsFilterMode import com.hippo.ehviewer.ktbuilder.diskCache import com.hippo.ehviewer.ktbuilder.imageLoader import com.hippo.ehviewer.ktor.Cronet +import com.hippo.ehviewer.ktor.configureClient import com.hippo.ehviewer.ui.keepNoMediaFileStatus import com.hippo.ehviewer.ui.lockObserver import com.hippo.ehviewer.ui.screen.detailCache @@ -64,11 +64,13 @@ import com.hippo.ehviewer.util.OSUtils import com.hippo.ehviewer.util.isAtLeastO import com.hippo.ehviewer.util.isAtLeastP import com.hippo.ehviewer.util.isAtLeastS +import com.hippo.ehviewer.util.isAtLeastSExtension7 import eu.kanade.tachiyomi.util.lang.launchIO import eu.kanade.tachiyomi.util.lang.launchUI import eu.kanade.tachiyomi.util.lang.withUIContext import eu.kanade.tachiyomi.util.system.logcat import io.ktor.client.HttpClient +import io.ktor.client.engine.apache5.Apache5 import io.ktor.client.plugins.cookies.HttpCookies import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -193,12 +195,23 @@ class EhApplication : companion object { val ktorClient by lazy { - HttpClient(Cronet) { - engine { - client = cronetHttpClient + if (isAtLeastSExtension7 && Settings.enableCronet) { + HttpClient(Cronet) { + engine { + configureClient() + } + install(HttpCookies) { + storage = EhCookieStore + } } - install(HttpCookies) { - storage = EhCookieStore + } else { + HttpClient(Apache5) { + engine { + configureClient() + } + install(HttpCookies) { + storage = EhCookieStore + } } } } diff --git a/app/src/main/kotlin/com/hippo/ehviewer/Settings.kt b/app/src/main/kotlin/com/hippo/ehviewer/Settings.kt index 420fc0d09..ed6ce83b0 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/Settings.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/Settings.kt @@ -164,6 +164,7 @@ object Settings : DataStorePreferences(null) { var downloadOriginImage by boolPref("download_origin_image", false) var saveAsCbz by boolPref("save_as_cbz", false) var archiveMetadata by boolPref("archive_metadata", true) + var enableCronet by boolPref("enable_cronet", true) var desktopSite by boolPref("desktop_site", false) var recentFavCat by intPref("recent_fav_cat", FavListUrlBuilder.FAV_CAT_LOCAL) var defaultFavSlot by intPref("default_favorite_slot", -2) diff --git a/app/src/main/kotlin/com/hippo/ehviewer/cronet/CronetKt.kt b/app/src/main/kotlin/com/hippo/ehviewer/cronet/CronetKt.kt deleted file mode 100644 index 7952919db..000000000 --- a/app/src/main/kotlin/com/hippo/ehviewer/cronet/CronetKt.kt +++ /dev/null @@ -1,20 +0,0 @@ -package com.hippo.ehviewer.cronet - -import com.hippo.ehviewer.Settings -import java.io.File -import org.chromium.net.CronetEngine -import splitties.init.appCtx - -val cronetHttpClient: CronetEngine = CronetEngine.Builder(appCtx).apply { - enableBrotli(true) - setUserAgent(Settings.userAgent) - - // Cache Quic hint only since the real cache mechanism should on Ktor layer - val cache = File(appCtx.cacheDir, "http_cache").apply { mkdirs() } - setStoragePath(cache.path) - enableHttpCache(CronetEngine.Builder.HTTP_CACHE_DISK_NO_HTTP, 4096) - - addQuicHint("e-hentai.org", 443, 443) - addQuicHint("forums.e-hentai.org", 443, 443) - addQuicHint("exhentai.org", 443, 443) -}.build() diff --git a/app/src/main/kotlin/com/hippo/ehviewer/ktor/Apache5Extensions.kt b/app/src/main/kotlin/com/hippo/ehviewer/ktor/Apache5Extensions.kt new file mode 100644 index 000000000..c2caa5c5c --- /dev/null +++ b/app/src/main/kotlin/com/hippo/ehviewer/ktor/Apache5Extensions.kt @@ -0,0 +1,34 @@ +package com.hippo.ehviewer.ktor + +import com.hippo.ehviewer.util.isAtLeastQ +import io.ktor.client.engine.apache5.Apache5EngineConfig +import java.security.Security +import org.apache.hc.client5.http.config.ConnectionConfig +import org.apache.hc.client5.http.impl.nio.PoolingAsyncClientConnectionManagerBuilder +import org.apache.hc.core5.reactor.IOReactorConfig +import org.apache.hc.core5.util.Timeout +import org.conscrypt.Conscrypt + +fun Apache5EngineConfig.configureClient() { + if (!isAtLeastQ) { + Security.insertProviderAt(Conscrypt.newProvider(), 1) + } + customizeClient { + setConnectionManager( + PoolingAsyncClientConnectionManagerBuilder.create().apply { + setMaxConnPerRoute(2) + setDefaultConnectionConfig( + ConnectionConfig.custom().apply { + setConnectTimeout(Timeout.ofMilliseconds(connectTimeout)) + setSocketTimeout(Timeout.ofMilliseconds(socketTimeout.toLong())) + }.build(), + ) + setIOReactorConfig( + IOReactorConfig.custom().apply { + setIoThreadCount(1) + }.build(), + ) + }.build(), + ) + } +} diff --git a/app/src/main/kotlin/com/hippo/ehviewer/ktor/Cronet.kt b/app/src/main/kotlin/com/hippo/ehviewer/ktor/Cronet.kt index 773f6748a..1616d7c1f 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/ktor/Cronet.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/ktor/Cronet.kt @@ -1,8 +1,12 @@ +@file:RequiresExtension(extension = Build.VERSION_CODES.S, version = 7) + package com.hippo.ehviewer.ktor -import io.ktor.client.engine.HttpClientEngine +import android.os.Build +import androidx.annotation.RequiresExtension import io.ktor.client.engine.HttpClientEngineFactory +import splitties.init.appCtx object Cronet : HttpClientEngineFactory { - override fun create(block: CronetConfig.() -> Unit): HttpClientEngine = CronetEngine(CronetConfig().apply(block)) + override fun create(block: CronetConfig.() -> Unit) = CronetEngine(CronetConfig(appCtx).apply(block)) } diff --git a/app/src/main/kotlin/com/hippo/ehviewer/ktor/CronetConfig.kt b/app/src/main/kotlin/com/hippo/ehviewer/ktor/CronetConfig.kt index b9f35892e..e95ccf8d1 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/ktor/CronetConfig.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/ktor/CronetConfig.kt @@ -1,13 +1,13 @@ +@file:RequiresExtension(extension = Build.VERSION_CODES.S, version = 7) + package com.hippo.ehviewer.ktor import android.content.Context +import android.net.http.HttpEngine +import android.os.Build +import androidx.annotation.RequiresExtension import io.ktor.client.engine.HttpClientEngineConfig -import org.chromium.net.CronetEngine -class CronetConfig : HttpClientEngineConfig() { - var client: CronetEngine? = null - - fun config(context: Context, block: CronetEngine.Builder.() -> Unit) { - client = CronetEngine.Builder(context).apply(block).build() - } +class CronetConfig(val context: Context) : HttpClientEngineConfig() { + var config: HttpEngine.Builder.() -> Unit = {} } diff --git a/app/src/main/kotlin/com/hippo/ehviewer/ktor/CronetEngine.kt b/app/src/main/kotlin/com/hippo/ehviewer/ktor/CronetEngine.kt index 804e338ea..8493eacc7 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/ktor/CronetEngine.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/ktor/CronetEngine.kt @@ -1,5 +1,15 @@ +@file:RequiresExtension(extension = Build.VERSION_CODES.S, version = 7) + package com.hippo.ehviewer.ktor +import android.net.http.HttpEngine +import android.net.http.HttpException +import android.net.http.UploadDataProvider +import android.net.http.UploadDataSink +import android.net.http.UrlRequest +import android.net.http.UrlResponseInfo +import android.os.Build +import androidx.annotation.RequiresExtension import io.ktor.client.engine.HttpClientEngineBase import io.ktor.client.engine.callContext import io.ktor.client.request.HttpRequestData @@ -27,17 +37,15 @@ import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.consumeEach import kotlinx.coroutines.job import kotlinx.coroutines.suspendCancellableCoroutine -import org.chromium.net.CronetException -import org.chromium.net.UrlRequest -import org.chromium.net.UrlResponseInfo -import org.chromium.net.apihelpers.UploadDataProviders class CronetEngine(override val config: CronetConfig) : HttpClientEngineBase("Cronet") { // Limit thread to 1 since we are async & non-blocking override val dispatcher = Dispatchers.Default.limitedParallelism(1) private val executor = dispatcher.asExecutor() private val pool = DirectByteBufferPool(32) - private val client = config.client ?: error("Cronet client is not configured") + private val client by lazy { + with(config) { HttpEngine.Builder(context).apply(config).build() } + } @InternalAPI override suspend fun execute(data: HttpRequestData) = executeHttpRequest(callContext(), data) @@ -48,7 +56,7 @@ class CronetEngine(override val config: CronetConfig) : HttpClientEngineBase("Cr ) = suspendCancellableCoroutine { continuation -> val requestTime = GMTDate() - val callback = object : UrlRequest.Callback() { + val callback = object : UrlRequest.Callback { val chunkChan = Channel() override fun onRedirectReceived(request: UrlRequest, info: UrlResponseInfo, newLocationUrl: String) { continuation.resume( @@ -87,16 +95,18 @@ class CronetEngine(override val config: CronetConfig) : HttpClientEngineBase("Cr chunkChan.close() } - override fun onFailed(request: UrlRequest, info: UrlResponseInfo?, error: CronetException) { + override fun onFailed(request: UrlRequest, info: UrlResponseInfo?, error: HttpException) { if (continuation.isActive) { continuation.resumeWithException(error) } else { chunkChan.close(error) } } + + override fun onCanceled(request: UrlRequest, info: UrlResponseInfo?) = Unit } - client.newUrlRequestBuilder(data.url.toString(), callback, executor).apply { + client.newUrlRequestBuilder(data.url.toString(), executor, callback).apply { setHttpMethod(data.method.value) data.headers.flattenForEach { key, value -> addHeader(key, value) } data.body.contentType?.let { addHeader(HttpHeaders.ContentType, it.toString()) } @@ -117,7 +127,7 @@ private fun UrlResponseInfo.toHttpResponseData( statusCode = HttpStatusCode.fromValue(httpStatusCode), requestTime = requestTime, headers = headers { - allHeaders.forEach { (key, value) -> + headers.asMap.forEach { (key, value) -> appendAll(key, value) } }, @@ -133,6 +143,17 @@ private fun UrlResponseInfo.toHttpResponseData( private fun OutgoingContent.toUploadDataProvider() = when (this) { is OutgoingContent.NoContent -> null - is OutgoingContent.ByteArrayContent -> UploadDataProviders.create(bytes()) + is OutgoingContent.ByteArrayContent -> object : UploadDataProvider() { + val buffer = ByteBuffer.wrap(bytes()).slice() + override fun getLength() = buffer.limit().toLong() + override fun read(uploadDataSink: UploadDataSink, byteBuffer: ByteBuffer) { + byteBuffer.put(buffer) + uploadDataSink.onReadSucceeded(false) + } + override fun rewind(uploadDataSink: UploadDataSink) { + buffer.position(0) + uploadDataSink.onRewindSucceeded() + } + } else -> error("UnsupportedContentType $this") } diff --git a/app/src/main/kotlin/com/hippo/ehviewer/ktor/CronetExtensions.kt b/app/src/main/kotlin/com/hippo/ehviewer/ktor/CronetExtensions.kt new file mode 100644 index 000000000..c27585a06 --- /dev/null +++ b/app/src/main/kotlin/com/hippo/ehviewer/ktor/CronetExtensions.kt @@ -0,0 +1,25 @@ +@file:RequiresExtension(extension = Build.VERSION_CODES.S, version = 7) + +package com.hippo.ehviewer.ktor + +import android.net.http.HttpEngine +import android.os.Build +import androidx.annotation.RequiresExtension +import com.hippo.ehviewer.Settings +import java.io.File + +fun CronetConfig.configureClient() { + config = { + setEnableBrotli(true) + setUserAgent(Settings.userAgent) + + // Cache Quic hint only since the real cache mechanism should on Ktor layer + val cache = File(context.cacheDir, "http_cache").apply { mkdirs() } + setStoragePath(cache.path) + setEnableHttpCache(HttpEngine.Builder.HTTP_CACHE_DISK_NO_HTTP, 4096) + + addQuicHint("e-hentai.org", 443, 443) + addQuicHint("forums.e-hentai.org", 443, 443) + addQuicHint("exhentai.org", 443, 443) + } +} diff --git a/app/src/main/kotlin/com/hippo/ehviewer/ui/settings/AdvancedScreen.kt b/app/src/main/kotlin/com/hippo/ehviewer/ui/settings/AdvancedScreen.kt index 3700ee0e5..19cb8e957 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/ui/settings/AdvancedScreen.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/ui/settings/AdvancedScreen.kt @@ -176,6 +176,12 @@ fun AdvancedScreen(navigator: DestinationsNavigator) { useSelectedAsSummary = true, entries = languages, ) + if (BuildConfig.DEBUG) { + SwitchPreference( + title = "Enable Cronet", + value = Settings::enableCronet, + ) + } if (isAtLeastO) { IntSliderPreference( maxValue = 16384, diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8703c9dd1..483b2dbe6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -81,7 +81,7 @@ compose-destinations-compiler = { module = "io.github.raamcosta.compose-destinat material-motion-core = "io.github.fornewid:material-motion-compose-core:2.0.1" -cronet-embedded = "org.chromium.net:cronet-embedded:119.6045.31" +conscrypt = "org.conscrypt:conscrypt-android:2.5.3" desugar = "com.android.tools:desugar_jdk_libs_nio:2.1.2" @@ -93,7 +93,7 @@ kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version. ktlint = "com.pinterest:ktlint:1.4.0-kotlin-dev-SNAPSHOT" -ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } +ktor-client-apache5 = { module = "io.ktor:ktor-client-apache5", version.ref = "ktor" } logcat = "com.squareup.logcat:logcat:0.1" @@ -122,6 +122,7 @@ androidx-profileinstaller = { module = "androidx.profileinstaller:profileinstall [bundles] coil = ["coil-compose", "coil-gif", "coil-network"] compose = ["compose-foundation", "compose-material-icons-extended", "compose-material3", "compose-material3-adaptive", "compose-ui-util"] +http-client = ["conscrypt", "ktor-client-apache5"] kotlinx-serialization = ["serialization-cbor", "serialization-json", "serialization-json-okio", "serialization-xml"] splitties = ["splitties-appctx", "splitties-arch-room", "splitties-preferences", "splitties-systemservices"]