Skip to content

Commit c033c87

Browse files
committed
feat(network): Refactor ProxyConfig to a sealed class and add system proxy support
This commit refines the proxy configuration model and enhances how the application handles network proxies across Android and JVM platforms. The previous `ProxyConfig` data class has been replaced with a sealed class hierarchy to explicitly support "None", "System", "HTTP", and "SOCKS" configurations. - **feat(domain)**: Refactored `ProxyConfig` into a sealed class with `None`, `System`, `Http`, and `Socks` subtypes. - **feat(data)**: Added support for automatic system proxy detection in `HttpClientFactory` for both Android (using `ProxySelector`) and JVM (via `java.net.ProxySelector`). - **feat(data)**: Improved `ProxyManager` with dedicated methods for setting different proxy types (`setNoProxy`, `setSystemProxy`, `setHttpProxy`, `setSocksProxy`). - **refactor(data)**: Updated `GitHubClientProvider` to use a more robust thread-safe mechanism (using `Mutex`) for recreating the `HttpClient` when proxy settings change. - **fix(android)**: Added support for SOCKS proxy authentication on Android using `java.net.Authenticator`. - **fix(jvm)**: Implemented manual system proxy resolution in the CIO engine by selecting the appropriate proxy for GitHub API URIs.
1 parent 4c3af65 commit c033c87

6 files changed

Lines changed: 158 additions & 65 deletions

File tree

core/data/src/androidMain/kotlin/zed/rainxch/core/data/network/HttpClientFactory.android.kt

Lines changed: 51 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6,32 +6,67 @@ import java.net.InetSocketAddress
66
import java.net.Proxy
77
import okhttp3.Credentials
88
import zed.rainxch.core.domain.model.ProxyConfig
9+
import java.net.PasswordAuthentication
10+
import java.net.ProxySelector
911

