Skip to content
Draft
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
6 changes: 4 additions & 2 deletions android-test-app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ plugins {
}

android {
compileSdk = 36
compileSdk {
version = release(37)
}

namespace = "okhttp.android.testapp"

Expand All @@ -18,7 +20,7 @@ android {

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

Expand Down
22 changes: 20 additions & 2 deletions android-test/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ plugins {
}

android {
compileSdk = 36
compileSdk {
version = release(37)
}

namespace = "okhttp.android.test"

Expand Down Expand Up @@ -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",
)
}
}


Expand Down
1 change: 1 addition & 0 deletions android-test/src/test/resources/robolectric.properties
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
sdk=36
2 changes: 1 addition & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import okhttp3.Response
import okhttp3.dnsoverhttps.DnsOverHttps.Companion.DNS_MESSAGE
import okhttp3.dnsoverhttps.DnsOverHttps.Companion.MAX_RESPONSE_SIZE
import okhttp3.internal.OkHttpInternalApi
import okhttp3.internal.dns.DnsCallStateMachine
import okhttp3.internal.dns.DnsMessage
import okhttp3.internal.dns.DnsMessageReader
import okhttp3.internal.platform.Platform
Expand Down
2 changes: 1 addition & 1 deletion okhttp/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ kotlin {
android {
namespace = "okhttp.okhttp3"
compileSdk {
version = release(36)
version = release(37)
}
minSdk = 21

Expand Down
211 changes: 211 additions & 0 deletions okhttp/src/androidMain/kotlin/okhttp3/android/AndroidDns.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
/*
* 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.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 java.util.concurrent.atomic.AtomicBoolean
import okhttp3.Dns
import okhttp3.internal.SuppressSignatureCheck
import okhttp3.internal.concurrent.Task
import okhttp3.internal.concurrent.TaskRunner
import okhttp3.internal.dns.DnsCallStateMachine
import okhttp3.internal.dns.DnsMessage
import okhttp3.internal.dns.DnsMessageReader
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.
*/
@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.

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.

I wonder if there’s a way to phrase this documentation to encourage people to keep it on.

‘Set this to false to omit the service metadata query. This will disable privacy features in the HTTPS call.’

@yschimke yschimke Jul 21, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I think maybe we end up defaulting to the networkSecurityConfig, but let developers opt-out, or opt-in.

For now, likely opt-in while we confirm it works.

GREASE means it should never cause failures just by being enabled.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I'll ask tomorrow if that mode still exists.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Yep, I'll update the wording. But also think if we want this we should make AndroidDns the default on API 37+ and when network security policy isn't globally disabled?

I think effectively the modes are (potentially per host)

UNSPECIFIED
DISABLED
ENABLED

So maybe that default once we are confident is the right option?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

*/
private val includeServiceMetadata: Boolean = true,
// Runs inline; the executor only hands off DnsResolver's callbacks.
private val executor: Executor = Executor { it.run() },
) : Dns {
/**
* 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<InetAddress> =
AndroidDnsCall(Dns.Request(hostname), includeServiceMetadata = false)
.execute()
.filterIsInstance<Dns.Record.IpAddress>()
.map { it.address }

override fun newCall(request: Dns.Request): Dns.Call = AndroidDnsCall(request, includeServiceMetadata = includeServiceMetadata)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

@swankjesse should includeServiceMetadata move to Dns.Request? so we can honour NetworkSecurityPolicy?

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.

I don't think so. I consider that to be a property of the service and not the data.


private inner class AndroidDnsCall(
override val request: Dns.Request,
includeServiceMetadata: Boolean,
) : Dns.Call,
DnsCallStateMachine.Transport<AndroidDnsCall.Query> {
Comment thread
yschimke marked this conversation as resolved.
private val stateMachine =
DnsCallStateMachine(
transport = this,
call = this,
canceledException = null,
// A single `A` query to represent the blocking InetAddress call for both.
Comment thread
yschimke marked this conversation as resolved.
includeIPv6 = false,
includeServiceMetadata = includeServiceMetadata,
)

override fun enqueue(callback: Dns.Callback) {
stateMachine.start(callback)
}

override fun cancel() {
stateMachine.cancel()
}

override fun isCanceled(): Boolean = stateMachine.canceled

override fun newQuery(dnsMessage: DnsMessage) = Query(dnsMessage)

override fun enqueue(query: Query) {
when (query.type) {
TYPE_A -> resolveAddresses(query)
else -> queryServiceMetadata(query)
}
}

/**
* Cancels a query. The system resolver doesn't call back once canceled, so this completes the
* query itself to keep the state machine from stalling.
Comment thread
yschimke marked this conversation as resolved.
*/
override fun cancel(query: Query) {
query.cancellationSignal?.cancel()
query.complete { stateMachine.onQueryFailure(query, IOException("canceled")) }
}

/**
* Resolves IP addresses through the system resolver. This is a blocking call, so it runs on a
* [TaskRunner] thread. When canceled, it still runs to completion but its result is discarded.
*/
private fun resolveAddresses(query: Query) {
TaskRunner.INSTANCE.newQueue().schedule(
object : Task("${request.hostname} address lookup", cancelable = false) {
override fun runOnce(): Long {
try {
val addresses =
when (network) {
null -> InetAddress.getAllByName(request.hostname)
else -> network.getAllByName(request.hostname)
}
val records = addresses.map { Dns.Record.IpAddress(request.hostname, it) }
query.complete { stateMachine.onQueryRecords(query, records) }

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

} catch (e: UnknownHostException) {
query.complete { stateMachine.onQueryFailure(query, 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 [Dns.Callback] rather than swallowed, so callers can tell an
* absent `HTTPS` record from a query that errored. Note that any addresses already resolved
* are still delivered first.
*/
@Suppress("ktlint:standard:comment-wrapping")
private fun queryServiceMetadata(query: Query) {
val queryCallback =
object : DnsResolver.Callback<ByteArray> {
override fun onAnswer(
answer: ByteArray,
rcode: Int,
) {
val message =
try {
DnsMessageReader(Buffer().write(answer)).read()
} catch (e: IOException) {
return query.complete { stateMachine.onQueryFailure(query, e) }
}

// The state machine turns a non-success rcode into a failure of its own.
query.complete { stateMachine.onQueryResponse(query, message) }
}

override fun onError(e: DnsResolver.DnsException) {
query.complete {
stateMachine.onQueryFailure(
query,
IOException("HTTPS query failed with code ${e.code}", e),
)
}
}
}

try {
dnsResolver.rawQuery(

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.

Nice

/* network = */ network,
/* domain = */ request.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 complete the query here or the state machine never terminates.
query.complete { stateMachine.onQueryFailure(query, IOException(e)) }
}
}

/** One outstanding query. Each completes the state machine's query at most once. */
inner class Query(
dnsMessage: DnsMessage,
) {
val type: Int = dnsMessage.questions.single().type
Comment thread
yschimke marked this conversation as resolved.

/** Only the `HTTPS` query reaches [DnsResolver], which is the API that takes a signal. */
val cancellationSignal = if (type == TYPE_A) null else CancellationSignal()
private val completed = AtomicBoolean()

fun complete(block: () -> Unit) {
if (completed.compareAndSet(false, true)) block()
Comment thread
yschimke marked this conversation as resolved.
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
Loading
Loading