feat(i18n): move UI strings to resources, add LocaleManager + Russian locale

In-app language switch; default + values-ru, with screen/validation/journal strings externalized.
This commit is contained in:
heaven 2026-06-16 16:43:46 +03:00
parent 7877cb49f0
commit 68aa25f280
16 changed files with 751 additions and 137 deletions

View file

@ -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))
}
}

View file

@ -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>): 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])
}

View file

@ -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
}

View file

@ -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 -> "Порт должен быть 165535"
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
}
}

View file

@ -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<AppInfo> = 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)

View file

@ -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
}
}

View file

@ -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()
}

View file

@ -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)
}
}
}

View file

@ -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
}

View file

@ -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))

View file

@ -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()

View file

@ -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<String?>(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) }
}
}
}

View file

@ -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)) "Допустимо 10009000 — не сохранено" 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,
)
}
}
}
}

View file

@ -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))
}
}
}

View file

@ -0,0 +1,198 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Russian translations. Base (English) + non-translatable tokens: values/strings.xml. -->
<resources>
<!-- Foreground-service notification channel -->
<string name="channel_name">Состояние VPN</string>
<string name="channel_desc">Уведомление о работе прокси-туннеля</string>
<!-- Notifications -->
<string name="notif_connecting">Подключение…</string>
<string name="notif_connected">Подключено · %1$s</string>
<string name="notif_error_title">Подключение прервано</string>
<!-- Shared actions / labels -->
<string name="action_disconnect">Отключить</string>
<string name="action_cancel">Отмена</string>
<string name="action_delete">Удалить</string>
<string name="action_edit">Изменить</string>
<string name="action_save">Сохранить</string>
<string name="action_reset">Сбросить</string>
<string name="action_paste">вставить</string>
<!-- Content descriptions -->
<string name="cd_add">Добавить</string>
<string name="cd_clear">Очистить</string>
<string name="cd_server_menu">Меню сервера</string>
<string name="cd_show_password">Показать пароль</string>
<string name="cd_hide_password">Скрыть пароль</string>
<!-- Tabs -->
<string name="tab_tunnel">Туннель</string>
<string name="tab_servers">Серверы</string>
<string name="tab_apps">Приложения</string>
<string name="tab_sites">Сайты</string>
<string name="tab_options">Опции</string>
<!-- Connection screen -->
<string name="error_no_server">Сервер не выбран</string>
<string name="conn_choose_server">выберите на вкладке «Серверы»</string>
<string name="conn_settings_changed">Настройки изменены</string>
<string name="conn_reconnect">переподключить</string>
<string name="conn_change_server_short">сменить</string>
<string name="conn_change_server">Сменить сервер</string>
<string name="conn_connect">Подключиться</string>
<string name="conn_disconnecting">Отключение…</string>
<string name="conn_journal">Журнал</string>
<string name="conn_ready">Готов к подключению</string>
<string name="stat_sent">Отправлено</string>
<string name="stat_received">Получено</string>
<string name="stat_time">Время</string>
<!-- Servers screen -->
<string name="server_add">Добавить сервер</string>
<string name="servers_empty_title">Нет серверов</string>
<string name="servers_empty_subtitle">Добавьте Shadowsocks, SOCKS5 или HTTP(S) прокси, чтобы начать.</string>
<string name="server_delete_title">Удалить сервер?</string>
<string name="server_delete_message">«%1$s» и его пароль будут удалены безвозвратно.</string>
<string name="server_new">Новый сервер</string>
<string name="server_edit">Изменить сервер</string>
<!-- Server editor — share-link import -->
<string name="import_needs_plugin">Ссылка ss:// требует плагин (obfs/v2ray) — он не поддерживается</string>
<string name="import_no_link">В буфере нет ссылки ss:// / socks5:// / http(s)://</string>
<!-- Form fields -->
<string name="field_name_label">Имя</string>
<string name="field_name_hint">Мой сервер</string>
<string name="field_protocol_label">Протокол</string>
<string name="field_address_label">Адрес</string>
<string name="field_port_label">Порт</string>
<string name="field_cipher_label">Шифр</string>
<string name="field_password_label">Пароль</string>
<string name="field_ss_password_hint">пароль Shadowsocks</string>
<string name="field_username_optional_label">Логин (необязательно)</string>
<string name="field_password_optional_label">Пароль (необязательно)</string>
<!-- Config validation -->
<string name="validation_name_required">Укажите имя</string>
<string name="validation_host_required">Укажите адрес сервера</string>
<string name="validation_port_range">Порт должен быть 165535</string>
<string name="validation_ss_password_required">Укажите пароль Shadowsocks</string>
<!-- Apps screen -->
<string name="apps_proxying">Проксируются</string>
<string name="apps_proxying_all">Проксируются: все приложения</string>
<string name="apps_empty_hint">Пустой список = весь трафик идёт через прокси.</string>
<string name="apps_search_hint">Поиск приложений…</string>
<string name="apps_selected_count">Выбранные · %1$d</string>
<string name="apps_all">Все приложения</string>
<string name="apps_not_installed">не установлено · нажмите, чтобы убрать</string>
<!-- Sites (domain routing) screen -->
<string name="route_domain_routing">Маршрутизация по доменам</string>
<string name="sites_listed_header">Сайты из списка</string>
<string name="sites_mode_through_proxy">через прокси</string>
<string name="sites_mode_direct">напрямую</string>
<string name="sites_rest_proxy">Остальной трафик идёт через прокси.</string>
<string name="sites_rest_direct">Остальной трафик идёт напрямую.</string>
<string name="sites_apps_filter_note">С учётом фильтра из вкладки «Приложения».</string>
<string name="sites_add_domain_hint">Добавить домен, напр. example.com</string>
<string name="sites_invalid_domain">Не похоже на домен</string>
<string name="sites_search_category_hint">Найти категорию: google, netflix, ads…</string>
<string name="sites_geosite_categories">Категории geosite</string>
<string name="sites_categories">Категории</string>
<string name="sites_categories_count">Категории · %1$d</string>
<string name="sites_categories_empty">Найдите категорию выше (google, netflix, telegram…) и добавьте её.</string>
<string name="sites_domains">Домены</string>
<string name="sites_domains_count">Домены · %1$d</string>
<string name="sites_domains_empty">Добавьте домен выше — он и все его поддомены попадут под правило.</string>
<string name="sites_geosite_contains_domain">содержит домен</string>
<!-- Settings screen -->
<string name="settings_section_network">Сеть</string>
<string name="settings_dns_label">DNS-сервер</string>
<string name="settings_dns_hint">DNS-запросы идут через туннель — провайдер их не видит.</string>
<string name="settings_dns_invalid">Не похоже на IP-адрес — не сохранено</string>
<string name="settings_mtu_invalid">Допустимо 10009000 — не сохранено</string>
<string name="settings_ipv6_subtitle">Маршрутизировать IPv6-трафик через туннель</string>
<string name="settings_apply_hint">Изменения применяются при следующем подключении.</string>
<string name="settings_section_language">Язык</string>
<string name="lang_system">Система</string>
<!-- Shared search -->
<string name="search_no_results">Ничего не найдено по «%1$s»</string>
<!-- Connection journal events (service) -->
<string name="event_reconnecting">Переподключение</string>
<string name="event_still_stopping">Туннель ещё останавливается</string>
<string name="event_still_stopping_detail">подождите пару секунд и повторите</string>
<string name="event_connecting">Подключение</string>
<string name="event_interface_up">Интерфейс поднят</string>
<string name="event_socks_bridge">SOCKS5-мост</string>
<string name="event_dns_intercept">Перехват DNS (fake-IP)</string>
<string name="event_dns_intercept_detail">%1$s · только IPv4</string>
<string name="event_dns_tunnel">DNS через туннель</string>
<string name="event_tunnel_up">Туннель поднят</string>
<string name="event_proxy_confirmed">Прокси подтверждён</string>
<string name="event_proxy_confirmed_detail">ответ за %1$d мс</string>
<string name="event_network_changed">Сеть изменилась</string>
<string name="event_disconnected">Отключено</string>
<string name="event_vpn_revoked">VPN отозван системой</string>
<string name="event_error">Ошибка</string>
<!-- Routing-mode summaries (journal detail) -->
<string name="route_mode_proxy_except">через прокси, кроме списка</string>
<string name="route_mode_direct_except">напрямую, кроме списка</string>
<!-- Network names (journal detail) -->
<string name="net_mobile">мобильная сеть</string>
<string name="net_other">другая сеть</string>
<!-- Tunnel / upstream failures (journal + notification) -->
<string name="err_vpn_not_allowed">Доступ к VPN не разрешён</string>
<string name="err_apps_uninstalled">Выбранные приложения не установлены — обновите список</string>
<string name="err_tunnel_start_failed">Не удалось запустить туннель</string>
<string name="err_engine_exited">Движок туннеля неожиданно завершился (код %1$d)</string>
<string name="err_cert_failed">Сертификат прокси не прошёл проверку</string>
<string name="err_tls">Ошибка TLS с прокси</string>
<string name="err_ss_bad_key">Неверный пароль или шифр Shadowsocks</string>
<string name="err_ss_key_likely">Похоже, неверный ключ или шифр Shadowsocks</string>
<string name="err_ss_key_likely_detail">сервер разорвал соединение, не ответив</string>
<string name="err_resolve">Не удалось разрешить адрес прокси</string>
<string name="err_auth">Прокси отклонил аутентификацию</string>
<string name="err_auth_detail">проверьте логин и пароль</string>
<string name="err_no_response">Прокси не отвечает</string>
<string name="err_no_response_detail">таймаут — проверьте сервер или сеть</string>
<string name="err_connect_failed">Не удалось подключиться к прокси</string>
<string name="err_refused">Прокси отклонил запрос</string>
<string name="err_refused_detail">доступ, политика или недоступный адрес</string>
<string name="err_generic">Ошибка прокси</string>
<!-- Quick Settings tile subtitles -->
<string name="tile_online">онлайн</string>
<string name="tile_connecting">подключение</string>
<string name="tile_stopping">остановка</string>
<string name="tile_error">ошибка</string>
<!-- Byte-size units: [bytes, KB, MB, GB, TB] -->
<string-array name="byte_units">
<item>Б</item>
<item>КБ</item>
<item>МБ</item>
<item>ГБ</item>
<item>ТБ</item>
</string-array>
<plurals name="route_rules">
<item quantity="one">%d правило</item>
<item quantity="few">%d правила</item>
<item quantity="many">%d правил</item>
<item quantity="other">%d правила</item>
</plurals>
<plurals name="geosite_domains">
<item quantity="one">%d домен</item>
<item quantity="few">%d домена</item>
<item quantity="many">%d доменов</item>
<item quantity="other">%d домена</item>
</plurals>
</resources>

