Skip to content
Next Next commit
Call decorator
  • Loading branch information
yschimke committed Aug 2, 2025
commit f39a19344ca99634f2db38db0fdbfc90c696bd04
1 change: 1 addition & 0 deletions android-test/build.gradle.kts
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/*
* 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.os.Build
import android.security.NetworkSecurityPolicy
import okhttp3.Call
import okhttp3.Request

class AlwaysHttps(
policy: Policy,
) : Call.Decorator {
val hostPolicy: HostPolicy = policy.hostPolicy

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

val updatedRequest =
if (request.url.scheme == "http" && !hostPolicy.isCleartextTrafficPermitted(request)) {
request
.newBuilder()
.url(
request.url
.newBuilder()
.scheme("https")
.build(),
).build()
} else {
request
}

return chain.proceed(updatedRequest)
}

fun interface HostPolicy {
fun isCleartextTrafficPermitted(request: Request): Boolean
}

enum class Policy {
Always {
override val hostPolicy: HostPolicy
get() = HostPolicy { false }
},
Manifest {
override val hostPolicy: HostPolicy
get() =
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.M) {
val networkSecurityPolicy = NetworkSecurityPolicy.getInstance()

if (Build.VERSION.SDK_INT > Build.VERSION_CODES.N) {
HostPolicy { networkSecurityPolicy.isCleartextTrafficPermitted(it.url.host) }
} else {
HostPolicy { networkSecurityPolicy.isCleartextTrafficPermitted }
}
} else {
HostPolicy { true }
}
}, ;

abstract val hostPolicy: HostPolicy
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
/*
* 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 java.util.logging.Logger
import mockwebserver3.MockResponse
import mockwebserver3.MockWebServer
import mockwebserver3.junit5.StartStop
import okhttp.android.test.AlwaysHttps.Policy
import okhttp3.OkHttpClient
import okhttp3.OkHttpClientTestRule
import okhttp3.Request
import okhttp3.tls.internal.TlsUtil.localhost
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Tag
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.RegisterExtension

@Tag("Slow")
class AndroidCallDecoratorTest {
@Suppress("RedundantVisibilityModifier")
@JvmField
@RegisterExtension
public val clientTestRule =
OkHttpClientTestRule().apply {
logger = Logger.getLogger(AndroidCallDecoratorTest::class.java.name)
}

private var client: OkHttpClient =
clientTestRule
.newClientBuilder()
.addCallDecorator(AlwaysHttps(Policy.Always))
.addCallDecorator(OffMainThread)
.build()

@StartStop
private val server = MockWebServer()

private val handshakeCertificates = localhost()

@Test
fun testSecureRequest() {
enableTls()

server.enqueue(MockResponse())

val request = Request.Builder().url(server.url("/")).build()

client.newCall(request).execute().use {
assertEquals(200, it.code)
}
}

@Test
fun testInsecureRequestChangedToSecure() {
enableTls()

server.enqueue(MockResponse())

val request =
Request
.Builder()
.url(
server
.url("/")
.newBuilder()
.scheme("http")
.build(),
).build()

client.newCall(request).execute().use {
assertEquals(200, it.code)
assertEquals("https", it.request.url.scheme)
}
}

private fun enableTls() {
client =
client
.newBuilder()
.sslSocketFactory(
handshakeCertificates.sslSocketFactory(),
handshakeCertificates.trustManager,
).build()
server.useHttps(handshakeCertificates.sslSocketFactory())
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/*
* 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.os.Looper
import okhttp3.Call
import okhttp3.Response

/**
* Sample of a Decorator that will fail any call on the Android Main thread.
*/
object OffMainThread : Call.Decorator {
override fun newCall(chain: Call.Chain): Call = StrictModeCall(chain.proceed(chain.request))

private class StrictModeCall(
private val delegate: Call,
) : Call by delegate {
override fun execute(): Response {
if (Looper.getMainLooper() === Looper.myLooper()) {
throw IllegalStateException("Network on main thread")
}

return delegate.execute()
}

override fun clone(): Call = StrictModeCall(delegate.clone())
}
}
12 changes: 12 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
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
29 changes: 29 additions & 0 deletions okhttp/src/commonJvmAndroid/kotlin/okhttp3/Call.kt
Original file line number Diff line number Diff line change
Expand Up @@ -96,4 +96,33 @@ interface Call : Cloneable {
fun interface Factory {
fun newCall(request: Request): Call
}

/**
* The equivalent of an Interceptor for [Call.Factory], but supported directly within [OkHttpClient] newCall.
*
* An [Interceptor] forms a chain as part of execution of a Call. Instead, Call.Decorator intercepts
* [Call.Factory.newCall] with similar flexibility to Application [OkHttpClient.interceptors].
*
* That is, it may do any of
* - Modify the request such as adding Tracing Context
* - Wrap the [Call] returned
* - Return some [Call] implementation that will immediately fail avoiding network calls based on network or
* authentication state.
* - Redirect the [Call], such as using an alternative [Call.Factory].
* - Defer execution, something not safe in an Interceptor.
*
* It should not throw an exception, instead it should return a Call that will fail on [Call.execute].
*
* A Decorator that changes the OkHttpClient should typically retain later decorators in the new client.
*/
fun interface Decorator {
fun newCall(chain: Chain): Call
}

interface Chain {
val client: OkHttpClient
val request: Request

fun proceed(request: Request): Call
}
}
Loading
Loading