mirror of
https://github.com/2dust/v2rayNG.git
synced 2026-11-01 07:56:25 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
200c59f773 | ||
|
|
49835a5cdb | ||
|
|
53c6506308 | ||
|
|
01b47581c0 | ||
|
|
362e541cb6 | ||
|
|
157acc8daa | ||
|
|
5d76fcd0fa | ||
|
|
4004cee279 | ||
|
|
06bcd4c16b | ||
|
|
8d61aedeca | ||
|
|
2020807c25 | ||
|
|
ca4185abad | ||
|
|
8d9152bb4e | ||
|
|
5bfcdf90ec | ||
|
|
f4f992eb9b | ||
|
|
1af8f4677c | ||
|
|
4d8ee1a916 | ||
|
|
95bcf32c0b | ||
|
|
c548e5f615 | ||
|
|
258de20972 | ||
|
|
3dc0173b9f |
No files matched your search
+1
-1
Submodule AndroidLibXrayLite updated: c634d1baea...d0c6c4ae1b.
@@ -13,8 +13,8 @@ android {
|
||||
applicationId = "com.v2ray.ang"
|
||||
minSdk = 24
|
||||
targetSdk = 37
|
||||
versionCode = 746
|
||||
versionName = "2.3.6"
|
||||
versionCode = 748
|
||||
versionName = "2.3.8"
|
||||
|
||||
val abiFilterList = (properties["ABI_FILTERS"] as? String)?.split(';')
|
||||
splits {
|
||||
|
||||
@@ -153,6 +153,7 @@
|
||||
android:exported="false" />
|
||||
<activity
|
||||
android:name=".ui.userasset.UserAssetUrlActivity"
|
||||
android:windowSoftInputMode="stateUnchanged|adjustResize"
|
||||
android:exported="false" />
|
||||
|
||||
<activity
|
||||
@@ -166,19 +167,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 +221,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 +239,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 +250,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 +260,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 +270,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 +279,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 +307,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 +328,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)}")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -96,6 +96,13 @@ abstract class BaseViewModel(application: Application) : AndroidViewModel(applic
|
||||
return localizedContext.getString(resId, *formatArgs)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a localized quantity string from a plurals resource.
|
||||
*/
|
||||
fun getQuantityString(resId: Int, quantity: Int, vararg formatArgs: Any): String {
|
||||
return localizedContext.resources.getQuantityString(resId, quantity, *formatArgs)
|
||||
}
|
||||
|
||||
/**
|
||||
* Send finish activity event.
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -464,7 +464,13 @@ class MainViewModel(
|
||||
toast(R.string.title_update_subscription_no_subscription)
|
||||
|
||||
result.successCount > 0 && result.failureCount + result.skipCount == 0 ->
|
||||
toast(dataSource.getString(R.string.title_update_config_count, result.configCount))
|
||||
toast(
|
||||
getQuantityString(
|
||||
R.plurals.title_update_config_count,
|
||||
result.configCount,
|
||||
result.configCount,
|
||||
)
|
||||
)
|
||||
|
||||
else ->
|
||||
toast(dataSource.getString(R.string.title_update_subscription_result, result.configCount, result.successCount, result.failureCount, result.skipCount))
|
||||
|
||||
@@ -41,13 +41,13 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.dto.AppInfo
|
||||
import com.v2ray.ang.extension.toastInfo
|
||||
import com.v2ray.ang.extension.toastSuccess
|
||||
import com.v2ray.ang.ui.base.BaseComponentActivity
|
||||
import com.v2ray.ang.ui.compose.AppDivider
|
||||
import com.v2ray.ang.ui.compose.AppDropdownMenuItems
|
||||
import com.v2ray.ang.ui.compose.AppListItem
|
||||
import com.v2ray.ang.ui.compose.AppTopBar
|
||||
import com.v2ray.ang.ui.compose.ConfirmDialog
|
||||
import com.v2ray.ang.ui.compose.ItemDivider
|
||||
import com.v2ray.ang.ui.compose.NavigationBarsBottomPadding
|
||||
import com.v2ray.ang.ui.compose.verticalScrollbar
|
||||
@@ -87,9 +87,6 @@ class PerAppProxyActivity : BaseComponentActivity() {
|
||||
onBackClick = { finish() },
|
||||
onPerAppProxyChanged = { viewModel.setPerAppProxyEnabled(it) },
|
||||
onBypassAppsChanged = { viewModel.setBypassAppsEnabled(it) },
|
||||
onInfoClick = {
|
||||
toastInfo(R.string.summary_pref_per_app_proxy)
|
||||
},
|
||||
onToggleApp = { viewModel.toggle(it) },
|
||||
onSearch = { viewModel.filterApps(it) },
|
||||
onSelectAll = { viewModel.selectAll() },
|
||||
@@ -118,7 +115,6 @@ fun PerAppProxyScreen(
|
||||
onBackClick: () -> Unit,
|
||||
onPerAppProxyChanged: (Boolean) -> Unit,
|
||||
onBypassAppsChanged: (Boolean) -> Unit,
|
||||
onInfoClick: () -> Unit,
|
||||
onToggleApp: (String) -> Unit,
|
||||
onSearch: (String) -> Unit,
|
||||
onSelectAll: () -> Unit,
|
||||
@@ -130,6 +126,8 @@ fun PerAppProxyScreen(
|
||||
var showSearch by rememberSaveable { mutableStateOf(false) }
|
||||
var searchQuery by rememberSaveable { mutableStateOf("") }
|
||||
var showMenu by remember { mutableStateOf(false) }
|
||||
var showInfoDialog by rememberSaveable { mutableStateOf(false) }
|
||||
val onInfoClick = { showInfoDialog = true }
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
@@ -281,4 +279,13 @@ fun PerAppProxyScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showInfoDialog) {
|
||||
ConfirmDialog(
|
||||
message = stringResource(R.string.summary_pref_per_app_proxy),
|
||||
dismissText = null,
|
||||
onConfirm = {},
|
||||
onDismiss = { showInfoDialog = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.v2ray.ang.ui.server
|
||||
|
||||
/** Removes one draft member and its row key together, resolving its current position at confirmation. */
|
||||
internal fun withoutProxyChainMember(
|
||||
members: List<String>,
|
||||
memberKeys: List<String>,
|
||||
memberKey: String,
|
||||
): Pair<List<String>, List<String>> {
|
||||
val index = memberKeys.indexOf(memberKey)
|
||||
if (index < 0) return members to memberKeys
|
||||
|
||||
return members.toMutableList().also { it.removeAt(index) } to
|
||||
memberKeys.toMutableList().also { it.removeAt(index) }
|
||||
}
|
||||
@@ -203,7 +203,7 @@ fun ProxyChainScreen(
|
||||
var members by rememberSaveable { mutableStateOf(initialMembers) }
|
||||
var memberKeys by rememberSaveable { mutableStateOf(List(initialMembers.size) { UUID.randomUUID().toString() }) }
|
||||
var showProfileDeleteConfirm by remember { mutableStateOf(false) }
|
||||
var memberToDeleteIndex by rememberSaveable { mutableStateOf<Int?>(null) }
|
||||
var memberToDeleteKey by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
val showDelete = editGuid.isNotEmpty() && !isRunning
|
||||
|
||||
val lazyListState = rememberLazyListState()
|
||||
@@ -285,7 +285,8 @@ fun ProxyChainScreen(
|
||||
}
|
||||
|
||||
itemsIndexed(items = members, key = { index, _ -> memberKeys[index] }) { index, member ->
|
||||
ReorderableItem(reorderableState, key = memberKeys[index]) { isDragging ->
|
||||
val memberKey = memberKeys[index]
|
||||
ReorderableItem(reorderableState, key = memberKey) { isDragging ->
|
||||
val elevation by animateDpAsState(if (isDragging) 4.dp else 0.dp)
|
||||
Surface(shadowElevation = elevation) {
|
||||
Row(
|
||||
@@ -314,10 +315,11 @@ fun ProxyChainScreen(
|
||||
)
|
||||
IconButton(onClick = {
|
||||
if (member.isBlank()) {
|
||||
members = members.toMutableList().also { it.removeAt(index) }
|
||||
memberKeys = memberKeys.toMutableList().also { it.removeAt(index) }
|
||||
val (remainingMembers, remainingKeys) = withoutProxyChainMember(members, memberKeys, memberKey)
|
||||
members = remainingMembers
|
||||
memberKeys = remainingKeys
|
||||
} else {
|
||||
memberToDeleteIndex = index
|
||||
memberToDeleteKey = memberKey
|
||||
}
|
||||
}) {
|
||||
Icon(
|
||||
@@ -339,15 +341,16 @@ fun ProxyChainScreen(
|
||||
onDismiss = { showProfileDeleteConfirm = false }
|
||||
)
|
||||
}
|
||||
memberToDeleteIndex?.let { index ->
|
||||
memberToDeleteKey?.let { memberKey ->
|
||||
DeleteConfirmDialog(
|
||||
message = stringResource(R.string.confirm_delete_proxy_chain_member),
|
||||
onConfirm = {
|
||||
members = members.toMutableList().also { it.removeAt(index) }
|
||||
memberKeys = memberKeys.toMutableList().also { it.removeAt(index) }
|
||||
memberToDeleteIndex = null
|
||||
val (remainingMembers, remainingKeys) = withoutProxyChainMember(members, memberKeys, memberKey)
|
||||
members = remainingMembers
|
||||
memberKeys = remainingKeys
|
||||
memberToDeleteKey = null
|
||||
},
|
||||
onDismiss = { memberToDeleteIndex = null }
|
||||
onDismiss = { memberToDeleteKey = null }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package com.v2ray.ang.ui.subscription
|
||||
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import android.os.Bundle
|
||||
import androidx.activity.viewModels
|
||||
import androidx.annotation.StringRes
|
||||
@@ -53,7 +52,6 @@ import com.v2ray.ang.ui.compose.ReorderableListItem
|
||||
import com.v2ray.ang.ui.compose.SelectListDialog
|
||||
import com.v2ray.ang.ui.compose.SettingsSwitchItem
|
||||
import com.v2ray.ang.ui.compose.verticalScrollbar
|
||||
import com.v2ray.ang.util.QRCodeDecoder
|
||||
import com.v2ray.ang.util.Utils
|
||||
import sh.calvin.reorderable.ReorderableItem
|
||||
import sh.calvin.reorderable.rememberReorderableLazyListState
|
||||
@@ -83,7 +81,7 @@ class SubSettingActivity : BaseComponentActivity() {
|
||||
startActivity(Intent(this, SubEditActivity::class.java).putExtra("subId", subId))
|
||||
},
|
||||
onRemoveSub = { subId -> removeSub(subId) },
|
||||
onShareQRCode = { url -> QRCodeDecoder.createQRCode(url) },
|
||||
onShareQRCode = viewModel::shareQRCode,
|
||||
onShareClipboard = { url ->
|
||||
Utils.setClipboard(this, url)
|
||||
toast(getString(R.string.toast_success))
|
||||
@@ -110,7 +108,7 @@ fun SubSettingScreen(
|
||||
onSubUpdate: () -> Unit,
|
||||
onEditSub: (String) -> Unit,
|
||||
onRemoveSub: (String) -> Unit,
|
||||
onShareQRCode: (String) -> Bitmap?,
|
||||
onShareQRCode: (String) -> Unit,
|
||||
onShareClipboard: (String) -> Unit
|
||||
) {
|
||||
val subscriptions by viewModel.subsFlow.collectAsStateWithLifecycle()
|
||||
@@ -119,7 +117,7 @@ fun SubSettingScreen(
|
||||
val confirmRemove = MmkvManager.decodeSettingsBool(AppConfig.PREF_CONFIRM_REMOVE, false)
|
||||
|
||||
var shareTarget by remember { mutableStateOf<Pair<String, String>?>(null) }
|
||||
var showQRCodeBitmap by remember { mutableStateOf<Bitmap?>(null) }
|
||||
val qrCodeBitmap by viewModel.qrCode.collectAsStateWithLifecycle()
|
||||
|
||||
val lazyListState = rememberLazyListState()
|
||||
val reorderableState = rememberReorderableLazyListState(lazyListState) { from, to ->
|
||||
@@ -254,7 +252,7 @@ fun SubSettingScreen(
|
||||
onSelected = { action ->
|
||||
shareTarget = null
|
||||
when (action) {
|
||||
SubscriptionShareAction.QRCode -> showQRCodeBitmap = onShareQRCode(url)
|
||||
SubscriptionShareAction.QRCode -> onShareQRCode(url)
|
||||
SubscriptionShareAction.Clipboard -> onShareClipboard(url)
|
||||
}
|
||||
},
|
||||
@@ -263,10 +261,10 @@ fun SubSettingScreen(
|
||||
}
|
||||
|
||||
// QR Code Dialog
|
||||
if (showQRCodeBitmap != null) {
|
||||
if (qrCodeBitmap != null) {
|
||||
QRCodeDialog(
|
||||
bitmap = showQRCodeBitmap,
|
||||
onDismiss = { showQRCodeBitmap = null }
|
||||
bitmap = qrCodeBitmap,
|
||||
onDismiss = viewModel::dismissQRCode
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package com.v2ray.ang.ui.subscription
|
||||
|
||||
import android.app.Application
|
||||
import android.graphics.Bitmap
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.dto.SubscriptionUpdateMessage
|
||||
@@ -14,14 +16,20 @@ 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 com.v2ray.ang.util.QRCodeDecoder
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class SubscriptionsViewModel(application: Application) : BaseViewModel(application) {
|
||||
private var qrCodeJob: Job? = null
|
||||
private val _qrCode = MutableStateFlow<Bitmap?>(null)
|
||||
internal val qrCode = _qrCode.asStateFlow()
|
||||
private val subscriptions: MutableList<SubscriptionCache> =
|
||||
MmkvManager.decodeSubscriptions().toMutableList()
|
||||
|
||||
@@ -55,6 +63,21 @@ class SubscriptionsViewModel(application: Application) : BaseViewModel(applicati
|
||||
_subsFlow.value = subscriptions.toList()
|
||||
}
|
||||
|
||||
internal fun shareQRCode(url: String) {
|
||||
dismissQRCode()
|
||||
qrCodeJob = viewModelScope.launch {
|
||||
val bitmap = withContext(Dispatchers.Default) { QRCodeDecoder.createQRCode(url) }
|
||||
if (bitmap == null) toastError(R.string.toast_failure)
|
||||
_qrCode.value = bitmap
|
||||
}
|
||||
}
|
||||
|
||||
internal fun dismissQRCode() {
|
||||
qrCodeJob?.cancel()
|
||||
qrCodeJob = null
|
||||
_qrCode.value = null
|
||||
}
|
||||
|
||||
fun move(fromPosition: Int, toPosition: Int) {
|
||||
if (subscriptions.moveItem(fromPosition, toPosition)) {
|
||||
MmkvManager.encodeSubsList(subscriptions.mapTo(mutableListOf()) { it.guid })
|
||||
@@ -87,7 +110,13 @@ class SubscriptionsViewModel(application: Application) : BaseViewModel(applicati
|
||||
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))
|
||||
toast(
|
||||
getQuantityString(
|
||||
R.plurals.title_update_config_count,
|
||||
result.configCount,
|
||||
result.configCount,
|
||||
)
|
||||
)
|
||||
|
||||
else ->
|
||||
toast(getString(R.string.title_update_subscription_result, result.configCount, result.successCount, result.failureCount, result.skipCount))
|
||||
|
||||
@@ -4,6 +4,8 @@ import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.provider.OpenableColumns
|
||||
import android.text.format.DateFormat
|
||||
import android.text.format.Formatter
|
||||
import androidx.activity.viewModels
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -34,6 +36,8 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
@@ -45,7 +49,6 @@ import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.dto.entities.AssetUrlCache
|
||||
import com.v2ray.ang.dto.entities.AssetUrlItem
|
||||
import com.v2ray.ang.extension.toTrafficString
|
||||
import com.v2ray.ang.extension.toast
|
||||
import com.v2ray.ang.extension.toastError
|
||||
import com.v2ray.ang.extension.toastSuccess
|
||||
@@ -66,7 +69,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.text.DateFormat
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
|
||||
private enum class AddAssetMenuAction(@StringRes val labelRes: Int) {
|
||||
@@ -219,7 +222,13 @@ class UserAssetActivity : HelperBaseComponentActivity() {
|
||||
viewModel.downloadGeoFiles(extDir, httpPort, proxyUsername, proxyPassword)
|
||||
}
|
||||
if (result.successCount > 0) {
|
||||
toast(getString(R.string.title_update_asset_count, result.successCount))
|
||||
toast(
|
||||
resources.getQuantityString(
|
||||
R.plurals.title_update_asset_count,
|
||||
result.successCount,
|
||||
result.successCount,
|
||||
)
|
||||
)
|
||||
} else {
|
||||
toast(getString(R.string.toast_failure))
|
||||
}
|
||||
@@ -353,11 +362,14 @@ private fun UserAssetItem(
|
||||
onEdit: () -> Unit,
|
||||
onDeleteClick: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val locale = LocalConfiguration.current.locales[0]
|
||||
val propertiesText = if (fileMetadata != null) {
|
||||
remember(fileMetadata) {
|
||||
val dateFormat = DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.MEDIUM)
|
||||
"${fileMetadata.length.toTrafficString()} • ${dateFormat.format(Date(fileMetadata.lastModified))}"
|
||||
}
|
||||
val fileSize = Formatter.formatFileSize(context, fileMetadata.length)
|
||||
val skeleton = if (DateFormat.is24HourFormat(context)) "yMMMdHm" else "yMMMdhm"
|
||||
val formattedDate = SimpleDateFormat(DateFormat.getBestDateTimePattern(locale, skeleton), locale)
|
||||
.format(Date(fileMetadata.lastModified))
|
||||
"$fileSize • $formattedDate"
|
||||
} else {
|
||||
stringResource(R.string.msg_file_not_found)
|
||||
}
|
||||
|
||||
@@ -4,15 +4,19 @@ import android.os.Bundle
|
||||
import android.text.TextUtils
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
@@ -81,6 +85,20 @@ class UserAssetUrlActivity : BaseComponentActivity() {
|
||||
}
|
||||
|
||||
private fun saveServer(remarks: String, url: String): Boolean {
|
||||
val assetList = MmkvManager.decodeAssetUrls()
|
||||
if (assetList.any { it.assetUrl.remarks == remarks && it.guid != editAssetId }) {
|
||||
toast(R.string.msg_remark_is_duplicate)
|
||||
return false
|
||||
}
|
||||
if (TextUtils.isEmpty(remarks)) {
|
||||
toast(R.string.sub_setting_remarks)
|
||||
return false
|
||||
}
|
||||
if (TextUtils.isEmpty(url)) {
|
||||
toast(R.string.title_url)
|
||||
return false
|
||||
}
|
||||
|
||||
var assetItem = MmkvManager.decodeAsset(editAssetId)
|
||||
var assetId = editAssetId
|
||||
if (assetItem != null) {
|
||||
@@ -100,20 +118,6 @@ class UserAssetUrlActivity : BaseComponentActivity() {
|
||||
assetItem.remarks = remarks
|
||||
assetItem.url = url
|
||||
|
||||
val assetList = MmkvManager.decodeAssetUrls()
|
||||
if (assetList.any { it.assetUrl.remarks == assetItem.remarks && it.guid != assetId }) {
|
||||
toast(R.string.msg_remark_is_duplicate)
|
||||
return false
|
||||
}
|
||||
if (TextUtils.isEmpty(assetItem.remarks)) {
|
||||
toast(R.string.sub_setting_remarks)
|
||||
return false
|
||||
}
|
||||
if (TextUtils.isEmpty(assetItem.url)) {
|
||||
toast(R.string.title_url)
|
||||
return false
|
||||
}
|
||||
|
||||
MmkvManager.encodeAsset(assetId, assetItem)
|
||||
toastSuccess(R.string.toast_success)
|
||||
finish()
|
||||
@@ -138,9 +142,9 @@ fun UserAssetUrlScreen(
|
||||
onSave: (String, String) -> Boolean,
|
||||
onDelete: () -> Unit
|
||||
) {
|
||||
var remarks by remember { mutableStateOf(initialRemarks) }
|
||||
var url by remember { mutableStateOf(initialUrl) }
|
||||
var showDeleteConfirm by remember { mutableStateOf(false) }
|
||||
var remarks by rememberSaveable(editAssetId, initialRemarks) { mutableStateOf(initialRemarks) }
|
||||
var url by rememberSaveable(editAssetId, initialUrl) { mutableStateOf(initialUrl) }
|
||||
var showDeleteConfirm by rememberSaveable(editAssetId) { mutableStateOf(false) }
|
||||
|
||||
Scaffold(
|
||||
contentWindowInsets = WindowInsets(0),
|
||||
@@ -171,6 +175,9 @@ fun UserAssetUrlScreen(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
.consumeWindowInsets(innerPadding)
|
||||
.imePadding()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(vertical = 8.dp)
|
||||
) {
|
||||
FormTextField(
|
||||
|
||||
@@ -11,7 +11,6 @@ import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.ui.base.BaseViewModel
|
||||
import com.v2ray.ang.util.HttpUtil
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import com.v2ray.ang.util.Utils
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.ensureActive
|
||||
@@ -50,7 +49,7 @@ class UserAssetViewModel(application: Application) : BaseViewModel(application)
|
||||
}.also { reloadJob = it }
|
||||
}
|
||||
|
||||
private fun buildAssetList(
|
||||
internal fun buildAssetList(
|
||||
decodedAssets: List<AssetUrlCache>?,
|
||||
geoFilesSource: String
|
||||
): List<AssetUrlCache> {
|
||||
@@ -59,7 +58,8 @@ class UserAssetViewModel(application: Application) : BaseViewModel(application)
|
||||
.filter { geoFile -> savedAssets.none { it.assetUrl.remarks == geoFile } }
|
||||
.map {
|
||||
AssetUrlCache(
|
||||
Utils.getUuid(),
|
||||
// Built-in rows have no persisted GUID; keep their UI identity across reloads.
|
||||
"builtin:$it",
|
||||
AssetUrlItem(
|
||||
it,
|
||||
String.format(AppConfig.GITHUB_DOWNLOAD_URL, geoFilesSource).concatUrl(it),
|
||||
|
||||
@@ -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>
|
||||
@@ -348,8 +351,22 @@
|
||||
<string name="title_del_config_count">حذف %d من التكوينات</string>
|
||||
<string name="title_import_config_count">استيراد %d من التكوينات</string>
|
||||
<string name="title_export_config_count">تصدير %d من التكوينات</string>
|
||||
<string name="title_update_config_count">تحديث %d من التكوينات</string>
|
||||
<string name="title_update_asset_count">تم تحديث %d من ملفات الأصول</string>
|
||||
<plurals name="title_update_config_count">
|
||||
<item quantity="zero">لم يتم تحديث أي تكوينات</item>
|
||||
<item quantity="one">تم تحديث تكوين واحد</item>
|
||||
<item quantity="two">تم تحديث تكوينين</item>
|
||||
<item quantity="few">تم تحديث %1$d تكوينات</item>
|
||||
<item quantity="many">تم تحديث %1$d تكوينًا</item>
|
||||
<item quantity="other">تم تحديث %1$d تكوين</item>
|
||||
</plurals>
|
||||
<plurals name="title_update_asset_count">
|
||||
<item quantity="zero">لم يتم تحديث أي ملفات أصول</item>
|
||||
<item quantity="one">تم تحديث ملف أصول واحد</item>
|
||||
<item quantity="two">تم تحديث ملفي أصول</item>
|
||||
<item quantity="few">تم تحديث %1$d ملفات أصول</item>
|
||||
<item quantity="many">تم تحديث %1$d ملف أصول</item>
|
||||
<item quantity="other">تم تحديث %1$d ملف أصول</item>
|
||||
</plurals>
|
||||
<string name="title_update_subscription_result">تم تحديث %1$d إعدادات (%2$d ناجحة، %3$d فاشلة، %4$d متخطاة)</string>
|
||||
<string name="title_update_subscription_no_subscription">لا توجد اشتراكات</string>
|
||||
<string name="toast_server_not_found_in_group">لم يُعثر على الخادم المحدد في المجموعة الحالية</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>
|
||||
@@ -348,8 +351,14 @@
|
||||
<string name="title_del_config_count">%dটি কনফিগারেশন মুছুন</string>
|
||||
<string name="title_import_config_count">%dটি কনফিগারেশন আমদানি করুন</string>
|
||||
<string name="title_export_config_count">%dটি কনফিগারেশন রপ্তানি করুন</string>
|
||||
<string name="title_update_config_count">%dটি কনফিগারেশন আপডেট করুন</string>
|
||||
<string name="title_update_asset_count">%dটি অ্যাসেট ফাইল আপডেট হয়েছে</string>
|
||||
<plurals name="title_update_config_count">
|
||||
<item quantity="one">%1$dটি কনফিগারেশন আপডেট হয়েছে</item>
|
||||
<item quantity="other">%1$dটি কনফিগারেশন আপডেট হয়েছে</item>
|
||||
</plurals>
|
||||
<plurals name="title_update_asset_count">
|
||||
<item quantity="one">%1$dটি অ্যাসেট ফাইল আপডেট হয়েছে</item>
|
||||
<item quantity="other">%1$dটি অ্যাসেট ফাইল আপডেট হয়েছে</item>
|
||||
</plurals>
|
||||
<string name="title_update_subscription_result">%1$dটি কনফিগ আপডেট হয়েছে (%2$d সফল, %3$d ব্যর্থ, %4$d এড়ানো)</string>
|
||||
<string name="title_update_subscription_no_subscription">কোনো সাবস্ক্রিপশন নেই</string>
|
||||
<string name="toast_server_not_found_in_group">বর্তমান গ্রুপে নির্বাচিত সার্ভারটি পাওয়া যায়নি</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>
|
||||
@@ -348,8 +351,14 @@
|
||||
<string name="title_del_config_count">پاک کردن %d کانفیگ</string>
|
||||
<string name="title_import_config_count">و من ٱووردن %d کانفیگ</string>
|
||||
<string name="title_export_config_count">و در کشیڌن %d کانفیگ</string>
|
||||
<string name="title_update_config_count">ورۊ کردن %d کانفیگ</string>
|
||||
<string name="title_update_asset_count">%d فایل بونچک جغرافیایی ورۊ وابی</string>
|
||||
<plurals name="title_update_config_count">
|
||||
<item quantity="one">%1$d کانفیگ ورۊ وابی</item>
|
||||
<item quantity="other">%1$d کانفیگ ورۊ وابی</item>
|
||||
</plurals>
|
||||
<plurals name="title_update_asset_count">
|
||||
<item quantity="one">%1$d فایل بونچک جوقرافیایی ورۊ وابی</item>
|
||||
<item quantity="other">%1$d فایل بونچک جوقرافیایی ورۊ وابی</item>
|
||||
</plurals>
|
||||
<string name="title_update_subscription_result">%1$d کانفیگ ورۊ وابی (مووفق: %2$d، شکست خرده: %3$d، رڌ وابیڌه: %4$d)</string>
|
||||
<string name="title_update_subscription_no_subscription">بؽ اشتراک</string>
|
||||
<string name="toast_server_not_found_in_group">سرور پسند وابیڌه من بونکۊ سکویی نجۊرست</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>
|
||||
@@ -348,8 +351,14 @@
|
||||
<string name="title_del_config_count">حذف %d کانفیگ</string>
|
||||
<string name="title_import_config_count">وارد کردن %d کانفیگ</string>
|
||||
<string name="title_export_config_count">صادر کردن %d کانفیگ</string>
|
||||
<string name="title_update_config_count">آپدیت کردن %d کانفیگ</string>
|
||||
<string name="title_update_asset_count">%d فایل منبع بروزرسانی شد</string>
|
||||
<plurals name="title_update_config_count">
|
||||
<item quantity="one">%1$d کانفیگ بهروزرسانی شد</item>
|
||||
<item quantity="other">%1$d کانفیگ بهروزرسانی شد</item>
|
||||
</plurals>
|
||||
<plurals name="title_update_asset_count">
|
||||
<item quantity="one">%1$d فایل منبع بهروزرسانی شد</item>
|
||||
<item quantity="other">%1$d فایل منبع بهروزرسانی شد</item>
|
||||
</plurals>
|
||||
<string name="title_update_subscription_result">بروزرسانی %1$d کانفیگ انجام شد (%2$d موفق، %3$d شکست خورده، %4$d نادیده گرفته شده)</string>
|
||||
<string name="title_update_subscription_no_subscription">هیچ اشتراکی وجود ندارد</string>
|
||||
<string name="toast_server_not_found_in_group">سرور انتخابشده در گروه فعلی پیدا نشد</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>
|
||||
@@ -348,8 +351,18 @@
|
||||
<string name="title_del_config_count">Удалено профилей: %d</string>
|
||||
<string name="title_import_config_count">Импортировано профилей: %d</string>
|
||||
<string name="title_export_config_count">Экспортировано профилей: %d</string>
|
||||
<string name="title_update_config_count">Обновлено профилей: %d</string>
|
||||
<string name="title_update_asset_count">Обновлено файлов ресурсов: %d</string>
|
||||
<plurals name="title_update_config_count">
|
||||
<item quantity="one">Обновлён %1$d профиль</item>
|
||||
<item quantity="few">Обновлено %1$d профиля</item>
|
||||
<item quantity="many">Обновлено %1$d профилей</item>
|
||||
<item quantity="other">Обновлено %1$d профиля</item>
|
||||
</plurals>
|
||||
<plurals name="title_update_asset_count">
|
||||
<item quantity="one">Обновлён %1$d файл ресурсов</item>
|
||||
<item quantity="few">Обновлено %1$d файла ресурсов</item>
|
||||
<item quantity="many">Обновлено %1$d файлов ресурсов</item>
|
||||
<item quantity="other">Обновлено %1$d файла ресурсов</item>
|
||||
</plurals>
|
||||
<string name="title_update_subscription_result">Обновлено профилей: %1$d (успешно: %2$d, ошибки: %3$d, пропущено: %4$d)</string>
|
||||
<string name="title_update_subscription_no_subscription">Нет подписок</string>
|
||||
<string name="toast_server_not_found_in_group">Выбранный профиль не найден в текущей группе</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>
|
||||
@@ -348,8 +351,12 @@
|
||||
<string name="title_del_config_count">Xóa %d cấu hình</string>
|
||||
<string name="title_import_config_count">Nhập %d cấu hình</string>
|
||||
<string name="title_export_config_count">Xuất %d cấu hình</string>
|
||||
<string name="title_update_config_count">Cập nhật %d cấu hình</string>
|
||||
<string name="title_update_asset_count">Đã cập nhật %d tệp tài nguyên</string>
|
||||
<plurals name="title_update_config_count">
|
||||
<item quantity="other">Đã cập nhật %1$d cấu hình</item>
|
||||
</plurals>
|
||||
<plurals name="title_update_asset_count">
|
||||
<item quantity="other">Đã cập nhật %1$d tệp tài nguyên</item>
|
||||
</plurals>
|
||||
<string name="title_update_subscription_result">Đã cập nhật %1$d cấu hình (%2$d thành công, %3$d thất bại, %4$d bỏ qua)</string>
|
||||
<string name="title_update_subscription_no_subscription">Không có đăng ký</string>
|
||||
<string name="toast_server_not_found_in_group">Không tìm thấy máy chủ đã chọn trong nhóm hiện tại</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>
|
||||
@@ -348,8 +351,12 @@
|
||||
<string name="title_del_config_count">删除 %d 个配置</string>
|
||||
<string name="title_import_config_count">导入 %d 个配置</string>
|
||||
<string name="title_export_config_count">导出 %d 个配置</string>
|
||||
<string name="title_update_config_count">更新 %d 个配置</string>
|
||||
<string name="title_update_asset_count">已更新 %d 个资源文件</string>
|
||||
<plurals name="title_update_config_count">
|
||||
<item quantity="other">已更新 %1$d 个配置</item>
|
||||
</plurals>
|
||||
<plurals name="title_update_asset_count">
|
||||
<item quantity="other">已更新 %1$d 个资源文件</item>
|
||||
</plurals>
|
||||
<string name="title_update_subscription_result">更新了 %1$d 个配置(%2$d 个成功,%3$d 个失败,%4$d 个跳过)</string>
|
||||
<string name="title_update_subscription_no_subscription">无订阅</string>
|
||||
<string name="toast_server_not_found_in_group">当前分组中未找到选中的服务器</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>
|
||||
@@ -348,8 +351,12 @@
|
||||
<string name="title_del_config_count">刪除 %d 個設定</string>
|
||||
<string name="title_import_config_count">匯入 %d 個設定</string>
|
||||
<string name="title_export_config_count">匯出 %d 個設定</string>
|
||||
<string name="title_update_config_count">更新 %d 個設定</string>
|
||||
<string name="title_update_asset_count">已更新 %d 個資源檔案</string>
|
||||
<plurals name="title_update_config_count">
|
||||
<item quantity="other">已更新 %1$d 個設定</item>
|
||||
</plurals>
|
||||
<plurals name="title_update_asset_count">
|
||||
<item quantity="other">已更新 %1$d 個資源檔案</item>
|
||||
</plurals>
|
||||
<string name="title_update_subscription_result">更新了 %1$d 個設定(%2$d 個成功,%3$d 個失敗,%4$d 個跳過)</string>
|
||||
<string name="title_update_subscription_no_subscription">無訂閱</string>
|
||||
<string name="toast_server_not_found_in_group">目前群組中找不到所選伺服器</string>
|
||||
|
||||
@@ -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>
|
||||
@@ -348,8 +351,14 @@
|
||||
<string name="title_del_config_count">Delete %d configs</string>
|
||||
<string name="title_import_config_count">Import %d configs</string>
|
||||
<string name="title_export_config_count">Export %d configs</string>
|
||||
<string name="title_update_config_count">Update %d configs</string>
|
||||
<string name="title_update_asset_count">Updated %d asset files</string>
|
||||
<plurals name="title_update_config_count">
|
||||
<item quantity="one">Updated %1$d config</item>
|
||||
<item quantity="other">Updated %1$d configs</item>
|
||||
</plurals>
|
||||
<plurals name="title_update_asset_count">
|
||||
<item quantity="one">Updated %1$d asset file</item>
|
||||
<item quantity="other">Updated %1$d asset files</item>
|
||||
</plurals>
|
||||
<string name="title_update_subscription_result">Updated %1$d configs (%2$d success, %3$d failed, %4$d skipped)</string>
|
||||
<string name="title_update_subscription_no_subscription">No subscriptions</string>
|
||||
<string name="toast_server_not_found_in_group">Selected server not found in current group</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,60 @@
|
||||
package com.v2ray.ang.ui.server
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Test
|
||||
|
||||
class ProxyChainMembersTest {
|
||||
@Test
|
||||
fun removalFollowsThePendingKeyAfterReordering() {
|
||||
val pendingKey = "two"
|
||||
val members = listOf("Two", "One", "Three")
|
||||
val keys = listOf("two", "one", "three")
|
||||
|
||||
val (remainingMembers, remainingKeys) = withoutProxyChainMember(members, keys, pendingKey)
|
||||
|
||||
assertEquals(listOf("One", "Three"), remainingMembers)
|
||||
assertEquals(listOf("one", "three"), remainingKeys)
|
||||
assertEquals(listOf("Two", "One", "Three"), members)
|
||||
assertEquals(listOf("two", "one", "three"), keys)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun duplicateNamesDoNotChangeWhichMemberIsRemoved() {
|
||||
val (members, keys) = withoutProxyChainMember(
|
||||
listOf("Same", "Same", "Other"),
|
||||
listOf("first", "second", "third"),
|
||||
"second",
|
||||
)
|
||||
|
||||
assertEquals(listOf("Same", "Other"), members)
|
||||
assertEquals(listOf("first", "third"), keys)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun missingMemberLeavesBothListsUnchanged() {
|
||||
val members = listOf("One", "Three")
|
||||
val keys = listOf("one", "three")
|
||||
|
||||
val (remainingMembers, remainingKeys) = withoutProxyChainMember(members, keys, "two")
|
||||
|
||||
assertSame(members, remainingMembers)
|
||||
assertSame(keys, remainingKeys)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun emptyChainRemainsEmpty() {
|
||||
assertEquals(
|
||||
emptyList<String>() to emptyList<String>(),
|
||||
withoutProxyChainMember(emptyList(), emptyList(), "missing"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun blankMemberIsRemovedWithItsOwnKey() {
|
||||
assertEquals(
|
||||
listOf("One") to listOf("one"),
|
||||
withoutProxyChainMember(listOf("One", ""), listOf("one", "blank"), "blank"),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.v2ray.ang.ui.userasset
|
||||
|
||||
import android.app.Application
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.dto.entities.AssetUrlCache
|
||||
import com.v2ray.ang.dto.entities.AssetUrlItem
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.mockito.Mockito.mock
|
||||
|
||||
class UserAssetViewModelTest {
|
||||
private val viewModel = UserAssetViewModel(mock(Application::class.java))
|
||||
|
||||
@Test
|
||||
fun builtInIdentitySurvivesReloadAndSourceChange() {
|
||||
val first = viewModel.buildAssetList(null, "first/source")
|
||||
val reloaded = viewModel.buildAssetList(emptyList(), "second/source")
|
||||
|
||||
assertEquals(3, first.size)
|
||||
assertEquals(first.size, first.map { it.guid }.distinct().size)
|
||||
assertEquals(first.map { it.guid }, reloaded.map { it.guid })
|
||||
assertTrue(reloaded.all { it.assetUrl.locked == true })
|
||||
assertEquals(
|
||||
AppConfig.GEOIP_ONLY_CN_PRIVATE_URL,
|
||||
reloaded.single { it.assetUrl.remarks == AppConfig.GEOIP_ONLY_CN_PRIVATE_DAT }.assetUrl.url,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun savedAssetKeepsItsIdentityAndReplacesMatchingBuiltIn() {
|
||||
val saved = AssetUrlCache("saved-guid", AssetUrlItem(AppConfig.GEOSITE_DAT, "https://example.invalid/geosite.dat"))
|
||||
val custom = AssetUrlCache("custom-guid", AssetUrlItem("custom.dat", "file"))
|
||||
val builtIns = viewModel.buildAssetList(emptyList(), "source")
|
||||
val rows = viewModel.buildAssetList(listOf(saved, custom), "source")
|
||||
|
||||
assertEquals(4, rows.size)
|
||||
assertEquals(saved, rows.single { it.assetUrl.remarks == saved.assetUrl.remarks })
|
||||
assertEquals(custom, rows.single { it.guid == custom.guid })
|
||||
assertEquals(
|
||||
builtIns.filter { it.assetUrl.remarks != AppConfig.GEOSITE_DAT }.map { it.guid },
|
||||
rows.filter { it.assetUrl.locked == true }.map { it.guid },
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user