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..9cfd009e733b 100644 --- a/build-logic/src/main/kotlin/okhttp.testing-conventions.gradle.kts +++ b/build-logic/src/main/kotlin/okhttp.testing-conventions.gradle.kts @@ -46,6 +46,12 @@ tasks.withType { systemProperty("okhttp.platform", platform) systemProperty("junit.jupiter.extensions.autodetection.enabled", "true") + + if (testJavaVersion < 21) { + // Robolectric needs Java 21 to sandbox Android SDK 37. Tests configured only for SDKs + // outside this set are skipped rather than failing to create a sandbox. + systemProperty("robolectric.enabledSdks", (21..36).joinToString(",")) + } } tasks.withType().configureEach { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8f48c42d142e..c32debd0ef29 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -48,7 +48,7 @@ org-conscrypt = "2.6.1" org-junit-jupiter = "5.13.4" playservices-safetynet = "18.1.0" robolectric = "4.17-beta-2" -robolectric-android = "16-robolectric-13921718" +robolectric-android = "17-robolectric-15733970" serialization = "1.11.0" shadow-plugin = "9.5.1" signature-android-apilevel21 = "5.0.1_r2" diff --git a/okhttp/api/android/okhttp.api b/okhttp/api/android/okhttp.api index 7855a24ceb7f..c7f1534c713f 100644 --- a/okhttp/api/android/okhttp.api +++ b/okhttp/api/android/okhttp.api @@ -1389,3 +1389,16 @@ public final class okhttp3/android/AndroidDns : okhttp3/Dns { public fun newCall (Lokhttp3/Dns$Request;)Lokhttp3/Dns$Call; } +public final class okhttp3/android/EchAwareDns : okhttp3/Dns { + public static final field Companion Lokhttp3/android/EchAwareDns$Companion; + public fun ()V + public fun (Lokhttp3/Dns;Lokhttp3/Dns;Landroid/security/NetworkSecurityPolicy;)V + public synthetic fun (Lokhttp3/Dns;Lokhttp3/Dns;Landroid/security/NetworkSecurityPolicy;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun lookup (Ljava/lang/String;)Ljava/util/List; + public fun newCall (Lokhttp3/Dns$Request;)Lokhttp3/Dns$Call; +} + +public final class okhttp3/android/EchAwareDns$Companion { + public final fun forNetwork (Landroid/net/Network;)Lokhttp3/android/EchAwareDns; +} + diff --git a/okhttp/build.gradle.kts b/okhttp/build.gradle.kts index 15d24f092949..96866a1f2be6 100644 --- a/okhttp/build.gradle.kts +++ b/okhttp/build.gradle.kts @@ -334,6 +334,10 @@ afterEvaluate { // Work around robolectric requirements and limitations // https://cs.android.com/android-studio/platform/tools/base/+/mirror-goog-studio-main:build-system/gradle-core/src/main/java/com/android/build/gradle/tasks/factory/AndroidUnitTest.java;l=339 allJvmArgs = allJvmArgs.filter { !it.startsWith("--add-opens") } + } else { + // Robolectric's FileDescriptor interceptor reaches into jdk.internal.access, which isn't + // exported to the unnamed module. Android 17 (API 37) images hit it on startup. + jvmArgs("--add-exports=java.base/jdk.internal.access=ALL-UNNAMED") } } } diff --git a/okhttp/src/androidHostTest/kotlin/okhttp3/android/EchAwareDnsTest.kt b/okhttp/src/androidHostTest/kotlin/okhttp3/android/EchAwareDnsTest.kt new file mode 100644 index 000000000000..36b338b438a6 --- /dev/null +++ b/okhttp/src/androidHostTest/kotlin/okhttp3/android/EchAwareDnsTest.kt @@ -0,0 +1,199 @@ +/* + * 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.annotation.SuppressLint +import android.net.Network +import android.security.NetworkSecurityPolicy +import android.security.NetworkSecurityPolicy.DOMAIN_ENCRYPTION_MODE_DISABLED +import android.security.NetworkSecurityPolicy.DOMAIN_ENCRYPTION_MODE_ENABLED +import android.security.NetworkSecurityPolicy.DOMAIN_ENCRYPTION_MODE_OPPORTUNISTIC +import android.security.NetworkSecurityPolicy.DOMAIN_ENCRYPTION_MODE_UNKNOWN +import assertk.assertThat +import assertk.assertions.containsExactly +import assertk.assertions.isEmpty +import assertk.assertions.isEqualTo +import assertk.assertions.isFalse +import assertk.assertions.isTrue +import java.net.InetAddress +import okhttp3.Dns +import okhttp3.FakeDns +import okhttp3.android.ShadowNetworkSecurityPolicy.Companion.newNetworkSecurityPolicy +import okhttp3.internal.OkHttpInternalApi +import okhttp3.internal.SuppressSignatureCheck +import okhttp3.internal.dns.ResourceRecord +import okhttp3.internal.dns.execute +import okio.ByteString +import okio.ByteString.Companion.encodeUtf8 +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.shadows.ShadowNetwork + +@SuppressLint("NewApi") +@SuppressSignatureCheck +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [37], shadows = [ShadowNetworkSecurityPolicy::class]) +class EchAwareDnsTest { + private val echAddress = InetAddress.getByAddress("publicobject.com", byteArrayOf(1, 1, 1, 1)) + private val addressOnlyAddress = InetAddress.getByAddress("publicobject.com", byteArrayOf(2, 2, 2, 2)) + private val echConfigList = "ech config list".encodeUtf8() + + /** Carries service metadata, as [AndroidDns] does when it queries the `HTTPS` record. */ + private val echDns = + FakeDns().apply { + setRecords("publicobject.com", echAddress, echConfigList) + } + + private val addressOnlyDns = + FakeDns().apply { + setRecords("publicobject.com", addressOnlyAddress) + } + + @Test + fun encryptionEnabledUsesEchDns() { + val records = recordsFor(DOMAIN_ENCRYPTION_MODE_ENABLED) + + assertThat(records.addresses()).containsExactly(echAddress) + assertThat(records.echConfigLists()).containsExactly(echConfigList) + } + + @Test + fun encryptionOpportunisticUsesEchDns() { + val records = recordsFor(DOMAIN_ENCRYPTION_MODE_OPPORTUNISTIC) + + assertThat(records.addresses()).containsExactly(echAddress) + assertThat(records.echConfigLists()).containsExactly(echConfigList) + } + + @Test + fun encryptionDisabledUsesAddressOnlyDns() { + val records = recordsFor(DOMAIN_ENCRYPTION_MODE_DISABLED) + + assertThat(records.addresses()).containsExactly(addressOnlyAddress) + assertThat(records.echConfigLists()).isEmpty() + } + + @Test + fun encryptionUnknownUsesAddressOnlyDns() { + val records = recordsFor(DOMAIN_ENCRYPTION_MODE_UNKNOWN) + + assertThat(records.addresses()).containsExactly(addressOnlyAddress) + assertThat(records.echConfigLists()).isEmpty() + } + + /** Hosts the policy says nothing about don't pay for an `HTTPS` query. */ + @Test + fun unconfiguredHostUsesAddressOnlyDns() { + val records = echAwareDns(newNetworkSecurityPolicy()).recordsFor("publicobject.com") + + assertThat(records.addresses()).containsExactly(addressOnlyAddress) + assertThat(records.echConfigLists()).isEmpty() + } + + /** Each host is judged on its own policy, even though both are available from [echDns]. */ + @Test + fun policyIsPerHost() { + val deniedEchAddress = InetAddress.getByAddress("denied.example.com", byteArrayOf(1, 1, 1, 2)) + val deniedEchConfigList = "denied ech config list".encodeUtf8() + val deniedAddress = InetAddress.getByAddress("denied.example.com", byteArrayOf(2, 2, 2, 2)) + echDns.setRecords("denied.example.com", deniedEchAddress, deniedEchConfigList) + addressOnlyDns.setRecords("denied.example.com", deniedAddress) + + val dns = + echAwareDns( + newNetworkSecurityPolicy( + "publicobject.com" to DOMAIN_ENCRYPTION_MODE_ENABLED, + "denied.example.com" to DOMAIN_ENCRYPTION_MODE_DISABLED, + ), + ) + + val allowedRecords = dns.recordsFor("publicobject.com") + assertThat(allowedRecords.addresses()).containsExactly(echAddress) + assertThat(allowedRecords.echConfigLists()).containsExactly(echConfigList) + + val deniedRecords = dns.recordsFor("denied.example.com") + assertThat(deniedRecords.addresses()).containsExactly(deniedAddress) + assertThat(deniedRecords.echConfigLists()).isEmpty() + } + + /** [Dns.lookup] can't carry HTTPS records, so it never pays for the `HTTPS` query. */ + @Test + fun lookupAlwaysUsesAddressOnlyDns() { + val dns = echAwareDns(newNetworkSecurityPolicy("publicobject.com" to DOMAIN_ENCRYPTION_MODE_ENABLED)) + + assertThat(dns.lookup("publicobject.com")).containsExactly(addressOnlyAddress) + } + + /** Both arms of [EchAwareDns.forNetwork] resolve on the network, not just the ECH one. */ + @Test + fun forNetworkScopesBothArms() { + val network = ShadowNetwork.newInstance(1234) + + val dns = EchAwareDns.forNetwork(network) + + val echDns = dns.echDns as AndroidDns + assertThat(echDns.network).isEqualTo(network) + assertThat(echDns.includeServiceMetadata).isTrue() + + val addressOnlyDns = dns.addressOnlyDns as AndroidDns + assertThat(addressOnlyDns.network).isEqualTo(network) + assertThat(addressOnlyDns.includeServiceMetadata).isFalse() + } + + private fun recordsFor(domainEncryptionMode: Int): List = + echAwareDns(newNetworkSecurityPolicy("publicobject.com" to domainEncryptionMode)) + .recordsFor("publicobject.com") + + private fun Dns.recordsFor(hostname: String): List = newCall(Dns.Request(hostname)).execute() + + private fun List.addresses() = filterIsInstance().map { it.address } + + private fun List.echConfigLists() = filterIsInstance().mapNotNull { it.echConfigList } + + private fun echAwareDns(policy: NetworkSecurityPolicy) = + EchAwareDns( + echDns = echDns, + addressOnlyDns = addressOnlyDns, + policy = policy, + ) +} + +/** Serves [address] for [hostname], plus an `HTTPS` record when [echConfigList] is non-null. */ +private fun FakeDns.setRecords( + hostname: String, + address: InetAddress, + echConfigList: ByteString? = null, +) { + this[hostname] = + listOfNotNull( + echConfigList?.let { + ResourceRecord.Https( + name = hostname, + timeToLive = 5, + echConfigList = it, + ) + }, + ResourceRecord.IpAddress( + name = hostname, + timeToLive = 5, + address = address, + ), + ) +} diff --git a/okhttp/src/androidHostTest/kotlin/okhttp3/android/ShadowNetworkSecurityPolicy.kt b/okhttp/src/androidHostTest/kotlin/okhttp3/android/ShadowNetworkSecurityPolicy.kt new file mode 100644 index 000000000000..2d80157b0062 --- /dev/null +++ b/okhttp/src/androidHostTest/kotlin/okhttp3/android/ShadowNetworkSecurityPolicy.kt @@ -0,0 +1,46 @@ +/* + * 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.android + +import android.security.NetworkSecurityPolicy +import android.security.NetworkSecurityPolicy.DOMAIN_ENCRYPTION_MODE_UNKNOWN +import okhttp3.internal.SuppressSignatureCheck +import org.robolectric.annotation.Implementation +import org.robolectric.annotation.Implements +import org.robolectric.shadow.api.Shadow + +/** + * Gives tests a [NetworkSecurityPolicy] with domain encryption modes of their choosing. The + * platform constructor is private, so instances come from [newNetworkSecurityPolicy] rather than + * from a subclass. + */ +@SuppressSignatureCheck +@Implements(NetworkSecurityPolicy::class) +class ShadowNetworkSecurityPolicy { + var domainEncryptionModes: Map = mapOf() + + @Implementation + fun getDomainEncryptionMode(hostname: String): Int = domainEncryptionModes[hostname] ?: DOMAIN_ENCRYPTION_MODE_UNKNOWN + + companion object { + /** Returns a policy that reports [domainEncryptionModes], and [DOMAIN_ENCRYPTION_MODE_UNKNOWN] for other hosts. */ + fun newNetworkSecurityPolicy(vararg domainEncryptionModes: Pair): NetworkSecurityPolicy { + val policy = Shadow.newInstanceOf(NetworkSecurityPolicy::class.java) + Shadow.extract(policy).domainEncryptionModes = domainEncryptionModes.toMap() + return policy + } + } +} diff --git a/okhttp/src/androidMain/kotlin/okhttp3/android/AndroidDns.kt b/okhttp/src/androidMain/kotlin/okhttp3/android/AndroidDns.kt index ea884a7a77cc..2de449e62bc0 100644 --- a/okhttp/src/androidMain/kotlin/okhttp3/android/AndroidDns.kt +++ b/okhttp/src/androidMain/kotlin/okhttp3/android/AndroidDns.kt @@ -52,17 +52,17 @@ import okio.Buffer @RequiresApi(29) @SuppressSignatureCheck class AndroidDns( - private val dnsResolver: DnsResolver = DnsResolver.getInstance(), - private val network: Network? = null, + internal val dnsResolver: DnsResolver = DnsResolver.getInstance(), + internal val network: Network? = null, dnsCache: DnsCache = DnsCache(), /** * 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, + internal val includeServiceMetadata: Boolean = true, // Runs inline; the executor only hands off DnsResolver's callbacks. - private val executor: Executor = Executor { it.run() }, + internal val executor: Executor = Executor { it.run() }, ) : Dns { private val taskRunner: TaskRunner = TaskRunner.INSTANCE diff --git a/okhttp/src/androidMain/kotlin/okhttp3/android/EchAwareDns.kt b/okhttp/src/androidMain/kotlin/okhttp3/android/EchAwareDns.kt new file mode 100644 index 000000000000..591f2d3d4e5d --- /dev/null +++ b/okhttp/src/androidMain/kotlin/okhttp3/android/EchAwareDns.kt @@ -0,0 +1,91 @@ +/* + * 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.android + +import android.annotation.SuppressLint +import android.net.Network +import android.os.Build +import android.security.NetworkSecurityPolicy +import android.security.NetworkSecurityPolicy.DOMAIN_ENCRYPTION_MODE_ENABLED +import android.security.NetworkSecurityPolicy.DOMAIN_ENCRYPTION_MODE_OPPORTUNISTIC +import androidx.annotation.ChecksSdkIntAtLeast +import androidx.annotation.RequiresApi +import java.net.InetAddress +import okhttp3.Dns +import okhttp3.internal.SuppressSignatureCheck +import okhttp3.internal.platform.Platform.Companion.isAndroid + +/** + * ECH Aware DNS Wrapper for Android 37. + * + * Uses the [NetworkSecurityPolicy] to read the domain encryption settings for + * a given host. + * + * Usage: + * ``` + * val dns = EchAwareDns(AndroidDns()) + * val client = OkHttpClient.Builder().dns(dns).build() + * ``` + * + * @param echDns resolves hosts that the policy permits ECH for. It should carry service metadata, + * such as [AndroidDns] with `includeServiceMetadata` enabled. + * @param addressOnlyDns resolves hosts that the policy denies ECH for. + * The default, [Dns.SYSTEM], resolves on the default network. + * @param policy the source of each host's domain encryption mode. + */ +@SuppressLint("NewApi") +@SuppressSignatureCheck +class EchAwareDns + @RequiresApi(37) + constructor( + internal val echDns: Dns = AndroidDns(), + internal val addressOnlyDns: Dns = Dns.SYSTEM, + internal val policy: NetworkSecurityPolicy = NetworkSecurityPolicy.getInstance(), + ) : Dns { + // Safe to call lookup as it doesn't carry HTTPS records + override fun lookup(hostname: String): List = addressOnlyDns.lookup(hostname) + + override fun newCall(request: Dns.Request): Dns.Call { + val dns = if (echAllowed(request.hostname)) echDns else addressOnlyDns + + return dns.newCall(request) + } + + /** + * Allows avoiding waiting for HTTPS records, when we know that Android Conscrypt won't use them. + */ + private fun echAllowed(hostname: String): Boolean = + when (policy.getDomainEncryptionMode(hostname)) { + DOMAIN_ENCRYPTION_MODE_ENABLED, DOMAIN_ENCRYPTION_MODE_OPPORTUNISTIC -> true + else -> false + } + + @SuppressSignatureCheck + companion object { + /** Returns a [Dns] that resolves on [network]. */ + @RequiresApi(37) + fun forNetwork(network: Network): EchAwareDns = + EchAwareDns( + echDns = AndroidDns(network = network), + addressOnlyDns = AndroidDns(network = network, includeServiceMetadata = false), + ) + + @ChecksSdkIntAtLeast(api = 37) + internal val isSupported: Boolean = isAndroid && Build.VERSION.SDK_INT >= 37 + + internal fun buildIfSupported(): Dns? = if (isSupported) EchAwareDns() else null + } + } 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 d65df2df3df3..3633ab55801d 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 @@ -142,6 +143,11 @@ open class Platform { } } + /** + * Provides the default [Dns] for the system, by default [Dns.SYSTEM]. + */ + open fun platformDns(): Dns = Dns.SYSTEM + @Throws(IOException::class) open fun connectSocket( socket: Socket,