Skip to content

Commit 110b854

Browse files
committed
feat(network): Implement dynamic proxy support for Ktor client
This commit introduces support for HTTP and SOCKS proxies in the application's network layer. It allows the `HttpClient` to be dynamically reconfigured at runtime when proxy settings change. The implementation creates a `GitHubClientProvider` that observes a `ProxyConfig` flow. When the proxy configuration is updated, it closes the existing Ktor `HttpClient` and creates a new one with the updated proxy settings. Platform-specific Ktor engines are used for proxy implementation: `OkHttp` on Android and `CIO` on JVM (desktop). - **feat(network)**: Added `ProxyConfig` data class to model proxy settings. - **feat(network)**: Introduced `ProxyManager` to hold and update the global proxy configuration. - **feat(network)**: Implemented `GitHubClientProvider` to manage the lifecycle of `HttpClient` and recreate it when proxy settings change. - **feat(network)**: Added platform-specific `HttpClientFactory` implementations for Android (`OkHttp`) and JVM (`CIO`) to handle proxy configuration. - **chore(deps)**: Added Ktor client engine dependencies: `ktor-client-okhttp` for Android and `ktor-client-cio` for JVM.
1 parent 39e788f commit 110b854

9 files changed

Lines changed: 165 additions & 11 deletions

File tree

