Compare commits

...
7 Commits
Author SHA1 Message Date
2dust 19b0c9a2c3 up 2.3.2 2026-08-02 15:17:48 +08:00
2dust db0755cf0c Auto delete and sort subscription after core tests 2026-08-02 15:14:40 +08:00
mola dc6a7eae93 fix: hide scrollbars during activity transitions (#6003) 2026-08-02 14:57:22 +08:00
2dust 3ae8db972e Fix ipv6 hosts
https://github.com/2dust/v2rayNG/issues/5965
2026-08-02 14:53:57 +08:00
2dust a899d7f444 Automated testing takes a long time. If enabled, the task will run in the background; otherwise, it will run on the current page.
https://github.com/2dust/v2rayNG/issues/5999
2026-08-02 14:42:00 +08:00
dkgksandroot c8f78621bf fix: restore XHTTP/gRPC/KCP transport settings in Compose server UI (#5995)
The Compose UI migration (ServerUiState + BaseServerActivity) dropped or
misbound several transport-specific fields that the legacy editor handled
correctly:

1. XHTTP mode (auto/packet-up/stream-up/stream-one) — stored in
   ProfileItem.xhttpMode, but ServerUiState had no field and the dropdown
   was bound to state.headerType (default "none")
2. gRPC mode (gun/multi) — stored in ProfileItem.mode, same problem
3. gRPC serviceName/authority — the "gRPC Authority" field was bound to
   state.host and "gRPC serviceName" to state.path, but the core outbound
   builder reads ProfileItem.authority/serviceName. Editing + saving a
   gRPC node wrote to host/path, breaking the connection
4. KCP seed — no field in ServerUiState, no input in the Compose UI

Changes (2 files):

ServerUiState.kt:
- Added constructor params + mutableState for: mode, xhttpMode,
  serviceName, authority, seed
- fromProfileItem(): read all five from initialConfig
- toProfileItem(): write all five with .nullIfBlank()

BaseServerActivity.kt CommonNetworkFields:
- Mode dropdown dispatches by network: GRPC -> state.mode,
  XHTTP -> state.xhttpMode, else -> state.headerType (TCP/KCP unchanged)
- Host/Authority field: GRPC -> state.authority, else -> state.host
- Path/serviceName field: GRPC -> state.serviceName, else -> state.path
- KCP: added seed text field, hide the path field (KCP doesn't use path;
  previously it showed a second "kcp seed" label bound to the wrong field)

Null handling is safe: XhttpSettingsBean.mode is nullable and Gson omits
null fields, so xray-core falls back to its default mode; gRPC
multiMode = (mode == "multi") is null-safe (false -> default gun).

Fixes #5993

Signed-off-by: dkgks

Signed-off-by: dkgks
Co-authored-by: root <root@debianG>
2026-08-02 10:41:49 +08:00
vemneyy 0cf95ac8a3 fix: tunnel dying some time after a network handover (#5994) 2026-08-02 10:35:36 +08:00
11 changed files with 138 additions and 50 deletions

No files matched your search

+2 -2
View File
@@ -13,8 +13,8 @@ android {
applicationId = "com.v2ray.ang"
minSdk = 24
targetSdk = 37
versionCode = 741
versionName = "2.3.1"
versionCode = 742
versionName = "2.3.2"
val abiFilterList = (properties["ABI_FILTERS"] as? String)?.split(';')
splits {
@@ -915,9 +915,14 @@ object CoreConfigManager {
val userHosts = MmkvManager.decodeSettingsString(AppConfig.PREF_DNS_HOSTS)
if (userHosts.isNotNullEmpty()) {
val userHostsMap = userHosts?.split(",").orEmpty()
.filter { it.isNotEmpty() }
.filter { it.contains(":") }
.associate { it.split(":").let { (k, v) -> k to v } }
.filter { it.isNotBlank() && it.contains(":") }
.associate {
// Use limit = 2 to split only at the first colon.
// This ensures that IPv6 addresses (which contain multiple colons)
// are preserved entirely in the second part.
val parts = it.split(":", limit = 2)
parts[0].trim() to parts[1].trim()
}
hosts.putAll(userHostsMap)
}
@@ -250,7 +250,7 @@ object CoreServiceManager {
if (!isRunning()) return false
return try {
val tunFd = tunFdForCore()
val tunFd = currentVpnInterface
isReloading = true
LogUtil.i(AppConfig.TAG, "StartCore-Manager: Core reload start...")
@@ -270,23 +270,6 @@ object CoreServiceManager {
}
}
/**
* Returns the tun descriptor to hand to the core on a reload.
*
* With hev-socks5-tunnel the core is started without a tun and never touches the descriptor.
* Otherwise it closes the one it was given when it stops, which would take the VPN interface
* down, so it gets a duplicate to close instead.
*/
private fun tunFdForCore(): ParcelFileDescriptor? {
val vpnInterface = currentVpnInterface ?: return null
return try {
if (SettingsManager.isUsingHevTun()) vpnInterface else vpnInterface.dup()
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Failed to duplicate VPN interface", e)
throw e
}
}
/**
* Queries and resets all outbound traffic counters in one core call.
* Go side format: tag,direction,value;tag,direction,value;
@@ -10,6 +10,7 @@ import com.v2ray.ang.dto.RealPingEvent
import com.v2ray.ang.dto.TestServiceMessage
import com.v2ray.ang.enums.NotificationChannelType
import com.v2ray.ang.extension.serializable
import com.v2ray.ang.handler.AngConfigManager
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.helper.MessageHelper
import com.v2ray.ang.helper.NotificationHelper
@@ -96,7 +97,7 @@ class CoreTestService : Service() {
context = this,
guids = guidsList,
onlyTcp = message.onlyTcp,
onEvent = { event -> handleWorkerEvent(event) { activeWorkers.remove(worker) } }
onEvent = { event -> handleWorkerEvent(event, message) { activeWorkers.remove(worker) } }
)
activeWorkers.add(worker)
worker.start()
@@ -106,7 +107,7 @@ class CoreTestService : Service() {
}
}
private fun handleWorkerEvent(event: RealPingEvent, onWorkerDone: () -> Unit) {
private fun handleWorkerEvent(event: RealPingEvent, message: TestServiceMessage, onWorkerDone: () -> Unit) {
when (event) {
is RealPingEvent.Progress -> {
NotificationHelper.updateNotification(
@@ -124,6 +125,16 @@ class CoreTestService : Service() {
}
is RealPingEvent.Finish -> {
if(message.subscriptionId.isNotEmpty()){
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_AUTO_REMOVE_INVALID_AFTER_TEST, false)) {
AngConfigManager.removeInvalidServer(message.subscriptionId)
}
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_AUTO_SORT_AFTER_TEST, false)) {
AngConfigManager.sortByTestResultsForSub(message.subscriptionId)
}
}
MessageHelper.sendMsg2UI(this, AppConfig.MSG_MEASURE_CONFIG_FINISH, event.status)
onWorkerDone()
if (activeWorkers.isEmpty()) {
@@ -1,7 +1,6 @@
package com.v2ray.ang.ui.compose
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.AnimationVector1D
import androidx.compose.animation.core.tween
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.lazy.LazyListState
@@ -9,6 +8,8 @@ import androidx.compose.foundation.lazy.grid.LazyGridState
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier
@@ -22,6 +23,9 @@ import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.currentStateAsState
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
@@ -43,8 +47,9 @@ private fun <T> rememberScrollbarAlpha(
key: Any,
config: ScrollbarConfig,
position: () -> T,
): Animatable<Float, AnimationVector1D> {
): State<Float> {
val alpha = remember(key) { Animatable(0f) }
val lifecycleState = LocalLifecycleOwner.current.lifecycle.currentStateAsState()
LaunchedEffect(key, config.fadeOutDurationMs, config.fadeAnimDurationMs) {
snapshotFlow(position).collectLatest {
alpha.snapTo(1f)
@@ -52,7 +57,11 @@ private fun <T> rememberScrollbarAlpha(
alpha.animateTo(0f, tween(config.fadeAnimDurationMs))
}
}
return alpha
return remember(alpha, lifecycleState) {
derivedStateOf {
if (lifecycleState.value == Lifecycle.State.RESUMED) alpha.value else 0f
}
}
}
@Composable
@@ -154,9 +154,19 @@ abstract class BaseServerActivity : BaseComponentActivity() {
else -> R.string.server_lab_head_type
}
),
state.headerType,
when (state.network) {
NetworkType.GRPC.type -> state.mode
NetworkType.XHTTP.type -> state.xhttpMode
else -> state.headerType
},
headerOptions,
{ state.headerType = it }
{
when (state.network) {
NetworkType.GRPC.type -> state.mode = it
NetworkType.XHTTP.type -> state.xhttpMode = it
else -> state.headerType = it
}
}
)
}
@@ -173,25 +183,26 @@ abstract class BaseServerActivity : BaseComponentActivity() {
else -> R.string.server_lab_request_host6
}
),
state.host,
{ state.host = it }
if (state.network == NetworkType.GRPC.type) state.authority else state.host,
{ if (state.network == NetworkType.GRPC.type) state.authority = it else state.host = it }
)
FormTextField(
stringResource(
when (state.network) {
NetworkType.KCP.type -> R.string.server_lab_path_kcp
NetworkType.WS.type -> R.string.server_lab_path_ws
NetworkType.HTTP_UPGRADE.type -> R.string.server_lab_path_httpupgrade
NetworkType.XHTTP.type -> R.string.server_lab_path_xhttp
NetworkType.H2.type -> R.string.server_lab_path_h2
NetworkType.GRPC.type -> R.string.server_lab_path_grpc
else -> R.string.server_lab_path
}
),
state.path,
{ state.path = it }
)
if (state.network != NetworkType.KCP.type) {
FormTextField(
stringResource(
when (state.network) {
NetworkType.WS.type -> R.string.server_lab_path_ws
NetworkType.HTTP_UPGRADE.type -> R.string.server_lab_path_httpupgrade
NetworkType.XHTTP.type -> R.string.server_lab_path_xhttp
NetworkType.H2.type -> R.string.server_lab_path_h2
NetworkType.GRPC.type -> R.string.server_lab_path_grpc
else -> R.string.server_lab_path
}
),
if (state.network == NetworkType.GRPC.type) state.serviceName else state.path,
{ if (state.network == NetworkType.GRPC.type) state.serviceName = it else state.path = it }
)
}
if (state.network == NetworkType.XHTTP.type) {
FormTextField(
@@ -201,6 +212,11 @@ abstract class BaseServerActivity : BaseComponentActivity() {
)
}
if (state.network == NetworkType.KCP.type) {
FormTextField(
stringResource(R.string.server_lab_path_kcp),
state.seed,
{ state.seed = it }
)
FormTextField(
stringResource(R.string.server_lab_kcp_mtu),
state.kcpMtu,
@@ -37,10 +37,15 @@ class ServerUiState(
bandwidthUp: String = "",
network: String = NetworkType.TCP.type,
headerType: String = "none",
mode: String = "",
xhttpMode: String = "",
serviceName: String = "",
authority: String = "",
host: String = "",
path: String = "",
xhttpExtra: String = "",
finalMask: String = "",
seed: String = "",
kcpMtu: String = "",
kcpTti: String = "",
browserDialerMode: String = "",
@@ -80,10 +85,15 @@ class ServerUiState(
var bandwidthUp by mutableStateOf(bandwidthUp)
var network by mutableStateOf(network)
var headerType by mutableStateOf(headerType)
var mode by mutableStateOf(mode)
var xhttpMode by mutableStateOf(xhttpMode)
var serviceName by mutableStateOf(serviceName)
var authority by mutableStateOf(authority)
var host by mutableStateOf(host)
var path by mutableStateOf(path)
var xhttpExtra by mutableStateOf(xhttpExtra)
var finalMask by mutableStateOf(finalMask)
var seed by mutableStateOf(seed)
var kcpMtu by mutableStateOf(kcpMtu)
var kcpTti by mutableStateOf(kcpTti)
var browserDialerMode by mutableStateOf(browserDialerMode)
@@ -139,10 +149,15 @@ class ServerUiState(
bandwidthUp = if (isHysteria2) bandwidthUp else null,
network = network,
headerType = headerType,
mode = mode.nullIfBlank(),
xhttpMode = xhttpMode.nullIfBlank(),
serviceName = serviceName.nullIfBlank(),
authority = authority.nullIfBlank(),
host = host,
path = path,
xhttpExtra = xhttpExtra.nullIfBlank(),
finalMask = finalMask.nullIfBlank(),
seed = seed.nullIfBlank(),
kcpMtu = kcpMtu.toIntOrNull(),
kcpTti = kcpTti.toIntOrNull(),
browserDialerMode = if (network in listOf(NetworkType.WS.type, NetworkType.XHTTP.type)) {
@@ -191,10 +206,15 @@ class ServerUiState(
bandwidthUp = initialConfig.bandwidthUp ?: "",
network = initialConfig.network ?: NetworkType.TCP.type,
headerType = initialConfig.headerType ?: "none",
mode = initialConfig.mode ?: "",
xhttpMode = initialConfig.xhttpMode ?: "",
serviceName = initialConfig.serviceName ?: "",
authority = initialConfig.authority ?: "",
host = initialConfig.host ?: "",
path = initialConfig.path ?: "",
xhttpExtra = initialConfig.xhttpExtra ?: "",
finalMask = initialConfig.finalMask ?: "",
seed = initialConfig.seed ?: "",
kcpMtu = initialConfig.kcpMtu?.toString() ?: "",
kcpTti = initialConfig.kcpTti?.toString() ?: "",
browserDialerMode = initialConfig.browserDialerMode ?: "",
@@ -7,14 +7,19 @@ import com.v2ray.ang.dto.SubscriptionUpdateMessage
import com.v2ray.ang.dto.entities.SubscriptionCache
import com.v2ray.ang.dto.entities.SubscriptionItem
import com.v2ray.ang.extension.moveItem
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.helper.MessageHelper
import com.v2ray.ang.ui.base.BaseViewModel
import com.v2ray.ang.util.LogUtil
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.withContext
class SubscriptionsViewModel(application: Application) : BaseViewModel(application) {
private val subscriptions: MutableList<SubscriptionCache> =
@@ -59,6 +64,45 @@ class SubscriptionsViewModel(application: Application) : BaseViewModel(applicati
}
fun updateSubscriptions() {
val updateSubscription = MmkvManager.decodeSettingsBool(AppConfig.PREF_UPDATE_SUBSCRIPTION, false)
val autoTestAfterUpdateSubscription = MmkvManager.decodeSettingsBool(AppConfig.PREF_AUTO_TEST_AFTER_UPDATE_SUBSCRIPTION, false)
when {
// If auto test is enabled, trigger background service for long-running task
autoTestAfterUpdateSubscription -> updateSubscriptionsMore()
// If only update is enabled, perform local update with UI loading state
updateSubscription -> updateSubscriptionsOnly()
}
}
fun updateSubscriptionsOnly() {
launchLoading {
try {
val result = withContext(Dispatchers.IO) {
AngConfigManager.updateConfigViaSubAll()
}
when {
result.successCount + result.failureCount + result.skipCount == 0 ->
toast(R.string.title_update_subscription_no_subscription)
result.successCount > 0 && result.failureCount + result.skipCount == 0 ->
toast(getString(R.string.title_update_config_count, result.configCount))
else ->
toast(getString(R.string.title_update_subscription_result, result.configCount, result.successCount, result.failureCount, result.skipCount))
}
reload()
} catch (cancelled: CancellationException) {
throw cancelled
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Subscription update failed", e)
toastError(R.string.toast_failure)
}
}
}
fun updateSubscriptionsMore() {
SettingsChangeManager.makeSetupGroupTab()
val subIds = MmkvManager.decodeSubscriptions()
.filter { it.subscription.enabled && it.subscription.url.isNotEmpty() }
@@ -171,7 +171,7 @@
<string name="subscription_updater_job_tips">任务将在后台运行,在通知中会显示进度信息</string>
<string name="title_pref_auto_test_after_update_subscription">更新订阅后自动测试</string>
<string name="summary_pref_auto_test_after_update_subscription">自动测试需要比较长的时间,请耐心等待</string>
<string name="summary_pref_auto_test_after_update_subscription">自动测试需要比较长的时间,开启后任务将在后台运行;不开启则在当前页面运行</string>
<string name="title_pref_auto_remove_invalid_after_test">测试后自动删除无效配置</string>
<string name="summary_pref_auto_remove_invalid_after_test">测试结果可能不准确且已删除的配置无法恢复</string>
<string name="title_pref_auto_sort_after_test">测试后自动排序</string>
@@ -170,7 +170,7 @@
<string name="subscription_updater_job_tips">任務將在背景運行,在通知中會顯示進度資訊</string>
<string name="title_pref_auto_test_after_update_subscription">更新訂閱後自動測試</string>
<string name="summary_pref_auto_test_after_update_subscription">自動測試需要比較長的時間,請耐心等待</string>
<string name="summary_pref_auto_test_after_update_subscription">自動測試需要較長的時間,開啟後任務將在後台運行;不開啟則在目前頁面運行</string>
<string name="title_pref_auto_remove_invalid_after_test">測試後自動刪除無效配置</string>
<string name="summary_pref_auto_remove_invalid_after_test">測試結果可能不準確且已刪除的配置無法復原</string>
<string name="title_pref_auto_sort_after_test">測試後自動排序</string>
+1 -1
View File
@@ -176,7 +176,7 @@
<string name="subscription_updater_job_tips">The task will run in the background, and progress information will be displayed in the notification.</string>
<string name="title_pref_auto_test_after_update_subscription">Auto test after updating subscription</string>
<string name="summary_pref_auto_test_after_update_subscription">Automated testing takes a relatively long time, please be patient.</string>
<string name="summary_pref_auto_test_after_update_subscription">Automated testing takes a long time. If enabled, the task will run in the background; otherwise, it will run on the current page.</string>
<string name="title_pref_auto_remove_invalid_after_test">Auto delete invalid config after testing</string>
<string name="summary_pref_auto_remove_invalid_after_test">Test results may not be accurate; deleted config cannot be recovered.</string>
<string name="title_pref_auto_sort_after_test">Auto sort after testing</string>