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
5 changes: 4 additions & 1 deletion android-test/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ android {
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
testInstrumentationRunnerArguments += mapOf(
"runnerBuilder" to "de.mannodermaus.junit5.AndroidJUnit5Builder",
"notPackage" to "org.bouncycastle",
"notPackage" to "org.bouncycastle,com.google.common",
"configurationParameters" to "junit.jupiter.extensions.autodetection.enabled=true"
)
}
Expand Down Expand Up @@ -65,6 +65,7 @@ dependencies {
"friendsImplementation"(projects.okhttpDnsoverhttps)

testImplementation(projects.okhttp)
testImplementation(projects.okhttpCoroutines)
testImplementation(libs.junit)
testImplementation(libs.junit.ktx)
testImplementation(libs.assertk)
Expand Down Expand Up @@ -104,6 +105,8 @@ dependencies {
androidTestImplementation(libs.squareup.moshi)
androidTestImplementation(libs.squareup.moshi.kotlin)
androidTestImplementation(libs.squareup.okio.fakefilesystem)
androidTestImplementation(libs.kotlinx.coroutines.test)
androidTestImplementation(projects.okhttpCoroutines)

androidTestImplementation(libs.androidx.test.runner)
androidTestImplementation(libs.junit.jupiter.api)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
/*
* Copyright (C) 2025 Block, Inc.
*
* 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.content.Context
import android.net.http.ConnectionMigrationOptions
import android.net.http.ConnectionMigrationOptions.MIGRATION_OPTION_ENABLED
import android.net.http.DnsOptions
import android.net.http.DnsOptions.DNS_OPTION_ENABLED
import android.net.http.HttpEngine
import android.net.http.QuicOptions
import androidx.test.core.app.ApplicationProvider
import androidx.test.filters.SdkSuppress
import kotlinx.coroutines.test.runTest
import okhttp3.Cache
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.android.httpengine.HttpEngineCallDecorator.Companion.callDecorator
import okhttp3.coroutines.executeAsync
import okio.Path.Companion.toPath
import okio.fakefilesystem.FakeFileSystem
import org.junit.Test

@SdkSuppress(minSdkVersion = 34)
class HttpEngineBridgeTest {
val context = ApplicationProvider.getApplicationContext<Context>()

val httpEngine =
HttpEngine
.Builder(context)
.setStoragePath(
context.filesDir
.resolve("httpEngine")
.apply {
mkdirs()
}.path,
).setConnectionMigrationOptions(
ConnectionMigrationOptions
.Builder()
.setAllowNonDefaultNetworkUsage(MIGRATION_OPTION_ENABLED)
.setDefaultNetworkMigration(MIGRATION_OPTION_ENABLED)
.setPathDegradationMigration(MIGRATION_OPTION_ENABLED)
.build(),
).addQuicHint("www.google.com", 443, 443)
.addQuicHint("google.com", 443, 443)
.setDnsOptions(
DnsOptions
.Builder()
.setPersistHostCache(DNS_OPTION_ENABLED)
.setPreestablishConnectionsToStaleDnsResults(DNS_OPTION_ENABLED)
.setUseHttpStackDnsResolver(DNS_OPTION_ENABLED)
.setStaleDnsOptions(
DnsOptions.StaleDnsOptions
.Builder()
.setUseStaleOnNameNotResolved(DNS_OPTION_ENABLED)
.build(),
).build(),
).setEnableQuic(true)
.setQuicOptions(
QuicOptions
.Builder()
.addAllowedQuicHost("www.google.com")
.addAllowedQuicHost("google.com")
.build(),
).build()

var client =
OkHttpClient
.Builder()
.addCallDecorator(httpEngine.callDecorator)
.build()

val imageUrls =
listOf(
"https://storage.googleapis.com/cronet/sun.jpg",
"https://storage.googleapis.com/cronet/flower.jpg",
"https://storage.googleapis.com/cronet/chair.jpg",
"https://storage.googleapis.com/cronet/white.jpg",
"https://storage.googleapis.com/cronet/moka.jpg",
"https://storage.googleapis.com/cronet/walnut.jpg",
).map { it.toHttpUrl() }

@Test
fun testNewCall() =
runTest {
val call = client.newCall(Request("https://google.com/robots.txt".toHttpUrl()))

val response = call.executeAsync()

println(response.body.string().take(40))

val call2 = client.newCall(Request("https://google.com/robots.txt".toHttpUrl()))

val response2 = call2.executeAsync()

println(response2.body.string().take(40))
println(response2.protocol)
}

@Test
fun testWithCache() =
runTest {
client =
client
.newBuilder()
.cache(Cache(FakeFileSystem(), "/cache".toPath(), 100_000_000))
.build()

repeat(10) {
imageUrls.forEach {
val call = client.newCall(Request(it))

val response = call.executeAsync()

println(
"${response.request.url} cached=${response.cacheResponse != null} " +
response.body
.byteString()
.md5()
.hex(),
)
}
}
}
}
2 changes: 2 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ com-squareup-moshi = "1.15.2"
com-squareup-okio = "3.16.0"
de-mannodermaus-junit5 = "1.8.0"
graalvm = "24.2.2"
guava = "33.4.8-android"
#noinspection UnusedVersionCatalogEntry
junit-platform = "1.13.4"
kotlinx-serialization = "1.9.0"
Expand Down Expand Up @@ -62,6 +63,7 @@ gradlePlugin-mavenPublish = "com.vanniktech:gradle-maven-publish-plugin:0.34.0"
gradlePlugin-mavenSympathy = "io.github.usefulness.maven-sympathy:io.github.usefulness.maven-sympathy.gradle.plugin:0.3.0"
gradlePlugin-shadow = "com.gradleup.shadow:shadow-gradle-plugin:9.0.2"
gradlePlugin-spotless = "com.diffplug.spotless:spotless-plugin-gradle:7.2.1"
guava = { module = "com.google.guava:guava", version.ref = "guava" }
hamcrestLibrary = "org.hamcrest:hamcrest-library:3.0"
httpClient5 = "org.apache.httpcomponents.client5:httpclient5:5.5"
#noinspection NewerVersionAvailable
Expand Down
42 changes: 42 additions & 0 deletions okhttp/api/android/okhttp.api
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,16 @@ public abstract interface class okhttp3/Call : java/lang/Cloneable {
public abstract fun timeout ()Lokio/Timeout;
}

public abstract interface class okhttp3/Call$Chain {
public abstract fun getClient ()Lokhttp3/OkHttpClient;
public abstract fun getRequest ()Lokhttp3/Request;
public abstract fun proceed (Lokhttp3/Request;)Lokhttp3/Call;
}

public abstract interface class okhttp3/Call$Decorator {
public abstract fun newCall (Lokhttp3/Call$Chain;)Lokhttp3/Call;
}

public abstract interface class okhttp3/Call$Factory {
public abstract fun newCall (Lokhttp3/Request;)Lokhttp3/Call;
}
Expand Down Expand Up @@ -902,6 +912,7 @@ public class okhttp3/OkHttpClient : okhttp3/Call$Factory, okhttp3/WebSocket$Fact
public final fun fastFallback ()Z
public final fun followRedirects ()Z
public final fun followSslRedirects ()Z
public final fun getCallDecorators ()Ljava/util/List;
public final fun hostnameVerifier ()Ljavax/net/ssl/HostnameVerifier;
public final fun interceptors ()Ljava/util/List;
public final fun minWebSocketMessageToCompress ()J
Expand All @@ -927,6 +938,7 @@ public final class okhttp3/OkHttpClient$Builder {
public final fun -addInterceptor (Lkotlin/jvm/functions/Function1;)Lokhttp3/OkHttpClient$Builder;
public final fun -addNetworkInterceptor (Lkotlin/jvm/functions/Function1;)Lokhttp3/OkHttpClient$Builder;
public fun <init> ()V
public final fun addCallDecorator (Lokhttp3/Call$Decorator;)Lokhttp3/OkHttpClient$Builder;
public final fun addInterceptor (Lokhttp3/Interceptor;)Lokhttp3/OkHttpClient$Builder;
public final fun addNetworkInterceptor (Lokhttp3/Interceptor;)Lokhttp3/OkHttpClient$Builder;
public final fun authenticator (Lokhttp3/Authenticator;)Lokhttp3/OkHttpClient$Builder;
Expand Down Expand Up @@ -1274,3 +1286,33 @@ public abstract class okhttp3/WebSocketListener {
public fun onOpen (Lokhttp3/WebSocket;Lokhttp3/Response;)V
}

public final class okhttp3/android/httpengine/HttpEngineCallDecorator : okhttp3/Call$Decorator {
public static final field Companion Lokhttp3/android/httpengine/HttpEngineCallDecorator$Companion;
public fun <init> (Landroid/net/http/HttpEngine;Lkotlin/jvm/functions/Function1;)V
public synthetic fun <init> (Landroid/net/http/HttpEngine;Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
public fun newCall (Lokhttp3/Call$Chain;)Lokhttp3/Call;
}

public final class okhttp3/android/httpengine/HttpEngineCallDecorator$Companion {
public final fun getCallDecorator (Landroid/net/http/HttpEngine;)Lokhttp3/android/httpengine/HttpEngineCallDecorator;
}

public final class okhttp3/android/httpengine/HttpEngineCallDecorator$HttpEngineCall : okhttp3/Call {
public fun <init> (Lokhttp3/android/httpengine/HttpEngineCallDecorator;Lokhttp3/Call;)V
public fun cancel ()V
public synthetic fun clone ()Ljava/lang/Object;
public fun clone ()Lokhttp3/Call;
public fun enqueue (Lokhttp3/Callback;)V
public fun execute ()Lokhttp3/Response;
public final fun getHttpEngine ()Landroid/net/http/HttpEngine;
public final fun getRealCall ()Lokhttp3/Call;
public fun isCanceled ()Z
public fun isExecuted ()Z
public fun request ()Lokhttp3/Request;
public fun timeout ()Lokio/Timeout;
}

public final class okhttp3/android/httpengine/HttpEngineTimeoutException : java/io/IOException {
public fun <init> ()V
}

12 changes: 12 additions & 0 deletions okhttp/api/jvm/okhttp.api
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,16 @@ public abstract interface class okhttp3/Call : java/lang/Cloneable {
public abstract fun timeout ()Lokio/Timeout;
}

public abstract interface class okhttp3/Call$Chain {
public abstract fun getClient ()Lokhttp3/OkHttpClient;
public abstract fun getRequest ()Lokhttp3/Request;
public abstract fun proceed (Lokhttp3/Request;)Lokhttp3/Call;
}

public abstract interface class okhttp3/Call$Decorator {
public abstract fun newCall (Lokhttp3/Call$Chain;)Lokhttp3/Call;
}

public abstract interface class okhttp3/Call$Factory {
public abstract fun newCall (Lokhttp3/Request;)Lokhttp3/Call;
}
Expand Down Expand Up @@ -901,6 +911,7 @@ public class okhttp3/OkHttpClient : okhttp3/Call$Factory, okhttp3/WebSocket$Fact
public final fun fastFallback ()Z
public final fun followRedirects ()Z
public final fun followSslRedirects ()Z
public final fun getCallDecorators ()Ljava/util/List;
public final fun hostnameVerifier ()Ljavax/net/ssl/HostnameVerifier;
public final fun interceptors ()Ljava/util/List;
public final fun minWebSocketMessageToCompress ()J
Expand All @@ -926,6 +937,7 @@ public final class okhttp3/OkHttpClient$Builder {
public final fun -addInterceptor (Lkotlin/jvm/functions/Function1;)Lokhttp3/OkHttpClient$Builder;
public final fun -addNetworkInterceptor (Lkotlin/jvm/functions/Function1;)Lokhttp3/OkHttpClient$Builder;
public fun <init> ()V
public final fun addCallDecorator (Lokhttp3/Call$Decorator;)Lokhttp3/OkHttpClient$Builder;
public final fun addInterceptor (Lokhttp3/Interceptor;)Lokhttp3/OkHttpClient$Builder;
public final fun addNetworkInterceptor (Lokhttp3/Interceptor;)Lokhttp3/OkHttpClient$Builder;
public final fun authenticator (Lokhttp3/Authenticator;)Lokhttp3/OkHttpClient$Builder;
Expand Down
1 change: 1 addition & 0 deletions okhttp/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ kotlin {
compileOnly(libs.conscrypt.openjdk)
implementation(libs.androidx.annotation)
implementation(libs.androidx.startup.runtime)
implementation(libs.guava)
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package okhttp3.android.httpengine

import android.annotation.SuppressLint
import android.net.http.HttpEngine
import android.os.Build
import android.os.ext.SdkExtensions
import androidx.annotation.RequiresExtension
import okhttp3.Call
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.internal.SuppressSignatureCheck
import okhttp3.internal.cache.CacheInterceptor
import okhttp3.internal.http.BridgeInterceptor
import okhttp3.internal.http.RetryAndFollowUpInterceptor

@SuppressSignatureCheck
class HttpEngineCallDecorator(
internal val httpEngine: HttpEngine,
Copy link
Contributor

Choose a reason for hiding this comment

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

It is debatable whether this should use HttpEngine directly, or if it should use the Cronet API wrapper (which would then call HttpEngine). The Cronet API wrapper is more flexible and would make it possible to use other variants of Cronet (such as embedded of Play Services) if we ever want to, but the downside is it would involve OkHttp taking a new dependency on the (very small) cronet-api Maven package.

Copy link
Collaborator Author

@yschimke yschimke Sep 23, 2025

Choose a reason for hiding this comment

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

I think that dependency is the blocker here.

But also, a first version of this should probably be a v2.0 of the google/cronet-transport-for-okhttp

So that can do whatever makes sense?

Copy link
Contributor

Choose a reason for hiding this comment

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

It is debatable whether we want to let the user choose which HttpEngine to use. Arguably this code should instantiate HttpEngine and should configure it by interpreting OkHttpClient settings. Otherwise we may end up backing ourselves into a corner by providing too much flexibility to the user.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Yep, I'm not aware of what we expect an app developer to configure in typical cases?

Do we want them to allowlist their known hosts for HTTP/3?

But no objection from me. Your call.

private val useHttpEngine: (Request) -> Boolean = { isHttpEngineSupported() },
) : Call.Decorator {
// TODO make this work with forked clients
internal lateinit var client: OkHttpClient

@SuppressLint("NewApi")
private val httpEngineInterceptor = HttpEngineInterceptor(this)

override fun newCall(chain: Call.Chain): Call {
val call = httpEngineCall(chain)

return call ?: chain.proceed(chain.request)
}

@SuppressLint("NewApi")
@Synchronized
private fun httpEngineCall(chain: Call.Chain): Call? {
if (!useHttpEngine(chain.request)) {
return null
}

if (!::client.isInitialized) {
val originalClient = chain.client
client =
originalClient
.newBuilder()
.apply {
networkInterceptors.clear()

// TODO refactor RetryAndFollowUpInterceptor to not require the Client directly
interceptors += RetryAndFollowUpInterceptor(originalClient)
interceptors += BridgeInterceptor(originalClient.cookieJar)
interceptors += CacheInterceptor(originalClient.cache)
interceptors += httpEngineInterceptor
interceptors +=
Interceptor {
throw IllegalStateException("Shouldn't attempt to connect with OkHttp")
}

// Keep decorators after this one in the new client
callDecorators.subList(0, callDecorators.indexOf(this@HttpEngineCallDecorator) + 1).clear()
}.build()
}

return HttpEngineCall(client.newCall(chain.request))
}

@RequiresExtension(extension = Build.VERSION_CODES.S, version = 7)
inner class HttpEngineCall(
val realCall: Call,
) : Call by realCall {
val httpEngine: HttpEngine
get() = [email protected]

override fun cancel() {
realCall.cancel()
httpEngineInterceptor.cancelCall(realCall)
}
}

companion object {
val HttpEngine.callDecorator
get() = HttpEngineCallDecorator(this)
}
}

private fun isHttpEngineSupported(): Boolean =
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && SdkExtensions.getExtensionVersion(Build.VERSION_CODES.S) >= 7
Loading
Loading