Compare commits

...
11 Commits
Author SHA1 Message Date
2dust 2020807c25 up 2.3.7 2026-09-05 15:52:22 +08:00
2dust ca4185abad Rename daemon process and split task services
Standardizes manifest process names by replacing the long `:RunSoLibV2RayDaemon` suffix with `:daemon` for shortcut activities, VPN/proxy services, and related receivers. It also moves `CoreTestService` and `SubscriptionUpdateService` to a separate `:tasks` process to isolate task-oriented work from the main daemon process.
2026-09-05 15:47:34 +08:00
eliotcougar 8d9152bb4e Align default settings hints with actual app behavior (#6183) 2026-09-05 15:06:19 +08:00
eliotcougar 5bfcdf90ec Clarify boot auto-connect and link system VPN settings (#6179)
* Clarify device-boot connection and link system VPN settings

* Match Bakhtiari system spelling in VPN settings strings
2026-09-05 14:59:06 +08:00
eliotcougar f4f992eb9b Deduplicate subscription IDs when reading the saved index (#6172)
A saved SUB_IDS array can contain the same GUID more than once. Loading
it verbatim creates repeated subscription rows with identical Compose
and reorderable-list keys, which can crash the subscription screen.

Deduplicate IDs in the shared reader while preserving the first-seen
order and mutable-list contract. This protects every index consumer
without changing subscription records, generating replacement IDs, or
adding writes during reads. Different subscriptions with identical or
blank names remain separate.

Add six regression tests for duplicate rows, stable ordering, read-only
decoding, mutable round trips, repeated names, and existing empty/invalid
input behavior. Three assertions fail on unchanged upstream; all six
pass with the fix. The full 63-test suite and Play Store debug build pass.

On the API 37.1 Pixel 9 Pro emulator, a deliberately duplicated saved
index loaded each subscription once, retained first-occurrence order,
reopened safely, and edited the correct record. The raw index remained
duplicated throughout the check; no app crash was observed.
2026-09-05 14:54:46 +08:00
2dust 1af8f4677c Revert "Use ruleset IDs for routing edits"
This reverts commit c548e5f615.
2026-09-05 14:50:28 +08:00
eliotcougar 4d8ee1a916 Fix Logcat crashes on duplicate log lines (#6170)
Logcat used the complete log text as a Compose lazy-list key after 258de2097. Identical timestamped messages and repeated stack frames are valid log output, but collide as keys and crash the main thread while browsing logs.

Give each occurrence its own key in a small ViewModel-owned presentation model. Count oldest-first so prepending newer logs and filtering unrelated lines retain existing occurrence keys. Build the projection off the main thread and keep raw text, duplicates, ordering, copy and share behavior unchanged.

Add six regression tests covering duplicate messages, repeated frames, filtering, refresh, empty input and key-like text. All 63 Play Store unit tests pass; the debug APK was checked on the API 37.1 emulator with duplicate-line scrolling, filtering, refresh, reopening and lossless export. The original failure is documented by the user-provided physical-device crash log.
2026-09-05 14:46:10 +08:00
eliotcougar 95bcf32c0b Fix Logcat refresh button navigation inset (#6155) 2026-09-05 14:26:39 +08:00
2dust c548e5f615 Use ruleset IDs for routing edits
Switch routing ruleset lookup, save, and delete flows from list index to stable ruleset ID. Routing edit intents now pass `ruleset_id`, editing reloads by ID, and list item edit actions use each rule’s ID. This avoids index mismatch bugs when list order changes and keeps update/delete targeting the correct ruleset.
2026-08-30 19:38:43 +08:00
2dust 258de20972 Use stable list keys in Logcat and Tasker 2026-08-30 19:29:03 +08:00
2dust 3dc0173b9f Fix
https://github.com/2dust/v2rayNG/issues/6153
2026-08-30 17:44:53 +08:00
26 changed files with 447 additions and 139 deletions

No files matched your search

+2 -2
View File
@@ -13,8 +13,8 @@ android {
applicationId = "com.v2ray.ang"
minSdk = 24
targetSdk = 37
versionCode = 746
versionName = "2.3.6"
versionCode = 747
versionName = "2.3.7"
val abiFilterList = (properties["ABI_FILTERS"] as? String)?.split(';')
splits {
+11 -11
View File
@@ -166,19 +166,19 @@
android:name=".ui.shortcut.ScSwitchActivity"
android:excludeFromRecents="true"
android:exported="false"
android:process=":RunSoLibV2RayDaemon"
android:process=":daemon"
android:theme="@style/AppThemeDayNight.NoActionBar.Translucent" />
<activity
android:name=".ui.shortcut.ScStartActivity"
android:excludeFromRecents="true"
android:exported="false"
android:process=":RunSoLibV2RayDaemon"
android:process=":daemon"
android:theme="@style/AppThemeDayNight.NoActionBar.Translucent" />
<activity
android:name=".ui.shortcut.ScStopActivity"
android:excludeFromRecents="true"
android:exported="false"
android:process=":RunSoLibV2RayDaemon"
android:process=":daemon"
android:theme="@style/AppThemeDayNight.NoActionBar.Translucent" />
<activity
@@ -220,7 +220,7 @@
android:foregroundServiceType="specialUse"
android:label="@string/app_name"
android:permission="android.permission.BIND_VPN_SERVICE"
android:process=":RunSoLibV2RayDaemon"
android:process=":daemon"
tools:ignore="VpnServicePolicy">
<intent-filter>
<action android:name="android.net.VpnService" />
@@ -238,7 +238,7 @@
android:exported="false"
android:foregroundServiceType="specialUse"
android:label="@string/app_name"
android:process=":RunSoLibV2RayDaemon">
android:process=":daemon">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="proxy" />
@@ -249,7 +249,7 @@
android:exported="false"
android:foregroundServiceType="specialUse"
android:label="@string/app_name"
android:process=":RunSoLibV2RayDaemon">
android:process=":daemon">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="proxy" />
@@ -259,7 +259,7 @@
android:name=".service.CoreTestService"
android:exported="false"
android:foregroundServiceType="specialUse"
android:process=":RunSoLibV2RayDaemon">
android:process=":tasks">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="test" />
@@ -269,7 +269,7 @@
android:name=".service.SubscriptionUpdateService"
android:exported="false"
android:foregroundServiceType="specialUse"
android:process=":RunSoLibV2RayDaemon">
android:process=":tasks">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="test" />
@@ -278,7 +278,7 @@
<receiver
android:name=".receiver.WidgetProvider"
android:exported="true"
android:process=":RunSoLibV2RayDaemon">
android:process=":daemon">
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/app_widget_provider" />
@@ -306,7 +306,7 @@
android:icon="@drawable/ic_stat_name"
android:label="@string/app_tile_name"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE"
android:process=":RunSoLibV2RayDaemon">
android:process=":daemon">
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE" />
</intent-filter>
@@ -327,7 +327,7 @@
<receiver
android:name=".receiver.TaskerReceiver"
android:exported="true"
android:process=":RunSoLibV2RayDaemon"
android:process=":daemon"
tools:ignore="ExportedReceiver">
<intent-filter>
<action android:name="com.twofortyfouram.locale.intent.action.FIRE_SETTING" />
@@ -168,6 +168,13 @@ object AppConfig {
const val WIREGUARD_LOCAL_MTU = "1420"
const val LOOPBACK = "127.0.0.1"
/** Shared defaults for settings shown in the UI and consumed by config generation. */
const val DEFAULT_SOCKS_ENABLE_UDP = true
const val DEFAULT_OUTBOUND_DOMAIN_RESOLVE_METHOD = "1"
const val DEFAULT_VPN_BYPASS_LAN = "1"
const val DEFAULT_HEV_TUNNEL_LOGLEVEL = "warn"
const val DEFAULT_MUX_XUDP_CONCURRENCY = "8"
/** Message constants for communication. */
const val MSG_REGISTER_CLIENT = 1
const val MSG_STATE_RUNNING = 11
@@ -517,7 +517,7 @@ object CoreConfigManager {
inbound1.listen = AppConfig.LOOPBACK
}
inbound1.port = socksPort
inbound1.settings?.udp = MmkvManager.decodeSettingsBool(AppConfig.PREF_SOCKS_ENABLE_UDP, true)
inbound1.settings?.udp = MmkvManager.decodeSettingsBool(AppConfig.PREF_SOCKS_ENABLE_UDP, AppConfig.DEFAULT_SOCKS_ENABLE_UDP)
if (socksUsername != null && socksPassword != null) {
inbound1.settings?.auth = "password"
inbound1.settings?.accounts = listOf(
@@ -1058,7 +1058,7 @@ object CoreConfigManager {
* Resolve outbound domains to IPs and write resolved hosts to DNS map.
*/
private fun resolveOutboundDomainsToHosts(v2rayConfig: V2rayConfig) {
if (MmkvManager.decodeSettingsString(AppConfig.PREF_OUTBOUND_DOMAIN_RESOLVE_METHOD, "1") != "1") {
if (MmkvManager.decodeSettingsString(AppConfig.PREF_OUTBOUND_DOMAIN_RESOLVE_METHOD, AppConfig.DEFAULT_OUTBOUND_DOMAIN_RESOLVE_METHOD) != "1") {
return
}
@@ -62,7 +62,7 @@ object CoreOutboundBuilder {
if (muxEnabled) {
outbound.mux?.enabled = true
outbound.mux?.concurrency = MmkvManager.decodeSettingsString(AppConfig.PREF_MUX_CONCURRENCY, "8").orEmpty().toInt()
outbound.mux?.xudpConcurrency = MmkvManager.decodeSettingsString(AppConfig.PREF_MUX_XUDP_CONCURRENCY, "16").orEmpty().toInt()
outbound.mux?.xudpConcurrency = MmkvManager.decodeSettingsString(AppConfig.PREF_MUX_XUDP_CONCURRENCY, AppConfig.DEFAULT_MUX_XUDP_CONCURRENCY).orEmpty().toInt()
outbound.mux?.xudpProxyUDP443 = MmkvManager.decodeSettingsString(AppConfig.PREF_MUX_XUDP_QUIC, "reject")
if (protocol.equals(EConfigType.VLESS.name, true) && outbound.settings?.flow?.isNotEmpty() == true) {
outbound.mux?.concurrency = -1
@@ -667,7 +667,7 @@ object CoreOutboundBuilder {
}
val domain = HttpUtil.toIdnDomain(profileItem.server.orEmpty())
if (MmkvManager.decodeSettingsString(AppConfig.PREF_OUTBOUND_DOMAIN_RESOLVE_METHOD, "1") != "2") {
if (MmkvManager.decodeSettingsString(AppConfig.PREF_OUTBOUND_DOMAIN_RESOLVE_METHOD, AppConfig.DEFAULT_OUTBOUND_DOMAIN_RESOLVE_METHOD) != "2") {
return domain
}
//Resolve and replace domain
@@ -718,7 +718,8 @@ object MmkvManager {
return if (json.isNullOrBlank()) {
mutableListOf()
} else {
JsonUtil.fromJsonSafe(json, Array<String>::class.java)?.toMutableList() ?: mutableListOf()
// Keep the first occurrence so a damaged index cannot produce duplicate Compose keys.
JsonUtil.fromJsonSafe(json, Array<String>::class.java)?.distinct()?.toMutableList() ?: mutableListOf()
}
}
@@ -176,7 +176,7 @@ object SettingsManager {
* @return True if bypassing LAN, false otherwise.
*/
fun routingRulesetsBypassLan(): Boolean {
val vpnBypassLan = MmkvManager.decodeSettingsString(AppConfig.PREF_VPN_BYPASS_LAN) ?: "1"
val vpnBypassLan = MmkvManager.decodeSettingsString(AppConfig.PREF_VPN_BYPASS_LAN, AppConfig.DEFAULT_VPN_BYPASS_LAN)
if (vpnBypassLan == "1") {
return true
} else if (vpnBypassLan == "2") {
@@ -467,7 +467,7 @@ object SettingsManager {
ensureDefaultValue(AppConfig.PREF_IP_API_URL, AppConfig.IP_API_URL)
ensureDefaultValue(AppConfig.PREF_HEV_TUNNEL_RW_TIMEOUT, AppConfig.HEVTUN_RW_TIMEOUT)
ensureDefaultValue(AppConfig.PREF_MUX_CONCURRENCY, "8")
ensureDefaultValue(AppConfig.PREF_MUX_XUDP_CONCURRENCY, "8")
ensureDefaultValue(AppConfig.PREF_MUX_XUDP_CONCURRENCY, AppConfig.DEFAULT_MUX_XUDP_CONCURRENCY)
ensureDefaultValue(AppConfig.PREF_FRAGMENT_LENGTH, "50-100")
ensureDefaultValue(AppConfig.PREF_FRAGMENT_INTERVAL, "10-20")
ensureDefaultValue(AppConfig.PREF_FRAGMENT_MAXSPLIT, "10")
@@ -97,7 +97,7 @@ class TProxyService(
appendLine("misc:")
appendLine(" tcp-read-write-timeout: ${tcpTimeout * 1000}")
appendLine(" udp-read-write-timeout: ${udpTimeout * 1000}")
appendLine(" log-level: ${MmkvManager.decodeSettingsString(AppConfig.PREF_HEV_TUNNEL_LOGLEVEL) ?: "warn"}")
appendLine(" log-level: ${MmkvManager.decodeSettingsString(AppConfig.PREF_HEV_TUNNEL_LOGLEVEL, AppConfig.DEFAULT_HEV_TUNNEL_LOGLEVEL)}")
}
}
@@ -12,9 +12,10 @@ import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
@@ -131,7 +132,7 @@ fun LogcatScreen(
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val logs by viewModel.filteredLogs.collectAsStateWithLifecycle()
val logs by viewModel.logEntries.collectAsStateWithLifecycle()
val isLoading by viewModel.isLoading.collectAsStateWithLifecycle()
var searchQuery by remember { mutableStateOf("") }
@@ -191,9 +192,10 @@ fun LogcatScreen(
)
},
floatingActionButton = {
FloatingActionButton(onClick = {
viewModel.loadLogcat()
}) {
FloatingActionButton(
onClick = { viewModel.loadLogcat() },
modifier = Modifier.navigationBarsPadding()
) {
Icon(
painterResource(R.drawable.ic_restore_24dp),
contentDescription = stringResource(R.string.acc_refresh)
@@ -213,8 +215,8 @@ fun LogcatScreen(
.verticalScrollbar(listState),
contentPadding = NavigationBarsBottomPadding()
) {
itemsIndexed(items = logs, key = { index, _ -> index }) { _, log ->
LogcatItem(log = log, onLongClick = { Utils.setClipboard(context, log) })
items(items = logs, key = { it.key }) { log ->
LogcatItem(log = log.text, onLongClick = { Utils.setClipboard(context, log.text) })
ItemDivider()
}
}
@@ -1,14 +1,20 @@
package com.v2ray.ang.ui.logcat
import android.app.Application
import androidx.lifecycle.viewModelScope
import com.v2ray.ang.AppConfig
import com.v2ray.ang.R
import com.v2ray.ang.ui.base.BaseViewModel
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.Utils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import java.io.IOException
class LogcatViewModel(application: Application) : BaseViewModel(application) {
@@ -17,6 +23,10 @@ class LogcatViewModel(application: Application) : BaseViewModel(application) {
private val _filteredLogs = MutableStateFlow<List<String>>(emptyList())
val filteredLogs: StateFlow<List<String>> = _filteredLogs.asStateFlow()
val logEntries: StateFlow<List<LogcatEntry>> = filteredLogs
.map(::createLogcatEntries)
.flowOn(Dispatchers.Default)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
fun loadLogcat() {
launchLoading {
@@ -73,4 +83,17 @@ class LogcatViewModel(application: Application) : BaseViewModel(application) {
logsetsAll.filter { it.contains(currentFilter) }
}
}
}
}
data class LogcatEntry(val key: String, val text: String)
internal fun createLogcatEntries(logs: List<String>): List<LogcatEntry> {
val occurrences = HashMap<String, Int>()
// Identical lines are valid. Count oldest-first so filtering other lines or
// prepending newer entries does not change the keys of existing occurrences.
return logs.asReversed().map { line ->
val occurrence = occurrences.getOrDefault(line, 0)
occurrences[line] = occurrence + 1
LogcatEntry("$occurrence:$line", line)
}.asReversed()
}
@@ -1,28 +1,21 @@
package com.v2ray.ang.ui.main
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ScrollableTabRow
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRowDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
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.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.material3.TabRowDefaults.tabIndicatorOffset
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.v2ray.ang.dto.GroupMapItem
import com.v2ray.ang.dto.entities.ServersCache
@@ -37,26 +30,20 @@ fun GroupTabBar(
modifier: Modifier = Modifier
) {
val selectedIndex = selectedTabIndex.coerceIn(0, groups.lastIndex)
val listState = rememberLazyListState(initialFirstVisibleItemIndex = selectedIndex)
LaunchedEffect(selectedIndex) {
if (listState.layoutInfo.visibleItemsInfo.none { it.index == selectedIndex }) {
listState.animateScrollToItem(selectedIndex)
}
}
LazyRow(
state = listState,
ScrollableTabRow(
selectedTabIndex = selectedIndex,
modifier = modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.95f)),
contentPadding = PaddingValues(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
edgePadding = 16.dp,
indicator = { tabPositions ->
TabRowDefaults.SecondaryIndicator(
modifier = Modifier.tabIndicatorOffset(tabPositions[selectedIndex]),
color = MaterialTheme.colorScheme.secondary
)
}
) {
itemsIndexed(
items = groups,
key = { _, group -> group.id },
) { index, group ->
groups.forEachIndexed { index, group ->
val serverFlow = remember(group.id, mainViewModel) {
mainViewModel.serversForGroup(group.id)
}
@@ -84,29 +71,19 @@ private fun GroupTabItem(
"${group.remarks} (${servers.size})"
}
Box(Modifier.widthIn(min = 56.dp)) {
Tab(
selected = selected,
onClick = onClick,
modifier = Modifier.heightIn(min = 48.dp),
text = {
Text(
text = text,
maxLines = 1,
softWrap = false,
overflow = TextOverflow.Ellipsis
)
}
)
if (selected) {
Box(
Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.height(3.dp)
.clip(RoundedCornerShape(3.dp))
.background(MaterialTheme.colorScheme.secondary)
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
)
}
}
)
}
@@ -1,7 +1,10 @@
package com.v2ray.ang.ui.settings
import android.content.ActivityNotFoundException
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.provider.Settings
import androidx.activity.viewModels
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
@@ -24,10 +27,14 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringArrayResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import com.v2ray.ang.AppConfig
import com.v2ray.ang.AppConfig.VPN
import com.v2ray.ang.R
import com.v2ray.ang.extension.toastError
import com.v2ray.ang.handler.AppLocaleManager
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.MmkvManager.rememberMmkvBool
@@ -44,7 +51,9 @@ import com.v2ray.ang.ui.compose.SettingsMenuItem
import com.v2ray.ang.ui.compose.SettingsSwitchItem
import com.v2ray.ang.ui.compose.ThemeManager
import com.v2ray.ang.ui.compose.verticalScrollbar
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.Utils
import kotlinx.coroutines.launch
class SettingsActivity : BaseComponentActivity() {
@@ -52,6 +61,26 @@ class SettingsActivity : BaseComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.RESUMED) {
viewModel.refreshSystemVpnSettingsAvailability()
}
}
}
private fun openSystemVpnSettings() {
try {
startActivity(Intent(Settings.ACTION_VPN_SETTINGS))
} catch (error: ActivityNotFoundException) {
reportSystemVpnSettingsFailure(error)
} catch (error: SecurityException) {
reportSystemVpnSettingsFailure(error)
}
}
private fun reportSystemVpnSettingsFailure(error: RuntimeException) {
LogUtil.e(AppConfig.TAG, "Cannot open system VPN settings", error)
toastError(R.string.toast_system_vpn_settings_unavailable)
}
@Composable
@@ -59,7 +88,8 @@ class SettingsActivity : BaseComponentActivity() {
SettingsScreen(
viewModel = viewModel,
onBackClick = { finish() },
onModeHelpClicked = { Utils.openUri(this, AppConfig.APP_WIKI_MODE) }
onModeHelpClicked = { Utils.openUri(this, AppConfig.APP_WIKI_MODE) },
onSystemVpnSettingsClicked = ::openSystemVpnSettings
)
}
}
@@ -69,10 +99,12 @@ class SettingsActivity : BaseComponentActivity() {
fun SettingsScreen(
viewModel: SettingsViewModel,
onBackClick: () -> Unit,
onModeHelpClicked: () -> Unit
onModeHelpClicked: () -> Unit,
onSystemVpnSettingsClicked: () -> Unit
) {
val scrollState = rememberScrollState()
val isLoading by viewModel.isLoading.collectAsStateWithLifecycle()
val systemVpnSettingsAvailable by viewModel.systemVpnSettingsAvailable.collectAsStateWithLifecycle()
var uiSettingsExpanded by rememberSaveable { mutableStateOf(true) }
var vpnSettingsExpanded by rememberSaveable { mutableStateOf(true) }
var coreSettingsExpanded by rememberSaveable { mutableStateOf(true) }
@@ -86,13 +118,13 @@ fun SettingsScreen(
var fakeDns by rememberMmkvBool(AppConfig.PREF_FAKE_DNS_ENABLED, false)
var appendHttpProxy by rememberMmkvBool(AppConfig.PREF_APPEND_HTTP_PROXY, false)
var vpnDns by rememberMmkvString(AppConfig.PREF_VPN_DNS, "")
var vpnBypassLan by rememberMmkvString(AppConfig.PREF_VPN_BYPASS_LAN, "0")
var vpnBypassLan by rememberMmkvString(AppConfig.PREF_VPN_BYPASS_LAN, AppConfig.DEFAULT_VPN_BYPASS_LAN)
var vpnInterfaceAddress by rememberMmkvString(AppConfig.PREF_VPN_INTERFACE_ADDRESS_CONFIG_INDEX, "0")
var vpnMtu by rememberMmkvString(AppConfig.PREF_VPN_MTU, "")
var mux by rememberMmkvBool(AppConfig.PREF_MUX_ENABLED, false)
var muxConcurrency by rememberMmkvString(AppConfig.PREF_MUX_CONCURRENCY, "8")
var muxXudpConcurrency by rememberMmkvString(AppConfig.PREF_MUX_XUDP_CONCURRENCY, "8")
var muxXudpConcurrency by rememberMmkvString(AppConfig.PREF_MUX_XUDP_CONCURRENCY, AppConfig.DEFAULT_MUX_XUDP_CONCURRENCY)
var muxXudpQuic by rememberMmkvString(AppConfig.PREF_MUX_XUDP_QUIC, "reject")
var fragment by rememberMmkvBool(AppConfig.PREF_FRAGMENT_ENABLED, false)
@@ -110,7 +142,7 @@ fun SettingsScreen(
var enableRootMode by rememberMmkvBool(AppConfig.PREF_ROOT_MODE_ENABLE, false)
var lanSharing by rememberMmkvBool(AppConfig.PREF_ROOT_LAN_SHARING, false)
var hevTunLogLevel by rememberMmkvString(AppConfig.PREF_HEV_TUNNEL_LOGLEVEL, "warning")
var hevTunLogLevel by rememberMmkvString(AppConfig.PREF_HEV_TUNNEL_LOGLEVEL, AppConfig.DEFAULT_HEV_TUNNEL_LOGLEVEL)
var hevTunRwTimeout by rememberMmkvString(AppConfig.PREF_HEV_TUNNEL_RW_TIMEOUT, "")
var useHevTun by rememberMmkvBool(AppConfig.PREF_USE_HEV_TUNNEL, true)
@@ -119,7 +151,7 @@ fun SettingsScreen(
var dynamicSocksPort by rememberMmkvBool(AppConfig.PREF_DYNAMIC_SOCKS_PORT, false)
var socksUsername by rememberMmkvString(AppConfig.PREF_SOCKS_USERNAME, "")
var socksPassword by rememberMmkvString(AppConfig.PREF_SOCKS_PASSWORD, "")
var socksEnableUdp by rememberMmkvBool(AppConfig.PREF_SOCKS_ENABLE_UDP, false)
var socksEnableUdp by rememberMmkvBool(AppConfig.PREF_SOCKS_ENABLE_UDP, AppConfig.DEFAULT_SOCKS_ENABLE_UDP)
var proxySharing by rememberMmkvBool(AppConfig.PREF_PROXY_SHARING, false)
var speedEnabled by rememberMmkvBool(AppConfig.PREF_SPEED_ENABLED, false)
@@ -142,7 +174,7 @@ fun SettingsScreen(
var domesticDns by rememberMmkvString(AppConfig.PREF_DOMESTIC_DNS, "")
var dnsHosts by rememberMmkvString(AppConfig.PREF_DNS_HOSTS, "")
var coreLogLevel by rememberMmkvString(AppConfig.PREF_LOGLEVEL, "warning")
var outboundResolveMethod by rememberMmkvString(AppConfig.PREF_OUTBOUND_DOMAIN_RESOLVE_METHOD, "0")
var outboundResolveMethod by rememberMmkvString(AppConfig.PREF_OUTBOUND_DOMAIN_RESOLVE_METHOD, AppConfig.DEFAULT_OUTBOUND_DOMAIN_RESOLVE_METHOD)
var isBooted by rememberMmkvBool(AppConfig.PREF_IS_BOOTED, false)
var delayTestUrl by rememberMmkvString(AppConfig.PREF_DELAY_TEST_URL, "")
@@ -153,7 +185,7 @@ fun SettingsScreen(
val hevTunEnabled = isVpn && useHevTun
val localProxyForced = hevTunEnabled
val effectiveLocalProxy = enableLocalProxy || localProxyForced
val muxXudpConcurrencyInt = muxXudpConcurrency.toIntOrNull() ?: 8
val muxXudpConcurrencyInt = muxXudpConcurrency.toIntOrNull() ?: AppConfig.DEFAULT_MUX_XUDP_CONCURRENCY.toInt()
val dynamicColorSupported = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
LaunchedEffect(dynamicColorSupported) {
@@ -612,6 +644,13 @@ fun SettingsScreen(
checked = isBooted,
onCheckedChange = { isBooted = it }
)
if (systemVpnSettingsAvailable) {
SettingsMenuItem(
title = stringResource(R.string.title_system_vpn_settings),
subtitle = stringResource(R.string.summary_system_vpn_settings),
onClick = onSystemVpnSettingsClicked
)
}
SettingsEditItem(
title = stringResource(R.string.title_pref_delay_test_url),
value = delayTestUrl,
@@ -1,15 +1,29 @@
package com.v2ray.ang.ui.settings
import android.app.Application
import android.content.Intent
import android.provider.Settings
import com.v2ray.ang.AppConfig
import com.v2ray.ang.R
import com.v2ray.ang.root.RootManager
import com.v2ray.ang.ui.base.BaseViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.withContext
class SettingsViewModel(application: Application) : BaseViewModel(application) {
private val _systemVpnSettingsAvailable = MutableStateFlow(false)
val systemVpnSettingsAvailable = _systemVpnSettingsAvailable.asStateFlow()
suspend fun refreshSystemVpnSettingsAvailability() {
_systemVpnSettingsAvailable.value = withContext(Dispatchers.IO) {
// Android exposes the VPN page, not a direct link to the Always-on VPN switch.
Intent(Settings.ACTION_VPN_SETTINGS).resolveActivity(getApplication<Application>().packageManager) != null
}
}
/**
* Checks for root access and requests it if necessary.
* Updates [isLoading] during the process.
@@ -56,4 +70,4 @@ class SettingsViewModel(application: Application) : BaseViewModel(application) {
null
}
}
}
}
@@ -39,10 +39,14 @@ import com.v2ray.ang.ui.compose.SettingsSwitchItem
import com.v2ray.ang.ui.compose.verticalScrollbar
import com.v2ray.ang.util.LogUtil
data class TaskerItem(
val label: String,
val guid: String,
)
class TaskerActivity : BaseComponentActivity() {
private var lstData: ArrayList<String> = ArrayList()
private var lstGuid: ArrayList<String> = ArrayList()
private val items = mutableListOf<TaskerItem>()
private val switchState = mutableStateOf(false)
private val selectedPosition = mutableStateOf(-1)
@@ -50,13 +54,11 @@ class TaskerActivity : BaseComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
lstData.add("Default")
lstGuid.add(AppConfig.TASKER_DEFAULT_GUID)
items.add(TaskerItem(label = "Default", guid = AppConfig.TASKER_DEFAULT_GUID))
MmkvManager.decodeAllServerList().forEach { key ->
MmkvManager.decodeServerConfig(key)?.let { config ->
lstData.add(config.remarks)
lstGuid.add(key)
items.add(TaskerItem(label = config.remarks, guid = key))
}
}
@@ -66,7 +68,7 @@ class TaskerActivity : BaseComponentActivity() {
@Composable
override fun ScreenContent() {
TaskerScreen(
items = lstData,
items = items,
switchState = switchState,
selectedPosition = selectedPosition,
onBackClick = { finish() },
@@ -84,10 +86,7 @@ class TaskerActivity : BaseComponentActivity() {
return
} else {
switchState.value = switch
val pos = lstGuid.indexOf(guid.toString())
if (pos >= 0) {
selectedPosition.value = pos
}
selectedPosition.value = items.indexOfFirst { it.guid == guid.toString() }
}
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to initialize Tasker settings", e)
@@ -102,13 +101,12 @@ class TaskerActivity : BaseComponentActivity() {
val extraBundle = Bundle()
extraBundle.putBoolean(AppConfig.TASKER_EXTRA_BUNDLE_SWITCH, switchState.value)
extraBundle.putString(AppConfig.TASKER_EXTRA_BUNDLE_GUID, lstGuid[position])
extraBundle.putString(AppConfig.TASKER_EXTRA_BUNDLE_GUID, items[position].guid)
val intent = Intent()
val remarks = lstData[position]
val blurb = getString(
if (switchState.value) R.string.tasker_blurb_start else R.string.tasker_blurb_stop,
remarks
items[position].label
)
intent.putExtra(AppConfig.TASKER_EXTRA_BUNDLE, extraBundle)
@@ -120,7 +118,7 @@ class TaskerActivity : BaseComponentActivity() {
@Composable
fun TaskerScreen(
items: List<String>,
items: List<TaskerItem>,
switchState: MutableState<Boolean>,
selectedPosition: MutableState<Int>,
onBackClick: () -> Unit,
@@ -158,7 +156,7 @@ fun TaskerScreen(
.verticalScrollbar(listState),
contentPadding = NavigationBarsBottomPadding()
) {
itemsIndexed(items) { index, remarks ->
itemsIndexed(items, key = { _, item -> item.guid }) { index, item ->
Row(
modifier = Modifier
.fillMaxWidth()
@@ -171,7 +169,7 @@ fun TaskerScreen(
onClick = { selectedPosition.value = index }
)
Spacer(modifier = Modifier.width(8.dp))
Text(text = remarks, style = MaterialTheme.typography.bodyLarge)
Text(text = item.label, style = MaterialTheme.typography.bodyLarge)
}
}
}
@@ -100,8 +100,8 @@
<string name="server_lab_reserved">Reserved (اختياري، مفصول بفواصل)</string>
<string name="server_lab_local_address">العنوان المحلي (اختياري IPv4/IPv6، مفصولة بفواصل)</string>
<string name="server_lab_local_mtu">MTU (اختياري، الافتراضي 1420)</string>
<string name="server_lab_kcp_mtu">mKCP MTU (اختياري، الافتراضي في النواة 1350)</string>
<string name="server_lab_kcp_tti">mKCP TTI (اختياري، الافتراضي في النواة 50)</string>
<string name="server_lab_kcp_mtu">mKCP MTU (اختياري، الافتراضي 1350)</string>
<string name="server_lab_kcp_tti">mKCP TTI (اختياري، الافتراضي 50 مللي ثانية)</string>
<string name="toast_success">نجاح</string>
<string name="toast_failure">فشل</string>
<string name="toast_none_data">لا يوجد شيء</string>
@@ -172,8 +172,11 @@
<string name="title_vpn_settings">إعدادات VPN</string>
<string name="title_pref_per_app_proxy">الوكيل لكل تطبيق</string>
<string name="summary_pref_per_app_proxy">عام: تستخدم التطبيقات المحددة الوكيل، وتتصل التطبيقات غير المحددة مباشرة؛\nوضع التجاوز: تتصل التطبيقات المحددة مباشرة، وتستخدم التطبيقات غير المحددة الوكيل.\nيتوفر في القائمة خيار لتحديد التطبيقات التي ستستخدم الوكيل تلقائيًا.</string>
<string name="title_pref_is_booted">الاتصال تلقائياً عند بدء التشغيل</string>
<string name="summary_pref_is_booted">الاتصال تلقائياً بالخادم المحدد عند بدء التشغيل؛ قد لا تنجح المحاولة</string>
<string name="title_pref_is_booted">الاتصال بعد إقلاع الجهاز</string>
<string name="summary_pref_is_booted">الاتصال تلقائياً بالخادم المحدد بعد إقلاع الجهاز. قد يقيّد Android بدء التشغيل في الخلفية.</string>
<string name="title_system_vpn_settings">إعدادات VPN للنظام</string>
<string name="summary_system_vpn_settings">فتح إعدادات VPN في Android لإعداد VPN الدائم، إذا كان الجهاز يدعمه.</string>
<string name="toast_system_vpn_settings_unavailable">إعدادات VPN للنظام غير متاحة.</string>
<string name="subscription_updater_job_tips">ستعمل المهمة في الخلفية، وسيُعرض تقدمها في الإشعار.</string>
@@ -100,8 +100,8 @@
<string name="server_lab_reserved">Reserved (ঐচ্ছিক, কমা দিয়ে পৃথক)</string>
<string name="server_lab_local_address">স্থানীয় ঠিকানা (ঐচ্ছিক IPv4/IPv6, কমা দ্বারা পৃথক করা)</string>
<string name="server_lab_local_mtu">MTU (ঐচ্ছিক, ডিফল্ট 1420)</string>
<string name="server_lab_kcp_mtu">mKCP MTU (ঐচ্ছিক, কোর ডিফল্ট 1350)</string>
<string name="server_lab_kcp_tti">mKCP TTI (ঐচ্ছিক, কোর ডিফল্ট 50)</string>
<string name="server_lab_kcp_mtu">mKCP MTU (ঐচ্ছিক, ডিফল্ট 1350)</string>
<string name="server_lab_kcp_tti">mKCP TTI (ঐচ্ছিক, ডিফল্ট 50 মিলিসেকেন্ড)</string>
<string name="toast_success">সফল</string>
<string name="toast_failure">ব্যর্থ</string>
<string name="toast_none_data">কোনও তথ্য নেই</string>
@@ -172,8 +172,11 @@
<string name="title_vpn_settings">VPN সেটিংস</string>
<string name="title_pref_per_app_proxy">প্রতি-অ্যাপ প্রক্সি</string>
<string name="summary_pref_per_app_proxy">সাধারণ: নির্বাচিত অ্যাপ প্রক্সি ব্যবহার করে, অনির্বাচিত অ্যাপ সরাসরি সংযুক্ত হয়;\nবাইপাস মোড: নির্বাচিত অ্যাপ সরাসরি সংযুক্ত হয়, অনির্বাচিত অ্যাপ প্রক্সি ব্যবহার করে।\nযেসব অ্যাপ প্রক্সি ব্যবহার করবে, সেগুলি স্বয়ংক্রিয়ভাবে নির্বাচনের বিকল্পটি মেনুতে আছে।</string>
<string name="title_pref_is_booted">চালুর সময় স্বয়ংক্রিয় সংযোগ</string>
<string name="summary_pref_is_booted">চালুর সময় নির্বাচিত সার্ভারে স্বয়ংক্রিয়ভাবে সংযোগ করে; সংযোগ ব্যর্থ হতে পারে</string>
<string name="title_pref_is_booted">ডিভাইস চালু হওয়ার পর সংযোগ</string>
<string name="summary_pref_is_booted">ডিভাইস চালু হওয়ার পর নির্বাচিত সার্ভারে স্বয়ংক্রিয়ভাবে সংযোগ করুন। Android ব্যাকগ্রাউন্ডে চালু হওয়া সীমিত করতে পারে।</string>
<string name="title_system_vpn_settings">সিস্টেমের VPN সেটিংস</string>
<string name="summary_system_vpn_settings">ডিভাইসে সমর্থিত হলে সর্বদা চালু VPN কনফিগার করতে Android-এর VPN সেটিংস খুলুন।</string>
<string name="toast_system_vpn_settings_unavailable">সিস্টেমের VPN সেটিংস উপলভ্য নয়।</string>
<string name="subscription_updater_job_tips">কাজটি ব্যাকগ্রাউন্ডে চলবে এবং অগ্রগতির তথ্য বিজ্ঞপ্তিতে দেখানো হবে।</string>
@@ -100,8 +100,8 @@
<string name="server_lab_reserved">Reserved(اختیاری، وا کاما (,) ز یک جوڌا ابۊن)</string>
<string name="server_lab_local_address">نشۊوی محلی (اختیاری IPv4/IPv6، وا کاما (,) ز یک جوڌا ابۊن)</string>
<string name="server_lab_local_mtu">MTU(اختیاری، پؽش فرض 1420)</string>
<string name="server_lab_kcp_mtu">mKCP MTU (اختیاری، پؽش فرض هسته 1350)</string>
<string name="server_lab_kcp_tti">mKCP TTI (اختیاری، پؽش فرض هسته 50)</string>
<string name="server_lab_kcp_mtu">mKCP MTU (اختیاری، پؽش فرض 1350)</string>
<string name="server_lab_kcp_tti">mKCP TTI (اختیاری، پؽش فرض 50 میلی‌ثانیه)</string>
<string name="toast_success">وا مووفقیت ٱنجوم وابی</string>
<string name="toast_failure">شکست خرد</string>
<string name="toast_none_data">هیچ داڌه ای وۊجۊڌ نڌاره</string>
@@ -172,8 +172,11 @@
<string name="title_vpn_settings">سامووا VPN</string>
<string name="title_pref_per_app_proxy">پروکسی و ری برنومه</string>
<string name="summary_pref_per_app_proxy">پوی وولاتی: برنومه پسند وابیڌه وا ی پروکسی منپیز ابۊ، برنومه پسند نوابیڌه موستقیمن منپیز ابۊ.\nحالت دور زیڌن: برنومه پسند وابیڌه موستقیمن منپیز ابۊ، برنومه پسند نوابیڌه وا ی پروکسی منپیز ابۊ.\nپسند خوتکار برنومه یلی ک وا ی پروکسی منپیز ابۊن من نومگه امکووݩ پذیر هڌ.</string>
<string name="title_pref_is_booted">منپیز خوتکار مجال ره ونی</string>
<string name="summary_pref_is_booted">مجال ره وندن، خوساخوس و سرور پسند بیڌه منپیز ابۊ که گاشڌ نا مووفق بۊ.</string>
<string name="title_pref_is_booted">منپیز بئڌ ره وندن دسگا</string>
<string name="summary_pref_is_booted">بئڌ ره وندن دسگا، خوساخوس و سرور پسند بیڌه منپیز ابۊ. Android گاشڌ ره وندن من پس زمینه ن مئدۊد کونه.</string>
<string name="title_system_vpn_settings">سامووا VPN سیستوم</string>
<string name="summary_system_vpn_settings">سامووا VPN من Android ن وا کۊنین سی ساموو کردن VPN همیشه ره وست، ٱر دسگا زس پشتیبانی اکونه.</string>
<string name="toast_system_vpn_settings_unavailable">سامووا VPN سیستوم من دسرس نین.</string>
<string name="subscription_updater_job_tips">وزیفه من پس زمینه کار اکونه وو ایلاعات پؽشرفت من اعلان نشووݩ داڌه ابۊ.</string>
@@ -100,8 +100,8 @@
<string name="server_lab_reserved">Reserved (اختیاری، جدا شده با کاما)</string>
<string name="server_lab_local_address">آدرس محلی (IPv4/IPv6 اختیاری، جدا شده با کاما)</string>
<string name="server_lab_local_mtu">MTU (اختیاری، پیش‌فرض 1420)</string>
<string name="server_lab_kcp_mtu">mKCP MTU (اختیاری، پیش‌فرض هسته 1350)</string>
<string name="server_lab_kcp_tti">mKCP TTI (اختیاری، پیش‌فرض هسته 50)</string>
<string name="server_lab_kcp_mtu">mKCP MTU (اختیاری، پیش‌فرض 1350)</string>
<string name="server_lab_kcp_tti">mKCP TTI (اختیاری، پیش‌فرض 50 میلی‌ثانیه)</string>
<string name="toast_success">موفقیت‌آمیز</string>
<string name="toast_failure">شکست!</string>
<string name="toast_none_data">داده‌ای یافت نشد</string>
@@ -172,8 +172,11 @@
<string name="title_vpn_settings">تنظیمات VPN</string>
<string name="title_pref_per_app_proxy">پروکسی به تفکیک برنامه</string>
<string name="summary_pref_per_app_proxy">حالت عادی: برنامه‌های انتخاب‌شده از پروکسی استفاده می‌کنند و برنامه‌های انتخاب‌نشده مستقیماً متصل می‌شوند؛\nحالت دور زدن: برنامه‌های انتخاب‌شده مستقیماً متصل می‌شوند و برنامه‌های انتخاب‌نشده از پروکسی استفاده می‌کنند.\nگزینهٔ انتخاب خودکار برنامه‌هایی که باید از پروکسی استفاده کنند در منو قرار دارد.</string>
<string name="title_pref_is_booted">اتصال خودکار هنگام راه اندازی</string>
<string name="summary_pref_is_booted">هنگام راه اندازی به طور خودکار به سرور انتخابی متصل می شود که ممکن است ناموفق باشد.</string>
<string name="title_pref_is_booted">اتصال پس از راه‌اندازی دستگاه</string>
<string name="summary_pref_is_booted">پس از راه‌اندازی دستگاه، به‌طور خودکار به سرور انتخاب‌شده متصل شود. ممکن است Android اجرای پس‌زمینه را محدود کند.</string>
<string name="title_system_vpn_settings">تنظیمات VPN سیستم</string>
<string name="summary_system_vpn_settings">تنظیمات VPN در Android را برای پیکربندی VPN همیشه‌روشن باز کنید، اگر دستگاه از آن پشتیبانی می‌کند.</string>
<string name="toast_system_vpn_settings_unavailable">تنظیمات VPN سیستم در دسترس نیست.</string>
<string name="subscription_updater_job_tips">این کار در پس‌زمینه اجرا می‌شود و پیشرفت آن در اعلان نمایش داده خواهد شد.</string>
@@ -101,7 +101,7 @@
<string name="server_lab_local_address">Локальный адрес (необязательно, IPv4/IPv6 через запятую)</string>
<string name="server_lab_local_mtu">MTU (необязательно, по умолчанию 1420)</string>
<string name="server_lab_kcp_mtu">mKCP MTU (необязательно, по умолчанию 1350)</string>
<string name="server_lab_kcp_tti">mKCP TTI (необязательно, по умолчанию 50)</string>
<string name="server_lab_kcp_tti">mKCP TTI (необязательно, по умолчанию 50 мс)</string>
<string name="toast_success">Успешно</string>
<string name="toast_failure">Ошибка</string>
<string name="toast_none_data">Ничего нет</string>
@@ -172,8 +172,11 @@
<string name="title_vpn_settings">Настройки VPN</string>
<string name="title_pref_per_app_proxy">Прокси для выбранных приложений</string>
<string name="summary_pref_per_app_proxy">Основной: выбранное приложение соединяется через прокси, не выбранное — напрямую;\nРежим обхода: выбранное приложение соединяется напрямую, не выбранное — через прокси.\nВ меню можно автоматически выбрать приложения, которые должны использовать прокси.</string>
<string name="title_pref_is_booted">Автоподключение при запуске</string>
<string name="summary_pref_is_booted">Автоматически подключаться к выбранному серверу при запуске приложения (может оказаться неудачным)</string>
<string name="title_pref_is_booted">Подключаться после загрузки устройства</string>
<string name="summary_pref_is_booted">Автоматически подключаться к выбранному серверу после загрузки устройства. Android может ограничивать запуск в фоне.</string>
<string name="title_system_vpn_settings">Системные настройки VPN</string>
<string name="summary_system_vpn_settings">Открыть настройки VPN в Android для включения постоянного VPN-подключения, если устройство его поддерживает.</string>
<string name="toast_system_vpn_settings_unavailable">Системные настройки VPN недоступны.</string>
<string name="subscription_updater_job_tips">Задача будет выполняться в фоновом режиме, информация о ходе выполнения будет отображаться в уведомлении.</string>
@@ -100,8 +100,8 @@
<string name="server_lab_reserved">Reserved (không bắt buộc, phân cách bằng dấu phẩy)</string>
<string name="server_lab_local_address">Địa chỉ cục bộ (IPv4/IPv6 không bắt buộc, phân cách bằng dấu phẩy)</string>
<string name="server_lab_local_mtu">MTU (không bắt buộc, mặc định 1420)</string>
<string name="server_lab_kcp_mtu">mKCP MTU (không bắt buộc, mặc định của core là 1350)</string>
<string name="server_lab_kcp_tti">mKCP TTI (không bắt buộc, mặc định của core là 50)</string>
<string name="server_lab_kcp_mtu">mKCP MTU (không bắt buộc, mặc định 1350)</string>
<string name="server_lab_kcp_tti">mKCP TTI (không bắt buộc, mặc định 50 ms)</string>
<string name="toast_success">Thành công!</string>
<string name="toast_failure">Đã xảy ra lỗi, vui lòng thử lại!</string>
<string name="toast_none_data">Không có gì ở đây!</string>
@@ -172,8 +172,11 @@
<string name="title_vpn_settings">Cài đặt VPN</string>
<string name="title_pref_per_app_proxy">Proxy theo từng ứng dụng</string>
<string name="summary_pref_per_app_proxy">Thông thường: ứng dụng đã chọn dùng proxy, ứng dụng chưa chọn kết nối trực tiếp;\nChế độ bỏ qua: ứng dụng đã chọn kết nối trực tiếp, ứng dụng chưa chọn dùng proxy.\nTrong trình đơn có tùy chọn tự động chọn các ứng dụng sẽ dùng proxy.</string>
<string name="title_pref_is_booted">Tự động kết nối khi khởi động</string>
<string name="summary_pref_is_booted">Tự động kết nối với máy chủ đã chọn khi khởi động; thao tác này có thể không thành công</string>
<string name="title_pref_is_booted">Kết nối sau khi thiết bị khởi động</string>
<string name="summary_pref_is_booted">Tự động kết nối với máy chủ đã chọn sau khi thiết bị khởi động. Android có thể hạn chế khởi chạy trong nền.</string>
<string name="title_system_vpn_settings">Cài đặt VPN hệ thống</string>
<string name="summary_system_vpn_settings">Mở cài đặt VPN của Android để cấu hình VPN luôn bật, nếu thiết bị này hỗ trợ.</string>
<string name="toast_system_vpn_settings_unavailable">Không có cài đặt VPN hệ thống.</string>
<string name="subscription_updater_job_tips">Tác vụ sẽ chạy trong nền và thông tin tiến độ sẽ hiển thị trong thông báo.</string>
@@ -100,8 +100,8 @@
<string name="server_lab_reserved">Reserved (可选,逗号隔开)</string>
<string name="server_lab_local_address">本地地址 (可选 IPv4/IPv6,逗号隔开)</string>
<string name="server_lab_local_mtu">MTU(可选,默认 1420)</string>
<string name="server_lab_kcp_mtu">mKCP MTU(可选,核心默认 1350)</string>
<string name="server_lab_kcp_tti">mKCP TTI(可选,核心默认 50)</string>
<string name="server_lab_kcp_mtu">mKCP MTU(可选,默认 1350)</string>
<string name="server_lab_kcp_tti">mKCP TTI(可选,默认 50 毫秒)</string>
<string name="toast_success">成功</string>
<string name="toast_failure">失败</string>
<string name="toast_none_data">没有数据</string>
@@ -172,8 +172,11 @@
<string name="title_vpn_settings">VPN 设置</string>
<string name="title_pref_per_app_proxy">分应用代理</string>
<string name="summary_pref_per_app_proxy">常规:勾选的应用使用代理,未勾选的应用直连;\n绕行模式:勾选的应用直连,未勾选的应用使用代理。\n菜单中可自动选择需要使用代理的应用。</string>
<string name="title_pref_is_booted">开机时自动连接</string>
<string name="summary_pref_is_booted">开机时自动连接选择的服务器,可能会不成功</string>
<string name="title_pref_is_booted">设备启动后自动连接</string>
<string name="summary_pref_is_booted">设备启动后自动连接到所选服务器。Android 可能会限制后台启动。</string>
<string name="title_system_vpn_settings">系统 VPN 设置</string>
<string name="summary_system_vpn_settings">打开 Android VPN 设置,配置始终开启的 VPN(如果此设备支持)。</string>
<string name="toast_system_vpn_settings_unavailable">系统 VPN 设置不可用。</string>
<string name="subscription_updater_job_tips">任务将在后台运行,在通知中会显示进度信息</string>
@@ -100,8 +100,8 @@
<string name="server_lab_reserved">Reserved (可選,逗號隔開)</string>
<string name="server_lab_local_address">本機位址 (可選 IPv4/IPv6,逗號隔開)</string>
<string name="server_lab_local_mtu">MTU(可選,預設 1420)</string>
<string name="server_lab_kcp_mtu">mKCP MTU(可選,核心預設 1350)</string>
<string name="server_lab_kcp_tti">mKCP TTI(可選,核心預設 50)</string>
<string name="server_lab_kcp_mtu">mKCP MTU(可選,預設 1350)</string>
<string name="server_lab_kcp_tti">mKCP TTI(可選,預設 50 毫秒)</string>
<string name="toast_success">成功</string>
<string name="toast_failure">失敗</string>
<string name="toast_none_data">無資料</string>
@@ -172,8 +172,11 @@
<string name="title_vpn_settings">VPN 設定</string>
<string name="title_pref_per_app_proxy">個別應用程式代理</string>
<string name="summary_pref_per_app_proxy">一般:已勾選的應用程式使用代理,未勾選的應用程式直接連線;\n繞行模式:已勾選的應用程式直接連線,未勾選的應用程式使用代理。\n選單中可自動選取需要使用代理的應用程式。</string>
<string name="title_pref_is_booted">開機時自動連線</string>
<string name="summary_pref_is_booted">開機時自動連線選擇的伺服器,可能會不成功</string>
<string name="title_pref_is_booted">裝置開機後自動連線</string>
<string name="summary_pref_is_booted">裝置開機後自動連線至所選伺服器。Android 可能會限制背景啟動。</string>
<string name="title_system_vpn_settings">系統 VPN 設定</string>
<string name="summary_system_vpn_settings">開啟 Android VPN 設定,設定永遠開啟的 VPN(若此裝置支援)。</string>
<string name="toast_system_vpn_settings_unavailable">系統 VPN 設定無法使用。</string>
<string name="subscription_updater_job_tips">任務將在背景執行,通知中會顯示進度資訊。</string>
+7 -4
View File
@@ -100,8 +100,8 @@
<string name="server_lab_reserved">Reserved (optional, separated by commas)</string>
<string name="server_lab_local_address">Local address (optional IPv4/IPv6, separated by commas)</string>
<string name="server_lab_local_mtu">MTU (optional, default 1420)</string>
<string name="server_lab_kcp_mtu">mKCP MTU (optional, core default 1350)</string>
<string name="server_lab_kcp_tti">mKCP TTI (optional, core default 50)</string>
<string name="server_lab_kcp_mtu">mKCP MTU (optional, default 1350)</string>
<string name="server_lab_kcp_tti">mKCP TTI (optional, default 50 ms)</string>
<string name="toast_success">Success</string>
<string name="toast_failure">Failure</string>
<string name="toast_none_data">No data available</string>
@@ -172,8 +172,11 @@
<string name="title_vpn_settings">VPN Settings</string>
<string name="title_pref_per_app_proxy">Per-app proxy</string>
<string name="summary_pref_per_app_proxy">General: checked apps use the proxy, and unchecked apps connect directly;\nBypass mode: checked apps connect directly, and unchecked apps use the proxy.\nThe option to automatically select apps that should use the proxy is in the menu.</string>
<string name="title_pref_is_booted">Auto connect at startup</string>
<string name="summary_pref_is_booted">Automatically connects to the selected server at startup, which may be unsuccessful</string>
<string name="title_pref_is_booted">Connect after device boot</string>
<string name="summary_pref_is_booted">Automatically connect to the selected server after the device starts. Android may restrict background starts.</string>
<string name="title_system_vpn_settings">System VPN settings</string>
<string name="summary_system_vpn_settings">Open Android VPN settings to configure Always-on VPN, if supported by this device.</string>
<string name="toast_system_vpn_settings_unavailable">System VPN settings are unavailable.</string>
<string name="subscription_updater_job_tips">The task will run in the background, and progress information will be displayed in the notification.</string>
@@ -0,0 +1,114 @@
package com.v2ray.ang.handler
import android.util.Log
import com.tencent.mmkv.MMKV
import com.v2ray.ang.dto.entities.SubscriptionItem
import com.v2ray.ang.util.JsonUtil
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.BeforeClass
import org.junit.Test
import org.mockito.Mockito.mockStatic
import org.mockito.kotlin.any
import org.mockito.kotlin.mock
import org.mockito.kotlin.never
import org.mockito.kotlin.reset
import org.mockito.kotlin.verify
import org.mockito.kotlin.whenever
class SubscriptionIndexTest {
private val mainValues = mutableMapOf<String, String>()
private val subValues = mutableMapOf<String, String>()
@Before
fun prepareStorage() {
for ((storage, values) in listOf(main to mainValues, subs to subValues)) {
reset(storage)
whenever(storage.decodeString(any())).thenAnswer { values[it.getArgument<String>(0)] }
whenever(storage.encode(any<String>(), any<String>())).thenAnswer {
values[it.getArgument(0)] = it.getArgument(1)
true
}
whenever(storage.allKeys()).thenAnswer { values.keys.toTypedArray() }
}
}
@Test
fun duplicateIdsKeepTheirFirstPositionWithoutWritingStorage() {
val stored = """["second","first","second","third","first"]"""
mainValues["SUB_IDS"] = stored
assertEquals(listOf("second", "first", "third"), MmkvManager.decodeSubsList())
assertEquals(stored, mainValues["SUB_IDS"])
verify(main, never()).encode(any<String>(), any<String>())
}
@Test
fun duplicateIndexEntriesProduceOnlyOneSubscriptionRow() {
mainValues["SUB_IDS"] = """["b","a","b","a"]"""
subValues["a"] = JsonUtil.toJson(SubscriptionItem(remarks = "Alpha"))
subValues["b"] = JsonUtil.toJson(SubscriptionItem(remarks = "Beta"))
val rows = MmkvManager.decodeSubscriptions()
assertEquals(listOf("b", "a"), rows.map { it.guid })
assertEquals(listOf("Beta", "Alpha"), rows.map { it.subscription.remarks })
}
@Test
fun repeatedAndBlankNamesDoNotMergeDifferentSubscriptions() {
mainValues["SUB_IDS"] = """["a","b","c","d"]"""
listOf("a" to "Same", "b" to "Same", "c" to "", "d" to " ").forEach { (id, name) ->
subValues[id] = JsonUtil.toJson(SubscriptionItem(remarks = name))
}
assertEquals(listOf("a", "b", "c", "d"), MmkvManager.decodeSubscriptions().map { it.guid })
}
@Test
fun decodedIndexRemainsMutableAndCanBeSavedInANewOrder() {
mainValues["SUB_IDS"] = """["a","b","a"]"""
val ids = MmkvManager.decodeSubsList()
ids.remove("a")
ids.add(0, "c")
MmkvManager.encodeSubsList(ids)
assertEquals("""["c","b"]""", mainValues["SUB_IDS"])
assertEquals(listOf("c", "b"), MmkvManager.decodeSubsList())
}
@Test
fun missingBlankAndEmptyIndexesRemainEmpty() {
assertEquals(emptyList<String>(), MmkvManager.decodeSubsList())
listOf("", " ", "[]", "null").forEach { stored ->
mainValues["SUB_IDS"] = stored
assertEquals(emptyList<String>(), MmkvManager.decodeSubsList())
}
}
@Test
fun malformedIndexKeepsTheExistingEmptyFallback() {
mockStatic(Log::class.java).use {
mainValues["SUB_IDS"] = "{"
assertEquals(emptyList<String>(), MmkvManager.decodeSubsList())
}
}
companion object {
private val main: MMKV = mock()
private val subs: MMKV = mock()
private val settings: MMKV = mock()
@BeforeClass
@JvmStatic
fun initializeHandles() {
mockStatic(MMKV::class.java).use {
it.`when`<MMKV> { MMKV.mmkvWithID("MAIN", MMKV.MULTI_PROCESS_MODE) }.thenReturn(main)
it.`when`<MMKV> { MMKV.mmkvWithID("SUB", MMKV.MULTI_PROCESS_MODE) }.thenReturn(subs)
it.`when`<MMKV> { MMKV.mmkvWithID("SETTING", MMKV.MULTI_PROCESS_MODE) }.thenReturn(settings)
MmkvManager.decodeSubscriptions()
MmkvManager.decodeSettingsString("test-initialize")
}
}
}
}
@@ -0,0 +1,62 @@
package com.v2ray.ang.ui.logcat
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class LogcatEntryTest {
@Test
fun emptyLogProducesNoEntries() {
assertTrue(createLogcatEntries(emptyList()).isEmpty())
}
@Test
fun identicalTimestampedLinesHaveUniqueKeysWithoutLosingMessages() {
val line = "08-31 04:32:41.187 I/GoLog (42): repeated message"
val logs = listOf(line, line, line)
val entries = createLogcatEntries(logs)
assertEquals(logs, entries.map { it.text })
assertEquals(3, entries.map { it.key }.distinct().size)
}
@Test
fun preservesNewestFirstOrderAndOriginalText() {
val logs = listOf("newest", "\tat repeatedFrame(File.kt:42)", "middle", "\tat repeatedFrame(File.kt:42)")
val entries = createLogcatEntries(logs)
assertEquals(logs, entries.map { it.text })
assertEquals(logs.joinToString("\n"), entries.joinToString("\n") { it.text })
assertEquals(logs.size, entries.map { it.key }.distinct().size)
}
@Test
fun filteringOtherLinesPreservesKeysOfEveryMatchingOccurrence() {
val logs = listOf("match", "other", "match", "last")
val entries = createLogcatEntries(logs)
val filtered = createLogcatEntries(logs.filter { it.contains("match") })
assertEquals(entries.filter { it.text.contains("match") }, filtered)
assertTrue(createLogcatEntries(logs.filter { it.contains("missing") }).isEmpty())
assertEquals(entries, createLogcatEntries(logs))
}
@Test
fun refreshWithNewerLinesPreservesExistingOccurrenceKeys() {
val logs = listOf("repeated", "older", "repeated")
val entries = createLogcatEntries(logs)
val refreshed = createLogcatEntries(listOf("newest", "repeated") + logs)
assertEquals(entries, refreshed.takeLast(entries.size))
assertEquals(refreshed.size, refreshed.map { it.key }.distinct().size)
}
@Test
fun emptyAndKeyLikeMessagesRemainDistinct() {
val logs = listOf("", "0:", "", "1:message", "message", "message")
val entries = createLogcatEntries(logs)
assertEquals(logs, entries.map { it.text })
assertEquals(logs.size, entries.map { it.key }.distinct().size)
}
}
@@ -0,0 +1,44 @@
package com.v2ray.ang.ui.settings
import android.app.Application
import android.content.ComponentName
import android.content.Intent
import android.content.pm.PackageManager
import android.provider.Settings
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.mockito.Mockito.mockConstruction
import org.mockito.kotlin.mock
import org.mockito.kotlin.whenever
class SettingsViewModelTest {
@Test
fun vpnSettingsVisibilityFollowsActivityAvailability() = runBlocking(Dispatchers.IO) {
val packageManager = mock<PackageManager>()
val application = mock<Application>()
whenever(application.packageManager).thenReturn(packageManager)
var activity: ComponentName? = null
mockConstruction(Intent::class.java) { intent, context ->
assertEquals(listOf(Settings.ACTION_VPN_SETTINGS), context.arguments())
whenever(intent.resolveActivity(packageManager)).thenAnswer { activity }
}.use {
val viewModel = SettingsViewModel(application)
assertFalse(viewModel.systemVpnSettingsAvailable.value)
viewModel.refreshSystemVpnSettingsAvailability()
assertFalse(viewModel.systemVpnSettingsAvailable.value)
activity = mock<ComponentName>()
viewModel.refreshSystemVpnSettingsAvailability()
assertTrue(viewModel.systemVpnSettingsAvailable.value)
activity = null
viewModel.refreshSystemVpnSettingsAvailability()
assertFalse(viewModel.systemVpnSettingsAvailable.value)
}
}
}