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/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 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..c7546980c359 --- /dev/null +++ b/okhttp/src/androidMain/kotlin/okhttp3/android/AndroidDns.kt @@ -0,0 +1,223 @@ +/* + * 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) + + 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}") + } + } + + /** + * 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_HTTPS) CancellationSignal() else null + } + } + +/** + * 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"