mirror of
https://github.com/2dust/v2rayNG.git
synced 2026-11-03 19:54:30 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
57907cf354 | ||
|
|
f94e997870 | ||
|
|
fc926f5291 | ||
|
|
5cb002f69d | ||
|
|
35f5c4e538 | ||
|
|
fa30dc1a31 | ||
|
|
aa4b05503a | ||
|
|
7179f0838f | ||
|
|
0d1c57000f | ||
|
|
40b8706ccd | ||
|
|
0f961c54c8 | ||
|
|
1b78a510f2 | ||
|
|
01b4630fdd | ||
|
|
6214bc19d4 | ||
|
|
b8d13bf52c | ||
|
|
3165bcf4fe | ||
|
|
eedb287cc9 | ||
|
|
59ff87606f | ||
|
|
700740a187 | ||
|
|
bb59a71cb9 | ||
|
|
552cc2425d | ||
|
|
c8f373bd3f | ||
|
|
118cf5bc7d | ||
|
|
58ca4cfe23 |
No files matched your search
@@ -81,7 +81,7 @@ jobs:
|
|||||||
out-file-path: V2rayNG/app/libs/
|
out-file-path: V2rayNG/app/libs/
|
||||||
|
|
||||||
- name: Setup Java
|
- name: Setup Java
|
||||||
uses: actions/setup-java@v6.0.1
|
uses: actions/setup-java@v6.1.0
|
||||||
with:
|
with:
|
||||||
distribution: 'temurin'
|
distribution: 'temurin'
|
||||||
java-version: '21'
|
java-version: '21'
|
||||||
|
|||||||
+1
-1
Submodule AndroidLibXrayLite updated: 1f3e2c5df1...2ffe5f794a.
@@ -13,8 +13,8 @@ android {
|
|||||||
applicationId = "com.v2ray.ang"
|
applicationId = "com.v2ray.ang"
|
||||||
minSdk = 24
|
minSdk = 24
|
||||||
targetSdk = 37
|
targetSdk = 37
|
||||||
versionCode = 751
|
versionCode = 755
|
||||||
versionName = "2.3.11"
|
versionName = "2.3.15"
|
||||||
|
|
||||||
val abiFilterList = (properties["ABI_FILTERS"] as? String)?.split(';')
|
val abiFilterList = (properties["ABI_FILTERS"] as? String)?.split(';')
|
||||||
splits {
|
splits {
|
||||||
|
|||||||
@@ -888,8 +888,6 @@ object CoreConfigManager {
|
|||||||
val remoteDns = SettingsManager.getRemoteDnsServers()
|
val remoteDns = SettingsManager.getRemoteDnsServers()
|
||||||
val domesticDns = SettingsManager.getDomesticDnsServers()
|
val domesticDns = SettingsManager.getDomesticDnsServers()
|
||||||
|
|
||||||
remoteDns.forEach { servers.add(it) }
|
|
||||||
|
|
||||||
val hosts = buildDnsHostsFromRoutingRules(configContext)
|
val hosts = buildDnsHostsFromRoutingRules(configContext)
|
||||||
val cnDomesticDnsTags = buildDnsCnModeFromRoutingRules(configContext, servers, domesticDns)
|
val cnDomesticDnsTags = buildDnsCnModeFromRoutingRules(configContext, servers, domesticDns)
|
||||||
val domesticDnsTags = buildDnsFromRoutingRules(
|
val domesticDnsTags = buildDnsFromRoutingRules(
|
||||||
@@ -900,6 +898,8 @@ object CoreConfigManager {
|
|||||||
)
|
)
|
||||||
domesticDnsTags.addAll(cnDomesticDnsTags)
|
domesticDnsTags.addAll(cnDomesticDnsTags)
|
||||||
|
|
||||||
|
remoteDns.forEach { servers.add(it) }
|
||||||
|
|
||||||
v2rayConfig.dns = V2rayConfig.DnsBean(
|
v2rayConfig.dns = V2rayConfig.DnsBean(
|
||||||
servers = servers,
|
servers = servers,
|
||||||
hosts = hosts,
|
hosts = hosts,
|
||||||
@@ -1042,6 +1042,7 @@ object CoreConfigManager {
|
|||||||
address = address,
|
address = address,
|
||||||
domains = rule.domain,
|
domains = rule.domain,
|
||||||
skipFallback = true,
|
skipFallback = true,
|
||||||
|
finalQuery = if (dnsIndex == domesticDns.lastIndex) true else null,
|
||||||
tag = tag
|
tag = tag
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -1051,12 +1052,15 @@ object CoreConfigManager {
|
|||||||
|
|
||||||
AppConfig.TAG_BLOCKED -> Unit
|
AppConfig.TAG_BLOCKED -> Unit
|
||||||
else -> {
|
else -> {
|
||||||
servers.add(
|
remoteDns.forEachIndexed { index, address ->
|
||||||
V2rayConfig.DnsBean.ServersBean(
|
servers.add(
|
||||||
address = remoteDns.first(),
|
V2rayConfig.DnsBean.ServersBean(
|
||||||
domains = rule.domain,
|
address = address,
|
||||||
|
domains = rule.domain,
|
||||||
|
finalQuery = if (index == remoteDns.lastIndex) true else null
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -252,18 +252,21 @@ object CoreOutboundBuilder {
|
|||||||
ipv4Addresses.ifEmpty { listOf(AppConfig.WIREGUARD_LOCAL_ADDRESS_V4) }
|
ipv4Addresses.ifEmpty { listOf(AppConfig.WIREGUARD_LOCAL_ADDRESS_V4) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val defaultDns = AppConfig.WIREGUARD_LOCAL_REMOTE_DNS.split(",").map { it.trim() }
|
||||||
|
|
||||||
val rawDNS = profileItem.remoteDNS
|
val rawDNS = profileItem.remoteDNS
|
||||||
?.split(",")
|
?.split(",")
|
||||||
?.map { it.trim() }
|
?.map { it.trim() }
|
||||||
?.filter { it.isNotEmpty() }
|
?.filter { it.isNotEmpty() }
|
||||||
?.ifEmpty { null }
|
?.ifEmpty { null }
|
||||||
?: listOf(AppConfig.WIREGUARD_LOCAL_REMOTE_DNS)
|
?: defaultDns
|
||||||
|
|
||||||
val remotes = if (MmkvManager.decodeSettingsBool(AppConfig.PREF_IPV6_ENABLED) == true) {
|
val remotes = if (MmkvManager.decodeSettingsBool(AppConfig.PREF_IPV6_ENABLED) == true) {
|
||||||
rawDNS
|
rawDNS
|
||||||
} else {
|
} else {
|
||||||
val ipv4Dns = rawDNS.filter { !it.contains(":") }
|
rawDNS.filter { !it.contains(":") }
|
||||||
ipv4Dns.ifEmpty { listOf(AppConfig.WIREGUARD_LOCAL_REMOTE_DNS) }
|
// falling back to the IPv4 entries of the default list
|
||||||
|
.ifEmpty { defaultDns.filter { !it.contains(":")} }
|
||||||
}
|
}
|
||||||
|
|
||||||
outboundBean?.settings?.let { wireguard ->
|
outboundBean?.settings?.let { wireguard ->
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package com.v2ray.ang.dto
|
package com.v2ray.ang.dto
|
||||||
|
|
||||||
|
import com.v2ray.ang.dto.entities.SubscriptionUsageInfo
|
||||||
|
|
||||||
data class GroupMapItem(
|
data class GroupMapItem(
|
||||||
var id: String,
|
var id: String,
|
||||||
var remarks: String,
|
var remarks: String,
|
||||||
|
val usageInfo: SubscriptionUsageInfo? = null,
|
||||||
)
|
)
|
||||||
@@ -353,6 +353,7 @@ data class V2rayConfig(
|
|||||||
var expectIPs: List<String>? = null,
|
var expectIPs: List<String>? = null,
|
||||||
val clientIp: String? = null,
|
val clientIp: String? = null,
|
||||||
val skipFallback: Boolean? = null,
|
val skipFallback: Boolean? = null,
|
||||||
|
val finalQuery: Boolean? = null,
|
||||||
val tag: String? = null,
|
val tag: String? = null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,5 +14,6 @@ data class SubscriptionItem(
|
|||||||
var allowInsecureUrl: Boolean = false,
|
var allowInsecureUrl: Boolean = false,
|
||||||
var userAgent: String? = null,
|
var userAgent: String? = null,
|
||||||
var requestHeaders: 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
|
||||||
|
}
|
||||||
@@ -23,7 +23,7 @@ object Hysteria2Fmt : FmtBase() {
|
|||||||
val uri = URI(Utils.fixIllegalUrl(str))
|
val uri = URI(Utils.fixIllegalUrl(str))
|
||||||
config.remarks = Utils.decodeURIComponent(uri.fragment.orEmpty()).let { it.ifEmpty { "none" } }
|
config.remarks = Utils.decodeURIComponent(uri.fragment.orEmpty()).let { it.ifEmpty { "none" } }
|
||||||
config.server = uri.idnHost
|
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.password = uri.userInfo
|
||||||
config.security = AppConfig.TLS
|
config.security = AppConfig.TLS
|
||||||
config.network = NetworkType.HYSTERIA.type
|
config.network = NetworkType.HYSTERIA.type
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ object WireguardFmt : FmtBase() {
|
|||||||
config.publicKey = queryParam["publickey"].orEmpty()
|
config.publicKey = queryParam["publickey"].orEmpty()
|
||||||
config.preSharedKey = queryParam["presharedkey"]?.nullIfBlank()
|
config.preSharedKey = queryParam["presharedkey"]?.nullIfBlank()
|
||||||
config.mtu = Utils.parseInt(queryParam["mtu"] ?: AppConfig.WIREGUARD_LOCAL_MTU)
|
config.mtu = Utils.parseInt(queryParam["mtu"] ?: AppConfig.WIREGUARD_LOCAL_MTU)
|
||||||
config.remoteDNS = queryParam["dns"] ?: AppConfig.WIREGUARD_LOCAL_REMOTE_DNS
|
config.remoteDNS = queryParam["dns"] ?: ""
|
||||||
config.reserved = queryParam["reserved"] ?: "0,0,0"
|
config.reserved = queryParam["reserved"] ?: "0,0,0"
|
||||||
config.finalMask = (queryParam["fm"] ?: queryParam["finalmask"] ?: queryParam["finalMask"])?.nullIfBlank()
|
config.finalMask = (queryParam["fm"] ?: queryParam["finalmask"] ?: queryParam["finalMask"])?.nullIfBlank()
|
||||||
|
|
||||||
@@ -83,7 +83,7 @@ object WireguardFmt : FmtBase() {
|
|||||||
config.remarks = System.currentTimeMillis().toString()
|
config.remarks = System.currentTimeMillis().toString()
|
||||||
config.localAddress = interfaceParams["address"] ?: AppConfig.WIREGUARD_LOCAL_ADDRESS_V4
|
config.localAddress = interfaceParams["address"] ?: AppConfig.WIREGUARD_LOCAL_ADDRESS_V4
|
||||||
config.mtu = Utils.parseInt(interfaceParams["mtu"] ?: AppConfig.WIREGUARD_LOCAL_MTU)
|
config.mtu = Utils.parseInt(interfaceParams["mtu"] ?: AppConfig.WIREGUARD_LOCAL_MTU)
|
||||||
config.remoteDNS = interfaceParams["dns"] ?: AppConfig.WIREGUARD_LOCAL_REMOTE_DNS
|
config.remoteDNS = interfaceParams["dns"] ?: ""
|
||||||
config.publicKey = peerParams["publickey"].orEmpty()
|
config.publicKey = peerParams["publickey"].orEmpty()
|
||||||
config.preSharedKey = peerParams["presharedkey"]?.nullIfBlank()
|
config.preSharedKey = peerParams["presharedkey"]?.nullIfBlank()
|
||||||
val endpoint = peerParams["endpoint"].orEmpty()
|
val endpoint = peerParams["endpoint"].orEmpty()
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import com.v2ray.ang.dto.UrlContentRequest
|
|||||||
import com.v2ray.ang.dto.entities.ProfileItem
|
import com.v2ray.ang.dto.entities.ProfileItem
|
||||||
import com.v2ray.ang.dto.entities.SubscriptionCache
|
import com.v2ray.ang.dto.entities.SubscriptionCache
|
||||||
import com.v2ray.ang.dto.entities.SubscriptionItem
|
import com.v2ray.ang.dto.entities.SubscriptionItem
|
||||||
|
import com.v2ray.ang.dto.entities.SubscriptionUsageInfo
|
||||||
import com.v2ray.ang.enums.EConfigType
|
import com.v2ray.ang.enums.EConfigType
|
||||||
import com.v2ray.ang.extension.isNotNullEmpty
|
import com.v2ray.ang.extension.isNotNullEmpty
|
||||||
import com.v2ray.ang.fmt.CustomFmt
|
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.LogUtil
|
||||||
import com.v2ray.ang.util.QRCodeDecoder
|
import com.v2ray.ang.util.QRCodeDecoder
|
||||||
import com.v2ray.ang.util.Utils
|
import com.v2ray.ang.util.Utils
|
||||||
|
import okhttp3.Headers
|
||||||
import java.net.URI
|
import java.net.URI
|
||||||
|
|
||||||
object AngConfigManager {
|
object AngConfigManager {
|
||||||
@@ -488,6 +490,17 @@ object AngConfigManager {
|
|||||||
val proxyUsername = SettingsManager.getSocksUsername()
|
val proxyUsername = SettingsManager.getSocksUsername()
|
||||||
val proxyPassword = SettingsManager.getSocksPassword()
|
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 {
|
var configText = try {
|
||||||
val httpPort = SettingsManager.getHttpPort()
|
val httpPort = SettingsManager.getHttpPort()
|
||||||
HttpUtil.getUrlContentWithUserAgent(
|
HttpUtil.getUrlContentWithUserAgent(
|
||||||
@@ -499,7 +512,8 @@ object AngConfigManager {
|
|||||||
httpPort = httpPort,
|
httpPort = httpPort,
|
||||||
proxyUsername = proxyUsername,
|
proxyUsername = proxyUsername,
|
||||||
proxyPassword = proxyPassword
|
proxyPassword = proxyPassword
|
||||||
)
|
),
|
||||||
|
captureUsageInfo
|
||||||
)
|
)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
LogUtil.e(AppConfig.ANG_PACKAGE, "Update subscription: proxy not ready or other error", e)
|
LogUtil.e(AppConfig.ANG_PACKAGE, "Update subscription: proxy not ready or other error", e)
|
||||||
@@ -512,7 +526,8 @@ object AngConfigManager {
|
|||||||
url = url,
|
url = url,
|
||||||
userAgent = userAgent,
|
userAgent = userAgent,
|
||||||
requestHeaders = requestHeaders
|
requestHeaders = requestHeaders
|
||||||
)
|
),
|
||||||
|
captureUsageInfo
|
||||||
)
|
)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
LogUtil.e(AppConfig.TAG, "Update subscription: Failed to get URL content with user agent", e)
|
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)
|
val count = parseConfigViaSub(configText, it.guid, false)
|
||||||
if (count > 0) {
|
if (count > 0) {
|
||||||
it.subscription.lastUpdated = System.currentTimeMillis()
|
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)
|
MmkvManager.encodeSubscription(it.guid, it.subscription)
|
||||||
LogUtil.i(AppConfig.TAG, "Subscription updated: ${it.subscription.remarks}, $count configs")
|
LogUtil.i(AppConfig.TAG, "Subscription updated: ${it.subscription.remarks}, $count configs")
|
||||||
return SubscriptionUpdateResult(
|
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 lanShare = forceLanShare || MmkvManager.decodeSettingsBool(AppConfig.PREF_ROOT_LAN_SHARING)
|
||||||
val corePid = Process.myPid()
|
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)) {
|
if (!writeHevConfig(cfgFile, config)) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -209,14 +209,28 @@ object RootProxyManager {
|
|||||||
* tun address when IPv6 is enabled; whether v6 actually flows in is decided separately by
|
* tun address when IPv6 is enabled; whether v6 actually flows in is decided separately by
|
||||||
* the v6 route into [TABLE].
|
* 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 v4 = AppConfig.ROOT_TUN_ADDR_V4.substringBefore("/")
|
||||||
val v6 = AppConfig.ROOT_TUN_ADDR_V6.substringBefore("/")
|
val v6 = AppConfig.ROOT_TUN_ADDR_V6.substringBefore("/")
|
||||||
return buildString {
|
return buildString {
|
||||||
appendLine("tunnel:")
|
appendLine("tunnel:")
|
||||||
appendLine(" name: '$TUN'")
|
appendLine(" name: '$TUN'")
|
||||||
appendLine(" mtu: ${SettingsManager.getVpnMtu()}")
|
appendLine(" mtu: $mtu")
|
||||||
appendLine(" multi-queue: true")
|
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'")
|
appendLine(" ipv4: '$v4'")
|
||||||
if (ipv6) appendLine(" ipv6: '$v6'")
|
if (ipv6) appendLine(" ipv6: '$v6'")
|
||||||
appendLine("socks5:")
|
appendLine("socks5:")
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import com.v2ray.ang.handler.AngConfigManager
|
|||||||
import com.v2ray.ang.handler.MmkvManager
|
import com.v2ray.ang.handler.MmkvManager
|
||||||
import com.v2ray.ang.handler.SettingsChangeManager
|
import com.v2ray.ang.handler.SettingsChangeManager
|
||||||
import com.v2ray.ang.handler.SettingsManager
|
import com.v2ray.ang.handler.SettingsManager
|
||||||
|
import com.v2ray.ang.handler.SubscriptionInfoParser
|
||||||
import com.v2ray.ang.ui.AboutActivity
|
import com.v2ray.ang.ui.AboutActivity
|
||||||
import com.v2ray.ang.ui.backup.BackupActivity
|
import com.v2ray.ang.ui.backup.BackupActivity
|
||||||
import com.v2ray.ang.ui.base.HelperBaseComponentActivity
|
import com.v2ray.ang.ui.base.HelperBaseComponentActivity
|
||||||
@@ -116,6 +117,7 @@ class MainActivity : HelperBaseComponentActivity() {
|
|||||||
is MainAction.EditServer -> editServer(action.guid, action.profile)
|
is MainAction.EditServer -> editServer(action.guid, action.profile)
|
||||||
is MainAction.ShareClipboard -> shareToClipboard(action.guid)
|
is MainAction.ShareClipboard -> shareToClipboard(action.guid)
|
||||||
is MainAction.ShareFullContent -> shareFullContentAsync(action.guid)
|
is MainAction.ShareFullContent -> shareFullContentAsync(action.guid)
|
||||||
|
is MainAction.OpenSubscriptionLink -> openSubscriptionLink(action.url)
|
||||||
else -> mainViewModel.onAction(action)
|
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) {
|
private fun navigateTo(destination: MainDestination) {
|
||||||
val intent = when (destination) {
|
val intent = when (destination) {
|
||||||
MainDestination.Subscriptions -> Intent(this, SubSettingActivity::class.java)
|
MainDestination.Subscriptions -> Intent(this, SubSettingActivity::class.java)
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ sealed interface MainAction {
|
|||||||
data object RemoveInvalidServers : MainAction
|
data object RemoveInvalidServers : MainAction
|
||||||
data object SortByTestResults : MainAction
|
data object SortByTestResults : MainAction
|
||||||
data object UpdateSubscriptions : MainAction
|
data object UpdateSubscriptions : MainAction
|
||||||
|
data class UpdateGroupSubscription(val groupId: String) : MainAction
|
||||||
data object ExportAll : MainAction
|
data object ExportAll : MainAction
|
||||||
|
|
||||||
data object ImportQRcode : MainAction
|
data object ImportQRcode : MainAction
|
||||||
@@ -63,6 +64,7 @@ sealed interface MainAction {
|
|||||||
data class ShareClipboard(val guid: String) : MainAction
|
data class ShareClipboard(val guid: String) : MainAction
|
||||||
data class ShareFullContent(val guid: String) : MainAction
|
data class ShareFullContent(val guid: String) : MainAction
|
||||||
data object DismissQRCodeDialog : MainAction
|
data object DismissQRCodeDialog : MainAction
|
||||||
|
data class OpenSubscriptionLink(val url: String) : MainAction
|
||||||
|
|
||||||
data class ImportBatchConfig(val configText: String) : MainAction
|
data class ImportBatchConfig(val configText: String) : MainAction
|
||||||
|
|
||||||
|
|||||||
@@ -1,26 +1,68 @@
|
|||||||
package com.v2ray.ang.ui.main
|
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.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.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.heightIn
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.widthIn
|
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.MaterialTheme
|
||||||
import androidx.compose.material3.ScrollableTabRow
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Tab
|
|
||||||
import androidx.compose.material3.TabRowDefaults
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.key
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
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.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.Constraints
|
||||||
|
import androidx.compose.ui.unit.IntSize
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.material3.TabRowDefaults.tabIndicatorOffset
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.v2ray.ang.dto.GroupMapItem
|
import com.v2ray.ang.dto.GroupMapItem
|
||||||
import com.v2ray.ang.dto.entities.ServersCache
|
import com.v2ray.ang.dto.entities.ServersCache
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
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
|
@Composable
|
||||||
fun GroupTabBar(
|
fun GroupTabBar(
|
||||||
groups: List<GroupMapItem>,
|
groups: List<GroupMapItem>,
|
||||||
@@ -30,33 +72,91 @@ fun GroupTabBar(
|
|||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val selectedIndex = selectedTabIndex.coerceIn(0, groups.lastIndex)
|
val selectedIndex = selectedTabIndex.coerceIn(0, groups.lastIndex)
|
||||||
ScrollableTabRow(
|
val colors = MaterialTheme.colorScheme
|
||||||
selectedTabIndex = selectedIndex,
|
// 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
|
modifier = modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.95f)),
|
.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||||
edgePadding = 16.dp,
|
shape = CircleShape,
|
||||||
indicator = { tabPositions ->
|
color = if (dark) colors.surfaceContainerHigh else colors.surface,
|
||||||
TabRowDefaults.SecondaryIndicator(
|
border = BorderStroke(0.5.dp, colors.outlineVariant.copy(alpha = if (dark) 0.6f else 0.35f)),
|
||||||
modifier = Modifier.tabIndicatorOffset(tabPositions[selectedIndex]),
|
shadowElevation = 3.dp
|
||||||
color = MaterialTheme.colorScheme.secondary
|
|
||||||
)
|
|
||||||
}
|
|
||||||
) {
|
) {
|
||||||
groups.forEachIndexed { index, group ->
|
BoxWithConstraints {
|
||||||
val serverFlow = remember(group.id, mainViewModel) {
|
val barPaddingPx = with(LocalDensity.current) { TAB_BAR_PADDING.roundToPx() }
|
||||||
mainViewModel.serversForGroup(group.id)
|
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
|
@Composable
|
||||||
private fun GroupTabItem(
|
private fun GroupTabItem(
|
||||||
group: GroupMapItem,
|
group: GroupMapItem,
|
||||||
@@ -65,25 +165,61 @@ private fun GroupTabItem(
|
|||||||
onClick: () -> Unit
|
onClick: () -> Unit
|
||||||
) {
|
) {
|
||||||
val servers by serverFlow.collectAsStateWithLifecycle()
|
val servers by serverFlow.collectAsStateWithLifecycle()
|
||||||
val text = if (group.id.isEmpty()) {
|
// The default group (empty id) never showed a count.
|
||||||
group.remarks
|
val count = if (group.id.isEmpty()) 0 else servers.size
|
||||||
} else {
|
val colors = MaterialTheme.colorScheme
|
||||||
"${group.remarks} (${servers.size})"
|
val bringIntoView = remember { BringIntoViewRequester() }
|
||||||
}
|
var size by remember { mutableStateOf(IntSize.Zero) }
|
||||||
|
val marginPx = with(LocalDensity.current) { SELECTED_TAB_MARGIN.toPx() }
|
||||||
|
|
||||||
Tab(
|
// Bring the tab in with some room around it so the pill never ends up flush against the bar's
|
||||||
selected = selected,
|
// rounded edge; at the first and last tab the scroll stops at the bar's own inner padding.
|
||||||
onClick = onClick,
|
LaunchedEffect(selected, size) {
|
||||||
modifier = Modifier
|
if (selected && size != IntSize.Zero) {
|
||||||
.widthIn(min = 56.dp)
|
bringIntoView.bringIntoView(
|
||||||
.heightIn(min = 48.dp),
|
Rect(-marginPx, 0f, size.width + marginPx, size.height.toFloat())
|
||||||
text = {
|
|
||||||
Text(
|
|
||||||
text = text,
|
|
||||||
maxLines = 1,
|
|
||||||
softWrap = false,
|
|
||||||
overflow = TextOverflow.Ellipsis
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
}
|
||||||
|
|
||||||
|
// 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)
|
shareTarget = Triple(guid, profile, true)
|
||||||
},
|
},
|
||||||
onRemoveServer = removeServer,
|
onRemoveServer = removeServer,
|
||||||
|
usageInfo = group.usageInfo,
|
||||||
|
groupTitle = group.remarks,
|
||||||
|
onOpenLink = { url -> onAction(MainAction.OpenSubscriptionLink(url)) },
|
||||||
contentPadding = PaddingValues(
|
contentPadding = PaddingValues(
|
||||||
start = 0.dp,
|
start = 0.dp,
|
||||||
top = 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.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
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.fillMaxHeight
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
@@ -32,10 +34,15 @@ import androidx.compose.material3.Text
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
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.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.semantics.semantics
|
import androidx.compose.ui.semantics.semantics
|
||||||
@@ -49,6 +56,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|||||||
import com.v2ray.ang.R
|
import com.v2ray.ang.R
|
||||||
import com.v2ray.ang.dto.LocateTarget
|
import com.v2ray.ang.dto.LocateTarget
|
||||||
import com.v2ray.ang.dto.entities.ProfileItem
|
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.ItemDivider
|
||||||
import com.v2ray.ang.ui.compose.ReorderableGridItem
|
import com.v2ray.ang.ui.compose.ReorderableGridItem
|
||||||
import com.v2ray.ang.ui.compose.ReorderableListItem
|
import com.v2ray.ang.ui.compose.ReorderableListItem
|
||||||
@@ -76,6 +84,9 @@ fun GroupPagerPage(
|
|||||||
onShareServer: (String, ProfileItem) -> Unit,
|
onShareServer: (String, ProfileItem) -> Unit,
|
||||||
onMoreServer: (String, ProfileItem) -> Unit,
|
onMoreServer: (String, ProfileItem) -> Unit,
|
||||||
onRemoveServer: (String, String) -> Unit,
|
onRemoveServer: (String, String) -> Unit,
|
||||||
|
usageInfo: SubscriptionUsageInfo?,
|
||||||
|
groupTitle: String,
|
||||||
|
onOpenLink: (String) -> Unit,
|
||||||
contentPadding: PaddingValues
|
contentPadding: PaddingValues
|
||||||
) {
|
) {
|
||||||
val groupStateFlow = remember(groupId) {
|
val groupStateFlow = remember(groupId) {
|
||||||
@@ -98,22 +109,50 @@ fun GroupPagerPage(
|
|||||||
remove = onRemoveServer,
|
remove = onRemoveServer,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
ServerListPage(
|
// The box floats over the list (the list scrolls underneath it), so the list is padded by the box height.
|
||||||
rows = groupState.rows,
|
val density = LocalDensity.current
|
||||||
selectedGuid = selectedGuid,
|
val layoutDirection = LocalLayoutDirection.current
|
||||||
locateTarget = locateTarget?.takeIf { it.groupId == groupId },
|
var infoHeightPx by remember { mutableIntStateOf(0) }
|
||||||
canReorder = canReorder,
|
val listPadding = if (usageInfo == null) {
|
||||||
doubleColumnDisplay = doubleColumnDisplay,
|
contentPadding
|
||||||
groupId = groupId,
|
} else {
|
||||||
lazyListStates = lazyListStates,
|
PaddingValues(
|
||||||
lazyGridStates = lazyGridStates,
|
start = contentPadding.calculateStartPadding(layoutDirection),
|
||||||
actions = actions,
|
top = contentPadding.calculateTopPadding() + with(density) { infoHeightPx.toDp() },
|
||||||
onLocateHandled = { mainViewModel.onAction(MainAction.LocateHandled) },
|
end = contentPadding.calculateEndPadding(layoutDirection),
|
||||||
onMoveServer = { fromIndex, toIndex ->
|
bottom = contentPadding.calculateBottomPadding()
|
||||||
mainViewModel.moveServer(groupId, fromIndex, toIndex)
|
)
|
||||||
},
|
}
|
||||||
contentPadding = contentPadding
|
|
||||||
)
|
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(
|
private class ServerRowActions(
|
||||||
|
|||||||
@@ -0,0 +1,201 @@
|
|||||||
|
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.TextDirection
|
||||||
|
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 NO_EXPIRY_SYMBOL = "\u221E"
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
// Figures and the slash between them always read left to right, also in right-to-left languages;
|
||||||
|
// otherwise the bidi algorithm swaps "used / total" around the slash.
|
||||||
|
val figuresStyle = MaterialTheme.typography.bodySmall.copy(textDirection = TextDirection.Ltr)
|
||||||
|
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 = figuresStyle,
|
||||||
|
color = if (model.trafficLow) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.size(8.dp))
|
||||||
|
Text(
|
||||||
|
// No expiry date means the subscription never expires.
|
||||||
|
text = model.expireMillis?.let { Utils.formatTimestamp(it, EXPIRY_DATE_PATTERN) } ?: NO_EXPIRY_SYMBOL,
|
||||||
|
style = figuresStyle,
|
||||||
|
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.isComplexType
|
||||||
import com.v2ray.ang.extension.matchesPattern
|
import com.v2ray.ang.extension.matchesPattern
|
||||||
import com.v2ray.ang.extension.moveItem
|
import com.v2ray.ang.extension.moveItem
|
||||||
|
import com.v2ray.ang.handler.SubscriptionInfoParser
|
||||||
import com.v2ray.ang.ui.base.BaseViewModel
|
import com.v2ray.ang.ui.base.BaseViewModel
|
||||||
import com.v2ray.ang.util.LogUtil
|
import com.v2ray.ang.util.LogUtil
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
@@ -283,7 +284,8 @@ class MainViewModel(
|
|||||||
MainAction.RemoveDuplicateServers -> removeDuplicateServerAsync()
|
MainAction.RemoveDuplicateServers -> removeDuplicateServerAsync()
|
||||||
MainAction.RemoveInvalidServers -> removeInvalidServerAsync()
|
MainAction.RemoveInvalidServers -> removeInvalidServerAsync()
|
||||||
MainAction.SortByTestResults -> sortByTestResultsAsync()
|
MainAction.SortByTestResults -> sortByTestResultsAsync()
|
||||||
MainAction.UpdateSubscriptions -> importConfigViaSub()
|
MainAction.UpdateSubscriptions -> importConfigViaSub(uiState.value.selectedGroupId)
|
||||||
|
is MainAction.UpdateGroupSubscription -> importConfigViaSub(action.groupId)
|
||||||
MainAction.ExportAll -> exportAllAsync()
|
MainAction.ExportAll -> exportAllAsync()
|
||||||
is MainAction.SelectGroup -> subscriptionIdChanged(action.groupId)
|
is MainAction.SelectGroup -> subscriptionIdChanged(action.groupId)
|
||||||
is MainAction.SelectServer -> updateSelectedGuid(action.guid)
|
is MainAction.SelectServer -> updateSelectedGuid(action.guid)
|
||||||
@@ -310,7 +312,8 @@ class MainViewModel(
|
|||||||
MainAction.LocateSelectedServer,
|
MainAction.LocateSelectedServer,
|
||||||
is MainAction.EditServer,
|
is MainAction.EditServer,
|
||||||
is MainAction.ShareClipboard,
|
is MainAction.ShareClipboard,
|
||||||
is MainAction.ShareFullContent -> {
|
is MainAction.ShareFullContent,
|
||||||
|
is MainAction.OpenSubscriptionLink -> {
|
||||||
// Handled by Activity via its onAction lambda
|
// Handled by Activity via its onAction lambda
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -453,7 +456,13 @@ class MainViewModel(
|
|||||||
cacheMutex.withLock { groupDataCache.clear() }
|
cacheMutex.withLock { groupDataCache.clear() }
|
||||||
}
|
}
|
||||||
val groups = dataSource.getSubscriptions().map {
|
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 selectedGroup = resolveSelectedGroup(groups)
|
||||||
val validIds = groups.mapTo(HashSet()) { it.id }
|
val validIds = groups.mapTo(HashSet()) { it.id }
|
||||||
@@ -532,8 +541,7 @@ class MainViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun importConfigViaSub() {
|
private fun importConfigViaSub(subId: String) {
|
||||||
val subId = uiState.value.selectedGroupId
|
|
||||||
launchLoading {
|
launchLoading {
|
||||||
withContext(ioDispatcher) {
|
withContext(ioDispatcher) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import androidx.compose.runtime.rememberCoroutineScope
|
|||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import com.v2ray.ang.AppConfig
|
||||||
import com.v2ray.ang.R
|
import com.v2ray.ang.R
|
||||||
import com.v2ray.ang.enums.EConfigType
|
import com.v2ray.ang.enums.EConfigType
|
||||||
import com.v2ray.ang.ui.compose.FormTextField
|
import com.v2ray.ang.ui.compose.FormTextField
|
||||||
@@ -77,8 +78,11 @@ class ServerWireguardActivity : BaseServerActivity() {
|
|||||||
FormTextField(
|
FormTextField(
|
||||||
stringResource(R.string.server_lab_remote_dns),
|
stringResource(R.string.server_lab_remote_dns),
|
||||||
state.remoteDNS,
|
state.remoteDNS,
|
||||||
{ state.remoteDNS = it }
|
{ state.remoteDNS = it },
|
||||||
|
supportingText = stringResource(
|
||||||
|
R.string.server_lab_remote_dns_hint,
|
||||||
|
AppConfig.WIREGUARD_LOCAL_REMOTE_DNS
|
||||||
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,6 +121,9 @@ class SubscriptionsViewModel(application: Application) : BaseViewModel(applicati
|
|||||||
else ->
|
else ->
|
||||||
toast(getString(R.string.title_update_subscription_result, result.configCount, result.successCount, result.failureCount, result.skipCount))
|
toast(getString(R.string.title_update_subscription_result, result.configCount, result.successCount, result.failureCount, result.skipCount))
|
||||||
}
|
}
|
||||||
|
if (result.configCount > 0) {
|
||||||
|
SettingsChangeManager.makeSetupGroupTab()
|
||||||
|
}
|
||||||
reload()
|
reload()
|
||||||
} catch (cancelled: CancellationException) {
|
} catch (cancelled: CancellationException) {
|
||||||
throw cancelled
|
throw cancelled
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import com.v2ray.ang.AppConfig.LOOPBACK
|
|||||||
import com.v2ray.ang.BuildConfig
|
import com.v2ray.ang.BuildConfig
|
||||||
import com.v2ray.ang.dto.UrlContentRequest
|
import com.v2ray.ang.dto.UrlContentRequest
|
||||||
import okhttp3.Credentials
|
import okhttp3.Credentials
|
||||||
|
import okhttp3.Headers
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import java.io.File
|
import java.io.File
|
||||||
@@ -139,11 +140,15 @@ object HttpUtil {
|
|||||||
* @param url The URL to fetch content from.
|
* @param url The URL to fetch content from.
|
||||||
* @param timeout The timeout value in milliseconds.
|
* @param timeout The timeout value in milliseconds.
|
||||||
* @param httpPort The HTTP port to use.
|
* @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.
|
* @return The content of the URL as a string.
|
||||||
* @throws IOException If an I/O error occurs.
|
* @throws IOException If an I/O error occurs.
|
||||||
*/
|
*/
|
||||||
@Throws(IOException::class)
|
@Throws(IOException::class)
|
||||||
fun getUrlContentWithUserAgent(request: UrlContentRequest): String {
|
fun getUrlContentWithUserAgent(
|
||||||
|
request: UrlContentRequest,
|
||||||
|
onResponseHeaders: ((Headers) -> Unit)? = null
|
||||||
|
): String {
|
||||||
var currentUrl = request.url
|
var currentUrl = request.url
|
||||||
var redirects = 0
|
var redirects = 0
|
||||||
val maxRedirects = 3
|
val maxRedirects = 3
|
||||||
@@ -193,6 +198,7 @@ object HttpUtil {
|
|||||||
}
|
}
|
||||||
|
|
||||||
response.isSuccessful -> {
|
response.isSuccessful -> {
|
||||||
|
onResponseHeaders?.invoke(response.headers)
|
||||||
return response.body?.string() ?: ""
|
return response.body?.string() ?: ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -104,6 +104,7 @@
|
|||||||
<string name="server_lab_local_address">العنوان المحلي (اختياري IPv4/IPv6، مفصولة بفواصل)</string>
|
<string name="server_lab_local_address">العنوان المحلي (اختياري IPv4/IPv6، مفصولة بفواصل)</string>
|
||||||
<string name="server_lab_local_mtu">MTU (اختياري، الافتراضي 1420)</string>
|
<string name="server_lab_local_mtu">MTU (اختياري، الافتراضي 1420)</string>
|
||||||
<string name="server_lab_remote_dns">remoteDNS (اختياري، عناوين IPv4/IPv6 مفصولة بفواصل)</string>
|
<string name="server_lab_remote_dns">remoteDNS (اختياري، عناوين IPv4/IPv6 مفصولة بفواصل)</string>
|
||||||
|
<string name="server_lab_remote_dns_hint">إذا كان فارغًا، فسيُستخدم %1$s افتراضيًا. لا تُستخدم عناوين IPv6 إلا عند تفعيل IPv6.</string>
|
||||||
<string name="server_lab_kcp_mtu">mKCP MTU (اختياري، الافتراضي 1350)</string>
|
<string name="server_lab_kcp_mtu">mKCP MTU (اختياري، الافتراضي 1350)</string>
|
||||||
<string name="server_lab_kcp_tti">mKCP TTI (اختياري، الافتراضي 50 مللي ثانية)</string>
|
<string name="server_lab_kcp_tti">mKCP TTI (اختياري، الافتراضي 50 مللي ثانية)</string>
|
||||||
<string name="toast_success">نجاح</string>
|
<string name="toast_success">نجاح</string>
|
||||||
@@ -568,5 +569,8 @@
|
|||||||
<string name="tasker_blurb_stop">إيقاف %1$s</string>
|
<string name="tasker_blurb_stop">إيقاف %1$s</string>
|
||||||
<string name="connection_test_empty_message">لا تتوفر تفاصيل</string>
|
<string name="connection_test_empty_message">لا تتوفر تفاصيل</string>
|
||||||
<string name="value_unknown">غير معروف</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>
|
</resources>
|
||||||
@@ -104,6 +104,7 @@
|
|||||||
<string name="server_lab_local_address">Yerli ünvan (istəyə bağlı IPv4/IPv6, 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_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_remote_dns">remoteDNS (istəyə bağlı, vergüllə ayrılmış IPv4/IPv6)</string>
|
||||||
|
<string name="server_lab_remote_dns_hint">Boşdursa, standart olaraq %1$s istifadə olunur. IPv6 ünvanları yalnız IPv6 aktiv olduqda istifadə edilir.</string>
|
||||||
<string name="server_lab_kcp_mtu">mKCP MTU (istəyə bağlı, standart 1350)</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="server_lab_kcp_tti">mKCP TTI (istəyə bağlı, standart 50 ms)</string>
|
||||||
<string name="toast_success">Uğurlu</string>
|
<string name="toast_success">Uğurlu</string>
|
||||||
@@ -212,7 +213,7 @@
|
|||||||
<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="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="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="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="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="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>
|
||||||
@@ -357,7 +358,6 @@
|
|||||||
<string name="title_export_config_count">%d konfiqurasiya ixrac edilsin</string>
|
<string name="title_export_config_count">%d konfiqurasiya ixrac edilsin</string>
|
||||||
<plurals name="title_update_config_count">
|
<plurals name="title_update_config_count">
|
||||||
<item quantity="one">%1$d konfiqurasiya yeniləndi</item>
|
<item quantity="one">%1$d konfiqurasiya yeniləndi</item>
|
||||||
|
|
||||||
<item quantity="other">%1$d konfiqurasiya yeniləndi</item>
|
<item quantity="other">%1$d konfiqurasiya yeniləndi</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
<plurals name="title_update_asset_count">
|
<plurals name="title_update_asset_count">
|
||||||
@@ -561,5 +561,9 @@
|
|||||||
<string name="tasker_blurb_stop">%1$s Dayandır</string>
|
<string name="tasker_blurb_stop">%1$s Dayandır</string>
|
||||||
<string name="connection_test_empty_message">ətraflı məlumat yoxdur</string>
|
<string name="connection_test_empty_message">ətraflı məlumat yoxdur</string>
|
||||||
<string name="value_unknown">naməlum</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>
|
</resources>
|
||||||
@@ -104,6 +104,7 @@
|
|||||||
<string name="server_lab_local_address">স্থানীয় ঠিকানা (ঐচ্ছিক IPv4/IPv6, কমা দ্বারা পৃথক করা)</string>
|
<string name="server_lab_local_address">স্থানীয় ঠিকানা (ঐচ্ছিক IPv4/IPv6, কমা দ্বারা পৃথক করা)</string>
|
||||||
<string name="server_lab_local_mtu">MTU (ঐচ্ছিক, ডিফল্ট 1420)</string>
|
<string name="server_lab_local_mtu">MTU (ঐচ্ছিক, ডিফল্ট 1420)</string>
|
||||||
<string name="server_lab_remote_dns">remoteDNS (ঐচ্ছিক, কমা দ্বারা পৃথক করা IPv4/IPv6 ঠিকানা)</string>
|
<string name="server_lab_remote_dns">remoteDNS (ঐচ্ছিক, কমা দ্বারা পৃথক করা IPv4/IPv6 ঠিকানা)</string>
|
||||||
|
<string name="server_lab_remote_dns_hint">খালি থাকলে ডিফল্টভাবে %1$s ব্যবহৃত হয়। IPv6 চালু থাকলেই কেবল IPv6 ঠিকানা ব্যবহৃত হয়।</string>
|
||||||
<string name="server_lab_kcp_mtu">mKCP MTU (ঐচ্ছিক, ডিফল্ট 1350)</string>
|
<string name="server_lab_kcp_mtu">mKCP MTU (ঐচ্ছিক, ডিফল্ট 1350)</string>
|
||||||
<string name="server_lab_kcp_tti">mKCP TTI (ঐচ্ছিক, ডিফল্ট 50 মিলিসেকেন্ড)</string>
|
<string name="server_lab_kcp_tti">mKCP TTI (ঐচ্ছিক, ডিফল্ট 50 মিলিসেকেন্ড)</string>
|
||||||
<string name="toast_success">সফল</string>
|
<string name="toast_success">সফল</string>
|
||||||
@@ -560,5 +561,8 @@
|
|||||||
<string name="tasker_blurb_stop">%1$s বন্ধ করুন</string>
|
<string name="tasker_blurb_stop">%1$s বন্ধ করুন</string>
|
||||||
<string name="connection_test_empty_message">কোনো বিবরণ পাওয়া যায়নি</string>
|
<string name="connection_test_empty_message">কোনো বিবরণ পাওয়া যায়নি</string>
|
||||||
<string name="value_unknown">অজানা</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>
|
</resources>
|
||||||
@@ -104,6 +104,7 @@
|
|||||||
<string name="server_lab_local_address">نشۊوی مهلی (اختیاری IPv4/IPv6، وا کاما (,) ز یک جوڌا ابۊن)</string>
|
<string name="server_lab_local_address">نشۊوی مهلی (اختیاری IPv4/IPv6، وا کاما (,) ز یک جوڌا ابۊن)</string>
|
||||||
<string name="server_lab_local_mtu">MTU(اختیاری، پؽش فرز 1420)</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 جوڌا وابیڌه وا کاما، یا «local» سی و کار گرؽڌن DNS دسگا)</string>
|
||||||
|
<string name="server_lab_remote_dns_hint">اگه خالی بی، پؽش فرز %1$s سی و کار گرؽڌه وابی. نشۊویا IPv6 فقظ وختؽ کار گرؽڌه وابیڌه که IPv6 رۊشن بی.</string>
|
||||||
<string name="server_lab_kcp_mtu">mKCP MTU (اختیاری، پؽش فرز 1350)</string>
|
<string name="server_lab_kcp_mtu">mKCP MTU (اختیاری، پؽش فرز 1350)</string>
|
||||||
<string name="server_lab_kcp_tti">mKCP TTI (اختیاری، پؽش فرز 50 میلیسانیه)</string>
|
<string name="server_lab_kcp_tti">mKCP TTI (اختیاری، پؽش فرز 50 میلیسانیه)</string>
|
||||||
<string name="toast_success">وا مووفقیت ٱنجوم وابی</string>
|
<string name="toast_success">وا مووفقیت ٱنجوم وابی</string>
|
||||||
@@ -560,5 +561,8 @@
|
|||||||
<string name="tasker_blurb_stop">واڌاشتن %1$s</string>
|
<string name="tasker_blurb_stop">واڌاشتن %1$s</string>
|
||||||
<string name="connection_test_empty_message">دووسمندی نؽ</string>
|
<string name="connection_test_empty_message">دووسمندی نؽ</string>
|
||||||
<string name="value_unknown">نا موشخس</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>
|
</resources>
|
||||||
@@ -104,6 +104,7 @@
|
|||||||
<string name="server_lab_local_address">آدرس محلی (IPv4/IPv6 اختیاری، جدا شده با کاما)</string>
|
<string name="server_lab_local_address">آدرس محلی (IPv4/IPv6 اختیاری، جدا شده با کاما)</string>
|
||||||
<string name="server_lab_local_mtu">MTU (اختیاری، پیشفرض 1420)</string>
|
<string name="server_lab_local_mtu">MTU (اختیاری، پیشفرض 1420)</string>
|
||||||
<string name="server_lab_remote_dns">remoteDNS (اختیاری، آدرسهای IPv4/IPv6 جدا شده با کاما)</string>
|
<string name="server_lab_remote_dns">remoteDNS (اختیاری، آدرسهای IPv4/IPv6 جدا شده با کاما)</string>
|
||||||
|
<string name="server_lab_remote_dns_hint">اگر خالی باشد، بهطور پیشفرض از %1$s استفاده میشود. آدرسهای IPv6 فقط زمانی استفاده میشوند که IPv6 فعال باشد.</string>
|
||||||
<string name="server_lab_kcp_mtu">mKCP MTU (اختیاری، پیشفرض 1350)</string>
|
<string name="server_lab_kcp_mtu">mKCP MTU (اختیاری، پیشفرض 1350)</string>
|
||||||
<string name="server_lab_kcp_tti">mKCP TTI (اختیاری، پیشفرض 50 میلیثانیه)</string>
|
<string name="server_lab_kcp_tti">mKCP TTI (اختیاری، پیشفرض 50 میلیثانیه)</string>
|
||||||
<string name="toast_success">موفقیتآمیز</string>
|
<string name="toast_success">موفقیتآمیز</string>
|
||||||
@@ -560,5 +561,8 @@
|
|||||||
<string name="tasker_blurb_stop">توقف %1$s</string>
|
<string name="tasker_blurb_stop">توقف %1$s</string>
|
||||||
<string name="connection_test_empty_message">جزئیاتی در دسترس نیست</string>
|
<string name="connection_test_empty_message">جزئیاتی در دسترس نیست</string>
|
||||||
<string name="value_unknown">ناشناخته</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>
|
</resources>
|
||||||
@@ -104,6 +104,7 @@
|
|||||||
<string name="server_lab_local_address">Локальный адрес (необязательно, IPv4/IPv6 через запятую)</string>
|
<string name="server_lab_local_address">Локальный адрес (необязательно, IPv4/IPv6 через запятую)</string>
|
||||||
<string name="server_lab_local_mtu">MTU (необязательно, по умолчанию 1420)</string>
|
<string name="server_lab_local_mtu">MTU (необязательно, по умолчанию 1420)</string>
|
||||||
<string name="server_lab_remote_dns">remoteDNS (необязательно, IPv4/IPv6-адреса через запятую)</string>
|
<string name="server_lab_remote_dns">remoteDNS (необязательно, IPv4/IPv6-адреса через запятую)</string>
|
||||||
|
<string name="server_lab_remote_dns_hint">Если пусто, используется %1$s. IPv6-адреса применяются только при включённом IPv6.</string>
|
||||||
<string name="server_lab_kcp_mtu">mKCP MTU (необязательно, по умолчанию 1350)</string>
|
<string name="server_lab_kcp_mtu">mKCP MTU (необязательно, по умолчанию 1350)</string>
|
||||||
<string name="server_lab_kcp_tti">mKCP TTI (необязательно, по умолчанию 50 мс)</string>
|
<string name="server_lab_kcp_tti">mKCP TTI (необязательно, по умолчанию 50 мс)</string>
|
||||||
<string name="toast_success">Успешно</string>
|
<string name="toast_success">Успешно</string>
|
||||||
@@ -564,5 +565,8 @@
|
|||||||
<string name="tasker_blurb_stop">Остановить %1$s</string>
|
<string name="tasker_blurb_stop">Остановить %1$s</string>
|
||||||
<string name="connection_test_empty_message">подробности отсутствуют</string>
|
<string name="connection_test_empty_message">подробности отсутствуют</string>
|
||||||
<string name="value_unknown">неизвестно</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>
|
</resources>
|
||||||
@@ -104,6 +104,7 @@
|
|||||||
<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_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_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)</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_remote_dns_hint">Nếu để trống, mặc định là %1$s. Địa chỉ IPv6 chỉ được dùng khi IPv6 được bật.</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_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="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>
|
<string name="toast_success">Thành công!</string>
|
||||||
@@ -558,5 +559,8 @@
|
|||||||
<string name="tasker_blurb_stop">Dừng %1$s</string>
|
<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="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="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>
|
</resources>
|
||||||
@@ -104,6 +104,7 @@
|
|||||||
<string name="server_lab_local_address">本地地址 (可选 IPv4/IPv6,逗号隔开)</string>
|
<string name="server_lab_local_address">本地地址 (可选 IPv4/IPv6,逗号隔开)</string>
|
||||||
<string name="server_lab_local_mtu">MTU(可选,默认 1420)</string>
|
<string name="server_lab_local_mtu">MTU(可选,默认 1420)</string>
|
||||||
<string name="server_lab_remote_dns">remoteDNS(可选,多个 IPv4/IPv6 地址用逗号分隔)</string>
|
<string name="server_lab_remote_dns">remoteDNS(可选,多个 IPv4/IPv6 地址用逗号分隔)</string>
|
||||||
|
<string name="server_lab_remote_dns_hint">留空时默认使用 %1$s。仅在启用 IPv6 时才使用 IPv6 地址。</string>
|
||||||
<string name="server_lab_kcp_mtu">mKCP MTU(可选,默认 1350)</string>
|
<string name="server_lab_kcp_mtu">mKCP MTU(可选,默认 1350)</string>
|
||||||
<string name="server_lab_kcp_tti">mKCP TTI(可选,默认 50 毫秒)</string>
|
<string name="server_lab_kcp_tti">mKCP TTI(可选,默认 50 毫秒)</string>
|
||||||
<string name="toast_success">成功</string>
|
<string name="toast_success">成功</string>
|
||||||
@@ -558,5 +559,8 @@
|
|||||||
<string name="tasker_blurb_stop">停止 %1$s</string>
|
<string name="tasker_blurb_stop">停止 %1$s</string>
|
||||||
<string name="connection_test_empty_message">无详细信息</string>
|
<string name="connection_test_empty_message">无详细信息</string>
|
||||||
<string name="value_unknown">未知</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>
|
</resources>
|
||||||
@@ -104,6 +104,7 @@
|
|||||||
<string name="server_lab_local_address">本機位址 (可選 IPv4/IPv6,逗號隔開)</string>
|
<string name="server_lab_local_address">本機位址 (可選 IPv4/IPv6,逗號隔開)</string>
|
||||||
<string name="server_lab_local_mtu">MTU(可選,預設 1420)</string>
|
<string name="server_lab_local_mtu">MTU(可選,預設 1420)</string>
|
||||||
<string name="server_lab_remote_dns">remoteDNS(選填,多個 IPv4/IPv6 位址以逗號分隔)</string>
|
<string name="server_lab_remote_dns">remoteDNS(選填,多個 IPv4/IPv6 位址以逗號分隔)</string>
|
||||||
|
<string name="server_lab_remote_dns_hint">留空時預設使用 %1$s。僅在啟用 IPv6 時才使用 IPv6 位址。</string>
|
||||||
<string name="server_lab_kcp_mtu">mKCP MTU(可選,預設 1350)</string>
|
<string name="server_lab_kcp_mtu">mKCP MTU(可選,預設 1350)</string>
|
||||||
<string name="server_lab_kcp_tti">mKCP TTI(可選,預設 50 毫秒)</string>
|
<string name="server_lab_kcp_tti">mKCP TTI(可選,預設 50 毫秒)</string>
|
||||||
<string name="toast_success">成功</string>
|
<string name="toast_success">成功</string>
|
||||||
@@ -558,5 +559,8 @@
|
|||||||
<string name="tasker_blurb_stop">停止 %1$s</string>
|
<string name="tasker_blurb_stop">停止 %1$s</string>
|
||||||
<string name="connection_test_empty_message">沒有詳細資訊</string>
|
<string name="connection_test_empty_message">沒有詳細資訊</string>
|
||||||
<string name="value_unknown">未知</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>
|
</resources>
|
||||||
@@ -104,6 +104,7 @@
|
|||||||
<string name="server_lab_local_address">Local address (optional IPv4/IPv6, 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_local_mtu">MTU (optional, default 1420)</string>
|
||||||
<string name="server_lab_remote_dns">remoteDNS (optional, comma-separated IPv4/IPv6)</string>
|
<string name="server_lab_remote_dns">remoteDNS (optional, comma-separated IPv4/IPv6)</string>
|
||||||
|
<string name="server_lab_remote_dns_hint">If empty, defaults to %1$s. IPv6 addresses are used only when IPv6 is enabled.</string>
|
||||||
<string name="server_lab_kcp_mtu">mKCP MTU (optional, default 1350)</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="server_lab_kcp_tti">mKCP TTI (optional, default 50 ms)</string>
|
||||||
<string name="toast_success">Success</string>
|
<string name="toast_success">Success</string>
|
||||||
@@ -560,5 +561,9 @@
|
|||||||
<string name="tasker_blurb_stop">Stop %1$s</string>
|
<string name="tasker_blurb_stop">Stop %1$s</string>
|
||||||
<string name="connection_test_empty_message">no details available</string>
|
<string name="connection_test_empty_message">no details available</string>
|
||||||
<string name="value_unknown">unknown</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>
|
</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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,43 @@
|
|||||||
package com.v2ray.ang.root
|
package com.v2ray.ang.root
|
||||||
|
|
||||||
import org.junit.jupiter.api.Assertions.assertEquals
|
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
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
class RootProxyManagerTest {
|
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
|
@Test
|
||||||
fun `single quotes are escaped in YAML scalars`() {
|
fun `single quotes are escaped in YAML scalars`() {
|
||||||
assertEquals("'user''name'", "user'name".toSingleQuotedYamlScalar())
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@ activity = "1.13.0"
|
|||||||
appcompat = "1.8.0"
|
appcompat = "1.8.0"
|
||||||
mmkvStatic = "1.3.17"
|
mmkvStatic = "1.3.17"
|
||||||
gson = "2.14.0"
|
gson = "2.14.0"
|
||||||
okhttp = "5.5.0"
|
okhttp = "5.5.1"
|
||||||
kotlinxCoroutines = "1.11.0"
|
kotlinxCoroutines = "1.11.0"
|
||||||
core = "3.5.4"
|
core = "3.5.4"
|
||||||
workRuntimeKtx = "2.12.0"
|
workRuntimeKtx = "2.12.0"
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
#Thu Nov 14 12:42:51 BDT 2024
|
#Thu Nov 14 12:42:51 BDT 2024
|
||||||
distributionBase=GRADLE_USER_HOME
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.1-bin.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.2-bin.zip
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
zipStorePath=wrapper/dists
|
zipStorePath=wrapper/dists
|
||||||
+1
-1
Submodule hev-socks5-tunnel updated: d9dca26c7a...c5584feca8.
Reference in new issue
Block a user