Skip to content
Open
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
20 changes: 20 additions & 0 deletions samples/android/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Android ECH sample

This instrumentation test makes HTTPS requests with Encrypted Client Hello (ECH). The sample keeps
OkHttp's API 21 minimum and only enables ECH on Android 17 (API 37) or newer. The tests are skipped
on older devices.

The important pieces are:

* [`EchClient.kt`](src/androidTest/kotlin/okhttp3/sample/ech/EchClient.kt), which configures OkHttp
with either Android's resolver or DNS over HTTPS, makes a sample request, and tests the result.
* [`network_security_config.xml`](src/main/res/xml/network_security_config.xml), which opts the
sample domain into enabled ECH. Older Android versions ignore the unsupported element.

Run the test from the repository root:

```shell
./gradlew :samples:android:connectedDebugAndroidTest
```

The test checks that Cloudflare reports `sni=encrypted`.
40 changes: 40 additions & 0 deletions samples/android/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
@file:Suppress("UnstableApiUsage")

plugins {
id("okhttp.base-conventions")
id("com.android.library")
}

android {
namespace = "okhttp3.sample.ech"

compileSdk {
version = release(37)
}

defaultConfig {
minSdk = 21
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}

testOptions {
targetSdk = 37
}

compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}

lint {
abortOnError = true
}
}

dependencies {
androidTestImplementation(projects.okhttp)
androidTestImplementation(projects.okhttpDnsoverhttps)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.test.runner)
androidTestImplementation(libs.assertk)
}
119 changes: 119 additions & 0 deletions samples/android/src/androidTest/kotlin/okhttp3/sample/ech/EchClient.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
/*
* 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.sample.ech

import android.os.Build
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SdkSuppress
import assertk.assertThat
import assertk.assertions.contains
import assertk.assertions.isTrue
import okhttp3.Dns
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.android.EchAwareDns
import okhttp3.dnsoverhttps.DnsOverHttps
import org.junit.Test
import org.junit.runner.RunWith

@RunWith(AndroidJUnit4::class)
@SdkSuppress(minSdkVersion = 37)
class EchClientTest {
@Test
fun androidDns() {
testEch(Resolver.AndroidDns)
}

@Test
fun dnsOverHttps() {
testEch(Resolver.DnsOverHttps)
}

private fun testEch(resolver: Resolver) {
val client = echClient(resolver)

client.newCall(ECH_CHECK_REQUEST).execute().use { response ->
assertThat(response.isSuccessful).isTrue()
assertThat(response.body.string()).contains("sni=encrypted")
}
}
}

private enum class Resolver {
AndroidDns,
DnsOverHttps,
}

private fun echClient(resolver: Resolver): OkHttpClient {
return OkHttpClient
.Builder()
.apply {
if (Build.VERSION.SDK_INT >= 37) {
dns(
when (resolver) {
Resolver.AndroidDns -> EchAwareDns()
Resolver.DnsOverHttps -> echAwareDnsOverHttps()
},
)
}
}.build()
}

private fun echAwareDnsOverHttps(): Dns {
val bootstrapClient = OkHttpClient()
val dnsUrl = "https://1.1.1.1/dns-query".toHttpUrl()

fun dns(includeServiceMetadata: Boolean): DnsOverHttps =
DnsOverHttps
.Builder()
.client(bootstrapClient)
.url(dnsUrl)
.includeServiceMetadata(includeServiceMetadata)
.build()

return EchAwareDns(
echDns = dns(includeServiceMetadata = true),
addressOnlyDns = dns(includeServiceMetadata = false),
)
}

/*
DNS record: cloudflare-ech.com/104.18.10.118
DNS record: cloudflare-ech.com/104.18.11.118
DNS record: cloudflare-ech.com/2606:4700::6812:a76
DNS record: cloudflare-ech.com/2606:4700::6812:b76
DNS record: ServiceMetadata{
cloudflare-ech.com,
alpnIds=[h3, h2, http/1.1],
ipAddressHints=[104.18.10.118, 104.18.11.118, 2606:4700::6812:a76, 2606:4700::6812:b76],
echConfigList=0045fe0d0041da002000201e8ee5aa34c64a7439d45dfd1157ab774e2f70abccceef4cd24ae0998286cc760004000100010012636c6f7564666c6172652d6563682e636f6d0000
}
Parsed ECHConfigList:
config[0]:
version: 0xfe0d
contents length: 65
config ID: 218
KEM ID: 0x0020
public key: 1e8ee5aa34c64a7439d45dfd1157ab774e2f70abccceef4cd24ae0998286cc76
maximum name length: 0
public name: cloudflare-ech.com
cipher suites:
KDF 0x0001, AEAD 0x0001
extensions:
*/
private val ECH_CHECK_REQUEST =
Request("https://cloudflare-ech.com/cdn-cgi/trace".toHttpUrl())
11 changes: 11 additions & 0 deletions samples/android/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">

<uses-permission android:name="android.permission.INTERNET" />

<application
android:networkSecurityConfig="@xml/network_security_config"
tools:targetApi="n" />

</manifest>
8 changes: 8 additions & 0 deletions samples/android/src/main/res/xml/network_security_config.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config cleartextTrafficPermitted="false" />
<domain-config>
<domain includeSubdomains="true">cloudflare-ech.com</domain>
<domainEncryption mode="enabled" />
</domain-config>
</network-security-config>
1 change: 1 addition & 0 deletions settings.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ val sdkDir = localProperties.getProperty("sdk.dir")
if (androidHome != null || sdkDir != null) {
include(":android-test")
include(":android-test-app")
include(":samples:android")
}

enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS")
Expand Down