10-
actual fun createPlatformHttpClient(proxyConfig: ProxyConfig?): HttpClient {
12+
actual fun createPlatformHttpClient(proxyConfig: ProxyConfig): HttpClient {
1113
return HttpClient(OkHttp) {
1214
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
15+
when (proxyConfig) {
16+
is ProxyConfig.None -> {
17+
proxy = Proxy.NO_PROXY
1718
}
18-
proxy = Proxy(javaProxyType, InetSocketAddress(config.host, config.port))
1919

20-
if (config.username != null) {
20+
is ProxyConfig.System -> {
2121
config {
22-
proxyAuthenticator { _, response ->
23-
response.request.newBuilder()
24-
.header(
25-
"Proxy-Authorization",
26-
Credentials.basic(
27-
config.username!!,
28-
config.password.orEmpty()
22+
proxySelector(ProxySelector.getDefault())
23+
}
24+
}
25+
26+
is ProxyConfig.Http -> {
27+
proxy = Proxy(
28+
Proxy.Type.HTTP,
29+
InetSocketAddress(proxyConfig.host, proxyConfig.port)
30+
)
31+
if (proxyConfig.username != null) {
32+
config {
33+
proxyAuthenticator { _, response ->
34+
response.request.newBuilder()
35+
.header(
36+
"Proxy-Authorization",
37+
Credentials.basic(
38+
proxyConfig.username!!,
39+
proxyConfig.password.orEmpty()
40+
)
2941
)
30-
)
31-
.build()
42+
.build()
43+
}
3244
}
3345
}
3446
}
47+
48+
is ProxyConfig.Socks -> {
49+
proxy = Proxy(
50+
Proxy.Type.SOCKS,
51+
InetSocketAddress(proxyConfig.host, proxyConfig.port)
52+
)
53+
54+
if (proxyConfig.username != null) {
55+
java.net.Authenticator.setDefault(object : java.net.Authenticator() {
56+
override fun getPasswordAuthentication(): PasswordAuthentication? {
57+
if (requestingHost == proxyConfig.host &&
58+
requestingPort == proxyConfig.port
59+
) {
60+
return PasswordAuthentication(
61+
proxyConfig.username,
62+
proxyConfig.password.orEmpty().toCharArray()
63+
)
64+
}
65+
return null
66+
}
67+
})
68+
}
69+
}
3570
}
3671
}
3772
}

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

Lines changed: 30 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -4,50 +4,57 @@ import io.ktor.client.HttpClient
44
import kotlinx.coroutines.CoroutineScope
55
import kotlinx.coroutines.Dispatchers
66
import kotlinx.coroutines.SupervisorJob
7+
import kotlinx.coroutines.cancel
78
import kotlinx.coroutines.flow.Flow
89
import kotlinx.coroutines.flow.MutableStateFlow
910
import kotlinx.coroutines.flow.SharingStarted
11+
import kotlinx.coroutines.flow.StateFlow
1012
import kotlinx.coroutines.flow.distinctUntilChanged
1113
import kotlinx.coroutines.flow.map
1214
import kotlinx.coroutines.flow.stateIn
15+
import kotlinx.coroutines.sync.Mutex
16+
import kotlinx.coroutines.sync.withLock
1317
import zed.rainxch.core.data.data_source.TokenStore
1418
import zed.rainxch.core.domain.model.ProxyConfig
1519
import zed.rainxch.core.domain.repository.RateLimitRepository
1620

1721
class GitHubClientProvider(
1822
private val tokenStore: TokenStore,
1923
private val rateLimitRepository: RateLimitRepository,
20-
proxyConfigFlow: Flow<ProxyConfig?>
24+
proxyConfigFlow: StateFlow<ProxyConfig>
2125
) {
22-
private val _client = MutableStateFlow<HttpClient?>(null)
26+
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
27+
private var currentClient: HttpClient? = null
28+
private val mutex = Mutex()
2329

24-
val client: Flow<HttpClient> = proxyConfigFlow
25-
.distinctUntilChanged()
30+
private val _client: StateFlow<HttpClient> = proxyConfigFlow
2631
.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
32+
mutex.withLock {
33+
currentClient?.close()
34+
val newClient = createGitHubHttpClient(
35+
tokenStore = tokenStore,
36+
rateLimitRepository = rateLimitRepository,
37+
proxyConfig = proxyConfig
38+
)
39+
currentClient = newClient
40+
newClient
41+
}
3642
}
3743
.stateIn(
38-
scope = CoroutineScope(SupervisorJob() + Dispatchers.Default),
39-
started = SharingStarted.Lazily,
40-
initialValue = createGitHubHttpClient(tokenStore, rateLimitRepository)
44+
scope = scope,
45+
started = SharingStarted.Eagerly,
46+
initialValue = createGitHubHttpClient(
47+
tokenStore = tokenStore,
48+
rateLimitRepository = rateLimitRepository,
49+
proxyConfig = proxyConfigFlow.value
50+
).also { currentClient = it }
4151
)
4252

43-
fun currentClient(): HttpClient {
44-
return _client.value
45-
?: createGitHubHttpClient(tokenStore, rateLimitRepository).also {
46-
_client.value = it
47-
}
48-
}
53+
/** Get the current HttpClient (always up to date with proxy settings) */
54+
val client: HttpClient get() = _client.value
4955

5056
fun close() {
51-
_client.value?.close()
57+
currentClient?.close()
58+
scope.cancel()
5259
}
5360
}

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

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,20 +19,19 @@ import zed.rainxch.core.domain.repository.RateLimitRepository
1919
import java.io.IOException
2020
import kotlin.coroutines.cancellation.CancellationException
2121

22-
expect fun createPlatformHttpClient(proxyConfig: ProxyConfig? = null): HttpClient
22+
expect fun createPlatformHttpClient(proxyConfig: ProxyConfig): HttpClient
2323

2424
fun createGitHubHttpClient(
2525
tokenStore: TokenStore,
2626
rateLimitRepository: RateLimitRepository,
27-
proxyConfig: ProxyConfig? = null
27+
proxyConfig: ProxyConfig = ProxyConfig.None
2828
): HttpClient {
2929
val json = Json {
3030
ignoreUnknownKeys = true
3131
isLenient = true
3232
}
3333

3434
return createPlatformHttpClient(proxyConfig).config {
35-
3635
install(RateLimitInterceptor) {
3736
this.rateLimitRepository = rateLimitRepository
3837
}
Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,37 @@
11
package zed.rainxch.core.data.network
22

33
import kotlinx.coroutines.flow.MutableStateFlow
4+
import kotlinx.coroutines.flow.StateFlow
45
import kotlinx.coroutines.flow.asStateFlow
5-
import kotlinx.coroutines.flow.update
66
import zed.rainxch.core.domain.model.ProxyConfig
77

88
object ProxyManager {
9-
private val _proxyConfig = MutableStateFlow<ProxyConfig?>(null)
10-
val currentProxyConfig = _proxyConfig.asStateFlow()
9+
private val _proxyConfig = MutableStateFlow<ProxyConfig>(ProxyConfig.None)
10+
val currentProxyConfig: StateFlow<ProxyConfig> = _proxyConfig.asStateFlow()
1111

12-
fun setProxyConfig(
13-
config: ProxyConfig?
12+
fun setNoProxy() {
13+
_proxyConfig.value = ProxyConfig.None
14+
}
15+
16+
fun setSystemProxy() {
17+
_proxyConfig.value = ProxyConfig.System
18+
}
19+
20+
fun setHttpProxy(
21+
host: String,
22+
port: Int,
23+
username: String? = null,
24+
password: String? = null
25+
) {
26+
_proxyConfig.value = ProxyConfig.Http(host, port, username, password)
27+
}
28+
29+
fun setSocksProxy(
30+
host: String,
31+
port: Int,
32+
username: String? = null,
33+
password: String? = null
1434
) {
15-
_proxyConfig.update { config }
35+
_proxyConfig.value = ProxyConfig.Socks(host, port, username, password)
1636
}
1737
}

core/data/src/jvmMain/kotlin/zed/rainxch/core/data/network/HttpClientFactory.jvm.kt

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,40 @@ import io.ktor.client.engine.ProxyBuilder
55
import io.ktor.client.engine.cio.CIO
66
import io.ktor.http.Url
77
import zed.rainxch.core.domain.model.ProxyConfig
8+
import java.net.ProxySelector
9+
import java.net.URI
810

9-
actual fun createPlatformHttpClient(proxyConfig: ProxyConfig?): HttpClient {
11+
actual fun createPlatformHttpClient(proxyConfig: ProxyConfig): HttpClient {
1012
return HttpClient(CIO) {
1113
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-
)
14+
proxy = when (proxyConfig) {
15+
is ProxyConfig.None -> null
16+
17+
is ProxyConfig.System -> {
18+
val systemProxy = ProxySelector.getDefault()
19+
?.select(URI("https://api.github.com"))
20+
?.firstOrNull { it.type() != java.net.Proxy.Type.DIRECT }
21+
22+
if (systemProxy != null) {
23+
val addr = systemProxy.address() as? java.net.InetSocketAddress
24+
if (addr != null) {
25+
when (systemProxy.type()) {
26+
java.net.Proxy.Type.HTTP ->
27+
ProxyBuilder.http(Url("http://${addr.hostString}:${addr.port}"))
28+
java.net.Proxy.Type.SOCKS ->
29+
ProxyBuilder.socks(addr.hostString, addr.port)
30+
else -> null
31+
}
32+
} else null
33+
} else null
34+
}
35+
36+
is ProxyConfig.Http -> {
37+
ProxyBuilder.http(Url("http://${proxyConfig.host}:${proxyConfig.port}"))
38+
}
39+
40+
is ProxyConfig.Socks -> {
41+
ProxyBuilder.socks(proxyConfig.host, proxyConfig.port)
2042
}
2143
}
2244
}
Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,21 @@
11
package zed.rainxch.core.domain.model
22

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 }
3+
sealed class ProxyConfig {
4+
data object None : ProxyConfig()
5+
6+
data object System : ProxyConfig()
7+
8+
data class Http(
9+
val host: String,
10+
val port: Int,
11+
val username: String? = null,
12+
val password: String? = null,
13+
) : ProxyConfig()
14+
15+
data class Socks(
16+
val host: String,
17+
val port: Int,
18+
val username: String? = null,
19+
val password: String? = null,
20+
) : ProxyConfig()
1121
}

0 commit comments

Comments
 (0)