mirror of
https://github.com/2dust/v2rayNG.git
synced 2026-10-29 14:39:25 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa4b05503a | ||
|
|
7179f0838f | ||
|
|
0d1c57000f | ||
|
|
40b8706ccd | ||
|
|
0f961c54c8 | ||
|
|
1b78a510f2 | ||
|
|
01b4630fdd | ||
|
|
6214bc19d4 | ||
|
|
b8d13bf52c | ||
|
|
3165bcf4fe | ||
|
|
eedb287cc9 | ||
|
|
59ff87606f | ||
|
|
700740a187 | ||
|
|
bb59a71cb9 | ||
|
|
552cc2425d | ||
|
|
c8f373bd3f |
No files matched your search
@@ -81,7 +81,7 @@ jobs:
|
||||
out-file-path: V2rayNG/app/libs/
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v6.0.1
|
||||
uses: actions/setup-java@v6.1.0
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '21'
|
||||
|
||||
+1
-1
Submodule AndroidLibXrayLite updated: 1be1e621be...76a6e21722.
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ activity = "1.13.0"
|
||||
appcompat = "1.8.0"
|
||||
mmkvStatic = "1.3.17"
|
||||
gson = "2.14.0"
|
||||
okhttp = "5.5.0"
|
||||
okhttp = "5.5.1"
|
||||
kotlinxCoroutines = "1.11.0"
|
||||
core = "3.5.4"
|
||||
workRuntimeKtx = "2.12.0"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
#Thu Nov 14 12:42:51 BDT 2024
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.1-bin.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.2-bin.zip
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
+1
-1
Submodule hev-socks5-tunnel updated: d9dca26c7a...c5584feca8.
Reference in new issue
Block a user