Compare commits

..
16 Commits
Author SHA1 Message Date
2dust aa4b05503a up 2.3.14 2026-10-28 10:40:04 +08:00
Yuriy Tumanov 7179f0838f Use the default port for Hysteria2 links without a port (#6358) 2026-10-28 10:12:18 +08:00
Miheichev Aleksandr Sergeevich 0d1c57000f build(deps): bump OkHttp from 5.5.0 to 5.5.1 (#6351) 2026-10-28 10:11:05 +08:00
Figim 40b8706ccd Update subscription page string in Azerbaijani (#6349)
Edit the translation added yesterday.
2026-10-28 10:10:50 +08:00
Miheichev Aleksandr Sergeevich 0f961c54c8 fix(deps): update hev-socks5-tunnel to 2.19.3 (#6357)
* chore(deps): update hev-socks5-tunnel to 2.19.2

* fix(root): disable jumbo for LAN forwarding

* fix(root): guard IPv6 LAN TSO fallback

* chore(deps): update hev-socks5-tunnel to 2.19.3
2026-10-25 18:49:47 +08:00
2dust 1b78a510f2 Revert "chore(deps): update hev-socks5-tunnel to 2.19.1 (#6328)"
This reverts commit 552cc2425d.
2026-10-24 18:45:21 +08:00
2dust 01b4630fdd up 2.3.13 2026-10-22 10:16:56 +08:00
Miheichev Aleksandr Sergeevich 6214bc19d4 ci: update actions/setup-java to v6.1.0 (#6327) 2026-10-22 10:00:24 +08:00
rasa68andrasa68 b8d13bf52c Show subscription usage info box (traffic, expiry, support link) on subscription groups (#6333)
* Show subscription usage info box on subscription groups

* Float the info box over the server list and make it translucent

---------

Co-authored-by: rasa68 <noreply@example.com>
2026-10-22 09:59:00 +08:00
Miheichev Aleksandr Sergeevich 3165bcf4fe chore(build): update Gradle wrapper to 9.8.2 (#6343) 2026-10-22 09:48:18 +08:00
rasa68andrasa68 eedb287cc9 Redesign subscription group tabs as a floating pill bar with count badges (#6336)
* Redesign subscription group tabs as a floating pill bar with count badges

* Group tabs: dark-mode bar tone, equal-width tabs when they fit, room around selected tab

---------

Co-authored-by: rasa68 <noreply@example.com>
2026-10-22 09:48:09 +08:00
Figim 59ff87606f Fix small symbol in Azerbaijani localization (#6341)
* Fix small symbol

* Fix space
2026-10-22 09:39:52 +08:00
dependabot[bot] 700740a187 Bump actions/setup-java from 6.0.1 to 6.1.0 (#6334)
Bumps [actions/setup-java](https://github.com/actions/setup-java) from 6.0.1 to 6.1.0.
- [Release notes](https://github.com/actions/setup-java/releases)
- [Commits](https://github.com/actions/setup-java/compare/v6.0.1...v6.1.0)

---
updated-dependencies:
- dependency-name: actions/setup-java
  dependency-version: 6.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-22 09:38:56 +08:00
fanymagnetandfanymagnet bb59a71cb9 fix(subscription): refresh main list after updating subscriptions (#6329)
Updating subscriptions from the subscription screen without auto-test replaced
profile GUIDs but did not request a main list refresh. The main screen kept stale
rows, and editing one opened an empty editor because its payloads had been removed.

Co-authored-by: fanymagnet <>
2026-10-22 09:38:35 +08:00
Miheichev Aleksandr Sergeevich 552cc2425d chore(deps): update hev-socks5-tunnel to 2.19.1 (#6328)
* chore(deps): update hev-socks5-tunnel to 2.19.0

* chore(deps): update hev-socks5-tunnel to 107a508
2026-10-22 09:38:02 +08:00
2dust c8f373bd3f Update AndroidLibXrayLite 2026-10-22 09:36:56 +08:00
37 changed files with 969 additions and 80 deletions

No files matched your search

+1 -1
View File
@@ -81,7 +81,7 @@ jobs:
out-file-path: V2rayNG/app/libs/
- name: Setup Java
uses: actions/setup-java@v6.0.1
uses: actions/setup-java@v6.1.0
with:
distribution: 'temurin'
java-version: '21'
+2 -2
View File
@@ -13,8 +13,8 @@ android {
applicationId = "com.v2ray.ang"
minSdk = 24
targetSdk = 37
versionCode = 752
versionName = "2.3.12"
versionCode = 754
versionName = "2.3.14"
val abiFilterList = (properties["ABI_FILTERS"] as? String)?.split(';')
splits {
@@ -1,6 +1,9 @@
package com.v2ray.ang.dto
import com.v2ray.ang.dto.entities.SubscriptionUsageInfo
data class GroupMapItem(
var id: String,
var remarks: String,
val usageInfo: SubscriptionUsageInfo? = null,
)
@@ -14,5 +14,6 @@ data class SubscriptionItem(
var allowInsecureUrl: Boolean = false,
var userAgent: String? = null,
var requestHeaders: String? = null,
var usageInfo: SubscriptionUsageInfo? = null,
)
@@ -0,0 +1,28 @@
package com.v2ray.ang.dto.entities
/**
* Account information a subscription panel (3x-ui, Marzban, Remnawave, ...) publishes in the
* HTTP response headers of the subscription URL. Every field is optional because panels send
* only the headers they have a value for.
*
* Persisted as JSON inside [SubscriptionItem]; only add fields, never rename or remove one.
*/
data class SubscriptionUsageInfo(
/** Bytes uploaded, from the `Subscription-Userinfo` header. */
val upload: Long = 0,
/** Bytes downloaded, from the `Subscription-Userinfo` header. */
val download: Long = 0,
/** Traffic quota in bytes; 0 means unlimited or not provided. */
val total: Long = 0,
/** Expiry as Unix epoch seconds; 0 means no expiry or not provided. */
val expire: Long = 0,
/** Decoded `Profile-Title` header. */
val title: String? = null,
/** Validated `Support-Url` header (usually a Telegram link). */
val supportUrl: String? = null,
/** Validated `Profile-Web-Page-Url` header. */
val webPageUrl: String? = null,
) {
/** Bytes consumed so far (upload + download). */
val used: Long get() = upload + download
}
@@ -23,7 +23,7 @@ object Hysteria2Fmt : FmtBase() {
val uri = URI(Utils.fixIllegalUrl(str))
config.remarks = Utils.decodeURIComponent(uri.fragment.orEmpty()).let { it.ifEmpty { "none" } }
config.server = uri.idnHost
config.serverPort = uri.port.toString()
config.serverPort = (if (uri.port == -1) AppConfig.DEFAULT_PORT else uri.port).toString()
config.password = uri.userInfo
config.security = AppConfig.TLS
config.network = NetworkType.HYSTERIA.type
@@ -10,6 +10,7 @@ import com.v2ray.ang.dto.UrlContentRequest
import com.v2ray.ang.dto.entities.ProfileItem
import com.v2ray.ang.dto.entities.SubscriptionCache
import com.v2ray.ang.dto.entities.SubscriptionItem
import com.v2ray.ang.dto.entities.SubscriptionUsageInfo
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.extension.isNotNullEmpty
import com.v2ray.ang.fmt.CustomFmt
@@ -26,6 +27,7 @@ import com.v2ray.ang.util.JsonUtil
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.QRCodeDecoder
import com.v2ray.ang.util.Utils
import okhttp3.Headers
import java.net.URI
object AngConfigManager {
@@ -488,6 +490,17 @@ object AngConfigManager {
val proxyUsername = SettingsManager.getSocksUsername()
val proxyPassword = SettingsManager.getSocksPassword()
// Account info (traffic, expiry, support link) the panel sends in the response headers.
var usageInfo: SubscriptionUsageInfo? = null
val captureUsageInfo: (Headers) -> Unit = { headers ->
usageInfo = SubscriptionInfoParser.parse(
userInfo = headers["Subscription-Userinfo"],
profileTitle = headers["Profile-Title"],
supportUrl = headers["Support-Url"],
webPageUrl = headers["Profile-Web-Page-Url"],
)
}
var configText = try {
val httpPort = SettingsManager.getHttpPort()
HttpUtil.getUrlContentWithUserAgent(
@@ -499,7 +512,8 @@ object AngConfigManager {
httpPort = httpPort,
proxyUsername = proxyUsername,
proxyPassword = proxyPassword
)
),
captureUsageInfo
)
} catch (e: Exception) {
LogUtil.e(AppConfig.ANG_PACKAGE, "Update subscription: proxy not ready or other error", e)
@@ -512,7 +526,8 @@ object AngConfigManager {
url = url,
userAgent = userAgent,
requestHeaders = requestHeaders
)
),
captureUsageInfo
)
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Update subscription: Failed to get URL content with user agent", e)
@@ -526,6 +541,8 @@ object AngConfigManager {
val count = parseConfigViaSub(configText, it.guid, false)
if (count > 0) {
it.subscription.lastUpdated = System.currentTimeMillis()
// Replaced on every successful update so the box disappears if the panel stops sending the headers.
it.subscription.usageInfo = usageInfo
MmkvManager.encodeSubscription(it.guid, it.subscription)
LogUtil.i(AppConfig.TAG, "Subscription updated: ${it.subscription.remarks}, $count configs")
return SubscriptionUpdateResult(
@@ -0,0 +1,121 @@
package com.v2ray.ang.handler
import com.v2ray.ang.dto.entities.SubscriptionUsageInfo
import com.v2ray.ang.util.Utils
import java.net.URI
import java.net.URLDecoder
import java.util.Locale
/**
* Parses the account information subscription panels put in the response headers of the
* subscription URL (the de-facto standard shared by 3x-ui, Marzban, Remnawave, Clash, v2rayN,
* Hiddify and others):
*
* - `Subscription-Userinfo: upload=<bytes>; download=<bytes>; total=<bytes>; expire=<unix seconds>`
* - `Profile-Title: <text>` or `base64:<base64 text>`
* - `Support-Url: <link>` (usually a Telegram link)
* - `Profile-Web-Page-Url: <link>` (the panel's subscription information page)
*
* Pure functions only, so the logic is covered by plain JVM tests.
*/
object SubscriptionInfoParser {
private const val BASE64_PREFIX = "base64:"
private const val MAX_TITLE_LENGTH = 100
private const val MAX_URL_LENGTH = 2048
/** Values at or above this are epoch milliseconds, not seconds (year 5138 in seconds). */
private const val MILLIS_THRESHOLD = 100_000_000_000L
private val SAFE_SCHEMES = setOf("http", "https", "tg")
private val TELEGRAM_HOSTS = setOf("t.me", "telegram.me", "telegram.dog", "telegram.org")
/**
* Builds usage info from raw header values.
*
* @return null when the panel sent none of the supported headers, so the UI shows nothing.
*/
fun parse(
userInfo: String?,
profileTitle: String?,
supportUrl: String?,
webPageUrl: String?,
decodeBase64: (String) -> String = Utils::decode,
): SubscriptionUsageInfo? {
val fields = parseUserInfoFields(userInfo)
val title = decodeTitle(profileTitle, decodeBase64)
val support = sanitizeLink(supportUrl)
val webPage = sanitizeLink(webPageUrl)
if (fields == null && title == null && support == null && webPage == null) return null
return SubscriptionUsageInfo(
upload = fields?.get("upload") ?: 0,
download = fields?.get("download") ?: 0,
total = fields?.get("total") ?: 0,
expire = normalizeExpire(fields?.get("expire") ?: 0),
title = title,
supportUrl = support,
webPageUrl = webPage,
)
}
/**
* The subscription URL itself answers with the panel's information page when it is opened in
* a browser (3x-ui, Marzban), so it is the fallback target of the "info page" button.
*/
fun withWebPageFallback(info: SubscriptionUsageInfo, subscriptionUrl: String): SubscriptionUsageInfo {
if (info.webPageUrl != null) return info
return info.copy(webPageUrl = sanitizeLink(subscriptionUrl))
}
/** Returns [raw] when it is an http(s) or tg:// link that is safe to hand to ACTION_VIEW. */
fun sanitizeLink(raw: String?): String? {
val value = raw?.trim().orEmpty()
if (value.isEmpty() || value.length > MAX_URL_LENGTH) return null
val uri = runCatching { URI(value) }.getOrNull() ?: return null
val scheme = uri.scheme?.lowercase(Locale.ROOT) ?: return null
if (scheme !in SAFE_SCHEMES) return null
if (scheme != "tg" && uri.host.isNullOrEmpty()) return null
return value
}
/** True for links that open in the Telegram app. */
fun isTelegramLink(url: String?): Boolean {
val uri = runCatching { URI(url.orEmpty().trim()) }.getOrNull() ?: return false
val scheme = uri.scheme?.lowercase(Locale.ROOT) ?: return false
if (scheme == "tg") return true
return uri.host?.lowercase(Locale.ROOT) in TELEGRAM_HOSTS
}
private fun parseUserInfoFields(header: String?): Map<String, Long>? {
if (header.isNullOrBlank()) return null
val result = HashMap<String, Long>()
for (part in header.split(';')) {
val separator = part.indexOf('=')
if (separator <= 0) continue
val key = part.substring(0, separator).trim().lowercase(Locale.ROOT)
val raw = part.substring(separator + 1).trim()
val number = raw.toLongOrNull() ?: raw.toDoubleOrNull()?.takeIf { it.isFinite() }?.toLong()
if (number != null) result[key] = number.coerceAtLeast(0)
}
return result.takeIf { it.isNotEmpty() }
}
private fun normalizeExpire(value: Long): Long =
if (value >= MILLIS_THRESHOLD) value / 1000 else value
private fun decodeTitle(raw: String?, decodeBase64: (String) -> String): String? {
val value = raw?.trim().orEmpty()
if (value.isEmpty()) return null
val decoded = when {
value.startsWith(BASE64_PREFIX, ignoreCase = true) ->
decodeBase64(value.substring(BASE64_PREFIX.length).trim())
value.contains('%') ->
runCatching { URLDecoder.decode(value, "UTF-8") }.getOrDefault(value)
else -> value
}
return decoded.trim().take(MAX_TITLE_LENGTH).ifEmpty { null }
}
}
@@ -126,7 +126,7 @@ object RootProxyManager {
val lanShare = forceLanShare || MmkvManager.decodeSettingsBool(AppConfig.PREF_ROOT_LAN_SHARING)
val corePid = Process.myPid()
val config = buildHevConfig(socksUsername, socksPassword, port, ipv6)
val config = buildHevConfig(socksUsername, socksPassword, port, ipv6, SettingsManager.getVpnMtu(), lanShare)
if (!writeHevConfig(cfgFile, config)) {
return null
}
@@ -209,14 +209,28 @@ object RootProxyManager {
* tun address when IPv6 is enabled; whether v6 actually flows in is decided separately by
* the v6 route into [TABLE].
*/
private fun buildHevConfig(socksUsername: String?, socksPassword: String?, socksPort: Int, ipv6: Boolean): String {
internal fun buildHevConfig(
socksUsername: String?,
socksPassword: String?,
socksPort: Int,
ipv6: Boolean,
mtu: Int,
lanSharing: Boolean,
): String {
val v4 = AppConfig.ROOT_TUN_ADDR_V4.substringBefore("/")
val v6 = AppConfig.ROOT_TUN_ADDR_V6.substringBefore("/")
return buildString {
appendLine("tunnel:")
appendLine(" name: '$TUN'")
appendLine(" mtu: ${SettingsManager.getVpnMtu()}")
appendLine(" mtu: $mtu")
appendLine(" multi-queue: true")
if (lanSharing) {
// VNET setup or TCP-GSO EINVAL on Android can leave IPv6 packets above the LAN MTU.
// Remove when both fallback forwarding tests pass with jumbo and TSO enabled.
appendLine(" offload:")
appendLine(" jumbo: false")
if (ipv6) appendLine(" tso: false")
}
appendLine(" ipv4: '$v4'")
if (ipv6) appendLine(" ipv6: '$v6'")
appendLine("socks5:")
@@ -24,6 +24,7 @@ import com.v2ray.ang.handler.AngConfigManager
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.SettingsChangeManager
import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.handler.SubscriptionInfoParser
import com.v2ray.ang.ui.AboutActivity
import com.v2ray.ang.ui.backup.BackupActivity
import com.v2ray.ang.ui.base.HelperBaseComponentActivity
@@ -116,6 +117,7 @@ class MainActivity : HelperBaseComponentActivity() {
is MainAction.EditServer -> editServer(action.guid, action.profile)
is MainAction.ShareClipboard -> shareToClipboard(action.guid)
is MainAction.ShareFullContent -> shareFullContentAsync(action.guid)
is MainAction.OpenSubscriptionLink -> openSubscriptionLink(action.url)
else -> mainViewModel.onAction(action)
}
},
@@ -136,6 +138,12 @@ class MainActivity : HelperBaseComponentActivity() {
}
}
private fun openSubscriptionLink(url: String) {
// Links come from a remote panel, so only http(s) and tg:// are ever handed to ACTION_VIEW.
val safeUrl = SubscriptionInfoParser.sanitizeLink(url) ?: return
Utils.openUri(this, safeUrl)
}
private fun navigateTo(destination: MainDestination) {
val intent = when (destination) {
MainDestination.Subscriptions -> Intent(this, SubSettingActivity::class.java)
@@ -45,6 +45,7 @@ sealed interface MainAction {
data object RemoveInvalidServers : MainAction
data object SortByTestResults : MainAction
data object UpdateSubscriptions : MainAction
data class UpdateGroupSubscription(val groupId: String) : MainAction
data object ExportAll : MainAction
data object ImportQRcode : MainAction
@@ -63,6 +64,7 @@ sealed interface MainAction {
data class ShareClipboard(val guid: String) : MainAction
data class ShareFullContent(val guid: String) : MainAction
data object DismissQRCodeDialog : MainAction
data class OpenSubscriptionLink(val url: String) : MainAction
data class ImportBatchConfig(val configText: String) : MainAction
@@ -1,26 +1,68 @@
package com.v2ray.ang.ui.main
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.relocation.BringIntoViewRequester
import androidx.compose.foundation.relocation.bringIntoViewRequester
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ScrollableTabRow
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRowDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.compose.material3.TabRowDefaults.tabIndicatorOffset
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.v2ray.ang.dto.GroupMapItem
import com.v2ray.ang.dto.entities.ServersCache
import kotlinx.coroutines.flow.StateFlow
private val TAB_BAR_PADDING = 3.dp
private val TAB_SPACING = 2.dp
private val TAB_HEIGHT = 30.dp
private val SELECTED_TAB_MARGIN = 12.dp
/**
* Subscription group tabs: a floating rounded bar whose selected tab is a tinted pill and whose
* server count is a small badge.
*
* While all tabs fit, they share the bar width equally (like Telegram's folder tabs); once they
* no longer fit, they keep their natural width and the bar scrolls horizontally, keeping the
* selected tab in view.
*/
@Composable
fun GroupTabBar(
groups: List<GroupMapItem>,
@@ -30,33 +72,91 @@ fun GroupTabBar(
modifier: Modifier = Modifier
) {
val selectedIndex = selectedTabIndex.coerceIn(0, groups.lastIndex)
ScrollableTabRow(
selectedTabIndex = selectedIndex,
val colors = MaterialTheme.colorScheme
// The plain surface color equals the screen background in dark mode, so the bar needs its own tone there.
val dark = colors.surface.luminance() < 0.5f
Surface(
modifier = modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.95f)),
edgePadding = 16.dp,
indicator = { tabPositions ->
TabRowDefaults.SecondaryIndicator(
modifier = Modifier.tabIndicatorOffset(tabPositions[selectedIndex]),
color = MaterialTheme.colorScheme.secondary
)
}
.padding(horizontal = 12.dp, vertical = 6.dp),
shape = CircleShape,
color = if (dark) colors.surfaceContainerHigh else colors.surface,
border = BorderStroke(0.5.dp, colors.outlineVariant.copy(alpha = if (dark) 0.6f else 0.35f)),
shadowElevation = 3.dp
) {
groups.forEachIndexed { index, group ->
val serverFlow = remember(group.id, mainViewModel) {
mainViewModel.serversForGroup(group.id)
BoxWithConstraints {
val barPaddingPx = with(LocalDensity.current) { TAB_BAR_PADDING.roundToPx() }
val availablePx = constraints.maxWidth - 2 * barPaddingPx
Row(
Modifier
.horizontalScroll(rememberScrollState())
.padding(TAB_BAR_PADDING)
) {
EqualOrNaturalTabsLayout(availablePx = availablePx) {
groups.forEachIndexed { index, group ->
key(group.id) {
val serverFlow = remember(group.id, mainViewModel) {
mainViewModel.serversForGroup(group.id)
}
GroupTabItem(
group = group,
selected = index == selectedIndex,
serverFlow = serverFlow,
onClick = { onTabClick(index) }
)
}
}
}
}
GroupTabItem(
group = group,
selected = index == selectedIndex,
serverFlow = serverFlow,
onClick = { onTabClick(index) }
)
}
}
}
/**
* Lays the tabs out in a row. If every tab fits in an equal share of [availablePx] they all get
* that share; otherwise, if they still fit together, the spare space is spread evenly; otherwise
* they keep their natural width (the parent scrolls).
*/
@Composable
private fun EqualOrNaturalTabsLayout(
availablePx: Int,
content: @Composable () -> Unit
) {
Layout(content = content) { measurables, _ ->
if (measurables.isEmpty()) return@Layout layout(0, 0) {}
val spacing = TAB_SPACING.roundToPx()
val tabHeight = TAB_HEIGHT.roundToPx()
val count = measurables.size
val gaps = spacing * (count - 1)
val natural = measurables.map { it.maxIntrinsicWidth(tabHeight) }
val equalShare = (availablePx - gaps) / count
val widths = when {
availablePx <= 0 -> natural
natural.all { it <= equalShare } -> List(count) { equalShare }
natural.sum() + gaps <= availablePx -> {
val extra = (availablePx - gaps - natural.sum()) / count
natural.map { it + extra }
}
else -> natural
}
val placeables = measurables.mapIndexed { i, measurable ->
measurable.measure(Constraints.fixed(widths[i], tabHeight))
}
layout(widths.sum() + gaps, tabHeight) {
var x = 0
placeables.forEach { placeable ->
placeable.placeRelative(x, 0)
x += placeable.width + spacing
}
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun GroupTabItem(
group: GroupMapItem,
@@ -65,25 +165,61 @@ private fun GroupTabItem(
onClick: () -> Unit
) {
val servers by serverFlow.collectAsStateWithLifecycle()
val text = if (group.id.isEmpty()) {
group.remarks
} else {
"${group.remarks} (${servers.size})"
}
// The default group (empty id) never showed a count.
val count = if (group.id.isEmpty()) 0 else servers.size
val colors = MaterialTheme.colorScheme
val bringIntoView = remember { BringIntoViewRequester() }
var size by remember { mutableStateOf(IntSize.Zero) }
val marginPx = with(LocalDensity.current) { SELECTED_TAB_MARGIN.toPx() }
Tab(
selected = selected,
onClick = onClick,
modifier = Modifier
.widthIn(min = 56.dp)
.heightIn(min = 48.dp),
text = {
Text(
text = text,
maxLines = 1,
softWrap = false,
overflow = TextOverflow.Ellipsis
// Bring the tab in with some room around it so the pill never ends up flush against the bar's
// rounded edge; at the first and last tab the scroll stops at the bar's own inner padding.
LaunchedEffect(selected, size) {
if (selected && size != IntSize.Zero) {
bringIntoView.bringIntoView(
Rect(-marginPx, 0f, size.width + marginPx, size.height.toFloat())
)
}
)
}
// One selectable node: name, count and selected state are announced together.
Row(
modifier = Modifier
.bringIntoViewRequester(bringIntoView)
.onSizeChanged { size = it }
.clip(CircleShape)
.background(if (selected) colors.primary.copy(alpha = 0.12f) else Color.Transparent)
.selectable(selected = selected, role = Role.Tab, onClick = onClick)
.padding(horizontal = 12.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = group.remarks,
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.Medium,
color = if (selected) colors.primary else colors.onSurfaceVariant,
maxLines = 1,
softWrap = false,
overflow = TextOverflow.Ellipsis
)
if (count > 0) {
Spacer(Modifier.width(3.dp))
Box(
modifier = Modifier
.defaultMinSize(minWidth = 16.dp, minHeight = 16.dp)
.clip(CircleShape)
.background(if (selected) colors.primary else colors.onSurfaceVariant.copy(alpha = 0.55f))
.padding(horizontal = 4.dp),
contentAlignment = Alignment.Center
) {
Text(
text = count.toString(),
style = MaterialTheme.typography.labelSmall.copy(fontSize = 10.sp, lineHeight = 12.sp),
color = if (selected) colors.onPrimary else colors.surface,
maxLines = 1
)
}
}
}
}
@@ -245,6 +245,9 @@ fun MainScreen(
shareTarget = Triple(guid, profile, true)
},
onRemoveServer = removeServer,
usageInfo = group.usageInfo,
groupTitle = group.remarks,
onOpenLink = { url -> onAction(MainAction.OpenSubscriptionLink(url)) },
contentPadding = PaddingValues(
start = 0.dp,
top = 0.dp,
@@ -9,6 +9,8 @@ import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.calculateEndPadding
import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
@@ -32,10 +34,15 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.semantics
@@ -49,6 +56,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.v2ray.ang.R
import com.v2ray.ang.dto.LocateTarget
import com.v2ray.ang.dto.entities.ProfileItem
import com.v2ray.ang.dto.entities.SubscriptionUsageInfo
import com.v2ray.ang.ui.compose.ItemDivider
import com.v2ray.ang.ui.compose.ReorderableGridItem
import com.v2ray.ang.ui.compose.ReorderableListItem
@@ -76,6 +84,9 @@ fun GroupPagerPage(
onShareServer: (String, ProfileItem) -> Unit,
onMoreServer: (String, ProfileItem) -> Unit,
onRemoveServer: (String, String) -> Unit,
usageInfo: SubscriptionUsageInfo?,
groupTitle: String,
onOpenLink: (String) -> Unit,
contentPadding: PaddingValues
) {
val groupStateFlow = remember(groupId) {
@@ -98,22 +109,50 @@ fun GroupPagerPage(
remove = onRemoveServer,
)
}
ServerListPage(
rows = groupState.rows,
selectedGuid = selectedGuid,
locateTarget = locateTarget?.takeIf { it.groupId == groupId },
canReorder = canReorder,
doubleColumnDisplay = doubleColumnDisplay,
groupId = groupId,
lazyListStates = lazyListStates,
lazyGridStates = lazyGridStates,
actions = actions,
onLocateHandled = { mainViewModel.onAction(MainAction.LocateHandled) },
onMoveServer = { fromIndex, toIndex ->
mainViewModel.moveServer(groupId, fromIndex, toIndex)
},
contentPadding = contentPadding
)
// The box floats over the list (the list scrolls underneath it), so the list is padded by the box height.
val density = LocalDensity.current
val layoutDirection = LocalLayoutDirection.current
var infoHeightPx by remember { mutableIntStateOf(0) }
val listPadding = if (usageInfo == null) {
contentPadding
} else {
PaddingValues(
start = contentPadding.calculateStartPadding(layoutDirection),
top = contentPadding.calculateTopPadding() + with(density) { infoHeightPx.toDp() },
end = contentPadding.calculateEndPadding(layoutDirection),
bottom = contentPadding.calculateBottomPadding()
)
}
Box(Modifier.fillMaxSize()) {
ServerListPage(
rows = groupState.rows,
selectedGuid = selectedGuid,
locateTarget = locateTarget?.takeIf { it.groupId == groupId },
canReorder = canReorder,
doubleColumnDisplay = doubleColumnDisplay,
groupId = groupId,
lazyListStates = lazyListStates,
lazyGridStates = lazyGridStates,
actions = actions,
onLocateHandled = { mainViewModel.onAction(MainAction.LocateHandled) },
onMoveServer = { fromIndex, toIndex ->
mainViewModel.moveServer(groupId, fromIndex, toIndex)
},
contentPadding = listPadding
)
if (usageInfo != null) {
SubscriptionInfoCard(
info = usageInfo,
fallbackTitle = groupTitle,
onOpenLink = onOpenLink,
onRefresh = { mainViewModel.onAction(MainAction.UpdateGroupSubscription(groupId)) },
modifier = Modifier
.align(Alignment.TopCenter)
.onSizeChanged { infoHeightPx = it.height }
)
}
}
}
private class ServerRowActions(
@@ -0,0 +1,197 @@
package com.v2ray.ang.ui.main
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.v2ray.ang.R
import com.v2ray.ang.dto.entities.SubscriptionUsageInfo
import com.v2ray.ang.extension.toTrafficString
import com.v2ray.ang.handler.SubscriptionInfoParser
import com.v2ray.ang.util.Utils
private const val MILLIS_PER_SECOND = 1000L
private const val EXPIRY_DATE_PATTERN = "yyyy-MM-dd"
private const val CARD_ALPHA = 0.97f
/** Locale-neutral values the subscription information box renders. */
internal data class SubscriptionInfoUiModel(
val title: String?,
val usedBytes: Long,
/** Traffic quota in bytes; 0 means unlimited. */
val totalBytes: Long,
/** Used share of the quota in 0..1; null when the quota is unlimited. */
val trafficFraction: Float?,
/** Expiry in epoch milliseconds; null when the subscription never expires. */
val expireMillis: Long?,
val expired: Boolean,
val supportUrl: String?,
val webPageUrl: String?,
) {
val trafficLow: Boolean get() = trafficFraction != null && trafficFraction >= TRAFFIC_WARNING_FRACTION
companion object {
const val TRAFFIC_WARNING_FRACTION = 0.9f
}
}
internal fun buildSubscriptionInfoUiModel(
info: SubscriptionUsageInfo,
nowMillis: Long,
): SubscriptionInfoUiModel {
val unlimited = info.total <= 0L
val expireMillis = info.expire.takeIf { it > 0L }?.let { it * MILLIS_PER_SECOND }
return SubscriptionInfoUiModel(
title = info.title,
usedBytes = info.used,
totalBytes = if (unlimited) 0L else info.total,
trafficFraction = if (unlimited) null else (info.used.toDouble() / info.total).toFloat().coerceIn(0f, 1f),
expireMillis = expireMillis,
expired = expireMillis != null && expireMillis <= nowMillis,
supportUrl = info.supportUrl,
webPageUrl = info.webPageUrl,
)
}
/**
* Fixed box on top of a subscription group showing the account information published by the
* subscription panel: traffic, expiry date and shortcuts (support link, panel page, update).
*/
@Composable
internal fun SubscriptionInfoCard(
info: SubscriptionUsageInfo,
fallbackTitle: String,
onOpenLink: (String) -> Unit,
onRefresh: () -> Unit,
modifier: Modifier = Modifier,
) {
val model = remember(info) { buildSubscriptionInfoUiModel(info, System.currentTimeMillis()) }
Surface(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 4.dp),
shape = MaterialTheme.shapes.medium,
// Almost opaque: the list scrolls underneath without showing through readably.
color = MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = CARD_ALPHA),
border = BorderStroke(0.5.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)),
) {
Column(Modifier.padding(start = 16.dp, end = 8.dp, top = 2.dp, bottom = 10.dp)) {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Text(
text = model.title ?: fallbackTitle,
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
model.supportUrl?.let { url ->
CardIconButton(
iconRes = if (SubscriptionInfoParser.isTelegramLink(url)) {
R.drawable.ic_telegram_24dp
} else {
R.drawable.ic_feedback_24dp
},
contentDescription = stringResource(R.string.sub_info_open_support),
onClick = { onOpenLink(url) },
)
}
model.webPageUrl?.let { url ->
CardIconButton(
iconRes = R.drawable.ic_about_24dp,
contentDescription = stringResource(R.string.sub_info_open_page),
onClick = { onOpenLink(url) },
)
}
CardIconButton(
iconRes = R.drawable.ic_refresh_24dp,
contentDescription = stringResource(R.string.title_sub_update),
onClick = onRefresh,
)
}
// One semantics node for the read-only figures so TalkBack reads them in one go.
Column(
Modifier
.fillMaxWidth()
.padding(end = 8.dp)
.semantics(mergeDescendants = true) {}
) {
StatsRow(model)
model.trafficFraction?.let { fraction ->
Spacer(Modifier.height(4.dp))
LinearProgressIndicator(
progress = { fraction },
modifier = Modifier
.fillMaxWidth()
.height(4.dp)
.clip(RoundedCornerShape(2.dp))
.clearAndSetSemantics {},
color = if (model.trafficLow) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary,
trackColor = MaterialTheme.colorScheme.surfaceVariant,
)
}
}
}
}
}
@Composable
private fun CardIconButton(iconRes: Int, contentDescription: String, onClick: () -> Unit) {
IconButton(onClick = onClick, modifier = Modifier.size(32.dp)) {
Icon(painterResource(iconRes), contentDescription, Modifier.size(20.dp))
}
}
@Composable
private fun StatsRow(model: SubscriptionInfoUiModel) {
val totalText = if (model.totalBytes > 0L) {
model.totalBytes.toTrafficString()
} else {
stringResource(R.string.sub_info_unlimited)
}
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(
text = stringResource(R.string.sub_info_traffic, model.usedBytes.toTrafficString(), totalText),
modifier = Modifier.weight(1f, fill = false),
style = MaterialTheme.typography.bodySmall,
color = if (model.trafficLow) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
model.expireMillis?.let { expireMillis ->
Spacer(Modifier.size(8.dp))
Text(
text = Utils.formatTimestamp(expireMillis, EXPIRY_DATE_PATTERN),
style = MaterialTheme.typography.bodySmall,
color = if (model.expired) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
)
}
}
}
@@ -18,6 +18,7 @@ import com.v2ray.ang.extension.delay
import com.v2ray.ang.extension.isComplexType
import com.v2ray.ang.extension.matchesPattern
import com.v2ray.ang.extension.moveItem
import com.v2ray.ang.handler.SubscriptionInfoParser
import com.v2ray.ang.ui.base.BaseViewModel
import com.v2ray.ang.util.LogUtil
import kotlinx.coroutines.CancellationException
@@ -283,7 +284,8 @@ class MainViewModel(
MainAction.RemoveDuplicateServers -> removeDuplicateServerAsync()
MainAction.RemoveInvalidServers -> removeInvalidServerAsync()
MainAction.SortByTestResults -> sortByTestResultsAsync()
MainAction.UpdateSubscriptions -> importConfigViaSub()
MainAction.UpdateSubscriptions -> importConfigViaSub(uiState.value.selectedGroupId)
is MainAction.UpdateGroupSubscription -> importConfigViaSub(action.groupId)
MainAction.ExportAll -> exportAllAsync()
is MainAction.SelectGroup -> subscriptionIdChanged(action.groupId)
is MainAction.SelectServer -> updateSelectedGuid(action.guid)
@@ -310,7 +312,8 @@ class MainViewModel(
MainAction.LocateSelectedServer,
is MainAction.EditServer,
is MainAction.ShareClipboard,
is MainAction.ShareFullContent -> {
is MainAction.ShareFullContent,
is MainAction.OpenSubscriptionLink -> {
// Handled by Activity via its onAction lambda
}
}
@@ -453,7 +456,13 @@ class MainViewModel(
cacheMutex.withLock { groupDataCache.clear() }
}
val groups = dataSource.getSubscriptions().map {
GroupMapItem(id = it.guid, remarks = it.subscription.remarks)
GroupMapItem(
id = it.guid,
remarks = it.subscription.remarks,
usageInfo = it.subscription.usageInfo?.let { info ->
SubscriptionInfoParser.withWebPageFallback(info, it.subscription.url)
},
)
}
val selectedGroup = resolveSelectedGroup(groups)
val validIds = groups.mapTo(HashSet()) { it.id }
@@ -532,8 +541,7 @@ class MainViewModel(
}
}
private fun importConfigViaSub() {
val subId = uiState.value.selectedGroupId
private fun importConfigViaSub(subId: String) {
launchLoading {
withContext(ioDispatcher) {
try {
@@ -121,6 +121,9 @@ class SubscriptionsViewModel(application: Application) : BaseViewModel(applicati
else ->
toast(getString(R.string.title_update_subscription_result, result.configCount, result.successCount, result.failureCount, result.skipCount))
}
if (result.configCount > 0) {
SettingsChangeManager.makeSetupGroupTab()
}
reload()
} catch (cancelled: CancellationException) {
throw cancelled
@@ -5,6 +5,7 @@ import com.v2ray.ang.AppConfig.LOOPBACK
import com.v2ray.ang.BuildConfig
import com.v2ray.ang.dto.UrlContentRequest
import okhttp3.Credentials
import okhttp3.Headers
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.File
@@ -139,11 +140,15 @@ object HttpUtil {
* @param url The URL to fetch content from.
* @param timeout The timeout value in milliseconds.
* @param httpPort The HTTP port to use.
* @param onResponseHeaders Receives the headers of the successful response that produced the returned content.
* @return The content of the URL as a string.
* @throws IOException If an I/O error occurs.
*/
@Throws(IOException::class)
fun getUrlContentWithUserAgent(request: UrlContentRequest): String {
fun getUrlContentWithUserAgent(
request: UrlContentRequest,
onResponseHeaders: ((Headers) -> Unit)? = null
): String {
var currentUrl = request.url
var redirects = 0
val maxRedirects = 3
@@ -193,6 +198,7 @@ object HttpUtil {
}
response.isSuccessful -> {
onResponseHeaders?.invoke(response.headers)
return response.body?.string() ?: ""
}
@@ -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>
@@ -568,5 +568,8 @@
<string name="tasker_blurb_stop">إيقاف %1$s</string>
<string name="connection_test_empty_message">لا تتوفر تفاصيل</string>
<string name="value_unknown">غير معروف</string>
<string name="sub_info_open_support">فتح رابط الدعم</string>
<string name="sub_info_open_page">فتح صفحة معلومات الاشتراك</string>
<string name="sub_info_unlimited">غير محدود</string>
</resources>
@@ -212,7 +212,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="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="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 +357,6 @@
<string name="title_export_config_count">%d konfiqurasiya ixrac edilsin</string>
<plurals name="title_update_config_count">
<item quantity="one">%1$d konfiqurasiya yeniləndi</item>
<item quantity="other">%1$d konfiqurasiya yeniləndi</item>
</plurals>
<plurals name="title_update_asset_count">
@@ -561,5 +560,9 @@
<string name="tasker_blurb_stop">%1$s Dayandır</string>
<string name="connection_test_empty_message">ətraflı məlumat yoxdur</string>
<string name="value_unknown">naməlum</string>
<string name="sub_info_open_support">Dəstək linkini aç</string>
<string name="sub_info_open_page">Abunəlik səhifəsini aç</string>
<string name="sub_info_traffic" translatable="false">%1$s / %2$s</string>
<string name="sub_info_unlimited">Limitsiz</string>
</resources>
@@ -560,5 +560,8 @@
<string name="tasker_blurb_stop">%1$s বন্ধ করুন</string>
<string name="connection_test_empty_message">কোনো বিবরণ পাওয়া যায়নি</string>
<string name="value_unknown">অজানা</string>
<string name="sub_info_open_support">সাপোর্ট লিঙ্ক খুলুন</string>
<string name="sub_info_open_page">সাবস্ক্রিপশন তথ্যের পাতা খুলুন</string>
<string name="sub_info_unlimited">সীমাহীন</string>
</resources>
@@ -560,5 +560,8 @@
<string name="tasker_blurb_stop">واڌاشتن %1$s</string>
<string name="connection_test_empty_message">دووسمندی نؽ</string>
<string name="value_unknown">نا موشخس</string>
<string name="sub_info_open_support">واز کردن لینگ پشتیوانی</string>
<string name="sub_info_open_page">واز کردن صفحه اطلاعات اشتراک</string>
<string name="sub_info_unlimited">بی‌حد</string>
</resources>
@@ -560,5 +560,8 @@
<string name="tasker_blurb_stop">توقف %1$s</string>
<string name="connection_test_empty_message">جزئیاتی در دسترس نیست</string>
<string name="value_unknown">ناشناخته</string>
<string name="sub_info_open_support">باز کردن لینک پشتیبانی</string>
<string name="sub_info_open_page">باز کردن صفحه اطلاعات اشتراک</string>
<string name="sub_info_unlimited">نامحدود</string>
</resources>
@@ -564,5 +564,8 @@
<string name="tasker_blurb_stop">Остановить %1$s</string>
<string name="connection_test_empty_message">подробности отсутствуют</string>
<string name="value_unknown">неизвестно</string>
<string name="sub_info_open_support">Открыть ссылку поддержки</string>
<string name="sub_info_open_page">Открыть страницу подписки</string>
<string name="sub_info_unlimited">Без ограничений</string>
</resources>
@@ -558,5 +558,8 @@
<string name="tasker_blurb_stop">Dừng %1$s</string>
<string name="connection_test_empty_message">không có thông tin chi tiết</string>
<string name="value_unknown">không xác định</string>
<string name="sub_info_open_support">Mở liên kết hỗ trợ</string>
<string name="sub_info_open_page">Mở trang thông tin đăng ký</string>
<string name="sub_info_unlimited">Không giới hạn</string>
</resources>
@@ -558,5 +558,8 @@
<string name="tasker_blurb_stop">停止 %1$s</string>
<string name="connection_test_empty_message">无详细信息</string>
<string name="value_unknown">未知</string>
<string name="sub_info_open_support">打开支持链接</string>
<string name="sub_info_open_page">打开订阅信息页面</string>
<string name="sub_info_unlimited">无限制</string>
</resources>
@@ -558,5 +558,8 @@
<string name="tasker_blurb_stop">停止 %1$s</string>
<string name="connection_test_empty_message">沒有詳細資訊</string>
<string name="value_unknown">未知</string>
<string name="sub_info_open_support">開啟支援連結</string>
<string name="sub_info_open_page">開啟訂閱資訊頁面</string>
<string name="sub_info_unlimited">無限制</string>
</resources>
@@ -560,5 +560,9 @@
<string name="tasker_blurb_stop">Stop %1$s</string>
<string name="connection_test_empty_message">no details available</string>
<string name="value_unknown">unknown</string>
<string name="sub_info_open_support">Open support link</string>
<string name="sub_info_open_page">Open subscription page</string>
<string name="sub_info_traffic" translatable="false">%1$s / %2$s</string>
<string name="sub_info_unlimited">Unlimited</string>
</resources>
@@ -0,0 +1,89 @@
package com.v2ray.ang.fmt
import com.v2ray.ang.AppConfig
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import java.net.URI
class Hysteria2FmtTest {
@Test
fun hysteria2WithoutPortUses443() {
val profile = Hysteria2Fmt.parse("hysteria2://test@example.com/")
assertEquals("example.com", profile.server)
assertEquals("443", profile.serverPort)
}
@Test
fun hy2WithoutPortUses443() {
val profile = Hysteria2Fmt.parse("hy2://test@example.com/")
assertEquals("example.com", profile.server)
assertEquals("443", profile.serverPort)
}
@Test
fun uriWithoutCredentialsUses443() {
val profile = Hysteria2Fmt.parse("hysteria2://example.com/")
assertEquals("example.com", profile.server)
assertEquals("443", profile.serverPort)
}
@Test
fun omittedPortPreservesTlsParameters() {
val profile = Hysteria2Fmt.parse(
"hy2://test@example.com/?sni=example.org&insecure=0&pinSHA256=test-pin#example"
)
assertEquals("test", profile.password)
assertEquals("example", profile.remarks)
assertEquals(AppConfig.TLS, profile.security)
assertEquals("example.org", profile.sni)
assertEquals(false, profile.insecure)
assertEquals("test-pin", profile.pinnedCA256)
assertEquals("443", profile.serverPort)
}
@Test
fun ipv6WithoutPortUses443() {
val profile = Hysteria2Fmt.parse("hysteria2://test@[2001:db8::1]/")
assertEquals("2001:db8::1", profile.server)
assertEquals("443", profile.serverPort)
}
@Test
fun explicitPortsArePreserved() {
for (scheme in listOf("hysteria2", "hy2")) {
// Port range validation is separate from choosing a missing-port default.
for (port in listOf(0, 443, 8443, 65535, 65536)) {
val profile = Hysteria2Fmt.parse("$scheme://test@example.com:$port/")
assertEquals("example.com", profile.server)
assertEquals(port.toString(), profile.serverPort)
}
}
}
@Test
fun nonnumericPortDoesNotAcquireAHost() {
val profile = Hysteria2Fmt.parse("hysteria2://test@example.com:invalid/")
assertEquals("", profile.server)
}
@Test
fun defaultPortSurvivesUriRoundTrip() {
val profile = Hysteria2Fmt.parse("hy2://test@example.com/?sni=example.org")
val exported = "hysteria2://${Hysteria2Fmt.toUri(profile)}"
val reparsed = Hysteria2Fmt.parse(exported)
assertEquals(443, URI(exported).port)
assertEquals("443", reparsed.serverPort)
assertEquals(profile.server, reparsed.server)
assertEquals(profile.password, reparsed.password)
assertEquals(profile.sni, reparsed.sni)
}
}
@@ -0,0 +1,91 @@
package com.v2ray.ang.handler
import com.v2ray.ang.dto.entities.SubscriptionUsageInfo
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.util.Base64
class SubscriptionInfoParserTest {
private val decode: (String) -> String = { String(Base64.getDecoder().decode(it), Charsets.UTF_8) }
private fun parse(
userInfo: String? = null,
title: String? = null,
support: String? = null,
web: String? = null,
) = SubscriptionInfoParser.parse(userInfo, title, support, web, decode)
@Test
fun noHeadersGivesNull() {
assertNull(parse())
assertNull(parse(userInfo = " ", title = "", support = "ftp://x", web = "javascript:alert(1)"))
}
@Test
fun parsesStandardUserInfo() {
val info = parse(userInfo = "upload=100; download=200; total=1000; expire=1893456000")!!
assertEquals(100, info.upload)
assertEquals(200, info.download)
assertEquals(300, info.used)
assertEquals(1000, info.total)
assertEquals(1893456000, info.expire)
}
@Test
fun toleratesCaseSpacesFloatsAndUnknownKeys() {
val info = parse(userInfo = "Upload=1.5e3;DOWNLOAD = 10 ;foo=bar;total=-5;expire=")!!
assertEquals(1500, info.upload)
assertEquals(10, info.download)
assertEquals(0, info.total)
assertEquals(0, info.expire)
}
@Test
fun expireInMillisecondsIsNormalizedToSeconds() {
assertEquals(1893456000, parse(userInfo = "expire=1893456000000")!!.expire)
}
@Test
fun garbageUserInfoWithoutOtherHeadersGivesNull() {
assertNull(parse(userInfo = "hello world"))
}
@Test
fun decodesBase64PlainAndPercentEncodedTitles() {
val b64 = Base64.getEncoder().encodeToString("اشتراک من".toByteArray())
assertEquals("اشتراک من", parse(title = "base64:$b64")!!.title)
assertEquals("My Plan", parse(title = "My Plan")!!.title)
assertEquals("My Plan", parse(title = "My%20Plan")!!.title)
}
@Test
fun onlyHttpHttpsAndTgLinksAreAccepted() {
assertEquals("https://t.me/support", parse(support = " https://t.me/support ")!!.supportUrl)
assertEquals("tg://resolve?domain=x", parse(support = "tg://resolve?domain=x")!!.supportUrl)
assertNull(SubscriptionInfoParser.sanitizeLink("javascript:alert(1)"))
assertNull(SubscriptionInfoParser.sanitizeLink("intent://x#Intent;end"))
assertNull(SubscriptionInfoParser.sanitizeLink("file:///etc/passwd"))
assertNull(SubscriptionInfoParser.sanitizeLink("https://"))
assertNull(SubscriptionInfoParser.sanitizeLink(null))
}
@Test
fun detectsTelegramLinks() {
assertTrue(SubscriptionInfoParser.isTelegramLink("https://t.me/abc"))
assertTrue(SubscriptionInfoParser.isTelegramLink("tg://resolve?domain=abc"))
assertFalse(SubscriptionInfoParser.isTelegramLink("https://example.com/t.me"))
assertFalse(SubscriptionInfoParser.isTelegramLink(null))
}
@Test
fun webPageFallsBackToSubscriptionUrl() {
val base = SubscriptionUsageInfo(total = 1)
assertEquals("https://p.example/sub/abc", SubscriptionInfoParser.withWebPageFallback(base, "https://p.example/sub/abc").webPageUrl)
val own = base.copy(webPageUrl = "https://p.example/info")
assertEquals("https://p.example/info", SubscriptionInfoParser.withWebPageFallback(own, "https://p.example/sub/abc").webPageUrl)
assertNull(SubscriptionInfoParser.withWebPageFallback(base, "not a url").webPageUrl)
}
}
@@ -1,10 +1,43 @@
package com.v2ray.ang.root
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
class RootProxyManagerTest {
@Test
fun `IPv6 LAN forwarding disables jumbo and TSO`() {
val config = RootProxyManager.buildHevConfig(null, null, 10808, true, 1500, true)
assertTrue(config.contains(" offload:\n jumbo: false\n"))
assertTrue(config.contains(" multi-queue: true\n"))
assertTrue(config.contains(" tso: false\n"))
assertFalse(config.contains("uso:"))
}
@Test
fun `local root keeps default offloads and configured MTU`() {
val config = RootProxyManager.buildHevConfig(null, null, 10808, true, 8500, false)
assertFalse(config.contains("offload:"))
assertTrue(config.contains(" mtu: 8500\n"))
assertTrue(config.contains(" ipv6:"))
}
@Test
fun `LAN offload guard preserves IPv4 only and quoted authentication`() {
val config = RootProxyManager.buildHevConfig("user'name", "pa'ss", 10809, false, 1500, true)
assertTrue(config.contains(" offload:\n jumbo: false\n"))
assertFalse(config.contains("tso:"))
assertFalse(config.contains(" ipv6:"))
assertTrue(config.contains(" port: 10809\n"))
assertTrue(config.contains(" username: 'user''name'\n"))
assertTrue(config.contains(" password: 'pa''ss'\n"))
}
@Test
fun `single quotes are escaped in YAML scalars`() {
assertEquals("'user''name'", "user'name".toSingleQuotedYamlScalar())
@@ -0,0 +1,50 @@
package com.v2ray.ang.ui.main
import com.v2ray.ang.dto.entities.SubscriptionUsageInfo
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
class MainSubscriptionInfoTest {
private val now = 1_000_000_000_000L // ms
@Test
fun limitedTrafficComputesFraction() {
val model = buildSubscriptionInfoUiModel(SubscriptionUsageInfo(upload = 100, download = 300, total = 1000), now)
assertEquals(400, model.usedBytes)
assertEquals(0.4f, model.trafficFraction!!, 0.0001f)
assertFalse(model.trafficLow)
}
@Test
fun unlimitedTrafficHasNoFraction() {
val model = buildSubscriptionInfoUiModel(SubscriptionUsageInfo(download = 5), now)
assertEquals(0L, model.totalBytes)
assertNull(model.trafficFraction)
assertFalse(model.trafficLow)
}
@Test
fun overUsageIsClampedAndFlaggedLow() {
val model = buildSubscriptionInfoUiModel(SubscriptionUsageInfo(download = 2000, total = 1000), now)
assertEquals(1f, model.trafficFraction!!, 0.0001f)
assertTrue(model.trafficLow)
}
@Test
fun expiryIsConvertedToMillisAndFlaggedWhenPast() {
val future = buildSubscriptionInfoUiModel(SubscriptionUsageInfo(expire = now / 1000 + 60), now)
assertEquals(now + 60_000, future.expireMillis)
assertFalse(future.expired)
assertTrue(buildSubscriptionInfoUiModel(SubscriptionUsageInfo(expire = now / 1000 - 1), now).expired)
}
@Test
fun zeroExpireMeansNeverExpires() {
val model = buildSubscriptionInfoUiModel(SubscriptionUsageInfo(expire = 0), now)
assertNull(model.expireMillis)
assertFalse(model.expired)
}
}
+1 -1
View File
@@ -11,7 +11,7 @@ activity = "1.13.0"
appcompat = "1.8.0"
mmkvStatic = "1.3.17"
gson = "2.14.0"
okhttp = "5.5.0"
okhttp = "5.5.1"
kotlinxCoroutines = "1.11.0"
core = "3.5.4"
workRuntimeKtx = "2.12.0"
+1 -1
View File
@@ -1,6 +1,6 @@
#Thu Nov 14 12:42:51 BDT 2024
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.1-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.2-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists