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:
parent
7877cb49f0
commit
68aa25f280
16 changed files with 751 additions and 137 deletions
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
}
|
||||
|
|
|
|||
79
app/src/main/java/chat/vojo/proxy/core/LocaleManager.kt
Normal file
79
app/src/main/java/chat/vojo/proxy/core/LocaleManager.kt
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
198
app/src/main/res/values-ru/strings.xml
Normal file
198
app/src/main/res/values-ru/strings.xml
Normal 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">Порт должен быть 1–65535</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">Допустимо 1000–9000 — не сохранено</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>
|
||||
|
|
@ -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 1–65535</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 1000–9000 — 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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue