From 110b854930108debd79b6b484811f751b531c7a3 Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Tue, 24 Feb 2026 10:38:26 +0500 Subject: [PATCH] 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. --- core/data/build.gradle.kts | 4 +- .../data/network/HttpClientFactory.android.kt | 38 +++++++++++++ .../zed/rainxch/core/data/di/SharedModule.kt | 10 ++++ .../core/data/network/GitHubClientProvider.kt | 53 +++++++++++++++++++ .../core/data/network/HttpClientFactory.kt | 18 +++---- .../rainxch/core/data/network/ProxyManager.kt | 17 ++++++ .../data/network/HttpClientFactory.jvm.kt | 24 +++++++++ .../rainxch/core/domain/model/ProxyConfig.kt | 11 ++++ gradle/libs.versions.toml | 1 + 9 files changed, 165 insertions(+), 11 deletions(-) create mode 100644 core/data/src/androidMain/kotlin/zed/rainxch/core/data/network/HttpClientFactory.android.kt create mode 100644 core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/GitHubClientProvider.kt create mode 100644 core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/ProxyManager.kt create mode 100644 core/data/src/jvmMain/kotlin/zed/rainxch/core/data/network/HttpClientFactory.jvm.kt create mode 100644 core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/ProxyConfig.kt diff --git a/core/data/build.gradle.kts b/core/data/build.gradle.kts index fb6d27434..b40906efd 100644 --- a/core/data/build.gradle.kts +++ b/core/data/build.gradle.kts @@ -24,13 +24,13 @@ kotlin { androidMain { dependencies { - + implementation(libs.ktor.client.okhttp) } } jvmMain { dependencies { - + implementation(libs.ktor.client.cio) } } } diff --git a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/network/HttpClientFactory.android.kt b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/network/HttpClientFactory.android.kt new file mode 100644 index 000000000..9ff45e10e --- /dev/null +++ b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/network/HttpClientFactory.android.kt @@ -0,0 +1,38 @@ +package zed.rainxch.core.data.network + +import io.ktor.client.* +import io.ktor.client.engine.okhttp.* +import java.net.InetSocketAddress +import java.net.Proxy +import okhttp3.Credentials +import zed.rainxch.core.domain.model.ProxyConfig + +actual fun createPlatformHttpClient(proxyConfig: ProxyConfig?): HttpClient { + return HttpClient(OkHttp) { + engine { + proxyConfig?.let { config -> + val javaProxyType = when (config.type) { + ProxyConfig.ProxyType.HTTP -> Proxy.Type.HTTP + ProxyConfig.ProxyType.SOCKS -> Proxy.Type.SOCKS + } + proxy = Proxy(javaProxyType, InetSocketAddress(config.host, config.port)) + + if (config.username != null) { + config { + proxyAuthenticator { _, response -> + response.request.newBuilder() + .header( + "Proxy-Authorization", + Credentials.basic( + config.username!!, + config.password.orEmpty() + ) + ) + .build() + } + } + } + } + } + } +} \ No newline at end of file diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/di/SharedModule.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/di/SharedModule.kt index 752966577..4f2d957ba 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/di/SharedModule.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/di/SharedModule.kt @@ -13,6 +13,8 @@ import zed.rainxch.core.data.local.db.dao.InstalledAppDao import zed.rainxch.core.data.local.db.dao.StarredRepoDao import zed.rainxch.core.data.local.db.dao.UpdateHistoryDao import zed.rainxch.core.data.logging.KermitLogger +import zed.rainxch.core.data.network.GitHubClientProvider +import zed.rainxch.core.data.network.ProxyManager import zed.rainxch.core.data.network.createGitHubHttpClient import zed.rainxch.core.data.repository.AuthenticationStateImpl import zed.rainxch.core.data.repository.FavouritesRepositoryImpl @@ -93,6 +95,14 @@ val coreModule = module { } val networkModule = module { + single { + GitHubClientProvider( + tokenStore = get(), + rateLimitRepository = get(), + proxyConfigFlow = ProxyManager.currentProxyConfig + ) + } + single { createGitHubHttpClient( tokenStore = get(), diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/GitHubClientProvider.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/GitHubClientProvider.kt new file mode 100644 index 000000000..3c9a2bb20 --- /dev/null +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/GitHubClientProvider.kt @@ -0,0 +1,53 @@ +package zed.rainxch.core.data.network + +import io.ktor.client.HttpClient +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import zed.rainxch.core.data.data_source.TokenStore +import zed.rainxch.core.domain.model.ProxyConfig +import zed.rainxch.core.domain.repository.RateLimitRepository + +class GitHubClientProvider( + private val tokenStore: TokenStore, + private val rateLimitRepository: RateLimitRepository, + proxyConfigFlow: Flow +) { + private val _client = MutableStateFlow(null) + + val client: Flow = proxyConfigFlow + .distinctUntilChanged() + .map { proxyConfig -> + _client.value?.close() + + val newClient = createGitHubHttpClient( + tokenStore = tokenStore, + rateLimitRepository = rateLimitRepository, + proxyConfig = proxyConfig + ) + _client.value = newClient + newClient + } + .stateIn( + scope = CoroutineScope(SupervisorJob() + Dispatchers.Default), + started = SharingStarted.Lazily, + initialValue = createGitHubHttpClient(tokenStore, rateLimitRepository) + ) + + fun currentClient(): HttpClient { + return _client.value + ?: createGitHubHttpClient(tokenStore, rateLimitRepository).also { + _client.value = it + } + } + + fun close() { + _client.value?.close() + } +} \ No newline at end of file diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/HttpClientFactory.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/HttpClientFactory.kt index 76bc47f1b..6ebb15692 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/HttpClientFactory.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/HttpClientFactory.kt @@ -9,24 +9,30 @@ import io.ktor.client.statement.HttpResponse import io.ktor.http.* import io.ktor.serialization.kotlinx.json.* import io.ktor.util.network.UnresolvedAddressException +import kotlinx.coroutines.flow.Flow import kotlinx.serialization.json.Json import zed.rainxch.core.data.data_source.TokenStore import zed.rainxch.core.data.network.interceptor.RateLimitInterceptor +import zed.rainxch.core.domain.model.ProxyConfig import zed.rainxch.core.domain.model.RateLimitException import zed.rainxch.core.domain.repository.RateLimitRepository import java.io.IOException import kotlin.coroutines.cancellation.CancellationException +expect fun createPlatformHttpClient(proxyConfig: ProxyConfig? = null): HttpClient + fun createGitHubHttpClient( tokenStore: TokenStore, - rateLimitRepository: RateLimitRepository + rateLimitRepository: RateLimitRepository, + proxyConfig: ProxyConfig? = null ): HttpClient { val json = Json { ignoreUnknownKeys = true isLenient = true } - return HttpClient { + return createPlatformHttpClient(proxyConfig).config { + install(RateLimitInterceptor) { this.rateLimitRepository = rateLimitRepository } @@ -45,23 +51,17 @@ fun createGitHubHttpClient( maxRetries = 3 retryIf { _, response -> val code = response.status.value - if (code == 403) { val remaining = response.headers["X-RateLimit-Remaining"]?.toIntOrNull() - if (remaining == 0) { - return@retryIf false - } + if (remaining == 0) return@retryIf false } - code in 500..<600 } - retryOnExceptionIf { _, cause -> cause is HttpRequestTimeoutException || cause is UnresolvedAddressException || cause is IOException } - exponentialDelay() } diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/ProxyManager.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/ProxyManager.kt new file mode 100644 index 000000000..1abca6390 --- /dev/null +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/ProxyManager.kt @@ -0,0 +1,17 @@ +package zed.rainxch.core.data.network + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import zed.rainxch.core.domain.model.ProxyConfig + +object ProxyManager { + private val _proxyConfig = MutableStateFlow(null) + val currentProxyConfig = _proxyConfig.asStateFlow() + + fun setProxyConfig( + config: ProxyConfig? + ) { + _proxyConfig.update { config } + } +} \ No newline at end of file diff --git a/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/network/HttpClientFactory.jvm.kt b/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/network/HttpClientFactory.jvm.kt new file mode 100644 index 000000000..6accb501e --- /dev/null +++ b/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/network/HttpClientFactory.jvm.kt @@ -0,0 +1,24 @@ +package zed.rainxch.core.data.network + +import io.ktor.client.HttpClient +import io.ktor.client.engine.ProxyBuilder +import io.ktor.client.engine.cio.CIO +import io.ktor.http.Url +import zed.rainxch.core.domain.model.ProxyConfig + +actual fun createPlatformHttpClient(proxyConfig: ProxyConfig?): HttpClient { + return HttpClient(CIO) { + engine { + proxy = proxyConfig?.let { config -> + when (config.type) { + ProxyConfig.ProxyType.HTTP -> ProxyBuilder.http( + Url("http://${config.host}:${config.port}") + ) + ProxyConfig.ProxyType.SOCKS -> ProxyBuilder.socks( + config.host, config.port + ) + } + } + } + } +} \ No newline at end of file diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/ProxyConfig.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/ProxyConfig.kt new file mode 100644 index 000000000..7da43cf58 --- /dev/null +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/ProxyConfig.kt @@ -0,0 +1,11 @@ +package zed.rainxch.core.domain.model + +data class ProxyConfig( + val type: ProxyType, + val host: String, + val port: Int, + val username: String? = null, + val password: String? = null, +) { + enum class ProxyType { HTTP, SOCKS } +} \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0b291dd12..42cc91309 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -110,6 +110,7 @@ jsystemthemedetector = { module = "com.github.Dansoftowner:jSystemThemeDetector" # Ktor ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } +ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" } ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" } ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" }