Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,12 @@ tasks.withType<Test> {

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<Test>().configureEach {
Expand Down
2 changes: 1 addition & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
13 changes: 13 additions & 0 deletions okhttp/api/android/okhttp.api
Original file line number Diff line number Diff line change
Expand Up @@ -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 <init> ()V
public fun <init> (Lokhttp3/Dns;Lokhttp3/Dns;Landroid/security/NetworkSecurityPolicy;)V
public synthetic fun <init> (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;
}

4 changes: 4 additions & 0 deletions okhttp/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great comment

jvmArgs("--add-exports=java.base/jdk.internal.access=ALL-UNNAMED")
}
}
}
Expand Down
199 changes: 199 additions & 0 deletions okhttp/src/androidHostTest/kotlin/okhttp3/android/EchAwareDnsTest.kt
Original file line number Diff line number Diff line change
@@ -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 {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Excellent

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()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Neat

}

/** 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<Dns.Record> =
echAwareDns(newNetworkSecurityPolicy("publicobject.com" to domainEncryptionMode))
.recordsFor("publicobject.com")

private fun Dns.recordsFor(hostname: String): List<Dns.Record> = newCall(Dns.Request(hostname)).execute()

private fun List<Dns.Record>.addresses() = filterIsInstance<Dns.Record.IpAddress>().map { it.address }

private fun List<Dns.Record>.echConfigLists() = filterIsInstance<Dns.Record.ServiceMetadata>().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,
),
)
}
Original file line number Diff line number Diff line change
@@ -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<String, Int> = 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<String, Int>): NetworkSecurityPolicy {
val policy = Shadow.newInstanceOf(NetworkSecurityPolicy::class.java)
Shadow.extract<ShadowNetworkSecurityPolicy>(policy).domainEncryptionModes = domainEncryptionModes.toMap()
return policy
}
}
}
8 changes: 4 additions & 4 deletions okhttp/src/androidMain/kotlin/okhttp3/android/AndroidDns.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading
Loading