mirror of
https://github.com/bitfireAT/davx5-ose.git
synced 2025-12-23 23:17:50 -05:00
[Ktor] Allow building a Ktor client (#1810)
* Add Ktor dependency * Add buildKtor method * Add test and deprecation notice * KDoc
This commit is contained in:
@@ -190,6 +190,8 @@ dependencies {
|
|||||||
implementation(libs.conscrypt)
|
implementation(libs.conscrypt)
|
||||||
implementation(libs.dnsjava)
|
implementation(libs.dnsjava)
|
||||||
implementation(libs.guava)
|
implementation(libs.guava)
|
||||||
|
implementation(libs.ktor.client.core)
|
||||||
|
implementation(libs.ktor.client.okhttp)
|
||||||
implementation(libs.mikepenz.aboutLibraries.m3)
|
implementation(libs.mikepenz.aboutLibraries.m3)
|
||||||
implementation(libs.okhttp.base)
|
implementation(libs.okhttp.base)
|
||||||
implementation(libs.okhttp.brotli)
|
implementation(libs.okhttp.brotli)
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ package at.bitfire.davdroid.network
|
|||||||
import android.security.NetworkSecurityPolicy
|
import android.security.NetworkSecurityPolicy
|
||||||
import dagger.hilt.android.testing.HiltAndroidRule
|
import dagger.hilt.android.testing.HiltAndroidRule
|
||||||
import dagger.hilt.android.testing.HiltAndroidTest
|
import dagger.hilt.android.testing.HiltAndroidTest
|
||||||
import okhttp3.OkHttpClient
|
import io.ktor.client.request.get
|
||||||
|
import io.ktor.client.statement.bodyAsText
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import okhttp3.mockwebserver.MockResponse
|
import okhttp3.mockwebserver.MockResponse
|
||||||
import okhttp3.mockwebserver.MockWebServer
|
import okhttp3.mockwebserver.MockWebServer
|
||||||
@@ -20,25 +22,23 @@ import org.junit.Before
|
|||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Provider
|
||||||
|
|
||||||
@HiltAndroidTest
|
@HiltAndroidTest
|
||||||
class HttpClientTest {
|
class HttpClientBuilderTest {
|
||||||
|
|
||||||
@get:Rule
|
@get:Rule
|
||||||
var hiltRule = HiltAndroidRule(this)
|
var hiltRule = HiltAndroidRule(this)
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
lateinit var httpClientBuilder: HttpClientBuilder
|
lateinit var httpClientBuilder: Provider<HttpClientBuilder>
|
||||||
|
|
||||||
lateinit var httpClient: OkHttpClient
|
|
||||||
lateinit var server: MockWebServer
|
lateinit var server: MockWebServer
|
||||||
|
|
||||||
@Before
|
@Before
|
||||||
fun setUp() {
|
fun setUp() {
|
||||||
hiltRule.inject()
|
hiltRule.inject()
|
||||||
|
|
||||||
httpClient = httpClientBuilder.build()
|
|
||||||
|
|
||||||
server = MockWebServer()
|
server = MockWebServer()
|
||||||
server.start(30000)
|
server.start(30000)
|
||||||
}
|
}
|
||||||
@@ -49,6 +49,18 @@ class HttpClientTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testBuildKtor_CreatesWorkingClient() = runTest {
|
||||||
|
server.enqueue(MockResponse()
|
||||||
|
.setResponseCode(200)
|
||||||
|
.setBody("Some Content"))
|
||||||
|
|
||||||
|
val client = httpClientBuilder.get().buildKtor()
|
||||||
|
val response = client.get(server.url("/").toString())
|
||||||
|
assertEquals(200, response.status.value)
|
||||||
|
assertEquals("Some Content", response.bodyAsText())
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testCookies() {
|
fun testCookies() {
|
||||||
Assume.assumeTrue(NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted)
|
Assume.assumeTrue(NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted)
|
||||||
@@ -60,6 +72,8 @@ class HttpClientTest {
|
|||||||
.addHeader("Set-Cookie", "cookie1=1; path=/")
|
.addHeader("Set-Cookie", "cookie1=1; path=/")
|
||||||
.addHeader("Set-Cookie", "cookie2=2")
|
.addHeader("Set-Cookie", "cookie2=2")
|
||||||
.setBody("Cookie set"))
|
.setBody("Cookie set"))
|
||||||
|
|
||||||
|
val httpClient = httpClientBuilder.get().build()
|
||||||
httpClient.newCall(Request.Builder()
|
httpClient.newCall(Request.Builder()
|
||||||
.get().url(url)
|
.get().url(url)
|
||||||
.build()).execute()
|
.build()).execute()
|
||||||
@@ -19,6 +19,8 @@ import at.bitfire.davdroid.settings.SettingsManager
|
|||||||
import at.bitfire.davdroid.ui.ForegroundTracker
|
import at.bitfire.davdroid.ui.ForegroundTracker
|
||||||
import com.google.common.net.HttpHeaders
|
import com.google.common.net.HttpHeaders
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import io.ktor.client.HttpClient
|
||||||
|
import io.ktor.client.engine.okhttp.OkHttp
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import net.openid.appauth.AuthState
|
import net.openid.appauth.AuthState
|
||||||
@@ -181,17 +183,17 @@ class HttpClientBuilder @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// actual builder
|
// okhttp builder
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds the [OkHttpClient].
|
* Builds an [OkHttpClient] with the configured settings.
|
||||||
*
|
*
|
||||||
* Must be called only once because multiple calls indicate this wrong usage pattern:
|
* [build] or [buildKtor] must be called only once because multiple calls indicate this wrong usage pattern:
|
||||||
*
|
*
|
||||||
* ```
|
* ```
|
||||||
* val builder = HttpClientBuilder(/*injected*/)
|
* val builder = HttpClientBuilder(/*injected*/)
|
||||||
* val client1 = builder.configure().builder()
|
* val client1 = builder.configure().build()
|
||||||
* val client2 = builder.configureOtherwise().builder()
|
* val client2 = builder.configureOtherwise().build()
|
||||||
* ```
|
* ```
|
||||||
*
|
*
|
||||||
* However in this case the configuration of `client1` is still in `builder` and would be reused for `client2`,
|
* However in this case the configuration of `client1` is still in `builder` and would be reused for `client2`,
|
||||||
@@ -199,42 +201,39 @@ class HttpClientBuilder @Inject constructor(
|
|||||||
*
|
*
|
||||||
* @throws IllegalStateException on second and later calls
|
* @throws IllegalStateException on second and later calls
|
||||||
*/
|
*/
|
||||||
|
@Deprecated("Use buildKtor instead", replaceWith = ReplaceWith("buildKtor()"))
|
||||||
fun build(): OkHttpClient {
|
fun build(): OkHttpClient {
|
||||||
if (alreadyBuilt)
|
if (alreadyBuilt)
|
||||||
throw IllegalStateException("build() must only be called once; use Provider<HttpClientBuilder>")
|
throw IllegalStateException("build() must only be called once; use Provider<HttpClientBuilder>")
|
||||||
|
|
||||||
val okBuilder = OkHttpClient.Builder()
|
val builder = OkHttpClient.Builder()
|
||||||
// Set timeouts. According to [AbstractThreadedSyncAdapter], when there is no network
|
configureOkHttp(builder)
|
||||||
// traffic within a minute, a sync will be cancelled.
|
|
||||||
.connectTimeout(15, TimeUnit.SECONDS)
|
|
||||||
.writeTimeout(30, TimeUnit.SECONDS)
|
|
||||||
.readTimeout(120, TimeUnit.SECONDS)
|
|
||||||
.pingInterval(45, TimeUnit.SECONDS) // avoid cancellation because of missing traffic; only works for HTTP/2
|
|
||||||
|
|
||||||
// don't allow redirects by default because it would break PROPFIND handling
|
alreadyBuilt = true
|
||||||
.followRedirects(followRedirects)
|
return builder.build()
|
||||||
|
}
|
||||||
|
|
||||||
// add User-Agent to every request
|
private fun configureOkHttp(builder: OkHttpClient.Builder) {
|
||||||
.addInterceptor(UserAgentInterceptor)
|
buildTimeouts(builder)
|
||||||
|
|
||||||
// connection-private cookie store
|
// don't allow redirects by default because it would break PROPFIND handling
|
||||||
.cookieJar(cookieStore)
|
builder.followRedirects(followRedirects)
|
||||||
|
|
||||||
// allow cleartext and TLS 1.2+
|
// add User-Agent to every request
|
||||||
.connectionSpecs(listOf(
|
builder.addInterceptor(UserAgentInterceptor)
|
||||||
ConnectionSpec.Companion.CLEARTEXT,
|
|
||||||
ConnectionSpec.Companion.MODERN_TLS
|
|
||||||
))
|
|
||||||
|
|
||||||
// offer Brotli and gzip compression (can be disabled per request with `Accept-Encoding: identity`)
|
// connection-private cookie store
|
||||||
.addInterceptor(BrotliInterceptor)
|
builder.cookieJar(cookieStore)
|
||||||
|
|
||||||
|
// offer Brotli and gzip compression (can be disabled per request with `Accept-Encoding: identity`)
|
||||||
|
builder.addInterceptor(BrotliInterceptor)
|
||||||
|
|
||||||
// app-wide custom proxy support
|
// app-wide custom proxy support
|
||||||
buildProxy(okBuilder)
|
buildProxy(builder)
|
||||||
|
|
||||||
// add connection security (including client certificates) and authentication
|
// add connection security (including client certificates) and authentication
|
||||||
buildConnectionSecurity(okBuilder)
|
buildConnectionSecurity(builder)
|
||||||
buildAuthentication(okBuilder)
|
buildAuthentication(builder)
|
||||||
|
|
||||||
// add network logging, if requested
|
// add network logging, if requested
|
||||||
if (logger.isLoggable(Level.FINEST)) {
|
if (logger.isLoggable(Level.FINEST)) {
|
||||||
@@ -244,11 +243,8 @@ class HttpClientBuilder @Inject constructor(
|
|||||||
loggingInterceptor.redactHeader(HttpHeaders.SET_COOKIE)
|
loggingInterceptor.redactHeader(HttpHeaders.SET_COOKIE)
|
||||||
loggingInterceptor.redactHeader(HttpHeaders.SET_COOKIE2)
|
loggingInterceptor.redactHeader(HttpHeaders.SET_COOKIE2)
|
||||||
loggingInterceptor.level = loggerInterceptorLevel
|
loggingInterceptor.level = loggerInterceptorLevel
|
||||||
okBuilder.addNetworkInterceptor(loggingInterceptor)
|
builder.addNetworkInterceptor(loggingInterceptor)
|
||||||
}
|
}
|
||||||
|
|
||||||
alreadyBuilt = true
|
|
||||||
return okBuilder.build()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun buildAuthentication(okBuilder: OkHttpClient.Builder) {
|
private fun buildAuthentication(okBuilder: OkHttpClient.Builder) {
|
||||||
@@ -258,6 +254,12 @@ class HttpClientBuilder @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun buildConnectionSecurity(okBuilder: OkHttpClient.Builder) {
|
private fun buildConnectionSecurity(okBuilder: OkHttpClient.Builder) {
|
||||||
|
// allow cleartext and TLS 1.2+
|
||||||
|
okBuilder.connectionSpecs(listOf(
|
||||||
|
ConnectionSpec.CLEARTEXT,
|
||||||
|
ConnectionSpec.MODERN_TLS
|
||||||
|
))
|
||||||
|
|
||||||
// client certificate
|
// client certificate
|
||||||
val clientKeyManager: KeyManager? = certificateAlias?.let { alias ->
|
val clientKeyManager: KeyManager? = certificateAlias?.let { alias ->
|
||||||
try {
|
try {
|
||||||
@@ -346,4 +348,55 @@ class HttpClientBuilder @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set timeouts for the connection.
|
||||||
|
*
|
||||||
|
* **Note:** According to [android.content.AbstractThreadedSyncAdapter], when there is no network
|
||||||
|
* traffic within a minute, a sync will be cancelled.
|
||||||
|
*/
|
||||||
|
private fun buildTimeouts(builder: OkHttpClient.Builder) {
|
||||||
|
builder.connectTimeout(15, TimeUnit.SECONDS)
|
||||||
|
.writeTimeout(30, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(120, TimeUnit.SECONDS)
|
||||||
|
.pingInterval(45, TimeUnit.SECONDS) // avoid cancellation because of missing traffic; only works for HTTP/2
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Ktor builder
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a Ktor [HttpClient] with the configured settings.
|
||||||
|
*
|
||||||
|
* [buildKtor] or [build] must be called only once because multiple calls indicate this wrong usage pattern:
|
||||||
|
*
|
||||||
|
* ```
|
||||||
|
* val builder = HttpClientBuilder(/*injected*/)
|
||||||
|
* val client1 = builder.configure().buildKtor()
|
||||||
|
* val client2 = builder.configureOtherwise().buildKtor()
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* However in this case the configuration of `client1` is still in `builder` and would be reused for `client2`,
|
||||||
|
* which is usually not desired.
|
||||||
|
*/
|
||||||
|
fun buildKtor(): HttpClient {
|
||||||
|
if (alreadyBuilt)
|
||||||
|
throw IllegalStateException("build() must only be called once; use Provider<HttpClientBuilder>")
|
||||||
|
|
||||||
|
val client = HttpClient(OkHttp) {
|
||||||
|
// Ktor-level configuration here
|
||||||
|
|
||||||
|
engine {
|
||||||
|
// okhttp engine configuration here
|
||||||
|
|
||||||
|
config {
|
||||||
|
// OkHttpClient.Builder configuration here
|
||||||
|
configureOkHttp(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
alreadyBuilt = true
|
||||||
|
return client
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -32,6 +32,7 @@ hilt = "2.57.2"
|
|||||||
kotlin = "2.2.21"
|
kotlin = "2.2.21"
|
||||||
kotlinx-coroutines = "1.10.2"
|
kotlinx-coroutines = "1.10.2"
|
||||||
ksp = "2.3.2"
|
ksp = "2.3.2"
|
||||||
|
ktor = "3.3.2"
|
||||||
mikepenz-aboutLibraries = "13.1.0"
|
mikepenz-aboutLibraries = "13.1.0"
|
||||||
mockk = "1.14.5"
|
mockk = "1.14.5"
|
||||||
okhttp = "5.3.0"
|
okhttp = "5.3.0"
|
||||||
@@ -93,6 +94,8 @@ junit = { module = "junit:junit", version = "4.13.2" }
|
|||||||
kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" }
|
kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" }
|
||||||
kotlinx-coroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" }
|
kotlinx-coroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" }
|
||||||
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" }
|
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" }
|
||||||
|
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
|
||||||
|
ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
|
||||||
mikepenz-aboutLibraries-m3 = { module = "com.mikepenz:aboutlibraries-compose-m3", version.ref = "mikepenz-aboutLibraries" }
|
mikepenz-aboutLibraries-m3 = { module = "com.mikepenz:aboutlibraries-compose-m3", version.ref = "mikepenz-aboutLibraries" }
|
||||||
mockk = { module = "io.mockk:mockk", version.ref = "mockk" }
|
mockk = { module = "io.mockk:mockk", version.ref = "mockk" }
|
||||||
mockk-android = { module = "io.mockk:mockk-android", version.ref = "mockk" }
|
mockk-android = { module = "io.mockk:mockk-android", version.ref = "mockk" }
|
||||||
|
|||||||
Reference in New Issue
Block a user