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
4 changes: 4 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,8 @@ yarn = "1.22.22"
ws = "8.20.1"
puppeteer = "25.0.4"

robolectric-android = "17-robolectric-15733970"

[libraries]

android-gradlePlugin = { module = "com.android.tools.build:gradle", version.ref = "android-gradlePlugin" }
Expand Down Expand Up @@ -255,6 +257,8 @@ mavenPublishing = { module = "com.vanniktech:gradle-maven-publish-plugin", versi

zstd-jni = { module = "com.github.luben:zstd-jni", version.ref = "zstd-jni" }

robolectric-android = { module = "org.robolectric:android-all", version.ref = "robolectric-android" }

[plugins]

kover = { id = "org.jetbrains.kotlinx.kover", version.ref = "kover" }
Expand Down
2 changes: 2 additions & 0 deletions ktor-client/ktor-client-android/api/ktor-client-android.api
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ public final class io/ktor/client/engine/android/AndroidEngineConfig : io/ktor/c
public final fun setRequestConfig (Lkotlin/jvm/functions/Function1;)V
public final fun setSocketTimeout (I)V
public final fun setSslManager (Lkotlin/jvm/functions/Function1;)V
public final fun useHttpEngine (Landroid/content/Context;Lkotlin/jvm/functions/Function1;)V
public static synthetic fun useHttpEngine$default (Lio/ktor/client/engine/android/AndroidEngineConfig;Landroid/content/Context;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)V
}