core/data/build.gradle.kts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,13 @@ kotlin {
2424

2525
androidMain {
2626
dependencies {
27-
27+
implementation(libs.ktor.client.okhttp)
2828
}
2929
}
3030

3131
jvmMain {
3232
dependencies {
33-
33+
implementation(libs.ktor.client.cio)
3434
}
3535
}
3636
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package zed.rainxch.core.data.network
2+
3+
import io.ktor.client.*
4+
import io.ktor.client.engine.okhttp.*
5+
import java.net.InetSocketAddress
6+
import java.net.Proxy
7+
import okhttp3.Credentials
8+
import zed.rainxch.core.domain.model.ProxyConfig
9+
10+
actual fun createPlatformHttpClient(proxyConfig: ProxyConfig?): HttpClient {
11+
return HttpClient(OkHttp) {
12+
engine {
13+
proxyConfig?.let { config ->
14+
val javaProxyType = when (config.type) {
15+
ProxyConfig.ProxyType.HTTP -> Proxy.Type.HTTP
16+
ProxyConfig.ProxyType.SOCKS -> Proxy.Type.SOCKS
17+
}
18+
proxy = Proxy(javaProxyType, InetSocketAddress(config.host, config.port))
19+
20+
if (config.username != null) {
21+
config {
22+
proxyAuthenticator { _, response ->
23+
response.request.newBuilder()
24+
.header(
25+
"Proxy-Authorization",
26+
Credentials.basic(
27+
config.username!!,
28+
config.password.orEmpty()
29+
)
30+
)
31+
.build()
32+
}
33+
}
34+
}
35+
}
36+
}
37+
}
38+
}

core/data/src/commonMain/kotlin/zed/rainxch/core/data/di/SharedModule.kt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ import zed.rainxch.core.data.local.db.dao.InstalledAppDao
1313
import zed.rainxch.core.data.local.db.dao.StarredRepoDao
1414
import zed.rainxch.core.data.local.db.dao.UpdateHistoryDao
1515
import zed.rainxch.core.data.logging.KermitLogger
16+
import zed.rainxch.core.data.network.GitHubClientProvider
17+
import zed.rainxch.core.data.network.ProxyManager
1618
import zed.rainxch.core.data.network.createGitHubHttpClient
1719
import zed.rainxch.core.data.repository.AuthenticationStateImpl
1820
import zed.rainxch.core.data.repository.FavouritesRepositoryImpl
@@ -93,6 +95,14 @@ val coreModule = module {
9395
}
9496

9597
val networkModule = module {
98+
single<GitHubClientProvider> {
99+
GitHubClientProvider(
100+
tokenStore = get(),
101+
rateLimitRepository = get(),
102+
proxyConfigFlow = ProxyManager.currentProxyConfig
103+
)
104+
}
105+
96106
single<HttpClient> {
97107
createGitHubHttpClient(
98108
tokenStore = get(),
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
package zed.rainxch.core.data.network
2+
3+
import io.ktor.client.HttpClient
4+
import kotlinx.coroutines.CoroutineScope
5+
import kotlinx.coroutines.Dispatchers
6+
import kotlinx.coroutines.SupervisorJob
7+
import kotlinx.coroutines.flow.Flow
8+
import kotlinx.coroutines.flow.MutableStateFlow
9+
import kotlinx.coroutines.flow.SharingStarted
10+
import kotlinx.coroutines.flow.distinctUntilChanged
11+
import kotlinx.coroutines.flow.map
12+
import kotlinx.coroutines.flow.stateIn
13+
import zed.rainxch.core.data.data_source.TokenStore
14+
import zed.rainxch.core.domain.model.ProxyConfig
15+
import zed.rainxch.core.domain.repository.RateLimitRepository
16+
17+
class GitHubClientProvider(
18+
private val tokenStore: TokenStore,
19+
private val rateLimitRepository: RateLimitRepository,
20+
proxyConfigFlow: Flow<ProxyConfig?>
21+
) {
22+
private val _client = MutableStateFlow<HttpClient?>(null)
23+
24+
val client: Flow<HttpClient> = proxyConfigFlow
25+
.distinctUntilChanged()
26+
.map { proxyConfig ->
27+
_client.value?.close()
28+
29+
val newClient = createGitHubHttpClient(
30+
tokenStore = tokenStore,
31+
rateLimitRepository = rateLimitRepository,
32+
proxyConfig = proxyConfig
33+
)
34+
_client.value = newClient
35+
newClient
36+
}
37+
.stateIn(
38+
scope = CoroutineScope(SupervisorJob() + Dispatchers.Default),
39+
started = SharingStarted.Lazily,
40+
initialValue = createGitHubHttpClient(tokenStore, rateLimitRepository)
41+
)
42+
43+
fun currentClient(): HttpClient {
44+
return _client.value
45+
?: createGitHubHttpClient(tokenStore, rateLimitRepository).also {
46+
_client.value = it
47+
}
48+
}
49+
50+
fun close() {
51+
_client.value?.close()
52+
}
53+
}

core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/HttpClientFactory.kt

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,24 +9,30 @@ import io.ktor.client.statement.HttpResponse
99
import io.ktor.http.*
1010
import io.ktor.serialization.kotlinx.json.*
1111
import io.ktor.util.network.UnresolvedAddressException
12+
import kotlinx.coroutines.flow.Flow
1213
import kotlinx.serialization.json.Json
1314
import zed.rainxch.core.data.data_source.TokenStore
1415
import zed.rainxch.core.data.network.interceptor.RateLimitInterceptor
16+
import zed.rainxch.core.domain.model.ProxyConfig
1517
import zed.rainxch.core.domain.model.RateLimitException
1618
import zed.rainxch.core.domain.repository.RateLimitRepository
1719
import java.io.IOException
1820
import kotlin.coroutines.cancellation.CancellationException
1921

22+
expect fun createPlatformHttpClient(proxyConfig: ProxyConfig? = null): HttpClient
23+
2024
fun createGitHubHttpClient(
2125
tokenStore: TokenStore,
22-
rateLimitRepository: RateLimitRepository
26+
rateLimitRepository: RateLimitRepository,
27+
proxyConfig: ProxyConfig? = null
2328
): HttpClient {
2429
val json = Json {
2530
ignoreUnknownKeys = true
2631
isLenient = true
2732
}
2833

29-
return HttpClient {
34+
return createPlatformHttpClient(proxyConfig).config {
35+
3036
install(RateLimitInterceptor) {
3137
this.rateLimitRepository = rateLimitRepository
3238
}
@@ -45,23 +51,17 @@ fun createGitHubHttpClient(
4551
maxRetries = 3
4652
retryIf { _, response ->
4753
val code = response.status.value
48-
4954
if (code == 403) {
5055
val remaining = response.headers["X-RateLimit-Remaining"]?.toIntOrNull()
51-
if (remaining == 0) {
52-
return@retryIf false
53-
}
56+
if (remaining == 0) return@retryIf false
5457
}
55-
5658
code in 500..<600
5759
}
58-
5960
retryOnExceptionIf { _, cause ->
6061
cause is HttpRequestTimeoutException ||
6162
cause is UnresolvedAddressException ||
6263
cause is IOException
6364
}
64-
6565
exponentialDelay()
6666
}
6767

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package zed.rainxch.core.data.network
2+
3+
import kotlinx.coroutines.flow.MutableStateFlow
4+
import kotlinx.coroutines.flow.asStateFlow
5+
import kotlinx.coroutines.flow.update
6+
import zed.rainxch.core.domain.model.ProxyConfig
7+
8+
object ProxyManager {
9+
private val _proxyConfig = MutableStateFlow<ProxyConfig?>(null)
10+
val currentProxyConfig = _proxyConfig.asStateFlow()
11+
12+
fun setProxyConfig(
13+
config: ProxyConfig?
14+
) {
15+
_proxyConfig.update { config }
16+
}
17+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package zed.rainxch.core.data.network
2+
3+
import io.ktor.client.HttpClient
4+
import io.ktor.client.engine.ProxyBuilder
5+
import io.ktor.client.engine.cio.CIO
6+
import io.ktor.http.Url
7+
import zed.rainxch.core.domain.model.ProxyConfig
8+
9+
actual fun createPlatformHttpClient(proxyConfig: ProxyConfig?): HttpClient {
10+
return HttpClient(CIO) {
11+
engine {
12+
proxy = proxyConfig?.let { config ->
13+
when (config.type) {
14+
ProxyConfig.ProxyType.HTTP -> ProxyBuilder.http(
15+
Url("http://${config.host}:${config.port}")
16+
)
17+
ProxyConfig.ProxyType.SOCKS -> ProxyBuilder.socks(
18+
config.host, config.port
19+
)
20+
}
21+
}
22+
}
23+
}
24+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package zed.rainxch.core.domain.model
2+
3+
data class ProxyConfig(
4+
val type: ProxyType,
5+
val host: String,
6+
val port: Int,
7+
val username: String? = null,
8+
val password: String? = null,
9+
) {
10+
enum class ProxyType { HTTP, SOCKS }
11+
}

gradle/libs.versions.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ jsystemthemedetector = { module = "com.github.Dansoftowner:jSystemThemeDetector"
110110
# Ktor
111111
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
112112
ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
113+
ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" }
113114
ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" }
114115
ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
115116
ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" }

0 commit comments

Comments
 (0)