Compare commits

..
21 Commits
Author SHA1 Message Date
2dust 200c59f773 up 2.3.8 2026-09-10 14:23:35 +08:00
eliotcougar 49835a5cdb Use completed-action wording and plurals for update results (#6196)
Extract the config and asset update-result resources from local/talkback-all-10 in all nine supported locales. Describe completed updates instead of an update command, and select singular or plural forms using the actual config or successful asset count.

Route both subscription update entry points through the existing localized ViewModel context and use activity resources for asset results. Preserve update execution, counts, partial-failure summaries, error handling, and toast delivery; no accessibility announcement infrastructure is included.
2026-09-10 13:56:49 +08:00
2dust 53c6506308 Update AndroidLibXrayLite 2026-09-10 12:19:02 +08:00
eliotcougar 01b47581c0 Keep proxy-chain deletion targets tied to stable row keys (#6200)
Store the pending member key instead of its list position and resolve that key when deletion is confirmed. Remove the member and its corresponding row key together; if the target no longer exists, leave the draft unchanged.

Extract the deletion fix from the accessibility assembly without importing accessibility actions, dialog wording changes, or drag refactors. Reuse the same key-based removal for blank members.

Validation: 75 JVM tests passed, including five deletion regressions; Play Store debug Kotlin compilation and x86_64 assembly passed. Emulator smoke check covered confirmation across rotation and immediate blank-member removal. Keyboard/D-pad and runtime reorder-during-confirmation scenarios were not run.
2026-09-10 12:16:17 +08:00
eliotcougar 362e541cb6 Keep built-in asset row identities stable across reloads (#6199)
Use a builtin: filename-based ID instead of generating a fresh UUID each time the asset list is rebuilt. This lets the existing GUID-keyed list and file-metadata map recognize the same built-in asset after reloads and download-source changes.

Extract the fix and its two JVM regression tests from local/talkback-all-10. Preserve stored asset GUIDs, matching built-in replacement, locked flags, and URL behavior. No persisted IDs, accessibility semantics, metadata formatting, or editor behavior are changed.
2026-09-10 11:50:59 +08:00
eliotcougar 157acc8daa Format asset metadata with localized file sizes and minute precision (#6197)
Extract the visible asset metadata formatting from local/talkback-all-10. Use Android file-size formatting and derive the date/time pattern from the app configuration locale and the device 12/24-hour preference, without seconds.

Remove the metadata-only remember cache so recomposition uses the current formatting context. Preserve the existing size/date separator, missing-file message, row layout, action buttons, and accessibility semantics. No update-result or asset-editor changes are included.
2026-09-10 11:49:29 +08:00
eliotcougar 5d76fcd0fa Generate subscription QR codes off the UI thread (#6194)
Move subscription QR bitmap generation from the synchronous share callback into the existing ViewModel, running CPU-bound encoding on Dispatchers.Default and exposing the completed bitmap through StateFlow.

Cancel pending result delivery when the dialog is dismissed, a newer share request replaces it, or the ViewModel is cleared. Keep dialog rendering lifecycle-aware and reuse the existing localized failure message when generation returns no bitmap.

Extract only QR generation and dialog-state wiring from local/talkback-all-10. Leave subscription row accessibility, custom actions, update handling, and clipboard sharing unchanged.
2026-09-10 11:35:59 +08:00
eliotcougar 4004cee279 UI/UX: Preserve asset-editor drafts during recompose (#6193)
* Preserve asset-editor drafts and handle the keyboard

Keep the asset name, URL, and delete-confirmation visibility in saveable Compose state so activity recreation does not discard an unfinished edit. Key the state by asset identity and initial field values.

Make the form vertically scrollable, consume the Scaffold padding, and apply IME padding so its fields remain reachable when the keyboard reduces the available height. Extract these presentation changes from local/talkback-all-10 without changing accessibility labels, deletion wording, or asset-save validation order.

* Resize the asset editor when the keyboard opens

Declare stateUnchanged|adjustResize for UserAssetUrlActivity, matching the other editor activities. Without an explicit resize policy, opening the IME in landscape pans the entire window and moves the toolbar Save action off-screen despite the scrollable Compose form.

Verified on Android 13 Pixel 6a: rotate with an unsaved draft, reopen the landscape keyboard, scroll between fields, edit the first field, save without hiding the keyboard, and reopen persisted values. Portrait keyboard and Save also pass. Debug assembly succeeded.
2026-09-10 11:34:20 +08:00
eliotcougar 06bcd4c16b Show per-app proxy help in a dialog (#6192)
Replace the short-lived info toast with the existing titleless confirmation dialog and a single OK button so users can read the routing explanation at their own pace. Keep dialog visibility in saveable screen state and reuse the existing localized help and action strings.

Extract only the help-dialog presentation from local/talkback-all-10; leave app-row semantics, switches, routing descriptions, and notification infrastructure in the accessibility branches.
2026-09-10 11:33:58 +08:00
eliotcougar 8d61aedeca fix: validate asset edits before deleting the cached file (#6190)
Reject duplicate names and empty names or URLs before invalidating the existing asset cache. Previously a rejected save left the stored source unchanged but deleted its downloaded file.

Preserve the validation messages and their order, duplicate-name exclusion by asset ID, and successful-save behavior. Renames still invalidate the old filename before updating the asset record.
2026-09-10 11:33:22 +08:00
2dust 2020807c25 up 2.3.7 2026-09-05 15:52:22 +08:00
2dust ca4185abad Rename daemon process and split task services
Standardizes manifest process names by replacing the long `:RunSoLibV2RayDaemon` suffix with `:daemon` for shortcut activities, VPN/proxy services, and related receivers. It also moves `CoreTestService` and `SubscriptionUpdateService` to a separate `:tasks` process to isolate task-oriented work from the main daemon process.
2026-09-05 15:47:34 +08:00
eliotcougar 8d9152bb4e Align default settings hints with actual app behavior (#6183) 2026-09-05 15:06:19 +08:00
eliotcougar 5bfcdf90ec Clarify boot auto-connect and link system VPN settings (#6179)
* Clarify device-boot connection and link system VPN settings

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

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

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

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

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

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

No files matched your search

+2 -2
View File
@@ -13,8 +13,8 @@ android {
applicationId = "com.v2ray.ang"
minSdk = 24
targetSdk = 37
versionCode = 746
versionName = "2.3.6"
versionCode = 748
versionName = "2.3.8"
val abiFilterList = (properties["ABI_FILTERS"] as? String)?.split(';')
splits {
+12 -11
View File
@@ -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),
+23 -6
View File
@@ -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>
+15 -6
View File
@@ -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>
+15 -6
View File
@@ -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>
+18 -5
View File
@@ -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>
+13 -6
View File
@@ -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>
+15 -6
View File
@@ -100,8 +100,8 @@
<string name="server_lab_reserved">Reserved (optional, separated by commas)</string>
<string name="server_lab_local_address">Local address (optional IPv4/IPv6, separated by commas)</string>
<string name="server_lab_local_mtu">MTU (optional, default 1420)</string>
<string name="server_lab_kcp_mtu">mKCP MTU (optional, core default 1350)</string>
<string name="server_lab_kcp_tti">mKCP TTI (optional, core default 50)</string>
<string name="server_lab_kcp_mtu">mKCP MTU (optional, default 1350)</string>
<string name="server_lab_kcp_tti">mKCP TTI (optional, default 50 ms)</string>
<string name="toast_success">Success</string>
<string name="toast_failure">Failure</string>
<string name="toast_none_data">No data available</string>
@@ -172,8 +172,11 @@
<string name="title_vpn_settings">VPN Settings</string>
<string name="title_pref_per_app_proxy">Per-app proxy</string>
<string name="summary_pref_per_app_proxy">General: checked apps use the proxy, and unchecked apps connect directly;\nBypass mode: checked apps connect directly, and unchecked apps use the proxy.\nThe option to automatically select apps that should use the proxy is in the menu.</string>
<string name="title_pref_is_booted">Auto connect at startup</string>
<string name="summary_pref_is_booted">Automatically connects to the selected server at startup, which may be unsuccessful</string>
<string name="title_pref_is_booted">Connect after device boot</string>
<string name="summary_pref_is_booted">Automatically connect to the selected server after the device starts. Android may restrict background starts.</string>
<string name="title_system_vpn_settings">System VPN settings</string>
<string name="summary_system_vpn_settings">Open Android VPN settings to configure Always-on VPN, if supported by this device.</string>
<string name="toast_system_vpn_settings_unavailable">System VPN settings are unavailable.</string>
<string name="subscription_updater_job_tips">The task will run in the background, and progress information will be displayed in the notification.</string>
@@ -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 },
)
}
}