diff --git a/.claude/settings.local.json b/.claude/settings.local.json index aab7f875..81c0379b 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -1,7 +1,10 @@ { "permissions": { "allow": [ - "Bash(grep -r \"\\\\.FLATPAK\\\\|Flatpak\\\\|flatpak\" D:/development/Github-Store --include=*.kt)" + "Bash(grep -r \"\\\\.FLATPAK\\\\|Flatpak\\\\|flatpak\" . --include=*.kt)", + "Bash(./gradlew :composeApp:assembleDebug)", + "Bash(./gradlew :composeApp:jvmJar)", + "Bash(./gradlew :composeApp:assembleDebug :composeApp:jvmJar)" ] } } diff --git a/composeApp/release/baselineProfiles/0/composeApp-release.dm b/composeApp/release/baselineProfiles/0/composeApp-release.dm index 838e163f..ce6abdff 100644 Binary files a/composeApp/release/baselineProfiles/0/composeApp-release.dm and b/composeApp/release/baselineProfiles/0/composeApp-release.dm differ diff --git a/composeApp/release/baselineProfiles/1/composeApp-release.dm b/composeApp/release/baselineProfiles/1/composeApp-release.dm index 6ab7c2f2..dd27f85c 100644 Binary files a/composeApp/release/baselineProfiles/1/composeApp-release.dm and b/composeApp/release/baselineProfiles/1/composeApp-release.dm differ 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 index 9afc5b53..89c9e577 100644 --- 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 @@ -8,7 +8,6 @@ import java.net.Authenticator import java.net.InetSocketAddress import java.net.PasswordAuthentication import java.net.Proxy -import java.net.ProxySelector actual fun createPlatformHttpClient(proxyConfig: ProxyConfig): HttpClient { Authenticator.setDefault(null) @@ -21,9 +20,11 @@ actual fun createPlatformHttpClient(proxyConfig: ProxyConfig): HttpClient { } is ProxyConfig.System -> { - config { - proxySelector(ProxySelector.getDefault()) - } + // java.net.ProxySelector.getDefault() does not read Android's + // per-network HTTP proxy. Android publishes the active proxy + // through standard system properties instead, which we resolve + // explicitly here so traffic actually flows through it. + proxy = resolveAndroidSystemProxy() } is ProxyConfig.Http -> { @@ -78,3 +79,22 @@ actual fun createPlatformHttpClient(proxyConfig: ProxyConfig): HttpClient { } } } + +internal fun resolveAndroidSystemProxy(): Proxy { + // System properties are user/OS-supplied, so guard against malformed + // values: InetSocketAddress(String, Int) throws IllegalArgumentException + // for ports outside 0..65535. + val httpsHost = System.getProperty("https.proxyHost")?.takeIf { it.isNotBlank() } + val httpsPort = System.getProperty("https.proxyPort")?.toIntOrNull()?.takeIf { it in 1..65535 } + if (httpsHost != null && httpsPort != null) { + return Proxy(Proxy.Type.HTTP, InetSocketAddress(httpsHost, httpsPort)) + } + + val httpHost = System.getProperty("http.proxyHost")?.takeIf { it.isNotBlank() } + val httpPort = System.getProperty("http.proxyPort")?.toIntOrNull()?.takeIf { it in 1..65535 } + if (httpHost != null && httpPort != null) { + return Proxy(Proxy.Type.HTTP, InetSocketAddress(httpHost, httpPort)) + } + + return Proxy.NO_PROXY +} diff --git a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AndroidDownloader.kt b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AndroidDownloader.kt index 56455f98..43d90c7d 100644 --- a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AndroidDownloader.kt +++ b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AndroidDownloader.kt @@ -11,6 +11,7 @@ import okhttp3.Credentials import okhttp3.OkHttpClient import okhttp3.Request import zed.rainxch.core.data.network.ProxyManager +import zed.rainxch.core.data.network.resolveAndroidSystemProxy import zed.rainxch.core.domain.model.DownloadProgress import zed.rainxch.core.domain.model.ProxyConfig import zed.rainxch.core.domain.network.Downloader @@ -44,7 +45,12 @@ class AndroidDownloader( proxy(Proxy.NO_PROXY) } - is ProxyConfig.System -> {} + is ProxyConfig.System -> { + // ProxySelector.getDefault() does not honor Android's + // per-network HTTP proxy; resolve it explicitly so + // downloads also flow through the device proxy. + proxy(resolveAndroidSystemProxy()) + } is ProxyConfig.Http -> { proxy(Proxy(Proxy.Type.HTTP, InetSocketAddress(config.host, config.port))) 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 f42f1f10..6283fa61 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 @@ -22,6 +22,7 @@ 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.ProxyTesterImpl import zed.rainxch.core.data.network.createGitHubHttpClient import zed.rainxch.core.data.repository.AuthenticationStateImpl import zed.rainxch.core.data.repository.FavouritesRepositoryImpl @@ -36,6 +37,7 @@ import zed.rainxch.core.domain.getPlatform import zed.rainxch.core.domain.logging.GitHubStoreLogger import zed.rainxch.core.domain.model.Platform import zed.rainxch.core.domain.model.ProxyConfig +import zed.rainxch.core.domain.network.ProxyTester import zed.rainxch.core.domain.repository.AuthenticationState import zed.rainxch.core.domain.repository.FavouritesRepository import zed.rainxch.core.domain.repository.InstalledAppsRepository @@ -117,6 +119,10 @@ val coreModule = ) } + single { + ProxyTesterImpl() + } + single { SyncInstalledAppsUseCase( packageMonitor = get(), diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/ProxyTesterImpl.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/ProxyTesterImpl.kt new file mode 100644 index 00000000..a3b8a115 --- /dev/null +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/ProxyTesterImpl.kt @@ -0,0 +1,70 @@ +package zed.rainxch.core.data.network + +import io.ktor.client.plugins.HttpRequestTimeoutException +import io.ktor.client.plugins.HttpTimeout +import io.ktor.client.request.get +import io.ktor.client.statement.HttpResponse +import io.ktor.util.network.UnresolvedAddressException +import zed.rainxch.core.domain.model.ProxyConfig +import zed.rainxch.core.domain.network.ProxyTestOutcome +import zed.rainxch.core.domain.network.ProxyTester +import java.io.IOException +import java.net.ConnectException +import java.net.SocketTimeoutException +import java.net.UnknownHostException +import kotlin.coroutines.cancellation.CancellationException +import kotlin.time.TimeSource + +class ProxyTesterImpl : ProxyTester { + override suspend fun test(config: ProxyConfig): ProxyTestOutcome { + val client = + createPlatformHttpClient(config).config { + install(HttpTimeout) { + requestTimeoutMillis = TEST_TIMEOUT_MS + connectTimeoutMillis = TEST_TIMEOUT_MS + socketTimeoutMillis = TEST_TIMEOUT_MS + } + expectSuccess = false + } + + return try { + val started = TimeSource.Monotonic.markNow() + val response: HttpResponse = client.get(TEST_URL) + val elapsed = started.elapsedNow().inWholeMilliseconds + + when { + response.status.value == 407 -> + ProxyTestOutcome.Failure.ProxyAuthRequired + + response.status.value in 200..299 -> + ProxyTestOutcome.Success(latencyMs = elapsed) + + else -> + ProxyTestOutcome.Failure.UnexpectedResponse(response.status.value) + } + } catch (e: CancellationException) { + throw e + } catch (e: HttpRequestTimeoutException) { + ProxyTestOutcome.Failure.Timeout + } catch (e: SocketTimeoutException) { + ProxyTestOutcome.Failure.Timeout + } catch (e: UnresolvedAddressException) { + ProxyTestOutcome.Failure.DnsFailure + } catch (e: UnknownHostException) { + ProxyTestOutcome.Failure.DnsFailure + } catch (e: ConnectException) { + ProxyTestOutcome.Failure.ProxyUnreachable + } catch (e: IOException) { + ProxyTestOutcome.Failure.Unknown(e.message) + } catch (e: Exception) { + ProxyTestOutcome.Failure.Unknown(e.message) + } finally { + client.close() + } + } + + private companion object { + const val TEST_URL = "https://api.github.com/zen" + const val TEST_TIMEOUT_MS = 8_000L + } +} diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/network/ProxyTester.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/network/ProxyTester.kt new file mode 100644 index 00000000..076789b0 --- /dev/null +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/network/ProxyTester.kt @@ -0,0 +1,44 @@ +package zed.rainxch.core.domain.network + +import zed.rainxch.core.domain.model.ProxyConfig + +/** + * Verifies that a [ProxyConfig] can actually reach the GitHub API. Implementations + * should issue a single lightweight request through a throwaway HTTP client built + * with the supplied config so the test exercises the same engine code path the + * real client uses. + */ +interface ProxyTester { + suspend fun test(config: ProxyConfig): ProxyTestOutcome +} + +sealed interface ProxyTestOutcome { + /** Connection succeeded. [latencyMs] is the round-trip time of the test request. */ + data class Success( + val latencyMs: Long, + ) : ProxyTestOutcome + + sealed interface Failure : ProxyTestOutcome { + /** Could not resolve a hostname (DNS failure or unresolved proxy host). */ + data object DnsFailure : Failure + + /** Reached the network but could not connect to the proxy itself. */ + data object ProxyUnreachable : Failure + + /** Connection or socket timed out. */ + data object Timeout : Failure + + /** Proxy returned 407 / requested authentication. */ + data object ProxyAuthRequired : Failure + + /** Proxy or upstream returned a non-2xx HTTP status. */ + data class UnexpectedResponse( + val statusCode: Int, + ) : Failure + + /** Anything else (TLS errors, malformed config, etc.). */ + data class Unknown( + val message: String?, + ) : Failure + } +} diff --git a/core/presentation/src/commonMain/composeResources/values-ar/strings-ar.xml b/core/presentation/src/commonMain/composeResources/values-ar/strings-ar.xml index 5f46a32c..843a8446 100644 --- a/core/presentation/src/commonMain/composeResources/values-ar/strings-ar.xml +++ b/core/presentation/src/commonMain/composeResources/values-ar/strings-ar.xml @@ -149,6 +149,15 @@ منفذ الوكيل غير صالح إظهار كلمة المرور إخفاء كلمة المرور + اختبار + جارٍ الاختبار… + الاتصال ناجح (%1$d مللي ثانية) + تعذر تحليل المضيف. تحقق من عنوان الوكيل. + تعذر الوصول إلى خادم الوكيل. + انتهت مهلة الاتصال. + يلزم التحقق من الوكيل. + استجابة غير متوقعة: HTTP %1$d + فشل اختبار الاتصال تم تسجيل الخروج بنجاح، جارٍ إعادة التوجيه... diff --git a/core/presentation/src/commonMain/composeResources/values-bn/strings-bn.xml b/core/presentation/src/commonMain/composeResources/values-bn/strings-bn.xml index 83f26e80..9ecb5171 100644 --- a/core/presentation/src/commonMain/composeResources/values-bn/strings-bn.xml +++ b/core/presentation/src/commonMain/composeResources/values-bn/strings-bn.xml @@ -410,6 +410,15 @@ অবৈধ প্রক্সি পোর্ট পাসওয়ার্ড দেখান পাসওয়ার্ড লুকান + পরীক্ষা + পরীক্ষা চলছে… + সংযোগ ঠিক আছে (%1$d ms) + হোস্ট সমাধান করা যায়নি। প্রক্সি ঠিকানা যাচাই করুন। + প্রক্সি সার্ভারে পৌঁছানো যায়নি। + সংযোগের সময় শেষ হয়ে গেছে। + প্রক্সি প্রমাণীকরণ প্রয়োজন। + অপ্রত্যাশিত প্রতিক্রিয়া: HTTP %1$d + সংযোগ পরীক্ষা ব্যর্থ diff --git a/core/presentation/src/commonMain/composeResources/values-es/strings-es.xml b/core/presentation/src/commonMain/composeResources/values-es/strings-es.xml index f7ec7bb3..34cf5e9b 100644 --- a/core/presentation/src/commonMain/composeResources/values-es/strings-es.xml +++ b/core/presentation/src/commonMain/composeResources/values-es/strings-es.xml @@ -375,6 +375,15 @@ Puerto de proxy no válido Mostrar contraseña Ocultar contraseña + Probar + Probando… + Conexión correcta (%1$d ms) + No se pudo resolver el host. Verifica la dirección del proxy. + No se pudo conectar con el servidor proxy. + Se agotó el tiempo de conexión. + Se requiere autenticación del proxy. + Respuesta inesperada: HTTP %1$d + La prueba de conexión falló diff --git a/core/presentation/src/commonMain/composeResources/values-fr/strings-fr.xml b/core/presentation/src/commonMain/composeResources/values-fr/strings-fr.xml index 1ba038f7..3af132c1 100644 --- a/core/presentation/src/commonMain/composeResources/values-fr/strings-fr.xml +++ b/core/presentation/src/commonMain/composeResources/values-fr/strings-fr.xml @@ -375,6 +375,15 @@ Port proxy invalide Afficher le mot de passe Masquer le mot de passe + Tester + Test en cours… + Connexion OK (%1$d ms) + Impossible de résoudre l\'hôte. Vérifiez l\'adresse du proxy. + Impossible de joindre le serveur proxy. + Délai de connexion dépassé. + Authentification proxy requise. + Réponse inattendue : HTTP %1$d + Échec du test de connexion diff --git a/core/presentation/src/commonMain/composeResources/values-hi/strings-hi.xml b/core/presentation/src/commonMain/composeResources/values-hi/strings-hi.xml index ffdd1973..23ce87ce 100644 --- a/core/presentation/src/commonMain/composeResources/values-hi/strings-hi.xml +++ b/core/presentation/src/commonMain/composeResources/values-hi/strings-hi.xml @@ -409,6 +409,15 @@ अमान्य प्रॉक्सी पोर्ट पासवर्ड दिखाएँ पासवर्ड छुपाएँ + परीक्षण + परीक्षण हो रहा है… + कनेक्शन ठीक है (%1$d ms) + होस्ट हल नहीं हो सका। प्रॉक्सी पता जाँचें। + प्रॉक्सी सर्वर तक नहीं पहुँचा जा सका। + कनेक्शन समय समाप्त हो गया। + प्रॉक्सी प्रमाणीकरण आवश्यक है। + अप्रत्याशित प्रतिक्रिया: HTTP %1$d + कनेक्शन परीक्षण विफल diff --git a/core/presentation/src/commonMain/composeResources/values-it/strings-it.xml b/core/presentation/src/commonMain/composeResources/values-it/strings-it.xml index 7a99a8ab..fc49c3a1 100644 --- a/core/presentation/src/commonMain/composeResources/values-it/strings-it.xml +++ b/core/presentation/src/commonMain/composeResources/values-it/strings-it.xml @@ -411,6 +411,15 @@ Porta proxy non valida Mostra password Nascondi password + Verifica + Verifica in corso… + Connessione OK (%1$d ms) + Impossibile risolvere l\'host. Controlla l\'indirizzo del proxy. + Impossibile raggiungere il server proxy. + Timeout della connessione. + Autenticazione proxy richiesta. + Risposta inattesa: HTTP %1$d + Verifica connessione non riuscita diff --git a/core/presentation/src/commonMain/composeResources/values-ja/strings-ja.xml b/core/presentation/src/commonMain/composeResources/values-ja/strings-ja.xml index 9f96e4ba..d7615153 100644 --- a/core/presentation/src/commonMain/composeResources/values-ja/strings-ja.xml +++ b/core/presentation/src/commonMain/composeResources/values-ja/strings-ja.xml @@ -375,6 +375,15 @@ 無効なプロキシポート パスワードを表示 パスワードを非表示 + テスト + テスト中… + 接続OK (%1$d ms) + ホストを解決できません。プロキシアドレスを確認してください。 + プロキシサーバーに接続できません。 + 接続がタイムアウトしました。 + プロキシ認証が必要です。 + 予期しない応答:HTTP %1$d + 接続テストに失敗しました diff --git a/core/presentation/src/commonMain/composeResources/values-ko/strings-ko.xml b/core/presentation/src/commonMain/composeResources/values-ko/strings-ko.xml index 039cab47..2d99333c 100644 --- a/core/presentation/src/commonMain/composeResources/values-ko/strings-ko.xml +++ b/core/presentation/src/commonMain/composeResources/values-ko/strings-ko.xml @@ -408,6 +408,15 @@ 잘못된 프록시 포트 비밀번호 표시 비밀번호 숨기기 + 테스트 + 테스트 중… + 연결 성공 (%1$d ms) + 호스트를 확인할 수 없습니다. 프록시 주소를 확인하세요. + 프록시 서버에 연결할 수 없습니다. + 연결 시간이 초과되었습니다. + 프록시 인증이 필요합니다. + 예기치 않은 응답: HTTP %1$d + 연결 테스트 실패 diff --git a/core/presentation/src/commonMain/composeResources/values-pl/strings-pl.xml b/core/presentation/src/commonMain/composeResources/values-pl/strings-pl.xml index 5c76bd83..756f3572 100644 --- a/core/presentation/src/commonMain/composeResources/values-pl/strings-pl.xml +++ b/core/presentation/src/commonMain/composeResources/values-pl/strings-pl.xml @@ -373,6 +373,15 @@ Nieprawidłowy port proxy Pokaż hasło Ukryj hasło + Testuj + Testowanie… + Połączenie OK (%1$d ms) + Nie można rozwiązać hosta. Sprawdź adres proxy. + Nie można połączyć się z serwerem proxy. + Upłynął limit czasu połączenia. + Wymagane uwierzytelnienie proxy. + Nieoczekiwana odpowiedź: HTTP %1$d + Test połączenia nie powiódł się diff --git a/core/presentation/src/commonMain/composeResources/values-ru/strings-ru.xml b/core/presentation/src/commonMain/composeResources/values-ru/strings-ru.xml index 6eeb2e75..33683cd1 100644 --- a/core/presentation/src/commonMain/composeResources/values-ru/strings-ru.xml +++ b/core/presentation/src/commonMain/composeResources/values-ru/strings-ru.xml @@ -375,6 +375,15 @@ Недопустимый порт прокси Показать пароль Скрыть пароль + Проверить + Проверка… + Соединение в порядке (%1$d мс) + Не удалось разрешить хост. Проверьте адрес прокси. + Не удалось подключиться к прокси-серверу. + Время ожидания истекло. + Требуется аутентификация прокси. + Неожиданный ответ: HTTP %1$d + Не удалось проверить соединение diff --git a/core/presentation/src/commonMain/composeResources/values-tr/strings-tr.xml b/core/presentation/src/commonMain/composeResources/values-tr/strings-tr.xml index a8301596..f43b397d 100644 --- a/core/presentation/src/commonMain/composeResources/values-tr/strings-tr.xml +++ b/core/presentation/src/commonMain/composeResources/values-tr/strings-tr.xml @@ -407,6 +407,15 @@ Geçersiz proxy portu Şifreyi göster Şifreyi gizle + Test + Test ediliyor… + Bağlantı tamam (%1$d ms) + Sunucu adresi çözümlenemedi. Proxy adresini kontrol edin. + Proxy sunucusuna ulaşılamadı. + Bağlantı zaman aşımına uğradı. + Proxy kimlik doğrulaması gerekiyor. + Beklenmeyen yanıt: HTTP %1$d + Bağlantı testi başarısız diff --git a/core/presentation/src/commonMain/composeResources/values-zh-rCN/strings-zh-rCN.xml b/core/presentation/src/commonMain/composeResources/values-zh-rCN/strings-zh-rCN.xml index 9c9c085e..2c008681 100644 --- a/core/presentation/src/commonMain/composeResources/values-zh-rCN/strings-zh-rCN.xml +++ b/core/presentation/src/commonMain/composeResources/values-zh-rCN/strings-zh-rCN.xml @@ -376,6 +376,15 @@ 无效的代理端口 显示密码 隐藏密码 + 测试 + 测试中… + 连接正常 (%1$d ms) + 无法解析主机。请检查代理地址。 + 无法连接到代理服务器。 + 连接超时。 + 需要代理身份验证。 + 意外响应:HTTP %1$d + 连接测试失败 diff --git a/core/presentation/src/commonMain/composeResources/values/strings.xml b/core/presentation/src/commonMain/composeResources/values/strings.xml index fed21943..9ad81f84 100644 --- a/core/presentation/src/commonMain/composeResources/values/strings.xml +++ b/core/presentation/src/commonMain/composeResources/values/strings.xml @@ -152,6 +152,15 @@ Invalid proxy port Show password Hide password + Test + Testing… + Connection OK (%1$d ms) + Could not resolve host. Check the proxy address. + Could not reach the proxy server. + Connection timed out. + Proxy authentication required. + Unexpected response: HTTP %1$d + Connection test failed Logged out successfully, redirecting... diff --git a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksAction.kt b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksAction.kt index 4d5e7c76..41511304 100644 --- a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksAction.kt +++ b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksAction.kt @@ -56,6 +56,8 @@ sealed interface TweaksAction { data object OnProxySave : TweaksAction + data object OnProxyTest : TweaksAction + data class OnInstallerTypeSelected( val type: InstallerType, ) : TweaksAction diff --git a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksEvent.kt b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksEvent.kt index f711b5d4..73ae34ca 100644 --- a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksEvent.kt +++ b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksEvent.kt @@ -7,6 +7,14 @@ sealed interface TweaksEvent { val message: String, ) : TweaksEvent + data class OnProxyTestSuccess( + val latencyMs: Long, + ) : TweaksEvent + + data class OnProxyTestError( + val message: String, + ) : TweaksEvent + data object OnCacheCleared : TweaksEvent data class OnCacheClearError( diff --git a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksRoot.kt b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksRoot.kt index 4ec18e88..d89d6594 100644 --- a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksRoot.kt +++ b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksRoot.kt @@ -36,6 +36,7 @@ import zed.rainxch.core.presentation.utils.ObserveAsEvents import zed.rainxch.githubstore.core.presentation.res.Res import zed.rainxch.githubstore.core.presentation.res.downloads_cleared import zed.rainxch.githubstore.core.presentation.res.proxy_saved +import zed.rainxch.githubstore.core.presentation.res.proxy_test_success import zed.rainxch.githubstore.core.presentation.res.seen_history_cleared import zed.rainxch.githubstore.core.presentation.res.tweaks_title import zed.rainxch.tweaks.presentation.components.ClearDownloadsDialog @@ -77,6 +78,20 @@ fun TweaksRoot(viewModel: TweaksViewModel = koinViewModel()) { } } + is TweaksEvent.OnProxyTestSuccess -> { + coroutineScope.launch { + snackbarState.showSnackbar( + getString(Res.string.proxy_test_success, event.latencyMs), + ) + } + } + + is TweaksEvent.OnProxyTestError -> { + coroutineScope.launch { + snackbarState.showSnackbar(event.message) + } + } + TweaksEvent.OnCacheCleared -> { coroutineScope.launch { snackbarState.showSnackbar(getString(Res.string.downloads_cleared)) diff --git a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksState.kt b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksState.kt index 2eb1ab00..a2d78052 100644 --- a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksState.kt +++ b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksState.kt @@ -18,6 +18,7 @@ data class TweaksState( val proxyUsername: String = "", val proxyPassword: String = "", val isProxyPasswordVisible: Boolean = false, + val isProxyTestInProgress: Boolean = false, val autoDetectClipboardLinks: Boolean = true, val cacheSize: String = "", val isClearDownloadsDialogVisible: Boolean = false, diff --git a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksViewModel.kt b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksViewModel.kt index de26ebf7..7b7153d0 100644 --- a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksViewModel.kt +++ b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksViewModel.kt @@ -2,6 +2,7 @@ package zed.rainxch.tweaks.presentation import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Job import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow @@ -13,6 +14,8 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.jetbrains.compose.resources.getString import zed.rainxch.core.domain.model.ProxyConfig +import zed.rainxch.core.domain.network.ProxyTestOutcome +import zed.rainxch.core.domain.network.ProxyTester import zed.rainxch.core.domain.repository.ProxyRepository import zed.rainxch.core.domain.repository.SeenReposRepository import zed.rainxch.core.domain.repository.TweaksRepository @@ -23,6 +26,12 @@ import zed.rainxch.githubstore.core.presentation.res.Res import zed.rainxch.githubstore.core.presentation.res.failed_to_save_proxy_settings import zed.rainxch.githubstore.core.presentation.res.invalid_proxy_port import zed.rainxch.githubstore.core.presentation.res.proxy_host_required +import zed.rainxch.githubstore.core.presentation.res.proxy_test_error_auth_required +import zed.rainxch.githubstore.core.presentation.res.proxy_test_error_dns +import zed.rainxch.githubstore.core.presentation.res.proxy_test_error_status +import zed.rainxch.githubstore.core.presentation.res.proxy_test_error_timeout +import zed.rainxch.githubstore.core.presentation.res.proxy_test_error_unknown +import zed.rainxch.githubstore.core.presentation.res.proxy_test_error_unreachable import zed.rainxch.profile.domain.repository.ProfileRepository import zed.rainxch.tweaks.presentation.model.ProxyType @@ -32,6 +41,7 @@ class TweaksViewModel( private val profileRepository: ProfileRepository, private val installerStatusProvider: InstallerStatusProvider, private val proxyRepository: ProxyRepository, + private val proxyTester: ProxyTester, private val updateScheduleManager: UpdateScheduleManager, private val seenReposRepository: SeenReposRepository, ) : ViewModel() { @@ -396,6 +406,26 @@ class TweaksViewModel( } } + TweaksAction.OnProxyTest -> { + if (_state.value.isProxyTestInProgress) return + val config = buildProxyConfigForTest() ?: return + _state.update { it.copy(isProxyTestInProgress = true) } + viewModelScope.launch { + val outcome: ProxyTestOutcome = + try { + proxyTester.test(config) + } catch (e: CancellationException) { + // Preserve structured concurrency — never swallow. + throw e + } catch (e: Exception) { + ProxyTestOutcome.Failure.Unknown(e.message) + } finally { + _state.update { it.copy(isProxyTestInProgress = false) } + } + _events.send(outcome.toEvent()) + } + } + is TweaksAction.OnInstallerTypeSelected -> { viewModelScope.launch { tweaksRepository.setInstallerType(action.type) @@ -483,4 +513,83 @@ class TweaksViewModel( } } } + + /** + * Builds the [ProxyConfig] to test from the current form state. For + * [ProxyType.HTTP] / [ProxyType.SOCKS] this requires a valid host and port — + * if either is missing the user is told via an error event and `null` is + * returned, mirroring the validation in [TweaksAction.OnProxySave]. + */ + private fun buildProxyConfigForTest(): ProxyConfig? { + val current = _state.value + return when (current.proxyType) { + ProxyType.NONE -> ProxyConfig.None + ProxyType.SYSTEM -> ProxyConfig.System + ProxyType.HTTP, ProxyType.SOCKS -> { + val port = + current.proxyPort + .toIntOrNull() + ?.takeIf { it in 1..65535 } + ?: run { + viewModelScope.launch { + _events.send( + TweaksEvent.OnProxyTestError( + getString(Res.string.invalid_proxy_port), + ), + ) + } + return null + } + val host = + current.proxyHost.trim().takeIf { it.isNotBlank() } + ?: run { + viewModelScope.launch { + _events.send( + TweaksEvent.OnProxyTestError( + getString(Res.string.proxy_host_required), + ), + ) + } + return null + } + val username = current.proxyUsername.takeIf { it.isNotBlank() } + val password = current.proxyPassword.takeIf { it.isNotBlank() } + if (current.proxyType == ProxyType.HTTP) { + ProxyConfig.Http(host, port, username, password) + } else { + ProxyConfig.Socks(host, port, username, password) + } + } + } + } + + private suspend fun ProxyTestOutcome.toEvent(): TweaksEvent = + when (this) { + is ProxyTestOutcome.Success -> + TweaksEvent.OnProxyTestSuccess(latencyMs = latencyMs) + + ProxyTestOutcome.Failure.DnsFailure -> + TweaksEvent.OnProxyTestError(getString(Res.string.proxy_test_error_dns)) + + ProxyTestOutcome.Failure.ProxyUnreachable -> + TweaksEvent.OnProxyTestError(getString(Res.string.proxy_test_error_unreachable)) + + ProxyTestOutcome.Failure.Timeout -> + TweaksEvent.OnProxyTestError(getString(Res.string.proxy_test_error_timeout)) + + ProxyTestOutcome.Failure.ProxyAuthRequired -> + TweaksEvent.OnProxyTestError(getString(Res.string.proxy_test_error_auth_required)) + + is ProxyTestOutcome.Failure.UnexpectedResponse -> + TweaksEvent.OnProxyTestError( + getString(Res.string.proxy_test_error_status, statusCode), + ) + + is ProxyTestOutcome.Failure.Unknown -> + // Raw exception messages are platform-specific, untranslated, + // and may leak internal detail — always show the localized + // fallback to the user. The original `message` is intentionally + // dropped here; surface it via logging if diagnostics are needed. + TweaksEvent.OnProxyTestError(getString(Res.string.proxy_test_error_unknown)) + } } diff --git a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/components/sections/Network.kt b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/components/sections/Network.kt index 56959619..c77b3004 100644 --- a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/components/sections/Network.kt +++ b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/components/sections/Network.kt @@ -19,10 +19,12 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.NetworkCheck import androidx.compose.material.icons.filled.Save import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.filled.VisibilityOff import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ElevatedCard import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.FilledTonalButton @@ -30,6 +32,7 @@ import androidx.compose.material3.FilterChip import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -72,16 +75,27 @@ fun LazyListScope.networkSection( enter = expandVertically() + fadeIn(), exit = shrinkVertically() + fadeOut(), ) { - Text( - text = - when (state.proxyType) { - ProxyType.SYSTEM -> stringResource(Res.string.proxy_system_description) - else -> stringResource(Res.string.proxy_none_description) - }, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(start = 8.dp, top = 12.dp), - ) + Column { + Text( + text = + when (state.proxyType) { + ProxyType.SYSTEM -> stringResource(Res.string.proxy_system_description) + else -> stringResource(Res.string.proxy_none_description) + }, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 8.dp, top = 12.dp), + ) + + Spacer(Modifier.height(12.dp)) + + ProxyTestButton( + isInProgress = state.isProxyTestInProgress, + enabled = !state.isProxyTestInProgress, + onClick = { onAction(TweaksAction.OnProxyTest) }, + modifier = Modifier.padding(start = 8.dp), + ) + } } AnimatedVisibility( @@ -266,20 +280,64 @@ private fun ProxyDetailsCard( shape = RoundedCornerShape(12.dp), ) - // Save button - FilledTonalButton( - onClick = { onAction(TweaksAction.OnProxySave) }, - enabled = isFormValid, + // Test + Save buttons + Row( modifier = Modifier.align(Alignment.End), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, ) { - Icon( - imageVector = Icons.Default.Save, - contentDescription = null, - modifier = Modifier.size(18.dp), + ProxyTestButton( + isInProgress = state.isProxyTestInProgress, + enabled = isFormValid && !state.isProxyTestInProgress, + onClick = { onAction(TweaksAction.OnProxyTest) }, ) - Spacer(Modifier.size(8.dp)) - Text(stringResource(Res.string.proxy_save)) + + FilledTonalButton( + onClick = { onAction(TweaksAction.OnProxySave) }, + enabled = isFormValid && !state.isProxyTestInProgress, + ) { + Icon( + imageVector = Icons.Default.Save, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(Modifier.size(8.dp)) + Text(stringResource(Res.string.proxy_save)) + } } } } } + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun ProxyTestButton( + isInProgress: Boolean, + enabled: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + OutlinedButton( + onClick = onClick, + enabled = enabled, + modifier = modifier, + ) { + if (isInProgress) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.primary, + ) + Spacer(Modifier.size(8.dp)) + Text(stringResource(Res.string.proxy_test_in_progress)) + } else { + Icon( + imageVector = Icons.Default.NetworkCheck, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(Modifier.size(8.dp)) + Text(stringResource(Res.string.proxy_test)) + } + } +}