From 68aa25f28026ddacf303e761aa325321bfe52b1a Mon Sep 17 00:00:00 2001 From: heaven Date: Tue, 16 Jun 2026 16:43:46 +0300 Subject: [PATCH] feat(i18n): move UI strings to resources, add LocaleManager + Russian locale In-app language switch; default + values-ru, with screen/validation/journal strings externalized. --- .../main/java/chat/vojo/proxy/MainActivity.kt | 9 +- .../main/java/chat/vojo/proxy/core/Format.kt | 12 +- .../chat/vojo/proxy/core/LocaleManager.kt | 79 +++++++ .../java/chat/vojo/proxy/core/ProxyConfig.kt | 14 +- .../chat/vojo/proxy/data/AppRepository.kt | 34 ++- .../vojo/proxy/service/ProxyTileService.kt | 15 +- .../vojo/proxy/service/ProxyVpnService.kt | 75 ++++--- .../java/chat/vojo/proxy/ui/AppsScreen.kt | 18 +- .../chat/vojo/proxy/ui/ConnectionScreen.kt | 32 +-- .../main/java/chat/vojo/proxy/ui/Fields.kt | 4 +- .../java/chat/vojo/proxy/ui/MainScreen.kt | 10 +- .../java/chat/vojo/proxy/ui/ServersScreen.kt | 56 ++--- .../java/chat/vojo/proxy/ui/SettingsScreen.kt | 71 +++++- .../java/chat/vojo/proxy/ui/SitesScreen.kt | 56 +++-- app/src/main/res/values-ru/strings.xml | 198 +++++++++++++++++ app/src/main/res/values/strings.xml | 205 +++++++++++++++++- 16 files changed, 751 insertions(+), 137 deletions(-) create mode 100644 app/src/main/java/chat/vojo/proxy/core/LocaleManager.kt create mode 100644 app/src/main/res/values-ru/strings.xml diff --git a/app/src/main/java/chat/vojo/proxy/MainActivity.kt b/app/src/main/java/chat/vojo/proxy/MainActivity.kt index c6a951f..6c39253 100644 --- a/app/src/main/java/chat/vojo/proxy/MainActivity.kt +++ b/app/src/main/java/chat/vojo/proxy/MainActivity.kt @@ -1,6 +1,7 @@ package chat.vojo.proxy import android.Manifest +import android.content.Context import android.content.pm.PackageManager import android.graphics.Color import android.net.VpnService @@ -13,6 +14,7 @@ import androidx.activity.enableEdgeToEdge import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels import androidx.core.content.ContextCompat +import chat.vojo.proxy.core.LocaleManager import chat.vojo.proxy.service.ProxyVpnService import chat.vojo.proxy.service.VpnState import chat.vojo.proxy.ui.MainScreen @@ -24,6 +26,11 @@ class MainActivity : ComponentActivity() { private val viewModel: MainViewModel by viewModels() private var pendingConnect = false + // Apply the in-app language before any resources are resolved for this activity. + override fun attachBaseContext(newBase: Context) { + super.attachBaseContext(LocaleManager.wrap(newBase)) + } + private val vpnPermissionLauncher = registerForActivityResult( ActivityResultContracts.StartActivityForResult() ) { result -> @@ -31,7 +38,7 @@ class MainActivity : ComponentActivity() { ProxyVpnService.start(this) } else { // Make the refusal visible instead of silently doing nothing. - VpnState.setStopped("Доступ к VPN не разрешён") + VpnState.setStopped(getString(R.string.err_vpn_not_allowed)) } } diff --git a/app/src/main/java/chat/vojo/proxy/core/Format.kt b/app/src/main/java/chat/vojo/proxy/core/Format.kt index 6402ba2..1376500 100644 --- a/app/src/main/java/chat/vojo/proxy/core/Format.kt +++ b/app/src/main/java/chat/vojo/proxy/core/Format.kt @@ -3,11 +3,15 @@ package chat.vojo.proxy.core import java.util.Calendar import java.util.Locale -fun formatBytes(b: Long): String { - if (b < 1024) return "$b Б" - val units = listOf("КБ", "МБ", "ГБ", "ТБ") +/** + * Formats [b] bytes with localized [units] = `[bytes, KB, MB, GB, TB]` (from + * `R.array.byte_units`). The numeric part stays `Locale.US` (dot decimal) to match the + * monospace, tabular technical readout in the UI; only the unit symbol is localized. + */ +fun formatBytes(b: Long, units: List): String { + if (b < 1024) return "$b ${units[0]}" var v = b / 1024.0 - var i = 0 + var i = 1 while (v >= 1024 && i < units.size - 1) { v /= 1024; i++ } return String.format(Locale.US, "%.1f %s", v, units[i]) } diff --git a/app/src/main/java/chat/vojo/proxy/core/LocaleManager.kt b/app/src/main/java/chat/vojo/proxy/core/LocaleManager.kt new file mode 100644 index 0000000..d019448 --- /dev/null +++ b/app/src/main/java/chat/vojo/proxy/core/LocaleManager.kt @@ -0,0 +1,79 @@ +package chat.vojo.proxy.core + +import android.app.Activity +import android.content.Context +import android.content.ContextWrapper +import android.content.res.Configuration +import java.util.Locale + +/** + * In-app UI language selection without AppCompat. The app deliberately keeps a tiny + * dependency surface (Compose + ComponentActivity only — see docs/conventions.md), so a + * per-app locale is applied the classic, framework-only way: a persisted tag plus a + * [Configuration] override wrapped around every component's base context in + * `attachBaseContext`. [SYSTEM] follows the device locale (resource qualifiers do the rest). + * + * The choice is stored in a tiny dedicated SharedPreferences rather than in [AppSettings]: + * it's a presentation concern, must be read synchronously at `attachBaseContext` (before any + * coroutine/DataStore is available), and must NOT trigger a tunnel reconnect the way a + * routing/app-filter change does. + */ +object LocaleManager { + const val SYSTEM = "system" + + private const val PREFS = "locale" + private const val KEY_LANG = "lang" + + /** + * The real device locale, captured before we ever override the process default. Resource + * resolution goes through the [Configuration] we build, but `Locale.getDefault()` is + * process-global — without restoring this on [SYSTEM], a once-picked UI language would leak + * into any future date/number formatting or third-party code that reads the default locale. + * Initialized lazily on first [wrap] (from `attachBaseContext`), before any override runs. + */ + private val deviceDefault: Locale = Locale.getDefault() + + /** Persisted UI language tag: [SYSTEM], "ru", or "en". */ + fun language(context: Context): String = + prefs(context).getString(KEY_LANG, SYSTEM) ?: SYSTEM + + /** + * Persists [tag]. The in-memory SharedPreferences value updates synchronously, so a + * caller can immediately recreate the activity and have [wrap] observe the new value. + */ + fun setLanguage(context: Context, tag: String) { + prefs(context).edit().putString(KEY_LANG, tag).apply() + } + + /** + * Wraps [base] so `getString()` / `resources` resolve in the chosen language. Returns + * [base] unchanged for [SYSTEM] (device locale via resource qualifiers). Call from every + * component's `attachBaseContext`. + */ + fun wrap(base: Context): Context { + val tag = language(base) + if (tag == SYSTEM) { + // Restore the device locale process-wide (resource qualifiers already follow it). + if (Locale.getDefault() != deviceDefault) Locale.setDefault(deviceDefault) + return base + } + val locale = Locale.forLanguageTag(tag) + Locale.setDefault(locale) + val config = Configuration(base.resources.configuration) + config.setLocale(locale) + return base.createConfigurationContext(config) + } + + private fun prefs(context: Context) = + context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE) +} + +/** Unwraps the (locale-wrapped) context chain to the hosting Activity, for `recreate()`. */ +fun Context.findActivity(): Activity? { + var ctx: Context = this + while (ctx is ContextWrapper) { + if (ctx is Activity) return ctx + ctx = ctx.baseContext + } + return null +} diff --git a/app/src/main/java/chat/vojo/proxy/core/ProxyConfig.kt b/app/src/main/java/chat/vojo/proxy/core/ProxyConfig.kt index 8f10584..64ebead 100644 --- a/app/src/main/java/chat/vojo/proxy/core/ProxyConfig.kt +++ b/app/src/main/java/chat/vojo/proxy/core/ProxyConfig.kt @@ -1,5 +1,7 @@ package chat.vojo.proxy.core +import androidx.annotation.StringRes +import chat.vojo.proxy.R import kotlinx.serialization.Serializable import java.util.UUID @@ -57,11 +59,13 @@ data class ProxyConfig( /** `host:port` shown in monospace, mirroring the `@user:server` handle in the design. */ val handle: String get() = "$host:$port" - fun validationError(): String? = when { - name.isBlank() -> "Укажите имя" - host.isBlank() -> "Укажите адрес сервера" - port !in 1..65535 -> "Порт должен быть 1–65535" - type == ProxyType.SHADOWSOCKS && password.isEmpty() -> "Укажите пароль Shadowsocks" + /** Returns a string resource id describing the first validation problem, or null if valid. */ + @StringRes + fun validationError(): Int? = when { + name.isBlank() -> R.string.validation_name_required + host.isBlank() -> R.string.validation_host_required + port !in 1..65535 -> R.string.validation_port_range + type == ProxyType.SHADOWSOCKS && password.isEmpty() -> R.string.validation_ss_password_required else -> null } } diff --git a/app/src/main/java/chat/vojo/proxy/data/AppRepository.kt b/app/src/main/java/chat/vojo/proxy/data/AppRepository.kt index 3f9d80d..dbcd6d1 100644 --- a/app/src/main/java/chat/vojo/proxy/data/AppRepository.kt +++ b/app/src/main/java/chat/vojo/proxy/data/AppRepository.kt @@ -1,6 +1,8 @@ package chat.vojo.proxy.data import android.content.Context +import android.content.Intent +import android.content.pm.ApplicationInfo import android.content.pm.PackageManager import android.graphics.Bitmap import android.graphics.Canvas @@ -15,6 +17,10 @@ import kotlinx.coroutines.withContext data class AppInfo( val packageName: String, val label: String, + /** Has a launcher activity — i.e. the user can see it in the app drawer. */ + val hasLauncher: Boolean = true, + /** Installed or updated by the user (not a pristine preinstalled system app). */ + val userInstalled: Boolean = true, ) /** Lists installable apps for the whitelist picker and lazily renders their icons. */ @@ -26,21 +32,47 @@ class AppRepository(context: Context) { suspend fun installedApps(): List = withContext(Dispatchers.IO) { val self = appContext.packageName + // Packages that own a launcher activity = the apps the user actually sees in the drawer. + // One query, O(1) lookups after — far cheaper than getLaunchIntentForPackage() per app. + // MATCH_DISABLED_COMPONENTS so an app whose launcher alias ships disabled-by-default + // (a common OEM/Samsung pattern) still counts as a real app, not background junk. + val launcherPkgs = pm.queryIntentActivities( + Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER), + PackageManager.MATCH_DISABLED_COMPONENTS, + ).mapNotNullTo(HashSet()) { it.activityInfo?.packageName } + val packages = pm.getInstalledPackages(PackageManager.GET_PERMISSIONS) packages.asSequence() .filter { it.packageName != self } .filter { it.requestedPermissions?.contains(android.Manifest.permission.INTERNET) == true } .map { pkg -> val ai = pkg.applicationInfo + val flags = ai?.flags ?: 0 + // Chrome/YouTube ship preinstalled but get Play-updated → FLAG_UPDATED_SYSTEM_APP, + // so they count as user-installed and float up, while pristine system apps sink. + val pristineSystem = (flags and ApplicationInfo.FLAG_SYSTEM) != 0 && + (flags and ApplicationInfo.FLAG_UPDATED_SYSTEM_APP) == 0 AppInfo( packageName = pkg.packageName, label = ai?.let { pm.getApplicationLabel(it).toString() } ?: pkg.packageName, + hasLauncher = pkg.packageName in launcherPkgs, + userInstalled = !pristineSystem, ) } - .sortedBy { it.label.lowercase() } + // Real apps first (yours, then preinstalled-with-icon), background system components last; + // alphabetical within each tier so the list stays scannable. + .sortedWith(compareBy({ tierOf(it) }, { it.label.lowercase() })) .toList() } + /** Lower tier = higher in the list. Mirrors the user's model: my apps → other real apps → junk. */ + private fun tierOf(app: AppInfo): Int = when { + app.userInstalled && app.hasLauncher -> 0 // apps you installed/updated and can launch + app.hasLauncher -> 1 // preinstalled, but visible in the drawer + app.userInstalled -> 2 // yours, but no launcher entry (rare) + else -> 3 // background system component + } + /** Synchronous cache hit for composition-time initial values (no thread hop). */ fun cachedIcon(packageName: String): ImageBitmap? = iconCache.get(packageName) diff --git a/app/src/main/java/chat/vojo/proxy/service/ProxyTileService.kt b/app/src/main/java/chat/vojo/proxy/service/ProxyTileService.kt index dad81f9..5bf5b16 100644 --- a/app/src/main/java/chat/vojo/proxy/service/ProxyTileService.kt +++ b/app/src/main/java/chat/vojo/proxy/service/ProxyTileService.kt @@ -1,12 +1,15 @@ package chat.vojo.proxy.service import android.app.PendingIntent +import android.content.Context import android.content.Intent import android.net.VpnService import android.os.Build import android.service.quicksettings.Tile import android.service.quicksettings.TileService import chat.vojo.proxy.MainActivity +import chat.vojo.proxy.R +import chat.vojo.proxy.core.LocaleManager import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -18,6 +21,10 @@ class ProxyTileService : TileService() { private var scope: CoroutineScope? = null + override fun attachBaseContext(newBase: Context) { + super.attachBaseContext(LocaleManager.wrap(newBase)) + } + override fun onStartListening() { scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate).also { sc -> sc.launch { VpnState.status.collect { render(it) } } @@ -37,10 +44,10 @@ class ProxyTileService : TileService() { } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { tile.subtitle = when (status) { - VpnState.Status.CONNECTED -> "online" - VpnState.Status.CONNECTING -> "connecting" - VpnState.Status.STOPPING -> "stopping" - VpnState.Status.ERROR -> "error" + VpnState.Status.CONNECTED -> getString(R.string.tile_online) + VpnState.Status.CONNECTING -> getString(R.string.tile_connecting) + VpnState.Status.STOPPING -> getString(R.string.tile_stopping) + VpnState.Status.ERROR -> getString(R.string.tile_error) VpnState.Status.DISCONNECTED -> null } } diff --git a/app/src/main/java/chat/vojo/proxy/service/ProxyVpnService.kt b/app/src/main/java/chat/vojo/proxy/service/ProxyVpnService.kt index 6c9969a..e855872 100644 --- a/app/src/main/java/chat/vojo/proxy/service/ProxyVpnService.kt +++ b/app/src/main/java/chat/vojo/proxy/service/ProxyVpnService.kt @@ -19,6 +19,7 @@ import androidx.core.content.ContextCompat import chat.vojo.proxy.MainActivity import chat.vojo.proxy.R import chat.vojo.proxy.core.AppSettings +import chat.vojo.proxy.core.LocaleManager import chat.vojo.proxy.core.ProxyConfig import chat.vojo.proxy.core.ProxyType import chat.vojo.proxy.core.RoutingMode @@ -73,6 +74,11 @@ class ProxyVpnService : VpnService(), SocketProtector, DirectDialer { @Volatile private var engineGen = 0 @Volatile private var lastUpstreamWarn = 0L + // Resolve user-facing strings (journal, notifications) in the chosen in-app language. + override fun attachBaseContext(newBase: Context) { + super.attachBaseContext(LocaleManager.wrap(newBase)) + } + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { when (intent?.action) { ACTION_STOP -> { @@ -86,7 +92,7 @@ class ProxyVpnService : VpnService(), SocketProtector, DirectDialer { if (!goForeground()) return START_NOT_STICKY lifecycle.execute { if (running) { - VpnState.event("Переподключение") + VpnState.event(getString(R.string.event_reconnecting)) shutdown(errorMessage = null, keepNotification = true) } startTunnel() @@ -129,7 +135,7 @@ class ProxyVpnService : VpnService(), SocketProtector, DirectDialer { // notification goForeground() just showed and end the service so the UI // doesn't sit on a stale "Подключение…". The daemon reaper finishes // closing the old fd; the user can reconnect once it's done. - VpnState.event("Туннель ещё останавливается", "подождите пару секунд и повторите", VpnState.TunnelEvent.Tone.WARN) + VpnState.event(getString(R.string.event_still_stopping), getString(R.string.event_still_stopping_detail), VpnState.TunnelEvent.Tone.WARN) VpnState.setStopped(null) stopForeground(STOP_FOREGROUND_REMOVE) stopSelf() @@ -150,7 +156,7 @@ class ProxyVpnService : VpnService(), SocketProtector, DirectDialer { fail(getString(R.string.error_no_server)) return } - VpnState.event("Подключение", "${config.name} · ${config.type.label}") + VpnState.event(getString(R.string.event_connecting), "${config.name} · ${config.type.label}") updateNotification(getString(R.string.notif_connecting)) val upstream = Upstream.create(config, this) @@ -164,17 +170,19 @@ class ProxyVpnService : VpnService(), SocketProtector, DirectDialer { val pfd = establish(config, state.settings, socks.port) vpnInterface = pfd - VpnState.event("Интерфейс поднят", "$TUN_IPV4 · mtu ${state.settings.mtu}") - VpnState.event("SOCKS5-мост", "127.0.0.1:${socks.port}") + VpnState.event(getString(R.string.event_interface_up), "$TUN_IPV4 · mtu ${state.settings.mtu}") + VpnState.event(getString(R.string.event_socks_bridge), "127.0.0.1:${socks.port}") if (domainRoutingActive(state.settings)) { val modeLabel = when (state.settings.routingMode) { - RoutingMode.PROXY_ALL -> "через прокси, кроме списка" - RoutingMode.DIRECT_ALL -> "напрямую, кроме списка" + RoutingMode.PROXY_ALL -> getString(R.string.route_mode_proxy_except) + RoutingMode.DIRECT_ALL -> getString(R.string.route_mode_direct_except) } - VpnState.event("Маршрутизация по доменам", "${state.settings.routedSites.size} правил · $modeLabel") - VpnState.event("Перехват DNS (fake-IP)", "$MAPDNS_ADDR · только IPv4") + val n = state.settings.routedSites.size + val rules = resources.getQuantityString(R.plurals.route_rules, n, n) + VpnState.event(getString(R.string.route_domain_routing), "$rules · $modeLabel") + VpnState.event(getString(R.string.event_dns_intercept), getString(R.string.event_dns_intercept_detail, MAPDNS_ADDR)) } else { - VpnState.event("DNS через туннель", dnsServerFor(state.settings)) + VpnState.event(getString(R.string.event_dns_tunnel), dnsServerFor(state.settings)) } val hevConfig = buildEngineConfig(socks, state.settings) @@ -195,14 +203,14 @@ class ProxyVpnService : VpnService(), SocketProtector, DirectDialer { // actually works (reachable, creds correct) is a separate question that // a green "Подключено" must not pre-answer — the async probe below // confirms it with OK, or names the real failure. Until then: NEUTRAL. - VpnState.event("Туннель поднят", "${config.name} · ${config.type.label}") + VpnState.event(getString(R.string.event_tunnel_up), "${config.name} · ${config.type.label}") updateNotification(getString(R.string.notif_connected, config.name)) startStatsLoop() registerNetworkCallback() runUpstreamProbe(upstream, config.type, gen) } catch (e: Throwable) { Log.e(TAG, "failed to start tunnel", e) - fail(e.message ?: "Не удалось запустить туннель") + fail(e.message ?: getString(R.string.err_tunnel_start_failed)) } } @@ -227,32 +235,32 @@ class ProxyVpnService : VpnService(), SocketProtector, DirectDialer { val msg = e.message ?: "" return when { e is javax.net.ssl.SSLPeerUnverifiedException -> - Triple("Сертификат прокси не прошёл проверку", msg.ifBlank { null }, err) + Triple(getString(R.string.err_cert_failed), msg.ifBlank { null }, err) e is javax.net.ssl.SSLException -> - Triple("Ошибка TLS с прокси", msg.ifBlank { null }, err) + Triple(getString(R.string.err_tls), msg.ifBlank { null }, err) e is java.security.GeneralSecurityException -> // Fires only when the server sends bytes that fail our AEAD tag (tampered or // a true cipher mismatch). The COMMON wrong-key case never reaches here — // most SS servers just drop the connection (see ShadowsocksKeyLikelyWrong). - Triple("Неверный пароль или шифр Shadowsocks", null, err) + Triple(getString(R.string.err_ss_bad_key), null, err) e is ShadowsocksKeyLikelyWrong -> - Triple("Похоже, неверный ключ или шифр Shadowsocks", "сервер разорвал соединение, не ответив", warn) + Triple(getString(R.string.err_ss_key_likely), getString(R.string.err_ss_key_likely_detail), warn) e is java.net.UnknownHostException -> - Triple("Не удалось разрешить адрес прокси", msg.ifBlank { null }, err) + Triple(getString(R.string.err_resolve), msg.ifBlank { null }, err) msg.contains("auth", ignoreCase = true) || msg.contains("407") -> - Triple("Прокси отклонил аутентификацию", "проверьте логин и пароль", err) + Triple(getString(R.string.err_auth), getString(R.string.err_auth_detail), err) e is java.net.SocketTimeoutException || msg.contains("timed out", ignoreCase = true) -> - Triple("Прокси не отвечает", "таймаут — проверьте сервер или сеть", warn) + Triple(getString(R.string.err_no_response), getString(R.string.err_no_response_detail), warn) e is java.net.ConnectException -> - Triple("Не удалось подключиться к прокси", msg.ifBlank { null }, err) + Triple(getString(R.string.err_connect_failed), msg.ifBlank { null }, err) // SOCKS5 rep!=0 / HTTP non-2xx: the proxy answered but refused the request. // Could be access/policy (some proxies, e.g. 3proxy, accept any user/pass at // the auth step and deny here instead) or an unreachable target — we can't // tell which, so don't claim it's specifically the destination. msg.contains("connect failed", ignoreCase = true) || Regex("rep=\\d").containsMatchIn(msg) -> - Triple("Прокси отклонил запрос", "доступ, политика или недоступный адрес", warn) + Triple(getString(R.string.err_refused), getString(R.string.err_refused_detail), warn) else -> - Triple("Ошибка прокси", msg.ifBlank { e.javaClass.simpleName }, warn) + Triple(getString(R.string.err_generic), msg.ifBlank { e.javaClass.simpleName }, warn) } } @@ -283,7 +291,7 @@ class ProxyVpnService : VpnService(), SocketProtector, DirectDialer { result .onSuccess { val ms = System.currentTimeMillis() - startedAt - VpnState.event("Прокси подтверждён", "ответ за ${ms} мс", VpnState.TunnelEvent.Tone.OK) + VpnState.event(getString(R.string.event_proxy_confirmed), getString(R.string.event_proxy_confirmed_detail, ms), VpnState.TunnelEvent.Tone.OK) } .onFailure { e -> Log.w(TAG, "upstream probe failed", e) @@ -348,7 +356,7 @@ class ProxyVpnService : VpnService(), SocketProtector, DirectDialer { /** Engine thread exited on its own (not via a requested stop) — tear down loudly. */ private fun onEngineExit(gen: Int, ret: Int) { // lifecycle thread if (gen != engineGen || stopRequested) return - fail("Движок туннеля неожиданно завершился (код $ret)") + fail(getString(R.string.err_engine_exited, ret)) } private fun establish(config: ProxyConfig, settings: AppSettings, socksPort: Int): ParcelFileDescriptor { @@ -387,7 +395,7 @@ class ProxyVpnService : VpnService(), SocketProtector, DirectDialer { } builder.setConfigureIntent(activityPendingIntent()) - return builder.establish() ?: throw IllegalStateException("VPN permission not granted") + return builder.establish() ?: throw IllegalStateException(getString(R.string.err_vpn_not_allowed)) } /** Whitelist mode when apps are selected; otherwise route everything except ourselves. */ @@ -403,7 +411,7 @@ class ProxyVpnService : VpnService(), SocketProtector, DirectDialer { // All selected packages are gone: routing *everything* through the // proxy would silently violate the whitelist contract. if (added == 0) { - throw IllegalStateException("Выбранные приложения не установлены — обновите список") + throw IllegalStateException(getString(R.string.err_apps_uninstalled)) } } else { runCatching { builder.addDisallowedApplication(self) } @@ -477,8 +485,9 @@ class ProxyVpnService : VpnService(), SocketProtector, DirectDialer { private fun trafficSummary(): String { val st = VpnState.stats.value + val units = resources.getStringArray(R.array.byte_units).asList() val up = formatUptime(VpnState.connectedSince.value, System.currentTimeMillis()) - return "↑ ${formatBytes(st.txBytes)} · ↓ ${formatBytes(st.rxBytes)} · $up" + return "↑ ${formatBytes(st.txBytes, units)} · ↓ ${formatBytes(st.rxBytes, units)} · $up" } private fun registerNetworkCallback() { @@ -500,11 +509,11 @@ class ProxyVpnService : VpnService(), SocketProtector, DirectDialer { val caps = runCatching { cm.getNetworkCapabilities(network) }.getOrNull() val name = when { caps?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true -> "Wi-Fi" - caps?.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) == true -> "мобильная сеть" + caps?.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) == true -> getString(R.string.net_mobile) caps?.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) == true -> "Ethernet" - else -> "другая сеть" + else -> getString(R.string.net_other) } - VpnState.event("Сеть изменилась", name, VpnState.TunnelEvent.Tone.WARN) + VpnState.event(getString(R.string.event_network_changed), name, VpnState.TunnelEvent.Tone.WARN) } } @@ -576,7 +585,7 @@ class ProxyVpnService : VpnService(), SocketProtector, DirectDialer { } private fun fail(message: String) { // lifecycle thread - VpnState.event("Ошибка", message, VpnState.TunnelEvent.Tone.ERROR) + VpnState.event(getString(R.string.event_error), message, VpnState.TunnelEvent.Tone.ERROR) shutdown(errorMessage = message) stopSelf() } @@ -641,7 +650,7 @@ class ProxyVpnService : VpnService(), SocketProtector, DirectDialer { // Don't log "Отключено" mid-reconnect (keepNotification) — that path // logs "Переподключение" → "Подключение" → "Подключено" instead. if (errorMessage == null && !keepNotification && VpnState.events.value.isNotEmpty()) { - VpnState.event("Отключено") + VpnState.event(getString(R.string.event_disconnected)) } if (!keepNotification) { stopForeground(STOP_FOREGROUND_REMOVE) @@ -678,7 +687,7 @@ class ProxyVpnService : VpnService(), SocketProtector, DirectDialer { override fun onRevoke() { // User revoked VPN permission or another VPN took over. lifecycle.execute { - VpnState.event("VPN отозван системой", null, VpnState.TunnelEvent.Tone.WARN) + VpnState.event(getString(R.string.event_vpn_revoked), null, VpnState.TunnelEvent.Tone.WARN) shutdown(errorMessage = null) stopSelf() } diff --git a/app/src/main/java/chat/vojo/proxy/ui/AppsScreen.kt b/app/src/main/java/chat/vojo/proxy/ui/AppsScreen.kt index d61cc36..8d00d56 100644 --- a/app/src/main/java/chat/vojo/proxy/ui/AppsScreen.kt +++ b/app/src/main/java/chat/vojo/proxy/ui/AppsScreen.kt @@ -38,12 +38,14 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import chat.vojo.proxy.R import chat.vojo.proxy.data.AppInfo import chat.vojo.proxy.ui.theme.Vojo @@ -79,7 +81,7 @@ fun AppsScreen( Column(Modifier.padding(horizontal = 14.dp, vertical = 12.dp)) { Row(verticalAlignment = Alignment.CenterVertically) { Text( - if (selected.isEmpty()) "Проксируются: все приложения" else "Проксируются", + stringResource(if (selected.isEmpty()) R.string.apps_proxying_all else R.string.apps_proxying), color = Vojo.text, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, ) if (selected.isNotEmpty()) { @@ -96,7 +98,7 @@ fun AppsScreen( Spacer(Modifier.weight(1f)) if (selected.isNotEmpty()) { Text( - "Сбросить", + stringResource(R.string.action_reset), color = Vojo.fleetSoft, fontSize = 13.sp, modifier = Modifier .clip(RoundedCornerShape(8.dp)) @@ -106,7 +108,7 @@ fun AppsScreen( } } Text( - "Пустой список = весь трафик идёт через прокси.", + stringResource(R.string.apps_empty_hint), color = Vojo.faint, fontSize = 12.sp, ) Spacer(Modifier.height(12.dp)) @@ -131,7 +133,7 @@ fun AppsScreen( cursorBrush = SolidColor(Vojo.fleet), decorationBox = { inner -> Box(Modifier.padding(vertical = 14.dp)) { - if (query.isEmpty()) Text("Поиск приложений…", color = Vojo.faint, fontSize = 15.sp) + if (query.isEmpty()) Text(stringResource(R.string.apps_search_hint), color = Vojo.faint, fontSize = 15.sp) inner() } }, @@ -151,7 +153,7 @@ fun AppsScreen( ) { if (query.isBlank() && (pinned.isNotEmpty() || missing.isNotEmpty())) { item(key = "hdr-selected") { - SectionLabel("Выбранные · ${selected.size}", Modifier.padding(start = 6.dp, top = 6.dp, bottom = 6.dp)) + SectionLabel(stringResource(R.string.apps_selected_count, selected.size), Modifier.padding(start = 6.dp, top = 6.dp, bottom = 6.dp)) } items(pinned, key = { "sel:" + it.packageName }) { app -> AppRow(app, true, loadIcon, cachedIcon) { on -> onToggle(app.packageName, on) } @@ -160,7 +162,7 @@ fun AppsScreen( MissingAppRow(pkg) { onToggle(pkg, false) } } item(key = "hdr-all") { - SectionLabel("Все приложения", Modifier.padding(start = 6.dp, top = 14.dp, bottom = 6.dp)) + SectionLabel(stringResource(R.string.apps_all), Modifier.padding(start = 6.dp, top = 14.dp, bottom = 6.dp)) } } val list = if (query.isBlank()) rest else filtered @@ -170,7 +172,7 @@ fun AppsScreen( if (filtered.isEmpty() && query.isNotBlank()) { item(key = "empty") { Box(Modifier.fillMaxWidth().padding(vertical = 32.dp), contentAlignment = Alignment.Center) { - Text("Ничего не найдено по «$query»", color = Vojo.faint, fontSize = 14.sp) + Text(stringResource(R.string.search_no_results, query), color = Vojo.faint, fontSize = 14.sp) } } } @@ -253,7 +255,7 @@ private fun MissingAppRow(packageName: String, onRemove: () -> Unit) { Spacer(Modifier.width(12.dp)) Column(Modifier.weight(1f)) { Text(packageName, color = Vojo.muted, fontFamily = Vojo.mono, fontSize = 13.sp, maxLines = 1, overflow = TextOverflow.Ellipsis) - Text("не установлено · нажмите, чтобы убрать", color = Vojo.faint, fontSize = 11.sp) + Text(stringResource(R.string.apps_not_installed), color = Vojo.faint, fontSize = 11.sp) } } } diff --git a/app/src/main/java/chat/vojo/proxy/ui/ConnectionScreen.kt b/app/src/main/java/chat/vojo/proxy/ui/ConnectionScreen.kt index 1391f48..0c0d0a5 100644 --- a/app/src/main/java/chat/vojo/proxy/ui/ConnectionScreen.kt +++ b/app/src/main/java/chat/vojo/proxy/ui/ConnectionScreen.kt @@ -44,6 +44,8 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.res.stringArrayResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow @@ -51,6 +53,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.Lifecycle import androidx.lifecycle.repeatOnLifecycle +import chat.vojo.proxy.R import chat.vojo.proxy.core.AppSettings import chat.vojo.proxy.core.ProxyConfig import chat.vojo.proxy.core.ProxyType @@ -134,8 +137,8 @@ fun ConnectionScreen( accent = Vojo.amber.copy(alpha = 0.45f), ) { Row(Modifier.padding(horizontal = 16.dp, vertical = 13.dp), verticalAlignment = Alignment.CenterVertically) { - Text("Настройки изменены", color = Vojo.muted, fontSize = 13.sp, modifier = Modifier.weight(1f)) - Text("переподключить", color = Vojo.fleetSoft, fontSize = 13.sp) + Text(stringResource(R.string.conn_settings_changed), color = Vojo.muted, fontSize = 13.sp, modifier = Modifier.weight(1f)) + Text(stringResource(R.string.conn_reconnect), color = Vojo.fleetSoft, fontSize = 13.sp) } } } @@ -156,7 +159,7 @@ fun ConnectionScreen( // ── Scrollable journal — the only thing that scrolls; newest on top ── Column(Modifier.fillMaxWidth().weight(1f).padding(horizontal = 18.dp)) { - SectionLabel("Журнал", Modifier.padding(start = 2.dp, bottom = 10.dp)) + SectionLabel(stringResource(R.string.conn_journal), Modifier.padding(start = 2.dp, bottom = 10.dp)) JournalList( events = events, pulseLast = status == VpnState.Status.CONNECTING, @@ -210,7 +213,7 @@ private fun ActiveServerCard( Column(Modifier.weight(1f)) { Row(verticalAlignment = Alignment.CenterVertically) { Text( - config?.name ?: "Сервер не выбран", + config?.name ?: stringResource(R.string.error_no_server), color = if (live) Vojo.green else Vojo.text, fontSize = 17.sp, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis, @@ -223,7 +226,7 @@ private fun ActiveServerCard( } Spacer(Modifier.height(3.dp)) Text( - config?.handle ?: "выберите на вкладке «Серверы»", + config?.handle ?: stringResource(R.string.conn_choose_server), color = Vojo.muted, fontFamily = Vojo.mono, fontSize = 13.sp, maxLines = 1, overflow = TextOverflow.Ellipsis, ) @@ -239,11 +242,11 @@ private fun ActiveServerCard( .clickable { onDisconnect() }, contentAlignment = Alignment.Center, ) { - Icon(VojoIcons.Cross, contentDescription = "Отключить", tint = Vojo.danger, modifier = Modifier.size(18.dp)) + Icon(VojoIcons.Cross, contentDescription = stringResource(R.string.action_disconnect), tint = Vojo.danger, modifier = Modifier.size(18.dp)) } } else if (config != null && onPick != null) { Spacer(Modifier.width(8.dp)) - Text("сменить", color = Vojo.fleetSoft, fontSize = 13.sp) + Text(stringResource(R.string.conn_change_server_short), color = Vojo.fleetSoft, fontSize = 13.sp) } } } @@ -273,7 +276,7 @@ private fun ServerPickerSheet( }, ) { Column(Modifier.padding(horizontal = 14.dp, vertical = 4.dp)) { - SectionLabel("Сменить сервер", Modifier.padding(start = 4.dp, bottom = 10.dp)) + SectionLabel(stringResource(R.string.conn_change_server), Modifier.padding(start = 4.dp, bottom = 10.dp)) configs.forEach { cfg -> val isActive = cfg.id == activeId val isLive = cfg.id == liveId @@ -318,14 +321,14 @@ private fun ConnectButton(status: VpnState.Status, onConnect: () -> Unit, enable .clickable(enabled = enabled) { onConnect() }, contentAlignment = Alignment.Center, ) { - Text("Подключиться", color = fg, fontSize = 16.sp, fontWeight = FontWeight.SemiBold) + Text(stringResource(R.string.conn_connect), color = fg, fontSize = 16.sp, fontWeight = FontWeight.SemiBold) } } /** Non-interactive transition state shown while the tunnel comes up or tears down. */ @Composable private fun ConnectingButton(status: VpnState.Status) { - val label = if (status == VpnState.Status.STOPPING) "Отключение…" else "Подключение…" + val label = if (status == VpnState.Status.STOPPING) stringResource(R.string.conn_disconnecting) else stringResource(R.string.notif_connecting) Box( Modifier .fillMaxWidth() @@ -347,10 +350,11 @@ private fun StatsRow(stats: VpnState.Stats, connectedSince: Long) { // Only this leaf subscribes to the 1 Hz tick, so the rest of the screen (and // the pager) doesn't recompose every second while connected. val nowMs = rememberNowTicker() + val units = stringArrayResource(R.array.byte_units).asList() Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) { - StatTile("Отправлено", formatBytes(stats.txBytes), Modifier.weight(1f)) - StatTile("Получено", formatBytes(stats.rxBytes), Modifier.weight(1f)) - StatTile("Время", formatUptime(connectedSince, nowMs), Modifier.weight(1f)) + StatTile(stringResource(R.string.stat_sent), formatBytes(stats.txBytes, units), Modifier.weight(1f)) + StatTile(stringResource(R.string.stat_received), formatBytes(stats.rxBytes, units), Modifier.weight(1f)) + StatTile(stringResource(R.string.stat_time), formatUptime(connectedSince, nowMs), Modifier.weight(1f)) } } @@ -394,7 +398,7 @@ private fun JournalList( Modifier.width(50.dp).padding(end = 12.dp), color = Vojo.faint, fontFamily = Vojo.mono, fontSize = 11.sp, textAlign = TextAlign.End, ) - Text("Готов к подключению", color = Vojo.muted, fontSize = 14.sp) + Text(stringResource(R.string.conn_ready), color = Vojo.muted, fontSize = 14.sp) } return } diff --git a/app/src/main/java/chat/vojo/proxy/ui/Fields.kt b/app/src/main/java/chat/vojo/proxy/ui/Fields.kt index d208e73..5a38576 100644 --- a/app/src/main/java/chat/vojo/proxy/ui/Fields.kt +++ b/app/src/main/java/chat/vojo/proxy/ui/Fields.kt @@ -28,6 +28,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.input.KeyboardType @@ -35,6 +36,7 @@ import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import chat.vojo.proxy.R import chat.vojo.proxy.ui.theme.Vojo @Composable @@ -94,7 +96,7 @@ fun VojoTextField( if (isPassword) { Icon( if (reveal) VojoIcons.EyeOff else VojoIcons.Eye, - contentDescription = if (reveal) "Скрыть пароль" else "Показать пароль", + contentDescription = stringResource(if (reveal) R.string.cd_hide_password else R.string.cd_show_password), tint = Vojo.faint, modifier = Modifier .clip(RoundedCornerShape(6.dp)) diff --git a/app/src/main/java/chat/vojo/proxy/ui/MainScreen.kt b/app/src/main/java/chat/vojo/proxy/ui/MainScreen.kt index 67bc5ed..3ae9296 100644 --- a/app/src/main/java/chat/vojo/proxy/ui/MainScreen.kt +++ b/app/src/main/java/chat/vojo/proxy/ui/MainScreen.kt @@ -29,7 +29,9 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import chat.vojo.proxy.R import chat.vojo.proxy.service.VpnState import chat.vojo.proxy.ui.theme.Vojo import kotlinx.coroutines.launch @@ -53,7 +55,13 @@ fun MainScreen( val geositeSuggestions by vm.geositeSuggestions.collectAsState() val geositeLoading by vm.geositeLoading.collectAsState() - val tabs = listOf("Туннель", "Серверы", "Приложения", "Сайты", "Опции") + val tabs = listOf( + stringResource(R.string.tab_tunnel), + stringResource(R.string.tab_servers), + stringResource(R.string.tab_apps), + stringResource(R.string.tab_sites), + stringResource(R.string.tab_options), + ) val pagerState = rememberPagerState(pageCount = { tabs.size }) val scope = rememberCoroutineScope() diff --git a/app/src/main/java/chat/vojo/proxy/ui/ServersScreen.kt b/app/src/main/java/chat/vojo/proxy/ui/ServersScreen.kt index f13dd9a..ac41479 100644 --- a/app/src/main/java/chat/vojo/proxy/ui/ServersScreen.kt +++ b/app/src/main/java/chat/vojo/proxy/ui/ServersScreen.kt @@ -35,6 +35,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow @@ -42,6 +44,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties +import chat.vojo.proxy.R import chat.vojo.proxy.core.ProxyConfig import chat.vojo.proxy.core.ProxyType import chat.vojo.proxy.core.SsCipher @@ -101,7 +104,7 @@ fun ServersScreen( ) { Icon(VojoIcons.Plus, contentDescription = null, tint = Vojo.fleetSoft, modifier = Modifier.size(17.dp)) Spacer(Modifier.width(8.dp)) - Text("Добавить сервер", color = Vojo.fleetSoft, fontSize = 14.sp, fontWeight = FontWeight.Medium) + Text(stringResource(R.string.server_add), color = Vojo.fleetSoft, fontSize = 14.sp, fontWeight = FontWeight.Medium) } } } @@ -167,7 +170,7 @@ private fun ServerRow( Box { Icon( VojoIcons.More, - contentDescription = "Меню сервера", + contentDescription = stringResource(R.string.cd_server_menu), tint = Vojo.muted, modifier = Modifier .clip(RoundedCornerShape(10.dp)) @@ -177,11 +180,11 @@ private fun ServerRow( ) DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) { DropdownMenuItem( - text = { Text("Изменить", color = Vojo.text) }, + text = { Text(stringResource(R.string.action_edit), color = Vojo.text) }, onClick = { menu = false; onEdit() }, ) DropdownMenuItem( - text = { Text("Удалить", color = Vojo.danger) }, + text = { Text(stringResource(R.string.action_delete), color = Vojo.danger) }, onClick = { menu = false; onDelete() }, ) } @@ -215,10 +218,10 @@ private fun ConfirmDeleteDialog(config: ProxyConfig, onConfirm: () -> Unit, onDi .border(1.dp, Vojo.hairline, RoundedCornerShape(18.dp)) .padding(20.dp), ) { - Text("Удалить сервер?", color = Vojo.text, fontSize = 18.sp, fontWeight = FontWeight.SemiBold) + Text(stringResource(R.string.server_delete_title), color = Vojo.text, fontSize = 18.sp, fontWeight = FontWeight.SemiBold) Spacer(Modifier.height(8.dp)) Text( - "«${config.name}» и его пароль будут удалены безвозвратно.", + stringResource(R.string.server_delete_message, config.name), color = Vojo.muted, fontSize = 14.sp, ) Spacer(Modifier.height(4.dp)) @@ -230,13 +233,13 @@ private fun ConfirmDeleteDialog(config: ProxyConfig, onConfirm: () -> Unit, onDi .border(1.dp, Vojo.hairline, RoundedCornerShape(12.dp)) .clickable { onDismiss() }, contentAlignment = Alignment.Center, - ) { Text("Отмена", color = Vojo.muted, fontSize = 15.sp) } + ) { Text(stringResource(R.string.action_cancel), color = Vojo.muted, fontSize = 15.sp) } Box( Modifier.weight(1f).height(48.dp).clip(RoundedCornerShape(12.dp)) .background(Vojo.danger) .clickable { onConfirm() }, contentAlignment = Alignment.Center, - ) { Text("Удалить", color = Color(0xFF0C0C0E), fontWeight = FontWeight.SemiBold, fontSize = 15.sp) } + ) { Text(stringResource(R.string.action_delete), color = Color(0xFF0C0C0E), fontWeight = FontWeight.SemiBold, fontSize = 15.sp) } } } } @@ -249,10 +252,10 @@ private fun EmptyServers(onAdd: () -> Unit) { verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally, ) { - Text("Нет серверов", color = Vojo.text, fontSize = 20.sp, fontWeight = FontWeight.SemiBold) + Text(stringResource(R.string.servers_empty_title), color = Vojo.text, fontSize = 20.sp, fontWeight = FontWeight.SemiBold) Spacer(Modifier.height(8.dp)) Text( - "Добавьте Shadowsocks, SOCKS5 или HTTP(S) прокси, чтобы начать.", + stringResource(R.string.servers_empty_subtitle), color = Vojo.muted, fontSize = 14.sp, modifier = Modifier.padding(horizontal = 16.dp), textAlign = TextAlign.Center, @@ -268,7 +271,7 @@ private fun EmptyServers(onAdd: () -> Unit) { ) { Icon(VojoIcons.Plus, contentDescription = null, tint = Color(0xFF0C0C0E), modifier = Modifier.size(17.dp)) Spacer(Modifier.width(8.dp)) - Text("Добавить сервер", color = Color(0xFF0C0C0E), fontWeight = FontWeight.SemiBold, fontSize = 15.sp) + Text(stringResource(R.string.server_add), color = Color(0xFF0C0C0E), fontWeight = FontWeight.SemiBold, fontSize = 15.sp) } } } @@ -289,6 +292,7 @@ fun ServerEditorDialog( var cipher by rememberSaveable { mutableStateOf(initial?.cipher ?: SsCipher.AES_256_GCM) } var errorText by rememberSaveable { mutableStateOf(null) } val clipboard = LocalClipboardManager.current + val context = LocalContext.current Dialog( onDismissRequest = onDismiss, @@ -306,7 +310,7 @@ fun ServerEditorDialog( ) { Row(verticalAlignment = Alignment.CenterVertically) { Text( - if (initial == null) "Новый сервер" else "Изменить сервер", + stringResource(if (initial == null) R.string.server_new else R.string.server_edit), color = Vojo.text, fontSize = 19.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f), ) @@ -329,9 +333,9 @@ fun ServerEditorDialog( errorText = null } ssRequiresPlugin(text) -> - errorText = "Ссылка ss:// требует плагин (obfs/v2ray) — он не поддерживается" + errorText = context.getString(R.string.import_needs_plugin) else -> - errorText = "В буфере нет ссылки ss:// / socks5:// / http(s)://" + errorText = context.getString(R.string.import_no_link) } } .padding(horizontal = 8.dp, vertical = 8.dp), @@ -339,16 +343,16 @@ fun ServerEditorDialog( ) { Icon(VojoIcons.Clipboard, contentDescription = null, tint = Vojo.fleetSoft, modifier = Modifier.size(15.dp)) Spacer(Modifier.width(6.dp)) - Text("вставить", color = Vojo.fleetSoft, fontSize = 13.sp) + Text(stringResource(R.string.action_paste), color = Vojo.fleetSoft, fontSize = 13.sp) } } Spacer(Modifier.height(18.dp)) - VojoTextField(name, { name = it }, "Имя", "Мой сервер") + VojoTextField(name, { name = it }, stringResource(R.string.field_name_label), stringResource(R.string.field_name_hint)) Spacer(Modifier.height(14.dp)) VojoDropdown( - label = "Протокол", + label = stringResource(R.string.field_protocol_label), options = ProxyType.entries, selected = type, optionLabel = { it.label }, @@ -357,27 +361,27 @@ fun ServerEditorDialog( Spacer(Modifier.height(14.dp)) Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { - VojoTextField(host, { host = it }, "Адрес", "example.com", Modifier.weight(2f), mono = true) - VojoTextField(port, { port = it.filter(Char::isDigit).take(5) }, "Порт", "8388", Modifier.weight(1f), keyboardType = androidx.compose.ui.text.input.KeyboardType.Number, mono = true) + VojoTextField(host, { host = it }, stringResource(R.string.field_address_label), "example.com", Modifier.weight(2f), mono = true) + VojoTextField(port, { port = it.filter(Char::isDigit).take(5) }, stringResource(R.string.field_port_label), "8388", Modifier.weight(1f), keyboardType = androidx.compose.ui.text.input.KeyboardType.Number, mono = true) } Spacer(Modifier.height(14.dp)) when (type) { ProxyType.SHADOWSOCKS -> { VojoDropdown( - label = "Шифр", + label = stringResource(R.string.field_cipher_label), options = SsCipher.entries, selected = cipher, optionLabel = { it.id }, onSelect = { cipher = it }, ) Spacer(Modifier.height(14.dp)) - VojoTextField(password, { password = it }, "Пароль", "пароль Shadowsocks", isPassword = true, mono = true) + VojoTextField(password, { password = it }, stringResource(R.string.field_password_label), stringResource(R.string.field_ss_password_hint), isPassword = true, mono = true) } else -> { - VojoTextField(username, { username = it }, "Логин (необязательно)", "user", mono = true) + VojoTextField(username, { username = it }, stringResource(R.string.field_username_optional_label), "user", mono = true) Spacer(Modifier.height(14.dp)) - VojoTextField(password, { password = it }, "Пароль (необязательно)", "••••", isPassword = true, mono = true) + VojoTextField(password, { password = it }, stringResource(R.string.field_password_optional_label), "••••", isPassword = true, mono = true) } } @@ -393,7 +397,7 @@ fun ServerEditorDialog( .border(1.dp, Vojo.hairline, RoundedCornerShape(12.dp)) .clickable { onDismiss() }, contentAlignment = Alignment.Center, - ) { Text("Отмена", color = Vojo.muted, fontSize = 15.sp) } + ) { Text(stringResource(R.string.action_cancel), color = Vojo.muted, fontSize = 15.sp) } Box( Modifier.weight(1f).height(50.dp).clip(RoundedCornerShape(12.dp)) .background(Vojo.fleet) @@ -409,10 +413,10 @@ fun ServerEditorDialog( cipher = cipher, ) val err = cfg.validationError() - if (err != null) errorText = err else onSave(cfg) + if (err != null) errorText = context.getString(err) else onSave(cfg) }, contentAlignment = Alignment.Center, - ) { Text("Сохранить", color = Color(0xFF0C0C0E), fontWeight = FontWeight.SemiBold, fontSize = 15.sp) } + ) { Text(stringResource(R.string.action_save), color = Color(0xFF0C0C0E), fontWeight = FontWeight.SemiBold, fontSize = 15.sp) } } } } diff --git a/app/src/main/java/chat/vojo/proxy/ui/SettingsScreen.kt b/app/src/main/java/chat/vojo/proxy/ui/SettingsScreen.kt index e78fb84..384d603 100644 --- a/app/src/main/java/chat/vojo/proxy/ui/SettingsScreen.kt +++ b/app/src/main/java/chat/vojo/proxy/ui/SettingsScreen.kt @@ -1,6 +1,8 @@ package chat.vojo.proxy.ui import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -27,12 +29,17 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import chat.vojo.proxy.R import chat.vojo.proxy.core.AppSettings +import chat.vojo.proxy.core.LocaleManager +import chat.vojo.proxy.core.findActivity import chat.vojo.proxy.ui.theme.Vojo /** Loose but useful: enough to keep garbage out of VpnService.addDnsServer. */ @@ -56,8 +63,8 @@ fun SettingsScreen( val dnsValue = dnsEdit ?: settings.dns val mtuValue = mtuEdit ?: settings.mtu.toString() - val dnsError = if (dnsEdit != null && !isValidIp(dnsValue.trim())) "Не похоже на IP-адрес — не сохранено" else null - val mtuError = if (mtuEdit != null && (mtuValue.toIntOrNull() == null || mtuValue.toInt() !in 1000..9000)) "Допустимо 1000–9000 — не сохранено" else null + val dnsError = if (dnsEdit != null && !isValidIp(dnsValue.trim())) stringResource(R.string.settings_dns_invalid) else null + val mtuError = if (mtuEdit != null && (mtuValue.toIntOrNull() == null || mtuValue.toInt() !in 1000..9000)) stringResource(R.string.settings_mtu_invalid) else null Column( // imePadding so the keyboard never covers the DNS/MTU fields under @@ -65,7 +72,7 @@ fun SettingsScreen( Modifier.fillMaxSize().verticalScroll(rememberScrollState()).imePadding().padding(18.dp), verticalArrangement = Arrangement.spacedBy(16.dp), ) { - SectionLabel("Сеть") + SectionLabel(stringResource(R.string.settings_section_network)) VojoTextField( value = dnsValue, @@ -74,13 +81,13 @@ fun SettingsScreen( val v = it.trim() if (isValidIp(v)) onChange { s -> s.copy(dns = v) } }, - label = "DNS-сервер", + label = stringResource(R.string.settings_dns_label), placeholder = "1.1.1.1", mono = true, keyboardType = KeyboardType.Ascii, error = dnsError, ) - Text("DNS-запросы идут через туннель — провайдер их не видит.", color = Vojo.faint, fontSize = 12.sp) + Text(stringResource(R.string.settings_dns_hint), color = Vojo.faint, fontSize = 12.sp) VojoTextField( value = mtuValue, @@ -98,15 +105,65 @@ fun SettingsScreen( ToggleRow( title = "IPv6", - subtitle = "Маршрутизировать IPv6-трафик через туннель", + subtitle = stringResource(R.string.settings_ipv6_subtitle), checked = settings.ipv6, onToggle = { onChange { s -> s.copy(ipv6 = it) } }, ) Text( - "Изменения применяются при следующем подключении.", + stringResource(R.string.settings_apply_hint), color = Vojo.faint, fontSize = 12.sp, ) + + SectionLabel(stringResource(R.string.settings_section_language)) + LanguageSelector() + } +} + +/** + * In-app UI language: System / Русский / English. Persists the choice via [LocaleManager] + * and recreates the activity so the new locale is applied to all resources immediately. + */ +@Composable +private fun LanguageSelector() { + val context = LocalContext.current + val current = LocaleManager.language(context) + val options = listOf( + LocaleManager.SYSTEM to stringResource(R.string.lang_system), + "ru" to stringResource(R.string.lang_russian), + "en" to stringResource(R.string.lang_english), + ) + Row( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(Vojo.bgPanel) + .border(1.dp, Vojo.divider, RoundedCornerShape(12.dp)) + .padding(3.dp), + horizontalArrangement = Arrangement.spacedBy(3.dp), + ) { + options.forEach { (tag, label) -> + val active = tag == current + Box( + Modifier + .weight(1f) + .clip(RoundedCornerShape(9.dp)) + .background(if (active) Vojo.fleet else Color.Transparent) + .clickable(enabled = !active) { + LocaleManager.setLanguage(context, tag) + context.findActivity()?.recreate() + } + .padding(vertical = 9.dp), + contentAlignment = Alignment.Center, + ) { + Text( + label, + color = if (active) Color(0xFF0C0C0E) else Vojo.muted, + fontSize = 13.sp, + fontWeight = if (active) FontWeight.SemiBold else FontWeight.Medium, + ) + } + } } } diff --git a/app/src/main/java/chat/vojo/proxy/ui/SitesScreen.kt b/app/src/main/java/chat/vojo/proxy/ui/SitesScreen.kt index 56488b3..6f0ef99 100644 --- a/app/src/main/java/chat/vojo/proxy/ui/SitesScreen.kt +++ b/app/src/main/java/chat/vojo/proxy/ui/SitesScreen.kt @@ -35,6 +35,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction @@ -42,6 +44,7 @@ import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import chat.vojo.proxy.R import chat.vojo.proxy.core.RoutingMode import chat.vojo.proxy.core.route.GEOSITE_PREFIX import chat.vojo.proxy.core.route.GeositeHit @@ -92,7 +95,7 @@ fun SitesScreen( Column(Modifier.fillMaxSize()) { Column(Modifier.padding(horizontal = 14.dp, vertical = 12.dp)) { Row(verticalAlignment = Alignment.CenterVertically) { - Text("Маршрутизация по доменам", color = Vojo.text, fontSize = 15.sp, fontWeight = FontWeight.SemiBold) + Text(stringResource(R.string.route_domain_routing), color = Vojo.text, fontSize = 15.sp, fontWeight = FontWeight.SemiBold) if (routedSites.isNotEmpty()) { Spacer(Modifier.width(8.dp)) Box(Modifier.clip(RoundedCornerShape(99.dp)).background(Vojo.fleet).padding(horizontal = 8.dp, vertical = 2.dp)) { @@ -102,7 +105,7 @@ fun SitesScreen( Spacer(Modifier.weight(1f)) if (routedSites.isNotEmpty()) { Text( - "Сбросить", + stringResource(R.string.action_reset), color = Vojo.fleetSoft, fontSize = 13.sp, modifier = Modifier.clip(RoundedCornerShape(8.dp)).clickable { onClear() }.padding(horizontal = 10.dp, vertical = 8.dp), ) @@ -110,20 +113,22 @@ fun SitesScreen( } Spacer(Modifier.height(12.dp)) - SectionLabel("Сайты из списка") + SectionLabel(stringResource(R.string.sites_listed_header)) Spacer(Modifier.height(6.dp)) ModeToggle(routingMode, onSetMode) Spacer(Modifier.height(6.dp)) Text( - when (routingMode) { - RoutingMode.PROXY_ALL -> "Остальной трафик идёт через прокси." - RoutingMode.DIRECT_ALL -> "Остальной трафик идёт напрямую." - }, + stringResource( + when (routingMode) { + RoutingMode.PROXY_ALL -> R.string.sites_rest_proxy + RoutingMode.DIRECT_ALL -> R.string.sites_rest_direct + } + ), color = Vojo.faint, fontSize = 12.sp, ) Spacer(Modifier.height(2.dp)) Text( - "С учётом фильтра из вкладки «Приложения».", + stringResource(R.string.sites_apps_filter_note), color = Vojo.faint, fontSize = 11.sp, ) Spacer(Modifier.height(12.dp)) @@ -132,7 +137,7 @@ fun SitesScreen( InputField( value = domainInput, onValueChange = { domainInput = it }, - placeholder = "Добавить домен, напр. example.com", + placeholder = stringResource(R.string.sites_add_domain_hint), mono = true, invalid = showInvalid, keyboardType = KeyboardType.Uri, @@ -144,13 +149,13 @@ fun SitesScreen( .then(if (canAdd) Modifier.clickable { commitDomain() } else Modifier), contentAlignment = Alignment.Center, ) { - Icon(VojoIcons.Plus, contentDescription = "Добавить", tint = if (canAdd) Color(0xFF0C0C0E) else Vojo.faint, modifier = Modifier.size(16.dp)) + Icon(VojoIcons.Plus, contentDescription = stringResource(R.string.cd_add), tint = if (canAdd) Color(0xFF0C0C0E) else Vojo.faint, modifier = Modifier.size(16.dp)) } }, ) if (showInvalid) { Spacer(Modifier.height(4.dp)) - Text("Не похоже на домен", color = Vojo.danger, fontSize = 11.sp) + Text(stringResource(R.string.sites_invalid_domain), color = Vojo.danger, fontSize = 11.sp) } Spacer(Modifier.height(10.dp)) @@ -158,7 +163,7 @@ fun SitesScreen( InputField( value = catQuery, onValueChange = { catQuery = it; onCategoryQuery(it) }, - placeholder = "Найти категорию: google, netflix, ads…", + placeholder = stringResource(R.string.sites_search_category_hint), mono = false, invalid = false, keyboardType = KeyboardType.Text, @@ -169,7 +174,7 @@ fun SitesScreen( Box( Modifier.size(30.dp).clip(RoundedCornerShape(8.dp)).clickable { catQuery = ""; onCategoryQuery("") }, contentAlignment = Alignment.Center, - ) { Icon(VojoIcons.Cross, contentDescription = "Очистить", tint = Vojo.muted, modifier = Modifier.size(14.dp)) } + ) { Icon(VojoIcons.Cross, contentDescription = stringResource(R.string.cd_clear), tint = Vojo.muted, modifier = Modifier.size(14.dp)) } } } else null, ) @@ -182,7 +187,7 @@ fun SitesScreen( ) { if (catQuery.isNotBlank()) { // Search mode: show suggestions from the geosite DB. - item(key = "hdr-sug") { SectionLabel("Категории geosite", Modifier.padding(start = 6.dp, top = 6.dp, bottom = 6.dp)) } + item(key = "hdr-sug") { SectionLabel(stringResource(R.string.sites_geosite_categories), Modifier.padding(start = 6.dp, top = 6.dp, bottom = 6.dp)) } if (categoryLoading && categorySuggestions.isEmpty()) { item(key = "sug-loading") { Box(Modifier.fillMaxWidth().padding(vertical = 24.dp), contentAlignment = Alignment.Center) { @@ -191,7 +196,7 @@ fun SitesScreen( } } else if (categorySuggestions.isEmpty()) { item(key = "sug-empty") { - Text("Ничего не найдено по «$catQuery»", color = Vojo.faint, fontSize = 13.sp, modifier = Modifier.padding(start = 6.dp, top = 10.dp, bottom = 10.dp)) + Text(stringResource(R.string.search_no_results, catQuery), color = Vojo.faint, fontSize = 13.sp, modifier = Modifier.padding(start = 6.dp, top = 10.dp, bottom = 10.dp)) } } else { items(categorySuggestions, key = { "sug:${it.category}" }) { hit -> @@ -207,22 +212,22 @@ fun SitesScreen( } else { // Browse mode: selected categories + manual domains. item(key = "hdr-cat") { - SectionLabel(if (selectedCategories.isEmpty()) "Категории" else "Категории · ${selectedCategories.size}", Modifier.padding(start = 6.dp, top = 6.dp, bottom = 6.dp)) + SectionLabel(if (selectedCategories.isEmpty()) stringResource(R.string.sites_categories) else stringResource(R.string.sites_categories_count, selectedCategories.size), Modifier.padding(start = 6.dp, top = 6.dp, bottom = 6.dp)) } if (selectedCategories.isEmpty()) { item(key = "cat-empty") { - Text("Найдите категорию выше (google, netflix, telegram…) и добавьте её.", color = Vojo.faint, fontSize = 12.sp, modifier = Modifier.padding(start = 6.dp, top = 4.dp, bottom = 8.dp)) + Text(stringResource(R.string.sites_categories_empty), color = Vojo.faint, fontSize = 12.sp, modifier = Modifier.padding(start = 6.dp, top = 4.dp, bottom = 8.dp)) } } items(selectedCategories, key = { "selcat:$it" }) { cat -> CategoryRow(category = cat, ruleCount = null, byName = true, checked = true) { onToggleCategory(cat, false) } } item(key = "hdr-dom") { - SectionLabel(if (domains.isEmpty()) "Домены" else "Домены · ${domains.size}", Modifier.padding(start = 6.dp, top = 14.dp, bottom = 6.dp)) + SectionLabel(if (domains.isEmpty()) stringResource(R.string.sites_domains) else stringResource(R.string.sites_domains_count, domains.size), Modifier.padding(start = 6.dp, top = 14.dp, bottom = 6.dp)) } if (domains.isEmpty()) { item(key = "dom-empty") { - Text("Добавьте домен выше — он и все его поддомены попадут под правило.", color = Vojo.faint, fontSize = 12.sp, modifier = Modifier.padding(start = 6.dp, top = 4.dp, bottom = 8.dp)) + Text(stringResource(R.string.sites_domains_empty), color = Vojo.faint, fontSize = 12.sp, modifier = Modifier.padding(start = 6.dp, top = 4.dp, bottom = 8.dp)) } } items(domains, key = { "dom:$it" }) { domain -> @@ -283,8 +288,8 @@ private fun ModeToggle(mode: RoutingMode, onSet: (RoutingMode) -> Unit) { ) { // The toggle states what the LISTED sites do: «через прокси» = list proxied, rest direct // (DIRECT_ALL); «напрямую» = list bypasses the proxy, rest proxied (PROXY_ALL). - ModePill("через прокси", mode == RoutingMode.DIRECT_ALL, Modifier.weight(1f)) { onSet(RoutingMode.DIRECT_ALL) } - ModePill("напрямую", mode == RoutingMode.PROXY_ALL, Modifier.weight(1f)) { onSet(RoutingMode.PROXY_ALL) } + ModePill(stringResource(R.string.sites_mode_through_proxy), mode == RoutingMode.DIRECT_ALL, Modifier.weight(1f)) { onSet(RoutingMode.DIRECT_ALL) } + ModePill(stringResource(R.string.sites_mode_direct), mode == RoutingMode.PROXY_ALL, Modifier.weight(1f)) { onSet(RoutingMode.PROXY_ALL) } } } @@ -313,10 +318,13 @@ private fun CategoryRow(category: String, ruleCount: Int?, byName: Boolean, chec Spacer(Modifier.width(11.dp)) Column(Modifier.weight(1f)) { Text(category, color = Vojo.text, fontSize = 15.sp, fontWeight = FontWeight.Medium, maxLines = 1, overflow = TextOverflow.Ellipsis) + // Resolve the resources before buildString — composable reads can't sit inside its lambda. + val domainsLabel = if (ruleCount != null) pluralStringResource(R.plurals.geosite_domains, ruleCount, ruleCount) else null + val containsLabel = stringResource(R.string.sites_geosite_contains_domain) val sub = buildString { append("geosite") - if (ruleCount != null) append(" · ${ruleCount} доменов") - if (!byName) append(" · содержит домен") + if (domainsLabel != null) append(" · $domainsLabel") + if (!byName) append(" · $containsLabel") } Text(sub, color = Vojo.faint, fontFamily = Vojo.mono, fontSize = 11.sp, maxLines = 1, overflow = TextOverflow.Ellipsis) } @@ -336,7 +344,7 @@ private fun DomainRow(domain: String, onRemove: () -> Unit) { Text(domain, color = Vojo.text, fontFamily = Vojo.mono, fontSize = 14.sp, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f)) Spacer(Modifier.width(10.dp)) Box(Modifier.size(28.dp).clip(RoundedCornerShape(8.dp)).clickable { onRemove() }, contentAlignment = Alignment.Center) { - Icon(VojoIcons.Cross, contentDescription = "Удалить", tint = Vojo.muted, modifier = Modifier.size(14.dp)) + Icon(VojoIcons.Cross, contentDescription = stringResource(R.string.action_delete), tint = Vojo.muted, modifier = Modifier.size(14.dp)) } } } diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml new file mode 100644 index 0000000..a61fad7 --- /dev/null +++ b/app/src/main/res/values-ru/strings.xml @@ -0,0 +1,198 @@ + + + + + Состояние VPN + Уведомление о работе прокси-туннеля + + + Подключение… + Подключено · %1$s + Подключение прервано + + + Отключить + Отмена + Удалить + Изменить + Сохранить + Сбросить + вставить + + + Добавить + Очистить + Меню сервера + Показать пароль + Скрыть пароль + + + Туннель + Серверы + Приложения + Сайты + Опции + + + Сервер не выбран + выберите на вкладке «Серверы» + Настройки изменены + переподключить + сменить + Сменить сервер + Подключиться + Отключение… + Журнал + Готов к подключению + Отправлено + Получено + Время + + + Добавить сервер + Нет серверов + Добавьте Shadowsocks, SOCKS5 или HTTP(S) прокси, чтобы начать. + Удалить сервер? + «%1$s» и его пароль будут удалены безвозвратно. + Новый сервер + Изменить сервер + + + Ссылка ss:// требует плагин (obfs/v2ray) — он не поддерживается + В буфере нет ссылки ss:// / socks5:// / http(s):// + + + Имя + Мой сервер + Протокол + Адрес + Порт + Шифр + Пароль + пароль Shadowsocks + Логин (необязательно) + Пароль (необязательно) + + + Укажите имя + Укажите адрес сервера + Порт должен быть 1–65535 + Укажите пароль Shadowsocks + + + Проксируются + Проксируются: все приложения + Пустой список = весь трафик идёт через прокси. + Поиск приложений… + Выбранные · %1$d + Все приложения + не установлено · нажмите, чтобы убрать + + + Маршрутизация по доменам + Сайты из списка + через прокси + напрямую + Остальной трафик идёт через прокси. + Остальной трафик идёт напрямую. + С учётом фильтра из вкладки «Приложения». + Добавить домен, напр. example.com + Не похоже на домен + Найти категорию: google, netflix, ads… + Категории geosite + Категории + Категории · %1$d + Найдите категорию выше (google, netflix, telegram…) и добавьте её. + Домены + Домены · %1$d + Добавьте домен выше — он и все его поддомены попадут под правило. + содержит домен + + + Сеть + DNS-сервер + DNS-запросы идут через туннель — провайдер их не видит. + Не похоже на IP-адрес — не сохранено + Допустимо 1000–9000 — не сохранено + Маршрутизировать IPv6-трафик через туннель + Изменения применяются при следующем подключении. + Язык + Система + + + Ничего не найдено по «%1$s» + + + Переподключение + Туннель ещё останавливается + подождите пару секунд и повторите + Подключение + Интерфейс поднят + SOCKS5-мост + Перехват DNS (fake-IP) + %1$s · только IPv4 + DNS через туннель + Туннель поднят + Прокси подтверждён + ответ за %1$d мс + Сеть изменилась + Отключено + VPN отозван системой + Ошибка + + + через прокси, кроме списка + напрямую, кроме списка + + + мобильная сеть + другая сеть + + + Доступ к VPN не разрешён + Выбранные приложения не установлены — обновите список + Не удалось запустить туннель + Движок туннеля неожиданно завершился (код %1$d) + Сертификат прокси не прошёл проверку + Ошибка TLS с прокси + Неверный пароль или шифр Shadowsocks + Похоже, неверный ключ или шифр Shadowsocks + сервер разорвал соединение, не ответив + Не удалось разрешить адрес прокси + Прокси отклонил аутентификацию + проверьте логин и пароль + Прокси не отвечает + таймаут — проверьте сервер или сеть + Не удалось подключиться к прокси + Прокси отклонил запрос + доступ, политика или недоступный адрес + Ошибка прокси + + + онлайн + подключение + остановка + ошибка + + + + Б + КБ + МБ + ГБ + ТБ + + + + %d правило + %d правила + %d правил + %d правила + + + %d домен + %d домена + %d доменов + %d домена + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 69f5463..1a8f510 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,14 +1,203 @@ + - vojo proxy + vojo proxy - Состояние VPN - Уведомление о работе прокси-туннеля + + VPN status + Proxy tunnel status notification - Подключение… - Подключено · %1$s - Отключить + + Connecting… + Connected · %1$s + Connection interrupted - Сервер не выбран - Подключение прервано + + Disconnect + Cancel + Delete + Edit + Save + Reset + paste + + + Add + Clear + Server menu + Show password + Hide password + + + Tunnel + Servers + Apps + Sites + Options + + + No server selected + choose one on the Servers tab + Settings changed + reconnect + change + Change server + Connect + Disconnecting… + Journal + Ready to connect + Sent + Received + Time + + + Add server + No servers + Add a Shadowsocks, SOCKS5 or HTTP(S) proxy to get started. + Delete server? + «%1$s» and its password will be deleted permanently. + New server + Edit server + + + An ss:// link with a plugin (obfs/v2ray) — not supported + No ss:// / socks5:// / http(s):// link in the clipboard + + + Name + My server + Protocol + Address + Port + Cipher + Password + Shadowsocks password + Username (optional) + Password (optional) + + + Enter a name + Enter the server address + Port must be 1–65535 + Enter the Shadowsocks password + + + Proxied + Proxied: all apps + An empty list = all traffic goes through the proxy. + Search apps… + Selected · %1$d + All apps + not installed · tap to remove + + + Domain routing + Listed sites + through proxy + direct + The rest of the traffic goes through the proxy. + The rest of the traffic goes direct. + Subject to the filter on the Apps tab. + Add a domain, e.g. example.com + That doesn\'t look like a domain + Find a category: google, netflix, ads… + Geosite categories + Categories + Categories · %1$d + Find a category above (google, netflix, telegram…) and add it. + Domains + Domains · %1$d + Add a domain above — it and all its subdomains will be covered by the rule. + contains domain + + + Network + DNS server + DNS queries go through the tunnel — your ISP can\'t see them. + That doesn\'t look like an IP address — not saved + Allowed range is 1000–9000 — not saved + Route IPv6 traffic through the tunnel + Changes take effect on the next connection. + Language + System + Русский + English + + + Nothing found for «%1$s» + + + Reconnecting + Tunnel is still stopping + wait a couple of seconds and try again + Connecting + Interface up + SOCKS5 bridge + DNS interception (fake-IP) + %1$s · IPv4 only + DNS over the tunnel + Tunnel up + Proxy confirmed + replied in %1$d ms + Network changed + Disconnected + VPN revoked by the system + Error + + + through proxy, except the list + direct, except the list + + + mobile network + other network + + + VPN permission not granted + The selected apps aren\'t installed — update the list + Couldn\'t start the tunnel + Tunnel engine exited unexpectedly (code %1$d) + Proxy certificate failed verification + TLS error with the proxy + Wrong Shadowsocks password or cipher + Shadowsocks key or cipher looks wrong + the server closed the connection without replying + Couldn\'t resolve the proxy address + Proxy rejected authentication + check the username and password + Proxy isn\'t responding + timeout — check the server or the network + Couldn\'t connect to the proxy + Proxy refused the request + access, policy, or an unreachable address + Proxy error + + + online + connecting + stopping + error + + + + B + KB + MB + GB + TB + + + + %d rule + %d rules + + + %d domain + %d domains +