From 9f11bb857d0feafc4c6a68391d1a3e502732ac16 Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Sat, 18 Apr 2026 12:09:56 +0500 Subject: [PATCH 1/5] feat: implement scoped proxy configurations - Introduce `ProxyScope` to support independent proxy settings for Discovery (GitHub API), Downloads (APK files), and Translation services. - Update `ProxyRepository` and `ProxyManager` to handle per-scope configurations with legacy migration support. - Implement `TranslationClientProvider` and update `BackendApiClient` to reactively rebuild HTTP clients when proxy settings change. - Refactor repositories to use `GitHubClientProvider` for dynamic client access. - Enhance the UI in the Tweaks section with independent proxy configuration cards for each scope. - Update localizations for proxy settings and refine telemetry descriptions across multiple languages. - Ensure the telemetry buffer is cleared before resetting the device analytics ID to prevent data leakage. --- .../core/data/services/AndroidDownloader.kt | 4 +- .../zed/rainxch/core/data/di/SharedModule.kt | 77 ++-- .../core/data/network/BackendApiClient.kt | 69 +++- .../rainxch/core/data/network/ProxyManager.kt | 40 +- .../data/network/TranslationClientProvider.kt | 53 +++ .../repository/InstalledAppsRepositoryImpl.kt | 9 +- .../data/repository/ProxyRepositoryImpl.kt | 252 +++++++------ .../data/repository/StarredRepositoryImpl.kt | 5 +- .../data/network/HttpClientFactory.jvm.kt | 63 +++- .../core/data/services/DesktopDownloader.kt | 4 +- .../rainxch/core/domain/model/ProxyScope.kt | 26 ++ .../core/domain/repository/ProxyRepository.kt | 8 +- .../composeResources/values-ar/strings-ar.xml | 9 +- .../composeResources/values-bn/strings-bn.xml | 9 +- .../composeResources/values-es/strings-es.xml | 9 +- .../composeResources/values-fr/strings-fr.xml | 9 +- .../composeResources/values-hi/strings-hi.xml | 9 +- .../composeResources/values-it/strings-it.xml | 9 +- .../composeResources/values-ja/strings-ja.xml | 9 +- .../composeResources/values-ko/strings-ko.xml | 9 +- .../composeResources/values-pl/strings-pl.xml | 9 +- .../composeResources/values-ru/strings-ru.xml | 9 +- .../composeResources/values-tr/strings-tr.xml | 9 +- .../values-zh-rCN/strings-zh-rCN.xml | 9 +- .../composeResources/values/strings.xml | 9 +- .../zed/rainxch/apps/data/di/SharedModule.kt | 2 +- .../data/repository/AppsRepositoryImpl.kt | 4 +- .../rainxch/details/data/di/SharedModule.kt | 3 +- .../repository/TranslationRepositoryImpl.kt | 6 +- .../devprofile/data/di/SharedModule.kt | 2 +- .../DeveloperProfileRepositoryImpl.kt | 5 +- .../zed/rainxch/home/data/di/SharedModule.kt | 2 +- .../data/repository/HomeRepositoryImpl.kt | 5 +- .../rainxch/profile/data/di/SharedModule.kt | 2 +- .../data/repository/ProfileRepositoryImpl.kt | 5 +- .../rainxch/search/data/di/SharedModule.kt | 2 +- .../data/repository/SearchRepositoryImpl.kt | 4 +- .../tweaks/presentation/TweaksAction.kt | 18 +- .../tweaks/presentation/TweaksState.kt | 18 +- .../tweaks/presentation/TweaksViewModel.kt | 171 +++++---- .../components/sections/Network.kt | 345 +++++++++--------- .../presentation/model/ProxyScopeFormState.kt | 18 + 42 files changed, 852 insertions(+), 487 deletions(-) create mode 100644 core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/TranslationClientProvider.kt create mode 100644 core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/ProxyScope.kt create mode 100644 feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/model/ProxyScopeFormState.kt 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 43d90c7d..2375dc36 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 @@ -14,6 +14,7 @@ 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.model.ProxyScope import zed.rainxch.core.domain.network.Downloader import java.io.File import java.net.Authenticator @@ -26,7 +27,6 @@ import java.util.concurrent.TimeUnit class AndroidDownloader( private val files: FileLocationsProvider, - private val proxyManager: ProxyManager = ProxyManager, ) : Downloader { private val activeDownloads = ConcurrentHashMap() private val activeFileNames = ConcurrentHashMap() @@ -40,7 +40,7 @@ class AndroidDownloader( .readTimeout(60, TimeUnit.SECONDS) .writeTimeout(60, TimeUnit.SECONDS) .apply { - when (val config = proxyManager.currentProxyConfig.value) { + when (val config = ProxyManager.currentConfig(ProxyScope.DOWNLOAD)) { is ProxyConfig.None -> { proxy(Proxy.NO_PROXY) } 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 b453dbf1..62f3995b 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 @@ -1,6 +1,5 @@ package zed.rainxch.core.data.di -import io.ktor.client.HttpClient import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -25,7 +24,7 @@ import zed.rainxch.core.data.network.BackendApiClient 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.network.TranslationClientProvider import zed.rainxch.core.data.repository.AuthenticationStateImpl import zed.rainxch.core.data.repository.FavouritesRepositoryImpl import zed.rainxch.core.data.repository.InstalledAppsRepositoryImpl @@ -41,6 +40,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.model.ProxyScope import zed.rainxch.core.domain.network.ProxyTester import zed.rainxch.core.domain.system.DownloadOrchestrator import zed.rainxch.core.domain.repository.AuthenticationState @@ -89,7 +89,7 @@ val coreModule = installedAppsDao = get(), historyDao = get(), installer = get(), - httpClient = get(), + clientProvider = get(), ) } @@ -98,7 +98,7 @@ val coreModule = installedAppsDao = get(), starredRepoDao = get(), platform = get(), - httpClient = get(), + clientProvider = get(), ) } @@ -144,7 +144,9 @@ val coreModule = } single { - BackendApiClient() + BackendApiClient( + proxyConfigFlow = ProxyManager.configFlow(ProxyScope.DISCOVERY), + ) } single { @@ -180,58 +182,37 @@ val coreModule = val networkModule = module { - single { - val config = - runBlocking { - runCatching { - withTimeout(1_500L) { - get().getProxyConfig().first() - } - }.getOrDefault(ProxyConfig.System) - } - - when (config) { - is ProxyConfig.None -> { - ProxyManager.setNoProxy() - } - - is ProxyConfig.System -> { - ProxyManager.setSystemProxy() - } - - is ProxyConfig.Http -> { - ProxyManager.setHttpProxy( - host = config.host, - port = config.port, - username = config.username, - password = config.password, - ) - } - - is ProxyConfig.Socks -> { - ProxyManager.setSocksProxy( - host = config.host, - port = config.port, - username = config.username, - password = config.password, - ) - } + // Seed ProxyManager from persisted per-scope configs *before* any + // HTTP client is constructed. Blocks briefly (≤1.5s per scope) on + // DataStore reads so the very first request uses the user's saved + // proxy rather than the System default. Failures are swallowed and + // fall back to System — we'd rather network work than the app stall + // on startup if DataStore is slow. + single(createdAtStart = true) { + val repository = get() + ProxyScope.entries.forEach { scope -> + val saved = + runBlocking { + runCatching { + withTimeout(1_500L) { + repository.getProxyConfig(scope).first() + } + }.getOrDefault(ProxyConfig.System) + } + ProxyManager.setConfig(scope, saved) } GitHubClientProvider( tokenStore = get(), rateLimitRepository = get(), authenticationState = get(), - proxyConfigFlow = ProxyManager.currentProxyConfig, + proxyConfigFlow = ProxyManager.configFlow(ProxyScope.DISCOVERY), ) } - single { - createGitHubHttpClient( - tokenStore = get(), - rateLimitRepository = get(), - authenticationState = get(), - scope = get(), + single(createdAtStart = true) { + TranslationClientProvider( + proxyConfigFlow = ProxyManager.configFlow(ProxyScope.TRANSLATION), ) } diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/BackendApiClient.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/BackendApiClient.kt index 439738e0..838d59ea 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/BackendApiClient.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/BackendApiClient.kt @@ -16,27 +16,70 @@ import io.ktor.client.request.setBody import io.ktor.http.ContentType import io.ktor.http.HttpStatusCode import io.ktor.http.contentType +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import zed.rainxch.core.data.dto.BackendExploreResponse import zed.rainxch.core.data.dto.BackendRepoResponse import zed.rainxch.core.data.dto.BackendSearchResponse import zed.rainxch.core.data.dto.EventRequest +import zed.rainxch.core.domain.model.ProxyConfig import kotlin.coroutines.cancellation.CancellationException -class BackendApiClient { +/** + * Client for GitHub Store's own backend (trending/popular/search). + * Treated as *discovery* traffic — routes through the discovery-scope + * proxy so users configuring a proxy for GitHub browsing also have + * their backend discovery requests proxied consistently. + */ +class BackendApiClient( + proxyConfigFlow: StateFlow, +) { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val mutex = Mutex() - private val httpClient = HttpClient { - install(ContentNegotiation) { - json(Json { ignoreUnknownKeys = true }) - } - install(HttpTimeout) { - requestTimeoutMillis = 5_000 - connectTimeoutMillis = 3_000 - socketTimeoutMillis = 5_000 - } - defaultRequest { - url(BASE_URL) + @Volatile + private var httpClient: HttpClient = buildClient(proxyConfigFlow.value) + + init { + proxyConfigFlow + .drop(1) + .distinctUntilChanged() + .onEach { config -> + mutex.withLock { + httpClient.close() + httpClient = buildClient(config) + } + }.launchIn(scope) + } + + private fun buildClient(proxyConfig: ProxyConfig): HttpClient = + createPlatformHttpClient(proxyConfig).config { + install(ContentNegotiation) { + json(Json { ignoreUnknownKeys = true }) + } + install(HttpTimeout) { + requestTimeoutMillis = 5_000 + connectTimeoutMillis = 3_000 + socketTimeoutMillis = 5_000 + } + defaultRequest { + url(BASE_URL) + } + expectSuccess = false } - expectSuccess = false + + fun close() { + httpClient.close() + scope.cancel() } suspend fun getCategory(category: String, platform: String): Result> = 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 index e6908a0c..fd491fc6 100644 --- 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 @@ -4,34 +4,28 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import zed.rainxch.core.domain.model.ProxyConfig +import zed.rainxch.core.domain.model.ProxyScope +/** + * Live in-memory cache of the three per-scope proxy configurations. + * Writers (the repository) push updates via [setConfig]; consumers + * subscribe to [configFlow] so they rebuild their HTTP clients when + * the user flips a setting mid-session. + */ object ProxyManager { - private val _proxyConfig = MutableStateFlow(ProxyConfig.System) - val currentProxyConfig: StateFlow = _proxyConfig.asStateFlow() + private val flows: Map> = + ProxyScope.entries.associateWith { MutableStateFlow(ProxyConfig.System) } - fun setNoProxy() { - _proxyConfig.value = ProxyConfig.None - } - - fun setSystemProxy() { - _proxyConfig.value = ProxyConfig.System - } + fun configFlow(scope: ProxyScope): StateFlow = + flows.getValue(scope).asStateFlow() - fun setHttpProxy( - host: String, - port: Int, - username: String? = null, - password: String? = null, - ) { - _proxyConfig.value = ProxyConfig.Http(host, port, username, password) - } + fun currentConfig(scope: ProxyScope): ProxyConfig = + flows.getValue(scope).value - fun setSocksProxy( - host: String, - port: Int, - username: String? = null, - password: String? = null, + fun setConfig( + scope: ProxyScope, + config: ProxyConfig, ) { - _proxyConfig.value = ProxyConfig.Socks(host, port, username, password) + flows.getValue(scope).value = config } } diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/TranslationClientProvider.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/TranslationClientProvider.kt new file mode 100644 index 00000000..ab958dbc --- /dev/null +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/TranslationClientProvider.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.cancel +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import zed.rainxch.core.domain.model.ProxyConfig + +/** + * Reactive holder for the HTTP client used by translation requests. + * Rebuilds the underlying client whenever the translation-scope + * proxy config changes so README translation picks up proxy updates + * without requiring an app restart. Mirrors [GitHubClientProvider] + * but keeps translation on its own client — translation endpoints + * (e.g. Google Translate) don't need any of the GitHub-specific + * interceptors, auth headers, or base URL defaults. + */ +class TranslationClientProvider( + proxyConfigFlow: StateFlow, +) { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val mutex = Mutex() + + @Volatile + private var currentClient: HttpClient = createPlatformHttpClient(proxyConfigFlow.value) + + init { + proxyConfigFlow + .drop(1) + .distinctUntilChanged() + .onEach { config -> + mutex.withLock { + currentClient.close() + currentClient = createPlatformHttpClient(config) + } + }.launchIn(scope) + } + + val client: HttpClient get() = currentClient + + fun close() { + currentClient.close() + scope.cancel() + } +} diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/InstalledAppsRepositoryImpl.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/InstalledAppsRepositoryImpl.kt index 027bfabf..e7d2b124 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/InstalledAppsRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/InstalledAppsRepositoryImpl.kt @@ -19,6 +19,7 @@ import zed.rainxch.core.data.local.db.dao.UpdateHistoryDao import zed.rainxch.core.data.local.db.entities.UpdateHistoryEntity import zed.rainxch.core.data.mappers.toDomain import zed.rainxch.core.data.mappers.toEntity +import zed.rainxch.core.data.network.GitHubClientProvider import zed.rainxch.core.data.network.executeRequest import zed.rainxch.core.domain.model.GithubAsset import zed.rainxch.core.domain.model.GithubRelease @@ -35,8 +36,14 @@ class InstalledAppsRepositoryImpl( private val installedAppsDao: InstalledAppDao, private val historyDao: UpdateHistoryDao, private val installer: Installer, - private val httpClient: HttpClient, + private val clientProvider: GitHubClientProvider, ) : InstalledAppsRepository { + // Reads the current Ktor client at every call site so any proxy + // change (ProxyManager rebuilds the client via [clientProvider]) + // is picked up immediately on the next request without requiring + // the repository itself to be reconstructed. + private val httpClient: HttpClient get() = clientProvider.client + private companion object { /** * How many releases the update checker fetches in one request. diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ProxyRepositoryImpl.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ProxyRepositoryImpl.kt index 85fd5b5f..70c0b86a 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ProxyRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ProxyRepositoryImpl.kt @@ -9,143 +9,175 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import zed.rainxch.core.data.network.ProxyManager import zed.rainxch.core.domain.model.ProxyConfig +import zed.rainxch.core.domain.model.ProxyScope import zed.rainxch.core.domain.repository.ProxyRepository +/** + * Persists one [ProxyConfig] per [ProxyScope] in DataStore, writes + * changes through to [ProxyManager] so live HTTP clients rebuild + * with the new settings. + * + * **Legacy migration**: installs that predate scoped proxies wrote a + * single global configuration under the unprefixed keys (`proxy_type`, + * `proxy_host`, …). On read, if a scope has no value of its own, we + * fall back to those legacy keys — so existing users' saved proxy + * silently applies to all three scopes until they customise one. + * The legacy keys are never written to again; once the user saves + * any scope, that scope's dedicated keys take over. + */ class ProxyRepositoryImpl( private val preferences: DataStore, ) : ProxyRepository { - private val proxyTypeKey = stringPreferencesKey("proxy_type") - private val proxyHostKey = stringPreferencesKey("proxy_host") - private val proxyPortKey = intPreferencesKey("proxy_port") - private val proxyUsernameKey = stringPreferencesKey("proxy_username") - private val proxyPasswordKey = stringPreferencesKey("proxy_password") + // Legacy (pre-scope) keys — read-only, used as a fallback seed. + private val legacyType = stringPreferencesKey("proxy_type") + private val legacyHost = stringPreferencesKey("proxy_host") + private val legacyPort = intPreferencesKey("proxy_port") + private val legacyUsername = stringPreferencesKey("proxy_username") + private val legacyPassword = stringPreferencesKey("proxy_password") - override fun getProxyConfig(): Flow = - preferences.data.map { prefs -> - when (prefs[proxyTypeKey]) { - "system" -> { - ProxyConfig.System - } + private data class ScopeKeys( + val type: androidx.datastore.preferences.core.Preferences.Key, + val host: androidx.datastore.preferences.core.Preferences.Key, + val port: androidx.datastore.preferences.core.Preferences.Key, + val username: androidx.datastore.preferences.core.Preferences.Key, + val password: androidx.datastore.preferences.core.Preferences.Key, + ) - "http" -> { - val host = prefs[proxyHostKey]?.takeIf { it.isNotBlank() } - val port = prefs[proxyPortKey]?.takeIf { it in 1..65535 } - if (host != null && port != null) { - ProxyConfig.Http( - host = host, - port = port, - username = prefs[proxyUsernameKey], - password = prefs[proxyPasswordKey], - ) - } else { - ProxyConfig.None - } - } + private fun keysFor(scope: ProxyScope): ScopeKeys { + val prefix = + when (scope) { + ProxyScope.DISCOVERY -> "discovery" + ProxyScope.DOWNLOAD -> "download" + ProxyScope.TRANSLATION -> "translation" + } + return ScopeKeys( + type = stringPreferencesKey("${prefix}_proxy_type"), + host = stringPreferencesKey("${prefix}_proxy_host"), + port = intPreferencesKey("${prefix}_proxy_port"), + username = stringPreferencesKey("${prefix}_proxy_username"), + password = stringPreferencesKey("${prefix}_proxy_password"), + ) + } - "socks" -> { - val host = prefs[proxyHostKey]?.takeIf { it.isNotBlank() } - val port = prefs[proxyPortKey]?.takeIf { it in 1..65535 } - if (host != null && port != null) { - ProxyConfig.Socks( - host = host, - port = port, - username = prefs[proxyUsernameKey], - password = prefs[proxyPasswordKey], - ) - } else { - ProxyConfig.None - } - } + override fun getProxyConfig(scope: ProxyScope): Flow = + preferences.data.map { prefs -> readConfigForScope(prefs, scope) } - else -> { - ProxyConfig.System + private fun readConfigForScope( + prefs: Preferences, + scope: ProxyScope, + ): ProxyConfig { + val keys = keysFor(scope) + // Scoped value present → use it directly. + if (prefs[keys.type] != null) { + return parseConfig( + type = prefs[keys.type], + host = prefs[keys.host], + port = prefs[keys.port], + username = prefs[keys.username], + password = prefs[keys.password], + ) + } + // No scoped value yet — lazy-fall back to the legacy single-key + // config so upgrading users don't lose their proxy setup. + return parseConfig( + type = prefs[legacyType], + host = prefs[legacyHost], + port = prefs[legacyPort], + username = prefs[legacyUsername], + password = prefs[legacyPassword], + ) + } + + private fun parseConfig( + type: String?, + host: String?, + port: Int?, + username: String?, + password: String?, + ): ProxyConfig = + when (type) { + "system" -> ProxyConfig.System + "none" -> ProxyConfig.None + "http" -> { + val validHost = host?.takeIf { it.isNotBlank() } + val validPort = port?.takeIf { it in 1..65535 } + if (validHost != null && validPort != null) { + ProxyConfig.Http( + host = validHost, + port = validPort, + username = username, + password = password, + ) + } else { + ProxyConfig.None + } + } + "socks" -> { + val validHost = host?.takeIf { it.isNotBlank() } + val validPort = port?.takeIf { it in 1..65535 } + if (validHost != null && validPort != null) { + ProxyConfig.Socks( + host = validHost, + port = validPort, + username = username, + password = password, + ) + } else { + ProxyConfig.None } } + else -> ProxyConfig.System } - override suspend fun setProxyConfig(config: ProxyConfig) { - // Persist first so config survives crashes, then apply in-memory + override suspend fun setProxyConfig( + scope: ProxyScope, + config: ProxyConfig, + ) { + val keys = keysFor(scope) preferences.edit { prefs -> when (config) { is ProxyConfig.None -> { - prefs[proxyTypeKey] = "none" - prefs.remove(proxyHostKey) - prefs.remove(proxyPortKey) - prefs.remove(proxyUsernameKey) - prefs.remove(proxyPasswordKey) + prefs[keys.type] = "none" + prefs.remove(keys.host) + prefs.remove(keys.port) + prefs.remove(keys.username) + prefs.remove(keys.password) } - is ProxyConfig.System -> { - prefs[proxyTypeKey] = "system" - prefs.remove(proxyHostKey) - prefs.remove(proxyPortKey) - prefs.remove(proxyUsernameKey) - prefs.remove(proxyPasswordKey) + prefs[keys.type] = "system" + prefs.remove(keys.host) + prefs.remove(keys.port) + prefs.remove(keys.username) + prefs.remove(keys.password) } - is ProxyConfig.Http -> { - prefs[proxyTypeKey] = "http" - prefs[proxyHostKey] = config.host - prefs[proxyPortKey] = config.port - if (config.username != null) { - prefs[proxyUsernameKey] = config.username!! - } else { - prefs.remove(proxyUsernameKey) - } - if (config.password != null) { - prefs[proxyPasswordKey] = config.password!! - } else { - prefs.remove(proxyPasswordKey) - } + prefs[keys.type] = "http" + prefs[keys.host] = config.host + prefs[keys.port] = config.port + writeOrRemove(prefs, keys.username, config.username) + writeOrRemove(prefs, keys.password, config.password) } - is ProxyConfig.Socks -> { - prefs[proxyTypeKey] = "socks" - prefs[proxyHostKey] = config.host - prefs[proxyPortKey] = config.port - if (config.username != null) { - prefs[proxyUsernameKey] = config.username!! - } else { - prefs.remove(proxyUsernameKey) - } - if (config.password != null) { - prefs[proxyPasswordKey] = config.password!! - } else { - prefs.remove(proxyPasswordKey) - } + prefs[keys.type] = "socks" + prefs[keys.host] = config.host + prefs[keys.port] = config.port + writeOrRemove(prefs, keys.username, config.username) + writeOrRemove(prefs, keys.password, config.password) } } } - applyToProxyManager(config) + ProxyManager.setConfig(scope, config) } - private fun applyToProxyManager(config: ProxyConfig) { - when (config) { - is ProxyConfig.None -> { - ProxyManager.setNoProxy() - } - - is ProxyConfig.System -> { - ProxyManager.setSystemProxy() - } - - is ProxyConfig.Http -> { - ProxyManager.setHttpProxy( - host = config.host, - port = config.port, - username = config.username, - password = config.password, - ) - } - - is ProxyConfig.Socks -> { - ProxyManager.setSocksProxy( - host = config.host, - port = config.port, - username = config.username, - password = config.password, - ) - } + private fun writeOrRemove( + prefs: androidx.datastore.preferences.core.MutablePreferences, + key: androidx.datastore.preferences.core.Preferences.Key, + value: String?, + ) { + if (value != null) { + prefs[key] = value + } else { + prefs.remove(key) } } } diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/StarredRepositoryImpl.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/StarredRepositoryImpl.kt index e7b956ea..344732dc 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/StarredRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/StarredRepositoryImpl.kt @@ -25,6 +25,7 @@ import zed.rainxch.core.data.local.db.dao.InstalledAppDao import zed.rainxch.core.data.local.db.dao.StarredRepoDao import zed.rainxch.core.data.mappers.toDomain import zed.rainxch.core.data.mappers.toEntity +import zed.rainxch.core.data.network.GitHubClientProvider import zed.rainxch.core.domain.model.Platform import zed.rainxch.core.domain.model.RateLimitException import zed.rainxch.core.domain.repository.StarredRepository @@ -37,8 +38,10 @@ class StarredRepositoryImpl( private val starredRepoDao: StarredRepoDao, private val installedAppsDao: InstalledAppDao, private val platform: Platform, - private val httpClient: HttpClient, + private val clientProvider: GitHubClientProvider, ) : StarredRepository { + private val httpClient: HttpClient get() = clientProvider.client + companion object { private const val SYNC_THRESHOLD_MS = 24 * 60 * 60 * 1000L // 24 hours } 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 index 6602298b..1a470344 100644 --- 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 @@ -1,35 +1,70 @@ package zed.rainxch.core.data.network import io.ktor.client.HttpClient -import io.ktor.client.engine.ProxyBuilder import io.ktor.client.engine.okhttp.OkHttp -import io.ktor.http.Url +import okhttp3.Credentials import zed.rainxch.core.domain.model.ProxyConfig +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 = HttpClient(OkHttp) { engine { - when (proxyConfig) { - is ProxyConfig.None -> { - config { + config { + // Reset any inherited global SOCKS authenticator before + // deciding what this client needs — prevents a stale + // Authenticator from a previous [ProxyConfig.Socks] client + // leaking into a subsequently-built plain client. + Authenticator.setDefault(null) + + when (proxyConfig) { + is ProxyConfig.None -> { proxy(Proxy.NO_PROXY) } - } - is ProxyConfig.System -> { - config { + is ProxyConfig.System -> { proxySelector(ProxySelector.getDefault()) } - } - is ProxyConfig.Http -> { - proxy = ProxyBuilder.http(Url("http://${proxyConfig.host}:${proxyConfig.port}")) - } + is ProxyConfig.Http -> { + proxy(Proxy(Proxy.Type.HTTP, InetSocketAddress(proxyConfig.host, proxyConfig.port))) + val username = proxyConfig.username + val password = proxyConfig.password + if (!username.isNullOrEmpty() && !password.isNullOrEmpty()) { + proxyAuthenticator { _, response -> + response.request + .newBuilder() + .header( + "Proxy-Authorization", + Credentials.basic(username, password), + ).build() + } + } + } - is ProxyConfig.Socks -> { - proxy = ProxyBuilder.socks(proxyConfig.host, proxyConfig.port) + is ProxyConfig.Socks -> { + proxy(Proxy(Proxy.Type.SOCKS, InetSocketAddress(proxyConfig.host, proxyConfig.port))) + val username = proxyConfig.username + val password = proxyConfig.password + if (!username.isNullOrEmpty() && !password.isNullOrEmpty()) { + // SOCKS5 username/password auth goes through + // java.net.Authenticator (OkHttp has no + // dedicated SOCKS auth hook), so install a + // default authenticator keyed on host:port. + Authenticator.setDefault( + object : Authenticator() { + override fun getPasswordAuthentication() = + PasswordAuthentication( + username, + password.toCharArray(), + ) + }, + ) + } + } } } } diff --git a/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/services/DesktopDownloader.kt b/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/services/DesktopDownloader.kt index 1d5bdeee..cbdd9650 100644 --- a/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/services/DesktopDownloader.kt +++ b/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/services/DesktopDownloader.kt @@ -13,6 +13,7 @@ import okhttp3.Request import zed.rainxch.core.data.network.ProxyManager import zed.rainxch.core.domain.model.DownloadProgress import zed.rainxch.core.domain.model.ProxyConfig +import zed.rainxch.core.domain.model.ProxyScope import zed.rainxch.core.domain.network.Downloader import java.io.File import java.net.Authenticator @@ -24,7 +25,6 @@ import java.util.concurrent.ConcurrentHashMap class DesktopDownloader( private val files: FileLocationsProvider, - private val proxyManager: ProxyManager = ProxyManager, ) : Downloader { private val activeDownloads = ConcurrentHashMap() private val nameToId = ConcurrentHashMap() @@ -35,7 +35,7 @@ class DesktopDownloader( return OkHttpClient .Builder() .apply { - when (val config = proxyManager.currentProxyConfig.value) { + when (val config = ProxyManager.currentConfig(ProxyScope.DOWNLOAD)) { is ProxyConfig.None -> { proxy(Proxy.NO_PROXY) } diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/ProxyScope.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/ProxyScope.kt new file mode 100644 index 00000000..1da13af3 --- /dev/null +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/ProxyScope.kt @@ -0,0 +1,26 @@ +package zed.rainxch.core.domain.model + +/** + * Independent proxy "channels" — each scope has its own configurable + * [ProxyConfig] so users can, for example, route GitHub API traffic + * through a corporate proxy while keeping APK downloads direct. + * + * Every outbound request in the app belongs to exactly one scope: + * + * - [DISCOVERY] — GitHub REST API calls: search, home, details, + * repo metadata, user profiles, starred, installed + * apps update checks. Basically everything that + * hits `api.github.com`. + * - [DOWNLOAD] — APK file downloads: manual installs from Details, + * one-tap updates from the Installed Apps list, and + * the Android auto-update worker. + * - [TRANSLATION] — README translation requests (currently Google + * Translate). Kept separate because translation + * services are often blocked/unblocked independently + * of GitHub in restricted networks. + */ +enum class ProxyScope { + DISCOVERY, + DOWNLOAD, + TRANSLATION, +} diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/ProxyRepository.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/ProxyRepository.kt index ec97cd8a..a1c6aa0d 100644 --- a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/ProxyRepository.kt +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/ProxyRepository.kt @@ -2,9 +2,13 @@ package zed.rainxch.core.domain.repository import kotlinx.coroutines.flow.Flow import zed.rainxch.core.domain.model.ProxyConfig +import zed.rainxch.core.domain.model.ProxyScope interface ProxyRepository { - fun getProxyConfig(): Flow + fun getProxyConfig(scope: ProxyScope): Flow - suspend fun setProxyConfig(config: ProxyConfig) + suspend fun setProxyConfig( + scope: ProxyScope, + config: ProxyConfig, + ) } 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 a8f019f8..5f394d8d 100644 --- a/core/presentation/src/commonMain/composeResources/values-ar/strings-ar.xml +++ b/core/presentation/src/commonMain/composeResources/values-ar/strings-ar.xml @@ -158,6 +158,13 @@ يلزم التحقق من الوكيل. استجابة غير متوقعة: HTTP %1$d فشل اختبار الاتصال + يمكن لكل فئة استخدام البروكسي الخاص بها. قم بتكوينها بشكل مستقل. + الاكتشاف (GitHub API) + الصفحة الرئيسية والبحث وتفاصيل المستودع وفحوصات التحديث + التنزيلات + تنزيلات APK والتحديثات التلقائية + الترجمة + خدمة ترجمة README تم تسجيل الخروج بنجاح، جارٍ إعادة التوجيه... @@ -622,7 +629,7 @@ تمت المشاهدة الخصوصية ساعد في تحسين البحث - مشاركة بيانات الاستخدام المجهولة (عمليات البحث، التثبيتات) لتحسين التوصيات. لا يتم جمع أي معلومات شخصية. + مشاركة بيانات الاستخدام المجهولة الهوية (عمليات البحث والتثبيتات والتفاعلات) المرتبطة بمعرّف تحليلي قابل لإعادة التعيين. لا تتم مشاركة تفاصيل الحساب. إعادة تعيين معرف التحليلات إنشاء معرف مجهول جديد، مما يقطع الصلة بالبيانات السابقة. تم إعادة تعيين معرف التحليلات 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 b30322bf..7415b17f 100644 --- a/core/presentation/src/commonMain/composeResources/values-bn/strings-bn.xml +++ b/core/presentation/src/commonMain/composeResources/values-bn/strings-bn.xml @@ -426,6 +426,13 @@ প্রক্সি প্রমাণীকরণ প্রয়োজন। অপ্রত্যাশিত প্রতিক্রিয়া: HTTP %1$d সংযোগ পরীক্ষা ব্যর্থ + প্রতিটি বিভাগ তার নিজস্ব প্রক্সি ব্যবহার করতে পারে। সেগুলি স্বাধীনভাবে কনফিগার করুন। + আবিষ্কার (GitHub API) + হোম, অনুসন্ধান, রেপো বিবরণ এবং আপডেট চেক + ডাউনলোড + APK ডাউনলোড এবং স্বয়ংক্রিয় আপডেট + অনুবাদ + README অনুবাদ পরিষেবা @@ -621,7 +628,7 @@ দেখা হয়েছে গোপনীয়তা অনুসন্ধান উন্নত করতে সহায়তা করুন - সুপারিশ উন্নত করতে বেনামী ব্যবহার ডেটা শেয়ার করুন (অনুসন্ধান, ইনস্টল)। কোনও ব্যক্তিগত তথ্য সংগ্রহ করা হয় না। + পুনরায় সেট করা যায় এমন একটি বিশ্লেষণ আইডির সাথে যুক্ত বেনামী ব্যবহার ডেটা (অনুসন্ধান, ইনস্টল, ইন্টারঅ্যাকশন) শেয়ার করুন। অ্যাকাউন্ট বিবরণ শেয়ার করা হয় না। বিশ্লেষণ আইডি রিসেট করুন একটি নতুন বেনামী আইডি তৈরি করুন, অতীতের টেলিমেট্রির সাথে সংযোগ বিচ্ছিন্ন করে। বিশ্লেষণ আইডি রিসেট করা হয়েছে 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 e74d6b5c..43db931a 100644 --- a/core/presentation/src/commonMain/composeResources/values-es/strings-es.xml +++ b/core/presentation/src/commonMain/composeResources/values-es/strings-es.xml @@ -391,6 +391,13 @@ Se requiere autenticación del proxy. Respuesta inesperada: HTTP %1$d La prueba de conexión falló + Cada categoría puede usar su propio proxy. Configúralos de forma independiente. + Descubrimiento (API de GitHub) + Inicio, búsqueda, detalles del repo y comprobación de actualizaciones + Descargas + Descargas de APK y actualizaciones automáticas + Traducción + Servicio de traducción de README @@ -582,7 +589,7 @@ Visto Privacidad Ayudar a mejorar la búsqueda - Compartir datos de uso anónimos (búsquedas, instalaciones) para mejorar las recomendaciones. No se recopila información personal. + Compartir datos de uso anonimizados (búsquedas, instalaciones, interacciones) vinculados a un ID de análisis restablecible. No se comparten detalles de la cuenta. Restablecer ID de análisis Generar un nuevo ID anónimo, cortando el vínculo con la telemetría anterior. ID de análisis restablecido 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 0b935772..fe3a6b2a 100644 --- a/core/presentation/src/commonMain/composeResources/values-fr/strings-fr.xml +++ b/core/presentation/src/commonMain/composeResources/values-fr/strings-fr.xml @@ -391,6 +391,13 @@ Authentification proxy requise. Réponse inattendue : HTTP %1$d Échec du test de connexion + Chaque catégorie peut utiliser son propre proxy. Configurez-les indépendamment. + Découverte (API GitHub) + Accueil, recherche, détails du dépôt et vérifications de mise à jour + Téléchargements + Téléchargements APK et mises à jour automatiques + Traduction + Service de traduction du README @@ -583,7 +590,7 @@ Consulté Confidentialité Aider à améliorer la recherche - Partager des données d\'utilisation anonymes (recherches, installations) pour améliorer les recommandations. Aucune information personnelle n\'est collectée. + Partager des données d\'utilisation anonymisées (recherches, installations, interactions) liées à un identifiant d\'analyse réinitialisable. Aucun détail de compte n\'est partagé. Réinitialiser l\'ID d\'analytique Générer un nouvel ID anonyme, coupant le lien avec la télémétrie passée. ID d\'analytique réinitialisé 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 f097eedd..18f8f3d0 100644 --- a/core/presentation/src/commonMain/composeResources/values-hi/strings-hi.xml +++ b/core/presentation/src/commonMain/composeResources/values-hi/strings-hi.xml @@ -425,6 +425,13 @@ प्रॉक्सी प्रमाणीकरण आवश्यक है। अप्रत्याशित प्रतिक्रिया: HTTP %1$d कनेक्शन परीक्षण विफल + प्रत्येक श्रेणी अपना प्रॉक्सी उपयोग कर सकती है। उन्हें स्वतंत्र रूप से कॉन्फ़िगर करें। + खोज (GitHub API) + होम, खोज, रेपो विवरण और अपडेट जांच + डाउनलोड + APK डाउनलोड और स्वचालित अपडेट + अनुवाद + README अनुवाद सेवा @@ -620,7 +627,7 @@ देखा गया गोपनीयता खोज को बेहतर बनाने में मदद करें - सुझाव बेहतर बनाने के लिए अनाम उपयोग डेटा (खोज, इंस्टॉल) साझा करें। कोई व्यक्तिगत जानकारी नहीं एकत्र की जाती। + रीसेट करने योग्य एनालिटिक्स आईडी से जुड़े अनाम उपयोग डेटा (खोज, इंस्टॉल, इंटरैक्शन) साझा करें। खाता विवरण साझा नहीं किए जाते। एनालिटिक्स आईडी रीसेट करें एक नया अनाम आईडी बनाएं, पिछले टेलीमेट्री से लिंक को तोड़ें। एनालिटिक्स आईडी रीसेट किया गया 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 f268b8a8..5fa832c8 100644 --- a/core/presentation/src/commonMain/composeResources/values-it/strings-it.xml +++ b/core/presentation/src/commonMain/composeResources/values-it/strings-it.xml @@ -427,6 +427,13 @@ Autenticazione proxy richiesta. Risposta inattesa: HTTP %1$d Verifica connessione non riuscita + Ogni categoria può utilizzare il proprio proxy. Configurali in modo indipendente. + Scoperta (API GitHub) + Home, ricerca, dettagli repo e controlli aggiornamenti + Download + Download APK e aggiornamenti automatici + Traduzione + Servizio di traduzione README @@ -621,7 +628,7 @@ Visualizzato Privacy Aiuta a migliorare la ricerca - Condividi dati di utilizzo anonimi (ricerche, installazioni) per migliorare i suggerimenti. Nessuna informazione personale viene raccolta. + Condividi dati di utilizzo anonimizzati (ricerche, installazioni, interazioni) collegati a un ID analitico ripristinabile. Nessun dettaglio dell\'account viene condiviso. Reimposta ID analitico Genera un nuovo ID anonimo, interrompendo il collegamento con la telemetria passata. ID analitico reimpostato 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 01172561..1fcec597 100644 --- a/core/presentation/src/commonMain/composeResources/values-ja/strings-ja.xml +++ b/core/presentation/src/commonMain/composeResources/values-ja/strings-ja.xml @@ -391,6 +391,13 @@ プロキシ認証が必要です。 予期しない応答:HTTP %1$d 接続テストに失敗しました + 各カテゴリは独自のプロキシを使用できます。個別に設定してください。 + ディスカバリー (GitHub API) + ホーム、検索、リポジトリ詳細、更新確認 + ダウンロード + APK ダウンロードと自動更新 + 翻訳 + README 翻訳サービス @@ -584,7 +591,7 @@ 閲覧済み プライバシー 検索の改善に協力 - 推奨を改善するため、匿名の使用データ(検索、インストール)を共有します。個人情報は収集されません。 + リセット可能な分析 ID に関連付けられた匿名化された使用データ(検索、インストール、操作)を共有します。アカウント情報は共有されません。 分析IDをリセット 新しい匿名IDを生成し、過去のテレメトリとのリンクを切断します。 分析IDをリセットしました 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 ab9f1776..562f35c1 100644 --- a/core/presentation/src/commonMain/composeResources/values-ko/strings-ko.xml +++ b/core/presentation/src/commonMain/composeResources/values-ko/strings-ko.xml @@ -424,6 +424,13 @@ 프록시 인증이 필요합니다. 예기치 않은 응답: HTTP %1$d 연결 테스트 실패 + 각 카테고리는 자체 프록시를 사용할 수 있습니다. 독립적으로 구성하십시오. + 탐색 (GitHub API) + 홈, 검색, 저장소 상세, 업데이트 확인 + 다운로드 + APK 다운로드 및 자동 업데이트 + 번역 + README 번역 서비스 @@ -619,7 +626,7 @@ 확인함 개인 정보 보호 검색 개선에 도움 주기 - 추천을 개선하기 위해 익명의 사용 데이터(검색, 설치)를 공유합니다. 개인 정보는 수집되지 않습니다. + 재설정 가능한 분석 ID에 연결된 익명화된 사용 데이터(검색, 설치, 상호작용)를 공유합니다. 계정 정보는 공유되지 않습니다. 분석 ID 재설정 새 익명 ID를 생성하여 과거 원격 측정과의 연결을 끊습니다. 분석 ID가 재설정되었습니다 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 c3bfff56..20d014af 100644 --- a/core/presentation/src/commonMain/composeResources/values-pl/strings-pl.xml +++ b/core/presentation/src/commonMain/composeResources/values-pl/strings-pl.xml @@ -389,6 +389,13 @@ Wymagane uwierzytelnienie proxy. Nieoczekiwana odpowiedź: HTTP %1$d Test połączenia nie powiódł się + Każda kategoria może używać własnego proxy. Skonfiguruj je niezależnie. + Odkrywanie (GitHub API) + Strona główna, wyszukiwanie, szczegóły repo i sprawdzanie aktualizacji + Pobieranie + Pobieranie APK i automatyczne aktualizacje + Tłumaczenie + Usługa tłumaczenia README @@ -585,7 +592,7 @@ Przeglądane Prywatność Pomóż ulepszyć wyszukiwanie - Udostępniaj anonimowe dane o użyciu (wyszukiwania, instalacje), aby poprawić rekomendacje. Żadne dane osobowe nie są zbierane. + Udostępniaj zanonimizowane dane o użyciu (wyszukiwania, instalacje, interakcje) powiązane z resetowalnym identyfikatorem analitycznym. Żadne dane konta nie są udostępniane. Zresetuj ID analityki Wygeneruj nowy anonimowy ID, zrywając powiązanie z poprzednią telemetrią. ID analityki zresetowany 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 99d53531..83bdfd1a 100644 --- a/core/presentation/src/commonMain/composeResources/values-ru/strings-ru.xml +++ b/core/presentation/src/commonMain/composeResources/values-ru/strings-ru.xml @@ -391,6 +391,13 @@ Требуется аутентификация прокси. Неожиданный ответ: HTTP %1$d Не удалось проверить соединение + Каждая категория может использовать свой собственный прокси. Настройте их независимо. + Обнаружение (GitHub API) + Главная, поиск, детали репозитория и проверка обновлений + Загрузки + Загрузка APK и автоматические обновления + Перевод + Сервис перевода README @@ -585,7 +592,7 @@ Просмотрено Конфиденциальность Помочь улучшить поиск - Отправлять анонимные данные об использовании (поиски, установки) для улучшения рекомендаций. Личная информация не собирается. + Отправлять обезличенные данные об использовании (поиски, установки, взаимодействия), привязанные к сбрасываемому идентификатору аналитики. Данные учётной записи не передаются. Сбросить ID аналитики Сгенерировать новый анонимный ID, разорвав связь с прошлой телеметрией. ID аналитики сброшен 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 3dd804f0..0602dede 100644 --- a/core/presentation/src/commonMain/composeResources/values-tr/strings-tr.xml +++ b/core/presentation/src/commonMain/composeResources/values-tr/strings-tr.xml @@ -423,6 +423,13 @@ Proxy kimlik doğrulaması gerekiyor. Beklenmeyen yanıt: HTTP %1$d Bağlantı testi başarısız + Her kategori kendi proxy\'sini kullanabilir. Bunları bağımsız olarak yapılandırın. + Keşif (GitHub API) + Ana sayfa, arama, depo ayrıntıları ve güncelleme denetimleri + İndirmeler + APK indirmeleri ve otomatik güncellemeler + Çeviri + README çeviri hizmeti @@ -619,7 +626,7 @@ Görüntülendi Gizlilik Aramayı iyileştirmeye yardım et - Önerileri iyileştirmek için anonim kullanım verilerini (aramalar, yüklemeler) paylaş. Kişisel bilgi toplanmaz. + Sıfırlanabilir bir analiz kimliğine bağlı anonimleştirilmiş kullanım verilerini (aramalar, yüklemeler, etkileşimler) paylaş. Hesap ayrıntıları paylaşılmaz. Analitik kimliğini sıfırla Yeni anonim kimlik oluştur, geçmiş telemetri ile bağı kopar. Analitik kimliği sıfırlandı 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 7c13129a..2cb8225e 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 @@ -392,6 +392,13 @@ 需要代理身份验证。 意外响应:HTTP %1$d 连接测试失败 + 每个类别都可以使用自己的代理。请独立配置它们。 + 发现 (GitHub API) + 主页、搜索、仓库详情和更新检查 + 下载 + APK 下载和自动更新 + 翻译 + README 翻译服务 @@ -585,7 +592,7 @@ 已浏览 隐私 帮助改进搜索 - 分享匿名使用数据(搜索、安装)以改进推荐。不收集任何个人信息。 + 分享与可重置分析 ID 关联的匿名使用数据(搜索、安装、交互)。不分享账户详情。 重置分析 ID 生成新的匿名 ID,切断与过去遥测数据的联系。 分析 ID 已重置 diff --git a/core/presentation/src/commonMain/composeResources/values/strings.xml b/core/presentation/src/commonMain/composeResources/values/strings.xml index 4daa370b..2ee1fbe7 100644 --- a/core/presentation/src/commonMain/composeResources/values/strings.xml +++ b/core/presentation/src/commonMain/composeResources/values/strings.xml @@ -161,6 +161,13 @@ Proxy authentication required. Unexpected response: HTTP %1$d Connection test failed + Each category can use its own proxy. Configure them independently. + Discovery (GitHub API) + Home, search, repo details, and update checks + Downloads + APK downloads and auto-updates + Translation + README translation service Logged out successfully, redirecting... @@ -641,7 +648,7 @@ Privacy Help improve search - Share anonymous usage data (searches, installs) so we can improve recommendations. No personal information is collected. + Share anonymized usage data (searches, installs, interactions) tied to a resettable analytics ID. No account details are shared. Reset analytics ID Generate a new anonymous ID, severing the link to past telemetry. Analytics ID reset diff --git a/feature/apps/data/src/commonMain/kotlin/zed/rainxch/apps/data/di/SharedModule.kt b/feature/apps/data/src/commonMain/kotlin/zed/rainxch/apps/data/di/SharedModule.kt index ccf7fea2..9cba6766 100644 --- a/feature/apps/data/src/commonMain/kotlin/zed/rainxch/apps/data/di/SharedModule.kt +++ b/feature/apps/data/src/commonMain/kotlin/zed/rainxch/apps/data/di/SharedModule.kt @@ -11,7 +11,7 @@ val appsModule = appLauncher = get(), appsRepository = get(), logger = get(), - httpClient = get(), + clientProvider = get(), packageMonitor = get(), tweaksRepository = get(), ) diff --git a/feature/apps/data/src/commonMain/kotlin/zed/rainxch/apps/data/repository/AppsRepositoryImpl.kt b/feature/apps/data/src/commonMain/kotlin/zed/rainxch/apps/data/repository/AppsRepositoryImpl.kt index cccebd24..d9c11715 100644 --- a/feature/apps/data/src/commonMain/kotlin/zed/rainxch/apps/data/repository/AppsRepositoryImpl.kt +++ b/feature/apps/data/src/commonMain/kotlin/zed/rainxch/apps/data/repository/AppsRepositoryImpl.kt @@ -14,6 +14,7 @@ import zed.rainxch.apps.domain.repository.AppsRepository import zed.rainxch.core.data.dto.GithubRepoNetworkModel import zed.rainxch.core.data.dto.ReleaseNetwork import zed.rainxch.core.data.mappers.toDomain +import zed.rainxch.core.data.network.GitHubClientProvider import zed.rainxch.core.data.network.executeRequest import zed.rainxch.core.domain.logging.GitHubStoreLogger import zed.rainxch.core.domain.model.DeviceApp @@ -34,10 +35,11 @@ class AppsRepositoryImpl( private val appLauncher: AppLauncher, private val appsRepository: InstalledAppsRepository, private val logger: GitHubStoreLogger, - private val httpClient: HttpClient, + private val clientProvider: GitHubClientProvider, private val packageMonitor: PackageMonitor, private val tweaksRepository: TweaksRepository, ) : AppsRepository { + private val httpClient: HttpClient get() = clientProvider.client private val json = Json { ignoreUnknownKeys = true } override suspend fun getApps(): Flow> = appsRepository.getAllInstalledApps() diff --git a/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/di/SharedModule.kt b/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/di/SharedModule.kt index 5edb7c53..c253e173 100644 --- a/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/di/SharedModule.kt +++ b/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/di/SharedModule.kt @@ -15,7 +15,7 @@ val detailsModule = single { DetailsRepositoryImpl( logger = get(), - httpClient = get(), + clientProvider = get(), backendApiClient = get(), localizationManager = get(), cacheManager = get(), @@ -25,6 +25,7 @@ val detailsModule = single { TranslationRepositoryImpl( localizationManager = get(), + clientProvider = get(), ) } diff --git a/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/repository/TranslationRepositoryImpl.kt b/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/repository/TranslationRepositoryImpl.kt index a501fd87..ffdd0a95 100644 --- a/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/repository/TranslationRepositoryImpl.kt +++ b/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/repository/TranslationRepositoryImpl.kt @@ -9,9 +9,8 @@ import kotlinx.coroutines.sync.withLock import kotlinx.serialization.json.Json import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonPrimitive -import zed.rainxch.core.data.network.createPlatformHttpClient +import zed.rainxch.core.data.network.TranslationClientProvider import zed.rainxch.core.data.services.LocalizationManager -import zed.rainxch.core.domain.model.ProxyConfig import zed.rainxch.details.domain.model.TranslationResult import zed.rainxch.details.domain.repository.TranslationRepository import kotlin.time.Clock @@ -19,8 +18,9 @@ import kotlin.time.ExperimentalTime class TranslationRepositoryImpl( private val localizationManager: LocalizationManager, + private val clientProvider: TranslationClientProvider, ) : TranslationRepository { - private val httpClient: HttpClient = createPlatformHttpClient(ProxyConfig.None) + private val httpClient: HttpClient get() = clientProvider.client private val json = Json { diff --git a/feature/dev-profile/data/src/commonMain/kotlin/zed/rainxch/devprofile/data/di/SharedModule.kt b/feature/dev-profile/data/src/commonMain/kotlin/zed/rainxch/devprofile/data/di/SharedModule.kt index a65076dd..8432f404 100644 --- a/feature/dev-profile/data/src/commonMain/kotlin/zed/rainxch/devprofile/data/di/SharedModule.kt +++ b/feature/dev-profile/data/src/commonMain/kotlin/zed/rainxch/devprofile/data/di/SharedModule.kt @@ -9,7 +9,7 @@ val devProfileModule = single { DeveloperProfileRepositoryImpl( logger = get(), - httpClient = get(), + clientProvider = get(), platform = get(), installedAppsDao = get(), favouritesRepository = get(), diff --git a/feature/dev-profile/data/src/commonMain/kotlin/zed/rainxch/devprofile/data/repository/DeveloperProfileRepositoryImpl.kt b/feature/dev-profile/data/src/commonMain/kotlin/zed/rainxch/devprofile/data/repository/DeveloperProfileRepositoryImpl.kt index 46c0b30a..c807aa94 100644 --- a/feature/dev-profile/data/src/commonMain/kotlin/zed/rainxch/devprofile/data/repository/DeveloperProfileRepositoryImpl.kt +++ b/feature/dev-profile/data/src/commonMain/kotlin/zed/rainxch/devprofile/data/repository/DeveloperProfileRepositoryImpl.kt @@ -17,6 +17,7 @@ import kotlinx.coroutines.withContext import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import zed.rainxch.core.data.local.db.dao.InstalledAppDao +import zed.rainxch.core.data.network.GitHubClientProvider import zed.rainxch.core.domain.logging.GitHubStoreLogger import zed.rainxch.core.domain.model.Platform import zed.rainxch.core.domain.model.RateLimitException @@ -29,12 +30,14 @@ import zed.rainxch.devprofile.domain.model.DeveloperRepository import zed.rainxch.devprofile.domain.repository.DeveloperProfileRepository class DeveloperProfileRepositoryImpl( - private val httpClient: HttpClient, + private val clientProvider: GitHubClientProvider, private val platform: Platform, private val installedAppsDao: InstalledAppDao, private val favouritesRepository: FavouritesRepository, private val logger: GitHubStoreLogger, ) : DeveloperProfileRepository { + private val httpClient: HttpClient get() = clientProvider.client + override suspend fun getDeveloperProfile(username: String): Result { return withContext(Dispatchers.IO) { try { diff --git a/feature/home/data/src/commonMain/kotlin/zed/rainxch/home/data/di/SharedModule.kt b/feature/home/data/src/commonMain/kotlin/zed/rainxch/home/data/di/SharedModule.kt index ea67f4af..a6996867 100644 --- a/feature/home/data/src/commonMain/kotlin/zed/rainxch/home/data/di/SharedModule.kt +++ b/feature/home/data/src/commonMain/kotlin/zed/rainxch/home/data/di/SharedModule.kt @@ -12,7 +12,7 @@ val homeModule = single { HomeRepositoryImpl( cachedDataSource = get(), - httpClient = get(), + clientProvider = get(), devicePlatform = get(), logger = get(), cacheManager = get(), diff --git a/feature/home/data/src/commonMain/kotlin/zed/rainxch/home/data/repository/HomeRepositoryImpl.kt b/feature/home/data/src/commonMain/kotlin/zed/rainxch/home/data/repository/HomeRepositoryImpl.kt index 98d04d7f..ddabb2fc 100644 --- a/feature/home/data/src/commonMain/kotlin/zed/rainxch/home/data/repository/HomeRepositoryImpl.kt +++ b/feature/home/data/src/commonMain/kotlin/zed/rainxch/home/data/repository/HomeRepositoryImpl.kt @@ -28,6 +28,7 @@ import zed.rainxch.core.data.cache.CacheManager.CacheTtl.HOME_REPOS import zed.rainxch.core.data.dto.GithubRepoNetworkModel import zed.rainxch.core.data.dto.GithubRepoSearchResponse import zed.rainxch.core.data.mappers.toSummary +import zed.rainxch.core.data.network.GitHubClientProvider import zed.rainxch.core.data.network.executeRequest import zed.rainxch.core.domain.logging.GitHubStoreLogger import zed.rainxch.core.domain.model.DiscoveryPlatform @@ -43,12 +44,14 @@ import kotlin.time.Duration.Companion.days import kotlin.time.ExperimentalTime class HomeRepositoryImpl( - private val httpClient: HttpClient, + private val clientProvider: GitHubClientProvider, private val devicePlatform: Platform, private val cachedDataSource: CachedRepositoriesDataSource, private val logger: GitHubStoreLogger, private val cacheManager: CacheManager, ) : HomeRepository { + private val httpClient: HttpClient get() = clientProvider.client + private fun cacheKey( category: String, requestedPlatform: DiscoveryPlatform, diff --git a/feature/profile/data/src/commonMain/kotlin/zed/rainxch/profile/data/di/SharedModule.kt b/feature/profile/data/src/commonMain/kotlin/zed/rainxch/profile/data/di/SharedModule.kt index aca87cc5..f4ffaf4e 100644 --- a/feature/profile/data/src/commonMain/kotlin/zed/rainxch/profile/data/di/SharedModule.kt +++ b/feature/profile/data/src/commonMain/kotlin/zed/rainxch/profile/data/di/SharedModule.kt @@ -10,7 +10,7 @@ val settingsModule = ProfileRepositoryImpl( authenticationState = get(), tokenStore = get(), - httpClient = get(), + clientProvider = get(), cacheManager = get(), logger = get(), fileLocationsProvider = get(), diff --git a/feature/profile/data/src/commonMain/kotlin/zed/rainxch/profile/data/repository/ProfileRepositoryImpl.kt b/feature/profile/data/src/commonMain/kotlin/zed/rainxch/profile/data/repository/ProfileRepositoryImpl.kt index b8ba0de6..2f564e10 100644 --- a/feature/profile/data/src/commonMain/kotlin/zed/rainxch/profile/data/repository/ProfileRepositoryImpl.kt +++ b/feature/profile/data/src/commonMain/kotlin/zed/rainxch/profile/data/repository/ProfileRepositoryImpl.kt @@ -13,6 +13,7 @@ import zed.rainxch.core.data.cache.CacheManager import zed.rainxch.core.data.cache.CacheManager.CacheTtl.USER_PROFILE import zed.rainxch.core.data.data_source.TokenStore import zed.rainxch.core.data.dto.UserProfileNetwork +import zed.rainxch.core.data.network.GitHubClientProvider import zed.rainxch.core.data.network.executeRequest import zed.rainxch.core.data.services.FileLocationsProvider import zed.rainxch.core.domain.logging.GitHubStoreLogger @@ -25,11 +26,13 @@ import zed.rainxch.profile.domain.repository.ProfileRepository class ProfileRepositoryImpl( private val authenticationState: AuthenticationState, private val tokenStore: TokenStore, - private val httpClient: HttpClient, + private val clientProvider: GitHubClientProvider, private val cacheManager: CacheManager, private val logger: GitHubStoreLogger, private val fileLocationsProvider: FileLocationsProvider, ) : ProfileRepository { + private val httpClient: HttpClient get() = clientProvider.client + companion object { private const val CACHE_KEY = "profile:me" } diff --git a/feature/search/data/src/commonMain/kotlin/zed/rainxch/search/data/di/SharedModule.kt b/feature/search/data/src/commonMain/kotlin/zed/rainxch/search/data/di/SharedModule.kt index c95dfda6..80aa81de 100644 --- a/feature/search/data/src/commonMain/kotlin/zed/rainxch/search/data/di/SharedModule.kt +++ b/feature/search/data/src/commonMain/kotlin/zed/rainxch/search/data/di/SharedModule.kt @@ -9,7 +9,7 @@ val searchModule = module { single { SearchRepositoryImpl( - httpClient = get(), + clientProvider = get(), backendApiClient = get(), cacheManager = get(), ) diff --git a/feature/search/data/src/commonMain/kotlin/zed/rainxch/search/data/repository/SearchRepositoryImpl.kt b/feature/search/data/src/commonMain/kotlin/zed/rainxch/search/data/repository/SearchRepositoryImpl.kt index fc6099a2..a8e6fa81 100644 --- a/feature/search/data/src/commonMain/kotlin/zed/rainxch/search/data/repository/SearchRepositoryImpl.kt +++ b/feature/search/data/src/commonMain/kotlin/zed/rainxch/search/data/repository/SearchRepositoryImpl.kt @@ -24,6 +24,7 @@ import zed.rainxch.core.data.dto.GithubRepoNetworkModel import zed.rainxch.core.data.dto.GithubRepoSearchResponse import zed.rainxch.core.data.mappers.toSummary import zed.rainxch.core.data.network.BackendApiClient +import zed.rainxch.core.data.network.GitHubClientProvider import zed.rainxch.core.data.network.executeRequest import zed.rainxch.core.domain.model.DiscoveryPlatform import zed.rainxch.core.domain.model.GithubRepoSummary @@ -38,10 +39,11 @@ import zed.rainxch.search.data.dto.GithubReleaseNetworkModel import zed.rainxch.search.data.utils.LruCache class SearchRepositoryImpl( - private val httpClient: HttpClient, + private val clientProvider: GitHubClientProvider, private val backendApiClient: BackendApiClient, private val cacheManager: CacheManager, ) : SearchRepository { + private val httpClient: HttpClient get() = clientProvider.client private val releaseCheckCache = LruCache(maxSize = 500) private val cacheMutex = Mutex() 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 0328760f..26c8c271 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 @@ -3,6 +3,7 @@ package zed.rainxch.tweaks.presentation import zed.rainxch.core.domain.model.AppTheme import zed.rainxch.core.domain.model.FontTheme import zed.rainxch.core.domain.model.InstallerType +import zed.rainxch.core.domain.model.ProxyScope import zed.rainxch.tweaks.presentation.model.ProxyType sealed interface TweaksAction { @@ -33,30 +34,41 @@ sealed interface TweaksAction { ) : TweaksAction data class OnProxyTypeSelected( + val scope: ProxyScope, val type: ProxyType, ) : TweaksAction data class OnProxyHostChanged( + val scope: ProxyScope, val host: String, ) : TweaksAction data class OnProxyPortChanged( + val scope: ProxyScope, val port: String, ) : TweaksAction data class OnProxyUsernameChanged( + val scope: ProxyScope, val username: String, ) : TweaksAction data class OnProxyPasswordChanged( + val scope: ProxyScope, val password: String, ) : TweaksAction - data object OnProxyPasswordVisibilityToggle : TweaksAction + data class OnProxyPasswordVisibilityToggle( + val scope: ProxyScope, + ) : TweaksAction - data object OnProxySave : TweaksAction + data class OnProxySave( + val scope: ProxyScope, + ) : TweaksAction - data object OnProxyTest : TweaksAction + data class OnProxyTest( + val scope: ProxyScope, + ) : TweaksAction data class OnInstallerTypeSelected( val type: InstallerType, 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 187cfa3a..1138c2e2 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 @@ -3,8 +3,9 @@ package zed.rainxch.tweaks.presentation import zed.rainxch.core.domain.model.AppTheme import zed.rainxch.core.domain.model.FontTheme import zed.rainxch.core.domain.model.InstallerType +import zed.rainxch.core.domain.model.ProxyScope import zed.rainxch.core.domain.model.ShizukuAvailability -import zed.rainxch.tweaks.presentation.model.ProxyType +import zed.rainxch.tweaks.presentation.model.ProxyScopeFormState data class TweaksState( val selectedThemeColor: AppTheme = AppTheme.OCEAN, @@ -12,13 +13,8 @@ data class TweaksState( val isAmoledThemeEnabled: Boolean = false, val isDarkTheme: Boolean? = null, val versionName: String = "", - val proxyType: ProxyType = ProxyType.NONE, - val proxyHost: String = "", - val proxyPort: String = "", - val proxyUsername: String = "", - val proxyPassword: String = "", - val isProxyPasswordVisible: Boolean = false, - val isProxyTestInProgress: Boolean = false, + val proxyForms: Map = + ProxyScope.entries.associateWith { ProxyScopeFormState() }, val autoDetectClipboardLinks: Boolean = true, val cacheSize: String = "", val isClearDownloadsDialogVisible: Boolean = false, @@ -31,4 +27,8 @@ data class TweaksState( val isHideSeenEnabled: Boolean = false, val isScrollbarEnabled: Boolean = false, val isTelemetryEnabled: Boolean = false, -) +) { + /** Convenience accessor — guaranteed non-null because the map is + * seeded with entries for every [ProxyScope] at construction time. */ + fun formFor(scope: ProxyScope): ProxyScopeFormState = proxyForms.getValue(scope) +} 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 f2963948..4aad03a5 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 @@ -14,15 +14,18 @@ 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.model.ProxyScope import zed.rainxch.core.domain.network.ProxyTestOutcome import zed.rainxch.core.domain.network.ProxyTester import zed.rainxch.core.domain.repository.DeviceIdentityRepository import zed.rainxch.core.domain.repository.ProxyRepository import zed.rainxch.core.domain.repository.SeenReposRepository +import zed.rainxch.core.domain.repository.TelemetryRepository import zed.rainxch.core.domain.repository.TweaksRepository import zed.rainxch.core.domain.system.InstallerStatusProvider import zed.rainxch.core.domain.system.UpdateScheduleManager import zed.rainxch.core.domain.utils.BrowserHelper +import zed.rainxch.tweaks.presentation.model.ProxyScopeFormState 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 @@ -46,6 +49,7 @@ class TweaksViewModel( private val updateScheduleManager: UpdateScheduleManager, private val seenReposRepository: SeenReposRepository, private val deviceIdentityRepository: DeviceIdentityRepository, + private val telemetryRepository: TelemetryRepository, ) : ViewModel() { private var hasLoadedInitialData = false private var cacheSizeJob: Job? = null @@ -162,41 +166,62 @@ class TweaksViewModel( } private fun loadProxyConfig() { - viewModelScope.launch { - proxyRepository.getProxyConfig().collect { config -> - _state.update { - it.copy( - proxyType = ProxyType.fromConfig(config), - proxyHost = - when (config) { - is ProxyConfig.Http -> config.host - is ProxyConfig.Socks -> config.host - else -> it.proxyHost - }, - proxyPort = - when (config) { - is ProxyConfig.Http -> config.port.toString() - is ProxyConfig.Socks -> config.port.toString() - else -> it.proxyPort - }, - proxyUsername = - when (config) { - is ProxyConfig.Http -> config.username ?: "" - is ProxyConfig.Socks -> config.username ?: "" - else -> it.proxyUsername - }, - proxyPassword = - when (config) { - is ProxyConfig.Http -> config.password ?: "" - is ProxyConfig.Socks -> config.password ?: "" - else -> it.proxyPassword - }, - ) + // Start one collector per scope. Each updates its slot in the + // [TweaksState.proxyForms] map — scopes are independent so the + // flows intentionally don't share state. + ProxyScope.entries.forEach { scope -> + viewModelScope.launch { + proxyRepository.getProxyConfig(scope).collect { config -> + _state.update { state -> + val existing = state.formFor(scope) + val populated = + existing.copy( + type = ProxyType.fromConfig(config), + host = + when (config) { + is ProxyConfig.Http -> config.host + is ProxyConfig.Socks -> config.host + else -> existing.host + }, + port = + when (config) { + is ProxyConfig.Http -> config.port.toString() + is ProxyConfig.Socks -> config.port.toString() + else -> existing.port + }, + username = + when (config) { + is ProxyConfig.Http -> config.username.orEmpty() + is ProxyConfig.Socks -> config.username.orEmpty() + else -> existing.username + }, + password = + when (config) { + is ProxyConfig.Http -> config.password.orEmpty() + is ProxyConfig.Socks -> config.password.orEmpty() + else -> existing.password + }, + ) + state.copy( + proxyForms = state.proxyForms + (scope to populated), + ) + } } } } } + private fun mutateForm( + scope: ProxyScope, + block: (ProxyScopeFormState) -> ProxyScopeFormState, + ) { + _state.update { state -> + state.copy( + proxyForms = state.proxyForms + (scope to block(state.formFor(scope))), + ) + } + } + private fun loadInstallerPreference() { viewModelScope.launch { tweaksRepository.getInstallerType().collect { type -> @@ -330,16 +355,21 @@ class TweaksViewModel( } is TweaksAction.OnProxyTypeSelected -> { - _state.update { it.copy(proxyType = action.type) } + mutateForm(action.scope) { it.copy(type = action.type) } + // NONE / SYSTEM have no form fields — persist immediately + // since there's nothing left for the user to fill in. For + // HTTP / SOCKS, wait for an explicit Save so validation + // can run against a completed form. if (action.type == ProxyType.NONE || action.type == ProxyType.SYSTEM) { val config = - when (action.type) { - ProxyType.NONE -> ProxyConfig.None - ProxyType.SYSTEM -> ProxyConfig.System + if (action.type == ProxyType.NONE) { + ProxyConfig.None + } else { + ProxyConfig.System } viewModelScope.launch { runCatching { - proxyRepository.setProxyConfig(config) + proxyRepository.setProxyConfig(action.scope, config) }.onSuccess { _events.send(TweaksEvent.OnProxySaved) }.onFailure { error -> @@ -354,29 +384,31 @@ class TweaksViewModel( } is TweaksAction.OnProxyHostChanged -> { - _state.update { it.copy(proxyHost = action.host) } + mutateForm(action.scope) { it.copy(host = action.host) } } is TweaksAction.OnProxyPortChanged -> { - _state.update { it.copy(proxyPort = action.port) } + mutateForm(action.scope) { it.copy(port = action.port) } } is TweaksAction.OnProxyUsernameChanged -> { - _state.update { it.copy(proxyUsername = action.username) } + mutateForm(action.scope) { it.copy(username = action.username) } } is TweaksAction.OnProxyPasswordChanged -> { - _state.update { it.copy(proxyPassword = action.password) } + mutateForm(action.scope) { it.copy(password = action.password) } } - TweaksAction.OnProxyPasswordVisibilityToggle -> { - _state.update { it.copy(isProxyPasswordVisible = !it.isProxyPasswordVisible) } + is TweaksAction.OnProxyPasswordVisibilityToggle -> { + mutateForm(action.scope) { + it.copy(isPasswordVisible = !it.isPasswordVisible) + } } - TweaksAction.OnProxySave -> { - val currentState = _state.value + is TweaksAction.OnProxySave -> { + val form = _state.value.formFor(action.scope) val port = - currentState.proxyPort + form.port .toIntOrNull() ?.takeIf { it in 1..65535 } ?: run { @@ -386,18 +418,18 @@ class TweaksViewModel( return } val host = - currentState.proxyHost.trim().takeIf { it.isNotBlank() } ?: run { + form.host.trim().takeIf { it.isNotBlank() } ?: run { viewModelScope.launch { _events.send(TweaksEvent.OnProxySaveError(getString(Res.string.proxy_host_required))) } return } - val username = currentState.proxyUsername.takeIf { it.isNotBlank() } - val password = currentState.proxyPassword.takeIf { it.isNotBlank() } + val username = form.username.takeIf { it.isNotBlank() } + val password = form.password.takeIf { it.isNotBlank() } val config = - when (currentState.proxyType) { + when (form.type) { ProxyType.HTTP -> ProxyConfig.Http(host, port, username, password) ProxyType.SOCKS -> ProxyConfig.Socks(host, port, username, password) ProxyType.NONE -> ProxyConfig.None @@ -406,7 +438,7 @@ class TweaksViewModel( viewModelScope.launch { runCatching { - proxyRepository.setProxyConfig(config) + proxyRepository.setProxyConfig(action.scope, config) }.onSuccess { _events.send(TweaksEvent.OnProxySaved) }.onFailure { error -> @@ -419,10 +451,11 @@ class TweaksViewModel( } } - TweaksAction.OnProxyTest -> { - if (_state.value.isProxyTestInProgress) return - val config = buildProxyConfigForTest() ?: return - _state.update { it.copy(isProxyTestInProgress = true) } + is TweaksAction.OnProxyTest -> { + val form = _state.value.formFor(action.scope) + if (form.isTestInProgress) return + val config = buildProxyConfigForTest(action.scope) ?: return + mutateForm(action.scope) { it.copy(isTestInProgress = true) } viewModelScope.launch { val outcome: ProxyTestOutcome = try { @@ -433,7 +466,7 @@ class TweaksViewModel( } catch (e: Exception) { ProxyTestOutcome.Failure.Unknown(e.message) } finally { - _state.update { it.copy(isProxyTestInProgress = false) } + mutateForm(action.scope) { it.copy(isTestInProgress = false) } } _events.send(outcome.toEvent()) } @@ -533,6 +566,12 @@ class TweaksViewModel( TweaksAction.OnResetAnalyticsId -> { viewModelScope.launch { + // Clear the telemetry buffer *before* resetting the ID. + // Order matters: any buffered event still carries the + // old device ID in its EventRequest payload, so draining + // them after the reset would leak the old ID to the + // backend attached to "fresh start" identity semantics. + telemetryRepository.clearPending() deviceIdentityRepository.resetDeviceId() _events.send(TweaksEvent.OnAnalyticsIdReset) } @@ -541,19 +580,19 @@ 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]. + * Builds the [ProxyConfig] to test from the current form state for [scope]. + * 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) { + private fun buildProxyConfigForTest(scope: ProxyScope): ProxyConfig? { + val form = _state.value.formFor(scope) + return when (form.type) { ProxyType.NONE -> ProxyConfig.None ProxyType.SYSTEM -> ProxyConfig.System ProxyType.HTTP, ProxyType.SOCKS -> { val port = - current.proxyPort + form.port .toIntOrNull() ?.takeIf { it in 1..65535 } ?: run { @@ -567,7 +606,7 @@ class TweaksViewModel( return null } val host = - current.proxyHost.trim().takeIf { it.isNotBlank() } + form.host.trim().takeIf { it.isNotBlank() } ?: run { viewModelScope.launch { _events.send( @@ -578,9 +617,9 @@ class TweaksViewModel( } return null } - val username = current.proxyUsername.takeIf { it.isNotBlank() } - val password = current.proxyPassword.takeIf { it.isNotBlank() } - if (current.proxyType == ProxyType.HTTP) { + val username = form.username.takeIf { it.isNotBlank() } + val password = form.password.takeIf { it.isNotBlank() } + if (form.type == ProxyType.HTTP) { ProxyConfig.Http(host, port, username, password) } else { ProxyConfig.Socks(host, port, username, password) 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 c77b3004..e899b4ca 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 @@ -44,82 +44,66 @@ import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.stringResource +import zed.rainxch.core.domain.model.ProxyScope import zed.rainxch.githubstore.core.presentation.res.* import zed.rainxch.tweaks.presentation.TweaksAction import zed.rainxch.tweaks.presentation.TweaksState import zed.rainxch.tweaks.presentation.components.SectionHeader +import zed.rainxch.tweaks.presentation.model.ProxyScopeFormState import zed.rainxch.tweaks.presentation.model.ProxyType -@OptIn(ExperimentalMaterial3ExpressiveApi::class) fun LazyListScope.networkSection( state: TweaksState, onAction: (TweaksAction) -> Unit, ) { item { - SectionHeader( - text = stringResource(Res.string.section_network), - ) - - Spacer(Modifier.height(8.dp)) - - ProxyTypeCard( - selectedType = state.proxyType, - onTypeSelected = { type -> - onAction(TweaksAction.OnProxyTypeSelected(type)) - }, + SectionHeader(text = stringResource(Res.string.section_network)) + Spacer(Modifier.height(4.dp)) + Text( + text = stringResource(Res.string.proxy_scope_intro), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), ) + Spacer(Modifier.height(4.dp)) + } - AnimatedVisibility( - visible = state.proxyType == ProxyType.NONE || state.proxyType == ProxyType.SYSTEM, - enter = expandVertically() + fadeIn(), - exit = shrinkVertically() + fadeOut(), - ) { - 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), - ) - } + // One card per scope. Ordering mirrors the user's mental model: + // browsing → downloading → translation (least-common last). + ProxyScope.entries.forEach { scope -> + item { + ProxyScopeCard( + scope = scope, + form = state.formFor(scope), + onAction = onAction, + ) + Spacer(Modifier.height(12.dp)) } + } +} - AnimatedVisibility( - visible = state.proxyType == ProxyType.HTTP || state.proxyType == ProxyType.SOCKS, - enter = expandVertically() + fadeIn(), - exit = shrinkVertically() + fadeOut(), - ) { - Column { - Spacer(Modifier.height(16.dp)) +private fun scopeTitleRes(scope: ProxyScope): StringResource = + when (scope) { + ProxyScope.DISCOVERY -> Res.string.proxy_scope_discovery_title + ProxyScope.DOWNLOAD -> Res.string.proxy_scope_download_title + ProxyScope.TRANSLATION -> Res.string.proxy_scope_translation_title + } - ProxyDetailsCard( - state = state, - onAction = onAction, - ) - } - } +private fun scopeDescriptionRes(scope: ProxyScope): StringResource = + when (scope) { + ProxyScope.DISCOVERY -> Res.string.proxy_scope_discovery_description + ProxyScope.DOWNLOAD -> Res.string.proxy_scope_download_description + ProxyScope.TRANSLATION -> Res.string.proxy_scope_translation_description } -} @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable -private fun ProxyTypeCard( - selectedType: ProxyType, - onTypeSelected: (ProxyType) -> Unit, +private fun ProxyScopeCard( + scope: ProxyScope, + form: ProxyScopeFormState, + onAction: (TweaksAction) -> Unit, ) { ElevatedCard( modifier = Modifier.fillMaxWidth(), @@ -129,17 +113,21 @@ private fun ProxyTypeCard( ), shape = RoundedCornerShape(32.dp), ) { - Column( - modifier = Modifier.padding(16.dp), - ) { + Column(modifier = Modifier.padding(16.dp)) { Text( - text = stringResource(Res.string.proxy_type), + text = stringResource(scopeTitleRes(scope)), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.SemiBold, ) + Text( + text = stringResource(scopeDescriptionRes(scope)), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 2.dp), + ) - Spacer(Modifier.height(8.dp)) + Spacer(Modifier.height(12.dp)) LazyRow( modifier = Modifier.fillMaxWidth(), @@ -147,8 +135,8 @@ private fun ProxyTypeCard( ) { items(ProxyType.entries) { type -> FilterChip( - selected = selectedType == type, - onClick = { onTypeSelected(type) }, + selected = form.type == type, + onClick = { onAction(TweaksAction.OnProxyTypeSelected(scope, type)) }, label = { Text( text = @@ -158,7 +146,7 @@ private fun ProxyTypeCard( ProxyType.HTTP -> stringResource(Res.string.proxy_http) ProxyType.SOCKS -> stringResource(Res.string.proxy_socks) }, - fontWeight = if (selectedType == type) FontWeight.Bold else FontWeight.Normal, + fontWeight = if (form.type == type) FontWeight.Bold else FontWeight.Normal, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface, maxLines = 1, @@ -168,148 +156,173 @@ private fun ProxyTypeCard( ) } } + + AnimatedVisibility( + visible = form.type == ProxyType.NONE || form.type == ProxyType.SYSTEM, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut(), + ) { + Column { + Spacer(Modifier.height(12.dp)) + Text( + text = + when (form.type) { + ProxyType.SYSTEM -> stringResource(Res.string.proxy_system_description) + else -> stringResource(Res.string.proxy_none_description) + }, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(8.dp)) + ProxyTestButton( + isInProgress = form.isTestInProgress, + enabled = !form.isTestInProgress, + onClick = { onAction(TweaksAction.OnProxyTest(scope)) }, + ) + } + } + + AnimatedVisibility( + visible = form.type == ProxyType.HTTP || form.type == ProxyType.SOCKS, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut(), + ) { + ProxyDetailsFields( + scope = scope, + form = form, + onAction = onAction, + ) + } } } } @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable -private fun ProxyDetailsCard( - state: TweaksState, +private fun ProxyDetailsFields( + scope: ProxyScope, + form: ProxyScopeFormState, onAction: (TweaksAction) -> Unit, ) { - val portValue = state.proxyPort + val portValue = form.port val isPortInvalid = portValue.isNotEmpty() && (portValue.toIntOrNull()?.let { it !in 1..65535 } ?: true) val isFormValid = - state.proxyHost.isNotBlank() && + form.host.isNotBlank() && portValue.isNotEmpty() && portValue.toIntOrNull()?.let { it in 1..65535 } == true - ElevatedCard( - modifier = Modifier.fillMaxWidth(), - colors = - CardDefaults.elevatedCardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainer, - ), - shape = RoundedCornerShape(32.dp), + Column( + modifier = Modifier.padding(top = 12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), ) { - Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - OutlinedTextField( - value = state.proxyHost, - onValueChange = { onAction(TweaksAction.OnProxyHostChanged(it)) }, - label = { Text(stringResource(Res.string.proxy_host)) }, - placeholder = { Text("127.0.0.1") }, - singleLine = true, - modifier = Modifier.weight(2f), - shape = RoundedCornerShape(12.dp), - ) - - OutlinedTextField( - value = state.proxyPort, - onValueChange = { onAction(TweaksAction.OnProxyPortChanged(it)) }, - label = { Text(stringResource(Res.string.proxy_port)) }, - placeholder = { Text("1080") }, - singleLine = true, - isError = isPortInvalid, - supportingText = - if (isPortInvalid) { - { Text(stringResource(Res.string.proxy_port_error)) } - } else { - null - }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - modifier = Modifier.weight(1f), - shape = RoundedCornerShape(12.dp), - ) - } - - // Username OutlinedTextField( - value = state.proxyUsername, - onValueChange = { onAction(TweaksAction.OnProxyUsernameChanged(it)) }, - label = { Text(stringResource(Res.string.proxy_username)) }, + value = form.host, + onValueChange = { onAction(TweaksAction.OnProxyHostChanged(scope, it)) }, + label = { Text(stringResource(Res.string.proxy_host)) }, + placeholder = { Text("127.0.0.1") }, singleLine = true, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.weight(2f), shape = RoundedCornerShape(12.dp), ) - // Password with visibility toggle OutlinedTextField( - value = state.proxyPassword, - onValueChange = { onAction(TweaksAction.OnProxyPasswordChanged(it)) }, - label = { Text(stringResource(Res.string.proxy_password)) }, + value = form.port, + onValueChange = { onAction(TweaksAction.OnProxyPortChanged(scope, it)) }, + label = { Text(stringResource(Res.string.proxy_port)) }, + placeholder = { Text("1080") }, singleLine = true, - visualTransformation = - if (state.isProxyPasswordVisible) { - VisualTransformation.None + isError = isPortInvalid, + supportingText = + if (isPortInvalid) { + { Text(stringResource(Res.string.proxy_port_error)) } } else { - PasswordVisualTransformation() + null }, - trailingIcon = { - IconButton( - onClick = { onAction(TweaksAction.OnProxyPasswordVisibilityToggle) }, - ) { - Icon( - imageVector = - if (state.isProxyPasswordVisible) { - Icons.Default.VisibilityOff - } else { - Icons.Default.Visibility - }, - contentDescription = - if (state.isProxyPasswordVisible) { - stringResource(Res.string.proxy_hide_password) - } else { - stringResource(Res.string.proxy_show_password) - }, - modifier = Modifier.size(20.dp), - ) - } - }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), - modifier = Modifier.fillMaxWidth(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.weight(1f), shape = RoundedCornerShape(12.dp), ) + } - // Test + Save buttons - Row( - modifier = Modifier.align(Alignment.End), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - ProxyTestButton( - isInProgress = state.isProxyTestInProgress, - enabled = isFormValid && !state.isProxyTestInProgress, - onClick = { onAction(TweaksAction.OnProxyTest) }, - ) + OutlinedTextField( + value = form.username, + onValueChange = { onAction(TweaksAction.OnProxyUsernameChanged(scope, it)) }, + label = { Text(stringResource(Res.string.proxy_username)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + ) - FilledTonalButton( - onClick = { onAction(TweaksAction.OnProxySave) }, - enabled = isFormValid && !state.isProxyTestInProgress, + OutlinedTextField( + value = form.password, + onValueChange = { onAction(TweaksAction.OnProxyPasswordChanged(scope, it)) }, + label = { Text(stringResource(Res.string.proxy_password)) }, + singleLine = true, + visualTransformation = + if (form.isPasswordVisible) { + VisualTransformation.None + } else { + PasswordVisualTransformation() + }, + trailingIcon = { + IconButton( + onClick = { onAction(TweaksAction.OnProxyPasswordVisibilityToggle(scope)) }, ) { Icon( - imageVector = Icons.Default.Save, - contentDescription = null, - modifier = Modifier.size(18.dp), + imageVector = + if (form.isPasswordVisible) { + Icons.Default.VisibilityOff + } else { + Icons.Default.Visibility + }, + contentDescription = + if (form.isPasswordVisible) { + stringResource(Res.string.proxy_hide_password) + } else { + stringResource(Res.string.proxy_show_password) + }, + modifier = Modifier.size(20.dp), ) - Spacer(Modifier.size(8.dp)) - Text(stringResource(Res.string.proxy_save)) } + }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + ) + + Row( + modifier = Modifier.align(Alignment.End), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + ProxyTestButton( + isInProgress = form.isTestInProgress, + enabled = isFormValid && !form.isTestInProgress, + onClick = { onAction(TweaksAction.OnProxyTest(scope)) }, + ) + + FilledTonalButton( + onClick = { onAction(TweaksAction.OnProxySave(scope)) }, + enabled = isFormValid && !form.isTestInProgress, + ) { + 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, diff --git a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/model/ProxyScopeFormState.kt b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/model/ProxyScopeFormState.kt new file mode 100644 index 00000000..f94795c8 --- /dev/null +++ b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/model/ProxyScopeFormState.kt @@ -0,0 +1,18 @@ +package zed.rainxch.tweaks.presentation.model + +/** + * Per-scope form backing state for the proxy section. Each + * [zed.rainxch.core.domain.model.ProxyScope] card owns one of these + * — keeps the in-progress, test, and visibility flags independent + * across scopes so the user can edit one card while a test runs on + * another. + */ +data class ProxyScopeFormState( + val type: ProxyType = ProxyType.SYSTEM, + val host: String = "", + val port: String = "", + val username: String = "", + val password: String = "", + val isPasswordVisible: Boolean = false, + val isTestInProgress: Boolean = false, +) From 53f096b0fd6b5fd6c067f64607d73bacc366a4a2 Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Sat, 18 Apr 2026 12:28:12 +0500 Subject: [PATCH 2/5] Introduce a configurable translation provider system, adding support for Youdao as an alternative to Google Translate. - Implement a `Translator` interface and refactor translation logic into specialized `GoogleTranslator` and `YoudaoTranslator` classes. - Add `YoudaoTranslator` to support users in mainland China, requiring a user-provided `appKey` and `appSecret`. - Update `TranslationRepositoryImpl` to dynamically resolve the selected translator and handle provider-specific chunk size limits. - Expand the Tweaks/Settings UI with a new Translation section to allow provider selection and credential management for Youdao. - Update `TweaksRepository` and `TweaksViewModel` to persist and manage translation settings. - Add localized strings for the new translation settings in English, Turkish, Arabic, Chinese (Simplified), Bengali, Japanese, Korean, Polish, Italian, French, Russian, Hindi, and Spanish. --- .../data/repository/TweaksRepositoryImpl.kt | 35 +++ .../core/domain/model/TranslationProvider.kt | 26 ++ .../domain/repository/TweaksRepository.kt | 13 + .../composeResources/values-ar/strings-ar.xml | 14 ++ .../composeResources/values-bn/strings-bn.xml | 14 ++ .../composeResources/values-es/strings-es.xml | 14 ++ .../composeResources/values-fr/strings-fr.xml | 134 +++++----- .../composeResources/values-hi/strings-hi.xml | 14 ++ .../composeResources/values-it/strings-it.xml | 78 +++--- .../composeResources/values-ja/strings-ja.xml | 14 ++ .../composeResources/values-ko/strings-ko.xml | 14 ++ .../composeResources/values-pl/strings-pl.xml | 14 ++ .../composeResources/values-ru/strings-ru.xml | 14 ++ .../composeResources/values-tr/strings-tr.xml | 44 ++-- .../values-zh-rCN/strings-zh-rCN.xml | 14 ++ .../composeResources/values/strings.xml | 14 ++ .../rainxch/details/data/di/SharedModule.kt | 1 + .../repository/TranslationRepositoryImpl.kt | 93 ++++--- .../data/translation/GoogleTranslator.kt | 54 ++++ .../details/data/translation/Translator.kt | 31 +++ .../data/translation/YoudaoTranslator.kt | 147 +++++++++++ .../tweaks/presentation/TweaksAction.kt | 17 ++ .../tweaks/presentation/TweaksEvent.kt | 4 + .../rainxch/tweaks/presentation/TweaksRoot.kt | 20 +- .../tweaks/presentation/TweaksState.kt | 5 + .../tweaks/presentation/TweaksViewModel.kt | 62 +++++ .../components/sections/SettingsSection.kt | 9 + .../components/sections/Translation.kt | 231 ++++++++++++++++++ 28 files changed, 981 insertions(+), 163 deletions(-) create mode 100644 core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/TranslationProvider.kt create mode 100644 feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/translation/GoogleTranslator.kt create mode 100644 feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/translation/Translator.kt create mode 100644 feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/translation/YoudaoTranslator.kt create mode 100644 feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/components/sections/Translation.kt diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/TweaksRepositoryImpl.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/TweaksRepositoryImpl.kt index aa9867a0..7c1f968d 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/TweaksRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/TweaksRepositoryImpl.kt @@ -12,6 +12,7 @@ import zed.rainxch.core.domain.model.AppTheme import zed.rainxch.core.domain.model.DiscoveryPlatform import zed.rainxch.core.domain.model.FontTheme import zed.rainxch.core.domain.model.InstallerType +import zed.rainxch.core.domain.model.TranslationProvider import zed.rainxch.core.domain.repository.TweaksRepository class TweaksRepositoryImpl( @@ -179,6 +180,37 @@ class TweaksRepositoryImpl( } } + override fun getTranslationProvider(): Flow = + preferences.data.map { prefs -> + TranslationProvider.fromName(prefs[TRANSLATION_PROVIDER_KEY]) + } + + override suspend fun setTranslationProvider(provider: TranslationProvider) { + preferences.edit { prefs -> + prefs[TRANSLATION_PROVIDER_KEY] = provider.name + } + } + + override fun getYoudaoAppKey(): Flow = + preferences.data.map { prefs -> prefs[YOUDAO_APP_KEY] ?: "" } + + override suspend fun setYoudaoAppKey(appKey: String) { + preferences.edit { prefs -> + val trimmed = appKey.trim() + if (trimmed.isEmpty()) prefs.remove(YOUDAO_APP_KEY) else prefs[YOUDAO_APP_KEY] = trimmed + } + } + + override fun getYoudaoAppSecret(): Flow = + preferences.data.map { prefs -> prefs[YOUDAO_APP_SECRET] ?: "" } + + override suspend fun setYoudaoAppSecret(appSecret: String) { + preferences.edit { prefs -> + val trimmed = appSecret.trim() + if (trimmed.isEmpty()) prefs.remove(YOUDAO_APP_SECRET) else prefs[YOUDAO_APP_SECRET] = trimmed + } + } + companion object { private const val DEFAULT_UPDATE_CHECK_INTERVAL_HOURS = 6L @@ -196,5 +228,8 @@ class TweaksRepositoryImpl( private val HIDE_SEEN_ENABLED_KEY = booleanPreferencesKey("hide_seen_enabled") private val SCROLLBAR_ENABLED_KEY = booleanPreferencesKey("scrollbar_enabled") private val TELEMETRY_ENABLED_KEY = booleanPreferencesKey("telemetry_enabled") + private val TRANSLATION_PROVIDER_KEY = stringPreferencesKey("translation_provider") + private val YOUDAO_APP_KEY = stringPreferencesKey("youdao_app_key") + private val YOUDAO_APP_SECRET = stringPreferencesKey("youdao_app_secret") } } diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/TranslationProvider.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/TranslationProvider.kt new file mode 100644 index 00000000..74760d80 --- /dev/null +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/TranslationProvider.kt @@ -0,0 +1,26 @@ +package zed.rainxch.core.domain.model + +/** + * Backend used to translate README content. + * + * - [GOOGLE] hits Google's public `translate.googleapis.com/translate_a/single` + * endpoint. No credentials required and works everywhere Google does, but + * it's an undocumented endpoint that can change or rate-limit at any time, + * and it's unreachable from mainland China without a proxy. + * - [YOUDAO] hits Youdao's official `openapi.youdao.com/api`. Requires the + * user to provide their own `appKey` / `appSecret` from Youdao's developer + * portal (there's no anonymous free tier). Directly accessible from + * mainland China, which is why it exists — see issue #429. + */ +enum class TranslationProvider { + GOOGLE, + YOUDAO, + ; + + companion object { + val Default: TranslationProvider = GOOGLE + + fun fromName(name: String?): TranslationProvider = + entries.firstOrNull { it.name == name } ?: Default + } +} diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/TweaksRepository.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/TweaksRepository.kt index b4f8816b..1624be46 100644 --- a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/TweaksRepository.kt +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/TweaksRepository.kt @@ -5,6 +5,7 @@ import zed.rainxch.core.domain.model.AppTheme import zed.rainxch.core.domain.model.DiscoveryPlatform import zed.rainxch.core.domain.model.FontTheme import zed.rainxch.core.domain.model.InstallerType +import zed.rainxch.core.domain.model.TranslationProvider interface TweaksRepository { fun getThemeColor(): Flow @@ -62,4 +63,16 @@ interface TweaksRepository { fun getTelemetryEnabled(): Flow suspend fun setTelemetryEnabled(enabled: Boolean) + + fun getTranslationProvider(): Flow + + suspend fun setTranslationProvider(provider: TranslationProvider) + + fun getYoudaoAppKey(): Flow + + suspend fun setYoudaoAppKey(appKey: String) + + fun getYoudaoAppSecret(): Flow + + suspend fun setYoudaoAppSecret(appSecret: String) } 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 5f394d8d..8a4246bb 100644 --- a/core/presentation/src/commonMain/composeResources/values-ar/strings-ar.xml +++ b/core/presentation/src/commonMain/composeResources/values-ar/strings-ar.xml @@ -704,4 +704,18 @@ تثبيت جاهز للتثبيت تثبيت (جاهز) + + + الترجمة + اختر الخدمة المستخدمة لترجمة README. + خدمة الترجمة + يعمل Google عالميًا دون تهيئة. يعمل Youdao من الصين القارية ولكنه يتطلب بيانات اعتماد API من بوابة مطوري Youdao. + Google + Youdao + تم تحديث خدمة الترجمة + سجّل في ai.youdao.com للحصول على App Key و App Secret. الطبقة المجانية كافية للاستخدام العادي. + App Key + App Secret + حفظ بيانات الاعتماد + تم حفظ بيانات اعتماد Youdao 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 7415b17f..a5cf16aa 100644 --- a/core/presentation/src/commonMain/composeResources/values-bn/strings-bn.xml +++ b/core/presentation/src/commonMain/composeResources/values-bn/strings-bn.xml @@ -703,4 +703,18 @@ ইনস্টল ইনস্টলের জন্য প্রস্তুত ইনস্টল (প্রস্তুত) + + + অনুবাদ + README অনুবাদের জন্য ব্যবহৃত পরিষেবা নির্বাচন করুন। + অনুবাদ পরিষেবা + Google বিশ্বব্যাপী কনফিগারেশন ছাড়াই কাজ করে। Youdao মূল ভূখণ্ডের চীন থেকে কাজ করে কিন্তু Youdao ডেভেলপার পোর্টাল থেকে API শংসাপত্র প্রয়োজন। + Google + Youdao + অনুবাদ পরিষেবা আপডেট করা হয়েছে + App Key এবং App Secret পেতে ai.youdao.com এ সাইন আপ করুন। সাধারণ ব্যবহারের জন্য বিনামূল্যের স্তর যথেষ্ট। + App Key + App Secret + শংসাপত্র সংরক্ষণ করুন + Youdao শংসাপত্র সংরক্ষিত হয়েছে 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 43db931a..d25dfb80 100644 --- a/core/presentation/src/commonMain/composeResources/values-es/strings-es.xml +++ b/core/presentation/src/commonMain/composeResources/values-es/strings-es.xml @@ -664,4 +664,18 @@ Instalar Listo para instalar Instalar (listo) + + + Traducción + Elige el servicio usado para traducir los README. + Servicio de traducción + Google funciona globalmente sin configuración. Youdao funciona desde China continental, pero requiere credenciales API del portal de desarrolladores de Youdao. + Google + Youdao + Servicio de traducción actualizado + Regístrate en ai.youdao.com para obtener tu App Key y App Secret. El nivel gratuito es suficiente para uso casual. + App Key + App Secret + Guardar credenciales + Credenciales de Youdao guardadas \ No newline at end of file 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 fe3a6b2a..3cfde6e3 100644 --- a/core/presentation/src/commonMain/composeResources/values-fr/strings-fr.xml +++ b/core/presentation/src/commonMain/composeResources/values-fr/strings-fr.xml @@ -170,12 +170,12 @@ Préparation pour AppManager Ouvert dans AppManager - Permission d\'installation bloquée par la politique de l\'appareil - Ouvert dans l\'installateur externe - Permission d\'installation indisponible - L\'APK a été téléchargé avec succès mais cet appareil n\'autorise pas l\'installation directe. Voulez-vous l\'ouvrir avec un installateur externe ? + Permission d'installation bloquée par la politique de l'appareil + Ouvert dans l'installateur externe + Permission d'installation indisponible + L'APK a été téléchargé avec succès mais cet appareil n'autorise pas l'installation directe. Voulez-vous l'ouvrir avec un installateur externe ? Ouvrir avec un installateur externe - Utiliser une application tierce pour installer l\'APK + Utiliser une application tierce pour installer l'APK Erreur : %1$s Type de fichier .%1$s non pris en charge @@ -324,7 +324,7 @@ Ouvrir le dépôt Ouvrir dans le navigateur Annuler le téléchargement - Afficher les options d\'installation + Afficher les options d'installation Aucune description fournie. @@ -345,10 +345,10 @@ Désinstaller Ouvrir La rétrogradation nécessite la désinstallation - L\'installation de la version %1$s nécessite la désinstallation de la version actuelle (%2$s). Les données de l\'application seront perdues. - Désinstaller d\'abord + L'installation de la version %1$s nécessite la désinstallation de la version actuelle (%2$s). Les données de l'application seront perdues. + Désinstaller d'abord Installer %1$s - Impossible d\'ouvrir %1$s + Impossible d'ouvrir %1$s Impossible de désinstaller %1$s @@ -357,7 +357,7 @@ Dernière vérification : %1$s Jamais vérifié - à l\'instant + à l'instant il y a %1$d min il y a %1$d h Vérification des mises à jour… @@ -370,11 +370,11 @@ SOCKS Hôte Port - Nom d\'utilisateur (facultatif) + Nom d'utilisateur (facultatif) Mot de passe (facultatif) Sauvegarder le Proxy Paramètres du proxy enregistrés - Utilise les paramètres proxy de l\'appareil + Utilise les paramètres proxy de l'appareil Le port doit être entre 1 et 65535 Connexion directe, pas de proxy Échec de l'enregistrement des paramètres du proxy @@ -385,7 +385,7 @@ Tester Test en cours… Connexion OK (%1$d ms) - Impossible de résoudre l\'hôte. Vérifiez l\'adresse du proxy. + 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. @@ -403,12 +403,12 @@ Suivre cette app App ajoutée à la liste de suivi - Échec du suivi de l\'app : %1$s - L\'app est déjà suivie + Échec du suivi de l'app : %1$s + L'app est déjà suivie Se connecter à GitHub - Débloquez l\'expérience complète. Gérez vos apps, synchronisez vos préférences et naviguez plus vite. + Débloquez l'expérience complète. Gérez vos apps, synchronisez vos préférences et naviguez plus vite. Dépôts Connexion Vos dépôts étoilés sur GitHub @@ -417,15 +417,15 @@ Session expirée Votre session GitHub a expiré ou le jeton a été révoqué. Veuillez vous reconnecter pour continuer à utiliser les fonctionnalités authentifiées. - Vous pouvez toujours naviguer en tant qu\'invité avec des requêtes API limitées. + Vous pouvez toujours naviguer en tant qu'invité avec des requêtes API limitées. Se reconnecter - Continuer en tant qu\'invité - Cela effacera votre session locale et les données en cache. Pour révoquer complètement l\'accès, visitez GitHub Settings > Applications. + Continuer en tant qu'invité + Cela effacera votre session locale et les données en cache. Pour révoquer complètement l'accès, visitez GitHub Settings > Applications. Le code expire dans %1$s - Le code de l\'appareil a expiré. + Le code de l'appareil a expiré. Veuillez réessayer de vous connecter pour obtenir un nouveau code. Vérifiez votre connexion internet et réessayez. - Vous avez refusé la demande d\'autorisation. Réessayez si c\'était involontaire. + Vous avez refusé la demande d'autorisation. Réessayez si c'était involontaire. J’ai déjà autorisé Vérification… Limite atteinte — nouvelle tentative dans %1$d s @@ -441,7 +441,7 @@ Traduire Traduction… - Afficher l\'original + Afficher l'original Traduit en %1$s Traduire en… Rechercher une langue @@ -451,9 +451,9 @@ Ouvrir le lien GitHub Lien GitHub détecté dans le presse-papiers Détecter les liens du presse-papiers - Détecter automatiquement les liens GitHub du presse-papiers lors de l\'ouverture de la recherche + Détecter automatiquement les liens GitHub du presse-papiers lors de l'ouverture de la recherche Liens détectés - Ouvrir dans l\'app + Ouvrir dans l'app Aucun lien GitHub trouvé dans le presse-papiers Stockage @@ -500,17 +500,17 @@ Installation Par défaut - Boîte de dialogue d\'installation système standard + Boîte de dialogue d'installation système standard Shizuku Installation silencieuse sans confirmation - Shizuku n\'est pas installé - Shizuku n\'est pas en cours d\'exécution + Shizuku n'est pas installé + Shizuku n'est pas en cours d'exécution Autorisation requise Prêt - Accorder l\'autorisation - Installez Shizuku pour activer l\'installation silencieuse - Démarrez Shizuku pour activer l\'installation silencieuse - L\'installation via Shizuku a échoué, utilisation de l\'installateur standard + Accorder l'autorisation + Installez Shizuku pour activer l'installation silencieuse + Démarrez Shizuku pour activer l'installation silencieuse + L'installation via Shizuku a échoué, utilisation de l'installateur standard Mise à jour automatique Télécharger et installer automatiquement les mises à jour en arrière-plan via Shizuku @@ -524,7 +524,7 @@ 24h Ajouter par lien - Lier l\'app au dépôt + Lier l'app au dépôt Choisissez une app installée à lier à un dépôt GitHub Rechercher des apps… URL du dépôt GitHub @@ -532,12 +532,12 @@ Validation… Lier et suivre Vérification de la dernière version… - Téléchargement de l\'APK pour vérification… + Téléchargement de l'APK pour vérification… Vérification de la clé de signature… - Nom de paquet différent : l\'APK est %1$s, mais l\'app sélectionnée est %2$s - Clé de signature différente : l\'APK de ce dépôt a été signé par un autre développeur - Sélectionner l\'installateur - Choisissez l\'APK à vérifier avec votre app installée + Nom de paquet différent : l'APK est %1$s, mais l'app sélectionnée est %2$s + Clé de signature différente : l'APK de ce dépôt a été signé par un autre développeur + Sélectionner l'installateur + Choisissez l'APK à vérifier avec votre app installée Échec du téléchargement Exporter Importer @@ -546,16 +546,16 @@ Collez le JSON exporté ici… Inclure les pré-versions Suivre les versions pré-release lors de la vérification des mises à jour. Désactivé, seules les versions stables sont prises en compte. - Désinstaller l\'app ? - Êtes-vous sûr de vouloir désinstaller %1$s ? Cette action est irréversible et les données de l\'app pourraient être perdues. + Désinstaller l'app ? + Êtes-vous sûr de vouloir désinstaller %1$s ? Cette action est irréversible et les données de l'app pourraient être perdues. URL GitHub invalide. Utilisez le format : github.com/owner/repo Dépôt introuvable : %1$s/%2$s - Limite de l\'API GitHub dépassée. Réessayez plus tard. + Limite de l'API GitHub dépassée. Réessayez plus tard. Échec de la liaison : %1$s Échec du chargement des apps installées %1$s liée à %2$s/%3$s - Échec de l\'exportation : %1$s - Échec de l\'importation : %1$s + Échec de l'exportation : %1$s + Échec de l'importation : %1$s %1$d apps importées , %1$d ignorées , %1$d échouées @@ -574,26 +574,26 @@ Réessayer Détection automatique : %1$s Sélectionner la langue - Incompatibilité de paquet : l\'APK est %1$s, mais l\'application installée est %2$s. Mise à jour bloquée. + Incompatibilité de paquet : l'APK est %1$s, mais l'application installée est %2$s. Mise à jour bloquée. Incompatibilité de clé de signature : la mise à jour a été signée par un développeur différent. Mise à jour bloquée. Effet verre liquide - Améliorez l\'interface avec une apparence vitreuse et fluide + Améliorez l'interface avec une apparence vitreuse et fluide Barre de défilement Afficher la barre de défilement dans les listes défilantes (bureau) Masquer les dépôts consultés Masquer les dépôts que vous avez déjà consultés des flux de découverte - Effacer l\'historique des consultations - Réinitialiser tous les dépôts consultés pour qu\'ils réapparaissent dans les flux + Effacer l'historique des consultations + Réinitialiser tous les dépôts consultés pour qu'ils réapparaissent dans les flux Historique des consultations effacé Consulté Confidentialité Aider à améliorer la recherche - Partager des données d\'utilisation anonymisées (recherches, installations, interactions) liées à un identifiant d\'analyse réinitialisable. Aucun détail de compte n\'est partagé. - Réinitialiser l\'ID d\'analytique + Partager des données d'utilisation anonymisées (recherches, installations, interactions) liées à un identifiant d'analyse réinitialisable. Aucun détail de compte n'est partagé. + Réinitialiser l'ID d'analytique Générer un nouvel ID anonyme, coupant le lien avec la télémétrie passée. - ID d\'analytique réinitialisé + ID d'analytique réinitialisé Récemment consultés Dépôts que vous avez visités @@ -614,7 +614,7 @@ Pré-versions - Filtre d\'assets + Filtre d'assets ex : ente-auth Seuls les assets correspondant à ce motif (regex) seront installés. Utile pour les monorepos contenant plusieurs apps. Regex invalide @@ -625,16 +625,16 @@ %1$d sur %2$d assets affichés Recourir aux anciennes versions - Parcourir les versions précédentes jusqu\'à en trouver une qui corresponde au filtre. Nécessaire pour les monorepos où la dernière version appartient à une autre app. + Parcourir les versions précédentes jusqu'à en trouver une qui corresponde au filtre. Nécessaire pour les monorepos où la dernière version appartient à une autre app. Filtre avancé Configurez comment cette app est identifiée dans le dépôt. Utile lorsque le dépôt contient plusieurs apps. Ouvrir le filtre avancé Enregistrer Aperçu - Actualiser l\'aperçu + Actualiser l'aperçu Saisissez un filtre pour prévisualiser les assets correspondants. - Aucune version récente ne contient d\'asset correspondant. Activez le repli vers les anciennes versions ou ajustez le regex. - Impossible de charger l\'aperçu. Vérifiez votre connexion et réessayez. + Aucune version récente ne contient d'asset correspondant. Activez le repli vers les anciennes versions ou ajustez le regex. + Impossible de charger l'aperçu. Vérifiez votre connexion et réessayez. Correspondance dans %1$s · %2$d asset Correspondance dans %1$s · %2$d assets @@ -642,11 +642,11 @@ Variante préférée - Choisissez la variante d\'APK à installer pour les mises à jour. Le choix est mémorisé entre les versions. + Choisissez la variante d'APK à installer pour les mises à jour. Le choix est mémorisé entre les versions. Aucun asset installable dans la dernière version. - Cette version n\'a pas de variantes étiquetées par version à épingler. Le sélecteur automatique continuera à choisir le meilleur asset pour votre appareil. + Cette version n'a pas de variantes étiquetées par version à épingler. Le sélecteur automatique continuera à choisir le meilleur asset pour votre appareil. Impossible de charger les variantes. Vérifiez votre connexion et réessayez. - Impossible d\'enregistrer votre choix. Réessayez. + Impossible d'enregistrer votre choix. Réessayez. La variante a changé — choisissez à nouveau Avant : %1$s Automatique @@ -658,11 +658,25 @@ Épinglée Choisir la variante Ajuster le filtre - Les futures mises à jour préféreront la variante %1$s. Modifiable à tout moment dans les paramètres de l\'application. - Les futures mises à jour préféreront cette variante. Modifiable à tout moment dans les paramètres de l\'application. + Les futures mises à jour préféreront la variante %1$s. Modifiable à tout moment dans les paramètres de l'application. + Les futures mises à jour préféreront cette variante. Modifiable à tout moment dans les paramètres de l'application. Variante désépinglée. Les mises à jour utiliseront le sélecteur automatique. - Le filtre décide quels fichiers sont pris en compte. L\'épingle de variante décide lequel sera installé. + Le filtre décide quels fichiers sont pris en compte. L'épingle de variante décide lequel sera installé. Installer Prêt à installer Installer (prêt) + + + Traduction + Choisissez le service utilisé pour traduire les README. + Service de traduction + Google fonctionne partout sans configuration. Youdao fonctionne depuis la Chine continentale mais nécessite des identifiants API du portail développeur de Youdao. + Google + Youdao + Service de traduction mis à jour + Inscrivez-vous sur ai.youdao.com pour obtenir votre App Key et App Secret. Le niveau gratuit est suffisant pour un usage occasionnel. + App Key + App Secret + Enregistrer les identifiants + Identifiants Youdao enregistrés \ No newline at end of file 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 18f8f3d0..a74c2fab 100644 --- a/core/presentation/src/commonMain/composeResources/values-hi/strings-hi.xml +++ b/core/presentation/src/commonMain/composeResources/values-hi/strings-hi.xml @@ -702,4 +702,18 @@ इंस्टॉल इंस्टॉल के लिए तैयार इंस्टॉल (तैयार) + + + अनुवाद + README का अनुवाद करने के लिए उपयोग की जाने वाली सेवा चुनें। + अनुवाद सेवा + Google बिना कॉन्फ़िगरेशन के वैश्विक रूप से काम करता है। Youdao मुख्यभूमि चीन से काम करता है लेकिन Youdao डेवलपर पोर्टल से API क्रेडेंशियल्स की आवश्यकता है। + Google + Youdao + अनुवाद सेवा अपडेट की गई + अपना App Key और App Secret पाने के लिए ai.youdao.com पर साइन अप करें। आम उपयोग के लिए मुफ्त स्तर पर्याप्त है। + App Key + App Secret + क्रेडेंशियल्स सहेजें + Youdao क्रेडेंशियल्स सहेजे गए 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 5fa832c8..e297e0ae 100644 --- a/core/presentation/src/commonMain/composeResources/values-it/strings-it.xml +++ b/core/presentation/src/commonMain/composeResources/values-it/strings-it.xml @@ -224,11 +224,11 @@ Preparazione dell'AppManager Aperto nell'AppManager Permesso di installazione bloccato dalla policy del dispositivo - Aperto nell\'installatore esterno + Aperto nell'installatore esterno Permesso di installazione non disponibile - L\'APK è stato scaricato con successo ma questo dispositivo non consente l\'installazione diretta. Vuoi aprirlo con un installatore esterno? + L'APK è stato scaricato con successo ma questo dispositivo non consente l'installazione diretta. Vuoi aprirlo con un installatore esterno? Apri con installatore esterno - Usa un\'app di terze parti per installare l\'APK + Usa un'app di terze parti per installare l'APK Errore: %1$s @@ -376,7 +376,7 @@ Disinstalla Apri Il downgrade richiede la disinstallazione - L\'installazione della versione %1$s richiede la disinstallazione della versione corrente (%2$s). I dati dell\'app verranno persi. + L'installazione della versione %1$s richiede la disinstallazione della versione corrente (%2$s). I dati dell'app verranno persi. Disinstalla prima Installa %1$s Impossibile aprire %1$s @@ -421,7 +421,7 @@ Verifica Verifica in corso… Connessione OK (%1$d ms) - Impossibile risolvere l\'host. Controlla l\'indirizzo del proxy. + Impossibile risolvere l'host. Controlla l'indirizzo del proxy. Impossibile raggiungere il server proxy. Timeout della connessione. Autenticazione proxy richiesta. @@ -439,12 +439,12 @@ Traccia questa app App aggiunta alla lista di monitoraggio - Impossibile tracciare l\'app: %1$s - L\'app è già monitorata + Impossibile tracciare l'app: %1$s + L'app è già monitorata Accedi a GitHub - Sblocca l\'esperienza completa. Gestisci le tue app, sincronizza le preferenze e naviga più velocemente. + Sblocca l'esperienza completa. Gestisci le tue app, sincronizza le preferenze e naviga più velocemente. Repo Accedi I tuoi repository preferiti su GitHub @@ -456,7 +456,7 @@ Puoi continuare a navigare come ospite con richieste API limitate. Accedi di nuovo Continua come ospite - Questo cancellerà la sessione locale e i dati nella cache. Per revocare completamente l\'accesso, visita GitHub Settings > Applications. + Questo cancellerà la sessione locale e i dati nella cache. Per revocare completamente l'accesso, visita GitHub Settings > Applications. Il codice scade tra %1$s Il codice del dispositivo è scaduto. Riprova ad accedere per ottenere un nuovo codice. @@ -487,9 +487,9 @@ Apri link GitHub Link GitHub rilevato negli appunti Rileva link dagli appunti - Rileva automaticamente i link GitHub dagli appunti all\'apertura della ricerca + Rileva automaticamente i link GitHub dagli appunti all'apertura della ricerca Link rilevati - Apri nell\'app + Apri nell'app Nessun link GitHub trovato negli appunti Archiviazione @@ -546,9 +546,9 @@ Autorizzazione necessaria Pronto Concedi autorizzazione - Installa Shizuku per abilitare l\'installazione silenziosa - Avvia Shizuku per abilitare l\'installazione silenziosa - Installazione tramite Shizuku fallita, utilizzo dell\'installatore standard + Installa Shizuku per abilitare l'installazione silenziosa + Avvia Shizuku per abilitare l'installazione silenziosa + Installazione tramite Shizuku fallita, utilizzo dell'installatore standard Aggiornamento automatico Scarica e installa automaticamente gli aggiornamenti in background tramite Shizuku @@ -563,19 +563,19 @@ Aggiungi tramite link Collega app al repository - Scegli un\'app installata da collegare a un repository GitHub + Scegli un'app installata da collegare a un repository GitHub Cerca app… URL del repository GitHub github.com/owner/repo Validazione… Collega e monitora - Controllo dell\'ultima versione… + Controllo dell'ultima versione… Download APK per la verifica… Verifica della chiave di firma… - Nome pacchetto diverso: l\'APK è %1$s, ma l\'app selezionata è %2$s - Chiave di firma diversa: l\'APK di questo repository è stato firmato da uno sviluppatore diverso + Nome pacchetto diverso: l'APK è %1$s, ma l'app selezionata è %2$s + Chiave di firma diversa: l'APK di questo repository è stato firmato da uno sviluppatore diverso Seleziona installatore - Scegli l\'APK da verificare con la tua app installata + Scegli l'APK da verificare con la tua app installata Download fallito Esporta Importa @@ -584,8 +584,8 @@ Incolla il JSON esportato qui… Includi pre-release Monitora le versioni pre-release durante il controllo aggiornamenti. Se disabilitato, vengono considerate solo le versioni stabili. - Disinstallare l\'app? - Sei sicuro di voler disinstallare %1$s? Questa azione non può essere annullata e i dati dell\'app potrebbero andare persi. + Disinstallare l'app? + Sei sicuro di voler disinstallare %1$s? Questa azione non può essere annullata e i dati dell'app potrebbero andare persi. URL GitHub non valido. Usa il formato: github.com/owner/repo Repository non trovato: %1$s/%2$s Limite API GitHub superato. Riprova più tardi. @@ -612,10 +612,10 @@ Riprova Rilevato automaticamente: %1$s Seleziona lingua - Pacchetto non corrispondente: l\'APK è %1$s, ma l\'app installata è %2$s. Aggiornamento bloccato. - Chiave di firma non corrispondente: l\'aggiornamento è stato firmato da uno sviluppatore diverso. Aggiornamento bloccato. + Pacchetto non corrispondente: l'APK è %1$s, ma l'app installata è %2$s. Aggiornamento bloccato. + Chiave di firma non corrispondente: l'aggiornamento è stato firmato da uno sviluppatore diverso. Aggiornamento bloccato. Effetto vetro liquido - Migliora l\'interfaccia con un aspetto liscio simile al vetro + Migliora l'interfaccia con un aspetto liscio simile al vetro Barra di scorrimento Mostra la barra di scorrimento nelle liste scorrevoli (desktop) @@ -628,7 +628,7 @@ Visualizzato Privacy Aiuta a migliorare la ricerca - Condividi dati di utilizzo anonimizzati (ricerche, installazioni, interazioni) collegati a un ID analitico ripristinabile. Nessun dettaglio dell\'account viene condiviso. + Condividi dati di utilizzo anonimizzati (ricerche, installazioni, interazioni) collegati a un ID analitico ripristinabile. Nessun dettaglio dell'account viene condiviso. Reimposta ID analitico Genera un nuovo ID anonimo, interrompendo il collegamento con la telemetria passata. ID analitico reimpostato @@ -663,7 +663,7 @@ Mostrati %1$d di %2$d asset Risalire alle release precedenti - Scorri le release precedenti finché una non corrisponde al filtro. Necessario per i monorepo in cui l\'ultima release riguarda un\'altra app. + Scorri le release precedenti finché una non corrisponde al filtro. Necessario per i monorepo in cui l'ultima release riguarda un'altra app. Filtro avanzato Configura come questa app viene identificata nel repository. Usa queste impostazioni quando il repo contiene più app. Apri filtro avanzato @@ -672,7 +672,7 @@ Aggiorna anteprima Inserisci un filtro per visualizzare gli asset corrispondenti. Nessuna release recente contiene asset corrispondenti. Attiva il fallback alle release precedenti o modifica la regex. - Impossibile caricare l\'anteprima. Controlla la connessione e riprova. + Impossibile caricare l'anteprima. Controlla la connessione e riprova. Corrispondenza in %1$s · %2$d asset Corrispondenza in %1$s · %2$d asset @@ -681,8 +681,8 @@ Variante preferita Scegli quale variante APK installare per gli aggiornamenti. La scelta viene ricordata tra le release. - Nessun asset installabile nell\'ultima release. - Questa release non ha varianti con versione da bloccare. Il selettore automatico continuerà a scegliere l\'asset migliore per il tuo dispositivo. + Nessun asset installabile nell'ultima release. + Questa release non ha varianti con versione da bloccare. Il selettore automatico continuerà a scegliere l'asset migliore per il tuo dispositivo. Impossibile caricare le varianti. Controlla la connessione e riprova. Impossibile salvare la scelta. Riprova. Variante cambiata — scegli di nuovo @@ -696,11 +696,25 @@ Bloccata Scegli variante Modifica filtro - I prossimi aggiornamenti preferiranno la variante %1$s. Puoi modificarlo in qualsiasi momento dalle impostazioni dell\'app. - I prossimi aggiornamenti preferiranno questa variante. Puoi modificarlo in qualsiasi momento dalle impostazioni dell\'app. + I prossimi aggiornamenti preferiranno la variante %1$s. Puoi modificarlo in qualsiasi momento dalle impostazioni dell'app. + I prossimi aggiornamenti preferiranno questa variante. Puoi modificarlo in qualsiasi momento dalle impostazioni dell'app. Variante sbloccata. Gli aggiornamenti useranno il selettore automatico. Il filtro decide quali file vengono considerati. Il blocco della variante decide quale di essi viene installato. Installa - Pronto per l\'installazione + Pronto per l'installazione Installa (pronto) + + + Traduzione + Scegli il servizio usato per tradurre i README. + Servizio di traduzione + Google funziona ovunque senza configurazione. Youdao funziona dalla Cina continentale ma richiede credenziali API dal portale sviluppatori di Youdao. + Google + Youdao + Servizio di traduzione aggiornato + Registrati su ai.youdao.com per ottenere App Key e App Secret. Il piano gratuito è sufficiente per un uso occasionale. + App Key + App Secret + Salva credenziali + Credenziali Youdao salvate \ No newline at end of file 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 1fcec597..6a120cb5 100644 --- a/core/presentation/src/commonMain/composeResources/values-ja/strings-ja.xml +++ b/core/presentation/src/commonMain/composeResources/values-ja/strings-ja.xml @@ -664,4 +664,18 @@ インストール インストール準備完了 インストール(準備完了) + + + 翻訳 + README の翻訳に使用するサービスを選択します。 + 翻訳サービス + Google は世界中で設定不要で動作します。Youdao は中国本土からアクセスできますが、Youdao 開発者ポータルから API 認証情報が必要です。 + Google + Youdao + 翻訳サービスを更新しました + ai.youdao.com に登録して App Key と App Secret を取得してください。無料プランで通常の利用には十分です。 + App Key + App Secret + 認証情報を保存 + Youdao の認証情報を保存しました \ No newline at end of file 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 562f35c1..c336c14f 100644 --- a/core/presentation/src/commonMain/composeResources/values-ko/strings-ko.xml +++ b/core/presentation/src/commonMain/composeResources/values-ko/strings-ko.xml @@ -699,4 +699,18 @@ 설치 설치 준비 완료 설치 (준비됨) + + + 번역 + README 번역에 사용할 서비스를 선택하세요. + 번역 서비스 + Google은 전 세계적으로 설정 없이 작동합니다. Youdao는 중국 본토에서 작동하지만 Youdao 개발자 포털의 API 자격 증명이 필요합니다. + Google + Youdao + 번역 서비스가 업데이트되었습니다 + ai.youdao.com에 가입하여 App Key와 App Secret을 받으세요. 무료 요금제로 일반 사용에는 충분합니다. + App Key + App Secret + 자격 증명 저장 + Youdao 자격 증명이 저장되었습니다 \ No newline at end of file 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 20d014af..3118e023 100644 --- a/core/presentation/src/commonMain/composeResources/values-pl/strings-pl.xml +++ b/core/presentation/src/commonMain/composeResources/values-pl/strings-pl.xml @@ -671,4 +671,18 @@ Zainstaluj Gotowe do instalacji Zainstaluj (gotowe) + + + Tłumaczenie + Wybierz usługę używaną do tłumaczenia plików README. + Usługa tłumaczenia + Google działa globalnie bez konfiguracji. Youdao działa z Chin kontynentalnych, ale wymaga poświadczeń API z portalu deweloperskiego Youdao. + Google + Youdao + Usługa tłumaczenia zaktualizowana + Zarejestruj się na ai.youdao.com, aby uzyskać App Key i App Secret. Darmowy plan wystarcza do zwykłego użytku. + App Key + App Secret + Zapisz poświadczenia + Poświadczenia Youdao zapisane \ No newline at end of file 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 83bdfd1a..597e00fd 100644 --- a/core/presentation/src/commonMain/composeResources/values-ru/strings-ru.xml +++ b/core/presentation/src/commonMain/composeResources/values-ru/strings-ru.xml @@ -671,4 +671,18 @@ Установить Готово к установке Установить (готово) + + + Перевод + Выберите сервис для перевода README. + Сервис перевода + Google работает глобально без настройки. Youdao работает из материкового Китая, но требует учётных данных API с портала разработчиков Youdao. + Google + Youdao + Сервис перевода обновлён + Зарегистрируйтесь на ai.youdao.com, чтобы получить App Key и App Secret. Бесплатного тарифа достаточно для обычного использования. + App Key + App Secret + Сохранить учётные данные + Учётные данные Youdao сохранены \ No newline at end of file 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 0602dede..ac5de824 100644 --- a/core/presentation/src/commonMain/composeResources/values-tr/strings-tr.xml +++ b/core/presentation/src/commonMain/composeResources/values-tr/strings-tr.xml @@ -193,10 +193,10 @@ İndirmeler Lisans - GitHub\'tan daha fazla getir - GitHub\'tan getiriliyor… - GitHub\'ta başka sonuç yok - GitHub\'tan getirilemedi. Tekrar deneyin. + GitHub'tan daha fazla getir + GitHub'tan getiriliyor… + GitHub'ta başka sonuç yok + GitHub'tan getirilemedi. Tekrar deneyin. %1$s @@ -221,13 +221,13 @@ AppManager için hazırlanıyor - AppManager\'da açıldı + AppManager'da açıldı Yükleme izni cihaz politikası tarafından engellendi Harici yükleyicide açıldı Yükleme izni kullanılamıyor APK başarıyla indirildi ancak bu cihaz doğrudan yüklemeye izin vermiyor. Harici bir yükleyici ile açmak ister misiniz? Harici yükleyici ile aç - APK\'yı yüklemek için üçüncü taraf bir uygulama kullanın + APK'yı yüklemek için üçüncü taraf bir uygulama kullanın Hata: %1$s @@ -423,7 +423,7 @@ Proxy kimlik doğrulaması gerekiyor. Beklenmeyen yanıt: HTTP %1$d Bağlantı testi başarısız - Her kategori kendi proxy\'sini kullanabilir. Bunları bağımsız olarak yapılandırın. + Her kategori kendi proxy'sini kullanabilir. Bunları bağımsız olarak yapılandırın. Keşif (GitHub API) Ana sayfa, arama, depo ayrıntıları ve güncelleme denetimleri İndirmeler @@ -544,8 +544,8 @@ İzin gerekli Hazır İzin ver - Sessiz kurulumu etkinleştirmek için Shizuku\'yu yükleyin - Sessiz kurulumu etkinleştirmek için Shizuku\'yu başlatın + Sessiz kurulumu etkinleştirmek için Shizuku'yu yükleyin + Sessiz kurulumu etkinleştirmek için Shizuku'yu başlatın Shizuku kurulumu başarısız, standart yükleyici kullanılıyor Uygulamaları otomatik güncelle @@ -563,7 +563,7 @@ Uygulamayı depoya bağla GitHub deposuna bağlamak için yüklü bir uygulama seçin Uygulama ara… - GitHub depo URL\'si + GitHub depo URL'si github.com/owner/repo Doğrulanıyor… Bağla ve takip et @@ -578,13 +578,13 @@ Dışa aktar İçe aktar Uygulamaları içe aktar - Takip edilen uygulamaları geri yüklemek için dışa aktarılan JSON\'u yapıştırın - Dışa aktarılan JSON\'u buraya yapıştırın… + Takip edilen uygulamaları geri yüklemek için dışa aktarılan JSON'u yapıştırın + Dışa aktarılan JSON'u buraya yapıştırın… Ön sürümleri dahil et Güncelleme kontrolünde ön sürümleri takip edin. Devre dışı bırakıldığında, yalnızca kararlı sürümler dikkate alınır. Uygulama kaldırılsın mı? %1$s uygulamasını kaldırmak istediğinizden emin misiniz? Bu işlem geri alınamaz ve uygulama verileri kaybolabilir. - Geçersiz GitHub URL\'si. Biçim: github.com/owner/repo + Geçersiz GitHub URL'si. Biçim: github.com/owner/repo Depo bulunamadı: %1$s/%2$s GitHub API istek sınırı aşıldı. Daha sonra tekrar deneyin. Bağlama başarısız: %1$s @@ -669,7 +669,7 @@ Önizleme Önizlemeyi yenile Eşleşen varlıkları önizlemek için bir filtre yazın. - Son sürümlerde eşleşen varlık yok. Eski sürümlere geri dönmeyi etkinleştirin veya regex\'i ayarlayın. + Son sürümlerde eşleşen varlık yok. Eski sürümlere geri dönmeyi etkinleştirin veya regex'i ayarlayın. Önizleme yüklenemedi. Bağlantınızı kontrol edip tekrar deneyin. %1$s sürümünde eşleşti · %2$d varlık @@ -688,7 +688,7 @@ Otomatik Cihazınız için en iyi varyantı GitHub Store seçsin Sabitlendi: %1$s - Varyant değişti — tekrar seçmek için Güncelle\'ye dokunun + Varyant değişti — tekrar seçmek için Güncelle'ye dokunun Varyant: %1$s Sabitlemeyi kaldır Sabitlendi @@ -701,4 +701,18 @@ Yükle Yüklemeye hazır Yükle (hazır) + + + Çeviri + README çevirisi için kullanılacak hizmeti seçin. + Çeviri Hizmeti + Google yapılandırma gerektirmeden her yerde çalışır. Youdao Çin anakarasından çalışır ancak Youdao geliştirici portalından API kimlik bilgileri gerekir. + Google + Youdao + Çeviri hizmeti güncellendi + App Key ve App Secret almak için ai.youdao.com'a kaydolun. Ücretsiz katman olağan kullanım için yeterlidir. + App Key + App Secret + Kimlik bilgilerini kaydet + Youdao kimlik bilgileri kaydedildi 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 2cb8225e..b0d156ab 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 @@ -665,4 +665,18 @@ 安装 准备安装 安装(已就绪) + + + 翻译 + 选择用于翻译 README 的服务。 + 翻译服务 + Google 全球可用,无需配置。有道可从中国大陆访问,但需要从有道开发者门户获取 API 凭据。 + Google + 有道 + 翻译服务已更新 + 在 ai.youdao.com 注册以获取 App Key 和 App Secret。免费套餐足以满足日常使用。 + App Key + App Secret + 保存凭据 + 有道凭据已保存 \ No newline at end of file diff --git a/core/presentation/src/commonMain/composeResources/values/strings.xml b/core/presentation/src/commonMain/composeResources/values/strings.xml index 2ee1fbe7..03bb03bf 100644 --- a/core/presentation/src/commonMain/composeResources/values/strings.xml +++ b/core/presentation/src/commonMain/composeResources/values/strings.xml @@ -717,4 +717,18 @@ Install Ready to install Install (ready) + + + Translation + Choose the service used to translate README content. + Translation Service + Google works globally without configuration. Youdao works from mainland China but requires API credentials from Youdao's developer portal. + Google + Youdao + Translation service updated + Sign up at ai.youdao.com to get your App Key and App Secret. The free tier is enough for casual use. + App Key + App Secret + Save credentials + Youdao credentials saved diff --git a/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/di/SharedModule.kt b/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/di/SharedModule.kt index c253e173..c3aacf11 100644 --- a/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/di/SharedModule.kt +++ b/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/di/SharedModule.kt @@ -26,6 +26,7 @@ val detailsModule = TranslationRepositoryImpl( localizationManager = get(), clientProvider = get(), + tweaksRepository = get(), ) } diff --git a/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/repository/TranslationRepositoryImpl.kt b/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/repository/TranslationRepositoryImpl.kt index ffdd0a95..be79c0f2 100644 --- a/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/repository/TranslationRepositoryImpl.kt +++ b/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/repository/TranslationRepositoryImpl.kt @@ -1,24 +1,32 @@ package zed.rainxch.details.data.repository import io.ktor.client.HttpClient -import io.ktor.client.request.get -import io.ktor.client.request.parameter -import io.ktor.client.statement.bodyAsText +import kotlinx.coroutines.flow.first import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.serialization.json.Json -import kotlinx.serialization.json.jsonArray -import kotlinx.serialization.json.jsonPrimitive import zed.rainxch.core.data.network.TranslationClientProvider import zed.rainxch.core.data.services.LocalizationManager +import zed.rainxch.core.domain.model.TranslationProvider +import zed.rainxch.core.domain.repository.TweaksRepository +import zed.rainxch.details.data.translation.GoogleTranslator +import zed.rainxch.details.data.translation.Translator +import zed.rainxch.details.data.translation.YoudaoTranslator import zed.rainxch.details.domain.model.TranslationResult import zed.rainxch.details.domain.repository.TranslationRepository import kotlin.time.Clock import kotlin.time.ExperimentalTime +/** + * Orchestrates translation: picks the user-configured [Translator] + * ([TranslationProvider]), drives chunking + caching in this layer + * (so each concrete translator only has to round-trip a single + * chunk), and stitches results back together. + */ class TranslationRepositoryImpl( private val localizationManager: LocalizationManager, private val clientProvider: TranslationClientProvider, + private val tweaksRepository: TweaksRepository, ) : TranslationRepository { private val httpClient: HttpClient get() = clientProvider.client @@ -28,9 +36,13 @@ class TranslationRepositoryImpl( isLenient = true } + // Google's provider has no per-install config — share a single + // instance for the lifetime of the repository. + private val googleTranslator: GoogleTranslator = + GoogleTranslator(httpClient = { httpClient }, json = json) + private val cacheMutex = Mutex() private val cache = LinkedHashMap(MAX_CACHE_SIZE, 0.75f, true) - private val maxChunkSize = 4500 @OptIn(ExperimentalTime::class) override suspend fun translate( @@ -47,12 +59,13 @@ class TranslationRepositoryImpl( } } - val chunks = chunkText(text) + val translator = resolveTranslator() + val chunks = chunkText(text, translator.maxChunkSize) val translatedParts = mutableListOf>() var detectedLang: String? = null for ((chunkText, delimiter) in chunks) { - val response = translateSingleChunk(chunkText, targetLanguage, sourceLanguage) + val response = translator.translate(chunkText, targetLanguage, sourceLanguage) translatedParts.add(response.translatedText to delimiter) if (detectedLang == null) { detectedLang = response.detectedSourceLanguage @@ -81,49 +94,30 @@ class TranslationRepositoryImpl( override fun getDeviceLanguageCode(): String = localizationManager.getPrimaryLanguageCode() - private suspend fun translateSingleChunk( - text: String, - targetLanguage: String, - sourceLanguage: String, - ): TranslationResult { - val responseText = - httpClient - .get( - "https://translate.googleapis.com/translate_a/single", - ) { - parameter("client", "gtx") - parameter("sl", sourceLanguage) - parameter("tl", targetLanguage) - parameter("dt", "t") - parameter("q", text) - }.bodyAsText() - - return parseTranslationResponse(responseText) - } - - private fun parseTranslationResponse(responseText: String): TranslationResult { - val root = json.parseToJsonElement(responseText).jsonArray - - val segments = root[0].jsonArray - val translatedText = - segments.joinToString("") { segment -> - segment.jsonArray[0].jsonPrimitive.content - } - - val detectedLang = - try { - root[2].jsonPrimitive.content - } catch (_: Exception) { - null + /** + * Resolves the currently-selected translator from preferences. + * Called per request rather than held as a field so provider / + * credential changes take effect on the next translation without + * requiring the repository to be rebuilt. + */ + private suspend fun resolveTranslator(): Translator { + val provider = tweaksRepository.getTranslationProvider().first() + return when (provider) { + TranslationProvider.GOOGLE -> googleTranslator + TranslationProvider.YOUDAO -> { + val appKey = tweaksRepository.getYoudaoAppKey().first() + val appSecret = tweaksRepository.getYoudaoAppSecret().first() + YoudaoTranslator( + httpClient = { httpClient }, + json = json, + appKey = appKey, + appSecret = appSecret, + ) } - - return TranslationResult( - translatedText = translatedText, - detectedSourceLanguage = detectedLang, - ) + } } - private fun chunkText(text: String): List> { + private fun chunkText(text: String, maxChunkSize: Int): List> { val paragraphs = text.split("\n\n") val chunks = mutableListOf>() val currentChunk = StringBuilder() @@ -134,7 +128,7 @@ class TranslationRepositoryImpl( chunks.add(Pair(currentChunk.toString(), "\n\n")) currentChunk.clear() } - chunkLargeParagraph(paragraph, chunks) + chunkLargeParagraph(paragraph, chunks, maxChunkSize) } else if (currentChunk.length + paragraph.length + 2 > maxChunkSize) { chunks.add(Pair(currentChunk.toString(), "\n\n")) currentChunk.clear() @@ -155,6 +149,7 @@ class TranslationRepositoryImpl( private fun chunkLargeParagraph( paragraph: String, chunks: MutableList>, + maxChunkSize: Int, ) { val lines = paragraph.split("\n") val currentChunk = StringBuilder() diff --git a/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/translation/GoogleTranslator.kt b/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/translation/GoogleTranslator.kt new file mode 100644 index 00000000..30a67114 --- /dev/null +++ b/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/translation/GoogleTranslator.kt @@ -0,0 +1,54 @@ +package zed.rainxch.details.data.translation + +import io.ktor.client.HttpClient +import io.ktor.client.request.get +import io.ktor.client.request.parameter +import io.ktor.client.statement.bodyAsText +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonPrimitive +import zed.rainxch.details.domain.model.TranslationResult + +/** + * Hits Google's undocumented `translate_a/single` endpoint. Works + * everywhere Google does, no credentials. May rate-limit or break + * without notice; Youdao is the escape hatch. + */ +internal class GoogleTranslator( + private val httpClient: () -> HttpClient, + private val json: Json, +) : Translator { + // GET request — constrained by URL length. 4500 leaves headroom for + // the query params + the URL-encoded text itself. + override val maxChunkSize: Int = 4500 + + override suspend fun translate( + text: String, + targetLanguage: String, + sourceLanguage: String, + ): TranslationResult { + val body = + httpClient() + .get("https://translate.googleapis.com/translate_a/single") { + parameter("client", "gtx") + parameter("sl", sourceLanguage) + parameter("tl", targetLanguage) + parameter("dt", "t") + parameter("q", text) + }.bodyAsText() + + val root = json.parseToJsonElement(body).jsonArray + val segments = root[0].jsonArray + val translated = + segments.joinToString("") { segment -> + segment.jsonArray[0].jsonPrimitive.content + } + val detected = + runCatching { root[2].jsonPrimitive.content }.getOrNull() + + return TranslationResult( + translatedText = translated, + detectedSourceLanguage = detected, + ) + } +} diff --git a/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/translation/Translator.kt b/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/translation/Translator.kt new file mode 100644 index 00000000..cefd1b29 --- /dev/null +++ b/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/translation/Translator.kt @@ -0,0 +1,31 @@ +package zed.rainxch.details.data.translation + +import zed.rainxch.details.domain.model.TranslationResult + +/** + * Single-shot translator for a chunk of already-sized text. Chunking, + * caching and result-joining stay in the repository layer so each + * provider implementation only has to answer the question "translate + * this string and tell me what language it was in." + */ +internal interface Translator { + suspend fun translate( + text: String, + targetLanguage: String, + sourceLanguage: String, + ): TranslationResult + + /** + * Rough per-chunk upper bound for [text] length, in characters. + * Used by the repository's chunker to avoid tripping provider + * limits (Google's GET query length, Youdao's POST body length). + */ + val maxChunkSize: Int +} + +/** + * Raised when the selected provider isn't configured (e.g. Youdao + * selected but `appKey` missing). UI surfaces this as "provider not + * configured" rather than a generic network error. + */ +internal class TranslationProviderNotConfiguredException(message: String) : RuntimeException(message) diff --git a/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/translation/YoudaoTranslator.kt b/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/translation/YoudaoTranslator.kt new file mode 100644 index 00000000..a606b778 --- /dev/null +++ b/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/translation/YoudaoTranslator.kt @@ -0,0 +1,147 @@ +package zed.rainxch.details.data.translation + +import io.ktor.client.HttpClient +import io.ktor.client.request.forms.submitForm +import io.ktor.client.statement.bodyAsText +import io.ktor.http.Parameters +import java.security.MessageDigest +import kotlin.time.Clock +import kotlin.time.ExperimentalTime +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import zed.rainxch.details.domain.model.TranslationResult + +/** + * Hits Youdao Translation Open API v3 (`openapi.youdao.com/api`). + * Directly accessible from mainland China — the reason this provider + * exists (see issue #429). Requires user-supplied `appKey`/`appSecret` + * from Youdao's developer portal; missing credentials throw + * [TranslationProviderNotConfiguredException] up to the UI. + * + * Signing: v3 uses + * sign = sha256(appKey + input + salt + curtime + appSecret) + * where `input` is the query truncated to first-10 + length + last-10 + * for strings longer than 20 characters. + * See https://ai.youdao.com/DOCSIRMA/html/trans/api/wbfy/index.html. + */ +@OptIn(ExperimentalTime::class, ExperimentalUuidApi::class) +internal class YoudaoTranslator( + private val httpClient: () -> HttpClient, + private val json: Json, + private val appKey: String, + private val appSecret: String, +) : Translator { + // POST body — Youdao accepts up to 5000 chars per call. Leave a + // little room for URL-encoding inflation. + override val maxChunkSize: Int = 4500 + + override suspend fun translate( + text: String, + targetLanguage: String, + sourceLanguage: String, + ): TranslationResult { + if (appKey.isBlank() || appSecret.isBlank()) { + throw TranslationProviderNotConfiguredException( + "Youdao appKey/appSecret not configured", + ) + } + + val salt = Uuid.random().toString() + val curtime = (Clock.System.now().toEpochMilliseconds() / 1000L).toString() + val signInput = buildSignInput(text) + val sign = sha256Hex(appKey + signInput + salt + curtime + appSecret) + + val from = mapLanguageCode(sourceLanguage) + val to = mapLanguageCode(targetLanguage) + + val response = + httpClient().submitForm( + url = "https://openapi.youdao.com/api", + formParameters = + Parameters.build { + append("q", text) + append("from", from) + append("to", to) + append("appKey", appKey) + append("salt", salt) + append("sign", sign) + append("signType", "v3") + append("curtime", curtime) + }, + ) + + val body = response.bodyAsText() + val root = json.parseToJsonElement(body).jsonObject + val errorCode = root["errorCode"]?.jsonPrimitive?.content + if (errorCode != "0") { + throw RuntimeException("Youdao translate error: $errorCode") + } + + val translation = + root["translation"] + ?.jsonArray + ?.joinToString("\n") { it.jsonPrimitive.content } + .orEmpty() + + // `l` is "2" — e.g. "en2zh-CHS". First half is the + // auto-detected source language when `from=auto` was requested. + val detected = + root["l"] + ?.jsonPrimitive + ?.content + ?.substringBefore('2') + ?.takeIf { it.isNotBlank() } + ?.let { reverseMapLanguageCode(it) } + + return TranslationResult( + translatedText = translation, + detectedSourceLanguage = detected, + ) + } + + private fun buildSignInput(q: String): String { + // Youdao's documented truncation rule for the signed `input`: + // if q.length > 20 use first 10 + q.length + last 10, else use q. + return if (q.length <= 20) { + q + } else { + q.substring(0, 10) + q.length.toString() + q.substring(q.length - 10) + } + } + + private fun sha256Hex(input: String): String { + val digest = MessageDigest.getInstance("SHA-256").digest(input.encodeToByteArray()) + return buildString(digest.size * 2) { + for (byte in digest) { + val v = byte.toInt() and 0xff + if (v < 0x10) append('0') + append(v.toString(16)) + } + } + } + + /** + * Translate Google-style BCP-47 codes (the rest of the app uses + * these) to Youdao's expected language codes. Anything we don't + * know passes through — if it's wrong Youdao will respond with + * errorCode 102 and the caller surfaces the error. + */ + private fun mapLanguageCode(code: String): String = + when (code.lowercase()) { + "auto", "" -> "auto" + "zh", "zh-cn", "zh-hans" -> "zh-CHS" + "zh-tw", "zh-hant" -> "zh-CHT" + else -> code + } + + private fun reverseMapLanguageCode(code: String): String = + when (code) { + "zh-CHS" -> "zh" + "zh-CHT" -> "zh-TW" + else -> code + } +} 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 26c8c271..95a9f1e1 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 @@ -4,6 +4,7 @@ import zed.rainxch.core.domain.model.AppTheme import zed.rainxch.core.domain.model.FontTheme import zed.rainxch.core.domain.model.InstallerType import zed.rainxch.core.domain.model.ProxyScope +import zed.rainxch.core.domain.model.TranslationProvider import zed.rainxch.tweaks.presentation.model.ProxyType sealed interface TweaksAction { @@ -113,4 +114,20 @@ sealed interface TweaksAction { ) : TweaksAction data object OnResetAnalyticsId : TweaksAction + + data class OnTranslationProviderSelected( + val provider: TranslationProvider, + ) : TweaksAction + + data class OnYoudaoAppKeyChanged( + val appKey: String, + ) : TweaksAction + + data class OnYoudaoAppSecretChanged( + val appSecret: String, + ) : TweaksAction + + data object OnYoudaoAppSecretVisibilityToggle : TweaksAction + + data object OnYoudaoCredentialsSave : 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 26de3696..8c75205a 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 @@ -24,4 +24,8 @@ sealed interface TweaksEvent { data object OnSeenHistoryCleared : TweaksEvent data object OnAnalyticsIdReset : TweaksEvent + + data object OnTranslationProviderSaved : TweaksEvent + + data object OnYoudaoCredentialsSaved : TweaksEvent } 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 5f166f20..dab182ae 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 @@ -33,13 +33,7 @@ import zed.rainxch.core.presentation.locals.LocalBottomNavigationHeight import zed.rainxch.core.presentation.locals.LocalBottomNavigationLiquid import zed.rainxch.core.presentation.theme.GithubStoreTheme 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.analytics_id_reset -import zed.rainxch.githubstore.core.presentation.res.seen_history_cleared -import zed.rainxch.githubstore.core.presentation.res.tweaks_title +import zed.rainxch.githubstore.core.presentation.res.* import zed.rainxch.tweaks.presentation.components.ClearDownloadsDialog import zed.rainxch.tweaks.presentation.components.sections.about import zed.rainxch.tweaks.presentation.components.sections.othersSection @@ -116,6 +110,18 @@ fun TweaksRoot(viewModel: TweaksViewModel = koinViewModel()) { snackbarState.showSnackbar(getString(Res.string.analytics_id_reset)) } } + + TweaksEvent.OnTranslationProviderSaved -> { + coroutineScope.launch { + snackbarState.showSnackbar(getString(Res.string.translation_provider_saved)) + } + } + + TweaksEvent.OnYoudaoCredentialsSaved -> { + coroutineScope.launch { + snackbarState.showSnackbar(getString(Res.string.translation_youdao_saved)) + } + } } } 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 1138c2e2..0b17895d 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 @@ -5,6 +5,7 @@ import zed.rainxch.core.domain.model.FontTheme import zed.rainxch.core.domain.model.InstallerType import zed.rainxch.core.domain.model.ProxyScope import zed.rainxch.core.domain.model.ShizukuAvailability +import zed.rainxch.core.domain.model.TranslationProvider import zed.rainxch.tweaks.presentation.model.ProxyScopeFormState data class TweaksState( @@ -27,6 +28,10 @@ data class TweaksState( val isHideSeenEnabled: Boolean = false, val isScrollbarEnabled: Boolean = false, val isTelemetryEnabled: Boolean = false, + val translationProvider: TranslationProvider = TranslationProvider.Default, + val youdaoAppKey: String = "", + val youdaoAppSecret: String = "", + val isYoudaoAppSecretVisible: Boolean = false, ) { /** Convenience accessor — guaranteed non-null because the map is * seeded with entries for every [ProxyScope] at construction time. */ 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 4aad03a5..d56b8982 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 @@ -15,6 +15,7 @@ import kotlinx.coroutines.launch import org.jetbrains.compose.resources.getString import zed.rainxch.core.domain.model.ProxyConfig import zed.rainxch.core.domain.model.ProxyScope +import zed.rainxch.core.domain.model.TranslationProvider import zed.rainxch.core.domain.network.ProxyTestOutcome import zed.rainxch.core.domain.network.ProxyTester import zed.rainxch.core.domain.repository.DeviceIdentityRepository @@ -70,6 +71,7 @@ class TweaksViewModel( loadHideSeenEnabled() loadScrollbarEnabled() loadTelemetryEnabled() + loadTranslationSettings() observeShizukuStatus() @@ -302,6 +304,24 @@ class TweaksViewModel( } } + private fun loadTranslationSettings() { + viewModelScope.launch { + tweaksRepository.getTranslationProvider().collect { provider -> + _state.update { it.copy(translationProvider = provider) } + } + } + viewModelScope.launch { + tweaksRepository.getYoudaoAppKey().collect { appKey -> + _state.update { it.copy(youdaoAppKey = appKey) } + } + } + viewModelScope.launch { + tweaksRepository.getYoudaoAppSecret().collect { appSecret -> + _state.update { it.copy(youdaoAppSecret = appSecret) } + } + } + } + private fun loadIncludePreReleases() { viewModelScope.launch { tweaksRepository.getIncludePreReleases().collect { enabled -> @@ -576,6 +596,48 @@ class TweaksViewModel( _events.send(TweaksEvent.OnAnalyticsIdReset) } } + + is TweaksAction.OnTranslationProviderSelected -> { + // Persist immediately — switching provider is a single-tap + // action, no credentials validation needed at this step + // (YOUDAO credentials are entered in the expanded form). + viewModelScope.launch { + tweaksRepository.setTranslationProvider(action.provider) + _events.send(TweaksEvent.OnTranslationProviderSaved) + } + } + + is TweaksAction.OnYoudaoAppKeyChanged -> { + _state.update { it.copy(youdaoAppKey = action.appKey) } + } + + is TweaksAction.OnYoudaoAppSecretChanged -> { + _state.update { it.copy(youdaoAppSecret = action.appSecret) } + } + + TweaksAction.OnYoudaoAppSecretVisibilityToggle -> { + _state.update { + it.copy(isYoudaoAppSecretVisible = !it.isYoudaoAppSecretVisible) + } + } + + TweaksAction.OnYoudaoCredentialsSave -> { + val current = _state.value + viewModelScope.launch { + tweaksRepository.setYoudaoAppKey(current.youdaoAppKey) + tweaksRepository.setYoudaoAppSecret(current.youdaoAppSecret) + // Auto-switch to YOUDAO when the user explicitly saves + // credentials — saves them an extra tap and matches the + // implicit intent ("I just configured this, use it"). + if (current.translationProvider != TranslationProvider.YOUDAO && + current.youdaoAppKey.isNotBlank() && + current.youdaoAppSecret.isNotBlank() + ) { + tweaksRepository.setTranslationProvider(TranslationProvider.YOUDAO) + } + _events.send(TweaksEvent.OnYoudaoCredentialsSaved) + } + } } } diff --git a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/components/sections/SettingsSection.kt b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/components/sections/SettingsSection.kt index 6f4995f9..7f263724 100644 --- a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/components/sections/SettingsSection.kt +++ b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/components/sections/SettingsSection.kt @@ -26,6 +26,15 @@ fun LazyListScope.settings( onAction = onAction, ) + item { + Spacer(Modifier.height(32.dp)) + } + + translationSection( + state = state, + onAction = onAction, + ) + item { Spacer(Modifier.height(12.dp)) } diff --git a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/components/sections/Translation.kt b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/components/sections/Translation.kt new file mode 100644 index 00000000..51cae97e --- /dev/null +++ b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/components/sections/Translation.kt @@ -0,0 +1,231 @@ +package zed.rainxch.tweaks.presentation.components.sections + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +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.ElevatedCard +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.foundation.text.KeyboardOptions +import org.jetbrains.compose.resources.stringResource +import zed.rainxch.core.domain.model.TranslationProvider +import zed.rainxch.githubstore.core.presentation.res.* +import zed.rainxch.tweaks.presentation.TweaksAction +import zed.rainxch.tweaks.presentation.TweaksState +import zed.rainxch.tweaks.presentation.components.SectionHeader + +fun LazyListScope.translationSection( + state: TweaksState, + onAction: (TweaksAction) -> Unit, +) { + item { + SectionHeader(text = stringResource(Res.string.section_translation)) + Spacer(Modifier.height(4.dp)) + Text( + text = stringResource(Res.string.translation_intro), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + ) + Spacer(Modifier.height(8.dp)) + + TranslationProviderCard( + state = state, + onAction = onAction, + ) + } +} + +@Composable +private fun TranslationProviderCard( + state: TweaksState, + onAction: (TweaksAction) -> Unit, +) { + ElevatedCard( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.elevatedCardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + shape = RoundedCornerShape(32.dp), + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = stringResource(Res.string.translation_provider_title), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = stringResource(Res.string.translation_provider_description), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 2.dp), + ) + + Spacer(Modifier.height(12.dp)) + + LazyRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(TranslationProvider.entries) { provider -> + FilterChip( + selected = state.translationProvider == provider, + onClick = { onAction(TweaksAction.OnTranslationProviderSelected(provider)) }, + label = { + Text( + text = providerLabel(provider), + fontWeight = + if (state.translationProvider == provider) { + FontWeight.Bold + } else { + FontWeight.Normal + }, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + ) + } + } + + AnimatedVisibility( + visible = state.translationProvider == TranslationProvider.YOUDAO, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut(), + ) { + YoudaoCredentialsForm( + state = state, + onAction = onAction, + ) + } + } + } +} + +@Composable +private fun providerLabel(provider: TranslationProvider): String = + when (provider) { + TranslationProvider.GOOGLE -> stringResource(Res.string.translation_provider_google) + TranslationProvider.YOUDAO -> stringResource(Res.string.translation_provider_youdao) + } + +@Composable +private fun YoudaoCredentialsForm( + state: TweaksState, + onAction: (TweaksAction) -> Unit, +) { + val canSave = + state.youdaoAppKey.isNotBlank() && state.youdaoAppSecret.isNotBlank() + + Column( + modifier = Modifier.padding(top = 16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = stringResource(Res.string.translation_youdao_help), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + OutlinedTextField( + value = state.youdaoAppKey, + onValueChange = { onAction(TweaksAction.OnYoudaoAppKeyChanged(it)) }, + label = { Text(stringResource(Res.string.translation_youdao_app_key)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + ) + + OutlinedTextField( + value = state.youdaoAppSecret, + onValueChange = { onAction(TweaksAction.OnYoudaoAppSecretChanged(it)) }, + label = { Text(stringResource(Res.string.translation_youdao_app_secret)) }, + singleLine = true, + visualTransformation = + if (state.isYoudaoAppSecretVisible) { + VisualTransformation.None + } else { + PasswordVisualTransformation() + }, + trailingIcon = { + IconButton( + onClick = { onAction(TweaksAction.OnYoudaoAppSecretVisibilityToggle) }, + ) { + Icon( + imageVector = + if (state.isYoudaoAppSecretVisible) { + Icons.Default.VisibilityOff + } else { + Icons.Default.Visibility + }, + contentDescription = + if (state.isYoudaoAppSecretVisible) { + stringResource(Res.string.proxy_hide_password) + } else { + stringResource(Res.string.proxy_show_password) + }, + modifier = Modifier.size(20.dp), + ) + } + }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + ) + + Row( + modifier = Modifier.align(Alignment.End), + verticalAlignment = Alignment.CenterVertically, + ) { + FilledTonalButton( + onClick = { onAction(TweaksAction.OnYoudaoCredentialsSave) }, + enabled = canSave, + ) { + Icon( + imageVector = Icons.Default.Save, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(Modifier.size(8.dp)) + Text(stringResource(Res.string.translation_youdao_save)) + } + } + } +} From 5512f2d91fba88535116b040718e8a4c43792f53 Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Sat, 18 Apr 2026 12:40:17 +0500 Subject: [PATCH 3/5] core: refactor proxy management, improve DI seeding, and update telemetry strings - Introduce `ProxyManagerSeeding` as a marker type to ensure explicit DI dependency ordering, preventing races between HTTP client creation and proxy configuration loading. - Optimize proxy seeding to run DataStore reads in parallel with a unified 1.5s timeout. - Implement "replace-then-close" logic in `GitHubClientProvider` and `TranslationClientProvider` to ensure callers always observe a live HTTP client. - Update `ProxyRepositoryImpl` to fallback to `ProxyConfig.System` and log a warning when encountering malformed configurations. - Refine proxy validation in `TweaksViewModel` to skip host/port requirements for "None" and "System" types. - Rename `settingsModule` to `profileModule` for better domain consistency. - Update `telemetry_description` across multiple locales (EN, TR, AR, ZH, BN, JA, KO, PL, IT, FR, RU, HI, ES) to remove explicit "anonymized" wording in favor of describing the association with a resettable ID. - Enhance SOCKS5 authentication on JVM to scope credentials to the specific proxy host and port, preventing credential leakage to other requests. --- .../rainxch/githubstore/app/di/initKoin.kt | 4 +- .../zed/rainxch/core/data/di/SharedModule.kt | 67 ++++++++++++++----- .../core/data/network/GitHubClientProvider.kt | 9 ++- .../core/data/network/ProxyManagerSeeding.kt | 11 +++ .../data/network/TranslationClientProvider.kt | 9 ++- .../data/repository/ProxyRepositoryImpl.kt | 32 ++++++--- .../data/network/HttpClientFactory.jvm.kt | 25 +++++-- .../composeResources/values-ar/strings-ar.xml | 2 +- .../composeResources/values-bn/strings-bn.xml | 2 +- .../composeResources/values-es/strings-es.xml | 2 +- .../composeResources/values-fr/strings-fr.xml | 2 +- .../composeResources/values-hi/strings-hi.xml | 2 +- .../composeResources/values-it/strings-it.xml | 2 +- .../composeResources/values-ja/strings-ja.xml | 2 +- .../composeResources/values-ko/strings-ko.xml | 2 +- .../composeResources/values-pl/strings-pl.xml | 2 +- .../composeResources/values-ru/strings-ru.xml | 2 +- .../composeResources/values-tr/strings-tr.xml | 2 +- .../values-zh-rCN/strings-zh-rCN.xml | 2 +- .../composeResources/values/strings.xml | 2 +- .../rainxch/profile/data/di/SharedModule.kt | 2 +- .../rainxch/search/data/di/SharedModule.kt | 1 - .../tweaks/presentation/TweaksState.kt | 10 ++- .../tweaks/presentation/TweaksViewModel.kt | 65 +++++++++++------- 24 files changed, 181 insertions(+), 80 deletions(-) create mode 100644 core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/ProxyManagerSeeding.kt diff --git a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/di/initKoin.kt b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/di/initKoin.kt index d1ae7bad..44401935 100644 --- a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/di/initKoin.kt +++ b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/di/initKoin.kt @@ -11,7 +11,7 @@ import zed.rainxch.core.data.di.networkModule import zed.rainxch.details.data.di.detailsModule import zed.rainxch.devprofile.data.di.devProfileModule import zed.rainxch.home.data.di.homeModule -import zed.rainxch.profile.data.di.settingsModule +import zed.rainxch.profile.data.di.profileModule import zed.rainxch.search.data.di.searchModule fun initKoin(config: KoinAppDeclaration? = null) { @@ -30,7 +30,7 @@ fun initKoin(config: KoinAppDeclaration? = null) { devProfileModule, homeModule, searchModule, - settingsModule, + profileModule, ) } } 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 62f3995b..1f27ec38 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 @@ -3,6 +3,9 @@ package zed.rainxch.core.data.di import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeout @@ -23,6 +26,7 @@ import zed.rainxch.core.data.logging.KermitLogger import zed.rainxch.core.data.network.BackendApiClient import zed.rainxch.core.data.network.GitHubClientProvider import zed.rainxch.core.data.network.ProxyManager +import zed.rainxch.core.data.network.ProxyManagerSeeding import zed.rainxch.core.data.network.ProxyTesterImpl import zed.rainxch.core.data.network.TranslationClientProvider import zed.rainxch.core.data.repository.AuthenticationStateImpl @@ -123,6 +127,7 @@ val coreModule = single { ProxyRepositoryImpl( preferences = get(), + logger = get(), ) } @@ -144,10 +149,24 @@ val coreModule = } single { + // Request the seeding sentinel so Koin guarantees ProxyManager + // has the user's persisted config loaded before we snapshot + // the discovery flow for the initial client build. + get() BackendApiClient( proxyConfigFlow = ProxyManager.configFlow(ProxyScope.DISCOVERY), ) } + // NOTE: the reviewer asked for a Koin onClose hook to call + // BackendApiClient.close()/GitHubClientProvider.close()/ + // TranslationClientProvider.close() at Koin shutdown. Koin 4.x + // (4.1.1 on this project) doesn't expose that hook at the + // module DSL level — it existed in 3.x and was removed — and + // there's no clean replacement short of wrapping each provider + // in a Koin scope. On Android/Desktop the process exit + // releases these resources anyway, so we intentionally leave + // the hooks off rather than fake them with an API that doesn't + // fit. Revisit if we upgrade Koin or adopt scope-based DI. single { DeviceIdentityRepositoryImpl( @@ -182,26 +201,41 @@ val coreModule = val networkModule = module { - // Seed ProxyManager from persisted per-scope configs *before* any - // HTTP client is constructed. Blocks briefly (≤1.5s per scope) on - // DataStore reads so the very first request uses the user's saved - // proxy rather than the System default. Failures are swallowed and - // fall back to System — we'd rather network work than the app stall - // on startup if DataStore is slow. - single(createdAtStart = true) { + // Seed [ProxyManager] from persisted per-scope configs *before* + // any HTTP client is constructed. Registered as its own + // [ProxyManagerSeeding] sentinel so client providers can depend + // on it explicitly — without this the seeding would live inside + // one provider's factory and silently race with others. + // + // Reads run in parallel under a single 1.5s budget (was 1.5s × 3 + // sequential). On timeout / DataStore failure we fall back to the + // in-memory defaults — we'd rather the app network with the + // System proxy than stall at launch on a slow disk. + single(createdAtStart = true) { val repository = get() - ProxyScope.entries.forEach { scope -> - val saved = - runBlocking { - runCatching { - withTimeout(1_500L) { - repository.getProxyConfig(scope).first() - } - }.getOrDefault(ProxyConfig.System) + runBlocking { + runCatching { + withTimeout(1_500L) { + coroutineScope { + ProxyScope.entries + .map { scope -> + async { + scope to repository.getProxyConfig(scope).first() + } + }.awaitAll() + } + } + }.onSuccess { results -> + results.forEach { (scope, config) -> + ProxyManager.setConfig(scope, config) } - ProxyManager.setConfig(scope, saved) + } } + ProxyManagerSeeding() + } + single(createdAtStart = true) { + get() GitHubClientProvider( tokenStore = get(), rateLimitRepository = get(), @@ -211,6 +245,7 @@ val networkModule = } single(createdAtStart = true) { + get() TranslationClientProvider( proxyConfigFlow = ProxyManager.configFlow(ProxyScope.TRANSLATION), ) 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 index 263e5f68..89b8b419 100644 --- 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 @@ -42,8 +42,10 @@ class GitHubClientProvider( .distinctUntilChanged() .onEach { proxyConfig -> mutex.withLock { - currentClient.close() - currentClient = + // Replace-then-close: readers of [client] always see + // a live client. Closing first opens a window where + // an in-flight call could touch a closed engine. + val replacement = createGitHubHttpClient( tokenStore = tokenStore, rateLimitRepository = rateLimitRepository, @@ -51,6 +53,9 @@ class GitHubClientProvider( scope = scope, proxyConfig = proxyConfig, ) + val previous = currentClient + currentClient = replacement + previous.close() } }.launchIn(scope) } diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/ProxyManagerSeeding.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/ProxyManagerSeeding.kt new file mode 100644 index 00000000..6419f682 --- /dev/null +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/ProxyManagerSeeding.kt @@ -0,0 +1,11 @@ +package zed.rainxch.core.data.network + +/** + * Marker type that represents "the [ProxyManager] has been seeded + * with the user's persisted per-scope proxy configs." Any component + * that constructs an HTTP client whose proxy is read from + * [ProxyManager] at creation time must inject this so Koin resolves + * the seeding step first — it forces the DI graph dependency to be + * explicit instead of depending on registration order. + */ +class ProxyManagerSeeding internal constructor() diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/TranslationClientProvider.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/TranslationClientProvider.kt index ab958dbc..bfbbba42 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/TranslationClientProvider.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/TranslationClientProvider.kt @@ -38,8 +38,13 @@ class TranslationClientProvider( .distinctUntilChanged() .onEach { config -> mutex.withLock { - currentClient.close() - currentClient = createPlatformHttpClient(config) + // Build the replacement *before* closing the old one + // so the volatile read from [client] never observes a + // closed-but-not-yet-reassigned client. + val replacement = createPlatformHttpClient(config) + val previous = currentClient + currentClient = replacement + previous.close() } }.launchIn(scope) } diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ProxyRepositoryImpl.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ProxyRepositoryImpl.kt index 70c0b86a..b497b561 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ProxyRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ProxyRepositoryImpl.kt @@ -1,6 +1,7 @@ package zed.rainxch.core.data.repository import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.MutablePreferences import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.intPreferencesKey @@ -8,6 +9,7 @@ import androidx.datastore.preferences.core.stringPreferencesKey import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import zed.rainxch.core.data.network.ProxyManager +import zed.rainxch.core.domain.logging.GitHubStoreLogger import zed.rainxch.core.domain.model.ProxyConfig import zed.rainxch.core.domain.model.ProxyScope import zed.rainxch.core.domain.repository.ProxyRepository @@ -27,6 +29,7 @@ import zed.rainxch.core.domain.repository.ProxyRepository */ class ProxyRepositoryImpl( private val preferences: DataStore, + private val logger: GitHubStoreLogger, ) : ProxyRepository { // Legacy (pre-scope) keys — read-only, used as a fallback seed. private val legacyType = stringPreferencesKey("proxy_type") @@ -36,11 +39,11 @@ class ProxyRepositoryImpl( private val legacyPassword = stringPreferencesKey("proxy_password") private data class ScopeKeys( - val type: androidx.datastore.preferences.core.Preferences.Key, - val host: androidx.datastore.preferences.core.Preferences.Key, - val port: androidx.datastore.preferences.core.Preferences.Key, - val username: androidx.datastore.preferences.core.Preferences.Key, - val password: androidx.datastore.preferences.core.Preferences.Key, + val type: Preferences.Key, + val host: Preferences.Key, + val port: Preferences.Key, + val username: Preferences.Key, + val password: Preferences.Key, ) private fun keysFor(scope: ProxyScope): ScopeKeys { @@ -109,7 +112,15 @@ class ProxyRepositoryImpl( password = password, ) } else { - ProxyConfig.None + // Malformed saved proxy — the user *asked for* a proxy, + // so falling back to System (honouring OS-level rules) + // is safer than silently switching to a direct + // connection. Logged so "my proxy stopped working" is + // diagnosable. + logger.warn( + "Malformed HTTP proxy config (type=$type, host=$host, port=$port); falling back to System", + ) + ProxyConfig.System } } "socks" -> { @@ -123,7 +134,10 @@ class ProxyRepositoryImpl( password = password, ) } else { - ProxyConfig.None + logger.warn( + "Malformed SOCKS proxy config (type=$type, host=$host, port=$port); falling back to System", + ) + ProxyConfig.System } } else -> ProxyConfig.System @@ -170,8 +184,8 @@ class ProxyRepositoryImpl( } private fun writeOrRemove( - prefs: androidx.datastore.preferences.core.MutablePreferences, - key: androidx.datastore.preferences.core.Preferences.Key, + prefs: MutablePreferences, + key: Preferences.Key, value: String?, ) { if (value != null) { 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 index 1a470344..aaa4ef26 100644 --- 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 @@ -49,18 +49,29 @@ actual fun createPlatformHttpClient(proxyConfig: ProxyConfig): HttpClient = proxy(Proxy(Proxy.Type.SOCKS, InetSocketAddress(proxyConfig.host, proxyConfig.port))) val username = proxyConfig.username val password = proxyConfig.password + val proxyHost = proxyConfig.host + val proxyPort = proxyConfig.port if (!username.isNullOrEmpty() && !password.isNullOrEmpty()) { // SOCKS5 username/password auth goes through // java.net.Authenticator (OkHttp has no - // dedicated SOCKS auth hook), so install a - // default authenticator keyed on host:port. + // dedicated SOCKS auth hook). Scope the + // credentials to the configured proxy host + // and port — `Authenticator.setDefault` is + // process-wide, so an unconditional responder + // would leak these creds to any other auth + // challenge the JVM sees. Authenticator.setDefault( object : Authenticator() { - override fun getPasswordAuthentication() = - PasswordAuthentication( - username, - password.toCharArray(), - ) + override fun getPasswordAuthentication(): PasswordAuthentication? { + val hostMatches = + requestingHost.equals(proxyHost, ignoreCase = true) + val portMatches = requestingPort == proxyPort + return if (hostMatches && portMatches) { + PasswordAuthentication(username, password.toCharArray()) + } else { + null + } + } }, ) } 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 8a4246bb..a5fddde0 100644 --- a/core/presentation/src/commonMain/composeResources/values-ar/strings-ar.xml +++ b/core/presentation/src/commonMain/composeResources/values-ar/strings-ar.xml @@ -629,7 +629,7 @@ تمت المشاهدة الخصوصية ساعد في تحسين البحث - مشاركة بيانات الاستخدام المجهولة الهوية (عمليات البحث والتثبيتات والتفاعلات) المرتبطة بمعرّف تحليلي قابل لإعادة التعيين. لا تتم مشاركة تفاصيل الحساب. + مشاركة بيانات الاستخدام (عمليات البحث والتثبيتات والتفاعلات) المرتبطة بمعرّف تحليلي قابل لإعادة التعيين. لا تتم مشاركة تفاصيل الحساب. إعادة تعيين معرف التحليلات إنشاء معرف مجهول جديد، مما يقطع الصلة بالبيانات السابقة. تم إعادة تعيين معرف التحليلات 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 a5cf16aa..62750d1d 100644 --- a/core/presentation/src/commonMain/composeResources/values-bn/strings-bn.xml +++ b/core/presentation/src/commonMain/composeResources/values-bn/strings-bn.xml @@ -628,7 +628,7 @@ দেখা হয়েছে গোপনীয়তা অনুসন্ধান উন্নত করতে সহায়তা করুন - পুনরায় সেট করা যায় এমন একটি বিশ্লেষণ আইডির সাথে যুক্ত বেনামী ব্যবহার ডেটা (অনুসন্ধান, ইনস্টল, ইন্টারঅ্যাকশন) শেয়ার করুন। অ্যাকাউন্ট বিবরণ শেয়ার করা হয় না। + পুনরায় সেট করা যায় এমন একটি বিশ্লেষণ আইডির সাথে যুক্ত ব্যবহার ডেটা (অনুসন্ধান, ইনস্টল, ইন্টারঅ্যাকশন) শেয়ার করুন। অ্যাকাউন্ট বিবরণ শেয়ার করা হয় না। বিশ্লেষণ আইডি রিসেট করুন একটি নতুন বেনামী আইডি তৈরি করুন, অতীতের টেলিমেট্রির সাথে সংযোগ বিচ্ছিন্ন করে। বিশ্লেষণ আইডি রিসেট করা হয়েছে 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 d25dfb80..5e903895 100644 --- a/core/presentation/src/commonMain/composeResources/values-es/strings-es.xml +++ b/core/presentation/src/commonMain/composeResources/values-es/strings-es.xml @@ -589,7 +589,7 @@ Visto Privacidad Ayudar a mejorar la búsqueda - Compartir datos de uso anonimizados (búsquedas, instalaciones, interacciones) vinculados a un ID de análisis restablecible. No se comparten detalles de la cuenta. + Compartir datos de uso (búsquedas, instalaciones, interacciones) asociados a un ID de análisis restablecible. No se comparten detalles de la cuenta. Restablecer ID de análisis Generar un nuevo ID anónimo, cortando el vínculo con la telemetría anterior. ID de análisis restablecido 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 3cfde6e3..8c6dd770 100644 --- a/core/presentation/src/commonMain/composeResources/values-fr/strings-fr.xml +++ b/core/presentation/src/commonMain/composeResources/values-fr/strings-fr.xml @@ -590,7 +590,7 @@ Consulté Confidentialité Aider à améliorer la recherche - Partager des données d'utilisation anonymisées (recherches, installations, interactions) liées à un identifiant d'analyse réinitialisable. Aucun détail de compte n'est partagé. + Partager des données d\'utilisation (recherches, installations, interactions) associées à un identifiant d\'analyse réinitialisable. Aucun détail de compte n\'est partagé. Réinitialiser l'ID d'analytique Générer un nouvel ID anonyme, coupant le lien avec la télémétrie passée. ID d'analytique réinitialisé 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 a74c2fab..2d34bc25 100644 --- a/core/presentation/src/commonMain/composeResources/values-hi/strings-hi.xml +++ b/core/presentation/src/commonMain/composeResources/values-hi/strings-hi.xml @@ -627,7 +627,7 @@ देखा गया गोपनीयता खोज को बेहतर बनाने में मदद करें - रीसेट करने योग्य एनालिटिक्स आईडी से जुड़े अनाम उपयोग डेटा (खोज, इंस्टॉल, इंटरैक्शन) साझा करें। खाता विवरण साझा नहीं किए जाते। + रीसेट करने योग्य एनालिटिक्स आईडी से जुड़े उपयोग डेटा (खोज, इंस्टॉल, इंटरैक्शन) साझा करें। खाता विवरण साझा नहीं किए जाते। एनालिटिक्स आईडी रीसेट करें एक नया अनाम आईडी बनाएं, पिछले टेलीमेट्री से लिंक को तोड़ें। एनालिटिक्स आईडी रीसेट किया गया 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 e297e0ae..2c036bf2 100644 --- a/core/presentation/src/commonMain/composeResources/values-it/strings-it.xml +++ b/core/presentation/src/commonMain/composeResources/values-it/strings-it.xml @@ -628,7 +628,7 @@ Visualizzato Privacy Aiuta a migliorare la ricerca - Condividi dati di utilizzo anonimizzati (ricerche, installazioni, interazioni) collegati a un ID analitico ripristinabile. Nessun dettaglio dell'account viene condiviso. + Condividi dati di utilizzo (ricerche, installazioni, interazioni) associati a un ID analitico ripristinabile. Nessun dettaglio dell\'account viene condiviso. Reimposta ID analitico Genera un nuovo ID anonimo, interrompendo il collegamento con la telemetria passata. ID analitico reimpostato 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 6a120cb5..50f05095 100644 --- a/core/presentation/src/commonMain/composeResources/values-ja/strings-ja.xml +++ b/core/presentation/src/commonMain/composeResources/values-ja/strings-ja.xml @@ -591,7 +591,7 @@ 閲覧済み プライバシー 検索の改善に協力 - リセット可能な分析 ID に関連付けられた匿名化された使用データ(検索、インストール、操作)を共有します。アカウント情報は共有されません。 + リセット可能な分析 ID に関連付けられた使用データ(検索、インストール、操作)を共有します。アカウント情報は共有されません。 分析IDをリセット 新しい匿名IDを生成し、過去のテレメトリとのリンクを切断します。 分析IDをリセットしました 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 c336c14f..e77a7558 100644 --- a/core/presentation/src/commonMain/composeResources/values-ko/strings-ko.xml +++ b/core/presentation/src/commonMain/composeResources/values-ko/strings-ko.xml @@ -626,7 +626,7 @@ 확인함 개인 정보 보호 검색 개선에 도움 주기 - 재설정 가능한 분석 ID에 연결된 익명화된 사용 데이터(검색, 설치, 상호작용)를 공유합니다. 계정 정보는 공유되지 않습니다. + 재설정 가능한 분석 ID에 연결된 사용 데이터(검색, 설치, 상호작용)를 공유합니다. 계정 정보는 공유되지 않습니다. 분석 ID 재설정 새 익명 ID를 생성하여 과거 원격 측정과의 연결을 끊습니다. 분석 ID가 재설정되었습니다 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 3118e023..b4303bb2 100644 --- a/core/presentation/src/commonMain/composeResources/values-pl/strings-pl.xml +++ b/core/presentation/src/commonMain/composeResources/values-pl/strings-pl.xml @@ -592,7 +592,7 @@ Przeglądane Prywatność Pomóż ulepszyć wyszukiwanie - Udostępniaj zanonimizowane dane o użyciu (wyszukiwania, instalacje, interakcje) powiązane z resetowalnym identyfikatorem analitycznym. Żadne dane konta nie są udostępniane. + Udostępniaj dane o użyciu (wyszukiwania, instalacje, interakcje) powiązane z resetowalnym identyfikatorem analitycznym. Żadne dane konta nie są udostępniane. Zresetuj ID analityki Wygeneruj nowy anonimowy ID, zrywając powiązanie z poprzednią telemetrią. ID analityki zresetowany 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 597e00fd..c39cde52 100644 --- a/core/presentation/src/commonMain/composeResources/values-ru/strings-ru.xml +++ b/core/presentation/src/commonMain/composeResources/values-ru/strings-ru.xml @@ -592,7 +592,7 @@ Просмотрено Конфиденциальность Помочь улучшить поиск - Отправлять обезличенные данные об использовании (поиски, установки, взаимодействия), привязанные к сбрасываемому идентификатору аналитики. Данные учётной записи не передаются. + Отправлять данные об использовании (поиски, установки, взаимодействия), связанные со сбрасываемым идентификатором аналитики. Данные учётной записи не передаются. Сбросить ID аналитики Сгенерировать новый анонимный ID, разорвав связь с прошлой телеметрией. ID аналитики сброшен 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 ac5de824..32f9ebb9 100644 --- a/core/presentation/src/commonMain/composeResources/values-tr/strings-tr.xml +++ b/core/presentation/src/commonMain/composeResources/values-tr/strings-tr.xml @@ -626,7 +626,7 @@ Görüntülendi Gizlilik Aramayı iyileştirmeye yardım et - Sıfırlanabilir bir analiz kimliğine bağlı anonimleştirilmiş kullanım verilerini (aramalar, yüklemeler, etkileşimler) paylaş. Hesap ayrıntıları paylaşılmaz. + Sıfırlanabilir bir analiz kimliğiyle ilişkilendirilmiş kullanım verilerini (aramalar, yüklemeler, etkileşimler) paylaş. Hesap ayrıntıları paylaşılmaz. Analitik kimliğini sıfırla Yeni anonim kimlik oluştur, geçmiş telemetri ile bağı kopar. Analitik kimliği sıfırlandı 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 b0d156ab..43d60e48 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 @@ -592,7 +592,7 @@ 已浏览 隐私 帮助改进搜索 - 分享与可重置分析 ID 关联的匿名使用数据(搜索、安装、交互)。不分享账户详情。 + 分享与可重置分析 ID 关联的使用数据(搜索、安装、交互)。不分享账户详情。 重置分析 ID 生成新的匿名 ID,切断与过去遥测数据的联系。 分析 ID 已重置 diff --git a/core/presentation/src/commonMain/composeResources/values/strings.xml b/core/presentation/src/commonMain/composeResources/values/strings.xml index 03bb03bf..b385fc88 100644 --- a/core/presentation/src/commonMain/composeResources/values/strings.xml +++ b/core/presentation/src/commonMain/composeResources/values/strings.xml @@ -648,7 +648,7 @@ Privacy Help improve search - Share anonymized usage data (searches, installs, interactions) tied to a resettable analytics ID. No account details are shared. + Share usage data (searches, installs, interactions) associated with a resettable analytics ID. No account details are shared. Reset analytics ID Generate a new anonymous ID, severing the link to past telemetry. Analytics ID reset diff --git a/feature/profile/data/src/commonMain/kotlin/zed/rainxch/profile/data/di/SharedModule.kt b/feature/profile/data/src/commonMain/kotlin/zed/rainxch/profile/data/di/SharedModule.kt index f4ffaf4e..212d1c76 100644 --- a/feature/profile/data/src/commonMain/kotlin/zed/rainxch/profile/data/di/SharedModule.kt +++ b/feature/profile/data/src/commonMain/kotlin/zed/rainxch/profile/data/di/SharedModule.kt @@ -4,7 +4,7 @@ import org.koin.dsl.module import zed.rainxch.profile.data.repository.ProfileRepositoryImpl import zed.rainxch.profile.domain.repository.ProfileRepository -val settingsModule = +val profileModule = module { single { ProfileRepositoryImpl( diff --git a/feature/search/data/src/commonMain/kotlin/zed/rainxch/search/data/di/SharedModule.kt b/feature/search/data/src/commonMain/kotlin/zed/rainxch/search/data/di/SharedModule.kt index 80aa81de..49ad9bb3 100644 --- a/feature/search/data/src/commonMain/kotlin/zed/rainxch/search/data/di/SharedModule.kt +++ b/feature/search/data/src/commonMain/kotlin/zed/rainxch/search/data/di/SharedModule.kt @@ -1,7 +1,6 @@ package zed.rainxch.search.data.di import org.koin.dsl.module -import zed.rainxch.core.data.network.BackendApiClient import zed.rainxch.domain.repository.SearchRepository import zed.rainxch.search.data.repository.SearchRepositoryImpl 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 0b17895d..2410a01a 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 @@ -33,7 +33,11 @@ data class TweaksState( val youdaoAppSecret: String = "", val isYoudaoAppSecretVisible: Boolean = false, ) { - /** Convenience accessor — guaranteed non-null because the map is - * seeded with entries for every [ProxyScope] at construction time. */ - fun formFor(scope: ProxyScope): ProxyScopeFormState = proxyForms.getValue(scope) + /** Convenience accessor — returns a fresh default if the map is + * missing an entry for [scope]. The constructor seeds all scopes, + * but `copy(proxyForms = …)` call sites could in theory produce an + * incomplete map; the safe default keeps the UI from crashing in + * that case. */ + fun formFor(scope: ProxyScope): ProxyScopeFormState = + proxyForms[scope] ?: ProxyScopeFormState() } 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 d56b8982..51da2abb 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 @@ -427,33 +427,50 @@ class TweaksViewModel( is TweaksAction.OnProxySave -> { val form = _state.value.formFor(action.scope) - val port = - form.port - .toIntOrNull() - ?.takeIf { it in 1..65535 } - ?: run { - viewModelScope.launch { - _events.send(TweaksEvent.OnProxySaveError(getString(Res.string.invalid_proxy_port))) - } - return - } - val host = - form.host.trim().takeIf { it.isNotBlank() } ?: run { - viewModelScope.launch { - _events.send(TweaksEvent.OnProxySaveError(getString(Res.string.proxy_host_required))) - } - return - } - - val username = form.username.takeIf { it.isNotBlank() } - val password = form.password.takeIf { it.isNotBlank() } - - val config = + // Only HTTP/SOCKS need host+port — validate for those + // only. NONE/SYSTEM carry no form fields and would + // otherwise be rejected with "host required" for no + // reason if something ever triggered Save for them + // (today the UI doesn't, but defense in depth). + val config: ProxyConfig = when (form.type) { - ProxyType.HTTP -> ProxyConfig.Http(host, port, username, password) - ProxyType.SOCKS -> ProxyConfig.Socks(host, port, username, password) ProxyType.NONE -> ProxyConfig.None ProxyType.SYSTEM -> ProxyConfig.System + ProxyType.HTTP, ProxyType.SOCKS -> { + val port = + form.port + .toIntOrNull() + ?.takeIf { it in 1..65535 } + ?: run { + viewModelScope.launch { + _events.send( + TweaksEvent.OnProxySaveError( + getString(Res.string.invalid_proxy_port), + ), + ) + } + return + } + val host = + form.host.trim().takeIf { it.isNotBlank() } + ?: run { + viewModelScope.launch { + _events.send( + TweaksEvent.OnProxySaveError( + getString(Res.string.proxy_host_required), + ), + ) + } + return + } + val username = form.username.takeIf { it.isNotBlank() } + val password = form.password.takeIf { it.isNotBlank() } + if (form.type == ProxyType.HTTP) { + ProxyConfig.Http(host, port, username, password) + } else { + ProxyConfig.Socks(host, port, username, password) + } + } } viewModelScope.launch { From acdb362488583bc9246ac0639685cfe46d8e0f0c Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Sat, 18 Apr 2026 16:07:38 +0500 Subject: [PATCH 4/5] tweak: implement draft states and improve Google translation reliability - **Proxy Settings**: Introduce `isDraftDirty` flag to `ProxyScopeFormState` to prevent background DataStore emissions from overwriting in-progress form edits. - **Translation Provider**: - Implement `draftTranslationProvider` in `TweaksState` to handle providers requiring configuration (e.g., Youdao) before persistence. - Update UI to reflect the draft selection while credentials are being entered. - Automatically commit the translation provider when valid credentials are saved. - **Google Translator**: - Switch from `GET` to `POST` (form-encoded) for translation requests to handle large payloads (CJK characters, etc.) that exceed URL length limits. - Update documentation regarding chunk size and encoding expansion. --- .../data/translation/GoogleTranslator.kt | 34 ++++--- .../tweaks/presentation/TweaksState.kt | 15 +++ .../tweaks/presentation/TweaksViewModel.kt | 92 ++++++++++++++++--- .../components/sections/Translation.kt | 6 +- .../presentation/model/ProxyScopeFormState.kt | 9 ++ 5 files changed, 130 insertions(+), 26 deletions(-) diff --git a/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/translation/GoogleTranslator.kt b/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/translation/GoogleTranslator.kt index 30a67114..e2c5eeb3 100644 --- a/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/translation/GoogleTranslator.kt +++ b/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/translation/GoogleTranslator.kt @@ -1,9 +1,9 @@ package zed.rainxch.details.data.translation import io.ktor.client.HttpClient -import io.ktor.client.request.get -import io.ktor.client.request.parameter +import io.ktor.client.request.forms.submitForm import io.ktor.client.statement.bodyAsText +import io.ktor.http.Parameters import kotlinx.serialization.json.Json import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonPrimitive @@ -13,13 +13,21 @@ import zed.rainxch.details.domain.model.TranslationResult * Hits Google's undocumented `translate_a/single` endpoint. Works * everywhere Google does, no credentials. May rate-limit or break * without notice; Youdao is the escape hatch. + * + * Uses POST (form-encoded body) instead of GET: the repository chunks + * on `String.length`, but URL encoding a non-ASCII character — CJK, + * Arabic, etc. — expands ~3× for UTF-8 and ×3 again for percent + * encoding (roughly 9×). A 4500-char CJK chunk would produce a ~40 KB + * URL, well past most HTTP stacks' ~8 KB cap. POST bodies have no + * such limit. */ internal class GoogleTranslator( private val httpClient: () -> HttpClient, private val json: Json, ) : Translator { - // GET request — constrained by URL length. 4500 leaves headroom for - // the query params + the URL-encoded text itself. + // POST body — bounded by server-side payload limits, not URL + // length. 4500 chars stays well within Google's accepted payload + // even for maximum-expansion CJK text. override val maxChunkSize: Int = 4500 override suspend fun translate( @@ -29,13 +37,17 @@ internal class GoogleTranslator( ): TranslationResult { val body = httpClient() - .get("https://translate.googleapis.com/translate_a/single") { - parameter("client", "gtx") - parameter("sl", sourceLanguage) - parameter("tl", targetLanguage) - parameter("dt", "t") - parameter("q", text) - }.bodyAsText() + .submitForm( + url = "https://translate.googleapis.com/translate_a/single", + formParameters = + Parameters.build { + append("client", "gtx") + append("sl", sourceLanguage) + append("tl", targetLanguage) + append("dt", "t") + append("q", text) + }, + ).bodyAsText() val root = json.parseToJsonElement(body).jsonArray val segments = root[0].jsonArray 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 2410a01a..fe847ed1 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 @@ -29,10 +29,25 @@ data class TweaksState( val isScrollbarEnabled: Boolean = false, val isTelemetryEnabled: Boolean = false, val translationProvider: TranslationProvider = TranslationProvider.Default, + /** + * Transient UI-only selection used when the user picks a provider + * that needs more configuration before it can be activated (e.g. + * Youdao with missing credentials). Rendered as the "selected + * chip" when non-null; persisted [translationProvider] is the + * source of truth for what the app actually uses for translation. + * Cleared once the pending selection is either committed + * (credentials saved) or abandoned (another provider picked). + */ + val draftTranslationProvider: TranslationProvider? = null, val youdaoAppKey: String = "", val youdaoAppSecret: String = "", val isYoudaoAppSecretVisible: Boolean = false, ) { + /** Effective provider to render as "selected" in the UI — draft + * overrides persisted when a pending selection is in flight. */ + val displayedTranslationProvider: TranslationProvider + get() = draftTranslationProvider ?: translationProvider + /** Convenience accessor — returns a fresh default if the map is * missing an entry for [scope]. The constructor seeds all scopes, * but `copy(proxyForms = …)` call sites could in theory produce an 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 51da2abb..695bc225 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 @@ -171,11 +171,20 @@ class TweaksViewModel( // Start one collector per scope. Each updates its slot in the // [TweaksState.proxyForms] map — scopes are independent so the // flows intentionally don't share state. + // + // If the user has an in-progress edit on a scope (isDraftDirty) + // we skip hydration for that scope until they commit (save) or + // reset (switch type via OnProxyTypeSelected for None/System). + // DataStore emits on *any* preference change — without this + // guard, toggling any unrelated setting while the user is mid- + // typing in the host field would snap the form back to persisted + // values. ProxyScope.entries.forEach { scope -> viewModelScope.launch { proxyRepository.getProxyConfig(scope).collect { config -> _state.update { state -> val existing = state.formFor(scope) + if (existing.isDraftDirty) return@update state val populated = existing.copy( type = ProxyType.fromConfig(config), @@ -213,13 +222,29 @@ class TweaksViewModel( } } + /** User-triggered form edit — marks the scope dirty so the + * preferences flow won't clobber the edit on an unrelated emit. */ private fun mutateForm( scope: ProxyScope, block: (ProxyScopeFormState) -> ProxyScopeFormState, ) { _state.update { state -> + val updated = block(state.formFor(scope)).copy(isDraftDirty = true) state.copy( - proxyForms = state.proxyForms + (scope to block(state.formFor(scope))), + proxyForms = state.proxyForms + (scope to updated), + ) + } + } + + /** Clears the dirty flag — call after a successful save or an + * explicit reset so the next preferences emission can re-hydrate + * the form. */ + private fun clearDirty(scope: ProxyScope) { + _state.update { state -> + val form = state.formFor(scope) + if (!form.isDraftDirty) return@update state + state.copy( + proxyForms = state.proxyForms + (scope to form.copy(isDraftDirty = false)), ) } } @@ -391,6 +416,9 @@ class TweaksViewModel( runCatching { proxyRepository.setProxyConfig(action.scope, config) }.onSuccess { + // Committed — allow preferences-flow hydration + // to resume for this scope. + clearDirty(action.scope) _events.send(TweaksEvent.OnProxySaved) }.onFailure { error -> _events.send( @@ -477,6 +505,7 @@ class TweaksViewModel( runCatching { proxyRepository.setProxyConfig(action.scope, config) }.onSuccess { + clearDirty(action.scope) _events.send(TweaksEvent.OnProxySaved) }.onFailure { error -> _events.send( @@ -615,12 +644,41 @@ class TweaksViewModel( } is TweaksAction.OnTranslationProviderSelected -> { - // Persist immediately — switching provider is a single-tap - // action, no credentials validation needed at this step - // (YOUDAO credentials are entered in the expanded form). - viewModelScope.launch { - tweaksRepository.setTranslationProvider(action.provider) - _events.send(TweaksEvent.OnTranslationProviderSaved) + when (action.provider) { + TranslationProvider.GOOGLE -> { + // No credentials required — persist immediately + // and clear any pending draft selection. + _state.update { it.copy(draftTranslationProvider = null) } + viewModelScope.launch { + tweaksRepository.setTranslationProvider(action.provider) + _events.send(TweaksEvent.OnTranslationProviderSaved) + } + } + TranslationProvider.YOUDAO -> { + val current = _state.value + val hasCreds = + current.youdaoAppKey.isNotBlank() && + current.youdaoAppSecret.isNotBlank() + if (hasCreds) { + _state.update { it.copy(draftTranslationProvider = null) } + viewModelScope.launch { + tweaksRepository.setTranslationProvider(action.provider) + _events.send(TweaksEvent.OnTranslationProviderSaved) + } + } else { + // No credentials yet — expose the selection as + // a draft so the UI expands the credentials + // form, but don't commit to storage. If we + // persisted here the next translation attempt + // would fail with "not configured" and any + // other repository that observes the flow + // would snap back on the next re-emission. + // Committed later from [OnYoudaoCredentialsSave]. + _state.update { + it.copy(draftTranslationProvider = TranslationProvider.YOUDAO) + } + } + } } } @@ -644,14 +702,24 @@ class TweaksViewModel( tweaksRepository.setYoudaoAppKey(current.youdaoAppKey) tweaksRepository.setYoudaoAppSecret(current.youdaoAppSecret) // Auto-switch to YOUDAO when the user explicitly saves - // credentials — saves them an extra tap and matches the - // implicit intent ("I just configured this, use it"). - if (current.translationProvider != TranslationProvider.YOUDAO && + // credentials — saves them an extra tap and matches + // the implicit intent ("I just configured this, use + // it"). Also covers the "draft" case where the chip + // was picked but not yet persisted because creds + // were missing. + val shouldActivate = current.youdaoAppKey.isNotBlank() && - current.youdaoAppSecret.isNotBlank() - ) { + current.youdaoAppSecret.isNotBlank() && + ( + current.translationProvider != TranslationProvider.YOUDAO || + current.draftTranslationProvider == TranslationProvider.YOUDAO + ) + if (shouldActivate) { tweaksRepository.setTranslationProvider(TranslationProvider.YOUDAO) } + // Drop any draft — either we committed it above or + // the user emptied fields and cancelled implicitly. + _state.update { it.copy(draftTranslationProvider = null) } _events.send(TweaksEvent.OnYoudaoCredentialsSaved) } } diff --git a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/components/sections/Translation.kt b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/components/sections/Translation.kt index 51cae97e..46b25f58 100644 --- a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/components/sections/Translation.kt +++ b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/components/sections/Translation.kt @@ -104,13 +104,13 @@ private fun TranslationProviderCard( ) { items(TranslationProvider.entries) { provider -> FilterChip( - selected = state.translationProvider == provider, + selected = state.displayedTranslationProvider == provider, onClick = { onAction(TweaksAction.OnTranslationProviderSelected(provider)) }, label = { Text( text = providerLabel(provider), fontWeight = - if (state.translationProvider == provider) { + if (state.displayedTranslationProvider == provider) { FontWeight.Bold } else { FontWeight.Normal @@ -126,7 +126,7 @@ private fun TranslationProviderCard( } AnimatedVisibility( - visible = state.translationProvider == TranslationProvider.YOUDAO, + visible = state.displayedTranslationProvider == TranslationProvider.YOUDAO, enter = expandVertically() + fadeIn(), exit = shrinkVertically() + fadeOut(), ) { diff --git a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/model/ProxyScopeFormState.kt b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/model/ProxyScopeFormState.kt index f94795c8..1e76aed4 100644 --- a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/model/ProxyScopeFormState.kt +++ b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/model/ProxyScopeFormState.kt @@ -15,4 +15,13 @@ data class ProxyScopeFormState( val password: String = "", val isPasswordVisible: Boolean = false, val isTestInProgress: Boolean = false, + /** + * True once the user has edited any field in this scope's form. + * Gates the preferences-to-form hydration path in the ViewModel: + * once a scope is dirty, incoming emissions from DataStore are + * ignored for that scope until the user saves (commits) or resets, + * so a concurrent write to *another* preference key doesn't + * clobber the in-progress edit when its Flow re-emits. + */ + val isDraftDirty: Boolean = false, ) From 99672593fc2ebb4dd01f5749a3b7ddd7f5ccd18b Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Sun, 19 Apr 2026 07:15:49 +0500 Subject: [PATCH 5/5] tweaks: introduce mutateFormUi to prevent blocking preference hydration for transient state - Add `mutateFormUi` to handle transient UI state changes (password visibility, test-in-progress) without marking the scope as dirty. - Update `OnProxyPasswordVisibilityToggle` and `OnProxyTestRequest` to use `mutateFormUi`. - Ensure that toggling UI elements or running tests does not prevent the form from re-hydrating during preference emissions. --- .../tweaks/presentation/TweaksViewModel.kt | 22 ++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) 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 695bc225..e4d9f760 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 @@ -236,6 +236,22 @@ class TweaksViewModel( } } + /** Transient UI-state mutation (password visibility, test-in- + * progress) — does *not* mark the scope dirty, so toggling the eye + * icon or running a test doesn't block preference hydration. Only + * use for flags that don't represent a real config change the user + * expects to save. */ + private fun mutateFormUi( + scope: ProxyScope, + block: (ProxyScopeFormState) -> ProxyScopeFormState, + ) { + _state.update { state -> + state.copy( + proxyForms = state.proxyForms + (scope to block(state.formFor(scope))), + ) + } + } + /** Clears the dirty flag — call after a successful save or an * explicit reset so the next preferences emission can re-hydrate * the form. */ @@ -448,7 +464,7 @@ class TweaksViewModel( } is TweaksAction.OnProxyPasswordVisibilityToggle -> { - mutateForm(action.scope) { + mutateFormUi(action.scope) { it.copy(isPasswordVisible = !it.isPasswordVisible) } } @@ -521,7 +537,7 @@ class TweaksViewModel( val form = _state.value.formFor(action.scope) if (form.isTestInProgress) return val config = buildProxyConfigForTest(action.scope) ?: return - mutateForm(action.scope) { it.copy(isTestInProgress = true) } + mutateFormUi(action.scope) { it.copy(isTestInProgress = true) } viewModelScope.launch { val outcome: ProxyTestOutcome = try { @@ -532,7 +548,7 @@ class TweaksViewModel( } catch (e: Exception) { ProxyTestOutcome.Failure.Unknown(e.message) } finally { - mutateForm(action.scope) { it.copy(isTestInProgress = false) } + mutateFormUi(action.scope) { it.copy(isTestInProgress = false) } } _events.send(outcome.toEvent()) }