mirror of
https://github.com/2dust/v2rayNG.git
synced 2026-10-29 06:31:54 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa4b05503a | ||
|
|
7179f0838f | ||
|
|
0d1c57000f | ||
|
|
40b8706ccd | ||
|
|
0f961c54c8 | ||
|
|
1b78a510f2 | ||
|
|
01b4630fdd | ||
|
|
6214bc19d4 | ||
|
|
b8d13bf52c | ||
|
|
3165bcf4fe | ||
|
|
eedb287cc9 | ||
|
|
59ff87606f | ||
|
|
700740a187 | ||
|
|
bb59a71cb9 | ||
|
|
552cc2425d | ||
|
|
c8f373bd3f | ||
|
|
118cf5bc7d | ||
|
|
58ca4cfe23 | ||
|
|
e1b73f2fa8 | ||
|
|
143ab036bf | ||
|
|
a258ba8bc1 | ||
|
|
e1b190d816 | ||
|
|
4c9c9afdc9 | ||
|
|
6fe3893bfd | ||
|
|
61c4e40ec1 | ||
|
|
4a1510eb65 | ||
|
|
44a56f56bc | ||
|
|
3687436792 | ||
|
|
ffc9614417 | ||
|
|
fcacb9cfb0 |
No files matched your search
@@ -81,7 +81,7 @@ jobs:
|
||||
out-file-path: V2rayNG/app/libs/
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v6.0.1
|
||||
uses: actions/setup-java@v6.1.0
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '21'
|
||||
|
||||
+1
-1
Submodule AndroidLibXrayLite updated: ea96a7f9c3...76a6e21722.
@@ -13,8 +13,8 @@ android {
|
||||
applicationId = "com.v2ray.ang"
|
||||
minSdk = 24
|
||||
targetSdk = 37
|
||||
versionCode = 750
|
||||
versionName = "2.3.10"
|
||||
versionCode = 754
|
||||
versionName = "2.3.14"
|
||||
|
||||
val abiFilterList = (properties["ABI_FILTERS"] as? String)?.split(';')
|
||||
splits {
|
||||
@@ -138,6 +138,7 @@ android {
|
||||
"ru",
|
||||
"fa",
|
||||
"ar",
|
||||
"az",
|
||||
"bn",
|
||||
"bqi-rIR"
|
||||
)
|
||||
|
||||
@@ -240,6 +240,7 @@ object AppConfig {
|
||||
const val ROOT_TUN2SOCKS_BIN = "libhevsockstun.so"
|
||||
const val ROOT_FWD_CHAIN = "CORE_FWD" // FORWARD chain for LAN/tethering sharing
|
||||
const val ROOT_DNS_CHAIN = "CORE_DNS" // nat chain for tethered-client DNS DNAT
|
||||
const val ROOT_DNS_OUT_CHAIN = "CORE_DNS_OUT" // nat chain for device DNS DNAT (root mode)
|
||||
const val ROOT_V6_CHAIN = "CORE6_FILTER" // ip6tables filter/OUTPUT chain: blackhole native IPv6 when it isn't tunneled
|
||||
const val ROOT_V6_FWD_CHAIN = "CORE6_FWD" // ip6tables FORWARD chain: route or reject tethered clients' native IPv6
|
||||
const val ROOT_V6_PRE_CHAIN = "CORE6_PRE" // ip6tables mangle/PREROUTING chain: mark forwarded clients' IPv6 into the tun
|
||||
|
||||
@@ -413,13 +413,7 @@ object CoreConfigManager {
|
||||
"${AppConfig.TAG_BALANCER_PRE}-${resolvedOutbound.tag}"
|
||||
}
|
||||
val strategyType = BalancerStrategyType.from(resolvedOutbound.profile.policyGroupType)
|
||||
val fallbackTag = if (strategyType.supportsObservatory && resolvedOutbound.profile.policyGroupTestOutbounds != false) {
|
||||
resolvedOutbound.profile.policyGroupFallbackTag
|
||||
?.takeIf { it.isNotEmpty() && it != AppConfig.TAG_PROXY }
|
||||
// Xray excludes dead random/roundRobin candidates only when fallbackTag is set;
|
||||
// without this default, an enabled empty field creates no observatory.
|
||||
?: membersToAdd.first().tag
|
||||
} else null
|
||||
val fallbackTag = resolvePolicyGroupFallbackTag(strategyType, resolvedOutbound.profile, membersToAdd.first().tag)
|
||||
val strategy = buildBalancerStrategy(
|
||||
strategyType = strategyType,
|
||||
selector = listOf(memberTagPrefix),
|
||||
@@ -435,6 +429,27 @@ object CoreConfigManager {
|
||||
policyGroupBalancerTags[resolvedOutbound.tag] = balancerTag
|
||||
}
|
||||
|
||||
internal fun resolvePolicyGroupFallbackTag(
|
||||
strategyType: BalancerStrategyType,
|
||||
profile: ProfileItem,
|
||||
firstMemberTag: String,
|
||||
): String? {
|
||||
if (strategyType.requiresObservatory || strategyType.requiresBurstObservatory) {
|
||||
// Missing or failed probes must not let a matched rule use Xray's default outbound.
|
||||
return firstMemberTag
|
||||
}
|
||||
return if (strategyType.supportsObservatory && profile.policyGroupTestOutbounds != false) {
|
||||
profile.policyGroupFallbackTag
|
||||
?.takeIf { it.isNotEmpty() && it != AppConfig.TAG_PROXY }
|
||||
// Xray excludes dead random/roundRobin candidates only when fallbackTag is set;
|
||||
// without this default, an enabled empty field creates no observatory.
|
||||
?: firstMemberTag
|
||||
} else null
|
||||
}
|
||||
|
||||
internal fun shouldUseStandardObservatory(strategyType: BalancerStrategyType, fallbackTag: String?): Boolean =
|
||||
strategyType.requiresObservatory || (strategyType.supportsObservatory && fallbackTag != null)
|
||||
|
||||
/**
|
||||
* Trim runtime sections that are not needed for latency testing.
|
||||
*/
|
||||
@@ -1249,7 +1264,7 @@ object CoreConfigManager {
|
||||
fallbackTag = fallbackTag,
|
||||
strategy = V2rayConfig.RoutingBean.StrategyObject(type = strategyType.policyGroupType)
|
||||
)
|
||||
val observatory = if (strategyType.requiresObservatory || fallbackTag != null) {
|
||||
val observatory = if (shouldUseStandardObservatory(strategyType, fallbackTag)) {
|
||||
V2rayConfig.ObservatoryObject(
|
||||
subjectSelector = selector,
|
||||
probeUrl = probeUrl,
|
||||
|
||||
@@ -259,9 +259,7 @@ object CoreOutboundBuilder {
|
||||
?.ifEmpty { null }
|
||||
?: listOf(AppConfig.WIREGUARD_LOCAL_REMOTE_DNS)
|
||||
|
||||
val remotes = if (rawDNS.size == 1 && rawDNS[0] == "local") {
|
||||
rawDNS
|
||||
} else if (MmkvManager.decodeSettingsBool(AppConfig.PREF_IPV6_ENABLED) == true) {
|
||||
val remotes = if (MmkvManager.decodeSettingsBool(AppConfig.PREF_IPV6_ENABLED) == true) {
|
||||
rawDNS
|
||||
} else {
|
||||
val ipv4Dns = rawDNS.filter { !it.contains(":") }
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package com.v2ray.ang.dto
|
||||
|
||||
import com.v2ray.ang.dto.entities.SubscriptionUsageInfo
|
||||
|
||||
data class GroupMapItem(
|
||||
var id: String,
|
||||
var remarks: String,
|
||||
val usageInfo: SubscriptionUsageInfo? = null,
|
||||
)
|
||||
@@ -69,7 +69,8 @@ data class V2rayConfig(
|
||||
var settings: OutSettingsBean? = null,
|
||||
var streamSettings: StreamSettingsBean? = null,
|
||||
val sendThrough: String? = null,
|
||||
var mux: MuxBean? = MuxBean(false)
|
||||
var mux: MuxBean? = MuxBean(false),
|
||||
var targetStrategy: String? = null
|
||||
) {
|
||||
data class OutSettingsBean(
|
||||
/*Common */
|
||||
@@ -99,7 +100,6 @@ data class V2rayConfig(
|
||||
var reserved: List<Int>? = null,
|
||||
var mtu: Int? = null,
|
||||
var remoteDNS: List<String>? = null,
|
||||
var domainStrategy: String? = null,
|
||||
) {
|
||||
data class WireGuardBean(
|
||||
var publicKey: String = "",
|
||||
|
||||
@@ -14,5 +14,6 @@ data class SubscriptionItem(
|
||||
var allowInsecureUrl: Boolean = false,
|
||||
var userAgent: String? = null,
|
||||
var requestHeaders: String? = null,
|
||||
var usageInfo: SubscriptionUsageInfo? = null,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.v2ray.ang.dto.entities
|
||||
|
||||
/**
|
||||
* Account information a subscription panel (3x-ui, Marzban, Remnawave, ...) publishes in the
|
||||
* HTTP response headers of the subscription URL. Every field is optional because panels send
|
||||
* only the headers they have a value for.
|
||||
*
|
||||
* Persisted as JSON inside [SubscriptionItem]; only add fields, never rename or remove one.
|
||||
*/
|
||||
data class SubscriptionUsageInfo(
|
||||
/** Bytes uploaded, from the `Subscription-Userinfo` header. */
|
||||
val upload: Long = 0,
|
||||
/** Bytes downloaded, from the `Subscription-Userinfo` header. */
|
||||
val download: Long = 0,
|
||||
/** Traffic quota in bytes; 0 means unlimited or not provided. */
|
||||
val total: Long = 0,
|
||||
/** Expiry as Unix epoch seconds; 0 means no expiry or not provided. */
|
||||
val expire: Long = 0,
|
||||
/** Decoded `Profile-Title` header. */
|
||||
val title: String? = null,
|
||||
/** Validated `Support-Url` header (usually a Telegram link). */
|
||||
val supportUrl: String? = null,
|
||||
/** Validated `Profile-Web-Page-Url` header. */
|
||||
val webPageUrl: String? = null,
|
||||
) {
|
||||
/** Bytes consumed so far (upload + download). */
|
||||
val used: Long get() = upload + download
|
||||
}
|
||||
@@ -12,6 +12,7 @@ enum class Language(val code: String, val languageTag: String) {
|
||||
RUSSIAN("ru", "ru"),
|
||||
PERSIAN("fa", "fa"),
|
||||
ARABIC("ar", "ar"),
|
||||
AZERBAIJANI("az", "az"),
|
||||
BANGLA("bn", "bn"),
|
||||
BAKHTIARI("bqi-rIR", "bqi-IR");
|
||||
|
||||
@@ -48,6 +49,7 @@ enum class Language(val code: String, val languageTag: String) {
|
||||
"ru" -> RUSSIAN
|
||||
"fa" -> PERSIAN
|
||||
"ar" -> ARABIC
|
||||
"az" -> AZERBAIJANI
|
||||
"bn" -> BANGLA
|
||||
"bqi" -> BAKHTIARI
|
||||
else -> AUTO
|
||||
|
||||
@@ -23,7 +23,7 @@ object Hysteria2Fmt : FmtBase() {
|
||||
val uri = URI(Utils.fixIllegalUrl(str))
|
||||
config.remarks = Utils.decodeURIComponent(uri.fragment.orEmpty()).let { it.ifEmpty { "none" } }
|
||||
config.server = uri.idnHost
|
||||
config.serverPort = uri.port.toString()
|
||||
config.serverPort = (if (uri.port == -1) AppConfig.DEFAULT_PORT else uri.port).toString()
|
||||
config.password = uri.userInfo
|
||||
config.security = AppConfig.TLS
|
||||
config.network = NetworkType.HYSTERIA.type
|
||||
|
||||
@@ -10,6 +10,7 @@ import com.v2ray.ang.dto.UrlContentRequest
|
||||
import com.v2ray.ang.dto.entities.ProfileItem
|
||||
import com.v2ray.ang.dto.entities.SubscriptionCache
|
||||
import com.v2ray.ang.dto.entities.SubscriptionItem
|
||||
import com.v2ray.ang.dto.entities.SubscriptionUsageInfo
|
||||
import com.v2ray.ang.enums.EConfigType
|
||||
import com.v2ray.ang.extension.isNotNullEmpty
|
||||
import com.v2ray.ang.fmt.CustomFmt
|
||||
@@ -26,6 +27,7 @@ import com.v2ray.ang.util.JsonUtil
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import com.v2ray.ang.util.QRCodeDecoder
|
||||
import com.v2ray.ang.util.Utils
|
||||
import okhttp3.Headers
|
||||
import java.net.URI
|
||||
|
||||
object AngConfigManager {
|
||||
@@ -488,6 +490,17 @@ object AngConfigManager {
|
||||
val proxyUsername = SettingsManager.getSocksUsername()
|
||||
val proxyPassword = SettingsManager.getSocksPassword()
|
||||
|
||||
// Account info (traffic, expiry, support link) the panel sends in the response headers.
|
||||
var usageInfo: SubscriptionUsageInfo? = null
|
||||
val captureUsageInfo: (Headers) -> Unit = { headers ->
|
||||
usageInfo = SubscriptionInfoParser.parse(
|
||||
userInfo = headers["Subscription-Userinfo"],
|
||||
profileTitle = headers["Profile-Title"],
|
||||
supportUrl = headers["Support-Url"],
|
||||
webPageUrl = headers["Profile-Web-Page-Url"],
|
||||
)
|
||||
}
|
||||
|
||||
var configText = try {
|
||||
val httpPort = SettingsManager.getHttpPort()
|
||||
HttpUtil.getUrlContentWithUserAgent(
|
||||
@@ -499,7 +512,8 @@ object AngConfigManager {
|
||||
httpPort = httpPort,
|
||||
proxyUsername = proxyUsername,
|
||||
proxyPassword = proxyPassword
|
||||
)
|
||||
),
|
||||
captureUsageInfo
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.ANG_PACKAGE, "Update subscription: proxy not ready or other error", e)
|
||||
@@ -512,7 +526,8 @@ object AngConfigManager {
|
||||
url = url,
|
||||
userAgent = userAgent,
|
||||
requestHeaders = requestHeaders
|
||||
)
|
||||
),
|
||||
captureUsageInfo
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "Update subscription: Failed to get URL content with user agent", e)
|
||||
@@ -526,6 +541,8 @@ object AngConfigManager {
|
||||
val count = parseConfigViaSub(configText, it.guid, false)
|
||||
if (count > 0) {
|
||||
it.subscription.lastUpdated = System.currentTimeMillis()
|
||||
// Replaced on every successful update so the box disappears if the panel stops sending the headers.
|
||||
it.subscription.usageInfo = usageInfo
|
||||
MmkvManager.encodeSubscription(it.guid, it.subscription)
|
||||
LogUtil.i(AppConfig.TAG, "Subscription updated: ${it.subscription.remarks}, $count configs")
|
||||
return SubscriptionUpdateResult(
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
package com.v2ray.ang.handler
|
||||
|
||||
import com.v2ray.ang.dto.entities.SubscriptionUsageInfo
|
||||
import com.v2ray.ang.util.Utils
|
||||
import java.net.URI
|
||||
import java.net.URLDecoder
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Parses the account information subscription panels put in the response headers of the
|
||||
* subscription URL (the de-facto standard shared by 3x-ui, Marzban, Remnawave, Clash, v2rayN,
|
||||
* Hiddify and others):
|
||||
*
|
||||
* - `Subscription-Userinfo: upload=<bytes>; download=<bytes>; total=<bytes>; expire=<unix seconds>`
|
||||
* - `Profile-Title: <text>` or `base64:<base64 text>`
|
||||
* - `Support-Url: <link>` (usually a Telegram link)
|
||||
* - `Profile-Web-Page-Url: <link>` (the panel's subscription information page)
|
||||
*
|
||||
* Pure functions only, so the logic is covered by plain JVM tests.
|
||||
*/
|
||||
object SubscriptionInfoParser {
|
||||
private const val BASE64_PREFIX = "base64:"
|
||||
private const val MAX_TITLE_LENGTH = 100
|
||||
private const val MAX_URL_LENGTH = 2048
|
||||
|
||||
/** Values at or above this are epoch milliseconds, not seconds (year 5138 in seconds). */
|
||||
private const val MILLIS_THRESHOLD = 100_000_000_000L
|
||||
|
||||
private val SAFE_SCHEMES = setOf("http", "https", "tg")
|
||||
private val TELEGRAM_HOSTS = setOf("t.me", "telegram.me", "telegram.dog", "telegram.org")
|
||||
|
||||
/**
|
||||
* Builds usage info from raw header values.
|
||||
*
|
||||
* @return null when the panel sent none of the supported headers, so the UI shows nothing.
|
||||
*/
|
||||
fun parse(
|
||||
userInfo: String?,
|
||||
profileTitle: String?,
|
||||
supportUrl: String?,
|
||||
webPageUrl: String?,
|
||||
decodeBase64: (String) -> String = Utils::decode,
|
||||
): SubscriptionUsageInfo? {
|
||||
val fields = parseUserInfoFields(userInfo)
|
||||
val title = decodeTitle(profileTitle, decodeBase64)
|
||||
val support = sanitizeLink(supportUrl)
|
||||
val webPage = sanitizeLink(webPageUrl)
|
||||
|
||||
if (fields == null && title == null && support == null && webPage == null) return null
|
||||
|
||||
return SubscriptionUsageInfo(
|
||||
upload = fields?.get("upload") ?: 0,
|
||||
download = fields?.get("download") ?: 0,
|
||||
total = fields?.get("total") ?: 0,
|
||||
expire = normalizeExpire(fields?.get("expire") ?: 0),
|
||||
title = title,
|
||||
supportUrl = support,
|
||||
webPageUrl = webPage,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The subscription URL itself answers with the panel's information page when it is opened in
|
||||
* a browser (3x-ui, Marzban), so it is the fallback target of the "info page" button.
|
||||
*/
|
||||
fun withWebPageFallback(info: SubscriptionUsageInfo, subscriptionUrl: String): SubscriptionUsageInfo {
|
||||
if (info.webPageUrl != null) return info
|
||||
return info.copy(webPageUrl = sanitizeLink(subscriptionUrl))
|
||||
}
|
||||
|
||||
/** Returns [raw] when it is an http(s) or tg:// link that is safe to hand to ACTION_VIEW. */
|
||||
fun sanitizeLink(raw: String?): String? {
|
||||
val value = raw?.trim().orEmpty()
|
||||
if (value.isEmpty() || value.length > MAX_URL_LENGTH) return null
|
||||
val uri = runCatching { URI(value) }.getOrNull() ?: return null
|
||||
val scheme = uri.scheme?.lowercase(Locale.ROOT) ?: return null
|
||||
if (scheme !in SAFE_SCHEMES) return null
|
||||
if (scheme != "tg" && uri.host.isNullOrEmpty()) return null
|
||||
return value
|
||||
}
|
||||
|
||||
/** True for links that open in the Telegram app. */
|
||||
fun isTelegramLink(url: String?): Boolean {
|
||||
val uri = runCatching { URI(url.orEmpty().trim()) }.getOrNull() ?: return false
|
||||
val scheme = uri.scheme?.lowercase(Locale.ROOT) ?: return false
|
||||
if (scheme == "tg") return true
|
||||
return uri.host?.lowercase(Locale.ROOT) in TELEGRAM_HOSTS
|
||||
}
|
||||
|
||||
private fun parseUserInfoFields(header: String?): Map<String, Long>? {
|
||||
if (header.isNullOrBlank()) return null
|
||||
val result = HashMap<String, Long>()
|
||||
for (part in header.split(';')) {
|
||||
val separator = part.indexOf('=')
|
||||
if (separator <= 0) continue
|
||||
val key = part.substring(0, separator).trim().lowercase(Locale.ROOT)
|
||||
val raw = part.substring(separator + 1).trim()
|
||||
val number = raw.toLongOrNull() ?: raw.toDoubleOrNull()?.takeIf { it.isFinite() }?.toLong()
|
||||
if (number != null) result[key] = number.coerceAtLeast(0)
|
||||
}
|
||||
return result.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
private fun normalizeExpire(value: Long): Long =
|
||||
if (value >= MILLIS_THRESHOLD) value / 1000 else value
|
||||
|
||||
private fun decodeTitle(raw: String?, decodeBase64: (String) -> String): String? {
|
||||
val value = raw?.trim().orEmpty()
|
||||
if (value.isEmpty()) return null
|
||||
val decoded = when {
|
||||
value.startsWith(BASE64_PREFIX, ignoreCase = true) ->
|
||||
decodeBase64(value.substring(BASE64_PREFIX.length).trim())
|
||||
|
||||
value.contains('%') ->
|
||||
runCatching { URLDecoder.decode(value, "UTF-8") }.getOrDefault(value)
|
||||
|
||||
else -> value
|
||||
}
|
||||
return decoded.trim().take(MAX_TITLE_LENGTH).ifEmpty { null }
|
||||
}
|
||||
}
|
||||
@@ -126,7 +126,7 @@ object RootProxyManager {
|
||||
val lanShare = forceLanShare || MmkvManager.decodeSettingsBool(AppConfig.PREF_ROOT_LAN_SHARING)
|
||||
val corePid = Process.myPid()
|
||||
|
||||
val config = buildHevConfig(socksUsername, socksPassword, port, ipv6)
|
||||
val config = buildHevConfig(socksUsername, socksPassword, port, ipv6, SettingsManager.getVpnMtu(), lanShare)
|
||||
if (!writeHevConfig(cfgFile, config)) {
|
||||
return null
|
||||
}
|
||||
@@ -172,6 +172,12 @@ object RootProxyManager {
|
||||
// mark the device's own packets into the tun (Root mode only)
|
||||
if (captureDeviceTraffic) {
|
||||
append(buildMangleMarking("iptables", appUid, perAppEnabled, bypassApps, selectedUids))
|
||||
// DNS DNAT: rewrite DNS queries destined for LAN/private resolvers to a
|
||||
// public IP so the core's routing engine can resolve them via the configured
|
||||
// remote DNS (dns-out) instead of falling through to a geoip:private → direct
|
||||
// rule that sends the query back to the LAN resolver (ISP DNS poisoning /
|
||||
// CDN mis-resolution). Mirrors the tethered-client DNAT in buildLanShareSetup.
|
||||
append(buildDnsDnat("iptables"))
|
||||
}
|
||||
// optionally route hotspot / USB-tethered clients through the tun too
|
||||
if (lanShare) {
|
||||
@@ -186,6 +192,7 @@ object RootProxyManager {
|
||||
appendLine("ip -6 route replace default dev $TUN table $TABLE 2>/dev/null || true")
|
||||
appendLine("ip -6 rule add fwmark $MARK table $TABLE priority $PRIORITY 2>/dev/null || true")
|
||||
append(buildMangleMarking("ip6tables", appUid, perAppEnabled, bypassApps, selectedUids))
|
||||
append(buildDnsDnat("ip6tables"))
|
||||
} else {
|
||||
// v6 disabled: blackhole native v6 egress for the captured apps so they
|
||||
// fall back to v4-through-proxy, matching what a v4-only VpnService does.
|
||||
@@ -202,14 +209,28 @@ object RootProxyManager {
|
||||
* tun address when IPv6 is enabled; whether v6 actually flows in is decided separately by
|
||||
* the v6 route into [TABLE].
|
||||
*/
|
||||
private fun buildHevConfig(socksUsername: String?, socksPassword: String?, socksPort: Int, ipv6: Boolean): String {
|
||||
internal fun buildHevConfig(
|
||||
socksUsername: String?,
|
||||
socksPassword: String?,
|
||||
socksPort: Int,
|
||||
ipv6: Boolean,
|
||||
mtu: Int,
|
||||
lanSharing: Boolean,
|
||||
): String {
|
||||
val v4 = AppConfig.ROOT_TUN_ADDR_V4.substringBefore("/")
|
||||
val v6 = AppConfig.ROOT_TUN_ADDR_V6.substringBefore("/")
|
||||
return buildString {
|
||||
appendLine("tunnel:")
|
||||
appendLine(" name: '$TUN'")
|
||||
appendLine(" mtu: ${SettingsManager.getVpnMtu()}")
|
||||
appendLine(" mtu: $mtu")
|
||||
appendLine(" multi-queue: true")
|
||||
if (lanSharing) {
|
||||
// VNET setup or TCP-GSO EINVAL on Android can leave IPv6 packets above the LAN MTU.
|
||||
// Remove when both fallback forwarding tests pass with jumbo and TSO enabled.
|
||||
appendLine(" offload:")
|
||||
appendLine(" jumbo: false")
|
||||
if (ipv6) appendLine(" tso: false")
|
||||
}
|
||||
appendLine(" ipv4: '$v4'")
|
||||
if (ipv6) appendLine(" ipv6: '$v6'")
|
||||
appendLine("socks5:")
|
||||
@@ -301,6 +322,40 @@ object RootProxyManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DNAT DNS queries (port 53) destined for LAN/private resolvers to a public IP.
|
||||
*
|
||||
* In root mode, DNS is marked into the tun and reaches the core's SOCKS inbound as a
|
||||
* UDP relay to the original resolver address (e.g. 192.168.1.1:53). The core's routing
|
||||
* rule `{inboundTag=socks, port=53, outboundTag=dns-out}` should intercept it, but when
|
||||
* the resolver is a private IP the query also matches a user's `geoip:private → direct`
|
||||
* routing rule, which sends it straight to the LAN resolver (ISP DNS) — causing DNS
|
||||
* poisoning, CDN mis-resolution, and the "some sites work, some don't" symptom.
|
||||
*
|
||||
* Rewriting the destination to a public IP (the configured remote DNS or 1.1.1.1
|
||||
* fallback) prevents `geoip:private` from matching, so `dns-out` can resolve via the
|
||||
* configured remote DNS servers (e.g. Cloudflare DoH) through the proxy.
|
||||
*
|
||||
* Uses the same resolver-selection logic as [buildLanShareSetup].
|
||||
*/
|
||||
private fun buildDnsDnat(cmd: String): String {
|
||||
val dnsChain = AppConfig.ROOT_DNS_OUT_CHAIN
|
||||
val dns = SettingsManager.getRemoteDnsServers()
|
||||
.firstOrNull { Utils.isPureIpAddress(it) && !it.contains(":") }
|
||||
?: AppConfig.ROOT_LAN_DNS
|
||||
val lanCidrs = if (cmd == "ip6tables") bypassCidrsV6 else bypassCidrs
|
||||
return buildString {
|
||||
appendLine("$cmd -t nat -N $dnsChain 2>/dev/null || true")
|
||||
appendLine("$cmd -t nat -F $dnsChain")
|
||||
lanCidrs.forEach {
|
||||
appendLine("$cmd -t nat -A $dnsChain -d $it -p udp --dport 53 -j DNAT --to $dns")
|
||||
appendLine("$cmd -t nat -A $dnsChain -d $it -p tcp --dport 53 -j DNAT --to $dns")
|
||||
}
|
||||
appendLine("$cmd -t nat -D OUTPUT -j $dnsChain 2>/dev/null || true")
|
||||
appendLine("$cmd -t nat -A OUTPUT -j $dnsChain")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Blackhole native IPv6 egress for the captured app population when IPv6 is NOT routed
|
||||
* into the tun. A v4-only VpnService has no v6 route, so the kernel rejects apps' v6 and
|
||||
@@ -477,6 +532,10 @@ object RootProxyManager {
|
||||
appendLine("iptables -t nat -D PREROUTING -j ${AppConfig.ROOT_DNS_CHAIN} 2>/dev/null || true")
|
||||
appendLine("iptables -t nat -F ${AppConfig.ROOT_DNS_CHAIN} 2>/dev/null || true")
|
||||
appendLine("iptables -t nat -X ${AppConfig.ROOT_DNS_CHAIN} 2>/dev/null || true")
|
||||
// device DNS DNAT chain (root mode)
|
||||
appendLine("iptables -t nat -D OUTPUT -j ${AppConfig.ROOT_DNS_OUT_CHAIN} 2>/dev/null || true")
|
||||
appendLine("iptables -t nat -F ${AppConfig.ROOT_DNS_OUT_CHAIN} 2>/dev/null || true")
|
||||
appendLine("iptables -t nat -X ${AppConfig.ROOT_DNS_OUT_CHAIN} 2>/dev/null || true")
|
||||
// IPv6 LAN-sharing chains (forward accept/reject + forwarded-client marking)
|
||||
appendLine("ip6tables -D FORWARD -j ${AppConfig.ROOT_V6_FWD_CHAIN} 2>/dev/null || true")
|
||||
appendLine("ip6tables -F ${AppConfig.ROOT_V6_FWD_CHAIN} 2>/dev/null || true")
|
||||
|
||||
@@ -24,6 +24,7 @@ import com.v2ray.ang.handler.AngConfigManager
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.handler.SettingsChangeManager
|
||||
import com.v2ray.ang.handler.SettingsManager
|
||||
import com.v2ray.ang.handler.SubscriptionInfoParser
|
||||
import com.v2ray.ang.ui.AboutActivity
|
||||
import com.v2ray.ang.ui.backup.BackupActivity
|
||||
import com.v2ray.ang.ui.base.HelperBaseComponentActivity
|
||||
@@ -116,6 +117,7 @@ class MainActivity : HelperBaseComponentActivity() {
|
||||
is MainAction.EditServer -> editServer(action.guid, action.profile)
|
||||
is MainAction.ShareClipboard -> shareToClipboard(action.guid)
|
||||
is MainAction.ShareFullContent -> shareFullContentAsync(action.guid)
|
||||
is MainAction.OpenSubscriptionLink -> openSubscriptionLink(action.url)
|
||||
else -> mainViewModel.onAction(action)
|
||||
}
|
||||
},
|
||||
@@ -136,6 +138,12 @@ class MainActivity : HelperBaseComponentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun openSubscriptionLink(url: String) {
|
||||
// Links come from a remote panel, so only http(s) and tg:// are ever handed to ACTION_VIEW.
|
||||
val safeUrl = SubscriptionInfoParser.sanitizeLink(url) ?: return
|
||||
Utils.openUri(this, safeUrl)
|
||||
}
|
||||
|
||||
private fun navigateTo(destination: MainDestination) {
|
||||
val intent = when (destination) {
|
||||
MainDestination.Subscriptions -> Intent(this, SubSettingActivity::class.java)
|
||||
|
||||
@@ -45,6 +45,7 @@ sealed interface MainAction {
|
||||
data object RemoveInvalidServers : MainAction
|
||||
data object SortByTestResults : MainAction
|
||||
data object UpdateSubscriptions : MainAction
|
||||
data class UpdateGroupSubscription(val groupId: String) : MainAction
|
||||
data object ExportAll : MainAction
|
||||
|
||||
data object ImportQRcode : MainAction
|
||||
@@ -63,6 +64,7 @@ sealed interface MainAction {
|
||||
data class ShareClipboard(val guid: String) : MainAction
|
||||
data class ShareFullContent(val guid: String) : MainAction
|
||||
data object DismissQRCodeDialog : MainAction
|
||||
data class OpenSubscriptionLink(val url: String) : MainAction
|
||||
|
||||
data class ImportBatchConfig(val configText: String) : MainAction
|
||||
|
||||
|
||||
@@ -1,26 +1,68 @@
|
||||
package com.v2ray.ang.ui.main
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.relocation.BringIntoViewRequester
|
||||
import androidx.compose.foundation.relocation.bringIntoViewRequester
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.selection.selectable
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ScrollableTabRow
|
||||
import androidx.compose.material3.Tab
|
||||
import androidx.compose.material3.TabRowDefaults
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
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.geometry.Rect
|
||||
import androidx.compose.ui.graphics.luminance
|
||||
import androidx.compose.ui.layout.Layout
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Constraints
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.material3.TabRowDefaults.tabIndicatorOffset
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.v2ray.ang.dto.GroupMapItem
|
||||
import com.v2ray.ang.dto.entities.ServersCache
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
private val TAB_BAR_PADDING = 3.dp
|
||||
private val TAB_SPACING = 2.dp
|
||||
private val TAB_HEIGHT = 30.dp
|
||||
private val SELECTED_TAB_MARGIN = 12.dp
|
||||
|
||||
/**
|
||||
* Subscription group tabs: a floating rounded bar whose selected tab is a tinted pill and whose
|
||||
* server count is a small badge.
|
||||
*
|
||||
* While all tabs fit, they share the bar width equally (like Telegram's folder tabs); once they
|
||||
* no longer fit, they keep their natural width and the bar scrolls horizontally, keeping the
|
||||
* selected tab in view.
|
||||
*/
|
||||
@Composable
|
||||
fun GroupTabBar(
|
||||
groups: List<GroupMapItem>,
|
||||
@@ -30,33 +72,91 @@ fun GroupTabBar(
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val selectedIndex = selectedTabIndex.coerceIn(0, groups.lastIndex)
|
||||
ScrollableTabRow(
|
||||
selectedTabIndex = selectedIndex,
|
||||
val colors = MaterialTheme.colorScheme
|
||||
// The plain surface color equals the screen background in dark mode, so the bar needs its own tone there.
|
||||
val dark = colors.surface.luminance() < 0.5f
|
||||
|
||||
Surface(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.95f)),
|
||||
edgePadding = 16.dp,
|
||||
indicator = { tabPositions ->
|
||||
TabRowDefaults.SecondaryIndicator(
|
||||
modifier = Modifier.tabIndicatorOffset(tabPositions[selectedIndex]),
|
||||
color = MaterialTheme.colorScheme.secondary
|
||||
)
|
||||
}
|
||||
.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||
shape = CircleShape,
|
||||
color = if (dark) colors.surfaceContainerHigh else colors.surface,
|
||||
border = BorderStroke(0.5.dp, colors.outlineVariant.copy(alpha = if (dark) 0.6f else 0.35f)),
|
||||
shadowElevation = 3.dp
|
||||
) {
|
||||
groups.forEachIndexed { index, group ->
|
||||
val serverFlow = remember(group.id, mainViewModel) {
|
||||
mainViewModel.serversForGroup(group.id)
|
||||
BoxWithConstraints {
|
||||
val barPaddingPx = with(LocalDensity.current) { TAB_BAR_PADDING.roundToPx() }
|
||||
val availablePx = constraints.maxWidth - 2 * barPaddingPx
|
||||
Row(
|
||||
Modifier
|
||||
.horizontalScroll(rememberScrollState())
|
||||
.padding(TAB_BAR_PADDING)
|
||||
) {
|
||||
EqualOrNaturalTabsLayout(availablePx = availablePx) {
|
||||
groups.forEachIndexed { index, group ->
|
||||
key(group.id) {
|
||||
val serverFlow = remember(group.id, mainViewModel) {
|
||||
mainViewModel.serversForGroup(group.id)
|
||||
}
|
||||
GroupTabItem(
|
||||
group = group,
|
||||
selected = index == selectedIndex,
|
||||
serverFlow = serverFlow,
|
||||
onClick = { onTabClick(index) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
GroupTabItem(
|
||||
group = group,
|
||||
selected = index == selectedIndex,
|
||||
serverFlow = serverFlow,
|
||||
onClick = { onTabClick(index) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lays the tabs out in a row. If every tab fits in an equal share of [availablePx] they all get
|
||||
* that share; otherwise, if they still fit together, the spare space is spread evenly; otherwise
|
||||
* they keep their natural width (the parent scrolls).
|
||||
*/
|
||||
@Composable
|
||||
private fun EqualOrNaturalTabsLayout(
|
||||
availablePx: Int,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
Layout(content = content) { measurables, _ ->
|
||||
if (measurables.isEmpty()) return@Layout layout(0, 0) {}
|
||||
|
||||
val spacing = TAB_SPACING.roundToPx()
|
||||
val tabHeight = TAB_HEIGHT.roundToPx()
|
||||
val count = measurables.size
|
||||
val gaps = spacing * (count - 1)
|
||||
val natural = measurables.map { it.maxIntrinsicWidth(tabHeight) }
|
||||
val equalShare = (availablePx - gaps) / count
|
||||
|
||||
val widths = when {
|
||||
availablePx <= 0 -> natural
|
||||
natural.all { it <= equalShare } -> List(count) { equalShare }
|
||||
natural.sum() + gaps <= availablePx -> {
|
||||
val extra = (availablePx - gaps - natural.sum()) / count
|
||||
natural.map { it + extra }
|
||||
}
|
||||
else -> natural
|
||||
}
|
||||
|
||||
val placeables = measurables.mapIndexed { i, measurable ->
|
||||
measurable.measure(Constraints.fixed(widths[i], tabHeight))
|
||||
}
|
||||
layout(widths.sum() + gaps, tabHeight) {
|
||||
var x = 0
|
||||
placeables.forEach { placeable ->
|
||||
placeable.placeRelative(x, 0)
|
||||
x += placeable.width + spacing
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun GroupTabItem(
|
||||
group: GroupMapItem,
|
||||
@@ -65,25 +165,61 @@ private fun GroupTabItem(
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val servers by serverFlow.collectAsStateWithLifecycle()
|
||||
val text = if (group.id.isEmpty()) {
|
||||
group.remarks
|
||||
} else {
|
||||
"${group.remarks} (${servers.size})"
|
||||
}
|
||||
// The default group (empty id) never showed a count.
|
||||
val count = if (group.id.isEmpty()) 0 else servers.size
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val bringIntoView = remember { BringIntoViewRequester() }
|
||||
var size by remember { mutableStateOf(IntSize.Zero) }
|
||||
val marginPx = with(LocalDensity.current) { SELECTED_TAB_MARGIN.toPx() }
|
||||
|
||||
Tab(
|
||||
selected = selected,
|
||||
onClick = onClick,
|
||||
modifier = Modifier
|
||||
.widthIn(min = 56.dp)
|
||||
.heightIn(min = 48.dp),
|
||||
text = {
|
||||
Text(
|
||||
text = text,
|
||||
maxLines = 1,
|
||||
softWrap = false,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
// Bring the tab in with some room around it so the pill never ends up flush against the bar's
|
||||
// rounded edge; at the first and last tab the scroll stops at the bar's own inner padding.
|
||||
LaunchedEffect(selected, size) {
|
||||
if (selected && size != IntSize.Zero) {
|
||||
bringIntoView.bringIntoView(
|
||||
Rect(-marginPx, 0f, size.width + marginPx, size.height.toFloat())
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// One selectable node: name, count and selected state are announced together.
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.bringIntoViewRequester(bringIntoView)
|
||||
.onSizeChanged { size = it }
|
||||
.clip(CircleShape)
|
||||
.background(if (selected) colors.primary.copy(alpha = 0.12f) else Color.Transparent)
|
||||
.selectable(selected = selected, role = Role.Tab, onClick = onClick)
|
||||
.padding(horizontal = 12.dp),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = group.remarks,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = if (selected) colors.primary else colors.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
softWrap = false,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
if (count > 0) {
|
||||
Spacer(Modifier.width(3.dp))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.defaultMinSize(minWidth = 16.dp, minHeight = 16.dp)
|
||||
.clip(CircleShape)
|
||||
.background(if (selected) colors.primary else colors.onSurfaceVariant.copy(alpha = 0.55f))
|
||||
.padding(horizontal = 4.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = count.toString(),
|
||||
style = MaterialTheme.typography.labelSmall.copy(fontSize = 10.sp, lineHeight = 12.sp),
|
||||
color = if (selected) colors.onPrimary else colors.surface,
|
||||
maxLines = 1
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -245,6 +245,9 @@ fun MainScreen(
|
||||
shareTarget = Triple(guid, profile, true)
|
||||
},
|
||||
onRemoveServer = removeServer,
|
||||
usageInfo = group.usageInfo,
|
||||
groupTitle = group.remarks,
|
||||
onOpenLink = { url -> onAction(MainAction.OpenSubscriptionLink(url)) },
|
||||
contentPadding = PaddingValues(
|
||||
start = 0.dp,
|
||||
top = 0.dp,
|
||||
|
||||
@@ -9,6 +9,8 @@ import androidx.compose.foundation.layout.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.calculateEndPadding
|
||||
import androidx.compose.foundation.layout.calculateStartPadding
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
@@ -32,10 +34,15 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
@@ -49,6 +56,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.dto.LocateTarget
|
||||
import com.v2ray.ang.dto.entities.ProfileItem
|
||||
import com.v2ray.ang.dto.entities.SubscriptionUsageInfo
|
||||
import com.v2ray.ang.ui.compose.ItemDivider
|
||||
import com.v2ray.ang.ui.compose.ReorderableGridItem
|
||||
import com.v2ray.ang.ui.compose.ReorderableListItem
|
||||
@@ -76,6 +84,9 @@ fun GroupPagerPage(
|
||||
onShareServer: (String, ProfileItem) -> Unit,
|
||||
onMoreServer: (String, ProfileItem) -> Unit,
|
||||
onRemoveServer: (String, String) -> Unit,
|
||||
usageInfo: SubscriptionUsageInfo?,
|
||||
groupTitle: String,
|
||||
onOpenLink: (String) -> Unit,
|
||||
contentPadding: PaddingValues
|
||||
) {
|
||||
val groupStateFlow = remember(groupId) {
|
||||
@@ -98,22 +109,50 @@ fun GroupPagerPage(
|
||||
remove = onRemoveServer,
|
||||
)
|
||||
}
|
||||
ServerListPage(
|
||||
rows = groupState.rows,
|
||||
selectedGuid = selectedGuid,
|
||||
locateTarget = locateTarget?.takeIf { it.groupId == groupId },
|
||||
canReorder = canReorder,
|
||||
doubleColumnDisplay = doubleColumnDisplay,
|
||||
groupId = groupId,
|
||||
lazyListStates = lazyListStates,
|
||||
lazyGridStates = lazyGridStates,
|
||||
actions = actions,
|
||||
onLocateHandled = { mainViewModel.onAction(MainAction.LocateHandled) },
|
||||
onMoveServer = { fromIndex, toIndex ->
|
||||
mainViewModel.moveServer(groupId, fromIndex, toIndex)
|
||||
},
|
||||
contentPadding = contentPadding
|
||||
)
|
||||
// The box floats over the list (the list scrolls underneath it), so the list is padded by the box height.
|
||||
val density = LocalDensity.current
|
||||
val layoutDirection = LocalLayoutDirection.current
|
||||
var infoHeightPx by remember { mutableIntStateOf(0) }
|
||||
val listPadding = if (usageInfo == null) {
|
||||
contentPadding
|
||||
} else {
|
||||
PaddingValues(
|
||||
start = contentPadding.calculateStartPadding(layoutDirection),
|
||||
top = contentPadding.calculateTopPadding() + with(density) { infoHeightPx.toDp() },
|
||||
end = contentPadding.calculateEndPadding(layoutDirection),
|
||||
bottom = contentPadding.calculateBottomPadding()
|
||||
)
|
||||
}
|
||||
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
ServerListPage(
|
||||
rows = groupState.rows,
|
||||
selectedGuid = selectedGuid,
|
||||
locateTarget = locateTarget?.takeIf { it.groupId == groupId },
|
||||
canReorder = canReorder,
|
||||
doubleColumnDisplay = doubleColumnDisplay,
|
||||
groupId = groupId,
|
||||
lazyListStates = lazyListStates,
|
||||
lazyGridStates = lazyGridStates,
|
||||
actions = actions,
|
||||
onLocateHandled = { mainViewModel.onAction(MainAction.LocateHandled) },
|
||||
onMoveServer = { fromIndex, toIndex ->
|
||||
mainViewModel.moveServer(groupId, fromIndex, toIndex)
|
||||
},
|
||||
contentPadding = listPadding
|
||||
)
|
||||
if (usageInfo != null) {
|
||||
SubscriptionInfoCard(
|
||||
info = usageInfo,
|
||||
fallbackTitle = groupTitle,
|
||||
onOpenLink = onOpenLink,
|
||||
onRefresh = { mainViewModel.onAction(MainAction.UpdateGroupSubscription(groupId)) },
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.onSizeChanged { infoHeightPx = it.height }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class ServerRowActions(
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
package com.v2ray.ang.ui.main
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.dto.entities.SubscriptionUsageInfo
|
||||
import com.v2ray.ang.extension.toTrafficString
|
||||
import com.v2ray.ang.handler.SubscriptionInfoParser
|
||||
import com.v2ray.ang.util.Utils
|
||||
|
||||
private const val MILLIS_PER_SECOND = 1000L
|
||||
private const val EXPIRY_DATE_PATTERN = "yyyy-MM-dd"
|
||||
private const val CARD_ALPHA = 0.97f
|
||||
|
||||
/** Locale-neutral values the subscription information box renders. */
|
||||
internal data class SubscriptionInfoUiModel(
|
||||
val title: String?,
|
||||
val usedBytes: Long,
|
||||
/** Traffic quota in bytes; 0 means unlimited. */
|
||||
val totalBytes: Long,
|
||||
/** Used share of the quota in 0..1; null when the quota is unlimited. */
|
||||
val trafficFraction: Float?,
|
||||
/** Expiry in epoch milliseconds; null when the subscription never expires. */
|
||||
val expireMillis: Long?,
|
||||
val expired: Boolean,
|
||||
val supportUrl: String?,
|
||||
val webPageUrl: String?,
|
||||
) {
|
||||
val trafficLow: Boolean get() = trafficFraction != null && trafficFraction >= TRAFFIC_WARNING_FRACTION
|
||||
|
||||
companion object {
|
||||
const val TRAFFIC_WARNING_FRACTION = 0.9f
|
||||
}
|
||||
}
|
||||
|
||||
internal fun buildSubscriptionInfoUiModel(
|
||||
info: SubscriptionUsageInfo,
|
||||
nowMillis: Long,
|
||||
): SubscriptionInfoUiModel {
|
||||
val unlimited = info.total <= 0L
|
||||
val expireMillis = info.expire.takeIf { it > 0L }?.let { it * MILLIS_PER_SECOND }
|
||||
return SubscriptionInfoUiModel(
|
||||
title = info.title,
|
||||
usedBytes = info.used,
|
||||
totalBytes = if (unlimited) 0L else info.total,
|
||||
trafficFraction = if (unlimited) null else (info.used.toDouble() / info.total).toFloat().coerceIn(0f, 1f),
|
||||
expireMillis = expireMillis,
|
||||
expired = expireMillis != null && expireMillis <= nowMillis,
|
||||
supportUrl = info.supportUrl,
|
||||
webPageUrl = info.webPageUrl,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fixed box on top of a subscription group showing the account information published by the
|
||||
* subscription panel: traffic, expiry date and shortcuts (support link, panel page, update).
|
||||
*/
|
||||
@Composable
|
||||
internal fun SubscriptionInfoCard(
|
||||
info: SubscriptionUsageInfo,
|
||||
fallbackTitle: String,
|
||||
onOpenLink: (String) -> Unit,
|
||||
onRefresh: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val model = remember(info) { buildSubscriptionInfoUiModel(info, System.currentTimeMillis()) }
|
||||
|
||||
Surface(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
// Almost opaque: the list scrolls underneath without showing through readably.
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = CARD_ALPHA),
|
||||
border = BorderStroke(0.5.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)),
|
||||
) {
|
||||
Column(Modifier.padding(start = 16.dp, end = 8.dp, top = 2.dp, bottom = 10.dp)) {
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = model.title ?: fallbackTitle,
|
||||
modifier = Modifier.weight(1f),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
model.supportUrl?.let { url ->
|
||||
CardIconButton(
|
||||
iconRes = if (SubscriptionInfoParser.isTelegramLink(url)) {
|
||||
R.drawable.ic_telegram_24dp
|
||||
} else {
|
||||
R.drawable.ic_feedback_24dp
|
||||
},
|
||||
contentDescription = stringResource(R.string.sub_info_open_support),
|
||||
onClick = { onOpenLink(url) },
|
||||
)
|
||||
}
|
||||
model.webPageUrl?.let { url ->
|
||||
CardIconButton(
|
||||
iconRes = R.drawable.ic_about_24dp,
|
||||
contentDescription = stringResource(R.string.sub_info_open_page),
|
||||
onClick = { onOpenLink(url) },
|
||||
)
|
||||
}
|
||||
CardIconButton(
|
||||
iconRes = R.drawable.ic_refresh_24dp,
|
||||
contentDescription = stringResource(R.string.title_sub_update),
|
||||
onClick = onRefresh,
|
||||
)
|
||||
}
|
||||
|
||||
// One semantics node for the read-only figures so TalkBack reads them in one go.
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(end = 8.dp)
|
||||
.semantics(mergeDescendants = true) {}
|
||||
) {
|
||||
StatsRow(model)
|
||||
model.trafficFraction?.let { fraction ->
|
||||
Spacer(Modifier.height(4.dp))
|
||||
LinearProgressIndicator(
|
||||
progress = { fraction },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(4.dp)
|
||||
.clip(RoundedCornerShape(2.dp))
|
||||
.clearAndSetSemantics {},
|
||||
color = if (model.trafficLow) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary,
|
||||
trackColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CardIconButton(iconRes: Int, contentDescription: String, onClick: () -> Unit) {
|
||||
IconButton(onClick = onClick, modifier = Modifier.size(32.dp)) {
|
||||
Icon(painterResource(iconRes), contentDescription, Modifier.size(20.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatsRow(model: SubscriptionInfoUiModel) {
|
||||
val totalText = if (model.totalBytes > 0L) {
|
||||
model.totalBytes.toTrafficString()
|
||||
} else {
|
||||
stringResource(R.string.sub_info_unlimited)
|
||||
}
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(
|
||||
text = stringResource(R.string.sub_info_traffic, model.usedBytes.toTrafficString(), totalText),
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = if (model.trafficLow) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
model.expireMillis?.let { expireMillis ->
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Text(
|
||||
text = Utils.formatTimestamp(expireMillis, EXPIRY_DATE_PATTERN),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = if (model.expired) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import com.v2ray.ang.extension.delay
|
||||
import com.v2ray.ang.extension.isComplexType
|
||||
import com.v2ray.ang.extension.matchesPattern
|
||||
import com.v2ray.ang.extension.moveItem
|
||||
import com.v2ray.ang.handler.SubscriptionInfoParser
|
||||
import com.v2ray.ang.ui.base.BaseViewModel
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import kotlinx.coroutines.CancellationException
|
||||
@@ -283,7 +284,8 @@ class MainViewModel(
|
||||
MainAction.RemoveDuplicateServers -> removeDuplicateServerAsync()
|
||||
MainAction.RemoveInvalidServers -> removeInvalidServerAsync()
|
||||
MainAction.SortByTestResults -> sortByTestResultsAsync()
|
||||
MainAction.UpdateSubscriptions -> importConfigViaSub()
|
||||
MainAction.UpdateSubscriptions -> importConfigViaSub(uiState.value.selectedGroupId)
|
||||
is MainAction.UpdateGroupSubscription -> importConfigViaSub(action.groupId)
|
||||
MainAction.ExportAll -> exportAllAsync()
|
||||
is MainAction.SelectGroup -> subscriptionIdChanged(action.groupId)
|
||||
is MainAction.SelectServer -> updateSelectedGuid(action.guid)
|
||||
@@ -310,7 +312,8 @@ class MainViewModel(
|
||||
MainAction.LocateSelectedServer,
|
||||
is MainAction.EditServer,
|
||||
is MainAction.ShareClipboard,
|
||||
is MainAction.ShareFullContent -> {
|
||||
is MainAction.ShareFullContent,
|
||||
is MainAction.OpenSubscriptionLink -> {
|
||||
// Handled by Activity via its onAction lambda
|
||||
}
|
||||
}
|
||||
@@ -453,7 +456,13 @@ class MainViewModel(
|
||||
cacheMutex.withLock { groupDataCache.clear() }
|
||||
}
|
||||
val groups = dataSource.getSubscriptions().map {
|
||||
GroupMapItem(id = it.guid, remarks = it.subscription.remarks)
|
||||
GroupMapItem(
|
||||
id = it.guid,
|
||||
remarks = it.subscription.remarks,
|
||||
usageInfo = it.subscription.usageInfo?.let { info ->
|
||||
SubscriptionInfoParser.withWebPageFallback(info, it.subscription.url)
|
||||
},
|
||||
)
|
||||
}
|
||||
val selectedGroup = resolveSelectedGroup(groups)
|
||||
val validIds = groups.mapTo(HashSet()) { it.id }
|
||||
@@ -532,8 +541,7 @@ class MainViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
private fun importConfigViaSub() {
|
||||
val subId = uiState.value.selectedGroupId
|
||||
private fun importConfigViaSub(subId: String) {
|
||||
launchLoading {
|
||||
withContext(ioDispatcher) {
|
||||
try {
|
||||
|
||||
@@ -121,6 +121,9 @@ class SubscriptionsViewModel(application: Application) : BaseViewModel(applicati
|
||||
else ->
|
||||
toast(getString(R.string.title_update_subscription_result, result.configCount, result.successCount, result.failureCount, result.skipCount))
|
||||
}
|
||||
if (result.configCount > 0) {
|
||||
SettingsChangeManager.makeSetupGroupTab()
|
||||
}
|
||||
reload()
|
||||
} catch (cancelled: CancellationException) {
|
||||
throw cancelled
|
||||
|
||||
@@ -5,6 +5,7 @@ import com.v2ray.ang.AppConfig.LOOPBACK
|
||||
import com.v2ray.ang.BuildConfig
|
||||
import com.v2ray.ang.dto.UrlContentRequest
|
||||
import okhttp3.Credentials
|
||||
import okhttp3.Headers
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.io.File
|
||||
@@ -139,11 +140,15 @@ object HttpUtil {
|
||||
* @param url The URL to fetch content from.
|
||||
* @param timeout The timeout value in milliseconds.
|
||||
* @param httpPort The HTTP port to use.
|
||||
* @param onResponseHeaders Receives the headers of the successful response that produced the returned content.
|
||||
* @return The content of the URL as a string.
|
||||
* @throws IOException If an I/O error occurs.
|
||||
*/
|
||||
@Throws(IOException::class)
|
||||
fun getUrlContentWithUserAgent(request: UrlContentRequest): String {
|
||||
fun getUrlContentWithUserAgent(
|
||||
request: UrlContentRequest,
|
||||
onResponseHeaders: ((Headers) -> Unit)? = null
|
||||
): String {
|
||||
var currentUrl = request.url
|
||||
var redirects = 0
|
||||
val maxRedirects = 3
|
||||
@@ -193,6 +198,7 @@ object HttpUtil {
|
||||
}
|
||||
|
||||
response.isSuccessful -> {
|
||||
onResponseHeaders?.invoke(response.headers)
|
||||
return response.body?.string() ?: ""
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import com.google.gson.JsonParser
|
||||
import com.google.gson.JsonPrimitive
|
||||
import com.google.gson.JsonSerializationContext
|
||||
import com.google.gson.JsonSerializer
|
||||
import com.google.gson.ToNumberPolicy
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import com.v2ray.ang.AppConfig
|
||||
import java.lang.reflect.Type
|
||||
@@ -64,12 +65,15 @@ object JsonUtil {
|
||||
val gsonPre = GsonBuilder()
|
||||
.setPrettyPrinting()
|
||||
.disableHtmlEscaping()
|
||||
.setObjectToNumberStrategy(ToNumberPolicy.LONG_OR_DOUBLE)
|
||||
.registerTypeAdapter( // custom serializer is needed here since JSON by default parse number as Double, core will fail to start
|
||||
object : TypeToken<Double>() {}.type,
|
||||
JsonSerializer { src: Double?, _: Type?, _: JsonSerializationContext? ->
|
||||
JsonPrimitive(
|
||||
src?.toInt()
|
||||
)
|
||||
when (src) {
|
||||
null -> null
|
||||
src.toLong().toDouble() -> JsonPrimitive(src.toLong())
|
||||
else -> JsonPrimitive(src)
|
||||
}
|
||||
}
|
||||
)
|
||||
.create()
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M17.65,6.35C16.2,4.9 14.21,4 12,4c-4.42,0 -7.99,3.58 -7.99,8s3.57,8 7.99,8c3.73,0 6.84,-2.55 7.73,-6h-2.08c-0.82,2.33 -3.04,4 -5.65,4 -3.31,0 -6,-2.69 -6,-6s2.69,-6 6,-6c1.66,0 3.14,0.69 4.22,1.78L13,11h7V4l-2.35,2.35z" />
|
||||
</vector>
|
||||
@@ -1,10 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<corners android:radius="20dp" />
|
||||
<padding
|
||||
android:bottom="0dp"
|
||||
android:left="0dp"
|
||||
android:right="0dp"
|
||||
android:top="0dp" />
|
||||
<solid android:color="#f97910" />
|
||||
</shape>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<corners android:radius="23dp" />
|
||||
<gradient
|
||||
android:angle="135"
|
||||
android:endColor="#EA580C"
|
||||
android:startColor="#FB923C"
|
||||
android:type="linear" />
|
||||
<stroke
|
||||
android:width="1.5dp"
|
||||
android:color="#FFEDD5" />
|
||||
</shape>
|
||||
@@ -1,10 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<corners android:radius="20dp" />
|
||||
<padding
|
||||
android:bottom="0dp"
|
||||
android:left="0dp"
|
||||
android:right="0dp"
|
||||
android:top="0dp" />
|
||||
<solid android:color="#9C9C9C" />
|
||||
</shape>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<corners android:radius="23dp" />
|
||||
<solid android:color="#A0374151" />
|
||||
<stroke
|
||||
android:width="1.5dp"
|
||||
android:color="#40FFFFFF" />
|
||||
</shape>
|
||||
@@ -5,27 +5,38 @@
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:gravity="center"
|
||||
android:orientation="vertical">
|
||||
android:orientation="vertical"
|
||||
android:padding="2dp">
|
||||
|
||||
<LinearLayout
|
||||
<FrameLayout
|
||||
android:id="@+id/layout_background"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center">
|
||||
android:layout_width="46dp"
|
||||
android:layout_height="46dp"
|
||||
android:layout_gravity="center"
|
||||
android:background="@drawable/ic_rounded_corner_inactive">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/image_switch"
|
||||
android:layout_width="45dp"
|
||||
android:layout_height="45dp"
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="24dp"
|
||||
android:layout_gravity="center"
|
||||
android:importantForAccessibility="no"
|
||||
android:padding="@dimen/padding_spacing_dp16"
|
||||
android:src="@drawable/ic_stat_name" />
|
||||
</LinearLayout>
|
||||
android:scaleType="fitCenter"
|
||||
android:src="@drawable/ic_play_24dp" />
|
||||
</FrameLayout>
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="1"
|
||||
android:shadowColor="#C0000000"
|
||||
android:shadowDx="0"
|
||||
android:shadowDy="1"
|
||||
android:shadowRadius="4"
|
||||
android:text="@string/app_name"
|
||||
android:textAppearance="@android:style/TextAppearance.Small"
|
||||
android:textColor="@android:color/white" />
|
||||
android:textColor="#FFFFFF"
|
||||
android:textSize="11sp"
|
||||
android:textStyle="bold" />
|
||||
</LinearLayout>
|
||||
@@ -103,7 +103,7 @@
|
||||
<string name="server_lab_reserved">Reserved (اختياري، مفصول بفواصل)</string>
|
||||
<string name="server_lab_local_address">العنوان المحلي (اختياري IPv4/IPv6، مفصولة بفواصل)</string>
|
||||
<string name="server_lab_local_mtu">MTU (اختياري، الافتراضي 1420)</string>
|
||||
<string name="server_lab_remote_dns">remoteDNS (اختياري، عناوين IPv4/IPv6 مفصولة بفواصل، أو "local" لاستخدام DNS الجهاز)</string>
|
||||
<string name="server_lab_remote_dns">remoteDNS (اختياري، عناوين IPv4/IPv6 مفصولة بفواصل)</string>
|
||||
<string name="server_lab_kcp_mtu">mKCP MTU (اختياري، الافتراضي 1350)</string>
|
||||
<string name="server_lab_kcp_tti">mKCP TTI (اختياري، الافتراضي 50 مللي ثانية)</string>
|
||||
<string name="toast_success">نجاح</string>
|
||||
@@ -568,5 +568,8 @@
|
||||
<string name="tasker_blurb_stop">إيقاف %1$s</string>
|
||||
<string name="connection_test_empty_message">لا تتوفر تفاصيل</string>
|
||||
<string name="value_unknown">غير معروف</string>
|
||||
<string name="sub_info_open_support">فتح رابط الدعم</string>
|
||||
<string name="sub_info_open_page">فتح صفحة معلومات الاشتراك</string>
|
||||
<string name="sub_info_unlimited">غير محدود</string>
|
||||
|
||||
</resources>
|
||||
@@ -0,0 +1,568 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name" translatable="false">v2rayNG</string>
|
||||
<string name="app_widget_name">Keçid</string>
|
||||
<string name="app_tile_name">Keçid</string>
|
||||
<string name="app_tile_first_use">Bu funksiyanı istifadə etməzdən əvvəl tətbiqə profil əlavə edin.</string>
|
||||
<string name="navigation_drawer_open">Naviqasiya menyusunu açın</string>
|
||||
<string name="navigation_drawer_close">Naviqasiya menyusunu bağlayın</string>
|
||||
<string name="migration_success">Məlumatların köçürülməsi uğurla başa çatdı!</string>
|
||||
<string name="action_stop_service">Xidməti saxla</string>
|
||||
<string name="action_ok">Oldu</string>
|
||||
<string name="action_cancel">Ləğv et</string>
|
||||
<string name="migration_fail">Məlumatların köçürülməsi uğursuz oldu!</string>
|
||||
<string name="pull_down_to_refresh">Yeniləmək üçün aşağı çəkin!</string>
|
||||
|
||||
<!-- Notifications -->
|
||||
<string name="notification_channel_service">v2rayNG xidməti</string>
|
||||
<string name="notification_channel_subscription_updates">Abunəlik yenilənmələri</string>
|
||||
<string name="notification_channel_connection_checks">Bağlantı yoxlamaları</string>
|
||||
<string name="notification_action_stop_v2ray">Dayandır</string>
|
||||
<string name="toast_permission_denied">İcazə rədd edildi</string>
|
||||
<string name="toast_permission_denied_notification">Bildiriş icazəsi rədd edildi</string>
|
||||
<string name="notification_action_more">Daha çoxu üçün toxunun</string>
|
||||
<string name="toast_services_start">Xidmət başladılır</string>
|
||||
<string name="toast_services_stop">Xidmət dayandırılır</string>
|
||||
<string name="toast_services_success">Xidmət uğurla başladıldı</string>
|
||||
<string name="toast_services_failure">Xidməti başlatmaq olmadı</string>
|
||||
|
||||
<!--ServerActivity-->
|
||||
<string name="title_server">Konfiqurasiya</string>
|
||||
<string name="menu_item_add_config">Konfiqurasiya əlavə et</string>
|
||||
<string name="menu_item_save_config">Konfiqurasiyanı saxla</string>
|
||||
<string name="menu_item_edit_config">Konfiqurasiyanı redaktə et</string>
|
||||
<string name="menu_item_del_config">Konfiqurasiyanı sil</string>
|
||||
<string name="menu_item_import_config_qrcode">QR koddan idxal et</string>
|
||||
<string name="menu_item_import_config_clipboard">Mübadilə buferindən idxal et</string>
|
||||
<string name="menu_item_import_config_local">Yerli fayldan idxal et</string>
|
||||
<string name="menu_item_import_config_policy_group">[Siyasət qrupu] əlavə et</string>
|
||||
<string name="menu_item_import_config_proxy_chain">[Proksi zənciri] əlavə et</string>
|
||||
<string name="menu_item_import_config_manually_vmess">[VMess] əlavə et</string>
|
||||
<string name="menu_item_import_config_manually_vless">[VLESS] əlavə et</string>
|
||||
<string name="menu_item_import_config_manually_ss">[Shadowsocks] əlavə et</string>
|
||||
<string name="menu_item_import_config_manually_socks">[SOCKS] əlavə et</string>
|
||||
<string name="menu_item_import_config_manually_http">[HTTP] əlavə et</string>
|
||||
<string name="menu_item_import_config_manually_trojan">[Trojan] əlavə et</string>
|
||||
<string name="menu_item_import_config_manually_wireguard">[WireGuard] əlavə et</string>
|
||||
<string name="menu_item_import_config_manually_hysteria2">[Hysteria2] əlavə et</string>
|
||||
<string name="confirm_delete_visible_profiles">Hazırda göstərilən bütün profilləri silmək istədiyinizə əminsiniz?</string>
|
||||
<string name="confirm_delete_duplicate_profiles">Hazırda göstərilən bütün təkrar profilləri silmək istədiyinizə əminsiniz?</string>
|
||||
<string name="confirm_delete_invalid_profiles">Zəhmət olmasa əvvəlcə profilləri sınaqdan keçirin. Hazırda göstərilən bütün yararsız profilləri silmək istədiyinizə əminsiniz?</string>
|
||||
<string name="confirm_delete_profile">Bu profili silmək istədiyinizə əminsiniz?</string>
|
||||
<string name="confirm_delete_policy_group">Bu siyasət qrupunu silmək istədiyinizə əminsiniz?</string>
|
||||
<string name="confirm_delete_proxy_chain_member">Zəncirin bu üzvünü silmək istədiyinizə əminsiniz?</string>
|
||||
<string name="confirm_delete_routing_rule">Bu marşrutlaşdırma qaydasını silmək istədiyinizə əminsiniz?</string>
|
||||
<string name="confirm_delete_subscription_group">Bu abunəlik qrupunu silmək istədiyinizə əminsiniz?</string>
|
||||
<string name="confirm_delete_asset_file">Bu resurs faylını silmək istədiyinizə əminsiniz?</string>
|
||||
<string name="confirm_delete_asset_source">Bu resurs mənbəyini silmək istədiyinizə əminsiniz?</string>
|
||||
<string name="server_lab_remarks">qeydlər</string>
|
||||
<string name="server_lab_address">ünvan</string>
|
||||
<string name="server_lab_port">port</string>
|
||||
<string name="server_lab_id">id</string>
|
||||
<string name="server_lab_alterid">alterId</string>
|
||||
<string name="server_lab_security">təhlükəsizlik</string>
|
||||
<string name="server_lab_network">Şəbəkə</string>
|
||||
<string name="server_lab_more_function">Nəqliyyat protokolu</string>
|
||||
<string name="server_lab_head_type">başlıq növü</string>
|
||||
<string name="server_lab_mode_type">gRPC rejimi</string>
|
||||
<string name="server_lab_request_host">host</string>
|
||||
<string name="server_lab_request_host_http">http host</string>
|
||||
<string name="server_lab_request_host_ws">ws host</string>
|
||||
<string name="server_lab_request_host_httpupgrade">httpupgrade host</string>
|
||||
<string name="server_lab_request_host_xhttp">xhttp host</string>
|
||||
<string name="server_lab_request_host_h2">h2 host</string>
|
||||
<string name="server_lab_request_host_quic">QUIC təhlükəsizliyi</string>
|
||||
<string name="server_lab_request_host_grpc">gRPC Authority</string>
|
||||
<string name="server_lab_path">yol</string>
|
||||
<string name="server_lab_path_ws">ws yolu</string>
|
||||
<string name="server_lab_path_httpupgrade">httpupgrade yolu</string>
|
||||
<string name="server_lab_path_xhttp">xhttp yolu</string>
|
||||
<string name="server_lab_path_h2">h2 yolu</string>
|
||||
<string name="server_lab_path_quic">QUIC açarı</string>
|
||||
<string name="server_lab_path_kcp">kcp seed</string>
|
||||
<string name="server_lab_path_grpc">gRPC serviceName</string>
|
||||
<string name="server_lab_stream_security" translatable="false">TLS</string>
|
||||
<string name="server_lab_stream_fingerprint">Barmaq izi</string>
|
||||
<string name="server_lab_stream_alpn" translatable="false">ALPN</string>
|
||||
<string name="server_lab_allow_insecure">allowInsecure</string>
|
||||
<string name="server_lab_sni" translatable="false">SNI</string>
|
||||
<string name="server_lab_address3">ünvan</string>
|
||||
<string name="server_lab_port3">port</string>
|
||||
<string name="server_lab_id3">şifrə</string>
|
||||
<string name="server_lab_security3">təhlükəsizlik</string>
|
||||
<string name="server_lab_id4">Şifrə (istəyə bağlı)</string>
|
||||
<string name="server_lab_security4">İstifadəçi (istəyə bağlı)</string>
|
||||
<string name="server_lab_encryption">şifrələmə</string>
|
||||
<string name="server_lab_flow">axın</string>
|
||||
<string name="server_lab_public_key">PublicKey</string>
|
||||
<string name="server_lab_preshared_key">PreSharedKey (istəyə bağlı)</string>
|
||||
<string name="server_lab_short_id">ShortId</string>
|
||||
<string name="server_lab_spider_x" translatable="false">SpiderX</string>
|
||||
<string name="server_lab_mldsa65_verify" translatable="false">mldsa65Verify</string>
|
||||
<string name="server_lab_secret_key">SecretKey</string>
|
||||
<string name="server_lab_reserved">Reserved (istəyə bağlı, vergüllə ayrılmış)</string>
|
||||
<string name="server_lab_local_address">Yerli ünvan (istəyə bağlı IPv4/IPv6, vergüllə ayrılmış)</string>
|
||||
<string name="server_lab_local_mtu">MTU (istəyə bağlı, standart 1420)</string>
|
||||
<string name="server_lab_remote_dns">remoteDNS (istəyə bağlı, vergüllə ayrılmış IPv4/IPv6)</string>
|
||||
<string name="server_lab_kcp_mtu">mKCP MTU (istəyə bağlı, standart 1350)</string>
|
||||
<string name="server_lab_kcp_tti">mKCP TTI (istəyə bağlı, standart 50 ms)</string>
|
||||
<string name="toast_success">Uğurlu</string>
|
||||
<string name="toast_failure">Uğursuz</string>
|
||||
<string name="toast_none_data">Əlçatan məlumat yoxdur</string>
|
||||
<string name="toast_incorrect_protocol">Yanlış protokol</string>
|
||||
<string name="toast_decoding_failed">Dekodlaşdırma uğursuz oldu</string>
|
||||
<string name="title_file_chooser">Konfiqurasiya seçin</string>
|
||||
<string name="toast_require_file_manager">Zəhmət olmasa fayl menecerini quraşdırın.</string>
|
||||
<string name="server_customize_config">Fərdi konfiqurasiya</string>
|
||||
<string name="toast_config_file_invalid">Yanlış konfiqurasiya</string>
|
||||
<string name="server_lab_content">Məzmun</string>
|
||||
<string name="toast_none_data_clipboard">Mübadilə buferində məlumat yoxdur</string>
|
||||
<string name="toast_invalid_url">Yanlış URL</string>
|
||||
<string name="toast_insecure_url_protocol">Təhlükəsiz olmayan HTTP abunəlik URL-indən istifadə etməyin</string>
|
||||
<string name="server_lab_need_inbound">Gələn portun tənzimləmələrə uyğun olduğundan əmin olun</string>
|
||||
<string name="toast_malformed_json">Yanlış konfiqurasiya.</string>
|
||||
<string name="server_lab_request_host6">Host (SNI) (istəyə bağlı)</string>
|
||||
<string name="toast_action_not_allowed">Fəaliyyətə icazə verilmir</string>
|
||||
<string name="server_obfs_password">Obfs şifrəsi</string>
|
||||
<string name="server_lab_port_hop">Port dəyişməsi (portu əvəzləyir)</string>
|
||||
<string name="server_lab_port_hop_interval">Port dəyişmə tezliyi (saniyə)</string>
|
||||
<string name="server_lab_bandwidth_down">Yükləmə zolaq genişliyi (dəstəklənən vahidlər: k/m/g/t)</string>
|
||||
<string name="server_lab_bandwidth_up">Ötürmə zolaq genişliyi (dəstəklənən vahidlər: k/m/g/t)</string>
|
||||
<string name="server_lab_xhttp_mode">XHTTP Rejimi</string>
|
||||
<string name="server_lab_xhttp_extra">XHTTP Əlavə xam JSON, format: { XHTTPObject }</string>
|
||||
<string name="server_lab_final_mask">finalMask xam JSON, format: { FinalMaskObject }</string>
|
||||
<string name="server_lab_ech_config_list" translatable="false">echConfigList</string>
|
||||
<string name="server_lab_verify_peer_cert_by_name">Qarşı tərəf sertifikatını ada görə doğrulayın</string>
|
||||
<string name="server_lab_pinned_ca256">Sertifikatın barmaq izi (SHA-256)</string>
|
||||
<string name="server_lab_browser_dialer">Brauzer Yığcısını aktivləşdirin</string>
|
||||
<string name="server_lab_browser_dialer_tip">Yalnız xhttp (packet-up) və ws çıxışlarını dəstəkləyir; TLS ilə bağlı tənzimləmələr nəzərə alınmaya və ya ziddiyyət təşkil edə bilər</string>
|
||||
<string name="toast_allow_insecure_deprecated">Cari düyün şifrələnməmiş bağlantı istifadə edir. Ünsiyyətiniz avtoritar hökumətlər tərəfindən idarə olunan şəbəkə vasitəçiləri tərəfindən birbaşa izlənilə bilər.\nTəhlükəsizlik səbəbiylə, bu tür düyünlərə Xray core 26.2.6+ versiyası ilə qoşulmaq mümkün deyil.\nƏgər bu özünüzün yaratdığı düyündürsə, xahiş olunur TLS kimi təhlükəsizlik şifrələməsini aktivləşdirin və ya pinnedCA256 sertifikatını bərkidib istifadə edin.\nƏgər bu xidmət təminatçısının düyünüdürsə, texniki yenilənməni tamamlamaq üçün xidmət təminatçısı ilə əlaqə saxlayın. Xidmət təminatçısı əməkdaşlıq etməkdən imtina edərsə, istifadəçi təhlükəsizliyinə daha çox önəm verən başqa bir provayderə keçməyiniz tövsiyə olunur.\nƏtraflı məlumat üçün https://github.com/2dust/v2rayN/discussions/9460 ünvanına daxil olun</string>
|
||||
<string name="pinned_ca256_action_fetch">Sertifikatın barmaq izini əldə et</string>
|
||||
<string name="toast_fetch_cert_sha256_success">Sertifikatın barmaq izi uğurla əldə edildi</string>
|
||||
<string name="toast_fetch_cert_sha256_failed">Sertifikatın barmaq izini əldə etmək mümkün olmadı</string>
|
||||
|
||||
<!-- UserAssetActivity -->
|
||||
<string name="toast_asset_copy_failed">Fayl kopyalanması uğursuz oldu, zəhmət olmasa Fayl Menecerindən istifadə edin</string>
|
||||
<string name="menu_item_add_asset">Resurs əlavə et</string>
|
||||
<string name="menu_item_add_file">Fayllar əlavə et</string>
|
||||
<string name="menu_item_add_url">URL əlavə et</string>
|
||||
<string name="menu_item_scan_qrcode">QR kodu skanla</string>
|
||||
<string name="title_url" translatable="false">URL</string>
|
||||
<string name="menu_item_download_file">Faylları endir</string>
|
||||
<string name="title_user_asset_add_url">Resurs URL-i əlavə et</string>
|
||||
<string name="msg_file_not_found">Fayl tapılmadı</string>
|
||||
<string name="msg_remark_is_duplicate">Bu ad artıq mövcuddur</string>
|
||||
<string name="asset_geo_files_sources">Geo fayl mənbəyi (istəyə bağlı)</string>
|
||||
|
||||
<!-- PerAppProxyActivity -->
|
||||
<string name="msg_dialog_progress">Yüklənir</string>
|
||||
<string name="menu_item_search">Axtar</string>
|
||||
<string name="menu_item_select_all">Hamısını seç</string>
|
||||
<string name="menu_item_invert_selection">Seçimi tərsinə çevir</string>
|
||||
<string name="msg_enter_keywords">Açar sözləri daxil edin</string>
|
||||
<string name="switch_bypass_apps_mode">Yayınma Rejimi</string>
|
||||
<string name="menu_item_select_proxy_app">Proksi istifadə edəcək tətbiqləri avtomatik seç</string>
|
||||
<string name="msg_downloading_content">Məzmun endirilir</string>
|
||||
<string name="menu_item_export_proxy_app">Mübadilə buferinə ixrac et</string>
|
||||
<string name="menu_item_import_proxy_app">Mübadilə buferindən idxal et</string>
|
||||
<string name="per_app_proxy_settings">Tətbiq üzrə proksi tənzimləmələri</string>
|
||||
<string name="per_app_proxy_settings_enable">Tətbiq üzrə proksini aktivləşdir</string>
|
||||
<string name="app_picker_unknown_app">Bilinməyən tətbiq (UID tanınmadı)</string>
|
||||
|
||||
<!-- Preferences -->
|
||||
<string name="title_settings">Tənzimləmələr</string>
|
||||
<string name="title_advanced">Ətraflı Tənzimləmələr</string>
|
||||
<string name="title_core_settings">Nüvə Tənzimləmələri</string>
|
||||
<string name="title_vpn_settings">VPN Tənzimləmələri</string>
|
||||
<string name="title_pref_per_app_proxy">Tətbiq üzrə proksi</string>
|
||||
<string name="summary_pref_per_app_proxy">Ümumi: seçilmiş tətbiqlər proksidən istifadə edir, seçilməyənlər birbaşa qoşulur;\nYayınma rejimi: seçilmiş tətbiqlər birbaşa qoşulur, seçilməyənlər proksidən istifadə edir.\nProksi istifadə etməli olan tətbiqlərin avtomatik seçilməsi seçimi menyudadır.</string>
|
||||
<string name="title_pref_is_booted">Cihaz açılanda qoşul</string>
|
||||
<string name="summary_pref_is_booted">Cihaz işə düşdükdən sonra avtomatik olaraq seçilmiş serverə qoşulun. Android fonda başladılmanı məhdudlaşdıra bilər.</string>
|
||||
<string name="title_system_vpn_settings">Sistem VPN tənzimləmələri</string>
|
||||
<string name="summary_system_vpn_settings">Bu cihaz dəstəkləyirsə, Həmişə açıq VPN-i konfiqurasiya etmək üçün Android VPN tənzimləmələrini açın.</string>
|
||||
<string name="toast_system_vpn_settings_unavailable">Sistem VPN tənzimləmələri əlçatmazdır.</string>
|
||||
|
||||
|
||||
<string name="subscription_updater_job_tips">Tapşırıq arxa fonda icra olunacaq və gedişat məlumatı bildirişdə göstəriləcək.</string>
|
||||
<string name="title_pref_auto_test_after_update_subscription">Abunəlik yeniləndikdən sonra avtomatik sınaqdan keçir</string>
|
||||
<string name="summary_pref_auto_test_after_update_subscription">Avtomatlaşdırılmış sınaq uzun çəkir. Aktivləşdirilərsə, tapşırıq arxa fonda işləyəcək; əks halda, cari səhifədə icra olunacaq.</string>
|
||||
<string name="title_pref_auto_remove_invalid_after_test">Sınaqdan sonra yararsız konfiqurasiyaları avtomatik sil</string>
|
||||
<string name="summary_pref_auto_remove_invalid_after_test">Sınaq nəticələri dəqiq olmaya bilər; silinmiş konfiqurasiyanı bərpa etmək mümkün deyil.</string>
|
||||
<string name="title_pref_auto_sort_after_test">Sınaqdan sonra avtomatik çeşidlə</string>
|
||||
<string name="summary_pref_auto_sort_after_test">Sınaq nəticələri dəqiq olmaya bilər;</string>
|
||||
|
||||
<string name="title_mux_settings">Mux Tənzimləmələri</string>
|
||||
<string name="title_pref_mux_enabled">Mux-ı aktivləşdir</string>
|
||||
<string name="summary_pref_mux_enabled">Daha sürətlidir, lakin bağlantı qeyri-sabitliyinə səbəb ola bilər\nAşağıda TCP, UDP və QUIC idarəetməsini fərdiləşdirin</string>
|
||||
<string name="title_pref_mux_concurrency">TCP bağlantıları (diapazon -1 ilə 1024 arası)</string>
|
||||
<string name="title_pref_mux_xudp_concurrency">XUDP bağlantıları (diapazon -1 ilə 1024 arası)</string>
|
||||
<string name="title_pref_mux_xudp_quic">Mux tunelində QUIC idarə edilməsi</string>
|
||||
<string-array name="mux_xudp_quic_entries">
|
||||
<item>rədd et</item>
|
||||
<item>icazə ver</item>
|
||||
<item>ötür</item>
|
||||
</string-array>
|
||||
|
||||
<string name="title_pref_speed_enabled">Sürət göstəricisini aktivləşdir</string>
|
||||
<string name="summary_pref_speed_enabled">Cari sürəti bildiriş panelində göstərin.\nİkona proksi və ya birbaşa trafikin üstünlük təşkil etdiyini bildirir.</string>
|
||||
|
||||
<string name="title_pref_sniffing_enabled">Dinləməni aktivləşdir</string>
|
||||
<string name="summary_pref_sniffing_enabled">Trafikdən hədəf domenləri təyin edin (standart olaraq aktivdir).</string>
|
||||
<string name="title_pref_route_only_enabled">routeOnly aktivləşdir</string>
|
||||
<string name="summary_pref_route_only_enabled">Təyin edilmiş domen adını yalnız marşrutlaşdırma üçün istifadə edin və hədəf ünvanını IP ünvanı olaraq saxlayın.</string>
|
||||
|
||||
<string name="title_pref_local_dns_enabled">Yerli DNS-i aktivləşdir</string>
|
||||
<string name="summary_pref_local_dns_enabled">DNS nüvənin DNS modulu tərəfindən emal olunur (LAN və ölkə daxili ünvanları yan keçmək lazımdırsa tövsiyə olunur)</string>
|
||||
|
||||
<string name="title_pref_fake_dns_enabled">FakeDNS aktivləşdir</string>
|
||||
<string name="summary_pref_fake_dns_enabled">FakeDNS domen sorğuları üçün süni IP ünvanları qaytarır (daha sürətlidir, lakin bəzi tətbiqlərlə işləməyə bilər).</string>
|
||||
|
||||
<string name="title_pref_ipv6_enabled">IPv6-nı aktivləşdir</string>
|
||||
<string name="summary_pref_ipv6_enabled">VPN interfeysinə IPv6 ünvanı və marşrutlarını əlavə edin</string>
|
||||
|
||||
<string name="title_pref_prefer_ipv6">IPv6-ya üstünlük ver</string>
|
||||
<string name="summary_pref_prefer_ipv6">Domen adlarını həll edərkən IPv6 ünvanlarına üstünlük verin</string>
|
||||
|
||||
<string name="title_pref_remote_dns">Uzaq DNS (UDP/TCP/HTTPS/QUIC, istəyə bağlı)</string>
|
||||
<string name="summary_pref_remote_dns" translatable="false">DNS</string>
|
||||
|
||||
<string name="title_pref_vpn_dns">VPN DNS (yalnız IPv4/IPv6)</string>
|
||||
<string name="title_pref_vpn_bypass_lan">VPN rejimində LAN-ı yan keç</string>
|
||||
|
||||
<string name="title_pref_vpn_interface_address">VPN İnterfeys Ünvanı</string>
|
||||
|
||||
<string name="title_pref_vpn_mtu">VPN MTU (standart 1500)</string>
|
||||
|
||||
|
||||
<string name="title_pref_domestic_dns">Birbaşa DNS (istəyə bağlı)</string>
|
||||
<string name="summary_pref_domestic_dns" translatable="false">DNS</string>
|
||||
|
||||
<string name="title_pref_dns_hosts">DNS host mənsubiyyətləri (format: domen:ünvan,…)</string>
|
||||
<string name="summary_pref_dns_hosts">domen:ünvan,…</string>
|
||||
|
||||
<string name="title_pref_delay_test_url">Real gecikmə sınağı URL-i</string>
|
||||
<string name="summary_pref_delay_test_url" translatable="false">URL</string>
|
||||
<string name="title_pref_real_ping_concurrency">Eyni vaxtda aparılan real gecikmə sınaqları</string>
|
||||
|
||||
<string name="title_pref_ip_api_url">Cari bağlantı məlumatı sınağı URL-i</string>
|
||||
<string name="summary_pref_ip_api_url" translatable="false">URL</string>
|
||||
|
||||
<string name="title_pref_proxy_sharing_enabled">LAN qoşulmalarına icazə ver</string>
|
||||
<string name="summary_pref_proxy_sharing_enabled">Digər cihazlar yerli proksi vasitəsilə sizin IP ünvanınızla proksiyə qoşula bilər. İcazəsiz qoşulmaların qarşısını almaq üçün yalnız etibarlı şəbəkələrdə aktivləşdirin</string>
|
||||
<string name="toast_warning_pref_proxysharing_short">LAN-dan qoşulmalara icazə verilir. Etibarlı şəbəkədə olduğunuzdan əmin olun</string>
|
||||
|
||||
<string name="title_pref_socks_port">Yerli proksi portu</string>
|
||||
<string name="title_pref_enable_local_proxy">Yerli proksini aktivləşdir</string>
|
||||
<string name="summary_pref_enable_local_proxy">Qapalı olduqda, SOCKS proksi əlçatan olmur, buna görə də abunəlik yenilənmələri və bənzər əməliyyatlar proksidən istifadə edə bilmir</string>
|
||||
<string name="title_pref_socks_enable_udp" translatable="false">SOCKS5 UDP</string>
|
||||
<string name="summary_pref_socks_enable_udp">UDP istifadə edərkən SOCKS5 doğrulama üsulu tam təhlükəsiz deyil.</string>
|
||||
<string name="summary_pref_socks_port">Yerli proksi portu</string>
|
||||
<string name="title_pref_dynamic_socks_port">Dinamik yerli proksi portunu aktivləşdir</string>
|
||||
<string name="summary_pref_dynamic_socks_port">Hər gələn bağlantı üçün təsadüfi yerli proksi portundan istifadə edin</string>
|
||||
<string name="title_pref_socks_username">Yerli proksi istifadəçi adı (istəyə bağlı)</string>
|
||||
<string name="summary_pref_socks_username">İstifadəçi adı</string>
|
||||
<string name="title_pref_socks_password">Yerli proksi şifrəsi (istəyə bağlı)</string>
|
||||
<string name="summary_pref_socks_password">Şifrə</string>
|
||||
|
||||
<string name="title_pref_local_dns_port">Yerli DNS portu</string>
|
||||
<string name="summary_pref_local_dns_port">Yerli DNS portu</string>
|
||||
|
||||
<string name="title_pref_confirm_remove">Konfiqurasiya silinməsi təsdiqi</string>
|
||||
<string name="summary_pref_confirm_remove">Konfiqurasiya silinərkən istifadəçidən ikinci təsdiqin tələb olunub-olunmaması</string>
|
||||
|
||||
|
||||
<string name="title_pref_append_http_proxy">VPN-ə HTTP proksi əlavə et</string>
|
||||
<string name="summary_pref_append_http_proxy">Brauzerlərə və dəstəklənən tətbiqlərə virtual şəbəkə interfeysi əvəzinə birbaşa HTTP proksidən istifadə etməyə icazə verin (Android 10+).</string>
|
||||
|
||||
<string name="title_pref_double_column_display">İki sütunlu düzülüşdən istifadə et</string>
|
||||
<string name="summary_pref_double_column_display">Ekrana daha çox məzmun sığdırmaq üçün profil siyahısını iki sütunda göstərin.</string>
|
||||
|
||||
<string name="title_pref_group_all_display">"Hamısı" qrup vərəqini göstər</string>
|
||||
<string name="summary_pref_group_all_display">Hər abunəlik qrupundakı profilləri bir vərəqdə birləşdirin.</string>
|
||||
|
||||
<!-- AboutActivity -->
|
||||
<string name="title_pref_feedback">Əks-əlaqə</string>
|
||||
<string name="summary_pref_feedback">GitHub-da təkliflərinizi bildirin və ya xətaları məruzə edin</string>
|
||||
<string name="summary_pref_tg_group">Telegram qrupuna qoşulun</string>
|
||||
<string name="toast_tg_app_not_found">Telegram tətbiqi tapılmadı</string>
|
||||
<string name="title_privacy_policy">Məxfilik siyasəti</string>
|
||||
<string name="title_qr_code">QR kod</string>
|
||||
<string name="title_about">Haqqında</string>
|
||||
<string name="title_source_code">Mənbə kodu</string>
|
||||
<string name="title_oss_license">Açıq mənbə lisenziyaları</string>
|
||||
<string name="title_translators">Tərcüməçilər</string>
|
||||
<string name="title_tg_channel">Telegram kanalı</string>
|
||||
|
||||
<string name="title_pref_promotion">Təşviqat</string>
|
||||
|
||||
<string name="title_pref_auto_update_subscription">Abunəlikləri avtomatik yenilə</string>
|
||||
<string name="summary_pref_auto_update_subscription">Abunəliklərinizi arxa fonda təyin olunmuş intervallarla avtomatik yeniləyin. Cihazdan asılı olaraq bu funksiya həmişə işləməyə bilər</string>
|
||||
<string name="title_pref_auto_update_interval">Avtomatik yenilənmə intervalı (dəqiqə; minimum 15)</string>
|
||||
|
||||
<string name="title_core_loglevel">Jurnal Səviyyəsi</string>
|
||||
<string name="title_outbound_domain_resolve_method">Çıxış domeninin həll edilməsi</string>
|
||||
<string name="title_mode">Rejim</string>
|
||||
<string name="title_mode_help">Ətraflı kömək üçün toxunun</string>
|
||||
<string name="title_language">Dil</string>
|
||||
<string name="title_ui_settings">İstifadəçi interfeysi tənzimləmələri</string>
|
||||
<string name="title_pref_ui_mode_night">İstifadəçi interfeysi rejim tənzimləmələri</string>
|
||||
<string name="title_pref_dynamic_color">Dinamik rəngi aktivləşdir</string>
|
||||
<string name="summary_pref_dynamic_color">Yalnız Android 12+ cihazlarında işləyir</string>
|
||||
<string name="title_pref_use_hev_tunnel">Hev TUN istifadə et</string>
|
||||
<string name="summary_pref_use_hev_tunnel">Aktiv edildikdə TUN hev-socks5-tunnel istifadə edəcək; əks halda xray-core istifadə olunacaq.</string>
|
||||
<string name="title_pref_hev_tunnel_loglevel">Hev TUN jurnal səviyyəsi</string>
|
||||
<string name="title_pref_hev_tunnel_rw_timeout">Hev TUN oxuma/yazma vaxt aşımı (saniyə; TCP/UDP standartları: 300/60)</string>
|
||||
<string name="title_mode_settings">Rejim Tənzimləmələri</string>
|
||||
<string name="title_root_mode_enabled">Root rejimini aktivləşdir (root istifadəçiləri)</string>
|
||||
<string name="summary_root_mode_enabled">Aşağı səviyyəli şəffaf proksiləmə üçün root icazəsindən istifadə edin. Root rejimi Android VPN xidmətindən istifadə etmək əvəzinə şəbəkə trafikini birbaşa idarə edir. Root rejimi aktiv olduqda standart rejim, tətbiq üzrə marşrutlaşdırma və VPN ilə bağlı tənzimləmələr tətbiq edilmir.</string>
|
||||
<string name="toast_root_required">Bu funksiya üçün Root icazəsi tələb olunur</string>
|
||||
<string name="title_root_lan_sharing">VPN-i LAN / modem rejimindəki cihazlarla paylaş (root istifadəçiləri)</string>
|
||||
<string name="summary_root_lan_sharing">Wi-Fi giriş nöqtəsi (hotspot) və USB modem rejiminə qoşulmuş cihazların trafikini VPN vasitəsilə yönləndirin.</string>
|
||||
|
||||
<string name="title_logcat" translatable="false">Logcat</string>
|
||||
<string name="logcat_copy">Kopyala</string>
|
||||
<string name="logcat_clear">Təmizlə</string>
|
||||
<string name="logcat_share">Jurnalı paylaş</string>
|
||||
<string name="title_service_restart">Xidməti yenidən başlat</string>
|
||||
<string name="title_del_all_config">Konfiqurasiyaları sil</string>
|
||||
<string name="title_del_duplicate_config">Təkrar konfiqurasiyaları sil</string>
|
||||
<string name="title_del_invalid_config">Yararsız konfiqurasiyaları sil</string>
|
||||
<string name="title_export_all">Konfiqurasiyaları mübadilə buferinə ixrac et</string>
|
||||
<string name="title_sub_setting">Abunəliklər</string>
|
||||
<string name="sub_setting_remarks">Qeydlər</string>
|
||||
<string name="sub_setting_url">İstəyə bağlı URL</string>
|
||||
<string name="sub_setting_user_agent" translatable="false">User Agent</string>
|
||||
<string name="sub_setting_request_headers">Sorğu Başlıqları (JSON formatında)</string>
|
||||
<string name="sub_setting_filter">Qeyd filtri (müntəzəm ifadə / regex)</string>
|
||||
<string name="sub_setting_enable">Yenilənməni aktivləşdir</string>
|
||||
<string name="sub_auto_update">Avtomatik yenilənməni aktivləşdir</string>
|
||||
<string name="sub_allow_insecure_url">Təhlükəsiz olmayan HTTP ünvanına icazə ver</string>
|
||||
<string name="sub_setting_pre_profile">Giriş proksisi</string>
|
||||
<string name="sub_setting_next_profile">Çıxış proksisi</string>
|
||||
<string name="sub_setting_pre_profile_tip">Konfiqurasiya qeydlərinin mövcud olduğundan və unikal olduğundan əmin olun</string>
|
||||
<string name="sub_setting_entry_proxy_tip">Seçilmiş proksi, bu abunəlikdəki hər bir profilin əvvəlinə həmin profil üçün proksi zəncirindəki giriş keçidi olaraq əlavə edilir.</string>
|
||||
<string name="sub_setting_exit_proxy_tip">Seçilmiş proksi, bu abunəlikdəki hər bir profilin sonuna həmin profil üçün proksi zəncirindəki çıxış keçidi olaraq əlavə edilir.</string>
|
||||
<string name="toast_invalid_update_interval">Yanlış yenilənmə intervalı. Minimum 15 dəqiqədir.</string>
|
||||
<string name="title_sub_update">Abunəliyi yenilə</string>
|
||||
<string name="title_ping_all_server">TCP gecikmələrini sınaqdan keçir (TCPing)</string>
|
||||
<string name="title_real_ping_all_server">Real gecikmələri sınaqdan keçir</string>
|
||||
<string name="title_user_asset_setting">Resurs faylları</string>
|
||||
<string name="title_sort_by_test_results">Sınaq nəticələrinə görə çeşidlə</string>
|
||||
<string name="title_filter_config">Konfiqurasiyaları filtrdən keçir</string>
|
||||
<string name="filter_config_all">Hamısı</string>
|
||||
<string name="summary_scan_qrcode">Kameranı açmaq və skan etmək üçün ekrana toxunun</string>
|
||||
<string name="title_del_duplicate_config_count">%d təkrar konfiqurasiya silinsin</string>
|
||||
<string name="title_del_config_count">%d konfiqurasiya silinsin</string>
|
||||
<string name="title_import_config_count">%d konfiqurasiya idxal edilsin</string>
|
||||
<string name="title_export_config_count">%d konfiqurasiya ixrac edilsin</string>
|
||||
<plurals name="title_update_config_count">
|
||||
<item quantity="one">%1$d konfiqurasiya yeniləndi</item>
|
||||
<item quantity="other">%1$d konfiqurasiya yeniləndi</item>
|
||||
</plurals>
|
||||
<plurals name="title_update_asset_count">
|
||||
<item quantity="one">%1$d resurs faylı yeniləndi</item>
|
||||
<item quantity="other">%1$d resurs faylı yeniləndi</item>
|
||||
</plurals>
|
||||
<string name="title_update_subscription_result">%1$d konfiqurasiya yeniləndi (%2$d uğurlu, %3$d uğursuz, %4$d ötürüldü)</string>
|
||||
<string name="title_update_subscription_no_subscription">Abunəlik yoxdur</string>
|
||||
<string name="toast_server_not_found_in_group">Seçilmiş server cari qrupda tapılmadı</string>
|
||||
<string name="title_locate_selected_config">Seçilmiş konfiqurasiyanı tapın</string>
|
||||
|
||||
<string name="tasker_start_service">Xidməti Başlat</string>
|
||||
<string name="tasker_setting_confirm">Təsdiqlə</string>
|
||||
|
||||
<!-- RoutingSettingActivity -->
|
||||
<string name="routing_settings_domain_strategy">Domen strategiyası</string>
|
||||
<string name="routing_settings_title">Marşrutlaşdırma Tənzimləmələri</string>
|
||||
<string name="routing_settings_tips">Qayda yalnız bütün konfiqurasiya edilmiş uyğunlaşma şərtləri yerinə yetirildikdə (VƏ) tətbiq olunur. Qaydalar yuxarıdan aşağıya doğru yoxlanılır; uyğun gələn ilk qayda tətbiq edilir.</string>
|
||||
<string name="routing_settings_comma_tip">Çoxsaylı daxiletmələri vergüllə (,) ayırın.</string>
|
||||
<string name="routing_settings_save">Saxla</string>
|
||||
<string name="routing_settings_delete">Təmizlə</string>
|
||||
<string name="routing_settings_rule_title">Marşrutlaşdırma Qaydası Tənzimləmələri</string>
|
||||
<string name="routing_settings_add_rule">Qayda əlavə et</string>
|
||||
<string name="routing_settings_import_predefined_rulesets">Əvvəlcədən təyin edilmiş qayda dəstlərini idxal et</string>
|
||||
<string name="routing_settings_import_rulesets_tip">Mövcud qayda dəstləri silinəcək. Davam edilsin?</string>
|
||||
<string name="routing_settings_import_rulesets_from_clipboard">Mübadilə buferindən qayda dəstini idxal et</string>
|
||||
<string name="routing_settings_import_rulesets_from_qrcode">QR koddan qayda dəstini idxal et</string>
|
||||
<string name="routing_settings_export_rulesets_to_clipboard">Qayda dəstini mübadilə buferinə ixrac et</string>
|
||||
<string name="routing_settings_locked">Kilidlənib; hazır tənzimləmələri idxal edərkən bu qaydanı saxlayın</string>
|
||||
<string name="routing_settings_domain" translatable="false">domain</string>
|
||||
<string name="routing_settings_ip" translatable="false">ip</string>
|
||||
<string name="routing_settings_process">proses (Paket Adı — yalnız Xray TUN istifadə edildikdə, routeOnly aktiv olduqda və Android 10+ versiyasında dəstəklənir)</string>
|
||||
<string name="routing_settings_process_select">Paket adını seçin</string>
|
||||
<string name="routing_settings_port" translatable="false">port</string>
|
||||
<string name="routing_settings_protocol" translatable="false">protocol</string>
|
||||
<string name="routing_settings_protocol_tip" translatable="false">[http,tls,bittorrent]</string>
|
||||
<string name="routing_settings_network" translatable="false">network</string>
|
||||
<string name="routing_settings_outbound_tag_hint">proxy / direct / block / <%1$s></string>
|
||||
<string name="routing_settings_outbound_tag" translatable="false">outboundTag</string>
|
||||
|
||||
<string name="connection_test_pending">Bağlantını yoxlayın</string>
|
||||
<string name="connection_test_testing">Yoxlanılır…</string>
|
||||
<string name="connection_test_testing_count">%d konfiqurasiya yoxlanılır…</string>
|
||||
<string name="connection_test_available">Bağlantı %1$s ərzində uğurla quruldu</string>
|
||||
<string name="server_test_delay_value">%1$d ms</string>
|
||||
<string name="connection_test_error">Bağlantı sınağı uğursuz oldu: %s</string>
|
||||
<string name="connection_test_fail">İnternet əlçatmazdır</string>
|
||||
<string name="connection_test_error_status_code">Xəta kodu: #%d</string>
|
||||
<string name="connection_connected">Qoşuldu. Bağlantını yoxlamaq üçün toxunun.</string>
|
||||
<string name="connection_not_connected">Qoşulmayıb</string>
|
||||
<string name="connection_running_task_left">İcra olunan sınaqlar: %s</string>
|
||||
|
||||
<string name="import_subscription_success">Abunəlik uğurla idxal edildi</string>
|
||||
<string name="import_subscription_failure">Abunəlik idxalı uğursuz oldu</string>
|
||||
<string name="title_fragment_settings">Fraqment Tənzimləmələri</string>
|
||||
<string name="title_pref_fragment_packets">Fraqment Paketləri</string>
|
||||
<string name="title_pref_fragment_length">Fraqment Uzunluğu (min-maks)</string>
|
||||
<string name="title_pref_fragment_interval">Fraqment İntervalı (min-maks)</string>
|
||||
<string name="title_pref_fragment_enabled">Fraqmenti aktivləşdir</string>
|
||||
<string name="title_pref_fragment_maxsplit">Fraqment Maksimum Bölünməsi</string>
|
||||
<string name="title_observatory_settings">Müşahidəçi Tənzimləmələri</string>
|
||||
<string name="title_pref_observatory_least_ping_interval">leastPing İntervalı</string>
|
||||
<string name="title_pref_observatory_least_load_interval">leastLoad İntervalı</string>
|
||||
<string name="title_pref_observatory_least_load_method">leastLoad HTTP metodu</string>
|
||||
<string name="title_pref_observatory_least_load_sampling">leastLoad Seçmələri</string>
|
||||
<string name="title_pref_observatory_least_load_timeout">leastLoad Vaxt Aşımı</string>
|
||||
<string name="toast_invalid_observatory_duration">Yanlış müddət. 3m və ya 30s kimi bir dəyərdən istifadə edin.</string>
|
||||
<string name="toast_invalid_observatory_sampling">Yanlış müddət. Müsbət ədəddən istifadə edin.</string>
|
||||
|
||||
<string name="update_check_for_update">Yenilənmələri yoxla</string>
|
||||
<string name="update_already_latest_version">Artıq ən son versiyadadır</string>
|
||||
<string name="update_new_version_found">Yeni versiya tapıldı: %s</string>
|
||||
<string name="update_now">İndi yenilə</string>
|
||||
<string name="update_check_pre_release">Sınaq versiyalarını yoxla</string>
|
||||
<string name="update_checking_for_update">Yenilənmələr yoxlanılır…</string>
|
||||
|
||||
<string name="title_policy_group_type">Siyasət qrupu növü</string>
|
||||
<string name="title_policy_group_subscription_id">Abunəlik qrupundan</string>
|
||||
<string name="title_policy_group_subscription_filter">Qeydlərin regex filtri</string>
|
||||
<string name="title_policy_group_test_outbounds">Çıxışları sınaqdan keçir</string>
|
||||
<string name="title_policy_group_fallback">Ehtiyat çıxışı (istəyə bağlı)</string>
|
||||
|
||||
<string name="server_proxy_chain_members">Proksi zənciri üzvləri</string>
|
||||
<string name="server_proxy_chain_pick_members">Üzv seçmək üçün bura toxunun</string>
|
||||
<string name="server_proxy_chain_member_unselected">Üzv seçin</string>
|
||||
<string name="server_proxy_chain_members_unselected">Zəhmət olmasa hər zəncir sətri üçün bir üzv seçin</string>
|
||||
<string name="server_proxy_chain_members_insufficient">Kifayət qədər üzv yoxdur</string>
|
||||
<string name="server_proxy_chain_members_invalid">Yanlış zəncir üzvləri: %1$s</string>
|
||||
|
||||
<!-- BackupActivity -->
|
||||
<string name="title_configuration_backup_restore">Ehtiyat Nüsxə & Bərpa</string>
|
||||
<string name="title_configuration_backup">Konfiqurasiyanın ehtiyat nüsxəsini çıxar</string>
|
||||
<string name="title_configuration_restore">Konfiqurasiyanı bərpa et</string>
|
||||
<string name="title_profile_storage_cleanup">Profil yaddaşını təmir et</string>
|
||||
<string name="summary_profile_storage_cleanup">İstifadə olunmayan profil məlumatlarını silin</string>
|
||||
<string name="message_profile_storage_cleanup">Artıq heç bir qrup tərəfindən istifadə edilməyən profil məlumatları silinsin? Seçilmiş profillər və təhlükəsiz təsnif edilə bilməyən məlumatlar saxlanılacaq.</string>
|
||||
<string name="toast_profile_storage_cleanup">İstifadə olunmayan profillər silindi: %1$d</string>
|
||||
<string name="toast_profile_storage_cleanup_skipped">Profil yaddaşı təhlükəsiz təsnif edilə bilmədi. Heç nə silinmədi.</string>
|
||||
<string name="title_configuration_share">Konfiqurasiyanı paylaş</string>
|
||||
<string name="title_webdav_config_setting">WebDAV Tənzimləmələri</string>
|
||||
<string name="title_webdav_config_setting_unknown">Zəhmət olmasa əvvəlcə WebDAV-ı konfiqurasiya edin.</string>
|
||||
<string name="title_webdav_url">WebDAV server URL-i</string>
|
||||
<string name="title_webdav_user">İstifadəçi adı</string>
|
||||
<string name="title_webdav_pass">Şifrə</string>
|
||||
<string name="title_webdav_remote_path">Uzaq qovluq (istəyə bağlı)</string>
|
||||
|
||||
<string name="share_method_qrcode">QR kod</string>
|
||||
<string name="share_method_clipboard">Profil keçidini kopyala</string>
|
||||
<string name="share_method_full_content">Tam konfiqurasiyanı kopyala</string>
|
||||
<string name="action_edit">Düzəliş et</string>
|
||||
<string name="action_delete">Sil</string>
|
||||
<string name="action_close">Bağla</string>
|
||||
|
||||
<string name="share_subscription_qrcode">QR kod</string>
|
||||
<string name="share_subscription_clipboard">Mübadilə buferinə ixrac et</string>
|
||||
|
||||
<string-array name="mode_entries">
|
||||
<item>VPN</item>
|
||||
<item>Yalnız proksi</item>
|
||||
</string-array>
|
||||
|
||||
<string-array name="browser_dialer_mode">
|
||||
<item>Qapat</item>
|
||||
<item>OkHttp</item>
|
||||
<item>WebView</item>
|
||||
</string-array>
|
||||
|
||||
<string-array name="ui_mode_night">
|
||||
<item>Sistem Standartı</item>
|
||||
<item>İşıqlı</item>
|
||||
<item>Qaranlıq</item>
|
||||
</string-array>
|
||||
|
||||
<string name="routing_preset_china_whitelist">Çin Ağ Siyahısı</string>
|
||||
<string name="routing_preset_china_blacklist">Çin Qara Siyahısı</string>
|
||||
<string name="routing_preset_global">Qlobal</string>
|
||||
<string name="routing_preset_iran_whitelist">İran Ağ Siyahısı</string>
|
||||
<string name="routing_preset_russia_whitelist">Rusiya Ağ Siyahısı</string>
|
||||
|
||||
<string-array name="vpn_bypass_lan">
|
||||
<item>Konfiqurasiyaya uyğunlaş</item>
|
||||
<item>Yan keç</item>
|
||||
<item>Yan keçmə</item>
|
||||
</string-array>
|
||||
|
||||
<string-array name="outbound_domain_resolve_method">
|
||||
<item>Həll etmə</item>
|
||||
<item>Həll et və DNS Hosts-a əlavə et</item>
|
||||
<item>Həll et və domeni əvəzlə</item>
|
||||
</string-array>
|
||||
|
||||
<string-array name="policy_group_type">
|
||||
<item>Ən az gecikmə</item>
|
||||
<item>Ən az yükləmə</item>
|
||||
<item>Təsadüfi</item>
|
||||
<item>Növbəli</item>
|
||||
</string-array>
|
||||
|
||||
<string name="backup_location_local">Yerli</string>
|
||||
<string name="backup_location_webdav" translatable="false">WebDAV</string>
|
||||
|
||||
<string name="acc_refresh">Jurnalı yenilə</string>
|
||||
<string name="toast_permission_denied_for">%1$s icazəsi rədd edildi</string>
|
||||
<string name="acc_add_subscription">Abunəlik əlavə et</string>
|
||||
<string name="toast_malformed_json_detail">Yanlış konfiqurasiya: %1$s</string>
|
||||
<string name="acc_add_asset">Resurs mənbəyi əlavə et</string>
|
||||
<string name="acc_download_file">Resurs fayllarını endir</string>
|
||||
<string name="acc_qr_code">QR kod</string>
|
||||
<string name="acc_copy_log">Jurnalı kopyala</string>
|
||||
<string name="acc_clear_log">Jurnalı təmizlə</string>
|
||||
<string name="acc_add_rule">Marşrutlaşdırma qaydası əlavə et</string>
|
||||
<string name="acc_start">Xidməti başlat</string>
|
||||
<string name="acc_stop">Xidməti dayandır</string>
|
||||
<string name="acc_back">Geri</string>
|
||||
<string name="acc_search">Axtar</string>
|
||||
<string name="acc_add">Konfiqurasiya əlavə et</string>
|
||||
<string name="acc_more">Əlavə seçimlər</string>
|
||||
<string name="acc_selected_server">Seçildi</string>
|
||||
<string name="acc_share_subscription">Abunəlik keçidini paylaş</string>
|
||||
<string name="acc_share_log">Jurnalı paylaş</string>
|
||||
<string name="acc_remove">Proksi zənciri üzvünü çıxar</string>
|
||||
<string name="acc_add_member">Proksi zənciri üzvü əlavə et</string>
|
||||
<string name="acc_start_scanner">QR skaneri başlat</string>
|
||||
<string name="acc_stop_scanner">QR skaneri dayandır</string>
|
||||
<string name="acc_turn_torch_on">Fənəri yandır</string>
|
||||
<string name="acc_turn_torch_off">Fənəri söndür</string>
|
||||
<string name="acc_select_image">Skan etmək üçün şəkil seçin</string>
|
||||
<string name="acc_locked">Hazır tənzimləmələri idxal edərkən bu qayda saxlanılır</string>
|
||||
<string name="acc_open_menu">Naviqasiya menyusunu açın</string>
|
||||
<string name="acc_edit">Düzəliş et</string>
|
||||
<string name="acc_delete">Sil</string>
|
||||
<string name="acc_save">Saxla</string>
|
||||
<string name="acc_update_subscriptions">Abunəlikləri yenilə</string>
|
||||
<string name="acc_per_app_proxy_information">Tətbiq üzrə proksi məlumatı</string>
|
||||
<string name="permission_camera">Kamera</string>
|
||||
<string name="permission_notification">Bildiriş</string>
|
||||
<string name="permission_local_network">Yerli şəbəkə</string>
|
||||
<string name="subscription_update_updating">Abunəlik yenilənir: %1$s</string>
|
||||
<string name="subscription_update_progress">%2$s sınaqdan keçirilir: %1$s</string>
|
||||
<string name="tasker_blurb_start">%1$s Başlat</string>
|
||||
<string name="tasker_blurb_stop">%1$s Dayandır</string>
|
||||
<string name="connection_test_empty_message">ətraflı məlumat yoxdur</string>
|
||||
<string name="value_unknown">naməlum</string>
|
||||
<string name="sub_info_open_support">Dəstək linkini aç</string>
|
||||
<string name="sub_info_open_page">Abunəlik səhifəsini aç</string>
|
||||
<string name="sub_info_traffic" translatable="false">%1$s / %2$s</string>
|
||||
<string name="sub_info_unlimited">Limitsiz</string>
|
||||
|
||||
</resources>
|
||||
@@ -103,7 +103,7 @@
|
||||
<string name="server_lab_reserved">Reserved (ঐচ্ছিক, কমা দিয়ে পৃথক)</string>
|
||||
<string name="server_lab_local_address">স্থানীয় ঠিকানা (ঐচ্ছিক IPv4/IPv6, কমা দ্বারা পৃথক করা)</string>
|
||||
<string name="server_lab_local_mtu">MTU (ঐচ্ছিক, ডিফল্ট 1420)</string>
|
||||
<string name="server_lab_remote_dns">remoteDNS (ঐচ্ছিক, কমা দ্বারা পৃথক করা IPv4/IPv6 ঠিকানা, অথবা ডিভাইসের DNS ব্যবহার করতে "local")</string>
|
||||
<string name="server_lab_remote_dns">remoteDNS (ঐচ্ছিক, কমা দ্বারা পৃথক করা IPv4/IPv6 ঠিকানা)</string>
|
||||
<string name="server_lab_kcp_mtu">mKCP MTU (ঐচ্ছিক, ডিফল্ট 1350)</string>
|
||||
<string name="server_lab_kcp_tti">mKCP TTI (ঐচ্ছিক, ডিফল্ট 50 মিলিসেকেন্ড)</string>
|
||||
<string name="toast_success">সফল</string>
|
||||
@@ -560,5 +560,8 @@
|
||||
<string name="tasker_blurb_stop">%1$s বন্ধ করুন</string>
|
||||
<string name="connection_test_empty_message">কোনো বিবরণ পাওয়া যায়নি</string>
|
||||
<string name="value_unknown">অজানা</string>
|
||||
<string name="sub_info_open_support">সাপোর্ট লিঙ্ক খুলুন</string>
|
||||
<string name="sub_info_open_page">সাবস্ক্রিপশন তথ্যের পাতা খুলুন</string>
|
||||
<string name="sub_info_unlimited">সীমাহীন</string>
|
||||
|
||||
</resources>
|
||||
@@ -560,5 +560,8 @@
|
||||
<string name="tasker_blurb_stop">واڌاشتن %1$s</string>
|
||||
<string name="connection_test_empty_message">دووسمندی نؽ</string>
|
||||
<string name="value_unknown">نا موشخس</string>
|
||||
<string name="sub_info_open_support">واز کردن لینگ پشتیوانی</string>
|
||||
<string name="sub_info_open_page">واز کردن صفحه اطلاعات اشتراک</string>
|
||||
<string name="sub_info_unlimited">بیحد</string>
|
||||
|
||||
</resources>
|
||||
@@ -103,7 +103,7 @@
|
||||
<string name="server_lab_reserved">Reserved (اختیاری، جدا شده با کاما)</string>
|
||||
<string name="server_lab_local_address">آدرس محلی (IPv4/IPv6 اختیاری، جدا شده با کاما)</string>
|
||||
<string name="server_lab_local_mtu">MTU (اختیاری، پیشفرض 1420)</string>
|
||||
<string name="server_lab_remote_dns">remoteDNS (اختیاری، آدرسهای IPv4/IPv6 جدا شده با کاما، یا "local" برای استفاده از DNS دستگاه)</string>
|
||||
<string name="server_lab_remote_dns">remoteDNS (اختیاری، آدرسهای IPv4/IPv6 جدا شده با کاما)</string>
|
||||
<string name="server_lab_kcp_mtu">mKCP MTU (اختیاری، پیشفرض 1350)</string>
|
||||
<string name="server_lab_kcp_tti">mKCP TTI (اختیاری، پیشفرض 50 میلیثانیه)</string>
|
||||
<string name="toast_success">موفقیتآمیز</string>
|
||||
@@ -560,5 +560,8 @@
|
||||
<string name="tasker_blurb_stop">توقف %1$s</string>
|
||||
<string name="connection_test_empty_message">جزئیاتی در دسترس نیست</string>
|
||||
<string name="value_unknown">ناشناخته</string>
|
||||
<string name="sub_info_open_support">باز کردن لینک پشتیبانی</string>
|
||||
<string name="sub_info_open_page">باز کردن صفحه اطلاعات اشتراک</string>
|
||||
<string name="sub_info_unlimited">نامحدود</string>
|
||||
|
||||
</resources>
|
||||
@@ -103,7 +103,7 @@
|
||||
<string name="server_lab_reserved">Reserved (необязательно, через запятую)</string>
|
||||
<string name="server_lab_local_address">Локальный адрес (необязательно, IPv4/IPv6 через запятую)</string>
|
||||
<string name="server_lab_local_mtu">MTU (необязательно, по умолчанию 1420)</string>
|
||||
<string name="server_lab_remote_dns">remoteDNS (необязательно, IPv4/IPv6-адреса через запятую, либо "local" для использования локального DNS)</string>
|
||||
<string name="server_lab_remote_dns">remoteDNS (необязательно, IPv4/IPv6-адреса через запятую)</string>
|
||||
<string name="server_lab_kcp_mtu">mKCP MTU (необязательно, по умолчанию 1350)</string>
|
||||
<string name="server_lab_kcp_tti">mKCP TTI (необязательно, по умолчанию 50 мс)</string>
|
||||
<string name="toast_success">Успешно</string>
|
||||
@@ -564,5 +564,8 @@
|
||||
<string name="tasker_blurb_stop">Остановить %1$s</string>
|
||||
<string name="connection_test_empty_message">подробности отсутствуют</string>
|
||||
<string name="value_unknown">неизвестно</string>
|
||||
<string name="sub_info_open_support">Открыть ссылку поддержки</string>
|
||||
<string name="sub_info_open_page">Открыть страницу подписки</string>
|
||||
<string name="sub_info_unlimited">Без ограничений</string>
|
||||
|
||||
</resources>
|
||||
@@ -103,7 +103,7 @@
|
||||
<string name="server_lab_reserved">Reserved (không bắt buộc, phân cách bằng dấu phẩy)</string>
|
||||
<string name="server_lab_local_address">Địa chỉ cục bộ (IPv4/IPv6 không bắt buộc, phân cách bằng dấu phẩy)</string>
|
||||
<string name="server_lab_local_mtu">MTU (không bắt buộc, mặc định 1420)</string>
|
||||
<string name="server_lab_remote_dns">remoteDNS (tùy chọn, các địa chỉ IPv4/IPv6 cách nhau bằng dấu phẩy, hoặc "local" để dùng DNS của thiết bị)</string>
|
||||
<string name="server_lab_remote_dns">remoteDNS (tùy chọn, các địa chỉ IPv4/IPv6 cách nhau bằng dấu phẩy)</string>
|
||||
<string name="server_lab_kcp_mtu">mKCP MTU (không bắt buộc, mặc định 1350)</string>
|
||||
<string name="server_lab_kcp_tti">mKCP TTI (không bắt buộc, mặc định 50 ms)</string>
|
||||
<string name="toast_success">Thành công!</string>
|
||||
@@ -558,5 +558,8 @@
|
||||
<string name="tasker_blurb_stop">Dừng %1$s</string>
|
||||
<string name="connection_test_empty_message">không có thông tin chi tiết</string>
|
||||
<string name="value_unknown">không xác định</string>
|
||||
<string name="sub_info_open_support">Mở liên kết hỗ trợ</string>
|
||||
<string name="sub_info_open_page">Mở trang thông tin đăng ký</string>
|
||||
<string name="sub_info_unlimited">Không giới hạn</string>
|
||||
|
||||
</resources>
|
||||
@@ -103,7 +103,7 @@
|
||||
<string name="server_lab_reserved">Reserved (可选,逗号隔开)</string>
|
||||
<string name="server_lab_local_address">本地地址 (可选 IPv4/IPv6,逗号隔开)</string>
|
||||
<string name="server_lab_local_mtu">MTU(可选,默认 1420)</string>
|
||||
<string name="server_lab_remote_dns">remoteDNS(可选,多个 IPv4/IPv6 地址用逗号分隔,或填写 "local" 使用设备本地 DNS)</string>
|
||||
<string name="server_lab_remote_dns">remoteDNS(可选,多个 IPv4/IPv6 地址用逗号分隔)</string>
|
||||
<string name="server_lab_kcp_mtu">mKCP MTU(可选,默认 1350)</string>
|
||||
<string name="server_lab_kcp_tti">mKCP TTI(可选,默认 50 毫秒)</string>
|
||||
<string name="toast_success">成功</string>
|
||||
@@ -558,5 +558,8 @@
|
||||
<string name="tasker_blurb_stop">停止 %1$s</string>
|
||||
<string name="connection_test_empty_message">无详细信息</string>
|
||||
<string name="value_unknown">未知</string>
|
||||
<string name="sub_info_open_support">打开支持链接</string>
|
||||
<string name="sub_info_open_page">打开订阅信息页面</string>
|
||||
<string name="sub_info_unlimited">无限制</string>
|
||||
|
||||
</resources>
|
||||
@@ -103,7 +103,7 @@
|
||||
<string name="server_lab_reserved">Reserved (可選,逗號隔開)</string>
|
||||
<string name="server_lab_local_address">本機位址 (可選 IPv4/IPv6,逗號隔開)</string>
|
||||
<string name="server_lab_local_mtu">MTU(可選,預設 1420)</string>
|
||||
<string name="server_lab_remote_dns">remoteDNS(選填,多個 IPv4/IPv6 位址以逗號分隔,或輸入 "local" 使用裝置本機 DNS)</string>
|
||||
<string name="server_lab_remote_dns">remoteDNS(選填,多個 IPv4/IPv6 位址以逗號分隔)</string>
|
||||
<string name="server_lab_kcp_mtu">mKCP MTU(可選,預設 1350)</string>
|
||||
<string name="server_lab_kcp_tti">mKCP TTI(可選,預設 50 毫秒)</string>
|
||||
<string name="toast_success">成功</string>
|
||||
@@ -558,5 +558,8 @@
|
||||
<string name="tasker_blurb_stop">停止 %1$s</string>
|
||||
<string name="connection_test_empty_message">沒有詳細資訊</string>
|
||||
<string name="value_unknown">未知</string>
|
||||
<string name="sub_info_open_support">開啟支援連結</string>
|
||||
<string name="sub_info_open_page">開啟訂閱資訊頁面</string>
|
||||
<string name="sub_info_unlimited">無限制</string>
|
||||
|
||||
</resources>
|
||||
@@ -146,6 +146,7 @@
|
||||
<item>Русский</item>
|
||||
<item>فارسی</item>
|
||||
<item>عربي</item>
|
||||
<item>Azərbaycanca</item>
|
||||
<item>বাংলা</item>
|
||||
<item>لۊری بختیاری</item>
|
||||
</string-array>
|
||||
@@ -160,6 +161,7 @@
|
||||
<item>ru</item>
|
||||
<item>fa</item>
|
||||
<item>ar</item>
|
||||
<item>az</item>
|
||||
<item>bn</item>
|
||||
<item>bqi-rIR</item>
|
||||
</string-array>
|
||||
|
||||
@@ -103,7 +103,7 @@
|
||||
<string name="server_lab_reserved">Reserved (optional, separated by commas)</string>
|
||||
<string name="server_lab_local_address">Local address (optional IPv4/IPv6, separated by commas)</string>
|
||||
<string name="server_lab_local_mtu">MTU (optional, default 1420)</string>
|
||||
<string name="server_lab_remote_dns">remoteDNS (optional, comma-separated IPv4/IPv6, or "local" to use device DNS)</string>
|
||||
<string name="server_lab_remote_dns">remoteDNS (optional, comma-separated IPv4/IPv6)</string>
|
||||
<string name="server_lab_kcp_mtu">mKCP MTU (optional, default 1350)</string>
|
||||
<string name="server_lab_kcp_tti">mKCP TTI (optional, default 50 ms)</string>
|
||||
<string name="toast_success">Success</string>
|
||||
@@ -560,5 +560,9 @@
|
||||
<string name="tasker_blurb_stop">Stop %1$s</string>
|
||||
<string name="connection_test_empty_message">no details available</string>
|
||||
<string name="value_unknown">unknown</string>
|
||||
<string name="sub_info_open_support">Open support link</string>
|
||||
<string name="sub_info_open_page">Open subscription page</string>
|
||||
<string name="sub_info_traffic" translatable="false">%1$s / %2$s</string>
|
||||
<string name="sub_info_unlimited">Unlimited</string>
|
||||
|
||||
</resources>
|
||||
@@ -0,0 +1,89 @@
|
||||
package com.v2ray.ang.fmt
|
||||
|
||||
import com.v2ray.ang.AppConfig
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.net.URI
|
||||
|
||||
class Hysteria2FmtTest {
|
||||
|
||||
@Test
|
||||
fun hysteria2WithoutPortUses443() {
|
||||
val profile = Hysteria2Fmt.parse("hysteria2://test@example.com/")
|
||||
|
||||
assertEquals("example.com", profile.server)
|
||||
assertEquals("443", profile.serverPort)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun hy2WithoutPortUses443() {
|
||||
val profile = Hysteria2Fmt.parse("hy2://test@example.com/")
|
||||
|
||||
assertEquals("example.com", profile.server)
|
||||
assertEquals("443", profile.serverPort)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun uriWithoutCredentialsUses443() {
|
||||
val profile = Hysteria2Fmt.parse("hysteria2://example.com/")
|
||||
|
||||
assertEquals("example.com", profile.server)
|
||||
assertEquals("443", profile.serverPort)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun omittedPortPreservesTlsParameters() {
|
||||
val profile = Hysteria2Fmt.parse(
|
||||
"hy2://test@example.com/?sni=example.org&insecure=0&pinSHA256=test-pin#example"
|
||||
)
|
||||
|
||||
assertEquals("test", profile.password)
|
||||
assertEquals("example", profile.remarks)
|
||||
assertEquals(AppConfig.TLS, profile.security)
|
||||
assertEquals("example.org", profile.sni)
|
||||
assertEquals(false, profile.insecure)
|
||||
assertEquals("test-pin", profile.pinnedCA256)
|
||||
assertEquals("443", profile.serverPort)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun ipv6WithoutPortUses443() {
|
||||
val profile = Hysteria2Fmt.parse("hysteria2://test@[2001:db8::1]/")
|
||||
|
||||
assertEquals("2001:db8::1", profile.server)
|
||||
assertEquals("443", profile.serverPort)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun explicitPortsArePreserved() {
|
||||
for (scheme in listOf("hysteria2", "hy2")) {
|
||||
// Port range validation is separate from choosing a missing-port default.
|
||||
for (port in listOf(0, 443, 8443, 65535, 65536)) {
|
||||
val profile = Hysteria2Fmt.parse("$scheme://test@example.com:$port/")
|
||||
|
||||
assertEquals("example.com", profile.server)
|
||||
assertEquals(port.toString(), profile.serverPort)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nonnumericPortDoesNotAcquireAHost() {
|
||||
val profile = Hysteria2Fmt.parse("hysteria2://test@example.com:invalid/")
|
||||
|
||||
assertEquals("", profile.server)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun defaultPortSurvivesUriRoundTrip() {
|
||||
val profile = Hysteria2Fmt.parse("hy2://test@example.com/?sni=example.org")
|
||||
val exported = "hysteria2://${Hysteria2Fmt.toUri(profile)}"
|
||||
val reparsed = Hysteria2Fmt.parse(exported)
|
||||
|
||||
assertEquals(443, URI(exported).port)
|
||||
assertEquals("443", reparsed.serverPort)
|
||||
assertEquals(profile.server, reparsed.server)
|
||||
assertEquals(profile.password, reparsed.password)
|
||||
assertEquals(profile.sni, reparsed.sni)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package com.v2ray.ang.handler
|
||||
|
||||
import com.v2ray.ang.dto.entities.SubscriptionUsageInfo
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.util.Base64
|
||||
|
||||
class SubscriptionInfoParserTest {
|
||||
private val decode: (String) -> String = { String(Base64.getDecoder().decode(it), Charsets.UTF_8) }
|
||||
|
||||
private fun parse(
|
||||
userInfo: String? = null,
|
||||
title: String? = null,
|
||||
support: String? = null,
|
||||
web: String? = null,
|
||||
) = SubscriptionInfoParser.parse(userInfo, title, support, web, decode)
|
||||
|
||||
@Test
|
||||
fun noHeadersGivesNull() {
|
||||
assertNull(parse())
|
||||
assertNull(parse(userInfo = " ", title = "", support = "ftp://x", web = "javascript:alert(1)"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parsesStandardUserInfo() {
|
||||
val info = parse(userInfo = "upload=100; download=200; total=1000; expire=1893456000")!!
|
||||
assertEquals(100, info.upload)
|
||||
assertEquals(200, info.download)
|
||||
assertEquals(300, info.used)
|
||||
assertEquals(1000, info.total)
|
||||
assertEquals(1893456000, info.expire)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun toleratesCaseSpacesFloatsAndUnknownKeys() {
|
||||
val info = parse(userInfo = "Upload=1.5e3;DOWNLOAD = 10 ;foo=bar;total=-5;expire=")!!
|
||||
assertEquals(1500, info.upload)
|
||||
assertEquals(10, info.download)
|
||||
assertEquals(0, info.total)
|
||||
assertEquals(0, info.expire)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun expireInMillisecondsIsNormalizedToSeconds() {
|
||||
assertEquals(1893456000, parse(userInfo = "expire=1893456000000")!!.expire)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun garbageUserInfoWithoutOtherHeadersGivesNull() {
|
||||
assertNull(parse(userInfo = "hello world"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun decodesBase64PlainAndPercentEncodedTitles() {
|
||||
val b64 = Base64.getEncoder().encodeToString("اشتراک من".toByteArray())
|
||||
assertEquals("اشتراک من", parse(title = "base64:$b64")!!.title)
|
||||
assertEquals("My Plan", parse(title = "My Plan")!!.title)
|
||||
assertEquals("My Plan", parse(title = "My%20Plan")!!.title)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onlyHttpHttpsAndTgLinksAreAccepted() {
|
||||
assertEquals("https://t.me/support", parse(support = " https://t.me/support ")!!.supportUrl)
|
||||
assertEquals("tg://resolve?domain=x", parse(support = "tg://resolve?domain=x")!!.supportUrl)
|
||||
assertNull(SubscriptionInfoParser.sanitizeLink("javascript:alert(1)"))
|
||||
assertNull(SubscriptionInfoParser.sanitizeLink("intent://x#Intent;end"))
|
||||
assertNull(SubscriptionInfoParser.sanitizeLink("file:///etc/passwd"))
|
||||
assertNull(SubscriptionInfoParser.sanitizeLink("https://"))
|
||||
assertNull(SubscriptionInfoParser.sanitizeLink(null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun detectsTelegramLinks() {
|
||||
assertTrue(SubscriptionInfoParser.isTelegramLink("https://t.me/abc"))
|
||||
assertTrue(SubscriptionInfoParser.isTelegramLink("tg://resolve?domain=abc"))
|
||||
assertFalse(SubscriptionInfoParser.isTelegramLink("https://example.com/t.me"))
|
||||
assertFalse(SubscriptionInfoParser.isTelegramLink(null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun webPageFallsBackToSubscriptionUrl() {
|
||||
val base = SubscriptionUsageInfo(total = 1)
|
||||
assertEquals("https://p.example/sub/abc", SubscriptionInfoParser.withWebPageFallback(base, "https://p.example/sub/abc").webPageUrl)
|
||||
val own = base.copy(webPageUrl = "https://p.example/info")
|
||||
assertEquals("https://p.example/info", SubscriptionInfoParser.withWebPageFallback(own, "https://p.example/sub/abc").webPageUrl)
|
||||
assertNull(SubscriptionInfoParser.withWebPageFallback(base, "not a url").webPageUrl)
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ class RootProcessRunnerTest {
|
||||
}
|
||||
@Test fun `inherited stdout cannot hold a completed command open`() {
|
||||
val start = System.nanoTime()
|
||||
val result = RootProcessRunner.run(listOf("sh", "-c", "sleep 5 & echo complete"), 250)
|
||||
val result = RootProcessRunner.run(listOf("sh", "-c", "sleep 5 & echo complete"), 3000)
|
||||
assertEquals(0, result.code)
|
||||
assertTrue(result.output.contains("complete"))
|
||||
assertTrue(TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start) < 1500)
|
||||
|
||||
@@ -1,10 +1,43 @@
|
||||
package com.v2ray.ang.root
|
||||
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class RootProxyManagerTest {
|
||||
|
||||
@Test
|
||||
fun `IPv6 LAN forwarding disables jumbo and TSO`() {
|
||||
val config = RootProxyManager.buildHevConfig(null, null, 10808, true, 1500, true)
|
||||
|
||||
assertTrue(config.contains(" offload:\n jumbo: false\n"))
|
||||
assertTrue(config.contains(" multi-queue: true\n"))
|
||||
assertTrue(config.contains(" tso: false\n"))
|
||||
assertFalse(config.contains("uso:"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `local root keeps default offloads and configured MTU`() {
|
||||
val config = RootProxyManager.buildHevConfig(null, null, 10808, true, 8500, false)
|
||||
|
||||
assertFalse(config.contains("offload:"))
|
||||
assertTrue(config.contains(" mtu: 8500\n"))
|
||||
assertTrue(config.contains(" ipv6:"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `LAN offload guard preserves IPv4 only and quoted authentication`() {
|
||||
val config = RootProxyManager.buildHevConfig("user'name", "pa'ss", 10809, false, 1500, true)
|
||||
|
||||
assertTrue(config.contains(" offload:\n jumbo: false\n"))
|
||||
assertFalse(config.contains("tso:"))
|
||||
assertFalse(config.contains(" ipv6:"))
|
||||
assertTrue(config.contains(" port: 10809\n"))
|
||||
assertTrue(config.contains(" username: 'user''name'\n"))
|
||||
assertTrue(config.contains(" password: 'pa''ss'\n"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `single quotes are escaped in YAML scalars`() {
|
||||
assertEquals("'user''name'", "user'name".toSingleQuotedYamlScalar())
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.v2ray.ang.ui.main
|
||||
|
||||
import com.v2ray.ang.dto.entities.SubscriptionUsageInfo
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class MainSubscriptionInfoTest {
|
||||
private val now = 1_000_000_000_000L // ms
|
||||
|
||||
@Test
|
||||
fun limitedTrafficComputesFraction() {
|
||||
val model = buildSubscriptionInfoUiModel(SubscriptionUsageInfo(upload = 100, download = 300, total = 1000), now)
|
||||
assertEquals(400, model.usedBytes)
|
||||
assertEquals(0.4f, model.trafficFraction!!, 0.0001f)
|
||||
assertFalse(model.trafficLow)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unlimitedTrafficHasNoFraction() {
|
||||
val model = buildSubscriptionInfoUiModel(SubscriptionUsageInfo(download = 5), now)
|
||||
assertEquals(0L, model.totalBytes)
|
||||
assertNull(model.trafficFraction)
|
||||
assertFalse(model.trafficLow)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun overUsageIsClampedAndFlaggedLow() {
|
||||
val model = buildSubscriptionInfoUiModel(SubscriptionUsageInfo(download = 2000, total = 1000), now)
|
||||
assertEquals(1f, model.trafficFraction!!, 0.0001f)
|
||||
assertTrue(model.trafficLow)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun expiryIsConvertedToMillisAndFlaggedWhenPast() {
|
||||
val future = buildSubscriptionInfoUiModel(SubscriptionUsageInfo(expire = now / 1000 + 60), now)
|
||||
assertEquals(now + 60_000, future.expireMillis)
|
||||
assertFalse(future.expired)
|
||||
assertTrue(buildSubscriptionInfoUiModel(SubscriptionUsageInfo(expire = now / 1000 - 1), now).expired)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun zeroExpireMeansNeverExpires() {
|
||||
val model = buildSubscriptionInfoUiModel(SubscriptionUsageInfo(expire = 0), now)
|
||||
assertNull(model.expireMillis)
|
||||
assertFalse(model.expired)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.v2ray.ang.util
|
||||
|
||||
import com.google.gson.JsonParser
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class JsonUtilTest {
|
||||
|
||||
@Test
|
||||
fun toJsonPrettyKeepsNumbersOfConfigArrayElements() {
|
||||
val configs = JsonUtil.fromJson(
|
||||
"""[{"port":443,"tolerance":0.4,"offset":-0.5,"timestamp":1700000000000,"name":"0.4"}]""",
|
||||
Array<Any>::class.java
|
||||
)
|
||||
|
||||
val output = JsonParser.parseString(JsonUtil.toJsonPretty(configs!![0])).asJsonObject
|
||||
|
||||
assertEquals("443", output["port"].asString)
|
||||
assertEquals("0.4", output["tolerance"].asString)
|
||||
assertEquals("-0.5", output["offset"].asString)
|
||||
assertEquals("1700000000000", output["timestamp"].asString)
|
||||
assertEquals("0.4", output["name"].asString)
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
agp = "9.4.1"
|
||||
desugarJdkLibs = "2.1.5"
|
||||
gradleLicensePlugin = "0.9.91"
|
||||
kotlin = "2.4.20"
|
||||
kotlin = "2.4.21"
|
||||
coreKtx = "1.19.1"
|
||||
junit = "6.1.3"
|
||||
junitVersion = "1.3.0"
|
||||
@@ -11,7 +11,7 @@ activity = "1.13.0"
|
||||
appcompat = "1.8.0"
|
||||
mmkvStatic = "1.3.17"
|
||||
gson = "2.14.0"
|
||||
okhttp = "5.5.0"
|
||||
okhttp = "5.5.1"
|
||||
kotlinxCoroutines = "1.11.0"
|
||||
core = "3.5.4"
|
||||
workRuntimeKtx = "2.12.0"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
#Thu Nov 14 12:42:51 BDT 2024
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.0-bin.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.2-bin.zip
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
+1
-1
Submodule hev-socks5-tunnel updated: d9dca26c7a...c5584feca8.
Reference in new issue
Block a user