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
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/*
* 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 java.net.HttpURLConnection
import java.net.URI

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

/**
* Builds an HttpEngine configured with the Android application context from the provided config.
*
* Retrieves the configuration's context (must be non-null) and uses its applicationContext to construct
* an HttpEngine with the settings from `config.httpEngineConfig`.
*
* @return A configured `HttpEngine` instance.
* @throws IllegalArgumentException if `config.context` is null.
*/
private fun buildEngine(): HttpEngine {
val ctx = requireNotNull(config.context) {
"AndroidEngineConfig.context must be set when using HttpEngine; prefer applicationContext."
}.applicationContext
return HttpEngine.Builder(ctx)
.apply(config.httpEngineConfig)
.build()
}

/**
* Create an HttpURLConnection for the given URL string using the configured HttpEngine.
*
* @param urlString The URL to open, as a string.
* @return An `HttpURLConnection` connected to the specified URL.
*/
override operator fun invoke(urlString: String): HttpURLConnection {
return engine.openConnection(URI.create(urlString).toURL()) as HttpURLConnection
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,27 @@ public class AndroidClientEngine(override val config: AndroidEngineConfig) : Htt

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

private val urlFactory = if (config.httpEngineDisabled ||
!isHttpEngineAvailable() ||
config.proxy != null ||
config.context == null
) {
URLConnectionFactory.StandardURLConnectionFactory(config)
} else {
AndroidNetHttpEngineFactory(config)
}

/**
* Executes the given HTTP request and returns the resulting response.
*
* The request described by `data` is sent over a configured HttpURLConnection; the request body
* (if any) is written to the connection, and the response status, headers, protocol version, and
* body (or an adapted representation when a ResponseAdapterAttribute is present) are returned.
*
* @param data The HTTP request data to execute (URL, method, headers, body, and attributes).
* @return The HttpResponseData containing the response status, headers, protocol version, body, and call context.
* @throws IllegalStateException If the request method does not allow a body but a non-empty body is provided.
*/
override suspend fun execute(data: HttpRequestData): HttpResponseData {
val callContext = callContext()

Expand All @@ -44,12 +65,13 @@ 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 {
connectTimeout = config.connectTimeout
readTimeout = config.socketTimeout

setupTimeoutAttributes(data)

// TODO document not active on Android 14
if (this is HttpsURLConnection) {
config.sslManager(this)
}
Expand Down Expand Up @@ -91,7 +113,7 @@ public class AndroidClientEngine(override val config: AndroidEngineConfig) : Htt
.mapKeys { it.key?.lowercase(Locale.getDefault()) ?: "" }
.filter { it.key.isNotBlank() }

val version: HttpProtocolVersion = HttpProtocolVersion.HTTP_1_1
val version: HttpProtocolVersion = urlFactory.protocolFromRequest(connection)
val responseHeaders = HeadersImpl(headerFields)

val responseBody: Any = data.attributes.getOrNull(ResponseAdapterAttributeKey)
Expand All @@ -101,14 +123,20 @@ 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
}
}

/**
* Writes this [OutgoingContent] into the provided [stream], honoring the supplied [callContext].
*
* Supports ByteArrayContent, ReadChannelContent, WriteChannelContent, NoContent and ContentWrapper;
* for WriteChannelContent a writer is launched with [callContext] and its resulting channel is copied to the stream.
* The stream is closed after writing completes.
*
* @param stream The destination [OutputStream] to write the content into; it will be closed when writing finishes.
* @param callContext The coroutine context used when producing content for `WriteChannelContent`.
*
* @throws UnsupportedContentTypeException if this content is a [OutgoingContent.ProtocolUpgrade].
*/
@OptIn(DelicateCoroutinesApi::class)
internal suspend fun OutgoingContent.writeTo(
stream: OutputStream,
Expand Down
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 @@ -85,7 +87,21 @@ internal fun HttpURLConnection.content(status: Int, callContext: CoroutineContex
}

/**
* Checks the exception and identifies timeout exception by it.
*/
* Determines whether this throwable represents a network timeout.
*
* @return `true` if the throwable is a `SocketTimeoutException` or a `ConnectException` whose message contains "timed out", `false` otherwise.
*/
private fun Throwable.isTimeoutException(): Boolean =
this is java.net.SocketTimeoutException || (this is ConnectException && message?.contains("timed out") ?: false)

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

/**
* Determines whether the Android HTTP engine is supported on the current runtime.
*
* @return `true` if the runtime VM is Dalvik, the OS SDK level is at least 30, and the S platform
* extensions version is at least 7; `false` otherwise.
*/
internal fun isHttpEngineAvailable() = isAndroid &&
Build.VERSION.SDK_INT >= 30 &&
SdkExtensions.getExtensionVersion(Build.VERSION_CODES.S) >= 7
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/*
* 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 io.ktor.http.*
import io.ktor.util.*
import java.net.*

@InternalAPI
internal interface URLConnectionFactory {
/**
* Open an HttpURLConnection for the specified URL string, using the factory's configured proxy if present.
*
* @param urlString The URL to connect to, expressed as a string.
* @return An HttpURLConnection for the specified URL.
*/
operator fun invoke(urlString: String): HttpURLConnection
/**
* Determine the HTTP protocol version associated with the given URL connection.
*
* @param connection The HttpURLConnection to inspect.
* @return The HTTP protocol version for the connection. The default implementation returns `HttpProtocolVersion.HTTP_1_1`.
*/
fun protocolFromRequest(connection: HttpURLConnection): HttpProtocolVersion {
// This is not exposed with HttpEngine
return HttpProtocolVersion.HTTP_1_1
}

@InternalAPI
class StandardURLConnectionFactory(val config: AndroidEngineConfig) : URLConnectionFactory {
/**
* Create an HttpURLConnection for the given URL string using the engine's proxy when configured.
*
* @param urlString The URL as a string to open a connection to.
* @return An HttpURLConnection for the specified URL; if the engine config defines a proxy, the connection is opened via that proxy.
*/
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
}
}
}