Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[KTOR-6886] Android Http Client using Android's HttpEngine on API 34+ #4013

Draft
wants to merge 5 commits into
base: main
Choose a base branch
from
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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
4 changes: 4 additions & 0 deletions ktor-client/ktor-client-android/api/ktor-client-android.api
Expand Up @@ -14,10 +14,14 @@ public final class io/ktor/client/engine/android/AndroidClientEngine : io/ktor/c
public final class io/ktor/client/engine/android/AndroidEngineConfig : io/ktor/client/engine/HttpClientEngineConfig {
public fun <init> ()V
public final fun getConnectTimeout ()I
public final fun getContext ()Landroid/content/Context;
public final fun getHttpEngineConfig ()Lkotlin/jvm/functions/Function1;
public final fun getRequestConfig ()Lkotlin/jvm/functions/Function1;
public final fun getSocketTimeout ()I
public final fun getSslManager ()Lkotlin/jvm/functions/Function1;
public final fun setConnectTimeout (I)V
public final fun setContext (Landroid/content/Context;)V
public final fun setHttpEngineConfig (Lkotlin/jvm/functions/Function1;)V
public final fun setRequestConfig (Lkotlin/jvm/functions/Function1;)V
public final fun setSocketTimeout (I)V
public final fun setSslManager (Lkotlin/jvm/functions/Function1;)V
Expand Down
2 changes: 2 additions & 0 deletions ktor-client/ktor-client-android/build.gradle.kts
Expand Up @@ -4,13 +4,15 @@ kotlin.sourceSets {
jvmMain {
dependencies {
api(project(":ktor-client:ktor-client-core"))
compileOnly("org.robolectric:android-all:14-robolectric-10818077")
}
}
jvmTest {
dependencies {
api(project(":ktor-client:ktor-client-tests"))
api(project(":ktor-network:ktor-network-tls"))
api(project(":ktor-network:ktor-network-tls:ktor-network-tls-certificates"))
implementation("org.robolectric:android-all:14-robolectric-10818077")
}
}
}
Expand Down
@@ -0,0 +1,67 @@
/*
* Copyright 2014-2021 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.ConnectionMigrationOptions
import android.net.http.HttpEngine
import androidx.test.platform.app.InstrumentationRegistry
import io.ktor.client.HttpClient
import io.ktor.client.request.get
import io.ktor.client.statement.bodyAsText
import kotlinx.coroutines.test.runTest
import org.junit.Test
import kotlin.test.assertContains

class AndroidEngineTest {
@Test
fun testHttpEngine() = runTest {
val client = HttpClient(Android.create {
httpEngineDisabled = false
context = InstrumentationRegistry.getInstrumentation().targetContext
})

val response = client.get("https://github.com/robots.txt")

assertContains(response.bodyAsText(), "Disallow")
}
@Test
fun testHttpEngineConfig() = runTest {
val client = HttpClient(Android.create {
httpEngineDisabled = false
context = InstrumentationRegistry.getInstrumentation().targetContext

httpEngineConfig = {
setUserAgent("Xyz")

setEnableBrotli(true)
setEnableHttpCache(HttpEngine.Builder.HTTP_CACHE_IN_MEMORY, 10_000_000)
setConnectionMigrationOptions(
ConnectionMigrationOptions.Builder()
.setDefaultNetworkMigration(ConnectionMigrationOptions.MIGRATION_OPTION_ENABLED)
.setPathDegradationMigration(ConnectionMigrationOptions.MIGRATION_OPTION_ENABLED)
.setAllowNonDefaultNetworkUsage(ConnectionMigrationOptions.MIGRATION_OPTION_ENABLED)
.build(),
)
addQuicHint("github.com", 443, 443)
}
})

val response = client.get("https://github.com/robots.txt")

assertContains(response.bodyAsText(), "Disallow")
}

@Test
fun testUrlConnection() = runTest {
val client = HttpClient(Android.create {
httpEngineDisabled = true
})

val response = client.get("https://github.com/robots.txt")

println(response.bodyAsText())
assertContains(response.bodyAsText(), "Disallow")
}
}
@@ -0,0 +1,22 @@
/*
* Copyright 2014-2024 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.*
import java.net.*

internal class Android14HttpEngineFactory(private val config: AndroidEngineConfig) : URLConnectionFactory {
private val engine = buildEngine()

fun buildEngine(): HttpEngine {
return HttpEngine.Builder(config.context!!)
.apply(config.httpEngineConfig)
.build()
}

override operator fun invoke(urlString: String): HttpURLConnection {
return engine.openConnection(URL(urlString)) as HttpURLConnection
}
}
Expand Up @@ -31,6 +31,12 @@ public class AndroidClientEngine(override val config: AndroidEngineConfig) : Htt

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

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

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

Expand All @@ -41,12 +47,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 @@ -90,7 +97,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 @@ -100,12 +107,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
Expand Up @@ -4,6 +4,7 @@

package io.ktor.client.engine.android

import android.net.http.*
import io.ktor.client.engine.*
import java.net.*
import javax.net.ssl.*
Expand Down Expand Up @@ -35,4 +36,19 @@ public class AndroidEngineConfig : HttpClientEngineConfig() {
* Allows you to set engine-specific request configuration.
*/
public var requestConfig: HttpURLConnection.() -> Unit = {}

/**
* Allows you to set engine-specific request configuration.
*/
public var httpEngineConfig: HttpEngine.Builder.() -> Unit = {}

internal var httpEngineDisabled = false

/**
* Allows you to set engine-specific request configuration.
*/
public var context: android.content.Context? = null
set(value) {
field = value
}
}
Expand Up @@ -4,6 +4,7 @@

package io.ktor.client.engine.android

import android.os.*
import io.ktor.client.network.sockets.*
import io.ktor.client.plugins.*
import io.ktor.client.request.*
Expand Down Expand Up @@ -83,3 +84,7 @@ internal fun HttpURLConnection.content(callContext: CoroutineContext, request: H
*/
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")

internal fun isAndroid14() = isAndroid && Build.VERSION.SDK_INT >= 34
@@ -0,0 +1,28 @@
/*
* Copyright 2014-2024 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 java.net.*

internal interface URLConnectionFactory {
operator fun invoke(urlString: String): HttpURLConnection
fun protocolFromRequest(connection: HttpURLConnection): HttpProtocolVersion {
return HttpProtocolVersion.HTTP_1_1
}

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
}

// Work out how to get version
// override fun protocolFromRequest(connection: HttpURLConnection): HttpProtocolVersion {
// return connection
// }
}
}
Expand Up @@ -4,6 +4,7 @@

package io.ktor.client.engine.android

import android.net.http.*
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.engine.*
Expand Down Expand Up @@ -101,4 +102,44 @@ class AndroidSpecificHttpsTest : TestWithKtor() {
assertEquals("Hello, world", actual)
}
}

@Test
fun withAndroid14Customisations(): Unit = runBlocking {
HttpClient(
Android.config {
// this.context = context
httpEngineConfig = {
// val cacheDir =
// context.cacheDir.resolve("httpEngine").also {
// it.mkdirs()
// }

setEnableBrotli(true)
// setStoragePath(cacheDir.path)
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)
}
}
}