From 4f8e286f8ef5d1f7f7e9d7ed68236c8f55bc1627 Mon Sep 17 00:00:00 2001 From: Yuri Schimke Date: Fri, 16 Jan 2026 11:23:07 +0000 Subject: [PATCH 01/31] Testing ECH in Android Platform --- android-test/build.gradle.kts | 4 +- .../java/okhttp/android/test/EchTest.kt | 57 +++++++++ okhttp/build.gradle.kts | 4 +- .../platform/AndroidCanaryPlatform.kt | 119 ++++++++++++++++++ .../internal/platform/PlatformRegistry.kt | 1 + .../android/AndroidCanarySocketAdapter.kt | 106 ++++++++++++++++ 6 files changed, 289 insertions(+), 2 deletions(-) create mode 100644 android-test/src/androidTest/java/okhttp/android/test/EchTest.kt create mode 100644 okhttp/src/androidMain/kotlin/okhttp3/internal/platform/AndroidCanaryPlatform.kt create mode 100644 okhttp/src/androidMain/kotlin/okhttp3/internal/platform/android/AndroidCanarySocketAdapter.kt diff --git a/android-test/build.gradle.kts b/android-test/build.gradle.kts index e704ee806d08..56932f017ca7 100644 --- a/android-test/build.gradle.kts +++ b/android-test/build.gradle.kts @@ -9,7 +9,9 @@ plugins { val androidBuild = property("androidBuild").toString().toBoolean() android { - compileSdk = 35 + compileSdk { + version = preview("CANARY") + } namespace = "okhttp.android.test" diff --git a/android-test/src/androidTest/java/okhttp/android/test/EchTest.kt b/android-test/src/androidTest/java/okhttp/android/test/EchTest.kt new file mode 100644 index 000000000000..06752bd67bed --- /dev/null +++ b/android-test/src/androidTest/java/okhttp/android/test/EchTest.kt @@ -0,0 +1,57 @@ +/* + * Copyright (C) 2025 Block, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package okhttp.android.test + +import java.net.InetAddress +import okhttp3.Dns +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import org.junit.jupiter.api.Test + +class EchTest { + + private var client: OkHttpClient = + OkHttpClient + .Builder() + .dns { + if (it == "crypto.cloudflare.com") { + println("returning hints") + listOf(InetAddress.getByName("162.159.135.79"), InetAddress.getByName("162.159.136.79")) + } else { + Dns.SYSTEM.lookup(it) + } + } + .build() + + @Test + fun testHttpsRequest() { + sendRequest(Request.Builder().url("https://cloudflare-ech.com/").build()) { + } + + sendRequest(Request.Builder().url("https://crypto.cloudflare.com/cdn-cgi/trace").build()) { + println(it.body.string()) + } + } + + private fun sendRequest(request: Request, fn: (Response) -> Unit = {}) { + val response = client.newCall(request).execute() + + response.use { + fn(it) + } + } +} diff --git a/okhttp/build.gradle.kts b/okhttp/build.gradle.kts index b9b6e6861ee5..7d334f2780c6 100644 --- a/okhttp/build.gradle.kts +++ b/okhttp/build.gradle.kts @@ -188,7 +188,9 @@ if (platform == "jdk8alpn") { } android { - compileSdk = 35 + compileSdk { + version = preview("CANARY") + } namespace = "okhttp.okhttp3" diff --git a/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/AndroidCanaryPlatform.kt b/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/AndroidCanaryPlatform.kt new file mode 100644 index 000000000000..fffad46f4f61 --- /dev/null +++ b/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/AndroidCanaryPlatform.kt @@ -0,0 +1,119 @@ +/* + * Copyright (C) 2016 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package okhttp3.internal.platform + +import android.annotation.SuppressLint +import android.content.Context +import android.os.Build +import android.os.StrictMode +import android.security.NetworkSecurityPolicy +import android.util.CloseGuard +import android.util.Log +import androidx.annotation.ChecksSdkIntAtLeast +import androidx.annotation.RequiresApi +import javax.net.ssl.SSLContext +import javax.net.ssl.SSLSocket +import javax.net.ssl.SSLSocketFactory +import javax.net.ssl.X509TrustManager +import okhttp3.Protocol +import okhttp3.internal.SuppressSignatureCheck +import okhttp3.internal.platform.AndroidPlatform.Companion.Tag +import okhttp3.internal.platform.android.AndroidCanarySocketAdapter +import okhttp3.internal.platform.android.AndroidCertificateChainCleaner +import okhttp3.internal.tls.CertificateChainCleaner +import okhttp3.internal.tls.TrustRootIndex + +/** Android 10+ (API 29+). */ +@SuppressSignatureCheck +class AndroidCanaryPlatform +@RequiresApi(36) +internal constructor() : + Platform(), + ContextAwarePlatform { + init { + println("AndroidCanaryPlatform") + } + + override var applicationContext: Context? = null + + private val socketAdapter by lazy { + AndroidCanarySocketAdapter.buildIfSupported()!! + } + + override fun trustManager(sslSocketFactory: SSLSocketFactory): X509TrustManager? = + socketAdapter.trustManager(sslSocketFactory) + + override fun newSSLContext(): SSLContext { + StrictMode.noteSlowCall("newSSLContext") + + return super.newSSLContext() + } + + override fun buildTrustRootIndex(trustManager: X509TrustManager): TrustRootIndex { + StrictMode.noteSlowCall("buildTrustRootIndex") + + return super.buildTrustRootIndex(trustManager) + } + + override fun configureTlsExtensions( + sslSocket: SSLSocket, + hostname: String?, + protocols: List, + ) { + socketAdapter.configureTlsExtensions(sslSocket, hostname, protocols) + } + + override fun getSelectedProtocol(sslSocket: SSLSocket): String? = + socketAdapter.getSelectedProtocol(sslSocket) + + @RequiresApi(36) + override fun getStackTraceForCloseable(closer: String): Any = + CloseGuard().apply { open(closer) } + + @RequiresApi(36) + override fun logCloseableLeak( + message: String, + stackTrace: Any?, + ) { + (stackTrace as CloseGuard).warnIfOpen() + } + + @SuppressLint("NewApi") + override fun isCleartextTrafficPermitted(hostname: String): Boolean = + NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted(hostname) + + override fun buildCertificateChainCleaner(trustManager: X509TrustManager): CertificateChainCleaner = + AndroidCertificateChainCleaner.buildIfSupported(trustManager)!! + + override fun log( + message: String, + level: Int, + t: Throwable?, + ) { + if (level == WARN) { + Log.w(Tag, message, t) + } else { + Log.i(Tag, message, t) + } + } + + companion object { + val isSupported: Boolean = isAndroid && Build.VERSION.SDK_INT >= 36 + + @ChecksSdkIntAtLeast(36) + fun buildIfSupported(): Platform? = if (isSupported) AndroidCanaryPlatform() else null + } +} diff --git a/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/PlatformRegistry.kt b/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/PlatformRegistry.kt index 4c912f5e61c1..0edb0997aca0 100644 --- a/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/PlatformRegistry.kt +++ b/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/PlatformRegistry.kt @@ -25,6 +25,7 @@ actual object PlatformRegistry { AndroidLog.enable() val androidPlatform = + AndroidCanaryPlatform.buildIfSupported() ?: Android10Platform.buildIfSupported() ?: AndroidPlatform.buildIfSupported() if (androidPlatform != null) return androidPlatform diff --git a/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/android/AndroidCanarySocketAdapter.kt b/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/android/AndroidCanarySocketAdapter.kt new file mode 100644 index 000000000000..257c86c1e345 --- /dev/null +++ b/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/android/AndroidCanarySocketAdapter.kt @@ -0,0 +1,106 @@ +/* + * Copyright (C) 2019 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package okhttp3.internal.platform.android + +import android.annotation.SuppressLint +import android.net.ssl.EchConfigList +import android.net.ssl.SSLSockets +import android.os.Build +import androidx.annotation.ChecksSdkIntAtLeast +import androidx.annotation.RequiresApi +import javax.net.ssl.SSLSocket +import okhttp3.Protocol +import okhttp3.internal.SuppressSignatureCheck +import okhttp3.internal.platform.Platform +import okhttp3.internal.platform.Platform.Companion.isAndroid +import okio.ByteString.Companion.decodeHex + +/** + * Simple non-reflection SocketAdapter for Android Q+. + * + * These API assumptions make it unsuitable for use on earlier Android versions. + */ +@SuppressLint("NewApi") +@SuppressSignatureCheck +class AndroidCanarySocketAdapter +@RequiresApi(36) +internal constructor() : SocketAdapter { + init { + println("AndroidCanarySocketAdapter") + } + + override fun matchesSocket(sslSocket: SSLSocket): Boolean = + SSLSockets.isSupportedSocket(sslSocket) + + override fun isSupported(): Boolean = Companion.isSupported() + + override fun getSelectedProtocol(sslSocket: SSLSocket): String? = + // SSLSocket.getApplicationProtocol returns "" if application protocols values will not + // be used. Observed if you didn't specify SSLParameters.setApplicationProtocols + when (val protocol = sslSocket.applicationProtocol) { + null, "" -> null + else -> protocol + } + + override fun configureTlsExtensions( + sslSocket: SSLSocket, + hostname: String?, + protocols: List, + ) { + SSLSockets.setUseSessionTickets(sslSocket, true) + + val sslParameters = sslSocket.sslParameters + + // Enable ALPN. + sslParameters.applicationProtocols = Platform.alpnProtocolNames(protocols).toTypedArray() + + if (hostname == "cloudflare-ech.com") { + println("setting ECH") + SSLSockets.setEchConfigList( + sslSocket, + EchConfigList.fromBytes(cloudflareEchList.toByteArray()) + ) + } else if (hostname == "crypto.cloudflare.com") { + println("setting ECH") + SSLSockets.setEchConfigList( + sslSocket, + EchConfigList.fromBytes( + cryptoCloudflareEchList.toByteArray() + ) + ) + } + + sslSocket.sslParameters = sslParameters + } + + @SuppressSignatureCheck + companion object { + val cloudflareEchList = + "0045fe0d0041860020002058a2172489f01dcd0ff39adf7a40f2e791c72ba65d889ca06e8a4282a286710a0004000100010012636c6f7564666c6172652d6563682e636f6d0000".decodeHex() + + val cryptoCloudflareEchList = + "00 45 fe 0d 00 41 7f 00 20 00 20 58 40 10 23 63 d4 2a f1 76 3c 2e e1 87 fc de 2e 4f 8e d2 dd ff f6 f6 bb 5e c4 cf 04 a9 67 a1 4f 00 04 00 01 00 01 00 12 63 6c 6f 75 64 66 6c 61 72 65 2d 65 63 68 2e 63 6f 6d 00 00".replace( + " ", + "" + ).decodeHex() + + fun buildIfSupported(): SocketAdapter? = + if (isSupported()) AndroidCanarySocketAdapter() else null + + @ChecksSdkIntAtLeast(api = 36) + fun isSupported() = isAndroid && Build.VERSION.SDK_INT >= 36 + } +} From e21fd6816ba98e7949bcf1b33b0d0b304732650c Mon Sep 17 00:00:00 2001 From: Yuri Schimke Date: Fri, 16 Jan 2026 11:49:24 +0000 Subject: [PATCH 02/31] More testing --- android-test/build.gradle.kts | 2 +- .../java/okhttp/android/test/EchTest.kt | 23 +++++++++++++------ .../android/AndroidCanarySocketAdapter.kt | 15 ++++++++++++ 3 files changed, 32 insertions(+), 8 deletions(-) diff --git a/android-test/build.gradle.kts b/android-test/build.gradle.kts index 56932f017ca7..44451a9123b4 100644 --- a/android-test/build.gradle.kts +++ b/android-test/build.gradle.kts @@ -42,7 +42,7 @@ android { } testOptions { - targetSdk = 34 + targetSdk = 37 unitTests.isIncludeAndroidResources = true } diff --git a/android-test/src/androidTest/java/okhttp/android/test/EchTest.kt b/android-test/src/androidTest/java/okhttp/android/test/EchTest.kt index 06752bd67bed..7b09a2579458 100644 --- a/android-test/src/androidTest/java/okhttp/android/test/EchTest.kt +++ b/android-test/src/androidTest/java/okhttp/android/test/EchTest.kt @@ -20,6 +20,7 @@ import okhttp3.Dns import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.Response +import okio.IOException import org.junit.jupiter.api.Test class EchTest { @@ -28,12 +29,12 @@ class EchTest { OkHttpClient .Builder() .dns { - if (it == "crypto.cloudflare.com") { - println("returning hints") - listOf(InetAddress.getByName("162.159.135.79"), InetAddress.getByName("162.159.136.79")) - } else { +// if (it == "crypto.cloudflare.com") { +// println("returning hints") +// listOf(InetAddress.getByName("162.159.135.79"), InetAddress.getByName("162.159.136.79")) +// } else { Dns.SYSTEM.lookup(it) - } +// } } .build() @@ -45,13 +46,21 @@ class EchTest { sendRequest(Request.Builder().url("https://crypto.cloudflare.com/cdn-cgi/trace").build()) { println(it.body.string()) } + + sendRequest(Request.Builder().url("https://tls-ech.dev/").build()) { + println(it.body.string()) + } } private fun sendRequest(request: Request, fn: (Response) -> Unit = {}) { + try { val response = client.newCall(request).execute() - response.use { - fn(it) + response.use { + fn(it) + } + } catch (ioe: IOException) { + ioe.printStackTrace() } } } diff --git a/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/android/AndroidCanarySocketAdapter.kt b/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/android/AndroidCanarySocketAdapter.kt index 257c86c1e345..e984b7fbd9d5 100644 --- a/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/android/AndroidCanarySocketAdapter.kt +++ b/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/android/AndroidCanarySocketAdapter.kt @@ -81,6 +81,14 @@ internal constructor() : SocketAdapter { cryptoCloudflareEchList.toByteArray() ) ) + } else if (hostname == "tls-ech.dev") { + println("setting ECH") + SSLSockets.setEchConfigList( + sslSocket, + EchConfigList.fromBytes( + echDevList.toByteArray() + ) + ) } sslSocket.sslParameters = sslParameters @@ -97,6 +105,13 @@ internal constructor() : SocketAdapter { "" ).decodeHex() + val echDevList = + "00 49 fe 0d 00 45 2b 00 20 00 20 01 58 81 d4 1a 3e 2e f8 f2 20 81 85 dc 47 92 45 d2 06 24 dd d0 91 8a 80 56 f2 e2 6a f4 7e 26 28 00 08 00 01 00 01 00 01 00 03 40 12 70 75 62 6c 69 63 2e 74 6c 73 2d 65 63 68 2e 64 65 76 00 00".replace( + " ", + "" + ).decodeHex() + + fun buildIfSupported(): SocketAdapter? = if (isSupported()) AndroidCanarySocketAdapter() else null From 8b062b870d58a2d03f6832abfb29db0d8765e3c7 Mon Sep 17 00:00:00 2001 From: Yuri Schimke Date: Fri, 16 Jan 2026 14:22:56 +0000 Subject: [PATCH 03/31] Fixes --- android-test/build.gradle.kts | 3 + .../java/okhttp/android/test/EchTest.kt | 106 +++++++++++++++--- .../android/AndroidCanarySocketAdapter.kt | 47 ++------ 3 files changed, 99 insertions(+), 57 deletions(-) diff --git a/android-test/build.gradle.kts b/android-test/build.gradle.kts index 44451a9123b4..e345b6075b2d 100644 --- a/android-test/build.gradle.kts +++ b/android-test/build.gradle.kts @@ -108,6 +108,9 @@ dependencies { androidTestImplementation(libs.squareup.moshi.kotlin) androidTestImplementation(libs.squareup.okio.fakefilesystem) + //noinspection UseTomlInstead + androidTestImplementation("dnsjava:dnsjava:3.6.3") + androidTestImplementation(libs.androidx.test.runner) androidTestImplementation(libs.junit.jupiter.api) androidTestImplementation(libs.junit5android.core) diff --git a/android-test/src/androidTest/java/okhttp/android/test/EchTest.kt b/android-test/src/androidTest/java/okhttp/android/test/EchTest.kt index 7b09a2579458..f4b69417d1dc 100644 --- a/android-test/src/androidTest/java/okhttp/android/test/EchTest.kt +++ b/android-test/src/androidTest/java/okhttp/android/test/EchTest.kt @@ -15,46 +15,83 @@ */ package okhttp.android.test +import android.net.DnsResolver +import android.net.DnsResolver.Callback +import android.net.ssl.EchConfigList +import android.net.ssl.SSLSockets import java.net.InetAddress +import java.net.Socket +import java.util.concurrent.CompletableFuture +import java.util.concurrent.Future +import javax.net.ssl.SSLSocket +import okhttp3.DelegatingSSLSocketFactory import okhttp3.Dns import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.Response +import okhttp3.internal.platform.Platform import okio.IOException import org.junit.jupiter.api.Test +import org.xbill.DNS.HTTPSRecord +import org.xbill.DNS.Message +import org.xbill.DNS.SVCBBase +import org.xbill.DNS.Section.ANSWER class EchTest { - private var client: OkHttpClient = - OkHttpClient - .Builder() - .dns { -// if (it == "crypto.cloudflare.com") { -// println("returning hints") -// listOf(InetAddress.getByName("162.159.135.79"), InetAddress.getByName("162.159.136.79")) -// } else { - Dns.SYSTEM.lookup(it) -// } - } - .build() - @Test fun testHttpsRequest() { - sendRequest(Request.Builder().url("https://cloudflare-ech.com/").build()) { + val dns = EchDnsResolver() + + val trustManager = Platform.get().platformTrustManager() + + val sslSocketFactory = Platform.get().newSslSocketFactory(trustManager) + + val echSf = object : DelegatingSSLSocketFactory(sslSocketFactory) { + override fun createSocket( + socket: Socket, + host: String, + port: Int, + autoClose: Boolean + ): SSLSocket { + return super.createSocket(socket, host, port, autoClose).also { + val httpsRecord = dns.httpsRecords[host]?.get() + val echConfig = httpsRecord?.getSvcParamValue(HTTPSRecord.ECH) as SVCBBase.ParameterEch? + + println("config for $host $echConfig") + + if (echConfig != null) { + SSLSockets.setEchConfigList( + it, + EchConfigList.fromBytes(echConfig.data) + ) + } + } + } + } + + val client: OkHttpClient = + OkHttpClient + .Builder() + .dns(dns) + .sslSocketFactory(echSf, trustManager) + .build() + + client.sendRequest(Request.Builder().url("https://cloudflare-ech.com/").build()) { } - sendRequest(Request.Builder().url("https://crypto.cloudflare.com/cdn-cgi/trace").build()) { + client.sendRequest(Request.Builder().url("https://crypto.cloudflare.com/cdn-cgi/trace").build()) { println(it.body.string()) } - sendRequest(Request.Builder().url("https://tls-ech.dev/").build()) { + client.sendRequest(Request.Builder().url("https://tls-ech.dev/").build()) { println(it.body.string()) } } - private fun sendRequest(request: Request, fn: (Response) -> Unit = {}) { + private fun OkHttpClient.sendRequest(request: Request, fn: (Response) -> Unit = {}) { try { - val response = client.newCall(request).execute() + val response = newCall(request).execute() response.use { fn(it) @@ -64,3 +101,36 @@ class EchTest { } } } + +class EchDnsResolver : Dns { + val dnsResolver = DnsResolver.getInstance() + + val httpsRecords: MutableMap> = HashMap() + + override fun lookup(hostname: String): List { + val future = CompletableFuture() + + val callback: Callback = object : Callback { + override fun onAnswer(p0: ByteArray, p1: Int) { + val answers = Message(p0).getSection(ANSWER) + if (answers.isEmpty()) { + future.complete(null) + } else { + future.complete(answers.single() as HTTPSRecord) + } + } + + override fun onError(p0: DnsResolver.DnsException) { + future.completeExceptionally(p0) + } + } + dnsResolver.rawQuery( + null, hostname, DnsResolver.CLASS_IN, 65, DnsResolver.FLAG_EMPTY, + { it.run() }, null, + callback + ) + httpsRecords[hostname] = future + + return Dns.SYSTEM.lookup(hostname) + } +} diff --git a/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/android/AndroidCanarySocketAdapter.kt b/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/android/AndroidCanarySocketAdapter.kt index e984b7fbd9d5..486f9c22972e 100644 --- a/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/android/AndroidCanarySocketAdapter.kt +++ b/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/android/AndroidCanarySocketAdapter.kt @@ -16,7 +16,6 @@ package okhttp3.internal.platform.android import android.annotation.SuppressLint -import android.net.ssl.EchConfigList import android.net.ssl.SSLSockets import android.os.Build import androidx.annotation.ChecksSdkIntAtLeast @@ -26,7 +25,6 @@ import okhttp3.Protocol import okhttp3.internal.SuppressSignatureCheck import okhttp3.internal.platform.Platform import okhttp3.internal.platform.Platform.Companion.isAndroid -import okio.ByteString.Companion.decodeHex /** * Simple non-reflection SocketAdapter for Android Q+. @@ -67,49 +65,20 @@ internal constructor() : SocketAdapter { // Enable ALPN. sslParameters.applicationProtocols = Platform.alpnProtocolNames(protocols).toTypedArray() - if (hostname == "cloudflare-ech.com") { - println("setting ECH") - SSLSockets.setEchConfigList( - sslSocket, - EchConfigList.fromBytes(cloudflareEchList.toByteArray()) - ) - } else if (hostname == "crypto.cloudflare.com") { - println("setting ECH") - SSLSockets.setEchConfigList( - sslSocket, - EchConfigList.fromBytes( - cryptoCloudflareEchList.toByteArray() - ) - ) - } else if (hostname == "tls-ech.dev") { - println("setting ECH") - SSLSockets.setEchConfigList( - sslSocket, - EchConfigList.fromBytes( - echDevList.toByteArray() - ) - ) - } + +// println("setting ECH") +// SSLSockets.setEchConfigList( +// sslSocket, +// EchConfigList.fromBytes( +// echDevList.toByteArray() +// ) +// ) sslSocket.sslParameters = sslParameters } @SuppressSignatureCheck companion object { - val cloudflareEchList = - "0045fe0d0041860020002058a2172489f01dcd0ff39adf7a40f2e791c72ba65d889ca06e8a4282a286710a0004000100010012636c6f7564666c6172652d6563682e636f6d0000".decodeHex() - - val cryptoCloudflareEchList = - "00 45 fe 0d 00 41 7f 00 20 00 20 58 40 10 23 63 d4 2a f1 76 3c 2e e1 87 fc de 2e 4f 8e d2 dd ff f6 f6 bb 5e c4 cf 04 a9 67 a1 4f 00 04 00 01 00 01 00 12 63 6c 6f 75 64 66 6c 61 72 65 2d 65 63 68 2e 63 6f 6d 00 00".replace( - " ", - "" - ).decodeHex() - - val echDevList = - "00 49 fe 0d 00 45 2b 00 20 00 20 01 58 81 d4 1a 3e 2e f8 f2 20 81 85 dc 47 92 45 d2 06 24 dd d0 91 8a 80 56 f2 e2 6a f4 7e 26 28 00 08 00 01 00 01 00 01 00 03 40 12 70 75 62 6c 69 63 2e 74 6c 73 2d 65 63 68 2e 64 65 76 00 00".replace( - " ", - "" - ).decodeHex() fun buildIfSupported(): SocketAdapter? = From 5b46c8649a1c32109ecea360aaeff215e340cc5b Mon Sep 17 00:00:00 2001 From: Yuri Schimke Date: Sun, 15 Mar 2026 09:12:51 +0000 Subject: [PATCH 04/31] Fixes --- okhttp/build.gradle.kts | 35 +++++------------------------------ 1 file changed, 5 insertions(+), 30 deletions(-) diff --git a/okhttp/build.gradle.kts b/okhttp/build.gradle.kts index 75122bde712b..d6bd53a86d0e 100644 --- a/okhttp/build.gradle.kts +++ b/okhttp/build.gradle.kts @@ -55,9 +55,11 @@ kotlin { jvm { } - androidLibrary { + android { namespace = "okhttp.okhttp3" - compileSdk = 36 + compileSdk { + version = preview("CANARY") + } minSdk = 21 androidResources { @@ -207,34 +209,7 @@ if (platform == "jdk8alpn") { dependencies.create("org.mortbay.jetty.alpn:alpn-boot:$alpnBootVersion"), ).singleFile tasks.withType { - jvmArgs("-Xbootclasspath/p:${alpnBootJar}") - } - } -} - -android { - compileSdk { - version = preview("CANARY") - } - - namespace = "okhttp.okhttp3" - - defaultConfig { - minSdk = 21 - - consumerProguardFiles("okhttp3.pro") - } - - testOptions { - unitTests { - isIncludeAndroidResources = true - } - } - - sourceSets { - named("main") { - manifest.srcFile("src/androidMain/AndroidManifest.xml") - assets.srcDir("src/androidMain/assets") + jvmArgs("-Xbootclasspath/p:$alpnBootJar") } } } From 7e08fcb70a3dd9e55b768a4d69fe4bd2df6762c2 Mon Sep 17 00:00:00 2001 From: Yuri Schimke Date: Sun, 15 Mar 2026 09:18:03 +0000 Subject: [PATCH 05/31] Fixes --- android-test-app/build.gradle.kts | 4 +++- .../androidDeviceTest/java/okhttp/android/test/EchTest.kt | 5 ++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/android-test-app/build.gradle.kts b/android-test-app/build.gradle.kts index 345ea057f26f..362023ac2266 100644 --- a/android-test-app/build.gradle.kts +++ b/android-test-app/build.gradle.kts @@ -9,7 +9,9 @@ plugins { } android { - compileSdk = 36 + compileSdk { + version = preview("CANARY") + } namespace = "okhttp.android.testapp" diff --git a/android-test/src/androidDeviceTest/java/okhttp/android/test/EchTest.kt b/android-test/src/androidDeviceTest/java/okhttp/android/test/EchTest.kt index f4b69417d1dc..8386dd66cea1 100644 --- a/android-test/src/androidDeviceTest/java/okhttp/android/test/EchTest.kt +++ b/android-test/src/androidDeviceTest/java/okhttp/android/test/EchTest.kt @@ -78,9 +78,12 @@ class EchTest { .build() client.sendRequest(Request.Builder().url("https://cloudflare-ech.com/").build()) { + println(it.body.string()) } - client.sendRequest(Request.Builder().url("https://crypto.cloudflare.com/cdn-cgi/trace").build()) { + client.sendRequest( + Request.Builder().url("https://crypto.cloudflare.com/cdn-cgi/trace").build() + ) { println(it.body.string()) } From 9692d67e25b1888829fa28bfc66d23afe2977ccc Mon Sep 17 00:00:00 2001 From: Yuri Schimke Date: Sun, 15 Mar 2026 12:43:27 +0000 Subject: [PATCH 06/31] Fixes --- android-test/build.gradle.kts | 2 +- .../java/okhttp/android/test/EchTest.kt | 139 ------------------ .../README.md | 0 .../java/okhttp/android/test/EchTest.kt | 68 +++++++++ .../java/okhttp/android/test/OkHttpTest.kt | 0 .../okhttp/android/test/SingleAndroidTest.kt | 0 .../okhttp/android/test/StrictModeTest.kt | 0 .../android/test/alpn/AlpnOverrideTest.kt | 0 .../test/letsencrypt/LetsEncryptClientTest.kt | 0 .../android/test/sni/SniOverrideTest.kt | 0 gradle.properties | 2 + okhttp/build.gradle.kts | 3 + .../internal/platform/Android10Platform.kt | 2 +- ...CanaryPlatform.kt => Android17Platform.kt} | 133 +++++++++++++++-- .../platform/AndroidDnsResolverDns.kt | 49 ++++++ .../internal/platform/PlatformRegistry.kt | 2 +- .../kotlin/okhttp3/OkHttpClient.kt | 2 +- .../okhttp3/internal/platform/Platform.kt | 67 ++++++--- 18 files changed, 300 insertions(+), 169 deletions(-) delete mode 100644 android-test/src/androidDeviceTest/java/okhttp/android/test/EchTest.kt rename android-test/src/{androidDeviceTest => androidTest}/README.md (100%) create mode 100644 android-test/src/androidTest/java/okhttp/android/test/EchTest.kt rename android-test/src/{androidDeviceTest => androidTest}/java/okhttp/android/test/OkHttpTest.kt (100%) rename android-test/src/{androidDeviceTest => androidTest}/java/okhttp/android/test/SingleAndroidTest.kt (100%) rename android-test/src/{androidDeviceTest => androidTest}/java/okhttp/android/test/StrictModeTest.kt (100%) rename android-test/src/{androidDeviceTest => androidTest}/java/okhttp/android/test/alpn/AlpnOverrideTest.kt (100%) rename android-test/src/{androidDeviceTest => androidTest}/java/okhttp/android/test/letsencrypt/LetsEncryptClientTest.kt (100%) rename android-test/src/{androidDeviceTest => androidTest}/java/okhttp/android/test/sni/SniOverrideTest.kt (100%) rename okhttp/src/androidMain/kotlin/okhttp3/internal/platform/{AndroidCanaryPlatform.kt => Android17Platform.kt} (50%) create mode 100644 okhttp/src/androidMain/kotlin/okhttp3/internal/platform/AndroidDnsResolverDns.kt diff --git a/android-test/build.gradle.kts b/android-test/build.gradle.kts index eb8e6f4cf0c9..bbf89537476a 100644 --- a/android-test/build.gradle.kts +++ b/android-test/build.gradle.kts @@ -104,7 +104,7 @@ dependencies { androidTestImplementation(libs.square.okio.fakefilesystem) //noinspection UseTomlInstead - androidTestImplementation("dnsjava:dnsjava:3.6.3") + androidTestImplementation("dnsjava:dnsjava:3.6.4") androidTestImplementation(libs.androidx.test.runner) androidTestImplementation(libs.junit.jupiter.api) diff --git a/android-test/src/androidDeviceTest/java/okhttp/android/test/EchTest.kt b/android-test/src/androidDeviceTest/java/okhttp/android/test/EchTest.kt deleted file mode 100644 index 8386dd66cea1..000000000000 --- a/android-test/src/androidDeviceTest/java/okhttp/android/test/EchTest.kt +++ /dev/null @@ -1,139 +0,0 @@ -/* - * Copyright (C) 2025 Block, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package okhttp.android.test - -import android.net.DnsResolver -import android.net.DnsResolver.Callback -import android.net.ssl.EchConfigList -import android.net.ssl.SSLSockets -import java.net.InetAddress -import java.net.Socket -import java.util.concurrent.CompletableFuture -import java.util.concurrent.Future -import javax.net.ssl.SSLSocket -import okhttp3.DelegatingSSLSocketFactory -import okhttp3.Dns -import okhttp3.OkHttpClient -import okhttp3.Request -import okhttp3.Response -import okhttp3.internal.platform.Platform -import okio.IOException -import org.junit.jupiter.api.Test -import org.xbill.DNS.HTTPSRecord -import org.xbill.DNS.Message -import org.xbill.DNS.SVCBBase -import org.xbill.DNS.Section.ANSWER - -class EchTest { - - @Test - fun testHttpsRequest() { - val dns = EchDnsResolver() - - val trustManager = Platform.get().platformTrustManager() - - val sslSocketFactory = Platform.get().newSslSocketFactory(trustManager) - - val echSf = object : DelegatingSSLSocketFactory(sslSocketFactory) { - override fun createSocket( - socket: Socket, - host: String, - port: Int, - autoClose: Boolean - ): SSLSocket { - return super.createSocket(socket, host, port, autoClose).also { - val httpsRecord = dns.httpsRecords[host]?.get() - val echConfig = httpsRecord?.getSvcParamValue(HTTPSRecord.ECH) as SVCBBase.ParameterEch? - - println("config for $host $echConfig") - - if (echConfig != null) { - SSLSockets.setEchConfigList( - it, - EchConfigList.fromBytes(echConfig.data) - ) - } - } - } - } - - val client: OkHttpClient = - OkHttpClient - .Builder() - .dns(dns) - .sslSocketFactory(echSf, trustManager) - .build() - - client.sendRequest(Request.Builder().url("https://cloudflare-ech.com/").build()) { - println(it.body.string()) - } - - client.sendRequest( - Request.Builder().url("https://crypto.cloudflare.com/cdn-cgi/trace").build() - ) { - println(it.body.string()) - } - - client.sendRequest(Request.Builder().url("https://tls-ech.dev/").build()) { - println(it.body.string()) - } - } - - private fun OkHttpClient.sendRequest(request: Request, fn: (Response) -> Unit = {}) { - try { - val response = newCall(request).execute() - - response.use { - fn(it) - } - } catch (ioe: IOException) { - ioe.printStackTrace() - } - } -} - -class EchDnsResolver : Dns { - val dnsResolver = DnsResolver.getInstance() - - val httpsRecords: MutableMap> = HashMap() - - override fun lookup(hostname: String): List { - val future = CompletableFuture() - - val callback: Callback = object : Callback { - override fun onAnswer(p0: ByteArray, p1: Int) { - val answers = Message(p0).getSection(ANSWER) - if (answers.isEmpty()) { - future.complete(null) - } else { - future.complete(answers.single() as HTTPSRecord) - } - } - - override fun onError(p0: DnsResolver.DnsException) { - future.completeExceptionally(p0) - } - } - dnsResolver.rawQuery( - null, hostname, DnsResolver.CLASS_IN, 65, DnsResolver.FLAG_EMPTY, - { it.run() }, null, - callback - ) - httpsRecords[hostname] = future - - return Dns.SYSTEM.lookup(hostname) - } -} diff --git a/android-test/src/androidDeviceTest/README.md b/android-test/src/androidTest/README.md similarity index 100% rename from android-test/src/androidDeviceTest/README.md rename to android-test/src/androidTest/README.md diff --git a/android-test/src/androidTest/java/okhttp/android/test/EchTest.kt b/android-test/src/androidTest/java/okhttp/android/test/EchTest.kt new file mode 100644 index 000000000000..c0bea00501c8 --- /dev/null +++ b/android-test/src/androidTest/java/okhttp/android/test/EchTest.kt @@ -0,0 +1,68 @@ +/* + * Copyright (C) 2025 Block, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package okhttp.android.test + +import android.net.ssl.EchConfigList +import android.net.ssl.SSLSockets +import java.net.Socket +import javax.net.ssl.SSLSocket +import okhttp3.DelegatingSSLSocketFactory +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import okhttp3.internal.platform.AndroidDnsResolverDns +import okhttp3.internal.platform.Platform +import okio.IOException +import org.junit.jupiter.api.Test +import org.xbill.DNS.HTTPSRecord +import org.xbill.DNS.SVCBBase + +class EchTest { + + @Test + fun testHttpsRequest() { + val client: OkHttpClient = + OkHttpClient + .Builder() + .build() + + client.sendRequest(Request.Builder().url("https://cloudflare-ech.com/").build()) { + println(it.body.string()) + } + + client.sendRequest( + Request.Builder().url("https://crypto.cloudflare.com/cdn-cgi/trace").build() + ) { + println(it.body.string()) + } + + client.sendRequest(Request.Builder().url("https://tls-ech.dev/").build()) { + println(it.body.string()) + } + } + + private fun OkHttpClient.sendRequest(request: Request, fn: (Response) -> Unit = {}) { + try { + val response = newCall(request).execute() + + response.use { + fn(it) + } + } catch (ioe: IOException) { + ioe.printStackTrace() + } + } +} diff --git a/android-test/src/androidDeviceTest/java/okhttp/android/test/OkHttpTest.kt b/android-test/src/androidTest/java/okhttp/android/test/OkHttpTest.kt similarity index 100% rename from android-test/src/androidDeviceTest/java/okhttp/android/test/OkHttpTest.kt rename to android-test/src/androidTest/java/okhttp/android/test/OkHttpTest.kt diff --git a/android-test/src/androidDeviceTest/java/okhttp/android/test/SingleAndroidTest.kt b/android-test/src/androidTest/java/okhttp/android/test/SingleAndroidTest.kt similarity index 100% rename from android-test/src/androidDeviceTest/java/okhttp/android/test/SingleAndroidTest.kt rename to android-test/src/androidTest/java/okhttp/android/test/SingleAndroidTest.kt diff --git a/android-test/src/androidDeviceTest/java/okhttp/android/test/StrictModeTest.kt b/android-test/src/androidTest/java/okhttp/android/test/StrictModeTest.kt similarity index 100% rename from android-test/src/androidDeviceTest/java/okhttp/android/test/StrictModeTest.kt rename to android-test/src/androidTest/java/okhttp/android/test/StrictModeTest.kt diff --git a/android-test/src/androidDeviceTest/java/okhttp/android/test/alpn/AlpnOverrideTest.kt b/android-test/src/androidTest/java/okhttp/android/test/alpn/AlpnOverrideTest.kt similarity index 100% rename from android-test/src/androidDeviceTest/java/okhttp/android/test/alpn/AlpnOverrideTest.kt rename to android-test/src/androidTest/java/okhttp/android/test/alpn/AlpnOverrideTest.kt diff --git a/android-test/src/androidDeviceTest/java/okhttp/android/test/letsencrypt/LetsEncryptClientTest.kt b/android-test/src/androidTest/java/okhttp/android/test/letsencrypt/LetsEncryptClientTest.kt similarity index 100% rename from android-test/src/androidDeviceTest/java/okhttp/android/test/letsencrypt/LetsEncryptClientTest.kt rename to android-test/src/androidTest/java/okhttp/android/test/letsencrypt/LetsEncryptClientTest.kt diff --git a/android-test/src/androidDeviceTest/java/okhttp/android/test/sni/SniOverrideTest.kt b/android-test/src/androidTest/java/okhttp/android/test/sni/SniOverrideTest.kt similarity index 100% rename from android-test/src/androidDeviceTest/java/okhttp/android/test/sni/SniOverrideTest.kt rename to android-test/src/androidTest/java/okhttp/android/test/sni/SniOverrideTest.kt diff --git a/gradle.properties b/gradle.properties index 163334ebdfca..2f28e408cd27 100644 --- a/gradle.properties +++ b/gradle.properties @@ -21,3 +21,5 @@ org.gradle.jvmargs='-Dfile.encoding=UTF-8' # AGP 9.0 Settings android.builtInKotlin=true android.newDsl=true + +android.suppressUnsupportedCompileSdk=CANARY diff --git a/okhttp/build.gradle.kts b/okhttp/build.gradle.kts index d6bd53a86d0e..261225baa7cc 100644 --- a/okhttp/build.gradle.kts +++ b/okhttp/build.gradle.kts @@ -123,6 +123,9 @@ kotlin { compileOnly(libs.conscrypt.openjdk) implementation(libs.androidx.annotation) implementation(libs.androidx.startup.runtime) + + //noinspection UseTomlInstead + implementation("dnsjava:dnsjava:3.6.4") } } diff --git a/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/Android10Platform.kt b/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/Android10Platform.kt index 671427de4e67..091c654c8063 100644 --- a/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/Android10Platform.kt +++ b/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/Android10Platform.kt @@ -40,7 +40,7 @@ import okhttp3.internal.tls.TrustRootIndex /** Android 10+ (API 29+). */ @SuppressSignatureCheck -class Android10Platform : +open class Android10Platform : Platform(), ContextAwarePlatform { override var applicationContext: Context? = null diff --git a/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/AndroidCanaryPlatform.kt b/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/Android17Platform.kt similarity index 50% rename from okhttp/src/androidMain/kotlin/okhttp3/internal/platform/AndroidCanaryPlatform.kt rename to okhttp/src/androidMain/kotlin/okhttp3/internal/platform/Android17Platform.kt index fffad46f4f61..af04dd74e622 100644 --- a/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/AndroidCanaryPlatform.kt +++ b/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/Android17Platform.kt @@ -17,6 +17,8 @@ package okhttp3.internal.platform import android.annotation.SuppressLint import android.content.Context +import android.net.ssl.EchConfigList +import android.net.ssl.SSLSockets import android.os.Build import android.os.StrictMode import android.security.NetworkSecurityPolicy @@ -24,10 +26,14 @@ import android.util.CloseGuard import android.util.Log import androidx.annotation.ChecksSdkIntAtLeast import androidx.annotation.RequiresApi +import java.io.IOException +import java.net.InetAddress +import java.net.Socket import javax.net.ssl.SSLContext import javax.net.ssl.SSLSocket import javax.net.ssl.SSLSocketFactory import javax.net.ssl.X509TrustManager +import okhttp3.Dns import okhttp3.Protocol import okhttp3.internal.SuppressSignatureCheck import okhttp3.internal.platform.AndroidPlatform.Companion.Tag @@ -35,17 +41,19 @@ import okhttp3.internal.platform.android.AndroidCanarySocketAdapter import okhttp3.internal.platform.android.AndroidCertificateChainCleaner import okhttp3.internal.tls.CertificateChainCleaner import okhttp3.internal.tls.TrustRootIndex +import org.xbill.DNS.HTTPSRecord +import org.xbill.DNS.SVCBBase -/** Android 10+ (API 29+). */ +/** Android 17+ (API 29+). */ @SuppressSignatureCheck -class AndroidCanaryPlatform -@RequiresApi(36) +class Android17Platform +@RequiresApi(37) internal constructor() : Platform(), ContextAwarePlatform { - init { - println("AndroidCanaryPlatform") - } + init { + println("Android17Platform") + } override var applicationContext: Context? = null @@ -95,6 +103,16 @@ internal constructor() : override fun isCleartextTrafficPermitted(hostname: String): Boolean = NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted(hostname) + override val echModeConfiguration: EchModeConfiguration = object : EchModeConfiguration { + @SuppressLint("NewApi") + override fun echMode(hostname: String): EchMode { + return EchMode.fromNetworkSecurityPolicy( + NetworkSecurityPolicy.getInstance().getDomainEncryptionMode(hostname) + ) + } + } + + override fun buildCertificateChainCleaner(trustManager: X509TrustManager): CertificateChainCleaner = AndroidCertificateChainCleaner.buildIfSupported(trustManager)!! @@ -110,10 +128,107 @@ internal constructor() : } } + @RequiresApi(37) + private val androidDns = AndroidDnsResolverDns() + + @SuppressLint("NewApi") + override fun platformDns(): Dns = androidDns + + @SuppressLint("NewApi") + override fun newSslSocketFactory(trustManager: X509TrustManager): SSLSocketFactory { + return Android17SSLSocketFactory(super.newSslSocketFactory(trustManager), androidDns, echModeConfiguration) + } + companion object { - val isSupported: Boolean = isAndroid && Build.VERSION.SDK_INT >= 36 + val isSupported: Boolean = isAndroid && Build.VERSION.SDK_INT >= 37 + + @ChecksSdkIntAtLeast(37) + fun buildIfSupported(): Platform? = if (isSupported) Android17Platform() else null + } +} + +@RequiresApi(37) +class Android17SSLSocketFactory(private val delegate: SSLSocketFactory, private val dns: AndroidDnsResolverDns, private val echModeConfiguration: EchModeConfiguration): SSLSocketFactory() { + @Throws(IOException::class) + override fun createSocket(): SSLSocket { + TODO() + } + + @Throws(IOException::class) + override fun createSocket( + host: String, + port: Int, + ): SSLSocket { + TODO() + } + + @Throws(IOException::class) + override fun createSocket( + host: String, + port: Int, + localAddress: InetAddress, + localPort: Int, + ): SSLSocket { + TODO() + } + + @Throws(IOException::class) + override fun createSocket( + host: InetAddress, + port: Int, + ): SSLSocket { + TODO() + } + + @Throws(IOException::class) + override fun createSocket( + host: InetAddress, + port: Int, + localAddress: InetAddress, + localPort: Int, + ): SSLSocket { + TODO() + } + + override fun getDefaultCipherSuites(): Array = delegate.defaultCipherSuites + + override fun getSupportedCipherSuites(): Array = delegate.supportedCipherSuites + + @Throws(IOException::class) + @Suppress("NewApi") + override fun createSocket( + socket: Socket, + host: String, + port: Int, + autoClose: Boolean, + ): SSLSocket { + val sslSocket = delegate.createSocket(socket, host, port, autoClose) as SSLSocket + + val echMode = echModeConfiguration.echMode(host) + if (echMode.attempt) { + // TODO check require + val httpsRecord = dns.httpsRecords[host]?.get() + val echConfig = httpsRecord?.getSvcParamValue(HTTPSRecord.ECH) as SVCBBase.ParameterEch? + + println("config for $host $echConfig") + + if (echConfig != null) { + SSLSockets.setEchConfigList( + sslSocket, + EchConfigList.fromBytes(echConfig.data) + ) + } + } + + return sslSocket + } +} - @ChecksSdkIntAtLeast(36) - fun buildIfSupported(): Platform? = if (isSupported) AndroidCanaryPlatform() else null +private fun EchMode.Companion.fromNetworkSecurityPolicy(domainEncryptionMode: Int): EchMode { + return when (domainEncryptionMode) { + NetworkSecurityPolicy.DOMAIN_ENCRYPTION_MODE_OPPORTUNISTIC -> EchMode.Opportunistic + NetworkSecurityPolicy.DOMAIN_ENCRYPTION_MODE_ENABLED -> EchMode.Strict + NetworkSecurityPolicy.DOMAIN_ENCRYPTION_MODE_DISABLED -> EchMode.Disabled + else -> EchMode.Unspecified } } diff --git a/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/AndroidDnsResolverDns.kt b/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/AndroidDnsResolverDns.kt new file mode 100644 index 000000000000..c4d22a0d2ea1 --- /dev/null +++ b/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/AndroidDnsResolverDns.kt @@ -0,0 +1,49 @@ +package okhttp3.internal.platform + +import android.net.DnsResolver +import android.net.DnsResolver.Callback +import androidx.annotation.RequiresApi +import java.net.InetAddress +import java.util.concurrent.CompletableFuture +import java.util.concurrent.Future +import okhttp3.Dns +import org.xbill.DNS.HTTPSRecord +import org.xbill.DNS.Message +import org.xbill.DNS.Section.ANSWER + +@Suppress("NewApi") +@RequiresApi(37) +class AndroidDnsResolverDns : Dns { + val dnsResolver = DnsResolver.getInstance() + + val httpsRecords: MutableMap> = HashMap() + + override fun lookup(hostname: String): List { + val future = CompletableFuture() + + val callback: Callback = object : Callback { + override fun onAnswer(p0: ByteArray, p1: Int) { + val answers = Message(p0).getSection(ANSWER) + if (answers.isEmpty()) { + future.complete(null) + } else { + future.complete(answers.single() as HTTPSRecord) + } + } + + override fun onError(p0: DnsResolver.DnsException) { + future.completeExceptionally(p0) + } + } + @Suppress("WrongConstant") + dnsResolver.rawQuery( + null, hostname, DnsResolver.CLASS_IN, 65, DnsResolver.FLAG_EMPTY, + { it.run() }, null, + callback + ) + httpsRecords[hostname] = future + + // TODO replace with DnsResolver call + return Dns.SYSTEM.lookup(hostname) + } +} diff --git a/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/PlatformRegistry.kt b/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/PlatformRegistry.kt index 0edb0997aca0..25317c453bd4 100644 --- a/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/PlatformRegistry.kt +++ b/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/PlatformRegistry.kt @@ -25,7 +25,7 @@ actual object PlatformRegistry { AndroidLog.enable() val androidPlatform = - AndroidCanaryPlatform.buildIfSupported() ?: + Android17Platform.buildIfSupported() ?: Android10Platform.buildIfSupported() ?: AndroidPlatform.buildIfSupported() if (androidPlatform != null) return androidPlatform diff --git a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/OkHttpClient.kt b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/OkHttpClient.kt index bea47f62e899..ff478913b0d2 100644 --- a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/OkHttpClient.kt +++ b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/OkHttpClient.kt @@ -597,7 +597,7 @@ open class OkHttpClient internal constructor( internal var followSslRedirects = true internal var cookieJar: CookieJar = CookieJar.NO_COOKIES internal var cache: Cache? = null - internal var dns: Dns = Dns.SYSTEM + internal var dns: Dns = Platform.get().platformDns() internal var proxy: Proxy? = null internal var proxySelector: ProxySelector? = null internal var proxyAuthenticator: Authenticator = Authenticator.NONE diff --git a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/platform/Platform.kt b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/platform/Platform.kt index f33d97972b94..5c46495e8469 100644 --- a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/platform/Platform.kt +++ b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/platform/Platform.kt @@ -31,6 +31,7 @@ import javax.net.ssl.SSLSocketFactory import javax.net.ssl.TrustManager import javax.net.ssl.TrustManagerFactory import javax.net.ssl.X509TrustManager +import okhttp3.Dns import okhttp3.OkHttpClient import okhttp3.Protocol import okhttp3.internal.publicsuffix.PublicSuffixDatabase @@ -76,10 +77,9 @@ open class Platform { open fun newSSLContext(): SSLContext = SSLContext.getInstance("TLS") open fun platformTrustManager(): X509TrustManager { - val factory = - TrustManagerFactory.getInstance( - TrustManagerFactory.getDefaultAlgorithm(), - ) + val factory = TrustManagerFactory.getInstance( + TrustManagerFactory.getDefaultAlgorithm(), + ) factory.init(null as KeyStore?) val trustManagers = factory.trustManagers!! check(trustManagers.size == 1 && trustManagers[0] is X509TrustManager) { @@ -160,18 +160,20 @@ open class Platform { open fun isCleartextTrafficPermitted(hostname: String): Boolean = true + open val echModeConfiguration: EchModeConfiguration + get() = EchModeConfiguration.Unspecified + /** * Returns an object that holds a stack trace created at the moment this method is executed. This * should be used specifically for [java.io.Closeable] objects and in conjunction with * [logCloseableLeak]. */ - open fun getStackTraceForCloseable(closer: String): Any? = - when { - logger.isLoggable(Level.FINE) -> Throwable(closer) + open fun getStackTraceForCloseable(closer: String): Any? = when { + logger.isLoggable(Level.FINE) -> Throwable(closer) - // These are expensive to allocate. - else -> null - } + // These are expensive to allocate. + else -> null + } open fun logCloseableLeak( message: String, @@ -179,8 +181,7 @@ open class Platform { ) { var logMessage = message if (stackTrace == null) { - logMessage += " To see where this was allocated, set the OkHttpClient logger level to " + - "FINE: Logger.getLogger(OkHttpClient.class.getName()).setLevel(Level.FINE);" + logMessage += " To see where this was allocated, set the OkHttpClient logger level to " + "FINE: Logger.getLogger(OkHttpClient.class.getName()).setLevel(Level.FINE);" } log(logMessage, WARN, stackTrace as Throwable?) } @@ -188,12 +189,12 @@ open class Platform { open fun buildCertificateChainCleaner(trustManager: X509TrustManager): CertificateChainCleaner = BasicCertificateChainCleaner(buildTrustRootIndex(trustManager)) - open fun buildTrustRootIndex(trustManager: X509TrustManager): TrustRootIndex = BasicTrustRootIndex(*trustManager.acceptedIssuers) + open fun buildTrustRootIndex(trustManager: X509TrustManager): TrustRootIndex = + BasicTrustRootIndex(*trustManager.acceptedIssuers) open fun newSslSocketFactory(trustManager: X509TrustManager): SSLSocketFactory { try { - return newSSLContext() - .apply { + return newSSLContext().apply { init(null, arrayOf(trustManager), null) }.socketFactory } catch (e: GeneralSecurityException) { @@ -201,10 +202,13 @@ open class Platform { } } + open fun platformDns(): Dns = Dns.SYSTEM + override fun toString(): String = javaClass.simpleName companion object { - @Volatile private var platform = findPlatform() + @Volatile + private var platform = findPlatform() const val INFO = 4 const val WARN = 5 @@ -219,7 +223,8 @@ open class Platform { PublicSuffixDatabase.resetForTests() } - fun alpnProtocolNames(protocols: List) = protocols.filter { it != Protocol.HTTP_1_0 }.map { it.toString() } + fun alpnProtocolNames(protocols: List) = + protocols.filter { it != Protocol.HTTP_1_0 }.map { it.toString() } val isAndroid: Boolean get() = PlatformRegistry.isAndroid @@ -241,3 +246,31 @@ open class Platform { } } } + +interface EchModeConfiguration { + open fun echMode(hostname: String): EchMode + + companion object { + val Unspecified = object : EchModeConfiguration { + override fun echMode(hostname: String): EchMode { + return EchMode.Unspecified + } + } + } +} + +enum class EchMode(val attempt: Boolean, val require: Boolean) { + Unspecified(attempt = false, require = false), + Disabled( + attempt = false, require = false + ), + Opportunistic( + attempt = true, require = false + ), + Strict( + attempt = true, require = false + ), + FailClosed(attempt = true, require = true); + + companion object +} From 7ec38bf92f19656ea844299c57c674d79b00cb80 Mon Sep 17 00:00:00 2001 From: Yuri Schimke Date: Sun, 15 Mar 2026 18:58:38 +0000 Subject: [PATCH 07/31] Fixes --- .../src/androidTest/java/okhttp/android/test/EchTest.kt | 9 --------- 1 file changed, 9 deletions(-) diff --git a/android-test/src/androidTest/java/okhttp/android/test/EchTest.kt b/android-test/src/androidTest/java/okhttp/android/test/EchTest.kt index c0bea00501c8..602295849f83 100644 --- a/android-test/src/androidTest/java/okhttp/android/test/EchTest.kt +++ b/android-test/src/androidTest/java/okhttp/android/test/EchTest.kt @@ -15,20 +15,11 @@ */ package okhttp.android.test -import android.net.ssl.EchConfigList -import android.net.ssl.SSLSockets -import java.net.Socket -import javax.net.ssl.SSLSocket -import okhttp3.DelegatingSSLSocketFactory import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.Response -import okhttp3.internal.platform.AndroidDnsResolverDns -import okhttp3.internal.platform.Platform import okio.IOException import org.junit.jupiter.api.Test -import org.xbill.DNS.HTTPSRecord -import org.xbill.DNS.SVCBBase class EchTest { From 33c62cd2573774b12beb7ab0f8e1157e4d0aa043 Mon Sep 17 00:00:00 2001 From: Yuri Schimke Date: Mon, 16 Mar 2026 09:38:37 +0000 Subject: [PATCH 08/31] More fixes --- .../main/res/xml/network_security_config.xml | 8 +- .../internal/platform/Android17Platform.kt | 112 ++++-------------- .../platform/AndroidDnsResolverDns.kt | 2 +- .../internal/connection/ConnectPlan.kt | 2 + 4 files changed, 36 insertions(+), 88 deletions(-) diff --git a/android-test/src/main/res/xml/network_security_config.xml b/android-test/src/main/res/xml/network_security_config.xml index 786dddecc784..628e3f7bc01f 100644 --- a/android-test/src/main/res/xml/network_security_config.xml +++ b/android-test/src/main/res/xml/network_security_config.xml @@ -2,4 +2,10 @@ - \ No newline at end of file + + cloudflare-ech.com + crypto.cloudflare.com + tls-ech.dev + + + diff --git a/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/Android17Platform.kt b/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/Android17Platform.kt index af04dd74e622..ac323632d714 100644 --- a/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/Android17Platform.kt +++ b/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/Android17Platform.kt @@ -47,7 +47,7 @@ import org.xbill.DNS.SVCBBase /** Android 17+ (API 29+). */ @SuppressSignatureCheck class Android17Platform -@RequiresApi(37) +@RequiresApi(36) internal constructor() : Platform(), ContextAwarePlatform { @@ -76,12 +76,32 @@ internal constructor() : return super.buildTrustRootIndex(trustManager) } + @Suppress("NewApi") + @RequiresApi(37) override fun configureTlsExtensions( sslSocket: SSLSocket, hostname: String?, protocols: List, ) { socketAdapter.configureTlsExtensions(sslSocket, hostname, protocols) + + if (hostname != null) { + val echMode = echModeConfiguration.echMode(hostname) + if (echMode.attempt) { + // TODO check require + val httpsRecord = androidDns.httpsRecords[hostname]?.get() + val echConfig = httpsRecord?.getSvcParamValue(HTTPSRecord.ECH) as SVCBBase.ParameterEch? + + println("config for $hostname $echConfig") + + if (echConfig != null) { + SSLSockets.setEchConfigList( + sslSocket, + EchConfigList.fromBytes(echConfig.data) + ) + } + } + } } override fun getSelectedProtocol(sslSocket: SSLSocket): String? = @@ -128,102 +148,22 @@ internal constructor() : } } - @RequiresApi(37) + + + @RequiresApi(36) private val androidDns = AndroidDnsResolverDns() @SuppressLint("NewApi") override fun platformDns(): Dns = androidDns - @SuppressLint("NewApi") - override fun newSslSocketFactory(trustManager: X509TrustManager): SSLSocketFactory { - return Android17SSLSocketFactory(super.newSslSocketFactory(trustManager), androidDns, echModeConfiguration) - } - companion object { - val isSupported: Boolean = isAndroid && Build.VERSION.SDK_INT >= 37 + val isSupported: Boolean = isAndroid && Build.VERSION.SDK_INT >= 36 - @ChecksSdkIntAtLeast(37) + @ChecksSdkIntAtLeast(36) fun buildIfSupported(): Platform? = if (isSupported) Android17Platform() else null } } -@RequiresApi(37) -class Android17SSLSocketFactory(private val delegate: SSLSocketFactory, private val dns: AndroidDnsResolverDns, private val echModeConfiguration: EchModeConfiguration): SSLSocketFactory() { - @Throws(IOException::class) - override fun createSocket(): SSLSocket { - TODO() - } - - @Throws(IOException::class) - override fun createSocket( - host: String, - port: Int, - ): SSLSocket { - TODO() - } - - @Throws(IOException::class) - override fun createSocket( - host: String, - port: Int, - localAddress: InetAddress, - localPort: Int, - ): SSLSocket { - TODO() - } - - @Throws(IOException::class) - override fun createSocket( - host: InetAddress, - port: Int, - ): SSLSocket { - TODO() - } - - @Throws(IOException::class) - override fun createSocket( - host: InetAddress, - port: Int, - localAddress: InetAddress, - localPort: Int, - ): SSLSocket { - TODO() - } - - override fun getDefaultCipherSuites(): Array = delegate.defaultCipherSuites - - override fun getSupportedCipherSuites(): Array = delegate.supportedCipherSuites - - @Throws(IOException::class) - @Suppress("NewApi") - override fun createSocket( - socket: Socket, - host: String, - port: Int, - autoClose: Boolean, - ): SSLSocket { - val sslSocket = delegate.createSocket(socket, host, port, autoClose) as SSLSocket - - val echMode = echModeConfiguration.echMode(host) - if (echMode.attempt) { - // TODO check require - val httpsRecord = dns.httpsRecords[host]?.get() - val echConfig = httpsRecord?.getSvcParamValue(HTTPSRecord.ECH) as SVCBBase.ParameterEch? - - println("config for $host $echConfig") - - if (echConfig != null) { - SSLSockets.setEchConfigList( - sslSocket, - EchConfigList.fromBytes(echConfig.data) - ) - } - } - - return sslSocket - } -} - private fun EchMode.Companion.fromNetworkSecurityPolicy(domainEncryptionMode: Int): EchMode { return when (domainEncryptionMode) { NetworkSecurityPolicy.DOMAIN_ENCRYPTION_MODE_OPPORTUNISTIC -> EchMode.Opportunistic diff --git a/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/AndroidDnsResolverDns.kt b/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/AndroidDnsResolverDns.kt index c4d22a0d2ea1..42e3cc3d6bb0 100644 --- a/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/AndroidDnsResolverDns.kt +++ b/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/AndroidDnsResolverDns.kt @@ -12,7 +12,7 @@ import org.xbill.DNS.Message import org.xbill.DNS.Section.ANSWER @Suppress("NewApi") -@RequiresApi(37) +@RequiresApi(36) class AndroidDnsResolverDns : Dns { val dnsResolver = DnsResolver.getInstance() diff --git a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/connection/ConnectPlan.kt b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/connection/ConnectPlan.kt index e90421ccdc44..ae440df32832 100644 --- a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/connection/ConnectPlan.kt +++ b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/connection/ConnectPlan.kt @@ -405,6 +405,8 @@ class ConnectPlan internal constructor( socket = sslSocket.asBufferedSocket() protocol = if (maybeProtocol != null) Protocol.get(maybeProtocol) else Protocol.HTTP_1_1 success = true +// } catch (echre: EchRejectedException) { + // TODO signal for retry? } finally { Platform.get().afterHandshake(sslSocket) if (!success) { From 2ea576ea8ed2c2d25d84350bc7a2de29f206e546 Mon Sep 17 00:00:00 2001 From: Yuri Schimke Date: Mon, 16 Mar 2026 09:43:29 +0000 Subject: [PATCH 09/31] More fixes --- .../kotlin/okhttp3/internal/platform/Android17Platform.kt | 7 ++----- ...oidCanarySocketAdapter.kt => Android17SocketAdapter.kt} | 6 +++--- 2 files changed, 5 insertions(+), 8 deletions(-) rename okhttp/src/androidMain/kotlin/okhttp3/internal/platform/android/{AndroidCanarySocketAdapter.kt => Android17SocketAdapter.kt} (95%) diff --git a/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/Android17Platform.kt b/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/Android17Platform.kt index ac323632d714..1b81eb61fa5a 100644 --- a/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/Android17Platform.kt +++ b/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/Android17Platform.kt @@ -26,9 +26,6 @@ import android.util.CloseGuard import android.util.Log import androidx.annotation.ChecksSdkIntAtLeast import androidx.annotation.RequiresApi -import java.io.IOException -import java.net.InetAddress -import java.net.Socket import javax.net.ssl.SSLContext import javax.net.ssl.SSLSocket import javax.net.ssl.SSLSocketFactory @@ -37,7 +34,7 @@ import okhttp3.Dns import okhttp3.Protocol import okhttp3.internal.SuppressSignatureCheck import okhttp3.internal.platform.AndroidPlatform.Companion.Tag -import okhttp3.internal.platform.android.AndroidCanarySocketAdapter +import okhttp3.internal.platform.android.Android17SocketAdapter import okhttp3.internal.platform.android.AndroidCertificateChainCleaner import okhttp3.internal.tls.CertificateChainCleaner import okhttp3.internal.tls.TrustRootIndex @@ -58,7 +55,7 @@ internal constructor() : override var applicationContext: Context? = null private val socketAdapter by lazy { - AndroidCanarySocketAdapter.buildIfSupported()!! + Android17SocketAdapter.buildIfSupported()!! } override fun trustManager(sslSocketFactory: SSLSocketFactory): X509TrustManager? = diff --git a/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/android/AndroidCanarySocketAdapter.kt b/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/android/Android17SocketAdapter.kt similarity index 95% rename from okhttp/src/androidMain/kotlin/okhttp3/internal/platform/android/AndroidCanarySocketAdapter.kt rename to okhttp/src/androidMain/kotlin/okhttp3/internal/platform/android/Android17SocketAdapter.kt index 486f9c22972e..97316099eb71 100644 --- a/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/android/AndroidCanarySocketAdapter.kt +++ b/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/android/Android17SocketAdapter.kt @@ -33,7 +33,7 @@ import okhttp3.internal.platform.Platform.Companion.isAndroid */ @SuppressLint("NewApi") @SuppressSignatureCheck -class AndroidCanarySocketAdapter +class Android17SocketAdapter @RequiresApi(36) internal constructor() : SocketAdapter { init { @@ -65,7 +65,7 @@ internal constructor() : SocketAdapter { // Enable ALPN. sslParameters.applicationProtocols = Platform.alpnProtocolNames(protocols).toTypedArray() - +// Would need access to Dns to do it here // println("setting ECH") // SSLSockets.setEchConfigList( // sslSocket, @@ -82,7 +82,7 @@ internal constructor() : SocketAdapter { fun buildIfSupported(): SocketAdapter? = - if (isSupported()) AndroidCanarySocketAdapter() else null + if (isSupported()) Android17SocketAdapter() else null @ChecksSdkIntAtLeast(api = 36) fun isSupported() = isAndroid && Build.VERSION.SDK_INT >= 36 From cc06a75957513a10ef26363be6591e92147f7f82 Mon Sep 17 00:00:00 2001 From: Yuri Schimke Date: Mon, 16 Mar 2026 09:45:38 +0000 Subject: [PATCH 10/31] More fixes --- .../internal/platform/Android17Platform.kt | 20 +----------- .../android/Android17SocketAdapter.kt | 31 ++++++++++++++----- 2 files changed, 24 insertions(+), 27 deletions(-) diff --git a/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/Android17Platform.kt b/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/Android17Platform.kt index 1b81eb61fa5a..20a4144098d3 100644 --- a/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/Android17Platform.kt +++ b/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/Android17Platform.kt @@ -81,24 +81,6 @@ internal constructor() : protocols: List, ) { socketAdapter.configureTlsExtensions(sslSocket, hostname, protocols) - - if (hostname != null) { - val echMode = echModeConfiguration.echMode(hostname) - if (echMode.attempt) { - // TODO check require - val httpsRecord = androidDns.httpsRecords[hostname]?.get() - val echConfig = httpsRecord?.getSvcParamValue(HTTPSRecord.ECH) as SVCBBase.ParameterEch? - - println("config for $hostname $echConfig") - - if (echConfig != null) { - SSLSockets.setEchConfigList( - sslSocket, - EchConfigList.fromBytes(echConfig.data) - ) - } - } - } } override fun getSelectedProtocol(sslSocket: SSLSocket): String? = @@ -148,7 +130,7 @@ internal constructor() : @RequiresApi(36) - private val androidDns = AndroidDnsResolverDns() + internal val androidDns = AndroidDnsResolverDns() @SuppressLint("NewApi") override fun platformDns(): Dns = androidDns diff --git a/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/android/Android17SocketAdapter.kt b/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/android/Android17SocketAdapter.kt index 97316099eb71..4b2f1c9861e1 100644 --- a/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/android/Android17SocketAdapter.kt +++ b/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/android/Android17SocketAdapter.kt @@ -16,6 +16,7 @@ package okhttp3.internal.platform.android import android.annotation.SuppressLint +import android.net.ssl.EchConfigList import android.net.ssl.SSLSockets import android.os.Build import androidx.annotation.ChecksSdkIntAtLeast @@ -23,8 +24,11 @@ import androidx.annotation.RequiresApi import javax.net.ssl.SSLSocket import okhttp3.Protocol import okhttp3.internal.SuppressSignatureCheck +import okhttp3.internal.platform.Android17Platform import okhttp3.internal.platform.Platform import okhttp3.internal.platform.Platform.Companion.isAndroid +import org.xbill.DNS.HTTPSRecord +import org.xbill.DNS.SVCBBase /** * Simple non-reflection SocketAdapter for Android Q+. @@ -65,14 +69,25 @@ internal constructor() : SocketAdapter { // Enable ALPN. sslParameters.applicationProtocols = Platform.alpnProtocolNames(protocols).toTypedArray() -// Would need access to Dns to do it here -// println("setting ECH") -// SSLSockets.setEchConfigList( -// sslSocket, -// EchConfigList.fromBytes( -// echDevList.toByteArray() -// ) -// ) + val platform = Platform.get() as Android17Platform + + if (hostname != null) { + val echMode = platform.echModeConfiguration.echMode(hostname) + if (echMode.attempt) { + // TODO check require + val httpsRecord = platform.androidDns.httpsRecords[hostname]?.get() + val echConfig = httpsRecord?.getSvcParamValue(HTTPSRecord.ECH) as SVCBBase.ParameterEch? + + println("config for $hostname $echConfig") + + if (echConfig != null) { + SSLSockets.setEchConfigList( + sslSocket, + EchConfigList.fromBytes(echConfig.data) + ) + } + } + } sslSocket.sslParameters = sslParameters } From cbecbaa580c3c0664347170e6d2c656113be8c90 Mon Sep 17 00:00:00 2001 From: Yuri Schimke Date: Mon, 16 Mar 2026 10:48:48 +0000 Subject: [PATCH 11/31] More fixes --- .../okhttp3/internal/platform/Android17Platform.kt | 11 +++++++---- .../kotlin/okhttp3/internal/platform/Platform.kt | 3 +++ 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/Android17Platform.kt b/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/Android17Platform.kt index 20a4144098d3..c4d207e22ed2 100644 --- a/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/Android17Platform.kt +++ b/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/Android17Platform.kt @@ -17,8 +17,7 @@ package okhttp3.internal.platform import android.annotation.SuppressLint import android.content.Context -import android.net.ssl.EchConfigList -import android.net.ssl.SSLSockets +import android.net.ssl.EchConfigMismatchException import android.os.Build import android.os.StrictMode import android.security.NetworkSecurityPolicy @@ -27,6 +26,7 @@ import android.util.Log import androidx.annotation.ChecksSdkIntAtLeast import androidx.annotation.RequiresApi import javax.net.ssl.SSLContext +import javax.net.ssl.SSLException import javax.net.ssl.SSLSocket import javax.net.ssl.SSLSocketFactory import javax.net.ssl.X509TrustManager @@ -38,8 +38,6 @@ import okhttp3.internal.platform.android.Android17SocketAdapter import okhttp3.internal.platform.android.AndroidCertificateChainCleaner import okhttp3.internal.tls.CertificateChainCleaner import okhttp3.internal.tls.TrustRootIndex -import org.xbill.DNS.HTTPSRecord -import org.xbill.DNS.SVCBBase /** Android 17+ (API 29+). */ @SuppressSignatureCheck @@ -109,6 +107,11 @@ internal constructor() : NetworkSecurityPolicy.getInstance().getDomainEncryptionMode(hostname) ) } + + @SuppressLint("NewApi") + override fun isEchConfigError(e: SSLException): Boolean { + return e is EchConfigMismatchException + } } diff --git a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/platform/Platform.kt b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/platform/Platform.kt index 5c46495e8469..9f72869e40d1 100644 --- a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/platform/Platform.kt +++ b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/platform/Platform.kt @@ -26,6 +26,7 @@ import java.util.logging.Logger import javax.net.ssl.ExtendedSSLSession import javax.net.ssl.SNIHostName import javax.net.ssl.SSLContext +import javax.net.ssl.SSLException import javax.net.ssl.SSLSocket import javax.net.ssl.SSLSocketFactory import javax.net.ssl.TrustManager @@ -250,6 +251,8 @@ open class Platform { interface EchModeConfiguration { open fun echMode(hostname: String): EchMode + open fun isEchConfigError(e: SSLException) = false + companion object { val Unspecified = object : EchModeConfiguration { override fun echMode(hostname: String): EchMode { From 487bc99117f131f93a5b83610e8bce3ff6b1b991 Mon Sep 17 00:00:00 2001 From: Yuri Schimke Date: Tue, 17 Mar 2026 09:41:27 +0000 Subject: [PATCH 12/31] Refactor --- .../java/okhttp/android/test/EchTest.kt | 38 ++-- android-test/src/main/AndroidManifest.xml | 2 +- .../android/test/AndroidSocketAdapterTest.kt | 14 +- .../kotlin/mockwebserver3/MockWebServer.kt | 7 +- .../internal/http2/Http2Server.kt | 7 +- okhttp/api/android/okhttp.api | 48 +++++ okhttp/api/jvm/okhttp.api | 48 +++++ .../internal/platform/Android10Platform.kt | 9 +- .../internal/platform/Android17Platform.kt | 164 ++++++++---------- .../platform/AndroidDnsResolverDns.kt | 66 +++++-- .../internal/platform/AndroidPlatform.kt | 9 +- .../internal/platform/PlatformRegistry.kt | 4 +- .../android/Android10SocketAdapter.kt | 2 + .../android/Android17SocketAdapter.kt | 98 +++++------ .../android/AndroidEchModeConfiguration.kt | 75 ++++++++ .../platform/android/AndroidSocketAdapter.kt | 2 + .../android/BouncyCastleSocketAdapter.kt | 2 + .../android/ConscryptSocketAdapter.kt | 2 + .../platform/android/DeferredSocketAdapter.kt | 4 +- .../platform/android/SocketAdapter.kt | 2 + .../commonJvmAndroid/kotlin/okhttp3/Dns.kt | 5 + .../kotlin/okhttp3/Handshake.kt | 8 +- .../kotlin/okhttp3/OkHttpClient.kt | 5 + .../kotlin/okhttp3/ech/EchConfig.kt | 29 ++++ .../kotlin/okhttp3/ech/EchMode.kt | 66 +++++++ .../okhttp3/ech/EchModeConfiguration.kt | 77 ++++++++ .../internal/connection/ConnectPlan.kt | 15 +- .../http/RetryAndFollowUpInterceptor.kt | 15 ++ .../okhttp3/internal/platform/Jdk9Platform.kt | 2 + .../okhttp3/internal/platform/Platform.kt | 65 +++---- .../internal/platform/BouncyCastlePlatform.kt | 4 +- .../internal/platform/ConscryptPlatform.kt | 4 +- .../platform/Jdk8WithJettyBootPlatform.kt | 2 + .../internal/platform/OpenJSSEPlatform.kt | 4 +- 34 files changed, 662 insertions(+), 242 deletions(-) create mode 100644 okhttp/src/androidMain/kotlin/okhttp3/internal/platform/android/AndroidEchModeConfiguration.kt create mode 100644 okhttp/src/commonJvmAndroid/kotlin/okhttp3/ech/EchConfig.kt create mode 100644 okhttp/src/commonJvmAndroid/kotlin/okhttp3/ech/EchMode.kt create mode 100644 okhttp/src/commonJvmAndroid/kotlin/okhttp3/ech/EchModeConfiguration.kt diff --git a/android-test/src/androidTest/java/okhttp/android/test/EchTest.kt b/android-test/src/androidTest/java/okhttp/android/test/EchTest.kt index 602295849f83..b9ce7cdba332 100644 --- a/android-test/src/androidTest/java/okhttp/android/test/EchTest.kt +++ b/android-test/src/androidTest/java/okhttp/android/test/EchTest.kt @@ -1,5 +1,5 @@ /* - * Copyright (C) 2025 Block, Inc. + * Copyright (c) 2026 OkHttp Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -15,10 +15,12 @@ */ package okhttp.android.test +import assertk.assertThat +import assertk.assertions.isNotNull +import assertk.assertions.matchesPredicate import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.Response -import okio.IOException import org.junit.jupiter.api.Test class EchTest { @@ -30,30 +32,32 @@ class EchTest { .Builder() .build() - client.sendRequest(Request.Builder().url("https://cloudflare-ech.com/").build()) { - println(it.body.string()) - } + val cloudflareEchBody = + client.sendRequest(Request.Builder().url("https://cloudflare-ech.com/").build()) { + it.body.string() + } + assertThat(cloudflareEchBody).matchesPredicate { it.contains("ECH enabled") } - client.sendRequest( + val cloudflareBody = client.sendRequest( Request.Builder().url("https://crypto.cloudflare.com/cdn-cgi/trace").build() ) { - println(it.body.string()) + it.body.string() } + assertThat(cloudflareBody).matchesPredicate { it.contains("ECH enabled") } - client.sendRequest(Request.Builder().url("https://tls-ech.dev/").build()) { - println(it.body.string()) + val tlsEchBody = client.sendRequest(Request.Builder().url("https://tls-ech.dev/").build()) { + it.body.string() } + assertThat(tlsEchBody).matchesPredicate { it.contains("ECH enabled") } } - private fun OkHttpClient.sendRequest(request: Request, fn: (Response) -> Unit = {}) { - try { - val response = newCall(request).execute() + private fun OkHttpClient.sendRequest(request: Request, fn: (Response) -> T): T { + val response = newCall(request).execute() - response.use { - fn(it) - } - } catch (ioe: IOException) { - ioe.printStackTrace() + assertThat(response.handshake?.echConfig).isNotNull() + + return response.use { + fn(it) } } } diff --git a/android-test/src/main/AndroidManifest.xml b/android-test/src/main/AndroidManifest.xml index 9a74ac7f8e7d..b0732f7778d6 100644 --- a/android-test/src/main/AndroidManifest.xml +++ b/android-test/src/main/AndroidManifest.xml @@ -4,6 +4,6 @@ - + diff --git a/android-test/src/test/kotlin/okhttp/android/test/AndroidSocketAdapterTest.kt b/android-test/src/test/kotlin/okhttp/android/test/AndroidSocketAdapterTest.kt index 59fc723ab598..4416db6b5540 100644 --- a/android-test/src/test/kotlin/okhttp/android/test/AndroidSocketAdapterTest.kt +++ b/android-test/src/test/kotlin/okhttp/android/test/AndroidSocketAdapterTest.kt @@ -58,7 +58,12 @@ class AndroidSocketAdapterTest( val sslSocket = socketFactory.createSocket() as SSLSocket assertTrue(adapter.matchesSocket(sslSocket)) - adapter.configureTlsExtensions(sslSocket, null, listOf(HTTP_2, HTTP_1_1)) + adapter.configureTlsExtensions( + call = null, + sslSocket = sslSocket, + hostname = null, + protocols = listOf(HTTP_2, HTTP_1_1) + ) // not connected assertNull(adapter.getSelectedProtocol(sslSocket)) } @@ -89,7 +94,12 @@ class AndroidSocketAdapterTest( object : DelegatingSSLSocket(context.socketFactory.createSocket() as SSLSocket) {} assertFalse(adapter.matchesSocket(sslSocket)) - adapter.configureTlsExtensions(sslSocket, null, listOf(HTTP_2, HTTP_1_1)) + adapter.configureTlsExtensions( + call = null, + sslSocket = sslSocket, + hostname = null, + protocols = listOf(HTTP_2, HTTP_1_1) + ) // not connected assertNull(adapter.getSelectedProtocol(sslSocket)) } diff --git a/mockwebserver/src/main/kotlin/mockwebserver3/MockWebServer.kt b/mockwebserver/src/main/kotlin/mockwebserver3/MockWebServer.kt index 60b0d28034e1..73589ec0a1b6 100644 --- a/mockwebserver/src/main/kotlin/mockwebserver3/MockWebServer.kt +++ b/mockwebserver/src/main/kotlin/mockwebserver3/MockWebServer.kt @@ -473,7 +473,12 @@ public class MockWebServer : Closeable { openClientSockets.add(sslSocket) if (protocolNegotiationEnabled) { - Platform.get().configureTlsExtensions(sslSocket, null, protocols) + Platform.get().configureTlsExtensions( + call = null, + sslSocket = sslSocket, + hostname = null, + protocols = protocols, + ) } sslSocket.startHandshake() diff --git a/mockwebserver/src/test/java/mockwebserver3/internal/http2/Http2Server.kt b/mockwebserver/src/test/java/mockwebserver3/internal/http2/Http2Server.kt index bc9a3a12b4cd..e2f50723ec71 100644 --- a/mockwebserver/src/test/java/mockwebserver3/internal/http2/Http2Server.kt +++ b/mockwebserver/src/test/java/mockwebserver3/internal/http2/Http2Server.kt @@ -82,7 +82,12 @@ class Http2Server( true, ) as SSLSocket sslSocket.useClientMode = false - Platform.get().configureTlsExtensions(sslSocket, null, listOf(Protocol.HTTP_2)) + Platform.get().configureTlsExtensions( + call = null, + sslSocket = sslSocket, + hostname = null, + protocols = listOf(Protocol.HTTP_2), + ) sslSocket.startHandshake() return sslSocket } diff --git a/okhttp/api/android/okhttp.api b/okhttp/api/android/okhttp.api index 5700f275c045..c4cb246ce95c 100644 --- a/okhttp/api/android/okhttp.api +++ b/okhttp/api/android/okhttp.api @@ -485,6 +485,10 @@ public abstract interface class okhttp3/Dns { public final class okhttp3/Dns$Companion { } +public abstract interface class okhttp3/EchAware { + public abstract fun getHostRecords (Ljava/lang/String;)Lokio/ByteString; +} + public abstract class okhttp3/EventListener { public static final field Companion Lokhttp3/EventListener$Companion; public static final field NONE Lokhttp3/EventListener; @@ -575,6 +579,7 @@ public final class okhttp3/Handshake { public fun equals (Ljava/lang/Object;)Z public static final fun get (Ljavax/net/ssl/SSLSession;)Lokhttp3/Handshake; public static final fun get (Lokhttp3/TlsVersion;Lokhttp3/CipherSuite;Ljava/util/List;Ljava/util/List;)Lokhttp3/Handshake; + public final fun getEchConfig ()Lokhttp3/ech/EchConfig; public fun hashCode ()I public final fun localCertificates ()Ljava/util/List; public final fun localPrincipal ()Ljava/security/Principal; @@ -940,6 +945,7 @@ public class okhttp3/OkHttpClient : okhttp3/Call$Factory, okhttp3/WebSocket$Fact public final fun fastFallback ()Z public final fun followRedirects ()Z public final fun followSslRedirects ()Z + public final fun getEchModeConfiguration ()Lokhttp3/ech/EchModeConfiguration; public final fun hostnameVerifier ()Ljavax/net/ssl/HostnameVerifier; public final fun interceptors ()Ljava/util/List; public final fun minWebSocketMessageToCompress ()J @@ -954,6 +960,7 @@ public class okhttp3/OkHttpClient : okhttp3/Call$Factory, okhttp3/WebSocket$Fact public final fun proxySelector ()Ljava/net/ProxySelector; public final fun readTimeoutMillis ()I public final fun retryOnConnectionFailure ()Z + public final fun setEchModeConfiguration (Lokhttp3/ech/EchModeConfiguration;)V public final fun socketFactory ()Ljavax/net/SocketFactory; public final fun sslSocketFactory ()Ljavax/net/ssl/SSLSocketFactory; public final fun webSocketCloseTimeout ()I @@ -1312,3 +1319,44 @@ public abstract class okhttp3/WebSocketListener { public fun onOpen (Lokhttp3/WebSocket;Lokhttp3/Response;)V } +public final class okhttp3/ech/EchConfig { + public fun (Lokio/ByteString;)V + public final fun component1 ()Lokio/ByteString; + public final fun copy (Lokio/ByteString;)Lokhttp3/ech/EchConfig; + public static synthetic fun copy$default (Lokhttp3/ech/EchConfig;Lokio/ByteString;ILjava/lang/Object;)Lokhttp3/ech/EchConfig; + public fun equals (Ljava/lang/Object;)Z + public final fun getConfig ()Lokio/ByteString; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class okhttp3/ech/EchMode : java/lang/Enum { + public static final field Companion Lokhttp3/ech/EchMode$Companion; + public static final field Disabled Lokhttp3/ech/EchMode; + public static final field FailClosed Lokhttp3/ech/EchMode; + public static final field Fallback Lokhttp3/ech/EchMode; + public static final field Opportunistic Lokhttp3/ech/EchMode; + public static final field Strict Lokhttp3/ech/EchMode; + public static final field Unspecified Lokhttp3/ech/EchMode; + public final fun getAttempt ()Z + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public final fun getFallback ()Z + public final fun getRequire ()Z + public static fun valueOf (Ljava/lang/String;)Lokhttp3/ech/EchMode; + public static fun values ()[Lokhttp3/ech/EchMode; +} + +public final class okhttp3/ech/EchMode$Companion { +} + +public abstract interface class okhttp3/ech/EchModeConfiguration { + public static final field Companion Lokhttp3/ech/EchModeConfiguration$Companion; + public abstract fun applyEch (Ljavax/net/ssl/SSLSocket;Lokhttp3/ech/EchMode;Ljava/lang/String;Lokhttp3/Dns;)V + public abstract fun echMode (Ljava/lang/String;)Lokhttp3/ech/EchMode; + public fun isEchConfigError (Ljavax/net/ssl/SSLException;)Z +} + +public final class okhttp3/ech/EchModeConfiguration$Companion { + public final fun getUnspecified ()Lokhttp3/ech/EchModeConfiguration; +} + diff --git a/okhttp/api/jvm/okhttp.api b/okhttp/api/jvm/okhttp.api index d065655fb030..c28e3baa99b5 100644 --- a/okhttp/api/jvm/okhttp.api +++ b/okhttp/api/jvm/okhttp.api @@ -485,6 +485,10 @@ public abstract interface class okhttp3/Dns { public final class okhttp3/Dns$Companion { } +public abstract interface class okhttp3/EchAware { + public abstract fun getHostRecords (Ljava/lang/String;)Lokio/ByteString; +} + public abstract class okhttp3/EventListener { public static final field Companion Lokhttp3/EventListener$Companion; public static final field NONE Lokhttp3/EventListener; @@ -575,6 +579,7 @@ public final class okhttp3/Handshake { public fun equals (Ljava/lang/Object;)Z public static final fun get (Ljavax/net/ssl/SSLSession;)Lokhttp3/Handshake; public static final fun get (Lokhttp3/TlsVersion;Lokhttp3/CipherSuite;Ljava/util/List;Ljava/util/List;)Lokhttp3/Handshake; + public final fun getEchConfig ()Lokhttp3/ech/EchConfig; public fun hashCode ()I public final fun localCertificates ()Ljava/util/List; public final fun localPrincipal ()Ljava/security/Principal; @@ -939,6 +944,7 @@ public class okhttp3/OkHttpClient : okhttp3/Call$Factory, okhttp3/WebSocket$Fact public final fun fastFallback ()Z public final fun followRedirects ()Z public final fun followSslRedirects ()Z + public final fun getEchModeConfiguration ()Lokhttp3/ech/EchModeConfiguration; public final fun hostnameVerifier ()Ljavax/net/ssl/HostnameVerifier; public final fun interceptors ()Ljava/util/List; public final fun minWebSocketMessageToCompress ()J @@ -953,6 +959,7 @@ public class okhttp3/OkHttpClient : okhttp3/Call$Factory, okhttp3/WebSocket$Fact public final fun proxySelector ()Ljava/net/ProxySelector; public final fun readTimeoutMillis ()I public final fun retryOnConnectionFailure ()Z + public final fun setEchModeConfiguration (Lokhttp3/ech/EchModeConfiguration;)V public final fun socketFactory ()Ljavax/net/SocketFactory; public final fun sslSocketFactory ()Ljavax/net/ssl/SSLSocketFactory; public final fun webSocketCloseTimeout ()I @@ -1311,3 +1318,44 @@ public abstract class okhttp3/WebSocketListener { public fun onOpen (Lokhttp3/WebSocket;Lokhttp3/Response;)V } +public final class okhttp3/ech/EchConfig { + public fun (Lokio/ByteString;)V + public final fun component1 ()Lokio/ByteString; + public final fun copy (Lokio/ByteString;)Lokhttp3/ech/EchConfig; + public static synthetic fun copy$default (Lokhttp3/ech/EchConfig;Lokio/ByteString;ILjava/lang/Object;)Lokhttp3/ech/EchConfig; + public fun equals (Ljava/lang/Object;)Z + public final fun getConfig ()Lokio/ByteString; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class okhttp3/ech/EchMode : java/lang/Enum { + public static final field Companion Lokhttp3/ech/EchMode$Companion; + public static final field Disabled Lokhttp3/ech/EchMode; + public static final field FailClosed Lokhttp3/ech/EchMode; + public static final field Fallback Lokhttp3/ech/EchMode; + public static final field Opportunistic Lokhttp3/ech/EchMode; + public static final field Strict Lokhttp3/ech/EchMode; + public static final field Unspecified Lokhttp3/ech/EchMode; + public final fun getAttempt ()Z + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public final fun getFallback ()Z + public final fun getRequire ()Z + public static fun valueOf (Ljava/lang/String;)Lokhttp3/ech/EchMode; + public static fun values ()[Lokhttp3/ech/EchMode; +} + +public final class okhttp3/ech/EchMode$Companion { +} + +public abstract interface class okhttp3/ech/EchModeConfiguration { + public static final field Companion Lokhttp3/ech/EchModeConfiguration$Companion; + public abstract fun applyEch (Ljavax/net/ssl/SSLSocket;Lokhttp3/ech/EchMode;Ljava/lang/String;Lokhttp3/Dns;)V + public abstract fun echMode (Ljava/lang/String;)Lokhttp3/ech/EchMode; + public fun isEchConfigError (Ljavax/net/ssl/SSLException;)Z +} + +public final class okhttp3/ech/EchModeConfiguration$Companion { + public final fun getUnspecified ()Lokhttp3/ech/EchModeConfiguration; +} + diff --git a/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/Android10Platform.kt b/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/Android10Platform.kt index 091c654c8063..109502dbb59e 100644 --- a/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/Android10Platform.kt +++ b/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/Android10Platform.kt @@ -26,6 +26,7 @@ import javax.net.ssl.SSLContext import javax.net.ssl.SSLSocket import javax.net.ssl.SSLSocketFactory import javax.net.ssl.X509TrustManager +import okhttp3.Call import okhttp3.Protocol import okhttp3.internal.SuppressSignatureCheck import okhttp3.internal.platform.AndroidPlatform.Companion.Tag @@ -72,6 +73,7 @@ open class Android10Platform : } override fun configureTlsExtensions( + call: Call?, sslSocket: SSLSocket, hostname: String?, protocols: List, @@ -79,7 +81,12 @@ open class Android10Platform : // No TLS extensions if the socket class is custom. socketAdapters .find { it.matchesSocket(sslSocket) } - ?.configureTlsExtensions(sslSocket, hostname, protocols) + ?.configureTlsExtensions( + call = call, + sslSocket = sslSocket, + hostname = hostname, + protocols = protocols, + ) } override fun getSelectedProtocol(sslSocket: SSLSocket): String? = diff --git a/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/Android17Platform.kt b/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/Android17Platform.kt index c4d207e22ed2..270eec930ca7 100644 --- a/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/Android17Platform.kt +++ b/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/Android17Platform.kt @@ -1,5 +1,5 @@ /* - * Copyright (C) 2016 Square, Inc. + * Copyright (c) 2026 OkHttp Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,7 +17,6 @@ package okhttp3.internal.platform import android.annotation.SuppressLint import android.content.Context -import android.net.ssl.EchConfigMismatchException import android.os.Build import android.os.StrictMode import android.security.NetworkSecurityPolicy @@ -26,131 +25,108 @@ import android.util.Log import androidx.annotation.ChecksSdkIntAtLeast import androidx.annotation.RequiresApi import javax.net.ssl.SSLContext -import javax.net.ssl.SSLException import javax.net.ssl.SSLSocket import javax.net.ssl.SSLSocketFactory import javax.net.ssl.X509TrustManager +import okhttp3.Call import okhttp3.Dns import okhttp3.Protocol +import okhttp3.ech.EchModeConfiguration import okhttp3.internal.SuppressSignatureCheck import okhttp3.internal.platform.AndroidPlatform.Companion.Tag import okhttp3.internal.platform.android.Android17SocketAdapter import okhttp3.internal.platform.android.AndroidCertificateChainCleaner +import okhttp3.internal.platform.android.AndroidEchModeConfiguration import okhttp3.internal.tls.CertificateChainCleaner import okhttp3.internal.tls.TrustRootIndex -/** Android 17+ (API 29+). */ +/** Android 17+ (API 37+). */ @SuppressSignatureCheck class Android17Platform -@RequiresApi(36) -internal constructor() : + @RequiresApi(36) + internal constructor() : Platform(), - ContextAwarePlatform { - init { - println("Android17Platform") - } + ContextAwarePlatform { + init { + println("Android17Platform") + } - override var applicationContext: Context? = null + override var applicationContext: Context? = null - private val socketAdapter by lazy { - Android17SocketAdapter.buildIfSupported()!! - } + private val socketAdapter by lazy { + Android17SocketAdapter.buildIfSupported()!! + } - override fun trustManager(sslSocketFactory: SSLSocketFactory): X509TrustManager? = - socketAdapter.trustManager(sslSocketFactory) + override fun trustManager(sslSocketFactory: SSLSocketFactory): X509TrustManager? = socketAdapter.trustManager(sslSocketFactory) - override fun newSSLContext(): SSLContext { - StrictMode.noteSlowCall("newSSLContext") + override fun newSSLContext(): SSLContext { + StrictMode.noteSlowCall("newSSLContext") - return super.newSSLContext() - } + return super.newSSLContext() + } - override fun buildTrustRootIndex(trustManager: X509TrustManager): TrustRootIndex { - StrictMode.noteSlowCall("buildTrustRootIndex") + override fun buildTrustRootIndex(trustManager: X509TrustManager): TrustRootIndex { + StrictMode.noteSlowCall("buildTrustRootIndex") - return super.buildTrustRootIndex(trustManager) - } - - @Suppress("NewApi") - @RequiresApi(37) - override fun configureTlsExtensions( - sslSocket: SSLSocket, - hostname: String?, - protocols: List, - ) { - socketAdapter.configureTlsExtensions(sslSocket, hostname, protocols) - } + return super.buildTrustRootIndex(trustManager) + } - override fun getSelectedProtocol(sslSocket: SSLSocket): String? = - socketAdapter.getSelectedProtocol(sslSocket) + override fun configureTlsExtensions( + call: Call?, + sslSocket: SSLSocket, + hostname: String?, + protocols: List, + ) { + socketAdapter.configureTlsExtensions( + call = call, + sslSocket = sslSocket, + hostname = hostname, + protocols = protocols, + ) + } - @RequiresApi(36) - override fun getStackTraceForCloseable(closer: String): Any = - CloseGuard().apply { open(closer) } + override fun getSelectedProtocol(sslSocket: SSLSocket): String? = socketAdapter.getSelectedProtocol(sslSocket) - @RequiresApi(36) - override fun logCloseableLeak( - message: String, - stackTrace: Any?, - ) { - (stackTrace as CloseGuard).warnIfOpen() - } + @RequiresApi(36) + override fun getStackTraceForCloseable(closer: String): Any = CloseGuard().apply { open(closer) } - @SuppressLint("NewApi") - override fun isCleartextTrafficPermitted(hostname: String): Boolean = - NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted(hostname) + @RequiresApi(36) + override fun logCloseableLeak( + message: String, + stackTrace: Any?, + ) { + (stackTrace as CloseGuard).warnIfOpen() + } - override val echModeConfiguration: EchModeConfiguration = object : EchModeConfiguration { @SuppressLint("NewApi") - override fun echMode(hostname: String): EchMode { - return EchMode.fromNetworkSecurityPolicy( - NetworkSecurityPolicy.getInstance().getDomainEncryptionMode(hostname) - ) - } + override fun isCleartextTrafficPermitted(hostname: String): Boolean = + NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted(hostname) @SuppressLint("NewApi") - override fun isEchConfigError(e: SSLException): Boolean { - return e is EchConfigMismatchException + override val echModeConfiguration: EchModeConfiguration = AndroidEchModeConfiguration() + + override fun buildCertificateChainCleaner(trustManager: X509TrustManager): CertificateChainCleaner = + AndroidCertificateChainCleaner.buildIfSupported(trustManager)!! + + override fun log( + message: String, + level: Int, + t: Throwable?, + ) { + if (level == WARN) { + Log.w(Tag, message, t) + } else { + Log.i(Tag, message, t) + } } - } + @SuppressLint("NewApi") + override fun platformDns(): Dns = AndroidDnsResolverDns() - override fun buildCertificateChainCleaner(trustManager: X509TrustManager): CertificateChainCleaner = - AndroidCertificateChainCleaner.buildIfSupported(trustManager)!! + companion object { + val isSupported: Boolean = (isAndroid && Build.VERSION.SDK_INT >= 36) - override fun log( - message: String, - level: Int, - t: Throwable?, - ) { - if (level == WARN) { - Log.w(Tag, message, t) - } else { - Log.i(Tag, message, t) + @ChecksSdkIntAtLeast(36) + fun buildIfSupported(): Platform? = if (isSupported) Android17Platform() else null } } - - - - @RequiresApi(36) - internal val androidDns = AndroidDnsResolverDns() - - @SuppressLint("NewApi") - override fun platformDns(): Dns = androidDns - - companion object { - val isSupported: Boolean = isAndroid && Build.VERSION.SDK_INT >= 36 - - @ChecksSdkIntAtLeast(36) - fun buildIfSupported(): Platform? = if (isSupported) Android17Platform() else null - } -} - -private fun EchMode.Companion.fromNetworkSecurityPolicy(domainEncryptionMode: Int): EchMode { - return when (domainEncryptionMode) { - NetworkSecurityPolicy.DOMAIN_ENCRYPTION_MODE_OPPORTUNISTIC -> EchMode.Opportunistic - NetworkSecurityPolicy.DOMAIN_ENCRYPTION_MODE_ENABLED -> EchMode.Strict - NetworkSecurityPolicy.DOMAIN_ENCRYPTION_MODE_DISABLED -> EchMode.Disabled - else -> EchMode.Unspecified - } -} diff --git a/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/AndroidDnsResolverDns.kt b/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/AndroidDnsResolverDns.kt index 42e3cc3d6bb0..5c0991395999 100644 --- a/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/AndroidDnsResolverDns.kt +++ b/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/AndroidDnsResolverDns.kt @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2026 OkHttp Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ package okhttp3.internal.platform import android.net.DnsResolver @@ -7,13 +22,19 @@ import java.net.InetAddress import java.util.concurrent.CompletableFuture import java.util.concurrent.Future import okhttp3.Dns +import okhttp3.EchAware +import okio.ByteString +import okio.ByteString.Companion.toByteString import org.xbill.DNS.HTTPSRecord import org.xbill.DNS.Message +import org.xbill.DNS.SVCBBase import org.xbill.DNS.Section.ANSWER @Suppress("NewApi") @RequiresApi(36) -class AndroidDnsResolverDns : Dns { +class AndroidDnsResolverDns : + Dns, + EchAware { val dnsResolver = DnsResolver.getInstance() val httpsRecords: MutableMap> = HashMap() @@ -21,29 +42,44 @@ class AndroidDnsResolverDns : Dns { override fun lookup(hostname: String): List { val future = CompletableFuture() - val callback: Callback = object : Callback { - override fun onAnswer(p0: ByteArray, p1: Int) { - val answers = Message(p0).getSection(ANSWER) - if (answers.isEmpty()) { - future.complete(null) - } else { - future.complete(answers.single() as HTTPSRecord) + val callback: Callback = + object : Callback { + override fun onAnswer( + p0: ByteArray, + p1: Int, + ) { + val answers = Message(p0).getSection(ANSWER) + if (answers.isEmpty()) { + future.complete(null) + } else { + future.complete(answers.single() as HTTPSRecord) + } } - } - override fun onError(p0: DnsResolver.DnsException) { - future.completeExceptionally(p0) + override fun onError(p0: DnsResolver.DnsException) { + future.completeExceptionally(p0) + } } - } @Suppress("WrongConstant") dnsResolver.rawQuery( - null, hostname, DnsResolver.CLASS_IN, 65, DnsResolver.FLAG_EMPTY, - { it.run() }, null, - callback + null, + hostname, + DnsResolver.CLASS_IN, + 65, + DnsResolver.FLAG_EMPTY, + { it.run() }, + null, + callback, ) httpsRecords[hostname] = future // TODO replace with DnsResolver call return Dns.SYSTEM.lookup(hostname) } + + override fun getHostRecords(host: String): ByteString? { + val record = httpsRecords[host]?.get() + val echConfig = record?.getSvcParamValue(HTTPSRecord.ECH) as SVCBBase.ParameterEch? + return echConfig?.data?.toByteString() + } } diff --git a/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/AndroidPlatform.kt b/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/AndroidPlatform.kt index 4f94d192b034..47bee577e84d 100644 --- a/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/AndroidPlatform.kt +++ b/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/AndroidPlatform.kt @@ -31,6 +31,7 @@ import javax.net.ssl.SSLContext import javax.net.ssl.SSLSocket import javax.net.ssl.SSLSocketFactory import javax.net.ssl.X509TrustManager +import okhttp3.Call import okhttp3.Protocol import okhttp3.internal.SuppressSignatureCheck import okhttp3.internal.platform.android.AndroidCertificateChainCleaner @@ -90,6 +91,7 @@ class AndroidPlatform : ?.trustManager(sslSocketFactory) override fun configureTlsExtensions( + call: Call?, sslSocket: SSLSocket, hostname: String?, protocols: List<@JvmSuppressWildcards Protocol>, @@ -97,7 +99,12 @@ class AndroidPlatform : // No TLS extensions if the socket class is custom. socketAdapters .find { it.matchesSocket(sslSocket) } - ?.configureTlsExtensions(sslSocket, hostname, protocols) + ?.configureTlsExtensions( + call = call, + sslSocket = sslSocket, + hostname = hostname, + protocols = protocols, + ) } override fun getSelectedProtocol(sslSocket: SSLSocket): String? = diff --git a/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/PlatformRegistry.kt b/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/PlatformRegistry.kt index 25317c453bd4..4a62c4ecc89a 100644 --- a/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/PlatformRegistry.kt +++ b/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/PlatformRegistry.kt @@ -25,8 +25,8 @@ actual object PlatformRegistry { AndroidLog.enable() val androidPlatform = - Android17Platform.buildIfSupported() ?: - Android10Platform.buildIfSupported() + Android17Platform.buildIfSupported() + ?: Android10Platform.buildIfSupported() ?: AndroidPlatform.buildIfSupported() if (androidPlatform != null) return androidPlatform diff --git a/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/android/Android10SocketAdapter.kt b/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/android/Android10SocketAdapter.kt index 83a3f4f41cbb..0d0e479f4b1d 100644 --- a/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/android/Android10SocketAdapter.kt +++ b/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/android/Android10SocketAdapter.kt @@ -21,6 +21,7 @@ import android.os.Build import java.io.IOException import java.lang.IllegalArgumentException import javax.net.ssl.SSLSocket +import okhttp3.Call import okhttp3.Protocol import okhttp3.internal.SuppressSignatureCheck import okhttp3.internal.platform.Platform @@ -54,6 +55,7 @@ class Android10SocketAdapter : SocketAdapter { @SuppressLint("NewApi") override fun configureTlsExtensions( + call: Call?, sslSocket: SSLSocket, hostname: String?, protocols: List, diff --git a/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/android/Android17SocketAdapter.kt b/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/android/Android17SocketAdapter.kt index 4b2f1c9861e1..79ab04baaa56 100644 --- a/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/android/Android17SocketAdapter.kt +++ b/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/android/Android17SocketAdapter.kt @@ -1,5 +1,5 @@ /* - * Copyright (C) 2019 Square, Inc. + * Copyright (c) 2026 OkHttp Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,19 +16,19 @@ package okhttp3.internal.platform.android import android.annotation.SuppressLint -import android.net.ssl.EchConfigList import android.net.ssl.SSLSockets import android.os.Build import androidx.annotation.ChecksSdkIntAtLeast import androidx.annotation.RequiresApi import javax.net.ssl.SSLSocket +import okhttp3.Call import okhttp3.Protocol +import okhttp3.ech.EchMode import okhttp3.internal.SuppressSignatureCheck +import okhttp3.internal.connection.RealCall import okhttp3.internal.platform.Android17Platform import okhttp3.internal.platform.Platform import okhttp3.internal.platform.Platform.Companion.isAndroid -import org.xbill.DNS.HTTPSRecord -import org.xbill.DNS.SVCBBase /** * Simple non-reflection SocketAdapter for Android Q+. @@ -38,68 +38,60 @@ import org.xbill.DNS.SVCBBase @SuppressLint("NewApi") @SuppressSignatureCheck class Android17SocketAdapter -@RequiresApi(36) -internal constructor() : SocketAdapter { - init { - println("AndroidCanarySocketAdapter") - } + @RequiresApi(36) + internal constructor() : SocketAdapter { + init { + println("AndroidCanarySocketAdapter") + } - override fun matchesSocket(sslSocket: SSLSocket): Boolean = - SSLSockets.isSupportedSocket(sslSocket) + override fun matchesSocket(sslSocket: SSLSocket): Boolean = SSLSockets.isSupportedSocket(sslSocket) - override fun isSupported(): Boolean = Companion.isSupported() + override fun isSupported(): Boolean = Companion.isSupported() - override fun getSelectedProtocol(sslSocket: SSLSocket): String? = - // SSLSocket.getApplicationProtocol returns "" if application protocols values will not - // be used. Observed if you didn't specify SSLParameters.setApplicationProtocols - when (val protocol = sslSocket.applicationProtocol) { - null, "" -> null - else -> protocol - } + override fun getSelectedProtocol(sslSocket: SSLSocket): String? = + // SSLSocket.getApplicationProtocol returns "" if application protocols values will not + // be used. Observed if you didn't specify SSLParameters.setApplicationProtocols + when (val protocol = sslSocket.applicationProtocol) { + null, "" -> null + else -> protocol + } + + override fun configureTlsExtensions( + call: Call?, + sslSocket: SSLSocket, + hostname: String?, + protocols: List, + ) { + SSLSockets.setUseSessionTickets(sslSocket, true) - override fun configureTlsExtensions( - sslSocket: SSLSocket, - hostname: String?, - protocols: List, - ) { - SSLSockets.setUseSessionTickets(sslSocket, true) + val sslParameters = sslSocket.sslParameters - val sslParameters = sslSocket.sslParameters + // Enable ALPN. + sslParameters.applicationProtocols = Platform.alpnProtocolNames(protocols).toTypedArray() - // Enable ALPN. - sslParameters.applicationProtocols = Platform.alpnProtocolNames(protocols).toTypedArray() + sslSocket.sslParameters = sslParameters - val platform = Platform.get() as Android17Platform + if (hostname != null) { + val client = (call as? RealCall)?.client ?: return - if (hostname != null) { - val echMode = platform.echModeConfiguration.echMode(hostname) - if (echMode.attempt) { - // TODO check require - val httpsRecord = platform.androidDns.httpsRecords[hostname]?.get() - val echConfig = httpsRecord?.getSvcParamValue(HTTPSRecord.ECH) as SVCBBase.ParameterEch? + val echModeConfiguration = client.echModeConfiguration - println("config for $hostname $echConfig") + val echMode = + call.tag(EchMode::class) { + echModeConfiguration.echMode(hostname) + } - if (echConfig != null) { - SSLSockets.setEchConfigList( - sslSocket, - EchConfigList.fromBytes(echConfig.data) - ) + if (echMode.attempt) { + echModeConfiguration.applyEch(sslSocket, echMode, hostname, client.dns) } } } - sslSocket.sslParameters = sslParameters - } - - @SuppressSignatureCheck - companion object { - + @SuppressSignatureCheck + companion object { + fun buildIfSupported(): SocketAdapter? = if (isSupported()) Android17SocketAdapter() else null - fun buildIfSupported(): SocketAdapter? = - if (isSupported()) Android17SocketAdapter() else null - - @ChecksSdkIntAtLeast(api = 36) - fun isSupported() = isAndroid && Build.VERSION.SDK_INT >= 36 + @ChecksSdkIntAtLeast(api = 36) + fun isSupported() = isAndroid && Build.VERSION.SDK_INT >= 36 + } } -} diff --git a/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/android/AndroidEchModeConfiguration.kt b/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/android/AndroidEchModeConfiguration.kt new file mode 100644 index 000000000000..1c30d72b3422 --- /dev/null +++ b/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/android/AndroidEchModeConfiguration.kt @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2026 OkHttp Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package okhttp3.internal.platform.android + +import android.annotation.SuppressLint +import android.net.ssl.EchConfigList +import android.net.ssl.EchConfigMismatchException +import android.net.ssl.SSLSockets +import android.security.NetworkSecurityPolicy +import androidx.annotation.RequiresApi +import javax.net.ssl.SSLException +import javax.net.ssl.SSLSocket +import okhttp3.Dns +import okhttp3.EchAware +import okhttp3.ech.EchMode +import okhttp3.ech.EchModeConfiguration +import okio.IOException + +@RequiresApi(37) +class AndroidEchModeConfiguration : EchModeConfiguration { + @Suppress("NewApi") + override fun echMode(host: String): EchMode { + EchMode.fromNetworkSecurityPolicy( + NetworkSecurityPolicy.getInstance().getDomainEncryptionMode(host).also { + println("$host = $it") + }, + ) + + // for now return enabled for testing + return EchMode.Opportunistic + } + + @SuppressLint("NewApi") + override fun isEchConfigError(e: SSLException): Boolean = e is EchConfigMismatchException + + @Suppress("NewApi") + override fun applyEch( + sslSocket: SSLSocket, + echMode: EchMode, + host: String, + dns: Dns, + ) { + val echConfig = (dns as EchAware).getHostRecords(host) + + if (echConfig != null) { + SSLSockets.setEchConfigList( + sslSocket, + EchConfigList.fromBytes(echConfig.toByteArray()), + ) + } else if (echMode.require) { + throw IOException("Unable to apply required ECH config for $host") + } + } +} + +private fun EchMode.Companion.fromNetworkSecurityPolicy(domainEncryptionMode: Int): EchMode = + when (domainEncryptionMode) { + NetworkSecurityPolicy.DOMAIN_ENCRYPTION_MODE_OPPORTUNISTIC -> EchMode.Opportunistic + NetworkSecurityPolicy.DOMAIN_ENCRYPTION_MODE_ENABLED -> EchMode.Strict + NetworkSecurityPolicy.DOMAIN_ENCRYPTION_MODE_DISABLED -> EchMode.Disabled + else -> EchMode.Unspecified + } diff --git a/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/android/AndroidSocketAdapter.kt b/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/android/AndroidSocketAdapter.kt index 9adf56dba2b2..baf09963cd57 100644 --- a/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/android/AndroidSocketAdapter.kt +++ b/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/android/AndroidSocketAdapter.kt @@ -19,6 +19,7 @@ import android.os.Build import java.lang.reflect.InvocationTargetException import java.lang.reflect.Method import javax.net.ssl.SSLSocket +import okhttp3.Call import okhttp3.Protocol import okhttp3.internal.platform.AndroidPlatform import okhttp3.internal.platform.Platform @@ -45,6 +46,7 @@ open class AndroidSocketAdapter( override fun matchesSocket(sslSocket: SSLSocket): Boolean = sslSocketClass.isInstance(sslSocket) override fun configureTlsExtensions( + call: Call?, sslSocket: SSLSocket, hostname: String?, protocols: List, diff --git a/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/android/BouncyCastleSocketAdapter.kt b/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/android/BouncyCastleSocketAdapter.kt index 6d2b8beb0e62..77d78bd05d17 100644 --- a/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/android/BouncyCastleSocketAdapter.kt +++ b/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/android/BouncyCastleSocketAdapter.kt @@ -16,6 +16,7 @@ package okhttp3.internal.platform.android import javax.net.ssl.SSLSocket +import okhttp3.Call import okhttp3.Protocol import okhttp3.internal.platform.Platform import org.bouncycastle.jsse.BCSSLSocket @@ -38,6 +39,7 @@ class BouncyCastleSocketAdapter : SocketAdapter { } override fun configureTlsExtensions( + call: Call?, sslSocket: SSLSocket, hostname: String?, protocols: List, diff --git a/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/android/ConscryptSocketAdapter.kt b/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/android/ConscryptSocketAdapter.kt index 006e593b7bcf..b699a05bbf2c 100644 --- a/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/android/ConscryptSocketAdapter.kt +++ b/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/android/ConscryptSocketAdapter.kt @@ -16,6 +16,7 @@ package okhttp3.internal.platform.android import javax.net.ssl.SSLSocket +import okhttp3.Call import okhttp3.Protocol import okhttp3.internal.platform.Platform import org.conscrypt.Conscrypt @@ -36,6 +37,7 @@ class ConscryptSocketAdapter : SocketAdapter { } override fun configureTlsExtensions( + call: Call?, sslSocket: SSLSocket, hostname: String?, protocols: List, diff --git a/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/android/DeferredSocketAdapter.kt b/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/android/DeferredSocketAdapter.kt index 10b619dec831..62cce3742759 100644 --- a/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/android/DeferredSocketAdapter.kt +++ b/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/android/DeferredSocketAdapter.kt @@ -16,6 +16,7 @@ package okhttp3.internal.platform.android import javax.net.ssl.SSLSocket +import okhttp3.Call import okhttp3.Protocol /** @@ -36,11 +37,12 @@ class DeferredSocketAdapter( override fun matchesSocket(sslSocket: SSLSocket): Boolean = socketAdapterFactory.matchesSocket(sslSocket) override fun configureTlsExtensions( + call: Call?, sslSocket: SSLSocket, hostname: String?, protocols: List, ) { - getDelegate(sslSocket)?.configureTlsExtensions(sslSocket, hostname, protocols) + getDelegate(sslSocket)?.configureTlsExtensions(call, sslSocket, hostname, protocols) } override fun getSelectedProtocol(sslSocket: SSLSocket): String? = getDelegate(sslSocket)?.getSelectedProtocol(sslSocket) diff --git a/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/android/SocketAdapter.kt b/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/android/SocketAdapter.kt index 40776f6bfb17..df9ce411d347 100644 --- a/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/android/SocketAdapter.kt +++ b/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/android/SocketAdapter.kt @@ -18,6 +18,7 @@ package okhttp3.internal.platform.android import javax.net.ssl.SSLSocket import javax.net.ssl.SSLSocketFactory import javax.net.ssl.X509TrustManager +import okhttp3.Call import okhttp3.Protocol interface SocketAdapter { @@ -30,6 +31,7 @@ interface SocketAdapter { fun matchesSocketFactory(sslSocketFactory: SSLSocketFactory): Boolean = false fun configureTlsExtensions( + call: Call?, sslSocket: SSLSocket, hostname: String?, protocols: List, diff --git a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/Dns.kt b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/Dns.kt index d7fdd38d564c..61632a499da0 100644 --- a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/Dns.kt +++ b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/Dns.kt @@ -18,6 +18,7 @@ package okhttp3 import java.net.InetAddress import java.net.UnknownHostException import okhttp3.Dns.Companion.SYSTEM +import okio.ByteString /** * A domain name service that resolves IP addresses for host names. Most applications will use the @@ -57,3 +58,7 @@ fun interface Dns { } } } + +interface EchAware { + fun getHostRecords(host: String): ByteString? +} diff --git a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/Handshake.kt b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/Handshake.kt index 4c61fc4b7bf6..8a0bc338814b 100644 --- a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/Handshake.kt +++ b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/Handshake.kt @@ -21,6 +21,7 @@ import java.security.cert.Certificate import java.security.cert.X509Certificate import javax.net.ssl.SSLPeerUnverifiedException import javax.net.ssl.SSLSession +import okhttp3.ech.EchConfig import okhttp3.internal.toImmutableList /** @@ -40,6 +41,7 @@ class Handshake internal constructor( @get:JvmName("cipherSuite") val cipherSuite: CipherSuite, /** Returns a possibly-empty list of certificates that identify this peer. */ @get:JvmName("localCertificates") val localCertificates: List, + val echConfig: EchConfig? = null, // Delayed provider of peerCertificates, to allow lazy cleaning. peerCertificatesFn: () -> List, ) { @@ -194,7 +196,11 @@ class Handshake internal constructor( localCertificates: List, ): Handshake { val peerCertificatesCopy = peerCertificates.toImmutableList() - return Handshake(tlsVersion, cipherSuite, localCertificates.toImmutableList()) { + return Handshake( + tlsVersion = tlsVersion, + cipherSuite = cipherSuite, + localCertificates = localCertificates.toImmutableList(), + ) { peerCertificatesCopy } } diff --git a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/OkHttpClient.kt b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/OkHttpClient.kt index ff478913b0d2..869edead073c 100644 --- a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/OkHttpClient.kt +++ b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/OkHttpClient.kt @@ -30,6 +30,7 @@ import javax.net.ssl.X509TrustManager import kotlin.time.Duration as KotlinDuration import okhttp3.Protocol.HTTP_1_1 import okhttp3.Protocol.HTTP_2 +import okhttp3.ech.EchModeConfiguration import okhttp3.internal.asFactory import okhttp3.internal.checkDuration import okhttp3.internal.concurrent.TaskRunner @@ -273,6 +274,8 @@ open class OkHttpClient internal constructor( builder.connectionPool = it } + var echModeConfiguration: EchModeConfiguration = builder.echModeConfiguration + constructor() : this(Builder()) init { @@ -618,6 +621,7 @@ open class OkHttpClient internal constructor( internal var minWebSocketMessageToCompress = RealWebSocket.DEFAULT_MINIMUM_DEFLATE_SIZE internal var routeDatabase: RouteDatabase? = null internal var taskRunner: TaskRunner? = null + internal var echModeConfiguration: EchModeConfiguration = Platform.get().echModeConfiguration internal constructor(okHttpClient: OkHttpClient) : this() { this.dispatcher = okHttpClient.dispatcher @@ -653,6 +657,7 @@ open class OkHttpClient internal constructor( this.minWebSocketMessageToCompress = okHttpClient.minWebSocketMessageToCompress this.routeDatabase = okHttpClient.routeDatabase this.taskRunner = okHttpClient.taskRunner + this.echModeConfiguration = okHttpClient.echModeConfiguration } /** diff --git a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/ech/EchConfig.kt b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/ech/EchConfig.kt new file mode 100644 index 000000000000..e3c7dfc59e7e --- /dev/null +++ b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/ech/EchConfig.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2026 OkHttp Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package okhttp3.ech + +import okio.ByteString + +/** + * Configuration for Encrypted Client Hello (ECH). + * + * This class contains the parameters required for a client to encrypt its ClientHello message, + * protecting sensitive fields such as the Server Name Indication (SNI) from passive observers. + * These parameters are typically retrieved from DNS via HTTPS or SVCB records. + */ +data class EchConfig( + val config: ByteString +) diff --git a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/ech/EchMode.kt b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/ech/EchMode.kt new file mode 100644 index 000000000000..12c623eb1bcc --- /dev/null +++ b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/ech/EchMode.kt @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2026 OkHttp Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package okhttp3.ech + +/** + * Configures the behavior of Encrypted Client Hello (ECH) for TLS connections. + */ +enum class EchMode( + val attempt: Boolean, + val require: Boolean, + val fallback: Boolean = false, +) { + /** + * The ECH mode is not specified. ECH will not be attempted or required. + */ + Unspecified(attempt = false, require = false), + + /** ECH is disabled. */ + Disabled( + attempt = false, + require = false, + ), + + /** + * Attempt ECH if configuration is available, but fall back to standard TLS if it fails. + */ + Opportunistic( + attempt = true, + require = false, + fallback = true, + ), + + /** + * Attempt ECH if the configuration is available. + */ + Strict( + attempt = true, + require = false, + ), + + /** + * Attempt ECH and fail the connection if it cannot be established. + */ + FailClosed(attempt = true, require = true), + + /** + * Retry with ECH disabled. + */ + Fallback(attempt = false, require = false), + ; + + companion object +} diff --git a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/ech/EchModeConfiguration.kt b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/ech/EchModeConfiguration.kt new file mode 100644 index 000000000000..d9c17fd6db1c --- /dev/null +++ b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/ech/EchModeConfiguration.kt @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2026 OkHttp Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package okhttp3.ech + +import javax.net.ssl.SSLException +import javax.net.ssl.SSLSocket +import okhttp3.Dns + +/** + * Configuration and management for Encrypted Client Hello (ECH). + * + * This interface provides the mechanism to determine the ECH strategy for a given host, + * apply ECH parameters to an [SSLSocket], and identify ECH-specific connection failures. + */ +interface EchModeConfiguration { + /** + * Determines the [EchMode] strategy to be used for the specified [host]. + * + * @param host the hostname for which the ECH strategy is requested. + * @return the [EchMode] to be applied during the connection process. + */ + fun echMode(host: String): EchMode + + /** + * Configures the [sslSocket] with Encrypted Client Hello (ECH) parameters + */ + fun applyEch( + sslSocket: SSLSocket, + echMode: EchMode, + host: String, + dns: Dns, + ) + + /** + * Returns true if [e] indicates a failure due to an invalid or expired ECH configuration. + * + * This typically occurs when the server's ECH public key has rotated. When this returns + * true, the client may use the server-provided "retry_config" to update its configuration + * and attempt the connection again. + * + * @param e the exception thrown during the SSL handshake. + */ + fun isEchConfigError(e: SSLException): Boolean = false + + companion object { + /** + * A default implementation of [EchModeConfiguration] that performs no ECH-related actions + * and always returns [EchMode.Unspecified]. + */ + val Unspecified = + object : EchModeConfiguration { + override fun echMode(hostname: String): EchMode = EchMode.Unspecified + + override fun applyEch( + sslSocket: SSLSocket, + echMode: EchMode, + hostname: String, + dns: Dns, + ) { + check(!echMode.attempt) + } + } + } +} diff --git a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/connection/ConnectPlan.kt b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/connection/ConnectPlan.kt index ae440df32832..497ee584ba27 100644 --- a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/connection/ConnectPlan.kt +++ b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/connection/ConnectPlan.kt @@ -33,6 +33,7 @@ import okhttp3.Handshake.Companion.handshake import okhttp3.Protocol import okhttp3.Request import okhttp3.Route +import okhttp3.ech.EchConfig import okhttp3.internal.closeQuietly import okhttp3.internal.concurrent.TaskRunner import okhttp3.internal.concurrent.withLock @@ -345,7 +346,12 @@ class ConnectPlan internal constructor( var success = false try { if (connectionSpec.supportsTlsExtensions) { - Platform.get().configureTlsExtensions(sslSocket, address.url.host, address.protocols) + Platform.get().configureTlsExtensions( + call = call, + sslSocket = sslSocket, + hostname = address.url.host, + protocols = address.protocols, + ) } // Force handshake. This can throw! @@ -378,9 +384,10 @@ class ConnectPlan internal constructor( val handshake = Handshake( - unverifiedHandshake.tlsVersion, - unverifiedHandshake.cipherSuite, - unverifiedHandshake.localCertificates, + tlsVersion = unverifiedHandshake.tlsVersion, + cipherSuite = unverifiedHandshake.cipherSuite, + localCertificates = unverifiedHandshake.localCertificates, + echConfig = call.tag(EchConfig::class) ) { certificatePinner.certificateChainCleaner!!.clean( unverifiedHandshake.peerCertificates, diff --git a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/http/RetryAndFollowUpInterceptor.kt b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/http/RetryAndFollowUpInterceptor.kt index 458c3eaad396..40b5a0b03c2f 100644 --- a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/http/RetryAndFollowUpInterceptor.kt +++ b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/http/RetryAndFollowUpInterceptor.kt @@ -30,17 +30,20 @@ import java.net.ProtocolException import java.net.Proxy import java.net.SocketTimeoutException import java.security.cert.CertificateException +import javax.net.ssl.SSLException import javax.net.ssl.SSLHandshakeException import javax.net.ssl.SSLPeerUnverifiedException import okhttp3.Interceptor import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.Response +import okhttp3.ech.EchMode import okhttp3.internal.canReuseConnectionFor import okhttp3.internal.closeQuietly import okhttp3.internal.connection.Exchange import okhttp3.internal.connection.RealCall import okhttp3.internal.http2.ConnectionShutdownException +import okhttp3.internal.platform.Platform import okhttp3.internal.stripBody import okhttp3.internal.withSuppressed @@ -138,6 +141,14 @@ class RetryAndFollowUpInterceptor : Interceptor { ): Boolean { val requestSendStarted = e !is ConnectionShutdownException + if (e is SSLException) { + val echConfig = call.client.echModeConfiguration + if (echConfig.echMode(call.request().url.host).fallback && echConfig.isEchConfigError(e)) { + Platform.get().log("Should retry here with ECH disabled") + call.tag(EchMode::class) { EchMode.Fallback } + } + } + // The application layer has forbidden retries. if (!chain.retryOnConnectionFailure) return false @@ -208,6 +219,10 @@ class RetryAndFollowUpInterceptor : Interceptor { exchange: Exchange?, chain: Interceptor.Chain, ): Request? { + if (chain.call().tag(EchMode::class) == EchMode.Fallback) { + return chain.request() + } + val route = exchange?.connection?.route() val responseCode = userResponse.code diff --git a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/platform/Jdk9Platform.kt b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/platform/Jdk9Platform.kt index 6247d485331f..f3d362cd37ad 100644 --- a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/platform/Jdk9Platform.kt +++ b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/platform/Jdk9Platform.kt @@ -20,6 +20,7 @@ import javax.net.ssl.SSLContext import javax.net.ssl.SSLSocket import javax.net.ssl.SSLSocketFactory import javax.net.ssl.X509TrustManager +import okhttp3.Call import okhttp3.Protocol import okhttp3.internal.SuppressSignatureCheck @@ -32,6 +33,7 @@ import okhttp3.internal.SuppressSignatureCheck open class Jdk9Platform : Platform() { @SuppressSignatureCheck override fun configureTlsExtensions( + call: Call?, sslSocket: SSLSocket, hostname: String?, protocols: List<@JvmSuppressWildcards Protocol>, diff --git a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/platform/Platform.kt b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/platform/Platform.kt index 9f72869e40d1..b72661961428 100644 --- a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/platform/Platform.kt +++ b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/platform/Platform.kt @@ -26,15 +26,16 @@ import java.util.logging.Logger import javax.net.ssl.ExtendedSSLSession import javax.net.ssl.SNIHostName import javax.net.ssl.SSLContext -import javax.net.ssl.SSLException import javax.net.ssl.SSLSocket import javax.net.ssl.SSLSocketFactory import javax.net.ssl.TrustManager import javax.net.ssl.TrustManagerFactory import javax.net.ssl.X509TrustManager +import okhttp3.Call import okhttp3.Dns import okhttp3.OkHttpClient import okhttp3.Protocol +import okhttp3.ech.EchModeConfiguration import okhttp3.internal.publicsuffix.PublicSuffixDatabase import okhttp3.internal.readFieldOrNull import okhttp3.internal.tls.BasicCertificateChainCleaner @@ -78,9 +79,10 @@ open class Platform { open fun newSSLContext(): SSLContext = SSLContext.getInstance("TLS") open fun platformTrustManager(): X509TrustManager { - val factory = TrustManagerFactory.getInstance( - TrustManagerFactory.getDefaultAlgorithm(), - ) + val factory = + TrustManagerFactory.getInstance( + TrustManagerFactory.getDefaultAlgorithm(), + ) factory.init(null as KeyStore?) val trustManagers = factory.trustManagers!! check(trustManagers.size == 1 && trustManagers[0] is X509TrustManager) { @@ -114,6 +116,7 @@ open class Platform { * Configure TLS extensions on `sslSocket` for `route`. */ open fun configureTlsExtensions( + call: Call?, sslSocket: SSLSocket, hostname: String?, protocols: List<@JvmSuppressWildcards Protocol>, @@ -169,12 +172,13 @@ open class Platform { * should be used specifically for [java.io.Closeable] objects and in conjunction with * [logCloseableLeak]. */ - open fun getStackTraceForCloseable(closer: String): Any? = when { - logger.isLoggable(Level.FINE) -> Throwable(closer) + open fun getStackTraceForCloseable(closer: String): Any? = + when { + logger.isLoggable(Level.FINE) -> Throwable(closer) - // These are expensive to allocate. - else -> null - } + // These are expensive to allocate. + else -> null + } open fun logCloseableLeak( message: String, @@ -182,7 +186,9 @@ open class Platform { ) { var logMessage = message if (stackTrace == null) { - logMessage += " To see where this was allocated, set the OkHttpClient logger level to " + "FINE: Logger.getLogger(OkHttpClient.class.getName()).setLevel(Level.FINE);" + logMessage += + " To see where this was allocated, set the OkHttpClient logger level to " + + "FINE: Logger.getLogger(OkHttpClient.class.getName()).setLevel(Level.FINE);" } log(logMessage, WARN, stackTrace as Throwable?) } @@ -190,12 +196,12 @@ open class Platform { open fun buildCertificateChainCleaner(trustManager: X509TrustManager): CertificateChainCleaner = BasicCertificateChainCleaner(buildTrustRootIndex(trustManager)) - open fun buildTrustRootIndex(trustManager: X509TrustManager): TrustRootIndex = - BasicTrustRootIndex(*trustManager.acceptedIssuers) + open fun buildTrustRootIndex(trustManager: X509TrustManager): TrustRootIndex = BasicTrustRootIndex(*trustManager.acceptedIssuers) open fun newSslSocketFactory(trustManager: X509TrustManager): SSLSocketFactory { try { - return newSSLContext().apply { + return newSSLContext() + .apply { init(null, arrayOf(trustManager), null) }.socketFactory } catch (e: GeneralSecurityException) { @@ -224,8 +230,7 @@ open class Platform { PublicSuffixDatabase.resetForTests() } - fun alpnProtocolNames(protocols: List) = - protocols.filter { it != Protocol.HTTP_1_0 }.map { it.toString() } + fun alpnProtocolNames(protocols: List) = protocols.filter { it != Protocol.HTTP_1_0 }.map { it.toString() } val isAndroid: Boolean get() = PlatformRegistry.isAndroid @@ -247,33 +252,3 @@ open class Platform { } } } - -interface EchModeConfiguration { - open fun echMode(hostname: String): EchMode - - open fun isEchConfigError(e: SSLException) = false - - companion object { - val Unspecified = object : EchModeConfiguration { - override fun echMode(hostname: String): EchMode { - return EchMode.Unspecified - } - } - } -} - -enum class EchMode(val attempt: Boolean, val require: Boolean) { - Unspecified(attempt = false, require = false), - Disabled( - attempt = false, require = false - ), - Opportunistic( - attempt = true, require = false - ), - Strict( - attempt = true, require = false - ), - FailClosed(attempt = true, require = true); - - companion object -} diff --git a/okhttp/src/jvmMain/kotlin/okhttp3/internal/platform/BouncyCastlePlatform.kt b/okhttp/src/jvmMain/kotlin/okhttp3/internal/platform/BouncyCastlePlatform.kt index 83e2e57ee61b..780d17d55d26 100644 --- a/okhttp/src/jvmMain/kotlin/okhttp3/internal/platform/BouncyCastlePlatform.kt +++ b/okhttp/src/jvmMain/kotlin/okhttp3/internal/platform/BouncyCastlePlatform.kt @@ -22,6 +22,7 @@ import javax.net.ssl.SSLSocket import javax.net.ssl.SSLSocketFactory import javax.net.ssl.TrustManagerFactory import javax.net.ssl.X509TrustManager +import okhttp3.Call import okhttp3.Protocol import org.bouncycastle.jsse.BCSSLSocket import org.bouncycastle.jsse.provider.BouncyCastleJsseProvider @@ -56,6 +57,7 @@ class BouncyCastlePlatform private constructor() : Platform() { ) override fun configureTlsExtensions( + call: Call?, sslSocket: SSLSocket, hostname: String?, protocols: List<@JvmSuppressWildcards Protocol>, @@ -69,7 +71,7 @@ class BouncyCastlePlatform private constructor() : Platform() { sslSocket.parameters = sslParameters } else { - super.configureTlsExtensions(sslSocket, hostname, protocols) + super.configureTlsExtensions(call, sslSocket, hostname, protocols) } } diff --git a/okhttp/src/jvmMain/kotlin/okhttp3/internal/platform/ConscryptPlatform.kt b/okhttp/src/jvmMain/kotlin/okhttp3/internal/platform/ConscryptPlatform.kt index 51ef4ca4b3c2..3bfab099ccb8 100644 --- a/okhttp/src/jvmMain/kotlin/okhttp3/internal/platform/ConscryptPlatform.kt +++ b/okhttp/src/jvmMain/kotlin/okhttp3/internal/platform/ConscryptPlatform.kt @@ -25,6 +25,7 @@ import javax.net.ssl.SSLSocketFactory import javax.net.ssl.TrustManager import javax.net.ssl.TrustManagerFactory import javax.net.ssl.X509TrustManager +import okhttp3.Call import okhttp3.Protocol import org.conscrypt.Conscrypt import org.conscrypt.ConscryptHostnameVerifier @@ -75,6 +76,7 @@ class ConscryptPlatform private constructor() : Platform() { override fun trustManager(sslSocketFactory: SSLSocketFactory): X509TrustManager? = null override fun configureTlsExtensions( + call: Call?, sslSocket: SSLSocket, hostname: String?, protocols: List<@JvmSuppressWildcards Protocol>, @@ -87,7 +89,7 @@ class ConscryptPlatform private constructor() : Platform() { val names = alpnProtocolNames(protocols) Conscrypt.setApplicationProtocols(sslSocket, names.toTypedArray()) } else { - super.configureTlsExtensions(sslSocket, hostname, protocols) + super.configureTlsExtensions(call, sslSocket, hostname, protocols) } } diff --git a/okhttp/src/jvmMain/kotlin/okhttp3/internal/platform/Jdk8WithJettyBootPlatform.kt b/okhttp/src/jvmMain/kotlin/okhttp3/internal/platform/Jdk8WithJettyBootPlatform.kt index 5ae0567cd212..c536cdefab3c 100644 --- a/okhttp/src/jvmMain/kotlin/okhttp3/internal/platform/Jdk8WithJettyBootPlatform.kt +++ b/okhttp/src/jvmMain/kotlin/okhttp3/internal/platform/Jdk8WithJettyBootPlatform.kt @@ -20,6 +20,7 @@ import java.lang.reflect.InvocationTargetException import java.lang.reflect.Method import java.lang.reflect.Proxy import javax.net.ssl.SSLSocket +import okhttp3.Call import okhttp3.Protocol /** OpenJDK 8 with `org.mortbay.jetty.alpn:alpn-boot` in the boot class path. */ @@ -31,6 +32,7 @@ class Jdk8WithJettyBootPlatform( private val serverProviderClass: Class<*>, ) : Platform() { override fun configureTlsExtensions( + call: Call?, sslSocket: SSLSocket, hostname: String?, protocols: List, diff --git a/okhttp/src/jvmMain/kotlin/okhttp3/internal/platform/OpenJSSEPlatform.kt b/okhttp/src/jvmMain/kotlin/okhttp3/internal/platform/OpenJSSEPlatform.kt index e4d50a391144..1ea8ad0d13cf 100644 --- a/okhttp/src/jvmMain/kotlin/okhttp3/internal/platform/OpenJSSEPlatform.kt +++ b/okhttp/src/jvmMain/kotlin/okhttp3/internal/platform/OpenJSSEPlatform.kt @@ -22,6 +22,7 @@ import javax.net.ssl.SSLSocket import javax.net.ssl.SSLSocketFactory import javax.net.ssl.TrustManagerFactory import javax.net.ssl.X509TrustManager +import okhttp3.Call import okhttp3.Protocol /** @@ -60,6 +61,7 @@ class OpenJSSEPlatform private constructor() : Platform() { ) override fun configureTlsExtensions( + call: Call?, sslSocket: SSLSocket, hostname: String?, protocols: List<@JvmSuppressWildcards Protocol>, @@ -75,7 +77,7 @@ class OpenJSSEPlatform private constructor() : Platform() { sslSocket.sslParameters = sslParameters } } else { - super.configureTlsExtensions(sslSocket, hostname, protocols) + super.configureTlsExtensions(call, sslSocket, hostname, protocols) } } From 7029d1278055a09f5446c4be5e79710fbc82954b Mon Sep 17 00:00:00 2001 From: Yuri Schimke Date: Sun, 5 Apr 2026 00:01:42 +0100 Subject: [PATCH 13/31] Android API 37 --- android-test-app/build.gradle.kts | 7 +- android-test/build.gradle.kts | 7 +- .../kotlin/okhttp.jvm-conventions.gradle.kts | 1 - gradle/libs.versions.toml | 8 +- gradle/wrapper/gradle-wrapper.properties | 2 +- mockwebserver-deprecated/build.gradle.kts | 1 - mockwebserver-junit5/build.gradle.kts | 1 - mockwebserver/build.gradle.kts | 1 - native-image-tests/build.gradle.kts | 1 - okhttp-brotli/build.gradle.kts | 1 - okhttp-coroutines/build.gradle.kts | 1 - okhttp-dnsoverhttps/build.gradle.kts | 1 - okhttp-osgi-tests/build.gradle.kts | 1 - okhttp-testing-support/build.gradle.kts | 1 - okhttp-tls/build.gradle.kts | 1 - okhttp-zstd/build.gradle.kts | 1 - okhttp/build.gradle.kts | 7 +- .../internal/platform/Android17Platform.kt | 10 +-- .../platform/AndroidDnsResolverDns.kt | 73 ++++++++++++------- .../android/AndroidEchModeConfiguration.kt | 4 +- .../commonJvmAndroid/kotlin/okhttp3/Dns.kt | 2 +- .../kotlin/okhttp3/ech/EchConfig.kt | 2 +- .../okhttp3/ech/EchModeConfiguration.kt | 4 +- .../internal/connection/ConnectPlan.kt | 2 +- 24 files changed, 67 insertions(+), 73 deletions(-) diff --git a/android-test-app/build.gradle.kts b/android-test-app/build.gradle.kts index 362023ac2266..e56326e65c54 100644 --- a/android-test-app/build.gradle.kts +++ b/android-test-app/build.gradle.kts @@ -1,8 +1,5 @@ @file:Suppress("UnstableApiUsage") -import okhttp3.buildsupport.testJavaVersion - - plugins { id("okhttp.base-conventions") id("com.android.application") @@ -10,7 +7,7 @@ plugins { android { compileSdk { - version = preview("CANARY") + version = release(37) } namespace = "okhttp.android.testapp" @@ -20,7 +17,7 @@ android { defaultConfig { minSdk = 21 - targetSdk = 36 + targetSdk = 37 testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } diff --git a/android-test/build.gradle.kts b/android-test/build.gradle.kts index bbf89537476a..b7bf36ebe9ce 100644 --- a/android-test/build.gradle.kts +++ b/android-test/build.gradle.kts @@ -8,7 +8,7 @@ plugins { android { compileSdk { - version = preview("CANARY") + version = release(37) } namespace = "okhttp.android.test" @@ -61,6 +61,7 @@ dependencies { implementation(libs.playservices.safetynet) "friendsImplementation"(projects.okhttp) "friendsImplementation"(projects.okhttpDnsoverhttps) + implementation(libs.androidx.activity) testImplementation(projects.okhttp) testImplementation(libs.junit) @@ -98,14 +99,10 @@ dependencies { androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(libs.http.client5) androidTestImplementation(libs.kotlin.test.common) - androidTestImplementation(libs.kotlin.test.junit) androidTestImplementation(libs.square.moshi) androidTestImplementation(libs.square.moshi.kotlin) androidTestImplementation(libs.square.okio.fakefilesystem) - //noinspection UseTomlInstead - androidTestImplementation("dnsjava:dnsjava:3.6.4") - androidTestImplementation(libs.androidx.test.runner) androidTestImplementation(libs.junit.jupiter.api) androidTestImplementation(libs.junit5android.core) diff --git a/build-logic/src/main/kotlin/okhttp.jvm-conventions.gradle.kts b/build-logic/src/main/kotlin/okhttp.jvm-conventions.gradle.kts index 6138b8f833fd..35fbd39ea8a2 100644 --- a/build-logic/src/main/kotlin/okhttp.jvm-conventions.gradle.kts +++ b/build-logic/src/main/kotlin/okhttp.jvm-conventions.gradle.kts @@ -41,7 +41,6 @@ tasks.withType { compilerOptions { jvmTarget.set(JvmTarget.JVM_1_8) freeCompilerArgs.addAll( - "-Xjvm-default=all", "-Xexpect-actual-classes", ) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f4888c20cc42..1190ddef560a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,8 +1,8 @@ [versions] -agp = "9.1.0" +agp = "9.2.0-alpha07" amazon-corretto = "2.5.0" android-junit5 = "2.0.1" -androidx-activity = "1.11.0" +androidx-activity = "1.13.0" androidx-annotation = "1.9.1" androidx-espresso-core = "3.7.0" androidx-junit = "1.3.0" @@ -13,7 +13,7 @@ assertk = "0.28.1" binary-compatibility-validator = "0.18.1" bnd = "7.2.3" brotli-dec = "0.1.2" -burst = "2.10.2" +burst = "2.12.1" checkstyle = "13.4.0" clikt = "5.1.0" extra-java-module-info = "1.14" @@ -35,7 +35,7 @@ jsoup = "1.22.1" junit-pioneer = "1.9.1" junit-platform = "1.14.3" junit4 = "4.13.2" -kotlin = "2.2.21" +kotlin = "2.3.20" ksp = "2.3.6" lint-gradle = "1.0.0-alpha05" maven-publish = "0.36.0" diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index c61a118f7ddb..44a1a53aa278 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.0-rc-1-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/mockwebserver-deprecated/build.gradle.kts b/mockwebserver-deprecated/build.gradle.kts index 8f0ab31584aa..d07b8e41dff8 100644 --- a/mockwebserver-deprecated/build.gradle.kts +++ b/mockwebserver-deprecated/build.gradle.kts @@ -17,5 +17,4 @@ dependencies { testImplementation(projects.okhttpTestingSupport) testImplementation(projects.okhttpTls) testImplementation(libs.kotlin.test.common) - testImplementation(libs.kotlin.test.junit) } diff --git a/mockwebserver-junit5/build.gradle.kts b/mockwebserver-junit5/build.gradle.kts index a759a0c5c36b..0638f0d49d7e 100644 --- a/mockwebserver-junit5/build.gradle.kts +++ b/mockwebserver-junit5/build.gradle.kts @@ -15,7 +15,6 @@ dependencies { compileOnly(libs.animalsniffer.annotations) testRuntimeOnly(libs.junit.jupiter.engine) - testImplementation(libs.kotlin.junit5) testImplementation(projects.okhttpTestingSupport) testImplementation(libs.assertk) } diff --git a/mockwebserver/build.gradle.kts b/mockwebserver/build.gradle.kts index 1d5bcfb0460c..be6dfb305e32 100644 --- a/mockwebserver/build.gradle.kts +++ b/mockwebserver/build.gradle.kts @@ -16,7 +16,6 @@ dependencies { testImplementation(projects.mockwebserver3Junit5) testImplementation(libs.junit) testImplementation(libs.kotlin.test.common) - testImplementation(libs.kotlin.test.junit) testImplementation(libs.assertk) } diff --git a/native-image-tests/build.gradle.kts b/native-image-tests/build.gradle.kts index d930d2b2580e..a89aace43d54 100644 --- a/native-image-tests/build.gradle.kts +++ b/native-image-tests/build.gradle.kts @@ -39,7 +39,6 @@ dependencies { testImplementation(projects.mockwebserver3Junit5) testImplementation(libs.assertk) testRuntimeOnly(libs.junit.jupiter.engine) - testImplementation(libs.kotlin.junit5) testImplementation(libs.junit.jupiter.params) } diff --git a/okhttp-brotli/build.gradle.kts b/okhttp-brotli/build.gradle.kts index 15c9fba4bf0a..371eaf5d9643 100644 --- a/okhttp-brotli/build.gradle.kts +++ b/okhttp-brotli/build.gradle.kts @@ -21,6 +21,5 @@ dependencies { testImplementation(libs.conscrypt.openjdk) testImplementation(libs.junit) testImplementation(libs.kotlin.test.common) - testImplementation(libs.kotlin.test.junit) testImplementation(libs.assertk) } diff --git a/okhttp-coroutines/build.gradle.kts b/okhttp-coroutines/build.gradle.kts index 0072bac92c73..2dfeaef656fa 100644 --- a/okhttp-coroutines/build.gradle.kts +++ b/okhttp-coroutines/build.gradle.kts @@ -21,7 +21,6 @@ dependencies { testImplementation(libs.kotlin.test.annotations) testImplementation(libs.kotlin.test.common) - testImplementation(libs.kotlin.test.junit) testApi(libs.assertk) testImplementation(projects.okhttpTestingSupport) testImplementation(libs.kotlinx.coroutines.test) diff --git a/okhttp-dnsoverhttps/build.gradle.kts b/okhttp-dnsoverhttps/build.gradle.kts index ac661973a16f..e8e34a47fd04 100644 --- a/okhttp-dnsoverhttps/build.gradle.kts +++ b/okhttp-dnsoverhttps/build.gradle.kts @@ -23,5 +23,4 @@ dependencies { testImplementation(libs.conscrypt.openjdk) testImplementation(libs.junit) testImplementation(libs.kotlin.test.common) - testImplementation(libs.kotlin.test.junit) } diff --git a/okhttp-osgi-tests/build.gradle.kts b/okhttp-osgi-tests/build.gradle.kts index 0ef1794d5abe..fbaeef80e208 100644 --- a/okhttp-osgi-tests/build.gradle.kts +++ b/okhttp-osgi-tests/build.gradle.kts @@ -19,7 +19,6 @@ dependencies { testImplementation(projects.okhttpTestingSupport) testImplementation(libs.junit) testImplementation(libs.kotlin.test.common) - testImplementation(libs.kotlin.test.junit) testImplementation(libs.assertk) testImplementation(libs.aqute.resolve) diff --git a/okhttp-testing-support/build.gradle.kts b/okhttp-testing-support/build.gradle.kts index 690b5f25b4c4..d602b82b97df 100644 --- a/okhttp-testing-support/build.gradle.kts +++ b/okhttp-testing-support/build.gradle.kts @@ -43,7 +43,6 @@ dependencies { compileOnly(libs.robolectric.android) testImplementation(libs.kotlin.test.common) - testImplementation(libs.kotlin.test.junit) } animalsniffer { diff --git a/okhttp-tls/build.gradle.kts b/okhttp-tls/build.gradle.kts index 6b4e9869baed..c1ce14076c4e 100644 --- a/okhttp-tls/build.gradle.kts +++ b/okhttp-tls/build.gradle.kts @@ -22,7 +22,6 @@ dependencies { testImplementation(projects.mockwebserver3Junit5) testImplementation(libs.junit) testImplementation(libs.kotlin.test.common) - testImplementation(libs.kotlin.test.junit) testImplementation(libs.assertk) } diff --git a/okhttp-zstd/build.gradle.kts b/okhttp-zstd/build.gradle.kts index 768ea1a7f026..69e8bf1ed4f1 100644 --- a/okhttp-zstd/build.gradle.kts +++ b/okhttp-zstd/build.gradle.kts @@ -19,6 +19,5 @@ dependencies { testImplementation(projects.okhttpBrotli) testImplementation(projects.okhttpTestingSupport) testImplementation(libs.kotlin.test.common) - testImplementation(libs.kotlin.test.junit) testImplementation(libs.assertk) } diff --git a/okhttp/build.gradle.kts b/okhttp/build.gradle.kts index 261225baa7cc..f461b95f4ec7 100644 --- a/okhttp/build.gradle.kts +++ b/okhttp/build.gradle.kts @@ -58,7 +58,7 @@ kotlin { android { namespace = "okhttp.okhttp3" compileSdk { - version = preview("CANARY") + version = release(37) } minSdk = 21 @@ -108,7 +108,6 @@ kotlin { implementation(libs.assertk) implementation(libs.kotlin.test.annotations) implementation(libs.kotlin.test.common) - implementation(libs.kotlin.test.junit) implementation(libs.junit) implementation(libs.junit.jupiter.api) implementation(libs.junit.jupiter.params) @@ -123,9 +122,6 @@ kotlin { compileOnly(libs.conscrypt.openjdk) implementation(libs.androidx.annotation) implementation(libs.androidx.startup.runtime) - - //noinspection UseTomlInstead - implementation("dnsjava:dnsjava:3.6.4") } } @@ -155,7 +151,6 @@ kotlin { implementation(libs.junit) implementation(libs.kotlin.test.annotations) implementation(libs.kotlin.test.common) - implementation(libs.kotlin.test.junit) implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.serialization.core) implementation(libs.kotlinx.serialization.json) diff --git a/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/Android17Platform.kt b/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/Android17Platform.kt index 270eec930ca7..3745d6fa2f14 100644 --- a/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/Android17Platform.kt +++ b/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/Android17Platform.kt @@ -43,14 +43,10 @@ import okhttp3.internal.tls.TrustRootIndex /** Android 17+ (API 37+). */ @SuppressSignatureCheck class Android17Platform - @RequiresApi(36) + @RequiresApi(37) internal constructor() : Platform(), ContextAwarePlatform { - init { - println("Android17Platform") - } - override var applicationContext: Context? = null private val socketAdapter by lazy { @@ -124,9 +120,9 @@ class Android17Platform override fun platformDns(): Dns = AndroidDnsResolverDns() companion object { - val isSupported: Boolean = (isAndroid && Build.VERSION.SDK_INT >= 36) + val isSupported: Boolean = (isAndroid && Build.VERSION.SDK_INT >= 37) - @ChecksSdkIntAtLeast(36) + @ChecksSdkIntAtLeast(37) fun buildIfSupported(): Platform? = if (isSupported) Android17Platform() else null } } diff --git a/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/AndroidDnsResolverDns.kt b/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/AndroidDnsResolverDns.kt index 5c0991395999..182ef55d1474 100644 --- a/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/AndroidDnsResolverDns.kt +++ b/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/AndroidDnsResolverDns.kt @@ -17,69 +17,90 @@ package okhttp3.internal.platform import android.net.DnsResolver import android.net.DnsResolver.Callback +import android.net.dns.HttpsEndpoint +import android.net.dns.HttpsRecord import androidx.annotation.RequiresApi import java.net.InetAddress import java.util.concurrent.CompletableFuture +import java.util.concurrent.Executor import java.util.concurrent.Future +import java.util.concurrent.TimeUnit import okhttp3.Dns import okhttp3.EchAware import okio.ByteString import okio.ByteString.Companion.toByteString -import org.xbill.DNS.HTTPSRecord -import org.xbill.DNS.Message -import org.xbill.DNS.SVCBBase -import org.xbill.DNS.Section.ANSWER @Suppress("NewApi") @RequiresApi(36) class AndroidDnsResolverDns : Dns, EchAware { - val dnsResolver = DnsResolver.getInstance() + val dnsResolver: DnsResolver by lazy { + val handlerThread = android.os.HandlerThread("DnsLooper").apply { start() } - val httpsRecords: MutableMap> = HashMap() + DnsResolver(PlatformRegistry.applicationContext!!, handlerThread.looper) + } + + val httpsRecords: MutableMap> = HashMap() override fun lookup(hostname: String): List { - val future = CompletableFuture() + val httpsFuture = CompletableFuture() + val dnsFuture = CompletableFuture>() - val callback: Callback = - object : Callback { + val callback: Callback = + object : Callback { override fun onAnswer( - p0: ByteArray, - p1: Int, + answer: HttpsEndpoint, + rcode: Int, ) { - val answers = Message(p0).getSection(ANSWER) - if (answers.isEmpty()) { - future.complete(null) - } else { - future.complete(answers.single() as HTTPSRecord) + if (answer.httpsRecords.isNotEmpty()) { + if (answer.httpsRecords.size > 1) { + answer.httpsRecords.forEach { + println("${it.priority} ${it.targetName} ${it.port} ${it.alpnIds} ${it.ipAddressHints}") + } + } + httpsFuture.complete(answer.httpsRecords.first()) + } + if (answer.ipAddresses.isNotEmpty()) { + dnsFuture.complete(answer.ipAddresses) } } override fun onError(p0: DnsResolver.DnsException) { - future.completeExceptionally(p0) + if (!dnsFuture.isDone) { + dnsFuture.completeExceptionally(p0) + } + if (!httpsFuture.isDone) { + httpsFuture.completeExceptionally(p0) + } } } @Suppress("WrongConstant") - dnsResolver.rawQuery( + dnsResolver.query( + // network = null, + // domain = hostname, - DnsResolver.CLASS_IN, - 65, + // flags = DnsResolver.FLAG_EMPTY, + // executor = { it.run() }, + // httpsTimeoutMillis = + 1_000, + // cancellationSignal = null, + // callback = callback, ) - httpsRecords[hostname] = future + httpsRecords[hostname] = httpsFuture - // TODO replace with DnsResolver call - return Dns.SYSTEM.lookup(hostname) + // TODO replace with real timeout + return dnsFuture.get(5, TimeUnit.SECONDS) } - override fun getHostRecords(host: String): ByteString? { + override fun getHostRecords(host: String): Any? { val record = httpsRecords[host]?.get() - val echConfig = record?.getSvcParamValue(HTTPSRecord.ECH) as SVCBBase.ParameterEch? - return echConfig?.data?.toByteString() + val echConfig = record?.echConfigList + return echConfig } } diff --git a/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/android/AndroidEchModeConfiguration.kt b/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/android/AndroidEchModeConfiguration.kt index 1c30d72b3422..b90a88130acf 100644 --- a/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/android/AndroidEchModeConfiguration.kt +++ b/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/android/AndroidEchModeConfiguration.kt @@ -53,12 +53,12 @@ class AndroidEchModeConfiguration : EchModeConfiguration { host: String, dns: Dns, ) { - val echConfig = (dns as EchAware).getHostRecords(host) + val echConfig = (dns as? EchAware)?.getHostRecords(host) if (echConfig != null) { SSLSockets.setEchConfigList( sslSocket, - EchConfigList.fromBytes(echConfig.toByteArray()), + echConfig as EchConfigList, ) } else if (echMode.require) { throw IOException("Unable to apply required ECH config for $host") diff --git a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/Dns.kt b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/Dns.kt index 61632a499da0..7f02aa5d9c71 100644 --- a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/Dns.kt +++ b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/Dns.kt @@ -60,5 +60,5 @@ fun interface Dns { } interface EchAware { - fun getHostRecords(host: String): ByteString? + fun getHostRecords(host: String): Any? } diff --git a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/ech/EchConfig.kt b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/ech/EchConfig.kt index e3c7dfc59e7e..24feb905f9cf 100644 --- a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/ech/EchConfig.kt +++ b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/ech/EchConfig.kt @@ -25,5 +25,5 @@ import okio.ByteString * These parameters are typically retrieved from DNS via HTTPS or SVCB records. */ data class EchConfig( - val config: ByteString + val config: ByteString, ) diff --git a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/ech/EchModeConfiguration.kt b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/ech/EchModeConfiguration.kt index d9c17fd6db1c..01ab731803a8 100644 --- a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/ech/EchModeConfiguration.kt +++ b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/ech/EchModeConfiguration.kt @@ -62,12 +62,12 @@ interface EchModeConfiguration { */ val Unspecified = object : EchModeConfiguration { - override fun echMode(hostname: String): EchMode = EchMode.Unspecified + override fun echMode(host: String): EchMode = EchMode.Unspecified override fun applyEch( sslSocket: SSLSocket, echMode: EchMode, - hostname: String, + host: String, dns: Dns, ) { check(!echMode.attempt) diff --git a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/connection/ConnectPlan.kt b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/connection/ConnectPlan.kt index 497ee584ba27..3fa785fd8eb6 100644 --- a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/connection/ConnectPlan.kt +++ b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/connection/ConnectPlan.kt @@ -387,7 +387,7 @@ class ConnectPlan internal constructor( tlsVersion = unverifiedHandshake.tlsVersion, cipherSuite = unverifiedHandshake.cipherSuite, localCertificates = unverifiedHandshake.localCertificates, - echConfig = call.tag(EchConfig::class) + echConfig = call.tag(EchConfig::class), ) { certificatePinner.certificateChainCleaner!!.clean( unverifiedHandshake.peerCertificates, From ae060bbbc70f62b03114d4961fe49d6df7fc8f12 Mon Sep 17 00:00:00 2001 From: Yuri Schimke Date: Sun, 5 Apr 2026 11:17:35 +0100 Subject: [PATCH 14/31] Testing with robolectric also --- android-test/build.gradle.kts | 6 +++++- android-test/src/test/resources/robolectric.properties | 1 + .../src/main/kotlin/okhttp.testing-conventions.gradle.kts | 2 ++ gradle/libs.versions.toml | 2 +- settings.gradle.kts | 2 ++ 5 files changed, 11 insertions(+), 2 deletions(-) create mode 100644 android-test/src/test/resources/robolectric.properties diff --git a/android-test/build.gradle.kts b/android-test/build.gradle.kts index b7bf36ebe9ce..e65eb2f71741 100644 --- a/android-test/build.gradle.kts +++ b/android-test/build.gradle.kts @@ -44,7 +44,6 @@ android { unitTests.isIncludeAndroidResources = true } - // issue merging due to conflict with httpclient and something else packagingOptions.resources.excludes += setOf( "META-INF/DEPENDENCIES", @@ -114,3 +113,8 @@ junitPlatform { excludeTags("Remote") } } + +tasks.withType { + // Fix for robolectric https://github.com/robolectric/robolectric/pull/10996 + jvmArgs("--add-opens", "java.base/jdk.internal.access=ALL-UNNAMED") +} diff --git a/android-test/src/test/resources/robolectric.properties b/android-test/src/test/resources/robolectric.properties new file mode 100644 index 000000000000..8a093a9991d3 --- /dev/null +++ b/android-test/src/test/resources/robolectric.properties @@ -0,0 +1 @@ +sdk=36 diff --git a/build-logic/src/main/kotlin/okhttp.testing-conventions.gradle.kts b/build-logic/src/main/kotlin/okhttp.testing-conventions.gradle.kts index 08dc0e1759d4..3216f51435d4 100644 --- a/build-logic/src/main/kotlin/okhttp.testing-conventions.gradle.kts +++ b/build-logic/src/main/kotlin/okhttp.testing-conventions.gradle.kts @@ -24,6 +24,8 @@ dependencies { tasks.withType { useJUnitPlatform() jvmArgs("-Dokhttp.platform=$platform") + // Fix for robolectric https://github.com/robolectric/robolectric/pull/10996 + jvmArgs("--add-opens", "java.base/jdk.internal.access=ALL-UNNAMED") if (platform == "loom") { jvmArgs("-Djdk.tracePinnedThreads=short") diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1190ddef560a..1cb30a3beb15 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -47,7 +47,7 @@ org-bouncycastle = "1.83" org-conscrypt = "2.5.2" org-junit-jupiter = "5.13.4" playservices-safetynet = "18.1.0" -robolectric = "4.16.1" +robolectric = "4.17-SNAPSHOT" robolectric-android = "16-robolectric-13921718" serialization = "1.10.0" shadow-plugin = "9.4.1" diff --git a/settings.gradle.kts b/settings.gradle.kts index 53d4dfdba76e..d0eeee2a8389 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -6,6 +6,7 @@ pluginManagement { mavenCentral() gradlePluginPortal() google() + maven { url = java.net.URI.create("https://central.sonatype.com/repository/maven-snapshots/") } } } @@ -15,6 +16,7 @@ dependencyResolutionManagement { repositories { mavenCentral() google() + maven { url = java.net.URI.create("https://central.sonatype.com/repository/maven-snapshots/") } } } From 1a05f84271e8620558625781db889aa835e3b309 Mon Sep 17 00:00:00 2001 From: Yuri Schimke Date: Thu, 30 Apr 2026 16:29:03 +0100 Subject: [PATCH 15/31] More fixes --- gradle/libs.versions.toml | 2 +- gradle/wrapper/gradle-wrapper.properties | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e2767a99b634..d6d76df1d0ad 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,5 @@ [versions] -agp = "9.1.1" +agp = "9.2.0" amazon-corretto = "2.5.0" android-junit5 = "2.0.1" androidx-activity = "1.11.0" diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 44a1a53aa278..1a704683a002 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.0-rc-1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.0-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME From e034658f5d70562ff6853b885451faf290ac086a Mon Sep 17 00:00:00 2001 From: Yuri Schimke Date: Mon, 4 May 2026 09:48:06 +0100 Subject: [PATCH 16/31] Fix ECH branch CI failures --- android-test/build.gradle.kts | 15 +++++++++------ mockwebserver-deprecated/build.gradle.kts | 1 + mockwebserver/build.gradle.kts | 1 + okhttp-brotli/build.gradle.kts | 1 + okhttp-coroutines/build.gradle.kts | 1 + okhttp-dnsoverhttps/build.gradle.kts | 1 + okhttp-testing-support/build.gradle.kts | 1 + okhttp-tls/build.gradle.kts | 1 + okhttp-zstd/build.gradle.kts | 1 + okhttp/api/android/okhttp.api | 14 +++++++++++--- okhttp/api/jvm/okhttp.api | 14 +++++++++++--- okhttp/build.gradle.kts | 2 ++ okhttp/src/commonJvmAndroid/kotlin/okhttp3/Dns.kt | 1 - 13 files changed, 41 insertions(+), 13 deletions(-) diff --git a/android-test/build.gradle.kts b/android-test/build.gradle.kts index 90424d902c14..5e0943894672 100644 --- a/android-test/build.gradle.kts +++ b/android-test/build.gradle.kts @@ -27,12 +27,14 @@ android { } if (androidBuild) { - sourceSets["androidTest"].java.srcDirs( - "../okhttp-brotli/src/test/java", - "../okhttp-dnsoverhttps/src/test/java", - "../okhttp-logging-interceptor/src/test/java", - "../okhttp-sse/src/test/java" - ) + sourceSets.getByName("androidTest") { + java.srcDirs( + "../okhttp-brotli/src/test/java", + "../okhttp-dnsoverhttps/src/test/java", + "../okhttp-logging-interceptor/src/test/java", + "../okhttp-sse/src/test/java", + ) + } } compileOptions { @@ -99,6 +101,7 @@ dependencies { androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(libs.http.client5) androidTestImplementation(libs.kotlin.test.common) + androidTestImplementation(libs.kotlin.test.junit) androidTestImplementation(libs.square.moshi) androidTestImplementation(libs.square.moshi.kotlin) androidTestImplementation(libs.square.okio.fakefilesystem) diff --git a/mockwebserver-deprecated/build.gradle.kts b/mockwebserver-deprecated/build.gradle.kts index d07b8e41dff8..8f0ab31584aa 100644 --- a/mockwebserver-deprecated/build.gradle.kts +++ b/mockwebserver-deprecated/build.gradle.kts @@ -17,4 +17,5 @@ dependencies { testImplementation(projects.okhttpTestingSupport) testImplementation(projects.okhttpTls) testImplementation(libs.kotlin.test.common) + testImplementation(libs.kotlin.test.junit) } diff --git a/mockwebserver/build.gradle.kts b/mockwebserver/build.gradle.kts index be6dfb305e32..1d5bcfb0460c 100644 --- a/mockwebserver/build.gradle.kts +++ b/mockwebserver/build.gradle.kts @@ -16,6 +16,7 @@ dependencies { testImplementation(projects.mockwebserver3Junit5) testImplementation(libs.junit) testImplementation(libs.kotlin.test.common) + testImplementation(libs.kotlin.test.junit) testImplementation(libs.assertk) } diff --git a/okhttp-brotli/build.gradle.kts b/okhttp-brotli/build.gradle.kts index 371eaf5d9643..15c9fba4bf0a 100644 --- a/okhttp-brotli/build.gradle.kts +++ b/okhttp-brotli/build.gradle.kts @@ -21,5 +21,6 @@ dependencies { testImplementation(libs.conscrypt.openjdk) testImplementation(libs.junit) testImplementation(libs.kotlin.test.common) + testImplementation(libs.kotlin.test.junit) testImplementation(libs.assertk) } diff --git a/okhttp-coroutines/build.gradle.kts b/okhttp-coroutines/build.gradle.kts index 2dfeaef656fa..0072bac92c73 100644 --- a/okhttp-coroutines/build.gradle.kts +++ b/okhttp-coroutines/build.gradle.kts @@ -21,6 +21,7 @@ dependencies { testImplementation(libs.kotlin.test.annotations) testImplementation(libs.kotlin.test.common) + testImplementation(libs.kotlin.test.junit) testApi(libs.assertk) testImplementation(projects.okhttpTestingSupport) testImplementation(libs.kotlinx.coroutines.test) diff --git a/okhttp-dnsoverhttps/build.gradle.kts b/okhttp-dnsoverhttps/build.gradle.kts index e8e34a47fd04..ac661973a16f 100644 --- a/okhttp-dnsoverhttps/build.gradle.kts +++ b/okhttp-dnsoverhttps/build.gradle.kts @@ -23,4 +23,5 @@ dependencies { testImplementation(libs.conscrypt.openjdk) testImplementation(libs.junit) testImplementation(libs.kotlin.test.common) + testImplementation(libs.kotlin.test.junit) } diff --git a/okhttp-testing-support/build.gradle.kts b/okhttp-testing-support/build.gradle.kts index d602b82b97df..690b5f25b4c4 100644 --- a/okhttp-testing-support/build.gradle.kts +++ b/okhttp-testing-support/build.gradle.kts @@ -43,6 +43,7 @@ dependencies { compileOnly(libs.robolectric.android) testImplementation(libs.kotlin.test.common) + testImplementation(libs.kotlin.test.junit) } animalsniffer { diff --git a/okhttp-tls/build.gradle.kts b/okhttp-tls/build.gradle.kts index c1ce14076c4e..6b4e9869baed 100644 --- a/okhttp-tls/build.gradle.kts +++ b/okhttp-tls/build.gradle.kts @@ -22,6 +22,7 @@ dependencies { testImplementation(projects.mockwebserver3Junit5) testImplementation(libs.junit) testImplementation(libs.kotlin.test.common) + testImplementation(libs.kotlin.test.junit) testImplementation(libs.assertk) } diff --git a/okhttp-zstd/build.gradle.kts b/okhttp-zstd/build.gradle.kts index 69e8bf1ed4f1..768ea1a7f026 100644 --- a/okhttp-zstd/build.gradle.kts +++ b/okhttp-zstd/build.gradle.kts @@ -19,5 +19,6 @@ dependencies { testImplementation(projects.okhttpBrotli) testImplementation(projects.okhttpTestingSupport) testImplementation(libs.kotlin.test.common) + testImplementation(libs.kotlin.test.junit) testImplementation(libs.assertk) } diff --git a/okhttp/api/android/okhttp.api b/okhttp/api/android/okhttp.api index c4cb246ce95c..da0fef9f33d0 100644 --- a/okhttp/api/android/okhttp.api +++ b/okhttp/api/android/okhttp.api @@ -486,7 +486,7 @@ public final class okhttp3/Dns$Companion { } public abstract interface class okhttp3/EchAware { - public abstract fun getHostRecords (Ljava/lang/String;)Lokio/ByteString; + public abstract fun getHostRecords (Ljava/lang/String;)Ljava/lang/Object; } public abstract class okhttp3/EventListener { @@ -1290,12 +1290,16 @@ public abstract interface class okhttp3/TrailersSource { public static final field Companion Lokhttp3/TrailersSource$Companion; public static final field EMPTY Lokhttp3/TrailersSource; public abstract fun get ()Lokhttp3/Headers; - public fun peek ()Lokhttp3/Headers; + public abstract fun peek ()Lokhttp3/Headers; } public final class okhttp3/TrailersSource$Companion { } +public final class okhttp3/TrailersSource$DefaultImpls { + public static fun peek (Lokhttp3/TrailersSource;)Lokhttp3/Headers; +} + public abstract interface class okhttp3/WebSocket { public abstract fun cancel ()V public abstract fun close (ILjava/lang/String;)Z @@ -1353,10 +1357,14 @@ public abstract interface class okhttp3/ech/EchModeConfiguration { public static final field Companion Lokhttp3/ech/EchModeConfiguration$Companion; public abstract fun applyEch (Ljavax/net/ssl/SSLSocket;Lokhttp3/ech/EchMode;Ljava/lang/String;Lokhttp3/Dns;)V public abstract fun echMode (Ljava/lang/String;)Lokhttp3/ech/EchMode; - public fun isEchConfigError (Ljavax/net/ssl/SSLException;)Z + public abstract fun isEchConfigError (Ljavax/net/ssl/SSLException;)Z } public final class okhttp3/ech/EchModeConfiguration$Companion { public final fun getUnspecified ()Lokhttp3/ech/EchModeConfiguration; } +public final class okhttp3/ech/EchModeConfiguration$DefaultImpls { + public static fun isEchConfigError (Lokhttp3/ech/EchModeConfiguration;Ljavax/net/ssl/SSLException;)Z +} + diff --git a/okhttp/api/jvm/okhttp.api b/okhttp/api/jvm/okhttp.api index c28e3baa99b5..a9a3dbec03a6 100644 --- a/okhttp/api/jvm/okhttp.api +++ b/okhttp/api/jvm/okhttp.api @@ -486,7 +486,7 @@ public final class okhttp3/Dns$Companion { } public abstract interface class okhttp3/EchAware { - public abstract fun getHostRecords (Ljava/lang/String;)Lokio/ByteString; + public abstract fun getHostRecords (Ljava/lang/String;)Ljava/lang/Object; } public abstract class okhttp3/EventListener { @@ -1289,12 +1289,16 @@ public abstract interface class okhttp3/TrailersSource { public static final field Companion Lokhttp3/TrailersSource$Companion; public static final field EMPTY Lokhttp3/TrailersSource; public abstract fun get ()Lokhttp3/Headers; - public fun peek ()Lokhttp3/Headers; + public abstract fun peek ()Lokhttp3/Headers; } public final class okhttp3/TrailersSource$Companion { } +public final class okhttp3/TrailersSource$DefaultImpls { + public static fun peek (Lokhttp3/TrailersSource;)Lokhttp3/Headers; +} + public abstract interface class okhttp3/WebSocket { public abstract fun cancel ()V public abstract fun close (ILjava/lang/String;)Z @@ -1352,10 +1356,14 @@ public abstract interface class okhttp3/ech/EchModeConfiguration { public static final field Companion Lokhttp3/ech/EchModeConfiguration$Companion; public abstract fun applyEch (Ljavax/net/ssl/SSLSocket;Lokhttp3/ech/EchMode;Ljava/lang/String;Lokhttp3/Dns;)V public abstract fun echMode (Ljava/lang/String;)Lokhttp3/ech/EchMode; - public fun isEchConfigError (Ljavax/net/ssl/SSLException;)Z + public abstract fun isEchConfigError (Ljavax/net/ssl/SSLException;)Z } public final class okhttp3/ech/EchModeConfiguration$Companion { public final fun getUnspecified ()Lokhttp3/ech/EchModeConfiguration; } +public final class okhttp3/ech/EchModeConfiguration$DefaultImpls { + public static fun isEchConfigError (Lokhttp3/ech/EchModeConfiguration;Ljavax/net/ssl/SSLException;)Z +} + diff --git a/okhttp/build.gradle.kts b/okhttp/build.gradle.kts index f461b95f4ec7..0c5effab190d 100644 --- a/okhttp/build.gradle.kts +++ b/okhttp/build.gradle.kts @@ -151,6 +151,7 @@ kotlin { implementation(libs.junit) implementation(libs.kotlin.test.annotations) implementation(libs.kotlin.test.common) + implementation(libs.kotlin.test.junit) implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.serialization.core) implementation(libs.kotlinx.serialization.json) @@ -190,6 +191,7 @@ kotlin { implementation(libs.junit.vintage.engine) implementation(libs.kotlin.test.annotations) implementation(libs.kotlin.test.common) + implementation(libs.kotlin.test.junit) implementation(libs.robolectric) } } diff --git a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/Dns.kt b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/Dns.kt index 7f02aa5d9c71..7cb39dc5d18b 100644 --- a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/Dns.kt +++ b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/Dns.kt @@ -18,7 +18,6 @@ package okhttp3 import java.net.InetAddress import java.net.UnknownHostException import okhttp3.Dns.Companion.SYSTEM -import okio.ByteString /** * A domain name service that resolves IP addresses for host names. Most applications will use the From 04f4a43bf119c84389c64e758e2116f1b07b3bcc Mon Sep 17 00:00:00 2001 From: Yuri Schimke Date: Mon, 4 May 2026 10:05:13 +0100 Subject: [PATCH 17/31] Avoid AGP source set cast for android tests --- android-test/build.gradle.kts | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/android-test/build.gradle.kts b/android-test/build.gradle.kts index 5e0943894672..72255d953cb6 100644 --- a/android-test/build.gradle.kts +++ b/android-test/build.gradle.kts @@ -26,17 +26,6 @@ android { ) } - if (androidBuild) { - sourceSets.getByName("androidTest") { - java.srcDirs( - "../okhttp-brotli/src/test/java", - "../okhttp-dnsoverhttps/src/test/java", - "../okhttp-logging-interceptor/src/test/java", - "../okhttp-sse/src/test/java", - ) - } - } - compileOptions { targetCompatibility(JavaVersion.VERSION_11) sourceCompatibility(JavaVersion.VERSION_11) @@ -58,6 +47,19 @@ android { ) } +if (androidBuild) { + androidComponents { + onVariants(selector().all()) { variant -> + variant.androidTest?.sources?.java?.apply { + addStaticSourceDirectory("../okhttp-brotli/src/test/java") + addStaticSourceDirectory("../okhttp-dnsoverhttps/src/test/java") + addStaticSourceDirectory("../okhttp-logging-interceptor/src/test/java") + addStaticSourceDirectory("../okhttp-sse/src/test/java") + } + } + } +} + dependencies { implementation(libs.kotlin.reflect) implementation(libs.playservices.safetynet) From 8702756b8584f1a0605095de86d47a7d72b12ace Mon Sep 17 00:00:00 2001 From: Yuri Schimke Date: Mon, 4 May 2026 10:42:28 +0100 Subject: [PATCH 18/31] Permit localhost cleartext in Android tests --- android-test/src/main/res/xml/network_security_config.xml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/android-test/src/main/res/xml/network_security_config.xml b/android-test/src/main/res/xml/network_security_config.xml index 628e3f7bc01f..601b3baffa21 100644 --- a/android-test/src/main/res/xml/network_security_config.xml +++ b/android-test/src/main/res/xml/network_security_config.xml @@ -2,10 +2,13 @@ + + localhost + cloudflare-ech.com crypto.cloudflare.com tls-ech.dev - + From ffe3d69f0f981e2803e1358130cdc5327e066779 Mon Sep 17 00:00:00 2001 From: Yuri Schimke Date: Mon, 4 May 2026 10:51:32 +0100 Subject: [PATCH 19/31] Document ECH public APIs --- okhttp/src/commonJvmAndroid/kotlin/okhttp3/Dns.kt | 10 ++++++++++ .../commonJvmAndroid/kotlin/okhttp3/Handshake.kt | 1 + .../commonJvmAndroid/kotlin/okhttp3/OkHttpClient.kt | 1 + .../kotlin/okhttp3/ech/EchConfig.kt | 1 + .../commonJvmAndroid/kotlin/okhttp3/ech/EchMode.kt | 4 ++++ .../kotlin/okhttp3/ech/EchModeConfiguration.kt | 6 +++++- .../okhttp3/internal/connection/ConnectPlan.kt | 4 ++-- .../internal/http/RetryAndFollowUpInterceptor.kt | 13 ++++++++----- 8 files changed, 32 insertions(+), 8 deletions(-) diff --git a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/Dns.kt b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/Dns.kt index 7cb39dc5d18b..f0f352c21363 100644 --- a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/Dns.kt +++ b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/Dns.kt @@ -58,6 +58,16 @@ fun interface Dns { } } +/** + * A [Dns] implementation that can also return HTTPS or SVCB host records for configuring + * Encrypted Client Hello (ECH). + */ interface EchAware { + /** + * Returns host records for [host], or null if no records are available. + * + * The returned type is platform-specific. On Android this is an `EchConfigList` suitable for + * configuring the TLS socket. + */ fun getHostRecords(host: String): Any? } diff --git a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/Handshake.kt b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/Handshake.kt index 8a0bc338814b..33e86528b782 100644 --- a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/Handshake.kt +++ b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/Handshake.kt @@ -41,6 +41,7 @@ class Handshake internal constructor( @get:JvmName("cipherSuite") val cipherSuite: CipherSuite, /** Returns a possibly-empty list of certificates that identify this peer. */ @get:JvmName("localCertificates") val localCertificates: List, + /** Returns the Encrypted Client Hello (ECH) configuration used for this handshake, if any. */ val echConfig: EchConfig? = null, // Delayed provider of peerCertificates, to allow lazy cleaning. peerCertificatesFn: () -> List, diff --git a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/OkHttpClient.kt b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/OkHttpClient.kt index 869edead073c..b5817cdec760 100644 --- a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/OkHttpClient.kt +++ b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/OkHttpClient.kt @@ -274,6 +274,7 @@ open class OkHttpClient internal constructor( builder.connectionPool = it } + /** Controls Encrypted Client Hello (ECH) behavior for new TLS connections. */ var echModeConfiguration: EchModeConfiguration = builder.echModeConfiguration constructor() : this(Builder()) diff --git a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/ech/EchConfig.kt b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/ech/EchConfig.kt index 24feb905f9cf..bbf8fb857b1a 100644 --- a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/ech/EchConfig.kt +++ b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/ech/EchConfig.kt @@ -25,5 +25,6 @@ import okio.ByteString * These parameters are typically retrieved from DNS via HTTPS or SVCB records. */ data class EchConfig( + /** The serialized ECH configuration list. */ val config: ByteString, ) diff --git a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/ech/EchMode.kt b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/ech/EchMode.kt index 12c623eb1bcc..9a0ad7748bde 100644 --- a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/ech/EchMode.kt +++ b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/ech/EchMode.kt @@ -19,8 +19,11 @@ package okhttp3.ech * Configures the behavior of Encrypted Client Hello (ECH) for TLS connections. */ enum class EchMode( + /** True if OkHttp should attempt to configure ECH for the TLS connection. */ val attempt: Boolean, + /** True if the connection must fail when ECH cannot be configured or negotiated. */ val require: Boolean, + /** True if OkHttp should retry without ECH when the server rejects the ECH configuration. */ val fallback: Boolean = false, ) { /** @@ -62,5 +65,6 @@ enum class EchMode( Fallback(attempt = false, require = false), ; + /** Companion for extension functions and Java interop. */ companion object } diff --git a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/ech/EchModeConfiguration.kt b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/ech/EchModeConfiguration.kt index 01ab731803a8..2cf099177234 100644 --- a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/ech/EchModeConfiguration.kt +++ b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/ech/EchModeConfiguration.kt @@ -35,7 +35,10 @@ interface EchModeConfiguration { fun echMode(host: String): EchMode /** - * Configures the [sslSocket] with Encrypted Client Hello (ECH) parameters + * Configures [sslSocket] with Encrypted Client Hello (ECH) parameters for [host]. + * + * Implementations may use [dns] to retrieve ECH configuration records. If [echMode] requires + * ECH and no configuration can be applied, this should throw an [java.io.IOException]. */ fun applyEch( sslSocket: SSLSocket, @@ -55,6 +58,7 @@ interface EchModeConfiguration { */ fun isEchConfigError(e: SSLException): Boolean = false + /** Built-in [EchModeConfiguration] instances. */ companion object { /** * A default implementation of [EchModeConfiguration] that performs no ECH-related actions diff --git a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/connection/ConnectPlan.kt b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/connection/ConnectPlan.kt index 3fa785fd8eb6..4532b9cffa44 100644 --- a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/connection/ConnectPlan.kt +++ b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/connection/ConnectPlan.kt @@ -412,9 +412,9 @@ class ConnectPlan internal constructor( socket = sslSocket.asBufferedSocket() protocol = if (maybeProtocol != null) Protocol.get(maybeProtocol) else Protocol.HTTP_1_1 success = true -// } catch (echre: EchRejectedException) { - // TODO signal for retry? } finally { + // ECH rejection is surfaced as an SSLException by the platform. Let it propagate so the + // retry interceptor can decide whether to retry with ECH disabled. Platform.get().afterHandshake(sslSocket) if (!success) { sslSocket.closeQuietly() diff --git a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/http/RetryAndFollowUpInterceptor.kt b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/http/RetryAndFollowUpInterceptor.kt index 40b5a0b03c2f..c1c4873b495a 100644 --- a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/http/RetryAndFollowUpInterceptor.kt +++ b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/http/RetryAndFollowUpInterceptor.kt @@ -143,7 +143,14 @@ class RetryAndFollowUpInterceptor : Interceptor { if (e is SSLException) { val echConfig = call.client.echModeConfiguration - if (echConfig.echMode(call.request().url.host).fallback && echConfig.isEchConfigError(e)) { + val echMode = echConfig.echMode(call.request().url.host) + if ( + call.tag(EchMode::class) != EchMode.Fallback && + echMode.fallback && + echConfig.isEchConfigError(e) + ) { + // Mark this call so the next connection attempt skips ECH. Without this guard a fallback + // connection that also fails with an ECH-classified SSLException could retry indefinitely. Platform.get().log("Should retry here with ECH disabled") call.tag(EchMode::class) { EchMode.Fallback } } @@ -219,10 +226,6 @@ class RetryAndFollowUpInterceptor : Interceptor { exchange: Exchange?, chain: Interceptor.Chain, ): Request? { - if (chain.call().tag(EchMode::class) == EchMode.Fallback) { - return chain.request() - } - val route = exchange?.connection?.route() val responseCode = userResponse.code From b2652bb3878bd69f127aaddd228875e1e8cff7fc Mon Sep 17 00:00:00 2001 From: Yuri Schimke Date: Mon, 4 May 2026 11:12:22 +0100 Subject: [PATCH 20/31] Harden Android ECH support --- .github/workflows/build.yml | 87 ++++++++- .../java/okhttp/android/test/EchTest.kt | 4 +- .../main/res/xml/network_security_config.xml | 2 +- .../okhttp.testing-conventions.gradle.kts | 6 +- gradle.properties | 2 - okhttp/api/android/okhttp.api | 52 ------ okhttp/api/jvm/okhttp.api | 52 ------ .../platform/AndroidDnsResolverDnsTest.kt | 90 +++++++++ .../AndroidEchModeConfigurationTest.kt | 38 ++++ .../internal/platform/Android17Platform.kt | 11 +- .../platform/AndroidDnsResolverDns.kt | 174 ++++++++++++------ .../android/Android17SocketAdapter.kt | 16 +- .../android/AndroidEchModeConfiguration.kt | 34 ++-- .../commonJvmAndroid/kotlin/okhttp3/Dns.kt | 14 +- .../kotlin/okhttp3/Handshake.kt | 3 +- .../kotlin/okhttp3/OkHttpClient.kt | 3 +- .../kotlin/okhttp3/ech/EchConfig.kt | 13 +- .../kotlin/okhttp3/ech/EchMode.kt | 2 +- .../okhttp3/ech/EchModeConfiguration.kt | 10 +- .../internal/connection/ConnectPlan.kt | 4 +- .../http/RetryAndFollowUpInterceptor.kt | 6 +- .../okhttp3/internal/platform/Platform.kt | 2 +- settings.gradle.kts | 2 - 23 files changed, 400 insertions(+), 227 deletions(-) create mode 100644 okhttp/src/androidHostTest/kotlin/okhttp3/internal/platform/AndroidDnsResolverDnsTest.kt create mode 100644 okhttp/src/androidHostTest/kotlin/okhttp3/internal/platform/android/AndroidEchModeConfigurationTest.kt diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 420d94959687..89a1b15d6bef 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -299,11 +299,22 @@ jobs: strategy: fail-fast: false matrix: - api-level: - - 21 - - 23 - - 29 - - 34 + include: + - api-level: 21 + arch: x86 + target: default + - api-level: 23 + arch: x86 + target: default + - api-level: 29 + arch: x86 + target: default + - api-level: 34 + arch: x86_64 + target: default + - api-level: '37.0' + arch: x86_64 + target: google_apis_ps16k steps: - name: Checkout @@ -338,7 +349,7 @@ jobs: uses: actions/cache@v5 id: avd-cache with: - key: avd-${{ runner.os }}-${{ matrix.api-level }}-${{ matrix.api-level >= 30 && 'x86_64' || 'x86' }} + key: avd-${{ runner.os }}-${{ matrix.api-level }}-${{ matrix.target }}-${{ matrix.arch }} path: | ~/.android/avd/* ~/.android/adb* @@ -346,12 +357,14 @@ jobs: ${{ env.ANDROID_HOME }}/system-images/android-${{ matrix.api-level }} - name: Create AVD and generate snapshot for caching - if: steps.avd-cache.outputs.cache-hit != 'true' + if: steps.avd-cache.outputs.cache-hit != 'true' && matrix.api-level != '37.0' uses: reactivecircus/android-emulator-runner@v2 with: api-level: ${{ matrix.api-level }} force-avd-creation: false - arch: ${{ matrix.api-level >= 30 && 'x86_64' || 'x86' }} + target: ${{ matrix.target }} + arch: ${{ matrix.arch }} + emulator-boot-timeout: 1200 # No window, no audio, and use swiftshader for headless environments emulator-options: > -no-window @@ -364,14 +377,69 @@ jobs: script: echo "Generated AVD snapshot for caching." - name: Run Tests + if: matrix.api-level != '37.0' uses: reactivecircus/android-emulator-runner@v2 with: api-level: ${{ matrix.api-level }} - arch: ${{ matrix.api-level == '34' && 'x86_64' || 'x86' }} + target: ${{ matrix.target }} + arch: ${{ matrix.arch }} + emulator-boot-timeout: 1200 + # Match the snapshot creation options. The action default includes -no-snapshot, + # which forces a slow cold boot. + emulator-options: > + -no-window + -gpu swiftshader_indirect + -noaudio + -no-boot-anim + -camera-back none + -memory 2048 script: ./gradlew -PandroidBuild=true connectedCheck env: API_LEVEL: ${{ matrix.api-level }} + - name: Run Android 37 Tests + if: matrix.api-level == '37.0' + run: | + SDKMANAGER="$(find "$ANDROID_HOME/cmdline-tools" -path '*/bin/sdkmanager' | sort | tail -n 1)" + AVDMANAGER="$(find "$ANDROID_HOME/cmdline-tools" -path '*/bin/avdmanager' | sort | tail -n 1)" + + yes | "$SDKMANAGER" --licenses > /dev/null + "$SDKMANAGER" --install \ + 'build-tools;36.0.0' \ + platform-tools \ + 'platforms;android-37.0' \ + emulator \ + 'system-images;android-37.0;google_apis_ps16k;x86_64' \ + --channel=0 > /dev/null + + echo no | "$AVDMANAGER" create avd \ + --force \ + --name test \ + --package 'system-images;android-37.0;google_apis_ps16k;x86_64' + + "$ANDROID_HOME/emulator/emulator" \ + -port 5554 \ + -avd test \ + -no-window \ + -gpu swiftshader_indirect \ + -no-snapshot \ + -noaudio \ + -no-boot-anim \ + -camera-back none \ + -memory 2048 & + + adb -s emulator-5554 wait-for-device + timeout 1200 bash -c 'until [[ "$(adb -s emulator-5554 shell getprop sys.boot_completed | tr -d "\r")" == "1" ]]; do sleep 2; done' + timeout 300 bash -c 'until adb -s emulator-5554 shell service check input | grep -q "found"; do sleep 2; done' + + ./gradlew -PandroidBuild=true connectedCheck + env: + API_LEVEL: ${{ matrix.api-level }} + + - name: Stop Android 37 Emulator + if: always() && matrix.api-level == '37.0' + run: adb -s emulator-5554 emu kill || true + - name: Build Release App run: ./gradlew android-test-app:lint android-test-app:assembleRelease @@ -440,4 +508,3 @@ jobs: - name: Run with Jlink run: ./gradlew module-tests:imageRun -PokhttpModuleTests=true - diff --git a/android-test/src/androidTest/java/okhttp/android/test/EchTest.kt b/android-test/src/androidTest/java/okhttp/android/test/EchTest.kt index b9ce7cdba332..5b914a277664 100644 --- a/android-test/src/androidTest/java/okhttp/android/test/EchTest.kt +++ b/android-test/src/androidTest/java/okhttp/android/test/EchTest.kt @@ -21,8 +21,10 @@ import assertk.assertions.matchesPredicate import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.Response +import org.junit.jupiter.api.Tag import org.junit.jupiter.api.Test +@Tag("Remote") class EchTest { @Test @@ -54,8 +56,6 @@ class EchTest { private fun OkHttpClient.sendRequest(request: Request, fn: (Response) -> T): T { val response = newCall(request).execute() - assertThat(response.handshake?.echConfig).isNotNull() - return response.use { fn(it) } diff --git a/android-test/src/main/res/xml/network_security_config.xml b/android-test/src/main/res/xml/network_security_config.xml index 601b3baffa21..700ee2437021 100644 --- a/android-test/src/main/res/xml/network_security_config.xml +++ b/android-test/src/main/res/xml/network_security_config.xml @@ -3,7 +3,7 @@ - localhost + localhost cloudflare-ech.com diff --git a/build-logic/src/main/kotlin/okhttp.testing-conventions.gradle.kts b/build-logic/src/main/kotlin/okhttp.testing-conventions.gradle.kts index 3216f51435d4..562894854cb6 100644 --- a/build-logic/src/main/kotlin/okhttp.testing-conventions.gradle.kts +++ b/build-logic/src/main/kotlin/okhttp.testing-conventions.gradle.kts @@ -24,8 +24,10 @@ dependencies { tasks.withType { useJUnitPlatform() jvmArgs("-Dokhttp.platform=$platform") - // Fix for robolectric https://github.com/robolectric/robolectric/pull/10996 - jvmArgs("--add-opens", "java.base/jdk.internal.access=ALL-UNNAMED") + if (testJavaVersion >= 9) { + // Fix for robolectric https://github.com/robolectric/robolectric/pull/10996 + jvmArgs("--add-opens", "java.base/jdk.internal.access=ALL-UNNAMED") + } if (platform == "loom") { jvmArgs("-Djdk.tracePinnedThreads=short") diff --git a/gradle.properties b/gradle.properties index 8486fda9e464..09306a9200fb 100644 --- a/gradle.properties +++ b/gradle.properties @@ -25,5 +25,3 @@ org.gradle.jvmargs=-Dfile.encoding=UTF-8 # AGP 9.0 Settings android.builtInKotlin=true android.newDsl=true - -android.suppressUnsupportedCompileSdk=CANARY diff --git a/okhttp/api/android/okhttp.api b/okhttp/api/android/okhttp.api index da0fef9f33d0..35024db87b61 100644 --- a/okhttp/api/android/okhttp.api +++ b/okhttp/api/android/okhttp.api @@ -485,10 +485,6 @@ public abstract interface class okhttp3/Dns { public final class okhttp3/Dns$Companion { } -public abstract interface class okhttp3/EchAware { - public abstract fun getHostRecords (Ljava/lang/String;)Ljava/lang/Object; -} - public abstract class okhttp3/EventListener { public static final field Companion Lokhttp3/EventListener$Companion; public static final field NONE Lokhttp3/EventListener; @@ -579,7 +575,6 @@ public final class okhttp3/Handshake { public fun equals (Ljava/lang/Object;)Z public static final fun get (Ljavax/net/ssl/SSLSession;)Lokhttp3/Handshake; public static final fun get (Lokhttp3/TlsVersion;Lokhttp3/CipherSuite;Ljava/util/List;Ljava/util/List;)Lokhttp3/Handshake; - public final fun getEchConfig ()Lokhttp3/ech/EchConfig; public fun hashCode ()I public final fun localCertificates ()Ljava/util/List; public final fun localPrincipal ()Ljava/security/Principal; @@ -945,7 +940,6 @@ public class okhttp3/OkHttpClient : okhttp3/Call$Factory, okhttp3/WebSocket$Fact public final fun fastFallback ()Z public final fun followRedirects ()Z public final fun followSslRedirects ()Z - public final fun getEchModeConfiguration ()Lokhttp3/ech/EchModeConfiguration; public final fun hostnameVerifier ()Ljavax/net/ssl/HostnameVerifier; public final fun interceptors ()Ljava/util/List; public final fun minWebSocketMessageToCompress ()J @@ -960,7 +954,6 @@ public class okhttp3/OkHttpClient : okhttp3/Call$Factory, okhttp3/WebSocket$Fact public final fun proxySelector ()Ljava/net/ProxySelector; public final fun readTimeoutMillis ()I public final fun retryOnConnectionFailure ()Z - public final fun setEchModeConfiguration (Lokhttp3/ech/EchModeConfiguration;)V public final fun socketFactory ()Ljavax/net/SocketFactory; public final fun sslSocketFactory ()Ljavax/net/ssl/SSLSocketFactory; public final fun webSocketCloseTimeout ()I @@ -1323,48 +1316,3 @@ public abstract class okhttp3/WebSocketListener { public fun onOpen (Lokhttp3/WebSocket;Lokhttp3/Response;)V } -public final class okhttp3/ech/EchConfig { - public fun (Lokio/ByteString;)V - public final fun component1 ()Lokio/ByteString; - public final fun copy (Lokio/ByteString;)Lokhttp3/ech/EchConfig; - public static synthetic fun copy$default (Lokhttp3/ech/EchConfig;Lokio/ByteString;ILjava/lang/Object;)Lokhttp3/ech/EchConfig; - public fun equals (Ljava/lang/Object;)Z - public final fun getConfig ()Lokio/ByteString; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public final class okhttp3/ech/EchMode : java/lang/Enum { - public static final field Companion Lokhttp3/ech/EchMode$Companion; - public static final field Disabled Lokhttp3/ech/EchMode; - public static final field FailClosed Lokhttp3/ech/EchMode; - public static final field Fallback Lokhttp3/ech/EchMode; - public static final field Opportunistic Lokhttp3/ech/EchMode; - public static final field Strict Lokhttp3/ech/EchMode; - public static final field Unspecified Lokhttp3/ech/EchMode; - public final fun getAttempt ()Z - public static fun getEntries ()Lkotlin/enums/EnumEntries; - public final fun getFallback ()Z - public final fun getRequire ()Z - public static fun valueOf (Ljava/lang/String;)Lokhttp3/ech/EchMode; - public static fun values ()[Lokhttp3/ech/EchMode; -} - -public final class okhttp3/ech/EchMode$Companion { -} - -public abstract interface class okhttp3/ech/EchModeConfiguration { - public static final field Companion Lokhttp3/ech/EchModeConfiguration$Companion; - public abstract fun applyEch (Ljavax/net/ssl/SSLSocket;Lokhttp3/ech/EchMode;Ljava/lang/String;Lokhttp3/Dns;)V - public abstract fun echMode (Ljava/lang/String;)Lokhttp3/ech/EchMode; - public abstract fun isEchConfigError (Ljavax/net/ssl/SSLException;)Z -} - -public final class okhttp3/ech/EchModeConfiguration$Companion { - public final fun getUnspecified ()Lokhttp3/ech/EchModeConfiguration; -} - -public final class okhttp3/ech/EchModeConfiguration$DefaultImpls { - public static fun isEchConfigError (Lokhttp3/ech/EchModeConfiguration;Ljavax/net/ssl/SSLException;)Z -} - diff --git a/okhttp/api/jvm/okhttp.api b/okhttp/api/jvm/okhttp.api index a9a3dbec03a6..a37c2deac8ab 100644 --- a/okhttp/api/jvm/okhttp.api +++ b/okhttp/api/jvm/okhttp.api @@ -485,10 +485,6 @@ public abstract interface class okhttp3/Dns { public final class okhttp3/Dns$Companion { } -public abstract interface class okhttp3/EchAware { - public abstract fun getHostRecords (Ljava/lang/String;)Ljava/lang/Object; -} - public abstract class okhttp3/EventListener { public static final field Companion Lokhttp3/EventListener$Companion; public static final field NONE Lokhttp3/EventListener; @@ -579,7 +575,6 @@ public final class okhttp3/Handshake { public fun equals (Ljava/lang/Object;)Z public static final fun get (Ljavax/net/ssl/SSLSession;)Lokhttp3/Handshake; public static final fun get (Lokhttp3/TlsVersion;Lokhttp3/CipherSuite;Ljava/util/List;Ljava/util/List;)Lokhttp3/Handshake; - public final fun getEchConfig ()Lokhttp3/ech/EchConfig; public fun hashCode ()I public final fun localCertificates ()Ljava/util/List; public final fun localPrincipal ()Ljava/security/Principal; @@ -944,7 +939,6 @@ public class okhttp3/OkHttpClient : okhttp3/Call$Factory, okhttp3/WebSocket$Fact public final fun fastFallback ()Z public final fun followRedirects ()Z public final fun followSslRedirects ()Z - public final fun getEchModeConfiguration ()Lokhttp3/ech/EchModeConfiguration; public final fun hostnameVerifier ()Ljavax/net/ssl/HostnameVerifier; public final fun interceptors ()Ljava/util/List; public final fun minWebSocketMessageToCompress ()J @@ -959,7 +953,6 @@ public class okhttp3/OkHttpClient : okhttp3/Call$Factory, okhttp3/WebSocket$Fact public final fun proxySelector ()Ljava/net/ProxySelector; public final fun readTimeoutMillis ()I public final fun retryOnConnectionFailure ()Z - public final fun setEchModeConfiguration (Lokhttp3/ech/EchModeConfiguration;)V public final fun socketFactory ()Ljavax/net/SocketFactory; public final fun sslSocketFactory ()Ljavax/net/ssl/SSLSocketFactory; public final fun webSocketCloseTimeout ()I @@ -1322,48 +1315,3 @@ public abstract class okhttp3/WebSocketListener { public fun onOpen (Lokhttp3/WebSocket;Lokhttp3/Response;)V } -public final class okhttp3/ech/EchConfig { - public fun (Lokio/ByteString;)V - public final fun component1 ()Lokio/ByteString; - public final fun copy (Lokio/ByteString;)Lokhttp3/ech/EchConfig; - public static synthetic fun copy$default (Lokhttp3/ech/EchConfig;Lokio/ByteString;ILjava/lang/Object;)Lokhttp3/ech/EchConfig; - public fun equals (Ljava/lang/Object;)Z - public final fun getConfig ()Lokio/ByteString; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public final class okhttp3/ech/EchMode : java/lang/Enum { - public static final field Companion Lokhttp3/ech/EchMode$Companion; - public static final field Disabled Lokhttp3/ech/EchMode; - public static final field FailClosed Lokhttp3/ech/EchMode; - public static final field Fallback Lokhttp3/ech/EchMode; - public static final field Opportunistic Lokhttp3/ech/EchMode; - public static final field Strict Lokhttp3/ech/EchMode; - public static final field Unspecified Lokhttp3/ech/EchMode; - public final fun getAttempt ()Z - public static fun getEntries ()Lkotlin/enums/EnumEntries; - public final fun getFallback ()Z - public final fun getRequire ()Z - public static fun valueOf (Ljava/lang/String;)Lokhttp3/ech/EchMode; - public static fun values ()[Lokhttp3/ech/EchMode; -} - -public final class okhttp3/ech/EchMode$Companion { -} - -public abstract interface class okhttp3/ech/EchModeConfiguration { - public static final field Companion Lokhttp3/ech/EchModeConfiguration$Companion; - public abstract fun applyEch (Ljavax/net/ssl/SSLSocket;Lokhttp3/ech/EchMode;Ljava/lang/String;Lokhttp3/Dns;)V - public abstract fun echMode (Ljava/lang/String;)Lokhttp3/ech/EchMode; - public abstract fun isEchConfigError (Ljavax/net/ssl/SSLException;)Z -} - -public final class okhttp3/ech/EchModeConfiguration$Companion { - public final fun getUnspecified ()Lokhttp3/ech/EchModeConfiguration; -} - -public final class okhttp3/ech/EchModeConfiguration$DefaultImpls { - public static fun isEchConfigError (Lokhttp3/ech/EchModeConfiguration;Ljavax/net/ssl/SSLException;)Z -} - diff --git a/okhttp/src/androidHostTest/kotlin/okhttp3/internal/platform/AndroidDnsResolverDnsTest.kt b/okhttp/src/androidHostTest/kotlin/okhttp3/internal/platform/AndroidDnsResolverDnsTest.kt new file mode 100644 index 000000000000..5de1047a8726 --- /dev/null +++ b/okhttp/src/androidHostTest/kotlin/okhttp3/internal/platform/AndroidDnsResolverDnsTest.kt @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2026 OkHttp Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package okhttp3.internal.platform + +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isNull +import java.net.InetAddress +import java.net.UnknownHostException +import kotlin.test.assertFailsWith +import okhttp3.ech.EchConfig +import okio.ByteString +import org.junit.Test + +class AndroidDnsResolverDnsTest { + private val address = InetAddress.getByName("192.0.2.1") + + @Test + fun lookupReturnsAddressesAndCachesEchConfig() { + val echConfig = FakeEchConfig + val dns = + AndroidDnsResolverDns( + FakeDnsLookup( + "example.com" to AndroidDnsResult(listOf(address), echConfig), + ), + ) + + assertThat(dns.lookup("example.com")).isEqualTo(listOf(address)) + assertThat(dns.getEchConfig("example.com")).isEqualTo(echConfig) + assertThat(dns.getEchConfig("other.example")).isNull() + } + + @Test + fun lookupWithoutEchConfigClearsStaleEchConfig() { + val echConfig = FakeEchConfig + val lookup = + FakeDnsLookup( + "example.com" to AndroidDnsResult(listOf(address), echConfig), + ) + val dns = AndroidDnsResolverDns(lookup) + + dns.lookup("example.com") + assertThat(dns.getEchConfig("example.com")).isEqualTo(echConfig) + + lookup["example.com"] = AndroidDnsResult(listOf(address), null) + dns.lookup("example.com") + assertThat(dns.getEchConfig("example.com")).isNull() + } + + @Test + fun lookupPropagatesUnknownHostException() { + val dns = AndroidDnsResolverDns(FakeDnsLookup()) + + assertFailsWith { + dns.lookup("missing.example") + } + } + + private class FakeDnsLookup( + vararg responses: Pair, + ) : AndroidDnsLookup { + private val responses = responses.toMap().toMutableMap() + + operator fun set( + hostname: String, + result: AndroidDnsResult, + ) { + responses[hostname] = result + } + + override fun lookup(hostname: String): AndroidDnsResult = responses[hostname] ?: throw UnknownHostException(hostname) + } + + private object FakeEchConfig : EchConfig { + override val config: ByteString = ByteString.EMPTY + } +} diff --git a/okhttp/src/androidHostTest/kotlin/okhttp3/internal/platform/android/AndroidEchModeConfigurationTest.kt b/okhttp/src/androidHostTest/kotlin/okhttp3/internal/platform/android/AndroidEchModeConfigurationTest.kt new file mode 100644 index 000000000000..47fdfbb1a7f3 --- /dev/null +++ b/okhttp/src/androidHostTest/kotlin/okhttp3/internal/platform/android/AndroidEchModeConfigurationTest.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2026 OkHttp Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package okhttp3.internal.platform.android + +import android.security.NetworkSecurityPolicy +import assertk.assertThat +import assertk.assertions.isEqualTo +import okhttp3.ech.EchMode +import org.junit.Test + +class AndroidEchModeConfigurationTest { + @Test + fun mapsNetworkSecurityPolicyModes() { + assertThat( + EchMode.fromNetworkSecurityPolicy(NetworkSecurityPolicy.DOMAIN_ENCRYPTION_MODE_OPPORTUNISTIC), + ).isEqualTo(EchMode.Opportunistic) + assertThat( + EchMode.fromNetworkSecurityPolicy(NetworkSecurityPolicy.DOMAIN_ENCRYPTION_MODE_ENABLED), + ).isEqualTo(EchMode.Strict) + assertThat( + EchMode.fromNetworkSecurityPolicy(NetworkSecurityPolicy.DOMAIN_ENCRYPTION_MODE_DISABLED), + ).isEqualTo(EchMode.Disabled) + assertThat(EchMode.fromNetworkSecurityPolicy(-1)).isEqualTo(EchMode.Unspecified) + } +} diff --git a/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/Android17Platform.kt b/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/Android17Platform.kt index 3745d6fa2f14..b17a7e83e33f 100644 --- a/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/Android17Platform.kt +++ b/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/Android17Platform.kt @@ -17,6 +17,7 @@ package okhttp3.internal.platform import android.annotation.SuppressLint import android.content.Context +import android.net.DnsResolver import android.os.Build import android.os.StrictMode import android.security.NetworkSecurityPolicy @@ -40,7 +41,13 @@ import okhttp3.internal.platform.android.AndroidEchModeConfiguration import okhttp3.internal.tls.CertificateChainCleaner import okhttp3.internal.tls.TrustRootIndex -/** Android 17+ (API 37+). */ +/** + * Android 17+ (API 37+). + * + * This platform uses the post-API 36 Android TLS and DNS APIs directly, including domain + * encryption policy, HTTPS/SVCB DNS records from [DnsResolver], and Encrypted Client Hello (ECH) + * configuration on TLS sockets. + */ @SuppressSignatureCheck class Android17Platform @RequiresApi(37) @@ -99,7 +106,7 @@ class Android17Platform NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted(hostname) @SuppressLint("NewApi") - override val echModeConfiguration: EchModeConfiguration = AndroidEchModeConfiguration() + internal override val echModeConfiguration: EchModeConfiguration = AndroidEchModeConfiguration() override fun buildCertificateChainCleaner(trustManager: X509TrustManager): CertificateChainCleaner = AndroidCertificateChainCleaner.buildIfSupported(trustManager)!! diff --git a/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/AndroidDnsResolverDns.kt b/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/AndroidDnsResolverDns.kt index 182ef55d1474..bc526159204c 100644 --- a/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/AndroidDnsResolverDns.kt +++ b/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/AndroidDnsResolverDns.kt @@ -15,92 +15,152 @@ */ package okhttp3.internal.platform +import android.annotation.SuppressLint import android.net.DnsResolver import android.net.DnsResolver.Callback import android.net.dns.HttpsEndpoint -import android.net.dns.HttpsRecord +import android.net.ssl.EchConfigList +import android.os.HandlerThread import androidx.annotation.RequiresApi import java.net.InetAddress +import java.net.UnknownHostException import java.util.concurrent.CompletableFuture +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.ExecutionException import java.util.concurrent.Executor -import java.util.concurrent.Future -import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeUnit.SECONDS +import java.util.concurrent.TimeoutException import okhttp3.Dns import okhttp3.EchAware +import okhttp3.ech.EchConfig import okio.ByteString import okio.ByteString.Companion.toByteString @Suppress("NewApi") @RequiresApi(36) -class AndroidDnsResolverDns : - Dns, +internal class AndroidDnsResolverDns internal constructor( + private val dnsResolver: AndroidDnsLookup = AndroidDnsResolver(), +) : Dns, EchAware { - val dnsResolver: DnsResolver by lazy { - val handlerThread = android.os.HandlerThread("DnsLooper").apply { start() } + private val echConfigs = ConcurrentHashMap() - DnsResolver(PlatformRegistry.applicationContext!!, handlerThread.looper) + override fun lookup(hostname: String): List { + val result = dnsResolver.lookup(hostname) + result.echConfig?.let { + echConfigs[hostname] = it + } ?: echConfigs.remove(hostname) + return result.addresses } - val httpsRecords: MutableMap> = HashMap() + override fun getEchConfig(host: String): EchConfig? = echConfigs[host] +} - override fun lookup(hostname: String): List { - val httpsFuture = CompletableFuture() - val dnsFuture = CompletableFuture>() +internal data class AndroidDnsResult( + val addresses: List, + val echConfig: EchConfig?, +) - val callback: Callback = - object : Callback { +internal data class AndroidEchConfig( + val echConfigList: EchConfigList, +) : EchConfig { + @get:SuppressLint("NewApi") + override val config: ByteString + get() = echConfigList.toBytes().toByteString() +} + +internal fun interface AndroidDnsLookup { + @Throws(UnknownHostException::class) + fun lookup(hostname: String): AndroidDnsResult +} + +@Suppress("NewApi") +@RequiresApi(36) +internal class AndroidDnsResolver( + private val dnsResolver: DnsResolver = + HandlerThread("OkHttp DnsResolver").let { handlerThread -> + handlerThread.start() + DnsResolver(PlatformRegistry.applicationContext!!, handlerThread.looper) + }, + private val executor: Executor = Executor { it.run() }, + private val timeoutSeconds: Long = 5L, +) : AndroidDnsLookup { + override fun lookup(hostname: String): AndroidDnsResult { + val endpoint = queryHttps(hostname) + return AndroidDnsResult( + addresses = endpoint.ipAddresses, + echConfig = endpoint.echConfigOrNull(), + ) + } + + private fun queryHttps(hostname: String): HttpsEndpoint = + execute(hostname) { callback -> + @Suppress("WrongConstant") + dnsResolver.query( + null, + hostname, + DnsResolver.FLAG_EMPTY, + executor, + SECONDS.toMillis(1L).toInt(), + null, + callback, + ) + } + + private fun execute( + hostname: String, + query: (Callback) -> Unit, + ): T { + val result = CompletableFuture() + + query( + object : Callback { override fun onAnswer( - answer: HttpsEndpoint, + answer: T, rcode: Int, ) { - if (answer.httpsRecords.isNotEmpty()) { - if (answer.httpsRecords.size > 1) { - answer.httpsRecords.forEach { - println("${it.priority} ${it.targetName} ${it.port} ${it.alpnIds} ${it.ipAddressHints}") - } - } - httpsFuture.complete(answer.httpsRecords.first()) - } - if (answer.ipAddresses.isNotEmpty()) { - dnsFuture.complete(answer.ipAddresses) - } + result.complete(answer) } - override fun onError(p0: DnsResolver.DnsException) { - if (!dnsFuture.isDone) { - dnsFuture.completeExceptionally(p0) - } - if (!httpsFuture.isDone) { - httpsFuture.completeExceptionally(p0) - } + override fun onError(e: DnsResolver.DnsException) { + result.completeExceptionally(e) } - } - @Suppress("WrongConstant") - dnsResolver.query( - // network = - null, - // domain = - hostname, - // flags = - DnsResolver.FLAG_EMPTY, - // executor = - { it.run() }, - // httpsTimeoutMillis = - 1_000, - // cancellationSignal = - null, - // callback = - callback, + }, ) - httpsRecords[hostname] = httpsFuture - // TODO replace with real timeout - return dnsFuture.get(5, TimeUnit.SECONDS) + return try { + result.get(timeoutSeconds, SECONDS) + } catch (e: ExecutionException) { + throw (e.cause as? DnsResolver.DnsException)?.toUnknownHostException(hostname) + ?: UnknownHostException("Broken system behaviour for dns lookup of $hostname").apply { + initCause(e.cause) + } + } catch (e: TimeoutException) { + throw UnknownHostException("DNS lookup timed out for $hostname").apply { + initCause(e) + } + } catch (e: InterruptedException) { + Thread.currentThread().interrupt() + throw UnknownHostException("Interrupted DNS lookup for $hostname").apply { + initCause(e) + } + } } +} - override fun getHostRecords(host: String): Any? { - val record = httpsRecords[host]?.get() - val echConfig = record?.echConfigList - return echConfig +@SuppressLint("NewApi") +private fun HttpsEndpoint.echConfigOrNull(): AndroidEchConfig? { + val httpsRecord = httpsRecords.firstOrNull() ?: return null + return try { + httpsRecord.echConfigList?.let(::AndroidEchConfig) + } catch (e: IllegalArgumentException) { + // TODO: remove this guard when Android handles malformed or absent ECH parameters. + // https://issuetracker.google.com/issues/319957694 + null } } + +@SuppressLint("NewApi") +private fun DnsResolver.DnsException.toUnknownHostException(hostname: String): UnknownHostException = + UnknownHostException("DNS lookup failed for $hostname").apply { + initCause(this@toUnknownHostException) + } diff --git a/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/android/Android17SocketAdapter.kt b/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/android/Android17SocketAdapter.kt index 79ab04baaa56..093fa8c0de7a 100644 --- a/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/android/Android17SocketAdapter.kt +++ b/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/android/Android17SocketAdapter.kt @@ -23,6 +23,7 @@ import androidx.annotation.RequiresApi import javax.net.ssl.SSLSocket import okhttp3.Call import okhttp3.Protocol +import okhttp3.ech.EchConfig import okhttp3.ech.EchMode import okhttp3.internal.SuppressSignatureCheck import okhttp3.internal.connection.RealCall @@ -31,9 +32,14 @@ import okhttp3.internal.platform.Platform import okhttp3.internal.platform.Platform.Companion.isAndroid /** - * Simple non-reflection SocketAdapter for Android Q+. + * Socket adapter for Android 17+ platform TLS APIs. * - * These API assumptions make it unsuitable for use on earlier Android versions. + * Unlike the older Android socket adapters, this calls public platform APIs directly instead of + * using reflection or Conscrypt-specific hooks. It configures session tickets, ALPN, and ECH on + * Android's `SSLSocket` implementation. + * + * These API assumptions make it unsuitable for earlier Android versions; use + * [Android17Platform] to select this adapter only when the runtime SDK supports it. */ @SuppressLint("NewApi") @SuppressSignatureCheck @@ -82,7 +88,11 @@ class Android17SocketAdapter } if (echMode.attempt) { - echModeConfiguration.applyEch(sslSocket, echMode, hostname, client.dns) + echModeConfiguration + .applyEch(sslSocket, echMode, hostname, client.dns) + ?.let { echConfig -> + call.tag(EchConfig::class) { echConfig } + } } } } diff --git a/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/android/AndroidEchModeConfiguration.kt b/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/android/AndroidEchModeConfiguration.kt index b90a88130acf..3c7bbdd97992 100644 --- a/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/android/AndroidEchModeConfiguration.kt +++ b/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/android/AndroidEchModeConfiguration.kt @@ -16,7 +16,6 @@ package okhttp3.internal.platform.android import android.annotation.SuppressLint -import android.net.ssl.EchConfigList import android.net.ssl.EchConfigMismatchException import android.net.ssl.SSLSockets import android.security.NetworkSecurityPolicy @@ -25,22 +24,25 @@ import javax.net.ssl.SSLException import javax.net.ssl.SSLSocket import okhttp3.Dns import okhttp3.EchAware +import okhttp3.ech.EchConfig import okhttp3.ech.EchMode import okhttp3.ech.EchModeConfiguration +import okhttp3.internal.platform.AndroidEchConfig import okio.IOException +/** + * Android implementation of [EchModeConfiguration] for API 37+. + * + * This bridges OkHttp's platform-neutral ECH policy to Android's native ECH APIs: + * [NetworkSecurityPolicy] supplies the per-host domain encryption policy, [Dns] may provide an + * HTTPS/SVCB ECH configuration list, and [SSLSockets] applies that configuration to the TLS socket. + */ @RequiresApi(37) -class AndroidEchModeConfiguration : EchModeConfiguration { +internal class AndroidEchModeConfiguration : EchModeConfiguration { @Suppress("NewApi") override fun echMode(host: String): EchMode { - EchMode.fromNetworkSecurityPolicy( - NetworkSecurityPolicy.getInstance().getDomainEncryptionMode(host).also { - println("$host = $it") - }, - ) - - // for now return enabled for testing - return EchMode.Opportunistic + val domainEncryptionMode = NetworkSecurityPolicy.getInstance().getDomainEncryptionMode(host) + return EchMode.fromNetworkSecurityPolicy(domainEncryptionMode) } @SuppressLint("NewApi") @@ -52,21 +54,25 @@ class AndroidEchModeConfiguration : EchModeConfiguration { echMode: EchMode, host: String, dns: Dns, - ) { - val echConfig = (dns as? EchAware)?.getHostRecords(host) + ): EchConfig? { + // The Android DNS implementation returns AndroidEchConfig instances. Other Dns + // implementations are valid; they simply won't be able to configure Android ECH sockets. + val echConfig = (dns as? EchAware)?.getEchConfig(host) as? AndroidEchConfig if (echConfig != null) { SSLSockets.setEchConfigList( sslSocket, - echConfig as EchConfigList, + echConfig.echConfigList, ) + return echConfig } else if (echMode.require) { throw IOException("Unable to apply required ECH config for $host") } + return null } } -private fun EchMode.Companion.fromNetworkSecurityPolicy(domainEncryptionMode: Int): EchMode = +internal fun EchMode.Companion.fromNetworkSecurityPolicy(domainEncryptionMode: Int): EchMode = when (domainEncryptionMode) { NetworkSecurityPolicy.DOMAIN_ENCRYPTION_MODE_OPPORTUNISTIC -> EchMode.Opportunistic NetworkSecurityPolicy.DOMAIN_ENCRYPTION_MODE_ENABLED -> EchMode.Strict diff --git a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/Dns.kt b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/Dns.kt index f0f352c21363..cad80e246d17 100644 --- a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/Dns.kt +++ b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/Dns.kt @@ -18,6 +18,7 @@ package okhttp3 import java.net.InetAddress import java.net.UnknownHostException import okhttp3.Dns.Companion.SYSTEM +import okhttp3.ech.EchConfig /** * A domain name service that resolves IP addresses for host names. Most applications will use the @@ -59,15 +60,14 @@ fun interface Dns { } /** - * A [Dns] implementation that can also return HTTPS or SVCB host records for configuring - * Encrypted Client Hello (ECH). + * A [Dns] implementation that can also return HTTPS or SVCB ECH configuration for a host. */ -interface EchAware { +internal interface EchAware { /** - * Returns host records for [host], or null if no records are available. + * Returns ECH configuration for [host], or null if no configuration is available. * - * The returned type is platform-specific. On Android this is an `EchConfigList` suitable for - * configuring the TLS socket. + * The returned [EchConfig] type is platform-specific. On Android this wraps an `EchConfigList` + * suitable for configuring the TLS socket. */ - fun getHostRecords(host: String): Any? + fun getEchConfig(host: String): EchConfig? } diff --git a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/Handshake.kt b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/Handshake.kt index 33e86528b782..1d1e13ff9dae 100644 --- a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/Handshake.kt +++ b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/Handshake.kt @@ -41,8 +41,7 @@ class Handshake internal constructor( @get:JvmName("cipherSuite") val cipherSuite: CipherSuite, /** Returns a possibly-empty list of certificates that identify this peer. */ @get:JvmName("localCertificates") val localCertificates: List, - /** Returns the Encrypted Client Hello (ECH) configuration used for this handshake, if any. */ - val echConfig: EchConfig? = null, + internal val echConfig: EchConfig? = null, // Delayed provider of peerCertificates, to allow lazy cleaning. peerCertificatesFn: () -> List, ) { diff --git a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/OkHttpClient.kt b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/OkHttpClient.kt index b5817cdec760..3ad2c93d8766 100644 --- a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/OkHttpClient.kt +++ b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/OkHttpClient.kt @@ -274,8 +274,7 @@ open class OkHttpClient internal constructor( builder.connectionPool = it } - /** Controls Encrypted Client Hello (ECH) behavior for new TLS connections. */ - var echModeConfiguration: EchModeConfiguration = builder.echModeConfiguration + internal val echModeConfiguration: EchModeConfiguration = builder.echModeConfiguration constructor() : this(Builder()) diff --git a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/ech/EchConfig.kt b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/ech/EchConfig.kt index bbf8fb857b1a..2186eaeaa993 100644 --- a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/ech/EchConfig.kt +++ b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/ech/EchConfig.kt @@ -20,11 +20,12 @@ import okio.ByteString /** * Configuration for Encrypted Client Hello (ECH). * - * This class contains the parameters required for a client to encrypt its ClientHello message, + * This contains the parameters required for a client to encrypt its ClientHello message, * protecting sensitive fields such as the Server Name Indication (SNI) from passive observers. - * These parameters are typically retrieved from DNS via HTTPS or SVCB records. + * These parameters are typically retrieved from DNS via HTTPS or SVCB records, and platform + * implementations may carry additional native objects needed to configure TLS sockets. */ -data class EchConfig( - /** The serialized ECH configuration list. */ - val config: ByteString, -) +internal interface EchConfig { + /** The serialized ECH configuration list from DNS. */ + val config: ByteString +} diff --git a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/ech/EchMode.kt b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/ech/EchMode.kt index 9a0ad7748bde..f1de08904ea5 100644 --- a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/ech/EchMode.kt +++ b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/ech/EchMode.kt @@ -18,7 +18,7 @@ package okhttp3.ech /** * Configures the behavior of Encrypted Client Hello (ECH) for TLS connections. */ -enum class EchMode( +internal enum class EchMode( /** True if OkHttp should attempt to configure ECH for the TLS connection. */ val attempt: Boolean, /** True if the connection must fail when ECH cannot be configured or negotiated. */ diff --git a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/ech/EchModeConfiguration.kt b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/ech/EchModeConfiguration.kt index 2cf099177234..66a88dab7f4a 100644 --- a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/ech/EchModeConfiguration.kt +++ b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/ech/EchModeConfiguration.kt @@ -25,7 +25,7 @@ import okhttp3.Dns * This interface provides the mechanism to determine the ECH strategy for a given host, * apply ECH parameters to an [SSLSocket], and identify ECH-specific connection failures. */ -interface EchModeConfiguration { +internal interface EchModeConfiguration { /** * Determines the [EchMode] strategy to be used for the specified [host]. * @@ -38,14 +38,15 @@ interface EchModeConfiguration { * Configures [sslSocket] with Encrypted Client Hello (ECH) parameters for [host]. * * Implementations may use [dns] to retrieve ECH configuration records. If [echMode] requires - * ECH and no configuration can be applied, this should throw an [java.io.IOException]. + * ECH and no configuration can be applied, this should throw an [java.io.IOException]. Returns + * the configuration that was applied, or null when no ECH configuration was used. */ fun applyEch( sslSocket: SSLSocket, echMode: EchMode, host: String, dns: Dns, - ) + ): EchConfig? /** * Returns true if [e] indicates a failure due to an invalid or expired ECH configuration. @@ -73,8 +74,9 @@ interface EchModeConfiguration { echMode: EchMode, host: String, dns: Dns, - ) { + ): EchConfig? { check(!echMode.attempt) + return null } } } diff --git a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/connection/ConnectPlan.kt b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/connection/ConnectPlan.kt index 4532b9cffa44..3a2751e8e250 100644 --- a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/connection/ConnectPlan.kt +++ b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/connection/ConnectPlan.kt @@ -413,8 +413,8 @@ class ConnectPlan internal constructor( protocol = if (maybeProtocol != null) Protocol.get(maybeProtocol) else Protocol.HTTP_1_1 success = true } finally { - // ECH rejection is surfaced as an SSLException by the platform. Let it propagate so the - // retry interceptor can decide whether to retry with ECH disabled. + // ECH rejection is surfaced as an SSLException by the platform. Let it propagate so + // RetryAndFollowUpInterceptor can classify it with EchModeConfiguration.isEchConfigError(). Platform.get().afterHandshake(sslSocket) if (!success) { sslSocket.closeQuietly() diff --git a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/http/RetryAndFollowUpInterceptor.kt b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/http/RetryAndFollowUpInterceptor.kt index c1c4873b495a..02abd3d71124 100644 --- a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/http/RetryAndFollowUpInterceptor.kt +++ b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/http/RetryAndFollowUpInterceptor.kt @@ -142,12 +142,12 @@ class RetryAndFollowUpInterceptor : Interceptor { val requestSendStarted = e !is ConnectionShutdownException if (e is SSLException) { - val echConfig = call.client.echModeConfiguration - val echMode = echConfig.echMode(call.request().url.host) + val echModeConfiguration = call.client.echModeConfiguration + val echMode = echModeConfiguration.echMode(call.request().url.host) if ( call.tag(EchMode::class) != EchMode.Fallback && echMode.fallback && - echConfig.isEchConfigError(e) + echModeConfiguration.isEchConfigError(e) ) { // Mark this call so the next connection attempt skips ECH. Without this guard a fallback // connection that also fails with an ECH-classified SSLException could retry indefinitely. diff --git a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/platform/Platform.kt b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/platform/Platform.kt index b72661961428..ba82cd9ae691 100644 --- a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/platform/Platform.kt +++ b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/platform/Platform.kt @@ -164,7 +164,7 @@ open class Platform { open fun isCleartextTrafficPermitted(hostname: String): Boolean = true - open val echModeConfiguration: EchModeConfiguration + internal open val echModeConfiguration: EchModeConfiguration get() = EchModeConfiguration.Unspecified /** diff --git a/settings.gradle.kts b/settings.gradle.kts index d0eeee2a8389..53d4dfdba76e 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -6,7 +6,6 @@ pluginManagement { mavenCentral() gradlePluginPortal() google() - maven { url = java.net.URI.create("https://central.sonatype.com/repository/maven-snapshots/") } } } @@ -16,7 +15,6 @@ dependencyResolutionManagement { repositories { mavenCentral() google() - maven { url = java.net.URI.create("https://central.sonatype.com/repository/maven-snapshots/") } } } From ccde3d30cc4a1b820d5d438125319657df135c08 Mon Sep 17 00:00:00 2001 From: Yuri Schimke Date: Mon, 4 May 2026 13:52:44 +0100 Subject: [PATCH 21/31] Temporarily focus CI on Android API 37 --- .github/workflows/build.yml | 25 +++++++++++-------------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 89a1b15d6bef..fac5afe5b156 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -46,6 +46,7 @@ jobs: validation: name: "Validation" runs-on: ubuntu-latest + if: false steps: - uses: actions/checkout@v6 - uses: gradle/actions/wrapper-validation@v5 @@ -58,6 +59,7 @@ jobs: permissions: checks: write # for mikepenz/action-junit-report runs-on: ubuntu-latest + if: false steps: - name: Checkout @@ -88,6 +90,7 @@ jobs: permissions: checks: write # for mikepenz/action-junit-report runs-on: ubuntu-latest + if: false strategy: fail-fast: false matrix: @@ -131,7 +134,7 @@ jobs: openjdk8alpn: runs-on: ubuntu-latest - if: github.ref == 'refs/heads/master' || contains(github.event.pull_request.labels.*.name, 'jdkversions') + if: false steps: - name: Checkout @@ -157,7 +160,7 @@ jobs: providers: runs-on: ubuntu-latest - if: github.ref == 'refs/heads/master' || contains(github.event.pull_request.labels.*.name, 'providers') + if: false strategy: matrix: include: @@ -190,6 +193,7 @@ jobs: openjdklatest: runs-on: ubuntu-latest + if: false steps: - name: Checkout @@ -243,6 +247,7 @@ jobs: testwindows: runs-on: windows-latest + if: false steps: - name: Checkout @@ -262,6 +267,7 @@ jobs: graal: runs-on: ubuntu-latest + if: false steps: - name: Checkout @@ -300,18 +306,6 @@ jobs: fail-fast: false matrix: include: - - api-level: 21 - arch: x86 - target: default - - api-level: 23 - arch: x86 - target: default - - api-level: 29 - arch: x86 - target: default - - api-level: 34 - arch: x86_64 - target: default - api-level: '37.0' arch: x86_64 target: google_apis_ps16k @@ -445,6 +439,7 @@ jobs: loom: runs-on: ubuntu-latest + if: false steps: - name: Checkout @@ -466,6 +461,7 @@ jobs: maven: runs-on: ubuntu-latest + if: false steps: - name: Checkout @@ -489,6 +485,7 @@ jobs: java9_modules: runs-on: ubuntu-latest + if: false steps: - name: Checkout From 1b74946c022a40fb9bb58b4baa5b9d6f4951ed3b Mon Sep 17 00:00:00 2001 From: Yuri Schimke Date: Mon, 4 May 2026 14:28:08 +0100 Subject: [PATCH 22/31] Use explicit adb path for Android 37 CI --- .github/workflows/build.yml | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index fac5afe5b156..4240e814a7c3 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -396,6 +396,8 @@ jobs: run: | SDKMANAGER="$(find "$ANDROID_HOME/cmdline-tools" -path '*/bin/sdkmanager' | sort | tail -n 1)" AVDMANAGER="$(find "$ANDROID_HOME/cmdline-tools" -path '*/bin/avdmanager' | sort | tail -n 1)" + ADB="$ANDROID_HOME/platform-tools/adb" + EMULATOR="$ANDROID_HOME/emulator/emulator" yes | "$SDKMANAGER" --licenses > /dev/null "$SDKMANAGER" --install \ @@ -406,12 +408,14 @@ jobs: 'system-images;android-37.0;google_apis_ps16k;x86_64' \ --channel=0 > /dev/null - echo no | "$AVDMANAGER" create avd \ + printf 'no\n' | "$AVDMANAGER" create avd \ --force \ --name test \ --package 'system-images;android-37.0;google_apis_ps16k;x86_64' - "$ANDROID_HOME/emulator/emulator" \ + "$AVDMANAGER" list avd + + "$EMULATOR" \ -port 5554 \ -avd test \ -no-window \ @@ -422,9 +426,9 @@ jobs: -camera-back none \ -memory 2048 & - adb -s emulator-5554 wait-for-device - timeout 1200 bash -c 'until [[ "$(adb -s emulator-5554 shell getprop sys.boot_completed | tr -d "\r")" == "1" ]]; do sleep 2; done' - timeout 300 bash -c 'until adb -s emulator-5554 shell service check input | grep -q "found"; do sleep 2; done' + "$ADB" -s emulator-5554 wait-for-device + timeout 1200 bash -c 'until [[ "$("$1" -s emulator-5554 shell getprop sys.boot_completed | tr -d "\r")" == "1" ]]; do sleep 2; done' -- "$ADB" + timeout 300 bash -c 'until "$1" -s emulator-5554 shell service check input | grep -q "found"; do sleep 2; done' -- "$ADB" ./gradlew -PandroidBuild=true connectedCheck env: @@ -432,7 +436,7 @@ jobs: - name: Stop Android 37 Emulator if: always() && matrix.api-level == '37.0' - run: adb -s emulator-5554 emu kill || true + run: "$ANDROID_HOME/platform-tools/adb" -s emulator-5554 emu kill || true - name: Build Release App run: ./gradlew android-test-app:lint android-test-app:assembleRelease From d71c14844a670f9b8efede6cb7f02eadd6931b0b Mon Sep 17 00:00:00 2001 From: Yuri Schimke Date: Mon, 4 May 2026 14:32:23 +0100 Subject: [PATCH 23/31] Fix Android 37 cleanup workflow syntax --- .github/workflows/build.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4240e814a7c3..0d6b640d7ff5 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -436,7 +436,8 @@ jobs: - name: Stop Android 37 Emulator if: always() && matrix.api-level == '37.0' - run: "$ANDROID_HOME/platform-tools/adb" -s emulator-5554 emu kill || true + run: | + "$ANDROID_HOME/platform-tools/adb" -s emulator-5554 emu kill || true - name: Build Release App run: ./gradlew android-test-app:lint android-test-app:assembleRelease From 1fb1f760387b77e24daae2609dc3d56048e625d0 Mon Sep 17 00:00:00 2001 From: Yuri Schimke Date: Mon, 4 May 2026 14:48:24 +0100 Subject: [PATCH 24/31] Split Android 37 CI into separate job --- .github/workflows/build.yml | 62 +++++++++++++++++++++++++++++++------ 1 file changed, 52 insertions(+), 10 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0d6b640d7ff5..cd8ab176c0ee 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -306,9 +306,18 @@ jobs: fail-fast: false matrix: include: - - api-level: '37.0' + - api-level: 21 + arch: x86 + target: default + - api-level: 23 + arch: x86 + target: default + - api-level: 29 + arch: x86 + target: default + - api-level: 34 arch: x86_64 - target: google_apis_ps16k + target: default steps: - name: Checkout @@ -351,7 +360,7 @@ jobs: ${{ env.ANDROID_HOME }}/system-images/android-${{ matrix.api-level }} - name: Create AVD and generate snapshot for caching - if: steps.avd-cache.outputs.cache-hit != 'true' && matrix.api-level != '37.0' + if: steps.avd-cache.outputs.cache-hit != 'true' uses: reactivecircus/android-emulator-runner@v2 with: api-level: ${{ matrix.api-level }} @@ -371,7 +380,6 @@ jobs: script: echo "Generated AVD snapshot for caching." - name: Run Tests - if: matrix.api-level != '37.0' uses: reactivecircus/android-emulator-runner@v2 with: api-level: ${{ matrix.api-level }} @@ -391,13 +399,50 @@ jobs: env: API_LEVEL: ${{ matrix.api-level }} + - name: Build Release App + run: ./gradlew android-test-app:lint android-test-app:assembleRelease + + android37: + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Configure JDK + uses: actions/setup-java@v5 + with: + distribution: 'temurin' + java-version: 21 + + - name: Enable KVM group perms + # https://github.blog/changelog/2023-02-23-hardware-accelerated-android-virtualization-on-actions-windows-and-linux-larger-hosted-runners/ + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + - name: Verify KVM + run: | + sudo apt-get install -y cpu-checker + kvm-ok || echo "KVM is not accelerated" + kvm-ok || exit 1 + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v5 + + - name: Gradle cache + run: ./gradlew :android-test:test + - name: Run Android 37 Tests - if: matrix.api-level == '37.0' run: | SDKMANAGER="$(find "$ANDROID_HOME/cmdline-tools" -path '*/bin/sdkmanager' | sort | tail -n 1)" AVDMANAGER="$(find "$ANDROID_HOME/cmdline-tools" -path '*/bin/avdmanager' | sort | tail -n 1)" ADB="$ANDROID_HOME/platform-tools/adb" EMULATOR="$ANDROID_HOME/emulator/emulator" + export ANDROID_AVD_HOME="$HOME/.android/avd" + mkdir -p "$ANDROID_AVD_HOME" yes | "$SDKMANAGER" --licenses > /dev/null "$SDKMANAGER" --install \ @@ -432,16 +477,13 @@ jobs: ./gradlew -PandroidBuild=true connectedCheck env: - API_LEVEL: ${{ matrix.api-level }} + API_LEVEL: 37.0 - name: Stop Android 37 Emulator - if: always() && matrix.api-level == '37.0' + if: always() run: | "$ANDROID_HOME/platform-tools/adb" -s emulator-5554 emu kill || true - - name: Build Release App - run: ./gradlew android-test-app:lint android-test-app:assembleRelease - loom: runs-on: ubuntu-latest if: false From 5febd475631e62e162c1e59bfa90ba4211814303 Mon Sep 17 00:00:00 2001 From: Yuri Schimke Date: Mon, 4 May 2026 15:52:58 +0100 Subject: [PATCH 25/31] Wait for Android 37 package services --- .github/workflows/build.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index cd8ab176c0ee..d733735b459d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -474,6 +474,10 @@ jobs: "$ADB" -s emulator-5554 wait-for-device timeout 1200 bash -c 'until [[ "$("$1" -s emulator-5554 shell getprop sys.boot_completed | tr -d "\r")" == "1" ]]; do sleep 2; done' -- "$ADB" timeout 300 bash -c 'until "$1" -s emulator-5554 shell service check input | grep -q "found"; do sleep 2; done' -- "$ADB" + timeout 300 bash -c 'until "$1" -s emulator-5554 shell service check package | grep -q "found"; do sleep 2; done' -- "$ADB" + timeout 300 bash -c 'until "$1" -s emulator-5554 shell service check activity | grep -q "found"; do sleep 2; done' -- "$ADB" + "$ADB" -s emulator-5554 shell getprop ro.build.version.sdk + "$ADB" -s emulator-5554 shell getprop ro.build.version.release ./gradlew -PandroidBuild=true connectedCheck env: From b890bb3b8bbe4fe9ce1d4a47e090f163a22ca75f Mon Sep 17 00:00:00 2001 From: Yuri Schimke Date: Mon, 4 May 2026 16:17:32 +0100 Subject: [PATCH 26/31] Try Android 37 Play Store system image --- .github/workflows/build.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d733735b459d..5d76d1ea1f22 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -450,13 +450,13 @@ jobs: platform-tools \ 'platforms;android-37.0' \ emulator \ - 'system-images;android-37.0;google_apis_ps16k;x86_64' \ + 'system-images;android-37.0;google_apis_playstore_ps16k;x86_64' \ --channel=0 > /dev/null printf 'no\n' | "$AVDMANAGER" create avd \ --force \ --name test \ - --package 'system-images;android-37.0;google_apis_ps16k;x86_64' + --package 'system-images;android-37.0;google_apis_playstore_ps16k;x86_64' "$AVDMANAGER" list avd From ec7e36d0b252e304380c75f0bd5c254c1e5f609a Mon Sep 17 00:00:00 2001 From: Yuri Schimke Date: Mon, 4 May 2026 16:32:52 +0100 Subject: [PATCH 27/31] Isolate Android 37 emulator job --- .github/workflows/build.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5d76d1ea1f22..27ea458024e1 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -300,6 +300,7 @@ jobs: android: runs-on: ubuntu-latest + if: false timeout-minutes: 30 strategy: @@ -469,7 +470,7 @@ jobs: -noaudio \ -no-boot-anim \ -camera-back none \ - -memory 2048 & + -memory 4096 & "$ADB" -s emulator-5554 wait-for-device timeout 1200 bash -c 'until [[ "$("$1" -s emulator-5554 shell getprop sys.boot_completed | tr -d "\r")" == "1" ]]; do sleep 2; done' -- "$ADB" From 892472ba1519547e8bd3fb8f095ff6f57774220a Mon Sep 17 00:00:00 2001 From: Yuri Schimke Date: Mon, 4 May 2026 16:51:38 +0100 Subject: [PATCH 28/31] Initialize OkHttp in public suffix Android test --- .../okhttp/android/testapp/PublicSuffixDatabaseTest.kt | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/android-test-app/src/androidTest/kotlin/okhttp/android/testapp/PublicSuffixDatabaseTest.kt b/android-test-app/src/androidTest/kotlin/okhttp/android/testapp/PublicSuffixDatabaseTest.kt index 5580f5dd332a..ec5783928413 100644 --- a/android-test-app/src/androidTest/kotlin/okhttp/android/testapp/PublicSuffixDatabaseTest.kt +++ b/android-test-app/src/androidTest/kotlin/okhttp/android/testapp/PublicSuffixDatabaseTest.kt @@ -15,15 +15,23 @@ */ package okhttp3.android +import androidx.test.platform.app.InstrumentationRegistry import assertk.assertThat import assertk.assertions.isEqualTo import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.OkHttp +import org.junit.Before import org.junit.Test /** * Run with "./gradlew :android-test-app:connectedCheck -PandroidBuild=true" and make sure ANDROID_SDK_ROOT is set. */ class PublicSuffixDatabaseTest { + @Before + fun setUp() { + OkHttp.initialize(InstrumentationRegistry.getInstrumentation().targetContext) + } + @Test fun testTopLevelDomain() { assertThat("https://www.google.com/robots.txt".toHttpUrl().topPrivateDomain()).isEqualTo("google.com") From 72742fe48a9adcdc7bb88dcabf8d10901fc507a3 Mon Sep 17 00:00:00 2001 From: Yuri Schimke Date: Mon, 4 May 2026 16:56:34 +0100 Subject: [PATCH 29/31] Re-enable regular build jobs --- .github/workflows/build.yml | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 27ea458024e1..afeefe77f278 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -46,7 +46,6 @@ jobs: validation: name: "Validation" runs-on: ubuntu-latest - if: false steps: - uses: actions/checkout@v6 - uses: gradle/actions/wrapper-validation@v5 @@ -59,7 +58,6 @@ jobs: permissions: checks: write # for mikepenz/action-junit-report runs-on: ubuntu-latest - if: false steps: - name: Checkout @@ -90,7 +88,6 @@ jobs: permissions: checks: write # for mikepenz/action-junit-report runs-on: ubuntu-latest - if: false strategy: fail-fast: false matrix: @@ -134,7 +131,6 @@ jobs: openjdk8alpn: runs-on: ubuntu-latest - if: false steps: - name: Checkout @@ -160,7 +156,6 @@ jobs: providers: runs-on: ubuntu-latest - if: false strategy: matrix: include: @@ -193,7 +188,6 @@ jobs: openjdklatest: runs-on: ubuntu-latest - if: false steps: - name: Checkout @@ -247,7 +241,6 @@ jobs: testwindows: runs-on: windows-latest - if: false steps: - name: Checkout @@ -267,7 +260,6 @@ jobs: graal: runs-on: ubuntu-latest - if: false steps: - name: Checkout @@ -300,7 +292,6 @@ jobs: android: runs-on: ubuntu-latest - if: false timeout-minutes: 30 strategy: @@ -491,7 +482,6 @@ jobs: loom: runs-on: ubuntu-latest - if: false steps: - name: Checkout @@ -513,7 +503,6 @@ jobs: maven: runs-on: ubuntu-latest - if: false steps: - name: Checkout @@ -537,7 +526,6 @@ jobs: java9_modules: runs-on: ubuntu-latest - if: false steps: - name: Checkout From 8cd005fbe463ece304c3c96fc7495ce5bef5e9cc Mon Sep 17 00:00:00 2001 From: Yuri Schimke Date: Mon, 4 May 2026 17:04:46 +0100 Subject: [PATCH 30/31] Restore conditional build job gates --- .github/workflows/build.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index afeefe77f278..edfd71e2e8f6 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -131,6 +131,7 @@ jobs: openjdk8alpn: runs-on: ubuntu-latest + if: github.ref == 'refs/heads/master' || contains(github.event.pull_request.labels.*.name, 'jdkversions') steps: - name: Checkout @@ -156,6 +157,7 @@ jobs: providers: runs-on: ubuntu-latest + if: github.ref == 'refs/heads/master' || contains(github.event.pull_request.labels.*.name, 'providers') strategy: matrix: include: From 994fe6f029b4dafe4dfeb7490f540037bf06af4f Mon Sep 17 00:00:00 2001 From: Yuri Schimke Date: Mon, 4 May 2026 17:08:42 +0100 Subject: [PATCH 31/31] Pin Android 37 workflow actions --- .github/workflows/build.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index edfd71e2e8f6..a01766b1fdd4 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -402,10 +402,10 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd - name: Configure JDK - uses: actions/setup-java@v5 + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 with: distribution: 'temurin' java-version: 21 @@ -424,7 +424,7 @@ jobs: kvm-ok || exit 1 - name: Setup Gradle - uses: gradle/actions/setup-gradle@v5 + uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c - name: Gradle cache run: ./gradlew :android-test:test