diff --git a/android-test/build.gradle.kts b/android-test/build.gradle.kts index 60e705263d64..7258aef6819f 100644 --- a/android-test/build.gradle.kts +++ b/android-test/build.gradle.kts @@ -66,6 +66,7 @@ dependencies { "friendsImplementation"(projects.okhttpDnsoverhttps) testImplementation(projects.okhttp) + testImplementation(projects.okhttpCoroutines) testImplementation(libs.junit) testImplementation(libs.junit.ktx) testImplementation(libs.assertk) diff --git a/android-test/src/androidTest/java/okhttp/android/test/AndroidNetworkPinningTest.kt b/android-test/src/androidTest/java/okhttp/android/test/AndroidNetworkPinningTest.kt new file mode 100644 index 000000000000..4b35857ef21b --- /dev/null +++ b/android-test/src/androidTest/java/okhttp/android/test/AndroidNetworkPinningTest.kt @@ -0,0 +1,126 @@ +/* + * Copyright (C) 2025 Square, 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.ConnectivityManager +import android.net.Network +import android.net.NetworkRequest +import android.os.Build +import androidx.test.core.app.ApplicationProvider +import androidx.test.filters.SdkSuppress +import mockwebserver3.MockResponse +import mockwebserver3.MockWebServer +import mockwebserver3.junit5.StartStop +import okhttp3.OkHttpClient +import okhttp3.OkHttpClientTestRule +import okhttp3.Request +import okhttp3.android.AndroidNetworkPinning +import okhttp3.internal.connection.RealCall +import okhttp3.internal.platform.PlatformRegistry +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotEquals +import org.junit.jupiter.api.Assumptions.assumeTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Tag +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.RegisterExtension + +@Tag("Slow") +@SdkSuppress(minSdkVersion = Build.VERSION_CODES.Q) +class AndroidNetworkPinningTest { + @Suppress("RedundantVisibilityModifier") + @JvmField + @RegisterExtension + public val clientTestRule = OkHttpClientTestRule() + + val applicationContext = ApplicationProvider.getApplicationContext() + val connectivityManager = applicationContext.getSystemService(ConnectivityManager::class.java) + + val pinning = AndroidNetworkPinning() + + private var client: OkHttpClient = + clientTestRule + .newClientBuilder() + .addInterceptor(pinning) + .addInterceptor { + it.proceed( + it + .request() + .newBuilder() + .header("second-decorator", "true") + .build(), + ) + }.addInterceptor { + val call = (it.call() as RealCall) + val dns = call.client.dns + it + .proceed(it.request()) + .newBuilder() + .header("used-dns", dns.javaClass.simpleName) + .build() + }.build() + + @StartStop + private val server = MockWebServer() + + @BeforeEach + fun setup() { + // Needed because of Platform.resetForTests + PlatformRegistry.applicationContext = applicationContext + + connectivityManager.registerNetworkCallback(NetworkRequest.Builder().build(), pinning.networkCallback) + } + + @Test + fun testDefaultRequest() { + server.enqueue(MockResponse(200, body = "Hello")) + + val request = Request.Builder().url(server.url("/")).build() + + val response = client.newCall(request).execute() + + response.use { + assertEquals(200, response.code) + assertNotEquals("AndroidDns", response.header("used-dns")) + assertEquals("true", response.request.header("second-decorator")) + } + } + + @Test + fun testPinnedRequest() { + server.enqueue(MockResponse(200, body = "Hello")) + + val network = connectivityManager.activeNetwork + + assumeTrue(network != null) + + val request = + Request + .Builder() + .url(server.url("/")) + .tag(network) + .build() + + val response = client.newCall(request).execute() + + response.use { + assertEquals(200, response.code) + assertEquals("AndroidDns", response.header("used-dns")) + assertEquals("true", response.request.header("second-decorator")) + } + } +} diff --git a/okhttp/api/android/okhttp.api b/okhttp/api/android/okhttp.api index 0cc0e076195d..504a69bc0fd7 100644 --- a/okhttp/api/android/okhttp.api +++ b/okhttp/api/android/okhttp.api @@ -326,6 +326,14 @@ public final class okhttp3/CipherSuite$Companion { public final fun forJavaName (Ljava/lang/String;)Lokhttp3/CipherSuite; } +public abstract class okhttp3/ClientForkingInterceptor : okhttp3/Interceptor { + public fun ()V + public abstract fun buildForKey (Lokhttp3/OkHttpClient$Builder;Ljava/lang/Object;)Lokhttp3/OkHttpClient; + public abstract fun clientKey (Lokhttp3/Request;)Ljava/lang/Object; + public fun intercept (Lokhttp3/Interceptor$Chain;)Lokhttp3/Response; + public final fun removeClient (Ljava/lang/Object;)V +} + public class okhttp3/CompressionInterceptor : okhttp3/Interceptor { public fun ([Lokhttp3/CompressionInterceptor$DecompressionAlgorithm;)V public final fun getAlgorithms ()[Lokhttp3/CompressionInterceptor$DecompressionAlgorithm; @@ -527,6 +535,9 @@ public abstract interface class okhttp3/EventListener$Factory { public abstract fun create (Lokhttp3/Call;)Lokhttp3/EventListener; } +public abstract interface annotation class okhttp3/ExperimentalOkHttpApi : java/lang/annotation/Annotation { +} + public final class okhttp3/FormBody : okhttp3/RequestBody { public static final field Companion Lokhttp3/FormBody$Companion; public final fun -deprecated_size ()I @@ -1281,3 +1292,12 @@ public abstract class okhttp3/WebSocketListener { public fun onOpen (Lokhttp3/WebSocket;Lokhttp3/Response;)V } +public final class okhttp3/android/AndroidNetworkPinning : okhttp3/ClientForkingInterceptor { + public fun ()V + public fun buildForKey (Lokhttp3/OkHttpClient$Builder;Landroid/net/Network;)Lokhttp3/OkHttpClient; + public synthetic fun buildForKey (Lokhttp3/OkHttpClient$Builder;Ljava/lang/Object;)Lokhttp3/OkHttpClient; + public fun clientKey (Lokhttp3/Request;)Landroid/net/Network; + public synthetic fun clientKey (Lokhttp3/Request;)Ljava/lang/Object; + public final fun getNetworkCallback ()Landroid/net/ConnectivityManager$NetworkCallback; +} + diff --git a/okhttp/api/jvm/okhttp.api b/okhttp/api/jvm/okhttp.api index d275ffa0f73b..f534ae4ca035 100644 --- a/okhttp/api/jvm/okhttp.api +++ b/okhttp/api/jvm/okhttp.api @@ -326,6 +326,14 @@ public final class okhttp3/CipherSuite$Companion { public final fun forJavaName (Ljava/lang/String;)Lokhttp3/CipherSuite; } +public abstract class okhttp3/ClientForkingInterceptor : okhttp3/Interceptor { + public fun ()V + public abstract fun buildForKey (Lokhttp3/OkHttpClient$Builder;Ljava/lang/Object;)Lokhttp3/OkHttpClient; + public abstract fun clientKey (Lokhttp3/Request;)Ljava/lang/Object; + public fun intercept (Lokhttp3/Interceptor$Chain;)Lokhttp3/Response; + public final fun removeClient (Ljava/lang/Object;)V +} + public class okhttp3/CompressionInterceptor : okhttp3/Interceptor { public fun ([Lokhttp3/CompressionInterceptor$DecompressionAlgorithm;)V public final fun getAlgorithms ()[Lokhttp3/CompressionInterceptor$DecompressionAlgorithm; @@ -527,6 +535,9 @@ public abstract interface class okhttp3/EventListener$Factory { public abstract fun create (Lokhttp3/Call;)Lokhttp3/EventListener; } +public abstract interface annotation class okhttp3/ExperimentalOkHttpApi : java/lang/annotation/Annotation { +} + public final class okhttp3/FormBody : okhttp3/RequestBody { public static final field Companion Lokhttp3/FormBody$Companion; public final fun -deprecated_size ()I diff --git a/okhttp/src/androidMain/kotlin/okhttp3/android/AndroidNetworkPinning.kt b/okhttp/src/androidMain/kotlin/okhttp3/android/AndroidNetworkPinning.kt new file mode 100644 index 000000000000..32486f3b4c09 --- /dev/null +++ b/okhttp/src/androidMain/kotlin/okhttp3/android/AndroidNetworkPinning.kt @@ -0,0 +1,53 @@ +/* + * Copyright (C) 2024 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 okhttp3.android + +import android.net.ConnectivityManager +import android.net.Network +import android.os.Build +import androidx.annotation.RequiresApi +import okhttp3.ClientForkingInterceptor +import okhttp3.ExperimentalOkHttpApi +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.android.internal.AndroidDns +import okhttp3.internal.SuppressSignatureCheck + +/** + * Decorator that supports Network Pinning on Android via Request tags. + */ +@OptIn(ExperimentalOkHttpApi::class) +@RequiresApi(Build.VERSION_CODES.Q) +@SuppressSignatureCheck +class AndroidNetworkPinning : ClientForkingInterceptor() { + /** ConnectivityManager.NetworkCallback that will clean up after networks are lost. */ + val networkCallback = + object : ConnectivityManager.NetworkCallback() { + override fun onLost(network: Network) { + removeClient(network) + } + } + + override fun OkHttpClient.Builder.buildForKey(key: Network): OkHttpClient = + dns(AndroidDns(key)) + .socketFactory(key.socketFactory) + .apply { + // Keep interceptors after this one in the new client + interceptors.subList(interceptors.indexOf(this@AndroidNetworkPinning) + 1, interceptors.size).clear() + }.build() + + override fun clientKey(request: Request): Network? = request.tag() +} diff --git a/okhttp/src/androidMain/kotlin/okhttp3/android/internal/AndroidDns.kt b/okhttp/src/androidMain/kotlin/okhttp3/android/internal/AndroidDns.kt new file mode 100644 index 000000000000..70fc149b0781 --- /dev/null +++ b/okhttp/src/androidMain/kotlin/okhttp3/android/internal/AndroidDns.kt @@ -0,0 +1,66 @@ +/* + * 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 okhttp3.android.internal + +import android.net.DnsResolver +import android.net.Network +import android.os.Build +import androidx.annotation.RequiresApi +import java.net.InetAddress +import java.net.UnknownHostException +import java.util.concurrent.CompletableFuture +import okhttp3.Dns +import okhttp3.internal.SuppressSignatureCheck + +@RequiresApi(Build.VERSION_CODES.Q) +@SuppressSignatureCheck +internal class AndroidDns( + val network: Network, +) : Dns { + // API 29+ + private val dnsResolver = DnsResolver.getInstance() + + override fun lookup(hostname: String): List { + // API 24+ + val result = CompletableFuture>() + + dnsResolver.query( + network, + hostname, + DnsResolver.FLAG_EMPTY, + { it.run() }, + null, + object : DnsResolver.Callback> { + override fun onAnswer( + answer: List, + rcode: Int, + ) { + result.complete(answer) + } + + override fun onError(error: DnsResolver.DnsException) { + result.completeExceptionally( + UnknownHostException(error.message).apply { + initCause(error) + }, + ) + } + }, + ) + + return result.get() + } +} diff --git a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/ClientForkingInterceptor.kt b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/ClientForkingInterceptor.kt new file mode 100644 index 000000000000..af5f185db76b --- /dev/null +++ b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/ClientForkingInterceptor.kt @@ -0,0 +1,41 @@ +package okhttp3 + +import java.io.IOException +import java.util.concurrent.ConcurrentHashMap +import okhttp3.internal.connection.RealCall + +@ExperimentalOkHttpApi +// Inspiration from https://publicobject.com/2017/04/02/a-clever-flawed-okhttp-interceptor-hack/ +// But behind an experimental but official API +abstract class ClientForkingInterceptor : Interceptor { + // TODO consider caching by client and cleaning up + private val forkedClients = ConcurrentHashMap() + + // TODO consider whether we need to address lifecycle of clients + // If someone else forks this client, should we know that we need a different pool? +// override fun onNewClientInstance(client: OkHttpClient): Interceptor { +// return this +// } + + fun removeClient(key: K) { + forkedClients.remove(key) + } + + override fun intercept(chain: Interceptor.Chain): Response { + val client = + (chain.call() as? RealCall)?.client ?: throw IOException("unable to access OkHttpClient") + + val key = clientKey(chain.request()) + + if (key == null) { + return chain.proceed(chain.request()) + } else { + val override = forkedClients.getOrPut(key) { client.newBuilder().buildForKey(key) } + return override.newCall(chain.request()).execute() + } + } + + abstract fun clientKey(request: Request): K? + + abstract fun OkHttpClient.Builder.buildForKey(key: K): OkHttpClient +} diff --git a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/ExperimentalOkHttpApi.kt b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/ExperimentalOkHttpApi.kt new file mode 100644 index 000000000000..082eeeec677e --- /dev/null +++ b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/ExperimentalOkHttpApi.kt @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2024 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 okhttp3 + +/** + * Marks declarations that are experimental and subject to change without following SemVer + * conventions. Both binary and source-incompatible changes are possible, including complete removal + * of the experimental API. + * + * Do not use these APIs in modules that may be executed using a version of OkHttp different from + * the version the module was compiled with. + * + * Do not use these APIs in published libraries. + * + * Do not use these APIs if you aren't willing to track changes to them. + */ +@MustBeDocumented +@Retention(value = AnnotationRetention.BINARY) +@Target( + AnnotationTarget.CLASS, + AnnotationTarget.ANNOTATION_CLASS, + AnnotationTarget.PROPERTY, + AnnotationTarget.FIELD, + AnnotationTarget.LOCAL_VARIABLE, + AnnotationTarget.VALUE_PARAMETER, + AnnotationTarget.CONSTRUCTOR, + AnnotationTarget.FUNCTION, + AnnotationTarget.PROPERTY_GETTER, + AnnotationTarget.PROPERTY_SETTER, + AnnotationTarget.TYPEALIAS, +) +@RequiresOptIn(level = RequiresOptIn.Level.ERROR) +public annotation class ExperimentalOkHttpApi