public final class io/ktor/client/engine/android/AndroidEngineContainer : io/ktor/client/HttpClientEngineContainer {
Expand Down
9 changes: 9 additions & 0 deletions ktor-client/ktor-client-android/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,24 @@ plugins {
id("test-server")
}

val isTestJdk21OrHigher = ktorBuild.jvmTestToolchain.get().canCompileOrRun(JavaLanguageVersion.of(21))

kotlin {
sourceSets {
jvmMain.dependencies {
api(projects.ktorClientCore)
// Used to compile against Android SDK without the android plugin
compileOnly(libs.robolectric.android)
}
jvmTest.dependencies {
implementation(projects.ktorClientTests)
implementation(projects.ktorNetworkTls)
implementation(projects.ktorNetworkTlsCertificates)
compileOnly(libs.robolectric.android)

if (isTestJdk21OrHigher) {
runtimeOnly(libs.robolectric.android)
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/*
* Copyright 2014-2025 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license.
*/

package io.ktor.client.engine.android

import android.net.http.HttpEngine
import android.net.http.ProxyOptions
import android.util.Pair
import kotlinx.atomicfu.atomic
import java.net.HttpURLConnection
import java.net.InetSocketAddress
import java.net.Proxy
import java.net.URI

// @RequiresExtension(extension = Build.VERSION_CODES.S, version = 7)
internal class AndroidNetHttpEngineFactory(private val config: AndroidEngineConfig) : URLConnectionFactory {
private val engineDelegate = lazy { buildEngine() }
private val engine by engineDelegate
private val closed = atomic(false)

private fun buildEngine(): HttpEngine {
val ctx = checkNotNull(config.context) {
"AndroidEngineConfig.context must be set when using HttpEngine; prefer applicationContext."
}
return HttpEngine.Builder(ctx)
.apply {
config.proxy?.let { ProxyHelper.setupProxy(this, it) }
config.httpEngineConfig?.invoke(this)
}
.build()
}

override operator fun invoke(urlString: String): HttpURLConnection {
return engine.openConnection(URI.create(urlString).toURL()) as HttpURLConnection
}
Comment thread
yschimke marked this conversation as resolved.
Resolved

override fun close() {
if (closed.compareAndSet(false, true) && engineDelegate.isInitialized()) {
engine.shutdown()
}
}

// @RequiresExtension(extension = Build.VERSION_CODES.S, version = 22)
private object ProxyHelper {
fun setupProxy(builder: HttpEngine.Builder, proxy: Proxy) {
val address = proxy.address() as? InetSocketAddress ?: return
if (proxy.type() != Proxy.Type.HTTP) return

val httpProxy = android.net.http.Proxy.createHttpProxy(
android.net.http.Proxy.SCHEME_HTTP,
address.hostString,
address.port,
{ it.run() },
object : android.net.http.Proxy.HttpConnectCallback {
override fun onBeforeRequest(request: android.net.http.Proxy.HttpConnectCallback.Request) {
request.use { it.proceed(emptyList()) }
}

override fun onResponseReceived(
responseHeaders: List<Pair<String, String>>,
statusCode: Int
): Int = android.net.http.Proxy.HttpConnectCallback.RESPONSE_ACTION_PROCEED
}
)
builder.setProxyOptions(
ProxyOptions.fromProxyList(
listOf(httpProxy),
ProxyOptions.ALL_PROXIES_FAILED_BEHAVIOR_DISALLOW_DIRECT
)
)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,9 @@ import io.ktor.utils.io.*
import io.ktor.utils.io.jvm.javaio.*
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.job
import java.io.OutputStream
import java.net.HttpURLConnection
import java.net.URL
import java.net.URLConnection
import java.util.*
import javax.net.ssl.HttpsURLConnection
import kotlin.coroutines.CoroutineContext
Expand All @@ -32,12 +31,21 @@ import kotlin.coroutines.CoroutineContext
*/
@OptIn(InternalAPI::class)
public class AndroidClientEngine(override val config: AndroidEngineConfig) : HttpClientEngineBase("ktor-android") {
override val supportedCapabilities: Set<HttpClientEngineCapability<*>> = setOf(HttpTimeoutCapability, SSECapability)

private val urlFactory: URLConnectionFactory = if (isHttpEngineAvailable(config)) {
AndroidNetHttpEngineFactory(config)
} else {
URLConnectionFactory.StandardURLConnectionFactory(config)
}

init {
configurePlatform()
coroutineContext.job.invokeOnCompletion {
urlFactory.close()
}
}

override val supportedCapabilities: Set<HttpClientEngineCapability<*>> = setOf(HttpTimeoutCapability, SSECapability)

override suspend fun execute(data: HttpRequestData): HttpResponseData {
val callContext = callContext()

Expand All @@ -48,7 +56,7 @@ public class AndroidClientEngine(override val config: AndroidEngineConfig) : Htt
val contentLength: Long? = data.headers[HttpHeaders.ContentLength]?.toLong()
?: outgoingContent.contentLength

val connection: HttpURLConnection = getProxyAwareConnection(url).apply {
val connection: HttpURLConnection = urlFactory(url).apply {
disconnectOnCancellation(callContext)

connectTimeout = config.connectTimeout
Expand Down Expand Up @@ -107,12 +115,6 @@ public class AndroidClientEngine(override val config: AndroidEngineConfig) : Htt
HttpResponseData(statusCode, requestTime, responseHeaders, version, responseBody, callContext)
}
}

private fun getProxyAwareConnection(urlString: String): HttpURLConnection {
val url = URL(urlString)
val connection: URLConnection = config.proxy?.let { url.openConnection(it) } ?: url.openConnection()
return connection as HttpURLConnection
}
}

@OptIn(DelicateCoroutinesApi::class)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@

package io.ktor.client.engine.android

import android.content.Context
import android.net.http.*
import io.ktor.client.engine.*
import io.ktor.utils.io.*
import java.net.*
import javax.net.ssl.*

Expand Down Expand Up @@ -35,6 +38,9 @@ public class AndroidEngineConfig : HttpClientEngineConfig() {
/**
* Allows you to configure [HTTPS](https://ktor.io/docs/client-ssl.html) settings for this engine.
*
* Note that this configuration is not applied when [useHttpEngine] is active, as Android's
* [HttpEngine] connections do not extend [HttpsURLConnection].
*
* [Report a problem](https://ktor.io/feedback/?fqname=io.ktor.client.engine.android.AndroidEngineConfig.sslManager)
*/
public var sslManager: (HttpsURLConnection) -> Unit = {}
Expand All @@ -45,4 +51,45 @@ public class AndroidEngineConfig : HttpClientEngineConfig() {
* [Report a problem](https://ktor.io/feedback/?fqname=io.ktor.client.engine.android.AndroidEngineConfig.requestConfig)
*/
public var requestConfig: HttpURLConnection.() -> Unit = {}

internal var httpEngineConfig: ((@KtorDsl HttpEngine.Builder).() -> Unit)? = null

internal var httpEngineEnabled: Boolean = true

internal var context: Context? = null

/**
* Enables Android's [HttpEngine] for executing requests on supported devices
* (Android 14 / API 34+ or Android 12+ with SDK Extensions S version 7+).
*
* Use [configure] to customize the [HttpEngine.Builder], such as enabling Brotli, QUIC,
* DNS options, or connection migration.
*
* If [HttpEngine] is not available on the device, or if a [proxy] is configured that is not
* supported by [HttpEngine] (HTTP proxies require SDK Extensions S version 22+ / API 37+),
* the engine falls back to the legacy [HttpURLConnection] implementation.
*
* Note that [sslManager] is not applied when [HttpEngine] is active because its connections
* do not extend [HttpsURLConnection].
*
* [Report a problem](https://ktor.io/feedback/?fqname=io.ktor.client.engine.android.AndroidEngineConfig.useHttpEngine)
*
* @param context Android [Context] used to initialize [HttpEngine.Builder]. `applicationContext` is used when available.
* @param configure Optional configuration block applied to [HttpEngine.Builder].
*/
public fun useHttpEngine(
context: Context,
configure: (@KtorDsl HttpEngine.Builder).() -> Unit = {}
) {
this.context = context.applicationContext ?: context
val previous = httpEngineConfig
httpEngineConfig = if (previous == null) {
configure
} else {
{
previous()
configure()
}
}
Comment thread
yschimke marked this conversation as resolved.
Resolved
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

package io.ktor.client.engine.android

import android.os.*
import android.os.ext.SdkExtensions
import io.ktor.client.network.sockets.*
import io.ktor.client.plugins.*
import io.ktor.client.request.*
Expand Down Expand Up @@ -95,3 +97,24 @@ internal fun HttpURLConnection.content(status: Int, callContext: CoroutineContex
*/
private fun Throwable.isTimeoutException(): Boolean =
this is java.net.SocketTimeoutException || (this is ConnectException && message?.contains("timed out") ?: false)

internal fun isAndroid(): Boolean = "Dalvik" == System.getProperty("java.vm.name")

internal fun isHttpEngineAvailable(): Boolean = isAndroid() &&
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R &&
SdkExtensions.getExtensionVersion(Build.VERSION_CODES.S) >= 7

internal fun isHttpEngineProxyAvailable(): Boolean = isAndroid() &&
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R &&
SdkExtensions.getExtensionVersion(Build.VERSION_CODES.S) >= 22

internal fun isHttpEngineAvailable(config: AndroidEngineConfig): Boolean {
if (!config.httpEngineEnabled || config.context == null || !isHttpEngineAvailable()) {
return false
}

val proxy = config.proxy ?: return true
return proxy.type() == Proxy.Type.HTTP &&
proxy.address() is InetSocketAddress &&
isHttpEngineProxyAvailable()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/*
* Copyright 2014-2025 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license.
*/

package io.ktor.client.engine.android

import java.io.Closeable
import java.net.*

internal interface URLConnectionFactory : Closeable {
operator fun invoke(urlString: String): HttpURLConnection

override fun close() {}

class StandardURLConnectionFactory(val config: AndroidEngineConfig) : URLConnectionFactory {
override operator fun invoke(urlString: String): HttpURLConnection {
val url = URL(urlString)
val connection: URLConnection = config.proxy?.let { url.openConnection(it) } ?: url.openConnection()
return connection as HttpURLConnection
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

package io.ktor.client.engine.android

import android.content.ContextWrapper
import android.net.http.*
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.engine.*
Expand All @@ -18,6 +20,8 @@ import io.ktor.server.response.*
import io.ktor.server.routing.*
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.BeforeAll
import org.junit.jupiter.api.condition.EnabledForJreRange
import org.junit.jupiter.api.condition.JRE
import java.io.File
import java.security.KeyStore
import javax.net.ssl.SSLContext
Expand Down Expand Up @@ -103,4 +107,47 @@ class AndroidSpecificHttpsTest : TestWithKtor() {
assertEquals("Hello, world", actual)
}
}

@Test
@EnabledForJreRange(min = JRE.JAVA_21)
fun withAndroid14Customisations(): Unit = runBlocking {
val context = object : ContextWrapper(null) {
override fun getApplicationContext() = this
}
HttpClient(
Android.config {
// Demonstrating options, but ultimately still using URLConnection against localhost on JVM.
// A stacked PR/CL exercises AndroidNetHttpEngineFactory end-to-end using Robolectric shadows.
sslManager = { conn ->
conn.sslSocketFactory = sslContext.socketFactory
}

useHttpEngine(context) {
setEnableBrotli(true)
setConnectionMigrationOptions(
ConnectionMigrationOptions.Builder()
.setDefaultNetworkMigration(ConnectionMigrationOptions.MIGRATION_OPTION_ENABLED)
.setPathDegradationMigration(ConnectionMigrationOptions.MIGRATION_OPTION_ENABLED)
.setAllowNonDefaultNetworkUsage(ConnectionMigrationOptions.MIGRATION_OPTION_ENABLED)
.build(),
)
setDnsOptions(
DnsOptions.Builder()
.setUseHttpStackDnsResolver(DnsOptions.DNS_OPTION_ENABLED)
.setStaleDns(DnsOptions.DNS_OPTION_ENABLED)
.setPersistHostCache(DnsOptions.DNS_OPTION_ENABLED)
.build(),
)
setQuicOptions(
QuicOptions.Builder()
.build(),
)
addQuicHint("www.google.com", 443, 443)
}
}
).use { client ->
val actual = client.get("https://127.0.0.1:$serverPort/").body<String>()
assertEquals("Hello, world", actual)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ package io.ktor.util.logging
import org.slf4j.*

private val isAndroid = try {
Class.forName("android.os.Build")
Class.forName("android.os.Build", false, Logger::class.java.classLoader)
true
} catch (_: ClassNotFoundException) {
false
Expand Down
Loading