View file

@ -1,14 +1,203 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Base (English) strings. Russian overrides live in values-ru/strings.xml.
resourceConfigurations in build.gradle.kts is pinned to {en, ru}; a device in any
other locale falls back to this file. In-app language override: core/LocaleManager.kt.
Language-neutral tokens (IPs, ports, example.com, protocol names) stay as code literals.
-->
<resources>
<string name="app_name">vojo proxy</string>
<string name="app_name" translatable="false">vojo proxy</string>
<string name="channel_name">Состояние VPN</string>
<string name="channel_desc">Уведомление о работе прокси-туннеля</string>
<!-- Foreground-service notification channel -->
<string name="channel_name">VPN status</string>
<string name="channel_desc">Proxy tunnel status notification</string>
<string name="notif_connecting">Подключение…</string>
<string name="notif_connected">Подключено · %1$s</string>
<string name="action_disconnect">Отключить</string>
<!-- Notifications -->
<string name="notif_connecting">Connecting…</string>
<string name="notif_connected">Connected · %1$s</string>
<string name="notif_error_title">Connection interrupted</string>
<string name="error_no_server">Сервер не выбран</string>
<string name="notif_error_title">Подключение прервано</string>
<!-- Shared actions / labels -->
<string name="action_disconnect">Disconnect</string>
<string name="action_cancel">Cancel</string>
<string name="action_delete">Delete</string>
<string name="action_edit">Edit</string>
<string name="action_save">Save</string>
<string name="action_reset">Reset</string>
<string name="action_paste">paste</string>
<!-- Content descriptions -->
<string name="cd_add">Add</string>
<string name="cd_clear">Clear</string>
<string name="cd_server_menu">Server menu</string>
<string name="cd_show_password">Show password</string>
<string name="cd_hide_password">Hide password</string>
<!-- Tabs -->
<string name="tab_tunnel">Tunnel</string>
<string name="tab_servers">Servers</string>
<string name="tab_apps">Apps</string>
<string name="tab_sites">Sites</string>
<string name="tab_options">Options</string>
<!-- Connection screen -->
<string name="error_no_server">No server selected</string>
<string name="conn_choose_server">choose one on the Servers tab</string>
<string name="conn_settings_changed">Settings changed</string>
<string name="conn_reconnect">reconnect</string>
<string name="conn_change_server_short">change</string>
<string name="conn_change_server">Change server</string>
<string name="conn_connect">Connect</string>
<string name="conn_disconnecting">Disconnecting…</string>
<string name="conn_journal">Journal</string>
<string name="conn_ready">Ready to connect</string>
<string name="stat_sent">Sent</string>
<string name="stat_received">Received</string>
<string name="stat_time">Time</string>
<!-- Servers screen -->
<string name="server_add">Add server</string>
<string name="servers_empty_title">No servers</string>
<string name="servers_empty_subtitle">Add a Shadowsocks, SOCKS5 or HTTP(S) proxy to get started.</string>
<string name="server_delete_title">Delete server?</string>
<string name="server_delete_message">«%1$s» and its password will be deleted permanently.</string>
<string name="server_new">New server</string>
<string name="server_edit">Edit server</string>
<!-- Server editor — share-link import -->
<string name="import_needs_plugin">An ss:// link with a plugin (obfs/v2ray) — not supported</string>
<string name="import_no_link">No ss:// / socks5:// / http(s):// link in the clipboard</string>
<!-- Form fields -->
<string name="field_name_label">Name</string>
<string name="field_name_hint">My server</string>
<string name="field_protocol_label">Protocol</string>
<string name="field_address_label">Address</string>
<string name="field_port_label">Port</string>
<string name="field_cipher_label">Cipher</string>
<string name="field_password_label">Password</string>
<string name="field_ss_password_hint">Shadowsocks password</string>
<string name="field_username_optional_label">Username (optional)</string>
<string name="field_password_optional_label">Password (optional)</string>
<!-- Config validation -->
<string name="validation_name_required">Enter a name</string>
<string name="validation_host_required">Enter the server address</string>
<string name="validation_port_range">Port must be 165535</string>
<string name="validation_ss_password_required">Enter the Shadowsocks password</string>
<!-- Apps screen -->
<string name="apps_proxying">Proxied</string>
<string name="apps_proxying_all">Proxied: all apps</string>
<string name="apps_empty_hint">An empty list = all traffic goes through the proxy.</string>
<string name="apps_search_hint">Search apps…</string>
<string name="apps_selected_count">Selected · %1$d</string>
<string name="apps_all">All apps</string>
<string name="apps_not_installed">not installed · tap to remove</string>
<!-- Sites (domain routing) screen -->
<string name="route_domain_routing">Domain routing</string>
<string name="sites_listed_header">Listed sites</string>
<string name="sites_mode_through_proxy">through proxy</string>
<string name="sites_mode_direct">direct</string>
<string name="sites_rest_proxy">The rest of the traffic goes through the proxy.</string>
<string name="sites_rest_direct">The rest of the traffic goes direct.</string>
<string name="sites_apps_filter_note">Subject to the filter on the Apps tab.</string>
<string name="sites_add_domain_hint">Add a domain, e.g. example.com</string>
<string name="sites_invalid_domain">That doesn\'t look like a domain</string>
<string name="sites_search_category_hint">Find a category: google, netflix, ads…</string>
<string name="sites_geosite_categories">Geosite categories</string>
<string name="sites_categories">Categories</string>
<string name="sites_categories_count">Categories · %1$d</string>
<string name="sites_categories_empty">Find a category above (google, netflix, telegram…) and add it.</string>
<string name="sites_domains">Domains</string>
<string name="sites_domains_count">Domains · %1$d</string>
<string name="sites_domains_empty">Add a domain above — it and all its subdomains will be covered by the rule.</string>
<string name="sites_geosite_contains_domain">contains domain</string>
<!-- Settings screen -->
<string name="settings_section_network">Network</string>
<string name="settings_dns_label">DNS server</string>
<string name="settings_dns_hint">DNS queries go through the tunnel — your ISP can\'t see them.</string>
<string name="settings_dns_invalid">That doesn\'t look like an IP address — not saved</string>
<string name="settings_mtu_invalid">Allowed range is 10009000 — not saved</string>
<string name="settings_ipv6_subtitle">Route IPv6 traffic through the tunnel</string>
<string name="settings_apply_hint">Changes take effect on the next connection.</string>
<string name="settings_section_language">Language</string>
<string name="lang_system">System</string>
<string name="lang_russian" translatable="false">Русский</string>
<string name="lang_english" translatable="false">English</string>
<!-- Shared search -->
<string name="search_no_results">Nothing found for «%1$s»</string>
<!-- Connection journal events (service) -->
<string name="event_reconnecting">Reconnecting</string>
<string name="event_still_stopping">Tunnel is still stopping</string>
<string name="event_still_stopping_detail">wait a couple of seconds and try again</string>
<string name="event_connecting">Connecting</string>
<string name="event_interface_up">Interface up</string>
<string name="event_socks_bridge">SOCKS5 bridge</string>
<string name="event_dns_intercept">DNS interception (fake-IP)</string>
<string name="event_dns_intercept_detail">%1$s · IPv4 only</string>
<string name="event_dns_tunnel">DNS over the tunnel</string>
<string name="event_tunnel_up">Tunnel up</string>
<string name="event_proxy_confirmed">Proxy confirmed</string>
<string name="event_proxy_confirmed_detail">replied in %1$d ms</string>
<string name="event_network_changed">Network changed</string>
<string name="event_disconnected">Disconnected</string>
<string name="event_vpn_revoked">VPN revoked by the system</string>
<string name="event_error">Error</string>
<!-- Routing-mode summaries (journal detail) -->
<string name="route_mode_proxy_except">through proxy, except the list</string>
<string name="route_mode_direct_except">direct, except the list</string>
<!-- Network names (journal detail) -->
<string name="net_mobile">mobile network</string>
<string name="net_other">other network</string>
<!-- Tunnel / upstream failures (journal + notification) -->
<string name="err_vpn_not_allowed">VPN permission not granted</string>
<string name="err_apps_uninstalled">The selected apps aren\'t installed — update the list</string>
<string name="err_tunnel_start_failed">Couldn\'t start the tunnel</string>
<string name="err_engine_exited">Tunnel engine exited unexpectedly (code %1$d)</string>
<string name="err_cert_failed">Proxy certificate failed verification</string>
<string name="err_tls">TLS error with the proxy</string>
<string name="err_ss_bad_key">Wrong Shadowsocks password or cipher</string>
<string name="err_ss_key_likely">Shadowsocks key or cipher looks wrong</string>
<string name="err_ss_key_likely_detail">the server closed the connection without replying</string>
<string name="err_resolve">Couldn\'t resolve the proxy address</string>
<string name="err_auth">Proxy rejected authentication</string>
<string name="err_auth_detail">check the username and password</string>
<string name="err_no_response">Proxy isn\'t responding</string>
<string name="err_no_response_detail">timeout — check the server or the network</string>
<string name="err_connect_failed">Couldn\'t connect to the proxy</string>
<string name="err_refused">Proxy refused the request</string>
<string name="err_refused_detail">access, policy, or an unreachable address</string>
<string name="err_generic">Proxy error</string>
<!-- Quick Settings tile subtitles -->
<string name="tile_online">online</string>
<string name="tile_connecting">connecting</string>
<string name="tile_stopping">stopping</string>
<string name="tile_error">error</string>
<!-- Byte-size units: [bytes, KB, MB, GB, TB] -->
<string-array name="byte_units">
<item>B</item>
<item>KB</item>
<item>MB</item>
<item>GB</item>
<item>TB</item>
</string-array>
<plurals name="route_rules">
<item quantity="one">%d rule</item>
<item quantity="other">%d rules</item>
</plurals>
<plurals name="geosite_domains">
<item quantity="one">%d domain</item>
<item quantity="other">%d domains</item>
</plurals>
</resources>