From 6eeb551c5339ee3e6fe23d13ae75a77457993427 Mon Sep 17 00:00:00 2001 From: Yuri Schimke Date: Fri, 24 Jul 2026 07:51:58 +0100 Subject: [PATCH 1/6] Add Android ECH support via DNS state machine Adds AndroidDns, backed by the system resolver and Android's DnsResolver for HTTPS/ECH records, driving okhttp's StateMachineDnsCall as a transport. Adds Android17SocketAdapter for platform ALPN/session-ticket/ECH setup on API 37+. Pins Robolectric android-test to SDK 36. --- android-test-app/build.gradle.kts | 6 +- android-test/build.gradle.kts | 22 +- .../src/test/resources/robolectric.properties | 1 + gradle/libs.versions.toml | 2 +- okhttp/build.gradle.kts | 2 +- .../kotlin/okhttp3/android/AndroidDns.kt | 219 ++++++++++++++++++ .../internal/platform/Android10Platform.kt | 2 + .../android/Android17SocketAdapter.kt | 97 ++++++++ regression-test/build.gradle.kts | 4 +- 9 files changed, 348 insertions(+), 7 deletions(-) create mode 100644 android-test/src/test/resources/robolectric.properties create mode 100644 okhttp/src/androidMain/kotlin/okhttp3/android/AndroidDns.kt create mode 100644 okhttp/src/androidMain/kotlin/okhttp3/internal/platform/android/Android17SocketAdapter.kt diff --git a/android-test-app/build.gradle.kts b/android-test-app/build.gradle.kts index 345ea057f26f..eac1b114ed77 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 = release(37) + } namespace = "okhttp.android.testapp" @@ -18,7 +20,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 10fe81bd7262..bc0e5212c78b 100644 --- a/android-test/build.gradle.kts +++ b/android-test/build.gradle.kts @@ -8,7 +8,9 @@ plugins { } android { - compileSdk = 36 + compileSdk { + version = release(37) + } namespace = "okhttp.android.test" @@ -39,8 +41,24 @@ android { } testOptions { - targetSdk = 34 + targetSdk = 37 unitTests.isIncludeAndroidResources = true + + // Robolectric 4.17 reflects into JDK internals, which JDK 17+ blocks by default. + // https://robolectric.org/getting-started/ + unitTests.all { + it.jvmArgs( + "--add-opens=java.base/java.lang=ALL-UNNAMED", + "--add-opens=java.base/java.util=ALL-UNNAMED", + "--add-opens=java.base/java.io=ALL-UNNAMED", + "--add-opens=java.base/java.net=ALL-UNNAMED", + "--add-opens=java.base/java.security=ALL-UNNAMED", + "--add-opens=java.base/java.text=ALL-UNNAMED", + "--add-opens=java.base/jdk.internal.access=ALL-UNNAMED", + "--add-opens=java.desktop/java.awt.font=ALL-UNNAMED", + "--add-opens=jdk.compiler/com.sun.tools.javac.api=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/gradle/libs.versions.toml b/gradle/libs.versions.toml index c9009f363554..8c08c25ec8c2 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -47,7 +47,7 @@ org-bouncycastle = "1.84" org-conscrypt = "2.5.2" org-junit-jupiter = "5.13.4" playservices-safetynet = "18.1.0" -robolectric = "4.16.1" +robolectric = "4.17-beta-2" robolectric-android = "16-robolectric-13921718" serialization = "1.11.0" shadow-plugin = "9.5.1" diff --git a/okhttp/build.gradle.kts b/okhttp/build.gradle.kts index 31b2eb3f0ecc..15d24f092949 100644 --- a/okhttp/build.gradle.kts +++ b/okhttp/build.gradle.kts @@ -58,7 +58,7 @@ kotlin { android { namespace = "okhttp.okhttp3" compileSdk { - version = release(36) + version = release(37) } minSdk = 21 diff --git a/okhttp/src/androidMain/kotlin/okhttp3/android/AndroidDns.kt b/okhttp/src/androidMain/kotlin/okhttp3/android/AndroidDns.kt new file mode 100644 index 000000000000..f9e72fce0b37 --- /dev/null +++ b/okhttp/src/androidMain/kotlin/okhttp3/android/AndroidDns.kt @@ -0,0 +1,219 @@ +/* + * 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. + */ +@file:OptIn(OkHttpInternalApi::class) + +package okhttp3.android + +import android.net.DnsResolver +import android.net.Network +import android.os.CancellationSignal +import androidx.annotation.RequiresApi +import java.io.IOException +import java.net.InetAddress +import java.net.UnknownHostException +import java.util.concurrent.Executor +import okhttp3.Dns +import okhttp3.internal.OkHttpInternalApi +import okhttp3.internal.SuppressSignatureCheck +import okhttp3.internal.concurrent.Task +import okhttp3.internal.concurrent.TaskRunner +import okhttp3.internal.dns.DnsMessage +import okhttp3.internal.dns.DnsMessageReader +import okhttp3.internal.dns.Question +import okhttp3.internal.dns.ResourceRecord +import okhttp3.internal.dns.StateMachineDnsCall +import okhttp3.internal.dns.TYPE_A +import okhttp3.internal.dns.TYPE_HTTPS +import okhttp3.internal.dns.execute +import okio.Buffer + +/** + * A [Dns] backed by Android's system resolver, with ECH support from Android's [DnsResolver]. + * + * IP addresses come from the system resolver — [InetAddress.getAllByName], or + * [Network.getAllByName] when a [network] is set. This drives OkHttp's [StateMachineDnsCall] as a + * transport: a single `A` query stands in for the blocking address lookup (which returns both + * address families), plus an optional `HTTPS` query for service metadata such as ECH. + */ +@RequiresApi(29) +@SuppressSignatureCheck +class AndroidDns + @JvmOverloads + constructor( + private val dnsResolver: DnsResolver = DnsResolver.getInstance(), + private val network: Network? = null, + /** + * True to also query the `HTTPS` record for service metadata such as ECH. Set this to false to + * save a query when only IP addresses are needed. + */ + private val includeServiceMetadata: Boolean = true, + // Runs inline; the executor only hands off DnsResolver's callbacks. + private val executor: Executor = Executor { it.run() }, + ) : Dns, + StateMachineDnsCall.Transport { + /** + * Resolves addresses only, for callers using the legacy blocking API. [Dns] cannot carry + * HTTPS/ECH metadata, so this skips that query rather than paying for it and discarding it. + */ + override fun lookup(hostname: String): List = + call(Dns.Request(hostname), includeServiceMetadata = false) + .execute() + .filterIsInstance() + .map { it.address } + + override fun newCall(request: Dns.Request): Dns.Call = call(request, includeServiceMetadata = includeServiceMetadata) + + private fun call( + request: Dns.Request, + includeServiceMetadata: Boolean, + ): Dns.Call = + StateMachineDnsCall( + request = request, + transport = this, + canceledException = null, + // A single `A` query stands in for both families: the system resolver returns IPv4 and + // IPv6 addresses together, so there's no separate `AAAA` query. + includeIPv6 = false, + includeServiceMetadata = includeServiceMetadata, + ) + + override fun newQuery(question: Question) = Query(question) + + override fun enqueue( + query: Query, + callback: StateMachineDnsCall.Transport.Callback, + ) { + when (query.type) { + TYPE_A -> resolveAddresses(query.hostname, callback) + else -> queryServiceMetadata(query, callback) + } + } + + /** + * Cancels a query. Only the `HTTPS` query reaches [DnsResolver]; the address lookup runs to + * completion and its result is discarded by [StateMachineDnsCall], which ignores callbacks once + * canceled. + */ + override fun cancel(query: Query) { + query.cancellationSignal?.cancel() + } + + /** + * Resolves IP addresses through the system resolver. This is a blocking call, so it runs on a + * [TaskRunner] thread. The addresses are wrapped in a synthetic [DnsMessage] because that's the + * only shape [StateMachineDnsCall] accepts — the system resolver gives us decoded addresses + * rather than a wire-format message. + */ + private fun resolveAddresses( + hostname: String, + callback: StateMachineDnsCall.Transport.Callback, + ) { + TaskRunner.INSTANCE.newQueue().schedule( + object : Task("$hostname address lookup", cancelable = false) { + override fun runOnce(): Long { + try { + val addresses = + when (network) { + null -> InetAddress.getAllByName(hostname) + else -> network.getAllByName(hostname) + } + callback.onResponse(addresses.toDnsMessage(hostname)) + } catch (e: UnknownHostException) { + callback.onFailure(e) + } + return -1L + } + }, + ) + } + + /** + * Asks [DnsResolver] for the `HTTPS` record. The platform has no typed API for this below + * API 36, so we request the raw message and decode it with OkHttp's own [DnsMessageReader] — + * the same decoder `DnsOverHttps` uses. + * + * Failures are reported to the [StateMachineDnsCall] rather than swallowed, so callers can tell + * an absent `HTTPS` record from a query that errored. Any addresses already resolved are still + * delivered first. + */ + @Suppress("ktlint:standard:comment-wrapping") + private fun queryServiceMetadata( + query: Query, + callback: StateMachineDnsCall.Transport.Callback, + ) { + val queryCallback = + object : DnsResolver.Callback { + override fun onAnswer( + answer: ByteArray, + rcode: Int, + ) { + val message = + try { + DnsMessageReader(Buffer().write(answer)).read() + } catch (e: IOException) { + return callback.onFailure(e) + } + // The state machine turns a non-success rcode into a failure of its own. + callback.onResponse(message) + } + + override fun onError(e: DnsResolver.DnsException) { + callback.onFailure(IOException("HTTPS query failed with code ${e.code}", e)) + } + } + + try { + dnsResolver.rawQuery( + /* network = */ network, + /* domain = */ query.hostname, + /* nsClass = */ DnsResolver.CLASS_IN, + /* nsType = */ TYPE_HTTPS, + /* flags = */ DnsResolver.FLAG_EMPTY, + /* executor = */ executor, + /* cancellationSignal = */ query.cancellationSignal, + /* callback = */ queryCallback, + ) + } catch (e: SecurityException) { + // The app lacks INTERNET permission, or network policy forbids the query. The callback + // won't run, so report the failure here or the state machine never terminates. + callback.onFailure(IOException(e)) + } + } + + /** One outstanding transport-layer query. */ + class Query( + question: Question, + ) { + val hostname: String = question.name + val type: Int = question.type + + /** Only the `HTTPS` query reaches [DnsResolver], which is the API that takes a signal. */ + val cancellationSignal = if (type == TYPE_A) null else CancellationSignal() + } + } + +/** + * Wraps system-resolved [addresses] in a successful [DnsMessage] so they can flow through + * [StateMachineDnsCall], which only accepts wire-format responses. + */ +private fun Array.toDnsMessage(hostname: String): DnsMessage = + DnsMessage( + id = 0, + // QR = 1 (Response), RCODE = 0 (Success). The state machine only reads the response code. + flags = 0b1___0000__0__0__1__0_000__0000, + questions = listOf(Question(hostname, TYPE_A)), + answers = map { ResourceRecord.IpAddress(name = hostname, timeToLive = 0, address = it) }, + ) diff --git a/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/Android10Platform.kt b/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/Android10Platform.kt index 00cbe21a7079..e89e0260f59c 100644 --- a/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/Android10Platform.kt +++ b/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/Android10Platform.kt @@ -30,6 +30,7 @@ import okhttp3.Protocol import okhttp3.internal.SuppressSignatureCheck import okhttp3.internal.platform.AndroidPlatform.Companion.Tag import okhttp3.internal.platform.android.Android10SocketAdapter +import okhttp3.internal.platform.android.Android17SocketAdapter import okhttp3.internal.platform.android.AndroidCertificateChainCleaner import okhttp3.internal.platform.android.AndroidSocketAdapter import okhttp3.internal.platform.android.BouncyCastleSocketAdapter @@ -48,6 +49,7 @@ class Android10Platform : private val socketAdapters = listOfNotNull( + Android17SocketAdapter.buildIfSupported(), Android10SocketAdapter.buildIfSupported(), DeferredSocketAdapter(AndroidSocketAdapter.playProviderFactory), // Delay and Defer any initialisation of Conscrypt and BouncyCastle diff --git a/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/android/Android17SocketAdapter.kt b/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/android/Android17SocketAdapter.kt new file mode 100644 index 000000000000..4855eb891f11 --- /dev/null +++ b/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/android/Android17SocketAdapter.kt @@ -0,0 +1,97 @@ +/* + * 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.InvalidEchDataException +import android.net.ssl.SSLSockets +import android.os.Build +import androidx.annotation.ChecksSdkIntAtLeast +import androidx.annotation.RequiresApi +import java.io.IOException +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 + +/** + * Socket adapter for Android 17+ platform TLS APIs. + * + * Specifically supports setEchConfigList for ECH goodness. + */ +@SuppressLint("NewApi") +@SuppressSignatureCheck +class Android17SocketAdapter + @RequiresApi(37) + internal constructor() : SocketAdapter { + 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, + echConfigList: ByteString?, + ) { + SSLSockets.setUseSessionTickets(sslSocket, true) + + val sslParameters = sslSocket.sslParameters + + // Enable ALPN. + sslParameters.applicationProtocols = Platform.alpnProtocolNames(protocols).toTypedArray() + + sslSocket.sslParameters = sslParameters + + if (hostname != null && echConfigList != null) { + // TODO put behind Network Policy + + // The ECH config was resolved during DNS (RealCall.resolveAddresses); just apply it here. + // A config we can't parse is skipped, and the connection proceeds without ECH. + val echConfig = + try { + EchConfigList.fromBytes(echConfigList.toByteArray()) + } catch (_: InvalidEchDataException) { + // The platform can throw on a malformed or absent ECH parameter. + // https://issuetracker.google.com/issues/319957694 + null + } + + if (echConfig != null) { + SSLSockets.setEchConfigList(sslSocket, echConfig) + } + } + } + + @SuppressSignatureCheck + companion object { + fun buildIfSupported(): SocketAdapter? = if (isSupported()) Android17SocketAdapter() else null + + @ChecksSdkIntAtLeast(api = 37) + fun isSupported() = isAndroid && Build.VERSION.SDK_INT >= 37 + } + } diff --git a/regression-test/build.gradle.kts b/regression-test/build.gradle.kts index 72cb794bd53b..522294381bd0 100644 --- a/regression-test/build.gradle.kts +++ b/regression-test/build.gradle.kts @@ -4,7 +4,9 @@ plugins { } android { - compileSdk = 36 + compileSdk { + version = release(37) + } namespace = "okhttp.android.regression" From c520a1dc87e11fd6bf1fbc22c8022aacc67709c2 Mon Sep 17 00:00:00 2001 From: Yuri Schimke Date: Fri, 24 Jul 2026 09:22:01 +0100 Subject: [PATCH 2/6] add test --- .../okhttp/android/test/AndroidDnsTest.kt | 75 +++++++++++++++++++ .../java/okhttp/android/test/EchTest.kt | 66 ++++++++++++++++ android-test/src/main/AndroidManifest.xml | 2 +- .../main/res/xml/network_security_config.xml | 9 +++ 4 files changed, 151 insertions(+), 1 deletion(-) create mode 100644 android-test/src/androidTest/java/okhttp/android/test/AndroidDnsTest.kt create mode 100644 android-test/src/androidTest/java/okhttp/android/test/EchTest.kt diff --git a/android-test/src/androidTest/java/okhttp/android/test/AndroidDnsTest.kt b/android-test/src/androidTest/java/okhttp/android/test/AndroidDnsTest.kt new file mode 100644 index 000000000000..415c47077bcc --- /dev/null +++ b/android-test/src/androidTest/java/okhttp/android/test/AndroidDnsTest.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 okhttp.android.test + +import android.os.Build +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isNotEmpty +import assertk.assertions.matchesPredicate +import java.net.InetAddress +import mockwebserver3.MockResponse +import mockwebserver3.MockWebServer +import mockwebserver3.junit5.StartStop +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.android.AndroidDns +import org.junit.jupiter.api.Assumptions.assumeTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +/** + * Exercises [AndroidDns] as the resolver behind an [OkHttpClient], without service metadata (ECH). + * The requests go to a local [MockWebServer] over `localhost`, so the system resolver is driven but + * no external network is required. + */ +class AndroidDnsTest { + @StartStop + private val server = MockWebServer() + + @BeforeEach + fun setup() { + // AndroidDns is backed by DnsResolver, which is API 29+. + assumeTrue(Build.VERSION.SDK_INT >= 29) + } + + @Test + fun lookupResolvesLocalhostToLoopback() { + val addresses = AndroidDns(includeServiceMetadata = false).lookup("localhost") + + assertThat(addresses).isNotEmpty() + assertThat(addresses).matchesPredicate { it.all(InetAddress::isLoopbackAddress) } + } + + @Test + fun getWithAndroidDns() { + server.enqueue(MockResponse(body = "hello")) + + val client = + OkHttpClient + .Builder() + .dns(AndroidDns(includeServiceMetadata = false)) + .build() + + // Force a hostname (not an IP literal) so the request goes through AndroidDns. + val url = server.url("/").newBuilder().host("localhost").build() + + client.newCall(Request.Builder().url(url).build()).execute().use { response -> + assertThat(response.code).isEqualTo(200) + assertThat(response.body.string()).isEqualTo("hello") + } + } +} 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..cad2037d8d29 --- /dev/null +++ b/android-test/src/androidTest/java/okhttp/android/test/EchTest.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 okhttp.android.test + +import assertk.assertThat +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 + fun testHttpsRequest() { + val client: OkHttpClient = + OkHttpClient + .Builder() + .build() + + val cloudflareEchBody = + client.sendRequest(Request.Builder().url("https://cloudflare-ech.com/").build()) { + it.body.string() + } + assertThat(cloudflareEchBody).matchesPredicate { it.contains("ECH enabled") } + + val cloudflareBody = + client.sendRequest( + Request.Builder().url("https://crypto.cloudflare.com/cdn-cgi/trace").build(), + ) { + it.body.string() + } + assertThat(cloudflareBody).matchesPredicate { it.contains("ECH enabled") } + + 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) -> T, + ): T { + val response = newCall(request).execute() + + 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/main/res/xml/network_security_config.xml b/android-test/src/main/res/xml/network_security_config.xml index 786dddecc784..96c2e646ab80 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,13 @@ + + localhost + + + cloudflare-ech.com + crypto.cloudflare.com + tls-ech.dev + + \ No newline at end of file From 8f21cf82758fc0b730af8ba7669506cf3bc45318 Mon Sep 17 00:00:00 2001 From: Yuri Schimke Date: Fri, 24 Jul 2026 10:03:15 +0100 Subject: [PATCH 3/6] cleanup --- .../src/androidMain/kotlin/okhttp3/android/AndroidDns.kt | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/okhttp/src/androidMain/kotlin/okhttp3/android/AndroidDns.kt b/okhttp/src/androidMain/kotlin/okhttp3/android/AndroidDns.kt index f9e72fce0b37..c7546980c359 100644 --- a/okhttp/src/androidMain/kotlin/okhttp3/android/AndroidDns.kt +++ b/okhttp/src/androidMain/kotlin/okhttp3/android/AndroidDns.kt @@ -98,7 +98,11 @@ class AndroidDns ) { when (query.type) { TYPE_A -> resolveAddresses(query.hostname, callback) - else -> queryServiceMetadata(query, callback) + + TYPE_HTTPS -> queryServiceMetadata(query, callback) + + // AndroidDns only ever issues `A` and `HTTPS` queries (includeIPv6 = false, so no `AAAA`). + else -> error("unexpected query type ${query.type}") } } @@ -201,7 +205,7 @@ class AndroidDns val type: Int = question.type /** Only the `HTTPS` query reaches [DnsResolver], which is the API that takes a signal. */ - val cancellationSignal = if (type == TYPE_A) null else CancellationSignal() + val cancellationSignal = if (type == TYPE_HTTPS) CancellationSignal() else null } } From 0a48a1873ff39e51fc58be2578385644ca86c988 Mon Sep 17 00:00:00 2001 From: Yuri Schimke Date: Fri, 24 Jul 2026 10:14:40 +0100 Subject: [PATCH 4/6] avoid leaking trasports --- okhttp/api/android/okhttp.api | 8 + .../kotlin/okhttp3/android/AndroidDns.kt | 204 +++++++++--------- 2 files changed, 112 insertions(+), 100 deletions(-) diff --git a/okhttp/api/android/okhttp.api b/okhttp/api/android/okhttp.api index 40236699c4f3..306a46733851 100644 --- a/okhttp/api/android/okhttp.api +++ b/okhttp/api/android/okhttp.api @@ -1366,3 +1366,11 @@ public abstract class okhttp3/WebSocketListener { public fun onOpen (Lokhttp3/WebSocket;Lokhttp3/Response;)V } +public final class okhttp3/android/AndroidDns : okhttp3/Dns { + public fun ()V + public fun (Landroid/net/DnsResolver;Landroid/net/Network;ZLjava/util/concurrent/Executor;)V + public synthetic fun (Landroid/net/DnsResolver;Landroid/net/Network;ZLjava/util/concurrent/Executor;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun lookup (Ljava/lang/String;)Ljava/util/List; + public fun newCall (Lokhttp3/Dns$Request;)Lokhttp3/Dns$Call; +} + diff --git a/okhttp/src/androidMain/kotlin/okhttp3/android/AndroidDns.kt b/okhttp/src/androidMain/kotlin/okhttp3/android/AndroidDns.kt index c7546980c359..4f4baaed743b 100644 --- a/okhttp/src/androidMain/kotlin/okhttp3/android/AndroidDns.kt +++ b/okhttp/src/androidMain/kotlin/okhttp3/android/AndroidDns.kt @@ -44,14 +44,14 @@ import okio.Buffer * A [Dns] backed by Android's system resolver, with ECH support from Android's [DnsResolver]. * * IP addresses come from the system resolver — [InetAddress.getAllByName], or - * [Network.getAllByName] when a [network] is set. This drives OkHttp's [StateMachineDnsCall] as a - * transport: a single `A` query stands in for the blocking address lookup (which returns both - * address families), plus an optional `HTTPS` query for service metadata such as ECH. + * [Network.getAllByName] when a [network] is set. Internally this drives OkHttp's + * [StateMachineDnsCall] through a private transport: a single `A` query stands in for the blocking + * address lookup (which returns both address families), plus an optional `HTTPS` query for service + * metadata such as ECH. */ @RequiresApi(29) @SuppressSignatureCheck class AndroidDns - @JvmOverloads constructor( private val dnsResolver: DnsResolver = DnsResolver.getInstance(), private val network: Network? = null, @@ -62,8 +62,9 @@ class AndroidDns private val includeServiceMetadata: Boolean = true, // Runs inline; the executor only hands off DnsResolver's callbacks. private val executor: Executor = Executor { it.run() }, - ) : Dns, - StateMachineDnsCall.Transport { + ) : Dns { + private val transport = AndroidTransport() + /** * Resolves addresses only, for callers using the legacy blocking API. [Dns] cannot carry * HTTPS/ECH metadata, so this skips that query rather than paying for it and discarding it. @@ -82,7 +83,7 @@ class AndroidDns ): Dns.Call = StateMachineDnsCall( request = request, - transport = this, + transport = transport, canceledException = null, // A single `A` query stands in for both families: the system resolver returns IPv4 and // IPv6 addresses together, so there's no separate `AAAA` query. @@ -90,115 +91,118 @@ class AndroidDns includeServiceMetadata = includeServiceMetadata, ) - override fun newQuery(question: Question) = Query(question) + /** Drives [StateMachineDnsCall] using the system resolver and [DnsResolver]. */ + private inner class AndroidTransport : StateMachineDnsCall.Transport { + override fun newQuery(question: Question) = Query(question) - override fun enqueue( - query: Query, - callback: StateMachineDnsCall.Transport.Callback, - ) { - when (query.type) { - TYPE_A -> resolveAddresses(query.hostname, callback) + override fun enqueue( + query: Query, + callback: StateMachineDnsCall.Transport.Callback, + ) { + when (query.type) { + TYPE_A -> resolveAddresses(query.hostname, callback) - TYPE_HTTPS -> queryServiceMetadata(query, callback) + TYPE_HTTPS -> queryServiceMetadata(query, callback) - // AndroidDns only ever issues `A` and `HTTPS` queries (includeIPv6 = false, so no `AAAA`). - else -> error("unexpected query type ${query.type}") + // AndroidDns only ever issues `A` and `HTTPS` queries (includeIPv6 = false, so no `AAAA`). + else -> error("unexpected query type ${query.type}") + } } - } - /** - * Cancels a query. Only the `HTTPS` query reaches [DnsResolver]; the address lookup runs to - * completion and its result is discarded by [StateMachineDnsCall], which ignores callbacks once - * canceled. - */ - override fun cancel(query: Query) { - query.cancellationSignal?.cancel() - } + /** + * Cancels a query. Only the `HTTPS` query reaches [DnsResolver]; the address lookup runs to + * completion and its result is discarded by [StateMachineDnsCall], which ignores callbacks + * once canceled. + */ + override fun cancel(query: Query) { + query.cancellationSignal?.cancel() + } - /** - * Resolves IP addresses through the system resolver. This is a blocking call, so it runs on a - * [TaskRunner] thread. The addresses are wrapped in a synthetic [DnsMessage] because that's the - * only shape [StateMachineDnsCall] accepts — the system resolver gives us decoded addresses - * rather than a wire-format message. - */ - private fun resolveAddresses( - hostname: String, - callback: StateMachineDnsCall.Transport.Callback, - ) { - TaskRunner.INSTANCE.newQueue().schedule( - object : Task("$hostname address lookup", cancelable = false) { - override fun runOnce(): Long { - try { - val addresses = - when (network) { - null -> InetAddress.getAllByName(hostname) - else -> network.getAllByName(hostname) + /** + * Resolves IP addresses through the system resolver. This is a blocking call, so it runs on a + * [TaskRunner] thread. The addresses are wrapped in a synthetic [DnsMessage] because that's + * the only shape [StateMachineDnsCall] accepts — the system resolver gives us decoded + * addresses rather than a wire-format message. + */ + private fun resolveAddresses( + hostname: String, + callback: StateMachineDnsCall.Transport.Callback, + ) { + TaskRunner.INSTANCE.newQueue().schedule( + object : Task("$hostname address lookup", cancelable = false) { + override fun runOnce(): Long { + try { + val addresses = + when (network) { + null -> InetAddress.getAllByName(hostname) + else -> network.getAllByName(hostname) + } + callback.onResponse(addresses.toDnsMessage(hostname)) + } catch (e: UnknownHostException) { + callback.onFailure(e) + } + return -1L + } + }, + ) + } + + /** + * Asks [DnsResolver] for the `HTTPS` record. The platform has no typed API for this below + * API 36, so we request the raw message and decode it with OkHttp's own [DnsMessageReader] — + * the same decoder `DnsOverHttps` uses. + * + * Failures are reported to the [StateMachineDnsCall] rather than swallowed, so callers can + * tell an absent `HTTPS` record from a query that errored. Any addresses already resolved are + * still delivered first. + */ + @Suppress("ktlint:standard:comment-wrapping") + private fun queryServiceMetadata( + query: Query, + callback: StateMachineDnsCall.Transport.Callback, + ) { + val queryCallback = + object : DnsResolver.Callback { + override fun onAnswer( + answer: ByteArray, + rcode: Int, + ) { + val message = + try { + DnsMessageReader(Buffer().write(answer)).read() + } catch (e: IOException) { + return callback.onFailure(e) } - callback.onResponse(addresses.toDnsMessage(hostname)) - } catch (e: UnknownHostException) { - callback.onFailure(e) + // The state machine turns a non-success rcode into a failure of its own. + callback.onResponse(message) } - return -1L - } - }, - ) - } - /** - * Asks [DnsResolver] for the `HTTPS` record. The platform has no typed API for this below - * API 36, so we request the raw message and decode it with OkHttp's own [DnsMessageReader] — - * the same decoder `DnsOverHttps` uses. - * - * Failures are reported to the [StateMachineDnsCall] rather than swallowed, so callers can tell - * an absent `HTTPS` record from a query that errored. Any addresses already resolved are still - * delivered first. - */ - @Suppress("ktlint:standard:comment-wrapping") - private fun queryServiceMetadata( - query: Query, - callback: StateMachineDnsCall.Transport.Callback, - ) { - val queryCallback = - object : DnsResolver.Callback { - override fun onAnswer( - answer: ByteArray, - rcode: Int, - ) { - val message = - try { - DnsMessageReader(Buffer().write(answer)).read() - } catch (e: IOException) { - return callback.onFailure(e) - } - // The state machine turns a non-success rcode into a failure of its own. - callback.onResponse(message) + override fun onError(e: DnsResolver.DnsException) { + callback.onFailure(IOException("HTTPS query failed with code ${e.code}", e)) + } } - override fun onError(e: DnsResolver.DnsException) { - callback.onFailure(IOException("HTTPS query failed with code ${e.code}", e)) - } + try { + dnsResolver.rawQuery( + /* network = */ network, + /* domain = */ query.hostname, + /* nsClass = */ DnsResolver.CLASS_IN, + /* nsType = */ TYPE_HTTPS, + /* flags = */ DnsResolver.FLAG_EMPTY, + /* executor = */ executor, + /* cancellationSignal = */ query.cancellationSignal, + /* callback = */ queryCallback, + ) + } catch (e: SecurityException) { + // The app lacks INTERNET permission, or network policy forbids the query. The callback + // won't run, so report the failure here or the state machine never terminates. + callback.onFailure(IOException(e)) } - - try { - dnsResolver.rawQuery( - /* network = */ network, - /* domain = */ query.hostname, - /* nsClass = */ DnsResolver.CLASS_IN, - /* nsType = */ TYPE_HTTPS, - /* flags = */ DnsResolver.FLAG_EMPTY, - /* executor = */ executor, - /* cancellationSignal = */ query.cancellationSignal, - /* callback = */ queryCallback, - ) - } catch (e: SecurityException) { - // The app lacks INTERNET permission, or network policy forbids the query. The callback - // won't run, so report the failure here or the state machine never terminates. - callback.onFailure(IOException(e)) } } /** One outstanding transport-layer query. */ - class Query( + private class Query( question: Question, ) { val hostname: String = question.name From 1a3d3dec13418a2f85f045451678b4e676ddcd9b Mon Sep 17 00:00:00 2001 From: Yuri Schimke Date: Fri, 24 Jul 2026 10:15:02 +0100 Subject: [PATCH 5/6] bump conscrypt --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8c08c25ec8c2..06bce4a8b7fe 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -44,7 +44,7 @@ mockserver-client = "5.15.0" mrjar = "0.1.1" openjsse = "1.1.14" org-bouncycastle = "1.84" -org-conscrypt = "2.5.2" +org-conscrypt = "2.6.1" org-junit-jupiter = "5.13.4" playservices-safetynet = "18.1.0" robolectric = "4.17-beta-2" From 6903e1fb3f9a875475abbd1e3015725595da683c Mon Sep 17 00:00:00 2001 From: Yuri Schimke Date: Fri, 24 Jul 2026 10:52:56 +0100 Subject: [PATCH 6/6] fixes --- .../kotlin/okhttp3/android/AndroidDns.kt | 14 ++++++++++---- .../platform/android/Android17SocketAdapter.kt | 11 ++++++++++- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/okhttp/src/androidMain/kotlin/okhttp3/android/AndroidDns.kt b/okhttp/src/androidMain/kotlin/okhttp3/android/AndroidDns.kt index 4f4baaed743b..aabf96cc8ba2 100644 --- a/okhttp/src/androidMain/kotlin/okhttp3/android/AndroidDns.kt +++ b/okhttp/src/androidMain/kotlin/okhttp3/android/AndroidDns.kt @@ -17,6 +17,7 @@ package okhttp3.android +import android.annotation.SuppressLint import android.net.DnsResolver import android.net.Network import android.os.CancellationSignal @@ -56,8 +57,9 @@ class AndroidDns private val dnsResolver: DnsResolver = DnsResolver.getInstance(), private val network: Network? = null, /** - * True to also query the `HTTPS` record for service metadata such as ECH. Set this to false to - * save a query when only IP addresses are needed. + * True to also query the `HTTPS` record for service metadata. Keep this on: it enables privacy + * features such as Encrypted Client Hello (ECH) for the HTTPS call. Set it to false only when + * you want to disable ECH. */ private val includeServiceMetadata: Boolean = true, // Runs inline; the executor only hands off DnsResolver's callbacks. @@ -153,9 +155,13 @@ class AndroidDns * the same decoder `DnsOverHttps` uses. * * Failures are reported to the [StateMachineDnsCall] rather than swallowed, so callers can - * tell an absent `HTTPS` record from a query that errored. Any addresses already resolved are - * still delivered first. + * tell an absent `HTTPS` record from a query that errored. + * + * `WrongConstant` is suppressed because okhttp's [TYPE_HTTPS] is the DNS wire value (65), the + * same as the platform's `DnsResolver.TYPE_HTTPS`. We use ours so the query stays valid on + * API 29+; `DnsResolver.TYPE_HTTPS` was only added in API 37. */ + @SuppressLint("WrongConstant") @Suppress("ktlint:standard:comment-wrapping") private fun queryServiceMetadata( query: Query, 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 4855eb891f11..40efd8bfb72c 100644 --- a/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/android/Android17SocketAdapter.kt +++ b/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/android/Android17SocketAdapter.kt @@ -60,7 +60,16 @@ class Android17SocketAdapter ) { SSLSockets.setUseSessionTickets(sslSocket, true) - val sslParameters = sslSocket.sslParameters + val sslParameters = + try { + sslSocket.sslParameters + } catch (iae: IllegalArgumentException) { + // Conscrypt's getSSLParameters() eagerly builds an SNIHostName from the peer host and + // throws "Invalid input to toASCII" (via IDN.toASCII) for names that violate STD3 ASCII, + // such as hostnames containing underscores. The JDK skips such names instead of throwing; + // wrap it as an IOException so the call fails cleanly. Mirrors Android10SocketAdapter. + throw IOException("Android internal error", iae) + } // Enable ALPN. sslParameters.applicationProtocols = Platform.alpnProtocolNames(protocols).toTypedArray()