Compare commits

...
23 Commits
Author SHA1 Message Date
2dust bd21bbc3f9 up 2.3.9 2026-09-22 14:49:01 +08:00
2dust 6e5d742e40 Fix
https://github.com/2dust/v2rayNG/issues/6240
2026-09-22 14:48:05 +08:00
2dust 4472162614 Preserve groups when replacing profiles 2026-09-22 14:30:22 +08:00
2dust 1f7cef32dc Animate start FAB icon spin on tap 2026-09-22 11:11:59 +08:00
2dust 7e68e2eaad Adjust toast background opacity
Increase toast background opacity from 70% to 85% for all theme variants in Theme.kt. This improves readability and keeps toast notifications visually consistent without changing their content or behavior.
2026-09-22 10:42:01 +08:00
2dust 4c7c964e65 Show service start failure details
Pass the optional failure message from the service layer through `MainServiceEvent` and show it in `MainViewModel` when available. This preserves the existing generic error toast as a fallback while surfacing more specific startup failures to the user.
2026-09-22 10:41:31 +08:00
2dust 1bd1bceadb Delete ServerActivity.kt 2026-09-21 19:13:13 +08:00
2dust 0e80f6b431 Add snackbar-safe toast dispatch
Refactor toast helpers to route through a shared dispatcher that first tries the app snackbar manager and falls back to Android Toasts on the main thread. This adds a `long` duration option across normal/success/error/info toast APIs and avoids posting to a new Handler for every message by reusing a single main-thread handler.
2026-09-21 19:10:33 +08:00
2dust 1ad5f30ede Show inline validation errors in forms
Move several save-time validation failures from toast messages to field error states in the server and subscription editors. Password validation now marks the relevant server form field, and subscription editing highlights invalid URLs and update intervals before save instead of relying on transient toasts.
2026-09-21 19:10:10 +08:00
2dust e4c0b09e1b Show inline errors in Compose forms
Replace toast-only required-field validation with Compose text field error states across routing, server, subscription, and user asset editors. The shared form field now supports `isError` and supporting text so save actions can block invalid input and highlight missing remarks, address, port, and URL values directly in the form.
2026-09-21 17:00:54 +08:00
Eliot the Cougar 073376e46d Localize notification-channel names (#6195)
Replace hard-coded channel labels with dedicated resource names for the main service, subscription updates, and connection checks in all nine supported locales. Resolve names through AppLocaleManager for service and application contexts.

Reuse the existing channel when refreshing its localized name, preserving channel IDs and user-controlled importance, sound, vibration, and visibility. Apply initial defaults only to newly created channels and remove the obsolete hard-coded service-channel name.

Extract channel localization from local/talkback-all-10 without adding its transient-message channel, snackbar fallback, or accessibility announcement infrastructure.
2026-09-21 14:55:32 +08:00
Miheichev Aleksandr SergeevichandClaude Opus 5 08c2a4deb1 refactor(ui): migrate Coil 2 to Coil 3 (#6248)
Coil 2 had its last release in July 2024 (2.7.0); Coil 3 is the
maintained line and uses new coordinates, io.coil-kt.coil3, with the
package prefix coil3.

AppIconFetcher now returns ImageFetchResult with drawable.asImage(),
since Coil 3 models images through its own Image type. Fetcher,
Fetcher.Factory, Options, DataSource and the ImageRequest builder chain
keep the same shapes, so the rest is imports.

No network artifact is required: AppListItem is the only AsyncImage call
site and both callers pass icon = null, so every model is the local
appicon: request served by AppIconFetcher.

Coil 3.6.3 declares minSdkVersion 23 and ships no native code, so
neither minSdk 24 nor the four app ABIs are affected.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 14:49:06 +08:00
wttommy-ctrl 60ab1af384 fix(root): enforce command deadlines while draining output (#6241)
* fix(root): bound command execution and output collection

* test(root): cover command deadlines and output limits
2026-09-21 14:47:18 +08:00
cinand2dust c48fbd3fc8 feat(wireguard): add remoteDNS field to server config UI (#6230)
Expose the `remoteDNS` option (added in Xray-core 26.9.9) in the wireguard.

- Accepts a comma-separated list of IPv4/IPv6 addresses
- Accepts "local" as a single value to resolve via the device's
  local DNS instead of routing lookups through the tunnel
- Falls back to WIREGUARD_LOCAL_REMOTE_DNS when the field is empty
  (Xray-core upstream value)

Co-authored-by: 2dust <31833384+2dust@users.noreply.github.com>
2026-09-21 14:42:56 +08:00
Eliot the Cougar b98895219a Fix WebView Browser Dialer recovery after network handovers (#6229)
* Confine WebView dialer lifecycle to its main looper

Preserve synchronous start/stop completion and failure propagation for worker-thread network reloads. Supersede queued lifecycle requests and prevent callbacks from reviving destroyed views. Add JVM and real-WebView regression coverage for issue #6228.

* Keep WebView device tests focused on framework thread ownership

* Remove WebView dialer instrumentation tests
2026-09-21 14:32:15 +08:00
solokot 0f65b1d348 Update Russian translation [reviewed] (#6210)
* Update Russian translation

Some improvements

* fix typo

* Update Russian translation [reviewed]
2026-09-21 11:50:22 +08:00
Eliot the Cougar a1cfa2aa7a UI/UX: Add item names to deletion confirmations (#6202)
* Name the targets in deletion confirmations

Extract the visible named-confirmation wording from the accessibility assembly onto current upstream. Cover main-list profiles, standard and custom profile editors, policy groups, proxy-chain members, subscriptions, routing-rule editors, and asset sources. Reuse the six localized named variants in all nine catalogs; preserve existing generic strings and the already-named asset-file confirmation.

Carry display names alongside existing GUIDs in main/subscription list confirmation requests, and retain the main target across recreation with its two-string saver. Use saved names in editors rather than uncommitted renames. Do not import accessibility semantics, action menus, live regions, the separate routing-list Delete button, or the proxy-chain stable-key migration.

Validation: 72 JVM tests passed, including two deletion-target saver regressions; Play Store debug compilation and x86_64 assembly passed. An API 33 emulator verified all 54 compiled localized resources with Unicode and percent-containing names, the main profile confirmation across rotation, and subscription confirmation/cancel. No deletion was confirmed. Other editors, double-column menus, keyboard/D-pad, and TalkBack were not exercised. Original APK and rotation settings were restored.

* Standardize named delete confirmation layout

Center delete confirmation messages while retaining a single Text node for the question and target. Separate each item name with a blank line in all nine locales, including asset files, which previously embedded the filename in the question.

Keep the title-free dialog and existing Cancel/Delete actions, and leave ordinary confirmation messages start-aligned. Bulk-delete prompts retain their collective wording.

Validation: 72 JVM tests, Kotlin compilation and Play Store debug assembly passed. All 63 compiled localized templates preserved an exact Unicode/percent target. Emulator checks covered profile and asset message nodes, two action targets, cancellation and profile dialog recreation after rotation. Actual spoken TalkBack output and the full theme/input matrix were not verified.

* Resolve named proxy-chain confirmations by stable member key

Upstream #6200 replaced pending member indices with stable keys. The textual rebase of #6202 retained an out-of-scope index reference in the confirmation text, breaking compilation.

Look up the current member name through memberKeys, using the same pending identity as deletion. Reordering no longer separates the displayed target from the member removed on confirmation.

* Reuse deletion questions instead of duplicating named translations

Let DeleteConfirmDialog append an optional item name after a blank line while keeping the complete message in one centered Text. Reuse the existing localized questions at all named deletion sites and remove six duplicate resource keys from all nine locales. Asset-file questions use the same shared layout. Unnamed and bulk prompts remain unchanged.

Retain stable-key proxy-chain target lookup and the corrected Russian policy-group wording in the reused question. Validation: existing ServerDeleteTargetTest, all 79 JVM tests, x86_64 Play Store debug assembly and a Pixel 9 Pro emulator check passed. The profile dialog exposes one question/name text node plus Cancel/Delete, and canceling preserves the lab profiles. TalkBack was enabled for the smoke check; actual speech and the full device/theme/locale matrix were not independently verified.
2026-09-21 11:49:32 +08:00
dependabot[bot] 53fce9a8f5 Bump actions/setup-java from 6.0.0 to 6.0.1 (#6232)
Bumps [actions/setup-java](https://github.com/actions/setup-java) from 6.0.0 to 6.0.1.
- [Release notes](https://github.com/actions/setup-java/releases)
- [Commits](https://github.com/actions/setup-java/compare/v6.0.0...v6.0.1)

---
updated-dependencies:
- dependency-name: actions/setup-java
  dependency-version: 6.0.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-19 16:01:23 +08:00
2dust cb39a10ada Upgrade Gradle, AGP, and core dependencies 2026-09-19 15:56:26 +08:00
Mohammad c1c93b62d1 Add finalMask parsing to Wireguard configuration handling (#6242) 2026-09-19 15:02:56 +08:00
eliotcougar a9b1242686 Coalesce bulk probe result updates (#6205) 2026-09-13 09:51:57 +08:00
eliotcougar 173f60a1da Keep connection-test requests tied to their lifecycle (#6203)
Extract request ownership and cancellation from the accessibility work without changing result wording or presentation. Correlate current and bulk replies with the request that produced them so older progress, cancellation, and completion cannot finish a replacement test.

Own current probes in the core manager and cancel them on replacement, stop, or reload. Use the existing ordered service acknowledgement to retire requests when the daemon is absent. Cancel pending bulk preparation when the UI cancels or its ViewModel is cleared, and snapshot the filtered target list before asynchronous preparation.

Tag bulk cancellation per worker, including service destruction, instead of reporting cancellation as successful completion or issuing an untagged reply. Report rejected bulk-service starts through the same cancellation path. Preserve the existing service modes, IPC payload classes, and group post-test policy.

Validation: 74 JVM tests, Kotlin compilation and Play Store debug assembly passed. External emulator probes exercised current and bulk replacement, stale replies, cancellation during preparation, daemon-absent acknowledgement, and real notification-command cancellation over 15 Lab profiles. VPN smoke testing returned a current result with IP information and preserved disconnected status after stop. Root/proxy-only and network-handover runtime checks were not run.
2026-09-11 19:46:40 +08:00
DHR60 b9c2decb34 Delete redundant fields (#6216) 2026-09-11 19:32:23 +08:00
71 changed files with 1522 additions and 1224 deletions

No files matched your search

+1 -1
View File
@@ -81,7 +81,7 @@ jobs:
out-file-path: V2rayNG/app/libs/
- name: Setup Java
uses: actions/setup-java@v6.0.0
uses: actions/setup-java@v6.0.1
with:
distribution: 'temurin'
java-version: '21'
+2 -2
View File
@@ -13,8 +13,8 @@ android {
applicationId = "com.v2ray.ang"
minSdk = 24
targetSdk = 37
versionCode = 748
versionName = "2.3.8"
versionCode = 749
versionName = "2.3.9"
val abiFilterList = (properties["ABI_FILTERS"] as? String)?.split(';')
splits {
+71 -91
View File
@@ -1,106 +1,86 @@
{
"stats":{},
"stats": {},
"log": {
"loglevel": "warning"
},
"policy":{
"levels": {
"8": {
"handshake": 4,
"connIdle": 300,
"uplinkOnly": 1,
"downlinkOnly": 1
"policy": {
"levels": {
"8": {
"handshake": 4,
"connIdle": 300,
"uplinkOnly": 1,
"downlinkOnly": 1
}
},
"system": {
"statsOutboundUplink": true,
"statsOutboundDownlink": true
}
},
"inbounds": [
{
"tag": "socks",
"port": 10808,
"protocol": "socks",
"settings": {
"auth": "noauth",
"udp": true,
"userLevel": 8
},
"sniffing": {
"enabled": true,
"destOverride": [
"http",
"tls",
"quic"
]
}
}
],
"outbounds": [
{
"tag": "proxy",
"protocol": "vmess",
"settings": {
"address": "v2ray.cool",
"port": 10086
},
"streamSettings": {
"network": "tcp"
},
"mux": {
"enabled": false
}
},
{
"protocol": "freedom",
"streamSettings": {
"sockopt": {
"domainStrategy": "UseIP",
"happyEyeballs": {
"tryDelayMs": 250,
"interleave": 2
}
}
},
"system": {
"statsOutboundUplink": true,
"statsOutboundDownlink": true
}
},
"inbounds": [{
"tag": "socks",
"port": 10808,
"protocol": "socks",
"settings": {
"auth": "noauth",
"udp": true,
"userLevel": 8
"tag": "direct"
},
"sniffing": {
"enabled": true,
"destOverride": [
"http",
"tls",
"quic"
]
}
}
],
"outbounds": [{
"tag": "proxy",
"protocol": "vmess",
"settings": {
"vnext": [
{
"address": "v2ray.cool",
"port": 10086,
"users": [
{
"id": "a3482e88-686a-4a58-8126-99c9df64b7bf",
"alterId": 0,
"security": "auto",
"level": 8
}
]
}
],
"servers": [
{
"address": "v2ray.cool",
"method": "chacha20",
"ota": false,
"password": "123456",
"port": 10086,
"level": 8
}
]
},
"streamSettings": {
"network": "tcp"
},
"mux": {
"enabled": false
}
},
{
"protocol": "freedom",
"streamSettings": {
"sockopt": {
"domainStrategy": "UseIP",
"happyEyeballs": {
"tryDelayMs": 250,
"interleave": 2
{
"protocol": "blackhole",
"tag": "block",
"settings": {
"response": {
"type": "http"
}
}
},
"tag": "direct"
},
{
"protocol": "blackhole",
"tag": "block",
"settings": {
"response": {
"type": "http"
}
}
}
],
"routing": {
"domainStrategy": "AsIs",
"rules": []
"domainStrategy": "AsIs",
"rules": []
},
"dns": {
"hosts": {},
"servers": []
"hosts": {},
"servers": []
}
}
}
@@ -1,123 +1,103 @@
{
"stats":{},
"stats": {},
"log": {
"loglevel": "warning"
},
"policy":{
"levels": {
"8": {
"handshake": 4,
"connIdle": 300,
"uplinkOnly": 1,
"downlinkOnly": 1
"policy": {
"levels": {
"8": {
"handshake": 4,
"connIdle": 300,
"uplinkOnly": 1,
"downlinkOnly": 1
}
},
"system": {
"statsOutboundUplink": true,
"statsOutboundDownlink": true
}
},
"inbounds": [
{
"tag": "socks",
"port": 10808,
"protocol": "socks",
"settings": {
"auth": "noauth",
"udp": true,
"userLevel": 8
},
"sniffing": {
"enabled": true,
"destOverride": [
"http",
"tls",
"quic"
]
}
},
{
"tag": "tun",
"protocol": "tun",
"settings": {
"name": "xray0",
"MTU": 1500,
"userLevel": 8
},
"sniffing": {
"enabled": true,
"destOverride": [
"http",
"tls",
"quic"
]
}
}
],
"outbounds": [
{
"tag": "proxy",
"protocol": "vmess",
"settings": {
"address": "v2ray.cool",
"port": 10086
},
"streamSettings": {
"network": "tcp"
},
"mux": {
"enabled": false
}
},
{
"protocol": "freedom",
"streamSettings": {
"sockopt": {
"domainStrategy": "UseIP",
"happyEyeballs": {
"tryDelayMs": 250,
"interleave": 2
}
}
},
"system": {
"statsOutboundUplink": true,
"statsOutboundDownlink": true
}
},
"inbounds": [{
"tag": "socks",
"port": 10808,
"protocol": "socks",
"settings": {
"auth": "noauth",
"udp": true,
"userLevel": 8
"tag": "direct"
},
"sniffing": {
"enabled": true,
"destOverride": [
"http",
"tls",
"quic"
]
}
},
{
"tag": "tun",
"protocol": "tun",
"settings": {
"name": "xray0",
"MTU": 1500,
"userLevel": 8
},
"sniffing": {
"enabled": true,
"destOverride": [
"http",
"tls",
"quic"
]
}
}
],
"outbounds": [{
"tag": "proxy",
"protocol": "vmess",
"settings": {
"vnext": [
{
"address": "v2ray.cool",
"port": 10086,
"users": [
{
"id": "a3482e88-686a-4a58-8126-99c9df64b7bf",
"alterId": 0,
"security": "auto",
"level": 8
}
]
}
],
"servers": [
{
"address": "v2ray.cool",
"method": "chacha20",
"ota": false,
"password": "123456",
"port": 10086,
"level": 8
}
]
},
"streamSettings": {
"network": "tcp"
},
"mux": {
"enabled": false
}
},
{
"protocol": "freedom",
"streamSettings": {
"sockopt": {
"domainStrategy": "UseIP",
"happyEyeballs": {
"tryDelayMs": 250,
"interleave": 2
{
"protocol": "blackhole",
"tag": "block",
"settings": {
"response": {
"type": "http"
}
}
},
"tag": "direct"
},
{
"protocol": "blackhole",
"tag": "block",
"settings": {
"response": {
"type": "http"
}
}
}
],
"routing": {
"domainStrategy": "AsIs",
"rules": []
"domainStrategy": "AsIs",
"rules": []
},
"dns": {
"hosts": {},
"servers": []
"hosts": {},
"servers": []
}
}
}
@@ -166,6 +166,7 @@ object AppConfig {
const val WIREGUARD_LOCAL_ADDRESS_V4 = "172.16.0.2/32"
const val WIREGUARD_LOCAL_ADDRESS_V6 = "2606:4700:110:8f81:d551:a0:532e:a2b3/128"
const val WIREGUARD_LOCAL_MTU = "1420"
const val WIREGUARD_LOCAL_REMOTE_DNS = "1.1.1.1,1.0.0.1,2606:4700:4700::1111,2606:4700:4700::1001"
const val LOOPBACK = "127.0.0.1"
/** Shared defaults for settings shown in the UI and consumed by config generation. */
@@ -188,6 +189,7 @@ object AppConfig {
const val MSG_STATE_RESTART = 5
const val MSG_MEASURE_DELAY = 6
const val MSG_MEASURE_DELAY_RESULT = 61
const val MSG_MEASURE_DELAY_CANCEL = 62
const val MSG_MEASURE_CONFIG_START = 7
const val MSG_MEASURE_CONFIG_CANCEL = 71
const val MSG_MEASURE_CONFIG_SUCCESS = 72
@@ -200,7 +202,6 @@ object AppConfig {
/** Notification channel IDs and names. */
// Use a new ID because Android does not let an app raise an existing channel's importance.
const val RAY_NG_CHANNEL_ID = "CORE_M_CH_ID_V2"
const val RAY_NG_CHANNEL_NAME = "Core Background Service"
/** Protocols Scheme **/
const val VMESS = "vmess://"
@@ -252,15 +252,33 @@ object CoreOutboundBuilder {
ipv4Addresses.ifEmpty { listOf(AppConfig.WIREGUARD_LOCAL_ADDRESS_V4) }
}
val rawDNS = profileItem.remoteDNS
?.split(",")
?.map { it.trim() }
?.filter { it.isNotEmpty() }
?.ifEmpty { null }
?: listOf(AppConfig.WIREGUARD_LOCAL_REMOTE_DNS)
val remotes = if (rawDNS.size == 1 && rawDNS[0] == "local") {
rawDNS
} else if (MmkvManager.decodeSettingsBool(AppConfig.PREF_IPV6_ENABLED) == true) {
rawDNS
} else {
val ipv4Dns = rawDNS.filter { !it.contains(":") }
ipv4Dns.ifEmpty { listOf(AppConfig.WIREGUARD_LOCAL_REMOTE_DNS) }
}
outboundBean?.settings?.let { wireguard ->
wireguard.secretKey = profileItem.secretKey
wireguard.address = addresses
wireguard.port = null
wireguard.peers?.firstOrNull()?.let { peer ->
peer.publicKey = profileItem.publicKey.orEmpty()
peer.preSharedKey = profileItem.preSharedKey?.nullIfBlank()
peer.endpoint = Utils.getIpv6Address(profileItem.server) + ":${profileItem.serverPort}"
}
wireguard.mtu = profileItem.mtu
wireguard.remoteDNS = remotes
wireguard.reserved = profileItem.reserved?.takeIf { it.isNotBlank() }?.split(",")?.filter { it.isNotBlank() }?.map { it.trim().toInt() }
}
@@ -31,8 +31,13 @@ import com.v2ray.ang.service.NetworkMonitor
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.Utils
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancelChildren
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import libv2ray.CoreCallbackHandler
import libv2ray.CoreController
import libv2ray.ProcessFinder
@@ -47,6 +52,7 @@ object CoreServiceManager {
private var processFinder: XrayProcessFinder? = null
private var browserDialer: IDialerService? = null
private var networkMonitor: NetworkMonitor? = null
private val connectionTestScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@Volatile
private var isReloading = false
@@ -184,6 +190,7 @@ object CoreServiceManager {
* @return True if the core was stopped successfully, false otherwise.
*/
fun stopCoreLoop(): Boolean {
connectionTestScope.coroutineContext.cancelChildren()
val service = getService() ?: return false
networkMonitor?.unregister()
@@ -254,6 +261,7 @@ object CoreServiceManager {
val tunFd = currentVpnInterface
isReloading = true
connectionTestScope.coroutineContext.cancelChildren()
LogUtil.i(AppConfig.TAG, "StartCore-Manager: Core reload start...")
coreController.stopLoop()
@@ -308,13 +316,15 @@ object CoreServiceManager {
* Tests with primary URL first, then falls back to alternative URL if needed.
* Also fetches remote IP information if the delay test was successful.
*/
private fun measureV2rayDelay() {
if (!isRunning()) {
private fun measureV2rayDelay(requestId: String) {
val service = getService() ?: return
if (!isRunning() || isReloading) {
MessageHelper.sendMsg2UI(service, AppConfig.MSG_MEASURE_DELAY_CANCEL, "", requestId)
return
}
CoroutineScope(Dispatchers.IO).launch {
val service = getService() ?: return@launch
connectionTestScope.coroutineContext.cancelChildren()
connectionTestScope.launch {
var time = -1L
var errorStr = ""
@@ -325,6 +335,7 @@ object CoreServiceManager {
errorStr = e.message?.substringAfter("\":").orEmpty()
}
if (time == -1L) {
ensureActive()
try {
time = coreController.measureDelay(SettingsManager.getDelayTestUrl(true))
} catch (e: Exception) {
@@ -333,6 +344,7 @@ object CoreServiceManager {
}
}
ensureActive()
val endpoint = if (time >= 0) SpeedtestManager.getRemoteIPInfo() else null
val result = ConnectionTestResult(
delayMillis = time,
@@ -340,7 +352,17 @@ object CoreServiceManager {
country = endpoint?.country,
ipAddress = endpoint?.ipAddress,
)
MessageHelper.sendMsg2UI(service, AppConfig.MSG_MEASURE_DELAY_RESULT, result)
withContext(Dispatchers.Main.immediate) {
if (isRunning()) {
MessageHelper.sendMsg2UI(service, AppConfig.MSG_MEASURE_DELAY_RESULT, result, requestId)
} else {
MessageHelper.sendMsg2UI(service, AppConfig.MSG_MEASURE_DELAY_CANCEL, "", requestId)
}
}
}.invokeOnCompletion { cause ->
if (cause is CancellationException) {
MessageHelper.sendMsg2UI(service, AppConfig.MSG_MEASURE_DELAY_CANCEL, "", requestId)
}
}
}
@@ -478,7 +500,8 @@ object CoreServiceManager {
}
AppConfig.MSG_MEASURE_DELAY -> {
measureV2rayDelay()
if (isOrderedBroadcast) resultCode = Activity.RESULT_OK
measureV2rayDelay(intent.getStringExtra("content").orEmpty())
}
}
@@ -0,0 +1,9 @@
package com.v2ray.ang.dto
import java.io.Serializable
/** One persisted delay-test result delivered from the task process to the UI. */
data class RealPingResult(
val guid: String,
val delayMillis: Long,
) : Serializable
@@ -98,6 +98,7 @@ data class V2rayConfig(
val peers: List<WireGuardBean>? = null,
var reserved: List<Int>? = null,
var mtu: Int? = null,
var remoteDNS: List<String>? = null,
var domainStrategy: String? = null,
) {
data class WireGuardBean(
@@ -56,6 +56,7 @@ data class ProfileItem(
var localAddress: String? = null,
var reserved: String? = null,
var mtu: Int? = null,
var remoteDNS: String? = null,
var obfsPassword: String? = null,
var portHopping: String? = null,
@@ -1,22 +1,25 @@
package com.v2ray.ang.enums
import androidx.annotation.StringRes
import com.v2ray.ang.R
/**
* Enum defining different notification channels.
* Each channel has a unique channelId, notificationId, and display name.
*/
enum class NotificationChannelType(
val channelId: String,
val channelName: String,
@StringRes val channelNameRes: Int,
val notificationId: Int
) {
SUBSCRIPTION_UPDATE(
channelId = "subscription_update_channel",
channelName = "Subscription Update Service",
channelNameRes = R.string.notification_channel_subscription_updates,
notificationId = 13
),
CORE_TEST(
channelId = "core_test_channel",
channelName = "Core Test Service",
channelNameRes = R.string.notification_channel_connection_checks,
notificationId = 12
)
}
}
@@ -7,111 +7,20 @@ import android.widget.Toast
import com.v2ray.ang.ui.compose.AppSnackbarManager
import com.v2ray.ang.ui.compose.ToastType
/**
* Shows a toast message with the given resource ID.
*
* @param message The resource ID of the message to show.
*/
fun Context.toast(message: Int) {
val text = getString(message)
dispatchMessage(text, ToastType.NORMAL) {
Toast.makeText(this, text, Toast.LENGTH_SHORT).show()
}
}
private val mainHandler by lazy { Handler(Looper.getMainLooper()) }
/**
* Shows a toast message with the given text.
*
* @param message The text of the message to show.
*/
fun Context.toast(message: CharSequence) {
dispatchMessage(message, ToastType.NORMAL) {
Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
}
}
/**
* Shows a toast message with the given resource ID.
*
* @param message The resource ID of the message to show.
*/
fun Context.toastSuccess(message: Int) {
val text = getString(message)
dispatchMessage(text, ToastType.SUCCESS) {
Toast.makeText(this, text, Toast.LENGTH_SHORT).show()
}
}
/**
* Shows a toast message with the given text.
*
* @param message The text of the message to show.
*/
fun Context.toastSuccess(message: CharSequence) {
dispatchMessage(message, ToastType.SUCCESS) {
Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
}
}
/**
* Shows a toast message with the given resource ID.
*
* @param message The resource ID of the message to show.
*/
fun Context.toastError(message: Int) {
val text = getString(message)
dispatchMessage(text, ToastType.ERROR) {
Toast.makeText(this, text, Toast.LENGTH_SHORT).show()
}
}
/**
* Shows a toast message with the given text.
*
* @param message The text of the message to show.
*/
fun Context.toastError(message: CharSequence) {
dispatchMessage(message, ToastType.ERROR) {
Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
}
}
/**
* Shows an info toast message with the given resource ID.
*
* @param message The resource ID of the message to show.
*/
fun Context.toastInfo(message: Int) {
val text = getString(message)
dispatchMessage(text, ToastType.INFO) {
Toast.makeText(this, text, Toast.LENGTH_SHORT).show()
}
}
/**
* Shows an info toast message with the given text.
*
* @param message The text of the message to show.
*/
fun Context.toastInfo(message: CharSequence) {
dispatchMessage(message, ToastType.INFO) {
Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
}
}
private inline fun runOnMain(crossinline block: () -> Unit) {
private fun runOnMain(block: () -> Unit) {
if (Looper.myLooper() == Looper.getMainLooper()) {
block()
} else {
Handler(Looper.getMainLooper()).post { block() }
mainHandler.post(block)
}
}
private inline fun dispatchMessage(
private fun Context.dispatchMessage(
message: CharSequence,
type: ToastType,
long: Boolean = false,
crossinline fallback: () -> Unit
long: Boolean = false
) {
val handledBySnackbar = AppSnackbarManager.show(
message = message,
@@ -119,6 +28,90 @@ private inline fun dispatchMessage(
long = long
)
if (!handledBySnackbar) {
runOnMain { fallback() }
val appContext = applicationContext
val duration = if (long) Toast.LENGTH_LONG else Toast.LENGTH_SHORT
runOnMain {
Toast.makeText(appContext, message, duration).show()
}
}
}
/**
* Shows a toast message with the given resource ID.
*
* @param message The resource ID of the message to show.
* @param long Whether to display the message for a longer duration.
*/
fun Context.toast(message: Int, long: Boolean = false) {
dispatchMessage(getString(message), ToastType.NORMAL, long)
}
/**
* Shows a toast message with the given text.
*
* @param message The text of the message to show.
* @param long Whether to display the message for a longer duration.
*/
fun Context.toast(message: CharSequence, long: Boolean = false) {
dispatchMessage(message, ToastType.NORMAL, long)
}
/**
* Shows a success toast message with the given resource ID.
*
* @param message The resource ID of the message to show.
* @param long Whether to display the message for a longer duration.
*/
fun Context.toastSuccess(message: Int, long: Boolean = false) {
dispatchMessage(getString(message), ToastType.SUCCESS, long)
}
/**
* Shows a success toast message with the given text.
*
* @param message The text of the message to show.
* @param long Whether to display the message for a longer duration.
*/
fun Context.toastSuccess(message: CharSequence, long: Boolean = false) {
dispatchMessage(message, ToastType.SUCCESS, long)
}
/**
* Shows an error toast message with the given resource ID.
*
* @param message The resource ID of the message to show.
* @param long Whether to display the message for a longer duration.
*/
fun Context.toastError(message: Int, long: Boolean = false) {
dispatchMessage(getString(message), ToastType.ERROR, long)
}
/**
* Shows an error toast message with the given text.
*
* @param message The text of the message to show.
* @param long Whether to display the message for a longer duration.
*/
fun Context.toastError(message: CharSequence, long: Boolean = false) {
dispatchMessage(message, ToastType.ERROR, long)
}
/**
* Shows an info toast message with the given resource ID.
*
* @param message The resource ID of the message to show.
* @param long Whether to display the message for a longer duration.
*/
fun Context.toastInfo(message: Int, long: Boolean = false) {
dispatchMessage(getString(message), ToastType.INFO, long)
}
/**
* Shows an info toast message with the given text.
*
* @param message The text of the message to show.
* @param long Whether to display the message for a longer duration.
*/
fun Context.toastInfo(message: CharSequence, long: Boolean = false) {
dispatchMessage(message, ToastType.INFO, long)
}
@@ -32,7 +32,9 @@ object WireguardFmt : FmtBase() {
config.publicKey = queryParam["publickey"].orEmpty()
config.preSharedKey = queryParam["presharedkey"]?.nullIfBlank()
config.mtu = Utils.parseInt(queryParam["mtu"] ?: AppConfig.WIREGUARD_LOCAL_MTU)
config.remoteDNS = queryParam["dns"] ?: AppConfig.WIREGUARD_LOCAL_REMOTE_DNS
config.reserved = queryParam["reserved"] ?: "0,0,0"
config.finalMask = (queryParam["fm"] ?: queryParam["finalmask"] ?: queryParam["finalMask"])?.nullIfBlank()
return config
}
@@ -81,6 +83,7 @@ object WireguardFmt : FmtBase() {
config.remarks = System.currentTimeMillis().toString()
config.localAddress = interfaceParams["address"] ?: AppConfig.WIREGUARD_LOCAL_ADDRESS_V4
config.mtu = Utils.parseInt(interfaceParams["mtu"] ?: AppConfig.WIREGUARD_LOCAL_MTU)
config.remoteDNS = interfaceParams["dns"] ?: AppConfig.WIREGUARD_LOCAL_REMOTE_DNS
config.publicKey = peerParams["publickey"].orEmpty()
config.preSharedKey = peerParams["presharedkey"]?.nullIfBlank()
val endpoint = peerParams["endpoint"].orEmpty()
@@ -93,6 +96,7 @@ object WireguardFmt : FmtBase() {
config.serverPort = ""
}
config.reserved = peerParams["reserved"] ?: "0,0,0"
config.finalMask = (peerParams["finalmask"] ?: peerParams["fm"] ?: interfaceParams["finalmask"] ?: interfaceParams["fm"])?.nullIfBlank()
return config
}
@@ -118,6 +122,10 @@ object WireguardFmt : FmtBase() {
if (config.preSharedKey != null) {
dicQuery["presharedkey"] = config.preSharedKey.removeWhiteSpace().orEmpty()
}
if (config.remoteDNS != null) {
dicQuery["dns"] = config.remoteDNS.removeWhiteSpace().orEmpty()
}
config.finalMask?.nullIfBlank()?.let { dicQuery["fm"] = it }
return toUri(config, config.secretKey, dicQuery)
}
@@ -20,6 +20,7 @@ import com.v2ray.ang.BuildConfig
import com.v2ray.ang.dto.entities.AssetUrlCache
import com.v2ray.ang.dto.entities.AssetUrlItem
import com.v2ray.ang.dto.entities.ProfileItem
import com.v2ray.ang.extension.isGroupType
import com.v2ray.ang.dto.entities.RulesetItem
import com.v2ray.ang.dto.entities.ServerAffiliationInfo
import com.v2ray.ang.dto.entities.SubscriptionCache
@@ -349,7 +350,9 @@ object MmkvManager {
val serverList = if (append) {
decodeServerList(subscriptionId)
} else {
mutableListOf()
replacedServers.filter { guid ->
decodeServerConfig(guid)?.configType?.isGroupType() == true
}.toMutableList()
}
val indexedServers = serverList.toHashSet()
profiles.keys.forEach { guid ->
@@ -373,7 +376,7 @@ object MmkvManager {
val referencedByOtherGroups = decodeServersReferencedByOtherGroups(subscriptionId)
val removablePayloads = ProfileReplacement.findRemovablePayloads(
replacedServers = replacedServers,
replacementServers = profiles.keys,
replacementServers = serverList.toSet(),
protectedServer = protectedServer,
serversReferencedByOtherGroups = referencedByOtherGroups,
)
@@ -1,7 +1,6 @@
package com.v2ray.ang.handler
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
@@ -17,6 +16,7 @@ import com.v2ray.ang.core.CoreServiceManager
import com.v2ray.ang.dto.entities.ProfileItem
import com.v2ray.ang.extension.delay
import com.v2ray.ang.extension.toSpeedString
import com.v2ray.ang.helper.NotificationHelper
import com.v2ray.ang.ui.main.MainActivity
import com.v2ray.ang.util.LogUtil
import kotlinx.coroutines.CoroutineScope
@@ -157,12 +157,17 @@ object NotificationManager {
@RequiresApi(Build.VERSION_CODES.O)
private fun createNotificationChannel(): String {
val channelId = AppConfig.RAY_NG_CHANNEL_ID
val channelName = AppConfig.RAY_NG_CHANNEL_NAME
// Foreground-service notifications must remain visible; LOW is silent but valid.
val chan = NotificationChannel(channelId, channelName, NotificationManager.IMPORTANCE_LOW)
chan.lightColor = Color.DKGRAY
chan.lockscreenVisibility = Notification.VISIBILITY_PRIVATE
getNotificationManager()?.createNotificationChannel(chan)
val service = getService() ?: return channelId
NotificationHelper.ensureNotificationChannel(
context = service,
channelId = channelId,
channelNameRes = R.string.notification_channel_service,
// Foreground-service notifications must remain visible; LOW is silent but valid.
importance = NotificationManager.IMPORTANCE_LOW,
) {
lightColor = Color.DKGRAY
lockscreenVisibility = Notification.VISIBILITY_PRIVATE
}
return channelId
}
@@ -16,7 +16,7 @@ import com.v2ray.ang.util.LogUtil
import java.io.Serializable
object MessageHelper {
const val EXTRA_REQUEST_ID = "requestId"
/**
* Sends a message to the service.
@@ -67,8 +67,8 @@ object MessageHelper {
* @param what The message identifier.
* @param content The message content.
*/
fun sendMsg2UI(ctx: Context, what: Int, content: Serializable) {
sendMsg(ctx, AppConfig.BROADCAST_ACTION_ACTIVITY, what, content)
fun sendMsg2UI(ctx: Context, what: Int, content: Serializable, requestId: String? = null) {
sendMsg(ctx, AppConfig.BROADCAST_ACTION_ACTIVITY, what, content, requestId)
}
/**
@@ -77,11 +77,12 @@ object MessageHelper {
* @param ctx The context.
* @param message The test service message containing key, subscriptionId, and serverGuids.
*/
fun sendMsg2TestService(ctx: Context, message: TestServiceMessage) {
fun sendMsg2TestService(ctx: Context, message: TestServiceMessage, requestId: String? = null) {
try {
val intent = Intent()
intent.component = ComponentName(ctx, CoreTestService::class.java)
intent.putExtra("content", message)
requestId?.let { intent.putExtra(EXTRA_REQUEST_ID, it) }
when (message.key) {
AppConfig.MSG_MEASURE_CONFIG_START -> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
@@ -102,6 +103,9 @@ object MessageHelper {
}
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to send message to test service", e)
if (message.key == AppConfig.MSG_MEASURE_CONFIG_START) {
sendMsg2UI(ctx, AppConfig.MSG_MEASURE_CONFIG_CANCEL, "", requestId)
}
}
}
@@ -146,9 +150,11 @@ object MessageHelper {
* @param what The message identifier.
* @param content The message content.
*/
private fun sendMsg(ctx: Context, action: String, what: Int, content: Serializable) {
private fun sendMsg(ctx: Context, action: String, what: Int, content: Serializable, requestId: String? = null) {
try {
ctx.sendBroadcast(messageIntent(action, what, content))
ctx.sendBroadcast(messageIntent(action, what, content).apply {
requestId?.let { putExtra(EXTRA_REQUEST_ID, it) }
})
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to send message with action: $action", e)
}
@@ -6,9 +6,11 @@ import android.app.NotificationManager
import android.app.Service
import android.content.Context
import android.os.Build
import androidx.annotation.StringRes
import androidx.core.app.NotificationCompat
import com.v2ray.ang.R
import com.v2ray.ang.enums.NotificationChannelType
import com.v2ray.ang.handler.AppLocaleManager
/**
* Unified notification helper for different notification channels.
@@ -124,20 +126,43 @@ object NotificationHelper {
return cachedNotificationManager!!
}
private fun ensureChannelCreated(channelType: NotificationChannelType, context: Context) {
private fun ensureChannelCreated(channelType: NotificationChannelType, context: Context) =
ensureNotificationChannel(
context = context,
channelId = channelType.channelId,
channelNameRes = channelType.channelNameRes,
importance = NotificationManager.IMPORTANCE_LOW,
)
/**
* Creates a channel or updates only its localized name.
*
* Android lets apps rename an existing channel, while its behavior remains under user control.
*/
internal fun ensureNotificationChannel(
context: Context,
channelId: String,
@StringRes channelNameRes: Int,
importance: Int,
configureNewChannel: NotificationChannel.() -> Unit = {},
) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
if (notificationManager.getNotificationChannel(channelType.channelId) != null) return
val channel = NotificationChannel(
channelType.channelId,
channelType.channelName,
NotificationManager.IMPORTANCE_LOW
).apply {
lockscreenVisibility = Notification.VISIBILITY_PRIVATE
val notificationManager =
context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val localizedName = AppLocaleManager.localizedContext(context).getString(channelNameRes)
val existingChannel = notificationManager.getNotificationChannel(channelId)
if (existingChannel == null) {
NotificationChannel(channelId, localizedName, importance)
.apply {
lockscreenVisibility = Notification.VISIBILITY_PRIVATE
configureNewChannel()
}
.also(notificationManager::createNotificationChannel)
} else if (existingChannel.name.toString() != localizedName) {
existingChannel.name = localizedName
notificationManager.createNotificationChannel(existingChannel)
}
notificationManager.createNotificationChannel(channel)
}
private fun buildNotificationBuilder(
@@ -6,7 +6,6 @@ import com.v2ray.ang.root.RootManager.refresh
import com.v2ray.ang.util.LogUtil
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.util.concurrent.TimeUnit
/**
* Detects whether the device grants root (`su`) access.
@@ -43,21 +42,17 @@ object RootManager {
private fun probe(): Boolean {
return try {
val process = ProcessBuilder("su", "-c", "id -u")
.redirectErrorStream(true)
.start()
val output = process.inputStream.bufferedReader().use { it.readText() }.trim()
val finished = process.waitFor(10, TimeUnit.SECONDS)
if (!finished) {
process.destroy()
val result = RootProcessRunner.run(listOf("su", "-c", "id -u"), 10000, 1024)
if (result.code == -1) {
LogUtil.w(AppConfig.TAG, "RootManager: su probe timed out")
return false
}
val isRoot = process.exitValue() == 0 && output.lineSequence().lastOrNull()?.trim() == "0"
val isRoot = result.code == 0 && result.output.lineSequence().filter { it.isNotBlank() }.lastOrNull()?.trim() == "0"
LogUtil.i(AppConfig.TAG, "RootManager: root available = $isRoot")
isRoot
} catch (e: Exception) {
LogUtil.w(AppConfig.TAG, "RootManager: no root access (${e.message})")
if (e is InterruptedException) Thread.currentThread().interrupt()
LogUtil.w(AppConfig.TAG, "RootManager: root probe failed", e)
false
}
}
@@ -0,0 +1,41 @@
package com.v2ray.ang.root
import java.io.ByteArrayOutputStream
import java.util.concurrent.TimeUnit
/** Owns one bounded root command. Neither inherited stdout nor output volume extends its deadline. */
internal object RootProcessRunner {
data class Outcome(val code: Int, val output: String)
fun run(command: List<String>, timeoutMillis: Long, outputLimit: Int = 65536): Outcome {
val process = ProcessBuilder(command).redirectErrorStream(true).start()
val captured = ByteArrayOutputStream()
val buffer = ByteArray(4096)
val deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(timeoutMillis)
var code = -1
try {
// Process.waitFor(timeout, unit) is API 26. Polling supports API 24–25 too.
// Read only available bytes: a detached helper may retain stdout after su exits.
while (System.nanoTime() < deadline) {
val available = process.inputStream.available()
if (available > 0) {
val size = process.inputStream.read(buffer, 0, minOf(buffer.size, available))
val remaining = outputLimit - captured.size()
if (size > 0 && remaining > 0) captured.write(buffer, 0, minOf(size, remaining))
}
try {
code = process.exitValue()
if (process.inputStream.available() == 0) break
} catch (_: IllegalThreadStateException) {
if (available == 0) Thread.sleep(10)
}
}
} finally {
if (code == -1) process.destroy()
process.inputStream.close()
process.outputStream.close()
process.errorStream.close()
}
return Outcome(code, captured.toString("UTF-8"))
}
}
@@ -4,7 +4,6 @@ import android.content.Context
import com.v2ray.ang.AppConfig
import com.v2ray.ang.util.LogUtil
import java.io.File
import java.util.concurrent.TimeUnit
/**
* Minimal root command runner backed by the `su` binary.
@@ -24,32 +23,29 @@ object RootShell {
val dir = File(context.filesDir, AppConfig.ROOT_RUNTIME_DIR).apply { mkdirs() }
val file = File(dir, name).apply {
writeText(script)
setExecutable(true, false)
setReadable(false, false)
setReadable(true, true)
setWritable(false, false)
setWritable(true, true)
}
val safePath = file.absolutePath.replace("'", "'\\''")
return exec("sh '$safePath'")
// Android toybox timeout owns the root shell, so killing the su client cannot leave
// a timed-out setup shell installing rules after rollback has already started.
return exec("timeout -s TERM -k 2 25 sh '$safePath'")
}
private fun exec(command: String, timeoutSeconds: Long = 30): Result {
return try {
val process = ProcessBuilder("su", "-c", command)
.redirectErrorStream(true)
.start()
val output = process.inputStream.bufferedReader().use { it.readText() }
val finished = process.waitFor(timeoutSeconds, TimeUnit.SECONDS)
if (!finished) {
process.destroy()
LogUtil.e(AppConfig.TAG, "RootShell: timed out: $command")
return Result(-1, output)
}
val result = Result(process.exitValue(), output)
val outcome = RootProcessRunner.run(listOf("su", "-c", command), timeoutSeconds * 1000)
val result = Result(outcome.code, outcome.output)
if (!result.success) {
LogUtil.w(AppConfig.TAG, "RootShell: '$command' exited ${result.code}: ${output.trim()}")
LogUtil.w(AppConfig.TAG, "RootShell: root command exited ${result.code}: ${result.output.trim()}")
}
result
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "RootShell: failed to run '$command'", e)
Result(-1, e.message ?: e.javaClass.simpleName)
if (e is InterruptedException) Thread.currentThread().interrupt()
LogUtil.e(AppConfig.TAG, "RootShell: root command failed", e)
Result(-1, e.javaClass.simpleName)
}
}
}
@@ -10,6 +10,7 @@ import com.v2ray.ang.AppConfig
import com.v2ray.ang.R
import com.v2ray.ang.core.CoreNativeManager
import com.v2ray.ang.dto.RealPingEvent
import com.v2ray.ang.dto.RealPingResult
import com.v2ray.ang.dto.TestServiceMessage
import com.v2ray.ang.enums.NotificationChannelType
import com.v2ray.ang.extension.serializable
@@ -19,7 +20,7 @@ import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.helper.MessageHelper
import com.v2ray.ang.helper.NotificationHelper
import com.v2ray.ang.util.LogUtil
import java.util.Collections
import java.util.concurrent.ConcurrentHashMap
class CoreTestService : Service() {
@@ -28,7 +29,7 @@ class CoreTestService : Service() {
}
// manage active batch workers so each batch is independent and cancellable
private val activeWorkers = Collections.synchronizedList(mutableListOf<RealPingWorkerService>())
private val activeWorkers = ConcurrentHashMap<RealPingWorkerService, String>()
private val cancelAction by lazy {
val intent = Intent(this, CoreTestService::class.java).putExtra(
"content",
@@ -69,10 +70,7 @@ class CoreTestService : Service() {
*/
override fun onDestroy() {
LogUtil.i(AppConfig.TAG, "CoreTestService is being destroyed, cancelling ${activeWorkers.size} active workers")
// cancel any active workers
val snapshot = ArrayList(activeWorkers)
snapshot.forEach { it.cancel() }
activeWorkers.clear()
cancelWorkers()
NotificationHelper.stopForeground(this)
super.onDestroy()
}
@@ -99,7 +97,9 @@ class CoreTestService : Service() {
}
when (message.key) {
AppConfig.MSG_MEASURE_CONFIG_START -> handleMeasureStart(message, startId)
AppConfig.MSG_MEASURE_CONFIG_START -> handleMeasureStart(
message, startId, intent.getStringExtra(MessageHelper.EXTRA_REQUEST_ID).orEmpty()
)
AppConfig.MSG_MEASURE_CONFIG_CANCEL -> handleMeasureCancel()
else -> {
NotificationHelper.stopForeground(this); stopSelf(startId)
@@ -108,7 +108,7 @@ class CoreTestService : Service() {
return START_NOT_STICKY
}
private fun handleMeasureStart(message: TestServiceMessage, startId: Int) {
private fun handleMeasureStart(message: TestServiceMessage, startId: Int, requestId: String) {
LogUtil.i(AppConfig.TAG, "CoreTestService starting worker subscription ${message.subscriptionId}")
val guidsList = when {
@@ -123,17 +123,22 @@ class CoreTestService : Service() {
context = this,
guids = guidsList,
onlyTcp = message.onlyTcp,
onEvent = { event -> handleWorkerEvent(event, message) { activeWorkers.remove(worker) } }
onEvent = { event ->
if (activeWorkers.containsKey(worker)) {
handleWorkerEvent(event, message, requestId) { activeWorkers.remove(worker) }
}
}
)
activeWorkers.add(worker)
activeWorkers[worker] = requestId
worker.start()
} else {
MessageHelper.sendMsg2UI(this, AppConfig.MSG_MEASURE_CONFIG_CANCEL, "", requestId)
NotificationHelper.stopForeground(this)
stopSelf(startId)
}
}
private fun handleWorkerEvent(event: RealPingEvent, message: TestServiceMessage, onWorkerDone: () -> Unit) {
private fun handleWorkerEvent(event: RealPingEvent, message: TestServiceMessage, requestId: String, onWorkerDone: () -> Unit) {
when (event) {
is RealPingEvent.Progress -> {
NotificationHelper.updateNotification(
@@ -142,12 +147,17 @@ class CoreTestService : Service() {
title = getString(R.string.app_name),
content = getString(R.string.connection_running_task_left, event.text)
)
MessageHelper.sendMsg2UI(this, AppConfig.MSG_MEASURE_CONFIG_NOTIFY, event.text)
MessageHelper.sendMsg2UI(this, AppConfig.MSG_MEASURE_CONFIG_NOTIFY, event.text, requestId)
}
is RealPingEvent.Result -> {
MmkvManager.encodeServerTestDelayMillis(event.guid, event.delayMillis)
MessageHelper.sendMsg2UI(this, AppConfig.MSG_MEASURE_CONFIG_SUCCESS, event.guid)
MessageHelper.sendMsg2UI(
this,
AppConfig.MSG_MEASURE_CONFIG_SUCCESS,
RealPingResult(event.guid, event.delayMillis),
requestId,
)
}
is RealPingEvent.Finish -> {
@@ -161,7 +171,7 @@ class CoreTestService : Service() {
}
}
MessageHelper.sendMsg2UI(this, AppConfig.MSG_MEASURE_CONFIG_FINISH, event.status)
MessageHelper.sendMsg2UI(this, AppConfig.MSG_MEASURE_CONFIG_FINISH, event.status, requestId)
onWorkerDone()
if (activeWorkers.isEmpty()) {
NotificationHelper.stopForeground(this)
@@ -172,12 +182,19 @@ class CoreTestService : Service() {
}
private fun handleMeasureCancel() {
MessageHelper.sendMsg2UI(this, AppConfig.MSG_MEASURE_CONFIG_FINISH, "0")
LogUtil.i(AppConfig.TAG, "CoreTestService received cancel message, cancelling ${activeWorkers.size} active workers")
val snapshot = ArrayList(activeWorkers)
snapshot.forEach { it.cancel() }
activeWorkers.clear()
cancelWorkers()
NotificationHelper.stopForeground(this)
stopSelf()
}
private fun cancelWorkers() {
// Reply for each cancelled request, never for a newer UI request that has not started here.
activeWorkers.entries.toList().forEach { (worker, requestId) ->
if (activeWorkers.remove(worker, requestId)) {
worker.cancel()
MessageHelper.sendMsg2UI(this, AppConfig.MSG_MEASURE_CONFIG_CANCEL, "", requestId)
}
}
}
}
@@ -8,18 +8,21 @@ import android.webkit.WebSettings
import android.webkit.WebView
import android.webkit.WebViewClient
import com.v2ray.ang.contracts.IDialerService
import java.util.concurrent.ExecutionException
import java.util.concurrent.FutureTask
import java.util.concurrent.atomic.AtomicLong
class DialerWebviewService : IDialerService {
private var webView: WebView? = null
private val handler = Handler(Looper.getMainLooper())
private val lifecycleVersion = AtomicLong()
private val keepAliveInterval = 30_000L // 30 seconds
private val keepAliveRunnable = object : Runnable {
override fun run() {
webView?.let {
it.resumeTimers()
it.onResume()
}
val view = webView ?: return
view.resumeTimers()
view.onResume()
handler.postDelayed(this, keepAliveInterval)
}
}
@@ -30,45 +33,88 @@ class DialerWebviewService : IDialerService {
* @param context Service context
*/
@SuppressLint("SetJavaScriptEnabled")
override fun start(context: Context, dialerAddr: String) {
if (webView != null) stop()
if (dialerAddr.isEmpty()) return
override fun start(context: Context, dialerAddr: String) = updateOnMainThread {
stopOnMainThread()
if (dialerAddr.isEmpty()) return@updateOnMainThread
val dialerUrl = "http://$dialerAddr/"
webView = WebView(context.applicationContext).apply {
settings.apply {
javaScriptEnabled = true
domStorageEnabled = true
// Allow JS to run even if not triggered by user
mediaPlaybackRequiresUserGesture = false
// Prevent aggressive caching issues
cacheMode = WebSettings.LOAD_DEFAULT
}
webViewClient = object : WebViewClient() {
override fun onPageFinished(view: WebView?, url: String?) {
super.onPageFinished(view, url)
view?.onResume()
view?.resumeTimers()
val view = WebView(context.applicationContext)
webView = view
try {
view.apply {
settings.apply {
javaScriptEnabled = true
domStorageEnabled = true
// Allow JS to run even if not triggered by user
mediaPlaybackRequiresUserGesture = false
// Prevent aggressive caching issues
cacheMode = WebSettings.LOAD_DEFAULT
}
webViewClient = object : WebViewClient() {
override fun onPageFinished(view: WebView?, url: String?) {
if (view === webView) {
view?.onResume()
view?.resumeTimers()
}
}
}
loadUrl(dialerUrl)
}
loadUrl(dialerUrl)
handler.post(keepAliveRunnable)
} catch (e: Exception) {
try {
stopOnMainThread()
} catch (cleanupError: Exception) {
e.addSuppressed(cleanupError)
}
throw e
}
handler.post(keepAliveRunnable)
}
override fun stop() {
override fun stop() = updateOnMainThread { stopOnMainThread() }
private fun stopOnMainThread() {
handler.removeCallbacks(keepAliveRunnable)
webView?.apply {
stopLoading()
pauseTimers()
// Important to call onPause to stop internal Chromium threads properly
onPause()
destroy()
}
val view = webView ?: return
webView = null
view.apply {
try {
stopLoading()
pauseTimers()
// Important to call onPause to stop internal Chromium threads properly
onPause()
} finally {
destroy()
}
}
}
private fun updateOnMainThread(action: () -> Unit) {
val version = lifecycleVersion.incrementAndGet()
val update = {
// A stop or replacement can overtake a worker's queued start on the main thread.
if (lifecycleVersion.get() == version) action()
}
if (Looper.myLooper() == handler.looper) {
update()
return
}
// WebView requires its creating looper on all Android versions. Keep the dialer's
// synchronous contract so reload waits for cleanup/setup and receives any failure.
val task = FutureTask(update)
check(handler.post(task)) { "WebView looper is shutting down" }
try {
task.get()
} catch (e: ExecutionException) {
throw e.cause ?: e
} catch (e: InterruptedException) {
task.cancel(false)
Thread.currentThread().interrupt()
throw e
}
}
}
@@ -32,7 +32,6 @@ class CheckUpdateViewModel(application: Application) : BaseViewModel(application
fun checkForUpdates() {
launchLoading {
toast(R.string.update_checking_for_update)
try {
val result = UpdateCheckerManager.checkForUpdate(_checkPreRelease.value)
if (result.hasUpdate) {
@@ -55,8 +55,8 @@ import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import coil.request.ImageRequest
import coil3.compose.AsyncImage
import coil3.request.ImageRequest
import com.v2ray.ang.R
import com.v2ray.ang.util.AppIconFetcher
import sh.calvin.reorderable.ReorderableCollectionItemScope
@@ -37,6 +37,7 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.v2ray.ang.R
@@ -47,6 +48,7 @@ fun ConfirmDialog(
confirmText: String = stringResource(R.string.action_ok),
dismissText: String? = stringResource(R.string.action_cancel),
confirmIcon: @Composable (() -> Unit)? = null,
messageTextAlign: TextAlign = TextAlign.Start,
onConfirm: () -> Unit,
onDismiss: () -> Unit
) {
@@ -61,7 +63,14 @@ fun ConfirmDialog(
AlertDialog(
onDismissRequest = onDismiss,
title = title?.let { { Text(it) } },
text = { Text(message, style = MaterialTheme.typography.bodyMedium) },
text = {
Text(
text = message,
modifier = Modifier.fillMaxWidth(),
style = MaterialTheme.typography.bodyMedium,
textAlign = messageTextAlign
)
},
confirmButton = {
TextButton(
onClick = { onConfirm(); onDismiss() },
@@ -91,9 +100,11 @@ fun DeleteConfirmDialog(
message: String,
onConfirm: () -> Unit,
onDismiss: () -> Unit,
itemName: String? = null,
) {
ConfirmDialog(
message = message,
message = if (itemName.isNullOrBlank()) message else "$message\n\n$itemName",
messageTextAlign = TextAlign.Center,
confirmText = stringResource(R.string.action_delete),
confirmIcon = {
Icon(
@@ -38,6 +38,8 @@ fun FormTextField(
keyboardType: KeyboardType = KeyboardType.Text,
placeholder: String? = null,
maxLines: Int = 5,
isError: Boolean = false,
supportingText: String? = null,
) {
Box(
modifier = modifier
@@ -49,6 +51,8 @@ fun FormTextField(
onValueChange = onValueChange,
label = { Text(label) },
placeholder = placeholder?.let { { Text(it) } },
isError = isError,
supportingText = supportingText?.let { { Text(it) } },
singleLine = false,
maxLines = maxLines,
enabled = enabled,
@@ -111,12 +111,12 @@ val colorFabInactiveDark = Color(0xFF646464) // Dark Gray
val dividerColorLight = Color(0xFFE0E0E0) // Light Gray
val dividerColorDark = Color(0xFF424242) // Dark Gray
// Toast Colors 70%
val toastNormalBgLight = Color(0xB3353A3E) // Dark Gray
val toastNormalBgDark = Color(0xB34A4F54) // Darker Gray
val toastSuccessBg = Color(0xB3388E3C) // Green
val toastErrorBg = Color(0xB3D50000) // Red
val toastInfoBg = Color(0xB33F51B5) // Indigo Blue
// Toast Colors 85%
val toastNormalBgLight = Color(0xD9353A3E) // Dark Gray
val toastNormalBgDark = Color(0xD94A4F54) // Darker Gray
val toastSuccessBg = Color(0xD9388E3C) // Green
val toastErrorBg = Color(0xD9D50000) // Red
val toastInfoBg = Color(0xD93F51B5) // Indigo Blue
val toastIconCircleBg = Color(0x33FFFFFF) // Semi-transparent White
val toastTextColor = Color.White // White
@@ -186,8 +186,7 @@ class MainActivity : HelperBaseComponentActivity() {
toast(R.string.title_file_chooser)
return
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.CINNAMON_BUN &&
MmkvManager.decodeSettingsBool(AppConfig.PREF_PROXY_SHARING)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.CINNAMON_BUN
) {
checkAndRequestPermission(PermissionType.ACCESS_LOCAL_NETWORK) {}
}
@@ -1,5 +1,7 @@
package com.v2ray.ang.ui.main
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
@@ -20,9 +22,13 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
@@ -33,6 +39,7 @@ import com.v2ray.ang.ui.compose.AppDivider
import com.v2ray.ang.ui.compose.colorFabActive
import com.v2ray.ang.ui.compose.colorFabInactiveDark
import com.v2ray.ang.ui.compose.colorFabInactiveLight
import kotlinx.coroutines.launch
@Composable
fun MainBottomBar(
@@ -41,6 +48,15 @@ fun MainBottomBar(
isDarkTheme: Boolean,
onAction: (MainAction) -> Unit
) {
val scope = rememberCoroutineScope()
val rotationAnim = remember { Animatable(0f) }
LaunchedEffect(isRunning) {
if (!isRunning) {
rotationAnim.snapTo(0f)
}
}
Box(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier
@@ -68,7 +84,17 @@ fun MainBottomBar(
}
}
FloatingActionButton(
onClick = { onAction(MainAction.ToggleService) },
onClick = {
if (!isRunning) {
scope.launch {
rotationAnim.animateTo(
targetValue = 720f,
animationSpec = tween(durationMillis = 3000)
)
}
}
onAction(MainAction.ToggleService)
},
modifier = Modifier
.align(Alignment.TopEnd)
.padding(end = 24.dp)
@@ -85,7 +111,9 @@ fun MainBottomBar(
if (isRunning) R.string.acc_stop else R.string.acc_start
),
tint = Color.White,
modifier = Modifier.size(24.dp)
modifier = Modifier
.size(24.dp)
.graphicsLayer { rotationZ = rotationAnim.value }
)
}
}
@@ -57,9 +57,9 @@ interface MainDataSource : Closeable {
fun share2Clipboard(guid: String): Boolean
fun sendMsg2Service(msgId: Int, content: String)
fun sendMsg2TestService(msg: TestServiceMessage)
fun sendMsg2TestService(msg: TestServiceMessage, requestId: String? = null)
fun cancelAllPing()
fun testCurrentServerRealPing()
fun testCurrentServerRealPing(requestId: String)
fun syncSubscriptions()
fun initAssets()
@@ -1,10 +1,23 @@
package com.v2ray.ang.ui.main
import androidx.compose.runtime.Composable
import androidx.compose.runtime.saveable.listSaver
import androidx.compose.ui.res.stringResource
import com.v2ray.ang.R
import com.v2ray.ang.ui.compose.DeleteConfirmDialog
data class ServerDeleteTarget(
val guid: String,
val profileName: String,
) {
companion object {
val Saver = listSaver<ServerDeleteTarget?, String>(
save = { target -> target?.let { listOf(it.guid, it.profileName) }.orEmpty() },
restore = { ServerDeleteTarget(it[0], it[1]) },
)
}
}
@Composable
fun MainDialogs(
showDelAllConfirm: Boolean,
@@ -16,7 +29,7 @@ fun MainDialogs(
showDelInvalidConfirm: Boolean,
onDismissDelInvalid: () -> Unit,
onConfirmDelInvalid: () -> Unit,
showRemoveConfirm: String?,
showRemoveConfirm: ServerDeleteTarget?,
onDismissRemove: () -> Unit,
onConfirmRemove: (String) -> Unit,
) {
@@ -41,11 +54,11 @@ fun MainDialogs(
onDismiss = onDismissDelInvalid
)
}
if (showRemoveConfirm != null) {
val guid = showRemoveConfirm
showRemoveConfirm?.let { target ->
DeleteConfirmDialog(
message = stringResource(R.string.confirm_delete_profile),
onConfirm = { onConfirmRemove(guid) },
itemName = target.profileName,
onConfirm = { onConfirmRemove(target.guid) },
onDismiss = onDismissRemove
)
}
@@ -79,7 +79,7 @@ fun ShareMethodDialog(
more: Boolean,
onDismiss: () -> Unit,
onAction: (MainAction) -> Unit,
onRemove: (String) -> Unit,
onRemove: (String, String) -> Unit,
) {
val menuActions = serverMenuActions(
isComplexProfile = profile.configType.isComplexType(),
@@ -95,7 +95,7 @@ fun ShareMethodDialog(
ServerMenuAction.ShareClipboard -> onAction(MainAction.ShareClipboard(guid))
ServerMenuAction.ShareFullContent -> onAction(MainAction.ShareFullContent(guid))
ServerMenuAction.Edit -> onAction(MainAction.EditServer(guid, profile))
ServerMenuAction.Delete -> onRemove(guid)
ServerMenuAction.Delete -> onRemove(guid, profile.remarks)
}
},
onDismiss = onDismiss
@@ -9,6 +9,7 @@ import com.v2ray.ang.AngApplication
import com.v2ray.ang.AppConfig
import com.v2ray.ang.R
import com.v2ray.ang.dto.ConnectionTestResult
import com.v2ray.ang.dto.RealPingResult
import com.v2ray.ang.dto.SubscriptionUpdateResult
import com.v2ray.ang.dto.TestServiceMessage
import com.v2ray.ang.dto.entities.ProfileItem
@@ -24,10 +25,9 @@ import com.v2ray.ang.handler.SubscriptionUpdater
import com.v2ray.ang.helper.MessageHelper
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.Utils
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.receiveAsFlow
import java.util.concurrent.atomic.AtomicBoolean
class MainRepository(
@@ -39,40 +39,44 @@ class MainRepository(
private val closed = AtomicBoolean(false)
private val _mainServiceEvent = MutableSharedFlow<MainServiceEvent>(
replay = 0,
extraBufferCapacity = 64,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
// Probe results are finite and must remain lossless until the ViewModel coalesces them.
private val mainServiceEventChannel = Channel<MainServiceEvent>(Channel.UNLIMITED)
override val mainServiceEvent: SharedFlow<MainServiceEvent> = _mainServiceEvent.asSharedFlow()
override val mainServiceEvent: Flow<MainServiceEvent> = mainServiceEventChannel.receiveAsFlow()
private val serviceReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
val safeIntent = intent ?: return
val requestId = safeIntent.getStringExtra(MessageHelper.EXTRA_REQUEST_ID).orEmpty()
val event = when (safeIntent.getIntExtra("key", 0)) {
AppConfig.MSG_STATE_RUNNING -> MainServiceEvent.StateRunning
AppConfig.MSG_STATE_NOT_RUNNING -> MainServiceEvent.StateNotRunning
AppConfig.MSG_STATE_START_SUCCESS -> MainServiceEvent.StateStartSuccess
AppConfig.MSG_STATE_START_FAILURE -> MainServiceEvent.StateStartFailure
AppConfig.MSG_STATE_START_FAILURE -> MainServiceEvent.StateStartFailure(
safeIntent.getStringExtra("content")
)
AppConfig.MSG_STATE_STOP_SUCCESS -> MainServiceEvent.StateStopSuccess
AppConfig.MSG_MEASURE_DELAY_RESULT -> safeIntent
.serializable<ConnectionTestResult>("content")
?.let { MainServiceEvent.MeasureDelayResult(it) }
?.let { MainServiceEvent.MeasureDelayResult(it, requestId) }
AppConfig.MSG_MEASURE_DELAY_CANCEL -> MainServiceEvent.MeasureDelayCancelled(requestId)
AppConfig.MSG_MEASURE_CONFIG_SUCCESS -> MainServiceEvent.MeasureConfigSuccess
AppConfig.MSG_MEASURE_CONFIG_SUCCESS -> safeIntent
.serializable<RealPingResult>("content")
?.let { MainServiceEvent.MeasureConfigSuccess(it, requestId) }
AppConfig.MSG_MEASURE_CONFIG_NOTIFY -> MainServiceEvent.MeasureConfigNotify(
safeIntent.getStringExtra("content").orEmpty()
safeIntent.getStringExtra("content").orEmpty(), requestId
)
AppConfig.MSG_MEASURE_CONFIG_FINISH -> MainServiceEvent.MeasureConfigFinish(
safeIntent.getStringExtra("content")
requestId
)
AppConfig.MSG_MEASURE_CONFIG_CANCEL -> MainServiceEvent.MeasureConfigCancelled(requestId)
else -> null
}
event?.let { _mainServiceEvent.tryEmit(it) }
event?.let { mainServiceEventChannel.trySend(it) }
}
}
@@ -98,6 +102,7 @@ class MainRepository(
}.onFailure {
LogUtil.e(AppConfig.TAG, "Failed to unregister main service receiver", it)
}
mainServiceEventChannel.close()
}
override fun getSelectedSubscriptionId(): String =
@@ -205,8 +210,8 @@ class MainRepository(
override fun sendMsg2Service(msgId: Int, content: String) =
MessageHelper.sendMsg2Service(app, msgId, content)
override fun sendMsg2TestService(msg: TestServiceMessage) =
MessageHelper.sendMsg2TestService(app, msg)
override fun sendMsg2TestService(msg: TestServiceMessage, requestId: String?) =
MessageHelper.sendMsg2TestService(app, msg, requestId)
override fun cancelAllPing() {
sendMsg2TestService(
@@ -214,8 +219,10 @@ class MainRepository(
)
}
override fun testCurrentServerRealPing() {
sendMsg2Service(AppConfig.MSG_MEASURE_DELAY, "")
override fun testCurrentServerRealPing(requestId: String) {
MessageHelper.sendMsg2ServiceForResult(app, AppConfig.MSG_MEASURE_DELAY, requestId) { handled ->
if (!handled) mainServiceEventChannel.trySend(MainServiceEvent.MeasureDelayCancelled(requestId))
}
}
override fun syncSubscriptions() {
@@ -21,6 +21,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier
@@ -57,11 +58,17 @@ fun MainScreen(
var showDelAllConfirm by remember { mutableStateOf(false) }
var showDelDuplicateConfirm by remember { mutableStateOf(false) }
var showDelInvalidConfirm by remember { mutableStateOf(false) }
var showRemoveConfirm by remember { mutableStateOf<String?>(null) }
var showRemoveConfirm by rememberSaveable(stateSaver = ServerDeleteTarget.Saver) {
mutableStateOf<ServerDeleteTarget?>(null)
}
var shareTarget by remember { mutableStateOf<Triple<String, ProfileItem, Boolean>?>(null) }
val removeServer: (String) -> Unit = { guid ->
if (confirmRemove) showRemoveConfirm = guid else onAction(MainAction.RemoveServer(guid))
val removeServer: (String, String) -> Unit = { guid, profileName ->
if (confirmRemove) {
showRemoveConfirm = ServerDeleteTarget(guid, profileName)
} else {
onAction(MainAction.RemoveServer(guid))
}
}
val pagerState = rememberPagerState(
@@ -75,7 +75,7 @@ fun GroupPagerPage(
onEditServer: (String, ProfileItem) -> Unit,
onShareServer: (String, ProfileItem) -> Unit,
onMoreServer: (String, ProfileItem) -> Unit,
onRemoveServer: (String) -> Unit,
onRemoveServer: (String, String) -> Unit,
contentPadding: PaddingValues
) {
val groupStateFlow = remember(groupId) {
@@ -121,7 +121,7 @@ private class ServerRowActions(
val edit: (String, ProfileItem) -> Unit,
val share: (String, ProfileItem) -> Unit,
val more: (String, ProfileItem) -> Unit,
val remove: (String) -> Unit,
val remove: (String, String) -> Unit,
)
@Composable
@@ -374,7 +374,7 @@ private fun ServerListItem(
Modifier.size(24.dp)
)
}
IconButton(onClick = { actions.remove(row.guid) }, Modifier.size(36.dp)) {
IconButton(onClick = { actions.remove(row.guid, row.remarks) }, Modifier.size(36.dp)) {
Icon(
painterResource(R.drawable.ic_delete_24dp),
stringResource(R.string.acc_delete),
@@ -1,15 +1,18 @@
package com.v2ray.ang.ui.main
import com.v2ray.ang.dto.ConnectionTestResult
import com.v2ray.ang.dto.RealPingResult
sealed class MainServiceEvent {
data object StateRunning : MainServiceEvent()
data object StateNotRunning : MainServiceEvent()
data object StateStartSuccess : MainServiceEvent()
data object StateStartFailure : MainServiceEvent()
data class StateStartFailure(val message: String? = null) : MainServiceEvent()
data object StateStopSuccess : MainServiceEvent()
data class MeasureDelayResult(val result: ConnectionTestResult) : MainServiceEvent()
data object MeasureConfigSuccess : MainServiceEvent()
data class MeasureConfigNotify(val progress: String) : MainServiceEvent()
data class MeasureConfigFinish(val finishedCount: String?) : MainServiceEvent()
data class MeasureDelayResult(val result: ConnectionTestResult, val requestId: String) : MainServiceEvent()
data class MeasureDelayCancelled(val requestId: String) : MainServiceEvent()
data class MeasureConfigSuccess(val result: RealPingResult, val requestId: String) : MainServiceEvent()
data class MeasureConfigNotify(val progress: String, val requestId: String) : MainServiceEvent()
data class MeasureConfigFinish(val requestId: String) : MainServiceEvent()
data class MeasureConfigCancelled(val requestId: String) : MainServiceEvent()
}
@@ -0,0 +1,37 @@
package com.v2ray.ang.ui.main
import java.util.UUID
/** Main-thread request ownership; a late reply must not complete a newer test. */
internal class MainTestRequests {
data class Bulk(val id: String, val groupId: String)
private var current: String? = null
var bulk: Bulk? = null
private set
val isTesting: Boolean get() = current != null || bulk != null
fun beginCurrent(): String = UUID.randomUUID().toString().also { current = it }
fun completeCurrent(id: String): Boolean {
if (id != current) return false
current = null
return true
}
fun invalidateCurrent() {
current = null
}
fun beginBulk(groupId: String): Bulk = Bulk(UUID.randomUUID().toString(), groupId).also { bulk = it }
fun completeBulk(id: String): Bulk? {
val request = bulk?.takeIf { it.id == id } ?: return null
bulk = null
return request
}
fun cancelBulk() {
bulk = null
}
}
@@ -9,6 +9,7 @@ import com.v2ray.ang.R
import com.v2ray.ang.dto.ConnectionTestResult
import com.v2ray.ang.dto.GroupMapItem
import com.v2ray.ang.dto.LocateTarget
import com.v2ray.ang.dto.RealPingResult
import com.v2ray.ang.dto.TestServiceMessage
import com.v2ray.ang.dto.entities.ProfileItem
import com.v2ray.ang.dto.entities.ServersCache
@@ -24,6 +25,7 @@ import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.MutableStateFlow
@@ -40,6 +42,30 @@ import kotlinx.coroutines.withContext
import java.util.concurrent.ConcurrentHashMap
import java.util.regex.PatternSyntaxException
private fun applyTestDelayResults(
servers: List<ServersCache>,
updates: Map<String, Long>,
): List<ServersCache> = servers.map { server ->
val delayMillis = updates[server.guid]
if (delayMillis == null || delayMillis == server.testDelayMillis) {
server
} else {
server.copy(testDelayMillis = delayMillis)
}
}
private fun applyTestDelayResultsToRows(
rows: List<ServerRowUiModel>,
updates: Map<String, Long>,
): List<ServerRowUiModel> = rows.map { row ->
val delayMillis = updates[row.guid]
if (delayMillis == null || delayMillis == row.testDelayMillis) {
row
} else {
row.copy(testDelayMillis = delayMillis)
}
}
class MainViewModel(
application: Application,
private val dataSource: MainDataSource
@@ -77,9 +103,11 @@ class MainViewModel(
private var preloadJob: Job? = null
private var selectedGroupLoadJob: Job? = null
private var reloadJob: Job? = null
private var testResultFlushJob: Job? = null
private val pendingTestResults = linkedMapOf<String, Long>()
@Volatile
private var testingGroupId: String? = null
private val testRequests = MainTestRequests()
private var bulkTestJob: Job? = null
private val initialPageReady = CompletableDeferred<Unit>()
@@ -106,31 +134,87 @@ class MainViewModel(
updateRunningState(true)
}
MainServiceEvent.StateStartFailure -> {
toastError(R.string.toast_services_failure)
is MainServiceEvent.StateStartFailure -> {
if (!event.message.isNullOrBlank()) {
toastError(event.message)
} else {
toastError(R.string.toast_services_failure)
}
updateRunningState(false)
}
MainServiceEvent.StateStopSuccess -> updateRunningState(false)
is MainServiceEvent.MeasureDelayResult -> {
_uiState.update { it.copy(status = MainStatus.ConnectionTest(event.result)) }
if (!uiState.value.isRunning || !testRequests.completeCurrent(event.requestId)) return
_uiState.update { it.copy(isTesting = testRequests.isTesting, status = MainStatus.ConnectionTest(event.result)) }
}
MainServiceEvent.MeasureConfigSuccess -> {
viewModelScope.launch(ioDispatcher) {
val gid = testingGroupId ?: uiState.value.selectedGroupId
cacheMutex.withLock { groupDataCache.remove(gid) }
updateGroupUi(gid, loadGroup(gid, forceRefresh = true))
}
is MainServiceEvent.MeasureConfigSuccess -> {
val request = testRequests.bulk?.takeIf { it.id == event.requestId } ?: return
queueTestResult(event.result, request)
}
is MainServiceEvent.MeasureConfigNotify -> {
_uiState.update { it.copy(status = MainStatus.TestProgress(event.progress)) }
if (event.requestId == testRequests.bulk?.id) {
_uiState.update { it.copy(status = MainStatus.TestProgress(event.progress)) }
}
}
is MainServiceEvent.MeasureConfigFinish -> {
onTestsFinished()
val request = testRequests.bulk?.takeIf { it.id == event.requestId } ?: return
val scheduledFlush = testResultFlushJob
testResultFlushJob = viewModelScope.launch {
scheduledFlush?.cancelAndJoin()
if (testRequests.bulk?.id != request.id) return@launch
flushPendingTestResults(request)
onTestsFinished(request.id)
}
}
is MainServiceEvent.MeasureDelayCancelled -> {
if (testRequests.completeCurrent(event.requestId)) resetTestStatus()
}
is MainServiceEvent.MeasureConfigCancelled -> {
if (testRequests.completeBulk(event.requestId) != null) {
cancelPendingTestResults()
resetTestStatus()
}
}
}
}
private fun queueTestResult(result: RealPingResult, request: MainTestRequests.Bulk) {
pendingTestResults[result.guid] = result.delayMillis
if (testResultFlushJob?.isActive == true) return
testResultFlushJob = viewModelScope.launch {
while (pendingTestResults.isNotEmpty()) {
delay(TEST_RESULT_FLUSH_INTERVAL_MS)
flushPendingTestResults(request)
}
}
}
private suspend fun flushPendingTestResults(request: MainTestRequests.Bulk) {
if (pendingTestResults.isEmpty()) return
if (testRequests.bulk?.id != request.id) return
val updates = cacheMutex.withLock {
val drained = pendingTestResults.toMap()
pendingTestResults.clear()
groupDataCache[request.groupId]?.let { cached ->
groupDataCache[request.groupId] = applyTestDelayResults(cached, drained)
}
drained
}
if (updates.isEmpty()) return
if (testRequests.bulk?.id != request.id) return
mutableServerGroupState(request.groupId).update { current ->
current.copy(
servers = applyTestDelayResults(current.servers, updates),
rows = applyTestDelayResultsToRows(current.rows, updates),
)
}
}
@@ -724,22 +808,30 @@ class MainViewModel(
// ---------- Testing ----------
fun cancelAllPing() {
bulkTestJob?.cancel()
bulkTestJob = null
testRequests.cancelBulk()
testRequests.invalidateCurrent()
cancelPendingTestResults()
resetTestStatus()
dataSource.cancelAllPing()
testingGroupId = null
}
private fun resetTestStatus() {
_uiState.update {
it.copy(
isTesting = false,
status = if (it.isRunning) MainStatus.Connected else MainStatus.Disconnected
isTesting = testRequests.isTesting,
status = if (testRequests.isTesting) MainStatus.Testing
else if (it.isRunning) MainStatus.Connected else MainStatus.Disconnected
)
}
}
fun testAllRealPing(onlyTcp: Boolean = false) {
dataSource.cancelAllPing()
cancelAllPing()
val groupId = uiState.value.selectedGroupId
val servers = currentServers()
if (servers.isEmpty()) {
_uiState.update { it.copy(isTesting = false) }
return
}
val serverGuids = servers.map { it.guid }
@@ -755,42 +847,54 @@ class MainViewModel(
}
)
}
testingGroupId = groupId
val request = testRequests.beginBulk(groupId)
val message = TestServiceMessage(
key = AppConfig.MSG_MEASURE_CONFIG_START,
subscriptionId = groupId,
serverGuids = if (keywordFilter.isNotEmpty()) serverGuids else emptyList(),
onlyTcp = onlyTcp
)
_uiState.update {
it.copy(
isTesting = true,
status = MainStatus.Testing
)
}
viewModelScope.launch(ioDispatcher) {
dataSource.clearAllTestDelayResults(serverGuids)
cacheMutex.withLock { groupDataCache.remove(groupId) }
dataSource.sendMsg2TestService(
TestServiceMessage(
key = AppConfig.MSG_MEASURE_CONFIG_START,
subscriptionId = groupId,
serverGuids = if (keywordFilter.isNotEmpty()) serverGuids else emptyList(),
onlyTcp = onlyTcp
)
)
bulkTestJob = viewModelScope.launch {
withContext(ioDispatcher) {
dataSource.clearAllTestDelayResults(serverGuids)
val resetGuids = serverGuids.toHashSet()
cacheMutex.withLock {
groupDataCache[groupId]?.let { cached ->
groupDataCache[groupId] = cached.map { server ->
if (server.guid !in resetGuids || server.testDelayMillis == 0L) server
else server.copy(testDelayMillis = 0L)
}
}
}
}
dataSource.sendMsg2TestService(message, request.id)
}
}
fun testCurrentServerRealPing() {
_uiState.update { it.copy(status = MainStatus.Testing) }
dataSource.testCurrentServerRealPing()
private fun cancelPendingTestResults() {
testResultFlushJob?.cancel()
testResultFlushJob = null
pendingTestResults.clear()
}
private fun onTestsFinished() {
fun testCurrentServerRealPing() {
if (!uiState.value.isRunning) return
val requestId = testRequests.beginCurrent()
_uiState.update { it.copy(isTesting = true, status = MainStatus.Testing) }
dataSource.testCurrentServerRealPing(requestId)
}
private fun onTestsFinished(requestId: String) {
if (testRequests.completeBulk(requestId) == null) return
resetTestStatus()
viewModelScope.launch(ioDispatcher) {
cacheMutex.withLock { groupDataCache.clear() }
testingGroupId = null
_uiState.update {
it.copy(
isTesting = false,
status = if (it.isRunning) MainStatus.Connected else MainStatus.Disconnected
)
}
reloadAllGroups(_uiState.value.groups.map { it.id })
}
}
@@ -818,10 +922,12 @@ class MainViewModel(
// ---------- Running state ----------
private fun updateRunningState(running: Boolean, clearTestingText: Boolean = true) {
if (!running || clearTestingText) testRequests.invalidateCurrent()
_uiState.update { state ->
state.copy(
isRunning = running,
status = if (!clearTestingText && state.isTesting) state.status
isTesting = testRequests.isTesting,
status = if (!clearTestingText && state.isRunning == running) state.status
else if (running) MainStatus.Connected else MainStatus.Disconnected
)
}
@@ -848,4 +954,8 @@ class MainViewModel(
throw IllegalArgumentException("Unknown ViewModel class")
}
}
private companion object {
const val TEST_RESULT_FLUSH_INTERVAL_MS = 500L
}
}
@@ -37,7 +37,6 @@ import com.v2ray.ang.AppConfig.TAG_PROXY
import com.v2ray.ang.R
import com.v2ray.ang.dto.entities.RulesetItem
import com.v2ray.ang.extension.nullIfBlank
import com.v2ray.ang.extension.toast
import com.v2ray.ang.extension.toastSuccess
import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.ui.apppicker.AppPickerActivity
@@ -86,7 +85,6 @@ class RoutingEditActivity : BaseComponentActivity() {
private fun saveServer(rulesetItem: RulesetItem): Boolean {
if (rulesetItem.remarks.isNullOrEmpty()) {
toast(R.string.sub_setting_remarks)
return false
}
if (position < 0 && rulesetItem.id.isEmpty()) {
@@ -124,6 +122,7 @@ fun RoutingEditScreen(
val scrollState = rememberScrollState()
var remarks by rememberSaveable { mutableStateOf(initial?.remarks ?: "") }
var isRemarksError by rememberSaveable { mutableStateOf(false) }
var locked by rememberSaveable { mutableStateOf(initial?.locked == true) }
var domain by rememberSaveable { mutableStateOf(initial?.domain?.joinToString(",") ?: "") }
var ip by rememberSaveable { mutableStateOf(initial?.ip?.joinToString(",") ?: "") }
@@ -193,7 +192,15 @@ fun RoutingEditScreen(
)
}
}
IconButton(onClick = { onSave(buildRuleset()) }) {
IconButton(onClick = {
val remarksErr = remarks.isBlank()
isRemarksError = remarksErr
val hasError = remarksErr
if (!hasError) {
onSave(buildRuleset())
}
}) {
Icon(
painterResource(R.drawable.ic_fab_check),
contentDescription = stringResource(R.string.acc_save)
@@ -216,7 +223,8 @@ fun RoutingEditScreen(
FormTextField(
label = stringResource(R.string.sub_setting_remarks),
value = remarks,
onValueChange = { remarks = it }
onValueChange = { remarks = it },
isError = isRemarksError
)
SettingsSwitchItem(
title = stringResource(R.string.routing_settings_locked),
@@ -309,6 +317,7 @@ fun RoutingEditScreen(
if (showDeleteConfirm) {
DeleteConfirmDialog(
message = stringResource(R.string.confirm_delete_routing_rule),
itemName = initial?.remarks.orEmpty(),
onConfirm = onDelete,
onDismiss = { showDeleteConfirm = false }
)
@@ -105,20 +105,23 @@ abstract class BaseServerActivity : BaseComponentActivity() {
) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
FormTextField(
stringResource(R.string.server_lab_remarks),
state.remarks,
{ state.remarks = it }
label = stringResource(R.string.server_lab_remarks),
value = state.remarks,
onValueChange = { state.remarks = it },
isError = state.isRemarksError
)
FormTextField(
stringResource(R.string.server_lab_address),
state.address,
{ state.address = it }
label = stringResource(R.string.server_lab_address),
value = state.address,
onValueChange = { state.address = it },
isError = state.isAddressError
)
FormTextField(
stringResource(R.string.server_lab_port),
state.port,
{ state.port = it },
keyboardType = KeyboardType.Number
label = stringResource(R.string.server_lab_port),
value = state.port,
onValueChange = { state.port = it },
keyboardType = KeyboardType.Number,
isError = state.isPortError
)
}
}
@@ -365,33 +368,27 @@ abstract class BaseServerActivity : BaseComponentActivity() {
}
protected fun validateBasicConfig(state: ServerUiState): Boolean {
if (state.remarks.isBlank()) {
toast(R.string.server_lab_remarks)
return false
}
if (state.address.isBlank()) {
toast(R.string.server_lab_address)
return false
}
if (
state.configType != EConfigType.HYSTERIA2 &&
(state.port.toIntOrNull() ?: 0) <= 0
) {
toast(R.string.server_lab_port)
return false
}
return true
val remarksErr = state.remarks.isBlank()
val addressErr = state.address.isBlank()
val portErr = state.configType != EConfigType.HYSTERIA2 && (state.port.toIntOrNull() ?: 0) <= 0
state.isRemarksError = remarksErr
state.isAddressError = addressErr
state.isPortError = portErr
val hasError = remarksErr || addressErr || portErr
return !hasError
}
protected open fun validateProtocolConfig(config: ProfileItem): Boolean = true
protected open fun validateCommonConfig(config: ProfileItem): Boolean {
protected open fun validateCommonConfig(state: ServerUiState, config: ProfileItem): Boolean {
if (config.password.isNullOrBlank()) {
state.isPasswordError = true
if (config.configType == EConfigType.VMESS ||
config.configType == EConfigType.VLESS
) {
toast(R.string.server_lab_id)
return false
}
@@ -399,7 +396,6 @@ abstract class BaseServerActivity : BaseComponentActivity() {
config.configType == EConfigType.SHADOWSOCKS ||
config.configType == EConfigType.HYSTERIA2
) {
toast(R.string.server_lab_id3)
return false
}
}
@@ -425,7 +421,7 @@ abstract class BaseServerActivity : BaseComponentActivity() {
protected fun saveServer(state: ServerUiState): Boolean {
if (!validateBasicConfig(state)) return false
val config = state.toProfileItem(initialConfig)
if (!validateCommonConfig(config)) return false
if (!validateCommonConfig(state, config)) return false
if (!validateProtocolConfig(config)) return false
config.description = AngConfigManager.generateDescription(config)
@@ -491,6 +487,7 @@ abstract class BaseServerActivity : BaseComponentActivity() {
if (showDeleteDialog) {
DeleteConfirmDialog(
message = stringResource(R.string.confirm_delete_profile),
itemName = initialConfig.remarks,
onConfirm = {
showDeleteDialog = false
deleteServer(editGuid)
@@ -1,526 +0,0 @@
package com.v2ray.ang.ui.server
import android.os.Bundle
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringArrayResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import com.v2ray.ang.AppConfig.DEFAULT_PORT
import com.v2ray.ang.AppConfig.REALITY
import com.v2ray.ang.AppConfig.TLS
import com.v2ray.ang.AppConfig.WIREGUARD_LOCAL_ADDRESS_V4
import com.v2ray.ang.AppConfig.WIREGUARD_LOCAL_MTU
import com.v2ray.ang.R
import com.v2ray.ang.dto.entities.ProfileItem
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.enums.NetworkType
import com.v2ray.ang.extension.nullIfBlank
import com.v2ray.ang.extension.toast
import com.v2ray.ang.extension.toastSuccess
import com.v2ray.ang.handler.AngConfigManager
import com.v2ray.ang.handler.CertificateFingerprintManager
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.ui.base.BaseComponentActivity
import com.v2ray.ang.ui.compose.AppTopBar
import com.v2ray.ang.ui.compose.DeleteConfirmDialog
import com.v2ray.ang.ui.compose.FormDropdownField
import com.v2ray.ang.ui.compose.FormTextField
import com.v2ray.ang.ui.compose.SettingsSwitchItem
import com.v2ray.ang.ui.compose.verticalScrollbar
import com.v2ray.ang.util.JsonUtil
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class ServerActivity : BaseComponentActivity() {
private val editGuid by lazy { intent.getStringExtra("guid").orEmpty() }
private val isRunning by lazy {
intent.getBooleanExtra("isRunning", false)
&& editGuid.isNotEmpty()
&& editGuid == MmkvManager.getSelectServer()
}
private val createConfigType by lazy {
EConfigType.fromInt(intent.getIntExtra("createConfigType", EConfigType.VMESS.value))
?: EConfigType.VMESS
}
private val subscriptionId by lazy { intent.getStringExtra("subscriptionId") }
private lateinit var initialConfig: ProfileItem
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val existingConfig = MmkvManager.decodeServerConfig(editGuid)
initialConfig = existingConfig ?: ProfileItem.create(createConfigType)
}
@Composable
override fun ScreenContent() {
ServerScreen(
guid = editGuid,
configType = initialConfig.configType,
initialConfig = initialConfig,
isRunning = isRunning,
onBackClick = { finish() },
onSave = { saveServer(it) },
onDelete = { deleteServer(editGuid, isRunning) }
)
}
private fun saveServer(config: ProfileItem): Boolean {
if (config.remarks.isBlank()) {
toast(R.string.server_lab_remarks)
return false
}
if (config.server.isNullOrBlank()) {
toast(R.string.server_lab_address)
return false
}
if (
config.configType != EConfigType.HYSTERIA2 &&
(config.serverPort?.toIntOrNull() ?: 0) <= 0
) {
toast(R.string.server_lab_port)
return false
}
if (
config.configType != EConfigType.SOCKS &&
config.configType != EConfigType.HTTP &&
config.password.isNullOrBlank()
) {
val message = when (config.configType) {
EConfigType.TROJAN,
EConfigType.SHADOWSOCKS,
EConfigType.HYSTERIA2 -> R.string.server_lab_id3
else -> R.string.server_lab_id
}
toast(message)
return false
}
if (
config.configType == EConfigType.TROJAN &&
config.security.isNullOrBlank()
) {
toast(R.string.server_lab_stream_security)
return false
}
if (
!config.xhttpExtra.isNullOrBlank() &&
JsonUtil.parseString(config.xhttpExtra) == null
) {
toast(R.string.server_lab_xhttp_extra)
return false
}
if (
!config.finalMask.isNullOrBlank() &&
JsonUtil.parseString(config.finalMask) == null
) {
toast(R.string.server_lab_final_mask)
return false
}
config.description =
AngConfigManager.generateDescription(config)
if (
config.subscriptionId.isEmpty() &&
!subscriptionId.isNullOrEmpty()
) {
config.subscriptionId = subscriptionId.orEmpty()
}
val savedGuid = MmkvManager.encodeServerConfig(
editGuid,
config
)
toastSuccess(R.string.toast_success)
ProfileEditorResult.run {
finishSaved(
guid = savedGuid,
restartService = isRunning
)
}
return true
}
private fun deleteServer(
guid: String,
isRunning: Boolean
) {
if (
guid.isEmpty() ||
guid == MmkvManager.getSelectServer()
) {
toast(R.string.toast_action_not_allowed)
return
}
MmkvManager.removeServer(guid)
ProfileEditorResult.run {
finishDeleted(guid)
}
}
}
@Composable
fun ServerScreen(
guid: String,
configType: EConfigType,
initialConfig: ProfileItem,
isRunning: Boolean,
onBackClick: () -> Unit,
onSave: (ProfileItem) -> Boolean,
onDelete: () -> Unit
) {
val context = LocalContext.current
val securityOptions = stringArrayResource(R.array.securitys).toList()
val ssSecurityOptions = stringArrayResource(R.array.ss_securitys).toList()
val flowOptions = stringArrayResource(R.array.flows).toList()
val networkOptions = stringArrayResource(R.array.networks).toList()
val tcpHeaderOptions = stringArrayResource(R.array.header_type_tcp).toList()
val kcpHeaderOptions = stringArrayResource(R.array.header_type_kcp_and_quic).toList()
val grpcModeOptions = stringArrayResource(R.array.mode_type_grpc).toList()
val xhttpModeOptions = stringArrayResource(R.array.xhttp_mode).toList()
val streamSecurityOptions = stringArrayResource(R.array.streamsecurityxs).toList()
val uTlsOptions = stringArrayResource(R.array.streamsecurity_utls).toList()
val alpnOptions = stringArrayResource(R.array.streamsecurity_alpn).toList()
val browserDialerOptions = stringArrayResource(R.array.browser_dialer_mode_value).toList()
var remarks by rememberSaveable { mutableStateOf(initialConfig.remarks) }
var address by rememberSaveable { mutableStateOf(initialConfig.server ?: "") }
var port by rememberSaveable { mutableStateOf(initialConfig.serverPort ?: DEFAULT_PORT.toString()) }
var password by rememberSaveable { mutableStateOf(initialConfig.password ?: "") }
var method by rememberSaveable { mutableStateOf(initialConfig.method ?: "") }
var flow by rememberSaveable { mutableStateOf(initialConfig.flow ?: "") }
var encryption by rememberSaveable { mutableStateOf(initialConfig.method ?: "") }
var username by rememberSaveable { mutableStateOf(initialConfig.username ?: "") }
var secretKey by rememberSaveable { mutableStateOf(initialConfig.secretKey ?: "") }
var publicKey by rememberSaveable { mutableStateOf(initialConfig.publicKey ?: "") }
var preSharedKey by rememberSaveable { mutableStateOf(initialConfig.preSharedKey ?: "") }
var reserved by rememberSaveable { mutableStateOf(initialConfig.reserved ?: "0,0,0") }
var localAddress by rememberSaveable { mutableStateOf(initialConfig.localAddress ?: WIREGUARD_LOCAL_ADDRESS_V4) }
var mtu by rememberSaveable { mutableStateOf(initialConfig.mtu?.toString() ?: WIREGUARD_LOCAL_MTU) }
var obfsPassword by rememberSaveable { mutableStateOf(initialConfig.obfsPassword ?: "") }
var portHopping by rememberSaveable { mutableStateOf(initialConfig.portHopping ?: "") }
var portHoppingInterval by rememberSaveable { mutableStateOf(initialConfig.portHoppingInterval ?: "") }
var bandwidthDown by rememberSaveable { mutableStateOf(initialConfig.bandwidthDown ?: "") }
var bandwidthUp by rememberSaveable { mutableStateOf(initialConfig.bandwidthUp ?: "") }
var network by rememberSaveable { mutableStateOf(initialConfig.network ?: NetworkType.TCP.type) }
var headerType by rememberSaveable { mutableStateOf(initialConfig.headerType ?: "none") }
var host by rememberSaveable { mutableStateOf(initialConfig.host ?: "") }
var path by rememberSaveable { mutableStateOf(initialConfig.path ?: "") }
var seed by rememberSaveable { mutableStateOf(initialConfig.seed ?: "") }
var quicSecurity by rememberSaveable { mutableStateOf(initialConfig.quicSecurity ?: "") }
var quicKey by rememberSaveable { mutableStateOf(initialConfig.quicKey ?: "") }
var mode by rememberSaveable { mutableStateOf(initialConfig.mode ?: "") }
var serviceName by rememberSaveable { mutableStateOf(initialConfig.serviceName ?: "") }
var authority by rememberSaveable { mutableStateOf(initialConfig.authority ?: "") }
var xhttpMode by rememberSaveable { mutableStateOf(initialConfig.xhttpMode ?: "") }
var xhttpExtra by rememberSaveable { mutableStateOf(initialConfig.xhttpExtra ?: "") }
var finalMask by rememberSaveable { mutableStateOf(initialConfig.finalMask ?: "") }
var kcpMtu by rememberSaveable { mutableStateOf(initialConfig.kcpMtu?.toString() ?: "") }
var kcpTti by rememberSaveable { mutableStateOf(initialConfig.kcpTti?.toString() ?: "") }
var browserDialerMode by rememberSaveable { mutableStateOf(initialConfig.browserDialerMode ?: "") }
var streamSecurity by rememberSaveable { mutableStateOf(initialConfig.security ?: "") }
var sni by rememberSaveable { mutableStateOf(initialConfig.sni ?: "") }
var allowInsecure by rememberSaveable { mutableStateOf(initialConfig.insecure == true) }
var fingerPrint by rememberSaveable { mutableStateOf(initialConfig.fingerPrint ?: "") }
var alpn by rememberSaveable { mutableStateOf(initialConfig.alpn ?: "") }
var publicKeyReality by rememberSaveable { mutableStateOf(initialConfig.publicKey ?: "") }
var shortId by rememberSaveable { mutableStateOf(initialConfig.shortId ?: "") }
var spiderX by rememberSaveable { mutableStateOf(initialConfig.spiderX ?: "") }
var mldsa65Verify by rememberSaveable { mutableStateOf(initialConfig.mldsa65Verify ?: "") }
var echConfigList by rememberSaveable { mutableStateOf(initialConfig.echConfigList ?: "") }
var verifyPeerCertByName by rememberSaveable { mutableStateOf(initialConfig.verifyPeerCertByName ?: "") }
var pinnedCA256 by rememberSaveable { mutableStateOf(initialConfig.pinnedCA256 ?: "") }
val isVmess = configType == EConfigType.VMESS
val isVless = configType == EConfigType.VLESS
val isShadowsocks = configType == EConfigType.SHADOWSOCKS
val isSocksOrHttp = configType == EConfigType.SOCKS || configType == EConfigType.HTTP
val isTrojan = configType == EConfigType.TROJAN
val isWireguard = configType == EConfigType.WIREGUARD
val isHysteria2 = configType == EConfigType.HYSTERIA2
var showDeleteDialog by rememberSaveable { mutableStateOf(false) }
var isFetchingCert by rememberSaveable { mutableStateOf(false) }
val scope = rememberCoroutineScope()
val listState = rememberLazyListState()
fun buildProfileItem(): ProfileItem = initialConfig.copy(
remarks = remarks,
server = address,
serverPort = port,
password = password,
method = when {
isVmess || isShadowsocks -> method
isVless -> encryption
else -> null
},
flow = if (isVless) flow else null,
username = if (isSocksOrHttp) username else null,
secretKey = if (isWireguard) secretKey else null,
publicKey = when {
isWireguard -> publicKey
streamSecurity == REALITY -> publicKeyReality
else -> null
},
preSharedKey = if (isWireguard) preSharedKey else null,
reserved = if (isWireguard) reserved else null,
localAddress = if (isWireguard) localAddress else null,
mtu = if (isWireguard) mtu.toIntOrNull() else null,
obfsPassword = if (isHysteria2) obfsPassword else null,
portHopping = if (isHysteria2) portHopping else null,
portHoppingInterval = if (isHysteria2) portHoppingInterval else null,
bandwidthDown = if (isHysteria2) bandwidthDown else null,
bandwidthUp = if (isHysteria2) bandwidthUp else null,
network = network,
headerType = headerType,
host = host,
path = path,
seed = seed,
quicSecurity = quicSecurity,
quicKey = quicKey,
mode = mode,
serviceName = serviceName,
authority = authority,
xhttpMode = xhttpMode,
xhttpExtra = xhttpExtra.nullIfBlank(),
finalMask = finalMask.nullIfBlank(),
kcpMtu = kcpMtu.toIntOrNull(),
kcpTti = kcpTti.toIntOrNull(),
browserDialerMode = if (network in listOf(NetworkType.WS.type, NetworkType.XHTTP.type)) browserDialerMode.nullIfBlank() else null,
security = streamSecurity,
sni = sni,
insecure = allowInsecure,
fingerPrint = fingerPrint,
alpn = alpn,
shortId = shortId,
spiderX = spiderX,
mldsa65Verify = mldsa65Verify,
echConfigList = echConfigList,
verifyPeerCertByName = verifyPeerCertByName,
pinnedCA256 = pinnedCA256
)
Scaffold(
contentWindowInsets = WindowInsets(0),
topBar = {
AppTopBar(
title = configType.toString(),
onBackClick = onBackClick,
actions = {
if (guid.isNotEmpty() && !isRunning) {
IconButton(onClick = { showDeleteDialog = true }) {
Icon(painterResource(R.drawable.ic_delete_24dp), stringResource(R.string.acc_delete))
}
}
IconButton(onClick = {
onSave(buildProfileItem())
}) {
Icon(painterResource(R.drawable.ic_fab_check), stringResource(R.string.acc_save))
}
}
)
}
) { innerPadding ->
LazyColumn(
state = listState,
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.consumeWindowInsets(innerPadding)
.imePadding()
.verticalScrollbar(listState),
contentPadding = PaddingValues(bottom = 36.dp + WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
item { FormTextField(stringResource(R.string.server_lab_remarks), remarks, { remarks = it }) }
item { FormTextField(stringResource(R.string.server_lab_address), address, { address = it }) }
if (configType != EConfigType.HYSTERIA2) item { FormTextField(stringResource(R.string.server_lab_port), port, { port = it }, keyboardType = KeyboardType.Number) }
when {
isVmess || isTrojan || isShadowsocks || isHysteria2 -> item {
FormTextField(
stringResource(
when {
isTrojan || isShadowsocks || isHysteria2 -> R.string.server_lab_id3
else -> R.string.server_lab_id
}
), password, { password = it })
}
isVless -> {
item { FormTextField(stringResource(R.string.server_lab_id), password, { password = it }) }
item { FormTextField(stringResource(R.string.server_lab_encryption), encryption, { encryption = it }) }
item { FormDropdownField(stringResource(R.string.server_lab_flow), flow, flowOptions, { flow = it }) }
}
isSocksOrHttp -> {
item { FormTextField(stringResource(R.string.server_lab_security4), username, { username = it }) }
item { FormTextField(stringResource(R.string.server_lab_id4), password, { password = it }) }
}
}
if (isVmess) item { FormDropdownField(stringResource(R.string.server_lab_security), method, securityOptions, { method = it }) }
if (isShadowsocks) item { FormDropdownField(stringResource(R.string.server_lab_security), method, ssSecurityOptions, { method = it }) }
if (isWireguard) {
item { FormTextField(stringResource(R.string.server_lab_secret_key), secretKey, { secretKey = it }) }
item { FormTextField(stringResource(R.string.server_lab_public_key), publicKey, { publicKey = it }) }
item { FormTextField(stringResource(R.string.server_lab_preshared_key), preSharedKey, { preSharedKey = it }) }
item { FormTextField(stringResource(R.string.server_lab_reserved), reserved, { reserved = it }) }
item { FormTextField(stringResource(R.string.server_lab_local_address), localAddress, { localAddress = it }) }
item { FormTextField(stringResource(R.string.server_lab_local_mtu), mtu, { mtu = it }, keyboardType = KeyboardType.Number) }
}
if (isHysteria2) {
item { FormTextField(stringResource(R.string.server_obfs_password), obfsPassword, { obfsPassword = it }) }
item { FormTextField(stringResource(R.string.server_lab_port_hop), portHopping, { portHopping = it }) }
item { FormTextField(stringResource(R.string.server_lab_port_hop_interval), portHoppingInterval, { portHoppingInterval = it }) }
item { FormTextField(stringResource(R.string.server_lab_bandwidth_down), bandwidthDown, { bandwidthDown = it }) }
item { FormTextField(stringResource(R.string.server_lab_bandwidth_up), bandwidthUp, { bandwidthUp = it }) }
}
item { FormDropdownField(stringResource(R.string.server_lab_network), network, networkOptions, { network = it }) }
val headerOptions = when (network) {
NetworkType.TCP.type -> tcpHeaderOptions
NetworkType.KCP.type -> kcpHeaderOptions
NetworkType.GRPC.type -> grpcModeOptions
NetworkType.XHTTP.type -> xhttpModeOptions
else -> listOf("---")
}
if (headerOptions.size > 1) {
item {
FormDropdownField(
stringResource(
when (network) {
NetworkType.GRPC.type -> R.string.server_lab_mode_type; NetworkType.XHTTP.type -> R.string.server_lab_xhttp_mode; else -> R.string.server_lab_head_type
}
),
headerType, headerOptions, { headerType = it }
)
}
}
item {
FormTextField(
stringResource(
when (network) {
NetworkType.TCP.type, NetworkType.HTTP_UPGRADE.type, NetworkType.XHTTP.type, NetworkType.H2.type -> R.string.server_lab_request_host_http
NetworkType.WS.type -> R.string.server_lab_request_host_ws
NetworkType.GRPC.type -> R.string.server_lab_request_host_grpc
else -> R.string.server_lab_request_host6
}
), host, { host = it })
}
item {
FormTextField(
stringResource(
when (network) {
NetworkType.KCP.type -> R.string.server_lab_path_kcp
NetworkType.WS.type -> R.string.server_lab_path_ws
NetworkType.HTTP_UPGRADE.type -> R.string.server_lab_path_httpupgrade
NetworkType.XHTTP.type -> R.string.server_lab_path_xhttp
NetworkType.H2.type -> R.string.server_lab_path_h2
NetworkType.GRPC.type -> R.string.server_lab_path_grpc
else -> R.string.server_lab_path
}
), path, { path = it })
}
if (network == NetworkType.XHTTP.type) {
item { FormTextField(stringResource(R.string.server_lab_xhttp_extra), xhttpExtra, { xhttpExtra = it }) }
}
if (network == NetworkType.KCP.type) {
item { FormTextField(stringResource(R.string.server_lab_kcp_mtu), kcpMtu, { kcpMtu = it }, keyboardType = KeyboardType.Number) }
item { FormTextField(stringResource(R.string.server_lab_kcp_tti), kcpTti, { kcpTti = it }, keyboardType = KeyboardType.Number) }
}
item { FormTextField(stringResource(R.string.server_lab_final_mask), finalMask, { finalMask = it }) }
if (network == NetworkType.WS.type || network == NetworkType.XHTTP.type) {
item { FormDropdownField(stringResource(R.string.server_lab_browser_dialer), browserDialerMode, browserDialerOptions, { browserDialerMode = it }) }
}
item { FormDropdownField(stringResource(R.string.server_lab_stream_security), streamSecurity, streamSecurityOptions, { streamSecurity = it }) }
if (streamSecurity.isNotBlank()) {
item { FormTextField(stringResource(R.string.server_lab_sni), sni, { sni = it }) }
item { FormDropdownField(stringResource(R.string.server_lab_stream_fingerprint), fingerPrint, uTlsOptions, { fingerPrint = it }) }
if (streamSecurity == TLS) {
item { SettingsSwitchItem(title = stringResource(R.string.server_lab_allow_insecure), checked = allowInsecure, onCheckedChange = { allowInsecure = it }) }
item { FormDropdownField(stringResource(R.string.server_lab_stream_alpn), alpn, alpnOptions, { alpn = it }) }
item { FormTextField(stringResource(R.string.server_lab_ech_config_list), echConfigList, { echConfigList = it }) }
item { FormTextField(stringResource(R.string.server_lab_verify_peer_cert_by_name), verifyPeerCertByName, { verifyPeerCertByName = it }) }
item { FormTextField(stringResource(R.string.server_lab_pinned_ca256), pinnedCA256, { pinnedCA256 = it }) }
item {
Button(
onClick = {
if (address.isBlank()) {
context.toast(R.string.server_lab_address); return@Button
}
if (configType != EConfigType.HYSTERIA2 && (port.toIntOrNull() ?: 0) <= 0) {
context.toast(R.string.server_lab_port); return@Button
}
val temp = buildProfileItem()
scope.launch {
isFetchingCert = true
try {
val sha256 = withContext(Dispatchers.IO) { CertificateFingerprintManager.fetchForManualFill(temp) }
if (sha256.isNullOrBlank()) context.toast(R.string.toast_fetch_cert_sha256_failed) else {
pinnedCA256 = sha256
context.toastSuccess(R.string.toast_fetch_cert_sha256_success)
}
} finally {
isFetchingCert = false
}
}
},
enabled = !isFetchingCert,
modifier = Modifier.padding(start = 16.dp)
) { Text(stringResource(R.string.pinned_ca256_action_fetch)) }
}
} else if (streamSecurity == REALITY) {
item { FormTextField(stringResource(R.string.server_lab_public_key), publicKeyReality, { publicKeyReality = it }) }
item { FormTextField(stringResource(R.string.server_lab_short_id), shortId, { shortId = it }) }
item { FormTextField(stringResource(R.string.server_lab_spider_x), spiderX, { spiderX = it }) }
item { FormTextField(stringResource(R.string.server_lab_mldsa65_verify), mldsa65Verify, { mldsa65Verify = it }) }
}
}
}
}
if (showDeleteDialog) {
DeleteConfirmDialog(
message = stringResource(R.string.confirm_delete_profile),
onConfirm = { showDeleteDialog = false; onDelete() },
onDismiss = { showDeleteDialog = false }
)
}
}
@@ -109,7 +109,6 @@ class ServerCustomConfigActivity : BaseComponentActivity() {
content: String
): Boolean {
if (remarks.isBlank()) {
toast(R.string.server_lab_remarks)
return false
}
@@ -207,6 +206,7 @@ fun ServerCustomConfigScreen(
onDelete: () -> Unit
) {
var remarks by rememberSaveable { mutableStateOf(initialRemarks) }
var isRemarksError by rememberSaveable { mutableStateOf(false) }
val textFieldState = rememberTextFieldState(initialText = initialContent)
var showDeleteConfirm by remember { mutableStateOf(false) }
val showDelete = editGuid.isNotEmpty() && !isRunning
@@ -325,7 +325,15 @@ fun ServerCustomConfigScreen(
)
}
}
IconButton(onClick = { onSave(remarks, textFieldState.text.toString()) }) {
IconButton(onClick = {
val remarksErr = remarks.isBlank()
isRemarksError = remarksErr
val hasError = remarksErr
if (!hasError) {
onSave(remarks, textFieldState.text.toString())
}
}) {
Icon(
painterResource(R.drawable.ic_fab_check),
contentDescription = stringResource(R.string.acc_save)
@@ -345,7 +353,8 @@ fun ServerCustomConfigScreen(
FormTextField(
label = stringResource(R.string.server_lab_remarks),
value = remarks,
onValueChange = { remarks = it }
onValueChange = { remarks = it },
isError = isRemarksError
)
Box(
@@ -486,6 +495,7 @@ fun ServerCustomConfigScreen(
if (showDeleteConfirm) {
DeleteConfirmDialog(
message = stringResource(R.string.confirm_delete_profile),
itemName = initialRemarks,
onConfirm = {
showDeleteConfirm = false
onDelete()
@@ -116,7 +116,6 @@ class ServerGroupActivity : BaseComponentActivity() {
fallbackTag: String,
): Boolean {
if (remarks.isBlank()) {
toast(R.string.server_lab_remarks)
return false
}
@@ -227,6 +226,7 @@ fun ServerGroupScreen(
val typeEntries = stringArrayResource(R.array.policy_group_type).toList()
var remarks by rememberSaveable { mutableStateOf(initialRemarks) }
var isRemarksError by rememberSaveable { mutableStateOf(false) }
var filter by rememberSaveable { mutableStateOf(initialFilter) }
var typeValue by rememberSaveable { mutableStateOf(typeEntries.getOrNull(initialType).orEmpty()) }
var subValue by rememberSaveable { mutableStateOf(subDisplay.getOrNull(initialSubIndex).orEmpty()) }
@@ -250,9 +250,15 @@ fun ServerGroupScreen(
}
}
IconButton(onClick = {
val typeIdx = typeEntries.indexOf(typeValue).coerceAtLeast(0)
val subIdx = subDisplay.indexOf(subValue).coerceAtLeast(0)
onSave(remarks, filter, typeIdx, subIdx, testOutbounds, fallbackTag)
val remarksErr = remarks.isBlank()
isRemarksError = remarksErr
val hasError = remarksErr
if (!hasError) {
val typeIdx = typeEntries.indexOf(typeValue).coerceAtLeast(0)
val subIdx = subDisplay.indexOf(subValue).coerceAtLeast(0)
onSave(remarks, filter, typeIdx, subIdx, testOutbounds, fallbackTag)
}
}) {
Icon(painterResource(R.drawable.ic_fab_check), contentDescription = stringResource(R.string.acc_save))
}
@@ -269,7 +275,12 @@ fun ServerGroupScreen(
.padding(vertical = 8.dp)
.verticalScroll(rememberScrollState())
) {
FormTextField(stringResource(R.string.server_lab_remarks), remarks, { remarks = it })
FormTextField(
label = stringResource(R.string.server_lab_remarks),
value = remarks,
onValueChange = { remarks = it },
isError = isRemarksError
)
FormDropdownField(
label = stringResource(R.string.title_policy_group_type),
value = typeValue,
@@ -306,6 +317,7 @@ fun ServerGroupScreen(
if (showDeleteConfirm) {
DeleteConfirmDialog(
message = stringResource(R.string.confirm_delete_policy_group),
itemName = initialRemarks,
onConfirm = onDelete,
onDismiss = { showDeleteConfirm = false }
)
@@ -1,14 +1,12 @@
package com.v2ray.ang.ui.server
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.res.stringResource
import com.v2ray.ang.AppConfig
import com.v2ray.ang.R
import com.v2ray.ang.dto.entities.ProfileItem
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.extension.toast
import com.v2ray.ang.ui.compose.FormTextField
import com.v2ray.ang.ui.compose.SettingsSwitchItem
@@ -18,7 +16,6 @@ class ServerHysteria2Activity : BaseServerActivity() {
@Composable
override fun ScreenContent() {
val scope = rememberCoroutineScope()
val uiState = rememberSaveable(saver = ServerUiState.Saver) {
ServerUiState.from(
initialConfig = initialConfig
@@ -39,7 +36,6 @@ class ServerHysteria2Activity : BaseServerActivity() {
override fun validateProtocolConfig(config: ProfileItem): Boolean {
if (config.password.isNullOrBlank()) {
toast(R.string.server_lab_id3)
return false
}
if (config.security.isNullOrBlank()) {
@@ -51,9 +47,10 @@ class ServerHysteria2Activity : BaseServerActivity() {
@Composable
private fun Hysteria2ProtocolFields(state: ServerUiState) {
FormTextField(
stringResource(R.string.server_lab_id3),
state.password,
{ state.password = it }
label = stringResource(R.string.server_lab_id3),
value = state.password,
onValueChange = { state.password = it },
isError = state.isPasswordError
)
FormTextField(
stringResource(R.string.server_obfs_password),
@@ -100,7 +100,6 @@ class ServerProxyChainActivity : BaseComponentActivity() {
members: List<String>
): Boolean {
if (remarks.isBlank()) {
toast(R.string.server_lab_remarks)
return false
}
@@ -200,6 +199,7 @@ fun ProxyChainScreen(
onDelete: () -> Unit
) {
var remarks by rememberSaveable { mutableStateOf(initialRemarks) }
var isRemarksError by rememberSaveable { mutableStateOf(false) }
var members by rememberSaveable { mutableStateOf(initialMembers) }
var memberKeys by rememberSaveable { mutableStateOf(List(initialMembers.size) { UUID.randomUUID().toString() }) }
var showProfileDeleteConfirm by remember { mutableStateOf(false) }
@@ -233,7 +233,15 @@ fun ProxyChainScreen(
Icon(painterResource(R.drawable.ic_delete_24dp), contentDescription = stringResource(R.string.acc_delete))
}
}
IconButton(onClick = { onSave(remarks, members) }) {
IconButton(onClick = {
val remarksErr = remarks.isBlank()
isRemarksError = remarksErr
val hasError = remarksErr
if (!hasError) {
onSave(remarks, members)
}
}) {
Icon(painterResource(R.drawable.ic_fab_check), contentDescription = stringResource(R.string.acc_save))
}
}
@@ -272,7 +280,8 @@ fun ProxyChainScreen(
FormTextField(
label = stringResource(R.string.server_lab_remarks),
value = remarks,
onValueChange = { remarks = it }
onValueChange = { remarks = it },
isError = isRemarksError
)
}
@@ -337,6 +346,7 @@ fun ProxyChainScreen(
if (showProfileDeleteConfirm) {
DeleteConfirmDialog(
message = stringResource(R.string.confirm_delete_profile),
itemName = initialRemarks,
onConfirm = { showProfileDeleteConfirm = false; onDelete() },
onDismiss = { showProfileDeleteConfirm = false }
)
@@ -344,6 +354,7 @@ fun ProxyChainScreen(
memberToDeleteKey?.let { memberKey ->
DeleteConfirmDialog(
message = stringResource(R.string.confirm_delete_proxy_chain_member),
itemName = members.getOrNull(memberKeys.indexOf(memberKey)).orEmpty(),
onConfirm = {
val (remainingMembers, remainingKeys) = withoutProxyChainMember(members, memberKeys, memberKey)
members = remainingMembers
@@ -52,9 +52,10 @@ class ServerShadowsocksActivity : BaseServerActivity() {
methodOptions: List<String>
) {
FormTextField(
stringResource(R.string.server_lab_id3),
state.password,
{ state.password = it }
label = stringResource(R.string.server_lab_id3),
value = state.password,
onValueChange = { state.password = it },
isError = state.isPasswordError
)
FormDropdownField(
stringResource(R.string.server_lab_security),
@@ -7,7 +7,6 @@ import androidx.compose.ui.res.stringResource
import com.v2ray.ang.R
import com.v2ray.ang.dto.entities.ProfileItem
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.extension.toast
import com.v2ray.ang.ui.compose.FormTextField
class ServerTrojanActivity : BaseServerActivity() {
@@ -44,11 +43,9 @@ class ServerTrojanActivity : BaseServerActivity() {
override fun validateProtocolConfig(config: ProfileItem): Boolean {
if (config.password.isNullOrBlank()) {
toast(R.string.server_lab_id3)
return false
}
if (config.security.isNullOrBlank()) {
toast(R.string.server_lab_stream_security)
return false
}
return true
@@ -57,9 +54,10 @@ class ServerTrojanActivity : BaseServerActivity() {
@Composable
private fun TrojanProtocolFields(state: ServerUiState) {
FormTextField(
stringResource(R.string.server_lab_id3),
state.password,
{ state.password = it }
label = stringResource(R.string.server_lab_id3),
value = state.password,
onValueChange = { state.password = it },
isError = state.isPasswordError
)
}
}
@@ -8,6 +8,7 @@ import com.v2ray.ang.AppConfig.DEFAULT_PORT
import com.v2ray.ang.AppConfig.REALITY
import com.v2ray.ang.AppConfig.WIREGUARD_LOCAL_ADDRESS_V4
import com.v2ray.ang.AppConfig.WIREGUARD_LOCAL_MTU
import com.v2ray.ang.AppConfig.WIREGUARD_LOCAL_REMOTE_DNS
import com.v2ray.ang.dto.entities.ProfileItem
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.enums.NetworkType
@@ -30,6 +31,7 @@ class ServerUiState(
reserved: String = "0,0,0",
localAddress: String = WIREGUARD_LOCAL_ADDRESS_V4,
mtu: String = WIREGUARD_LOCAL_MTU,
remoteDNS: String = WIREGUARD_LOCAL_REMOTE_DNS,
obfsPassword: String = "",
portHopping: String = "",
portHoppingInterval: String = "",
@@ -78,6 +80,7 @@ class ServerUiState(
var reserved by mutableStateOf(reserved)
var localAddress by mutableStateOf(localAddress)
var mtu by mutableStateOf(mtu)
var remoteDNS by mutableStateOf(remoteDNS)
var obfsPassword by mutableStateOf(obfsPassword)
var portHopping by mutableStateOf(portHopping)
var portHoppingInterval by mutableStateOf(portHoppingInterval)
@@ -111,6 +114,11 @@ class ServerUiState(
var pinnedCA256 by mutableStateOf(pinnedCA256)
var isFetchingCert by mutableStateOf(isFetchingCert)
var isRemarksError by mutableStateOf(false)
var isAddressError by mutableStateOf(false)
var isPortError by mutableStateOf(false)
var isPasswordError by mutableStateOf(false)
fun toProfileItem(initialConfig: ProfileItem): ProfileItem {
val isVmess = configType == EConfigType.VMESS
val isVless = configType == EConfigType.VLESS
@@ -142,6 +150,7 @@ class ServerUiState(
reserved = if (isWireguard) reserved else null,
localAddress = if (isWireguard) localAddress else null,
mtu = if (isWireguard) mtu.toIntOrNull() else null,
remoteDNS = if (isWireguard) remoteDNS else null,
obfsPassword = if (isHysteria2) obfsPassword else null,
portHopping = if (isHysteria2) portHopping else null,
portHoppingInterval = if (isHysteria2) portHoppingInterval else null,
@@ -199,6 +208,7 @@ class ServerUiState(
reserved = initialConfig.reserved ?: "0,0,0",
localAddress = initialConfig.localAddress ?: WIREGUARD_LOCAL_ADDRESS_V4,
mtu = initialConfig.mtu?.toString() ?: WIREGUARD_LOCAL_MTU,
remoteDNS = initialConfig.remoteDNS ?: WIREGUARD_LOCAL_REMOTE_DNS,
obfsPassword = initialConfig.obfsPassword ?: "",
portHopping = initialConfig.portHopping ?: "",
portHoppingInterval = initialConfig.portHoppingInterval ?: "",
@@ -8,7 +8,6 @@ import androidx.compose.ui.res.stringResource
import com.v2ray.ang.R
import com.v2ray.ang.dto.entities.ProfileItem
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.extension.toast
import com.v2ray.ang.ui.compose.FormDropdownField
import com.v2ray.ang.ui.compose.FormTextField
@@ -47,7 +46,6 @@ class ServerVlessActivity : BaseServerActivity() {
override fun validateProtocolConfig(config: ProfileItem): Boolean {
if (config.password.isNullOrBlank()) {
toast(R.string.server_lab_id)
return false
}
return true
@@ -59,9 +57,10 @@ class ServerVlessActivity : BaseServerActivity() {
flowOptions: List<String>
) {
FormTextField(
stringResource(R.string.server_lab_id),
state.password,
{ state.password = it }
label = stringResource(R.string.server_lab_id),
value = state.password,
onValueChange = { state.password = it },
isError = state.isPasswordError
)
FormTextField(
stringResource(R.string.server_lab_encryption),
@@ -8,7 +8,6 @@ import androidx.compose.ui.res.stringResource
import com.v2ray.ang.R
import com.v2ray.ang.dto.entities.ProfileItem
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.extension.toast
import com.v2ray.ang.ui.compose.FormDropdownField
import com.v2ray.ang.ui.compose.FormTextField
@@ -47,7 +46,6 @@ class ServerVmessActivity : BaseServerActivity() {
override fun validateProtocolConfig(config: ProfileItem): Boolean {
if (config.password.isNullOrBlank()) {
toast(R.string.server_lab_id)
return false
}
return true
@@ -59,9 +57,10 @@ class ServerVmessActivity : BaseServerActivity() {
methodOptions: List<String>
) {
FormTextField(
stringResource(R.string.server_lab_id),
state.password,
{ state.password = it }
label = stringResource(R.string.server_lab_id),
value = state.password,
onValueChange = { state.password = it },
isError = state.isPasswordError
)
FormDropdownField(
stringResource(R.string.server_lab_security),
@@ -73,6 +73,12 @@ class ServerWireguardActivity : BaseServerActivity() {
state.finalMask,
{ state.finalMask = it }
)
FormTextField(
stringResource(R.string.server_lab_remote_dns),
state.remoteDNS,
{ state.remoteDNS = it }
)
}
}
@@ -29,7 +29,6 @@ import com.v2ray.ang.R
import com.v2ray.ang.dto.entities.SubscriptionItem
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.extension.toLongEx
import com.v2ray.ang.extension.toast
import com.v2ray.ang.extension.toastSuccess
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.SettingsChangeManager
@@ -78,26 +77,19 @@ class SubEditActivity : BaseComponentActivity() {
}
private fun saveServer(subItem: SubscriptionItem): Boolean {
if (TextUtils.isEmpty(subItem.remarks)) {
toast(R.string.sub_setting_remarks)
return false
}
if (subItem.url.isNotEmpty()) {
if (!Utils.isValidUrl(subItem.url)) {
toast(R.string.toast_invalid_url)
return false
}
if (!Utils.isValidSubUrl(subItem.url)) {
toast(R.string.toast_insecure_url_protocol)
if (!subItem.allowInsecureUrl) {
return false
}
if (!Utils.isValidSubUrl(subItem.url) && !subItem.allowInsecureUrl) {
return false
}
}
if (subItem.autoUpdate && subItem.updateInterval < AppConfig.SUBSCRIPTION_MIN_INTERVAL_MINUTES) {
toast(R.string.toast_invalid_update_interval)
return false
}
@@ -132,13 +124,16 @@ fun SubEditScreen(
) {
//val context = LocalContext.current
var remarks by rememberSaveable { mutableStateOf(initial.remarks.orEmpty()) }
var isRemarksError by rememberSaveable { mutableStateOf(false) }
var url by rememberSaveable { mutableStateOf(initial.url.orEmpty()) }
var isUrlError by rememberSaveable { mutableStateOf(false) }
var userAgent by rememberSaveable { mutableStateOf(initial.userAgent.orEmpty()) }
var requestHeaders by rememberSaveable { mutableStateOf(initial.requestHeaders.orEmpty()) }
var filter by rememberSaveable { mutableStateOf(initial.filter ?: "") }
var enabled by rememberSaveable { mutableStateOf(initial.enabled) }
var autoUpdate by rememberSaveable { mutableStateOf(initial.autoUpdate) }
var updateInterval by rememberSaveable { mutableStateOf(initial.updateInterval.toString()) }
var isUpdateIntervalError by rememberSaveable { mutableStateOf(false) }
var allowInsecureUrl by rememberSaveable { mutableStateOf(initial.allowInsecureUrl) }
var prevProfile by rememberSaveable { mutableStateOf(initial.prevProfile ?: "") }
var nextProfile by rememberSaveable { mutableStateOf(initial.nextProfile ?: "") }
@@ -177,7 +172,22 @@ fun SubEditScreen(
Icon(painterResource(R.drawable.ic_delete_24dp), contentDescription = stringResource(R.string.acc_delete))
}
}
IconButton(onClick = { buildSubItem()?.let { onSave(it) } }) {
IconButton(onClick = {
val remarksErr = remarks.isBlank()
val urlErr = url.isNotEmpty() && (
!Utils.isValidUrl(url) || (!Utils.isValidSubUrl(url) && !allowInsecureUrl)
)
val intervalErr = autoUpdate && updateInterval.toLongEx() < AppConfig.SUBSCRIPTION_MIN_INTERVAL_MINUTES
isRemarksError = remarksErr
isUrlError = urlErr
isUpdateIntervalError = intervalErr
val hasError = remarksErr || urlErr || intervalErr
if (!hasError) {
onSave(buildSubItem())
}
}) {
Icon(painterResource(R.drawable.ic_fab_check), contentDescription = stringResource(R.string.acc_save))
}
}
@@ -195,8 +205,19 @@ fun SubEditScreen(
.padding(vertical = 8.dp)
.padding(bottom = 36.dp)
) {
FormTextField(stringResource(R.string.sub_setting_remarks), remarks, { remarks = it })
FormTextField(stringResource(R.string.sub_setting_url), url, { url = it })
FormTextField(
label = stringResource(R.string.sub_setting_remarks),
value = remarks,
onValueChange = { remarks = it },
isError = isRemarksError
)
FormTextField(
label = stringResource(R.string.sub_setting_url),
value = url,
onValueChange = { url = it },
isError = isUrlError,
supportingText = if (isUrlError) stringResource(R.string.toast_invalid_url) else null
)
FormTextField(stringResource(R.string.sub_setting_user_agent), userAgent, { userAgent = it })
FormTextField(stringResource(R.string.sub_setting_request_headers), requestHeaders, { requestHeaders = it })
FormTextField(stringResource(R.string.sub_setting_filter), filter, { filter = it })
@@ -213,8 +234,12 @@ fun SubEditScreen(
)
FormTextField(
stringResource(R.string.title_pref_auto_update_interval),
updateInterval, { updateInterval = it }, keyboardType = KeyboardType.Number
label = stringResource(R.string.title_pref_auto_update_interval),
value = updateInterval,
onValueChange = { updateInterval = it },
keyboardType = KeyboardType.Number,
isError = isUpdateIntervalError,
supportingText = if (isUpdateIntervalError) stringResource(R.string.toast_invalid_update_interval) else null
)
SettingsSwitchItem(
@@ -247,6 +272,7 @@ fun SubEditScreen(
if (showDeleteConfirm) {
DeleteConfirmDialog(
message = stringResource(R.string.confirm_delete_subscription_group),
itemName = initial.remarks,
onConfirm = onDelete,
onDismiss = { showDeleteConfirm = false }
)
@@ -61,6 +61,8 @@ private enum class SubscriptionShareAction(@StringRes val labelRes: Int) {
Clipboard(R.string.share_subscription_clipboard)
}
private data class SubscriptionDeleteTarget(val guid: String, val name: String)
class SubSettingActivity : BaseComponentActivity() {
private val viewModel: SubscriptionsViewModel by viewModels()
@@ -113,7 +115,7 @@ fun SubSettingScreen(
) {
val subscriptions by viewModel.subsFlow.collectAsStateWithLifecycle()
var showUpdateDialog by remember { mutableStateOf(false) }
var removeTarget by remember { mutableStateOf<String?>(null) }
var removeTarget by remember { mutableStateOf<SubscriptionDeleteTarget?>(null) }
val confirmRemove = MmkvManager.decodeSettingsBool(AppConfig.PREF_CONFIRM_REMOVE, false)
var shareTarget by remember { mutableStateOf<Pair<String, String>?>(null) }
@@ -212,7 +214,7 @@ fun SubSettingScreen(
)
}
IconButton(onClick = {
if (confirmRemove) removeTarget = subCache.guid
if (confirmRemove) removeTarget = SubscriptionDeleteTarget(subCache.guid, subCache.subscription.remarks)
else onRemoveSub(subCache.guid)
}) {
Icon(
@@ -268,12 +270,13 @@ fun SubSettingScreen(
)
}
if (removeTarget != null) {
removeTarget?.let { target ->
DeleteConfirmDialog(
message = stringResource(R.string.confirm_delete_subscription_group),
itemName = target.name,
onConfirm = {
onRemoveSub(removeTarget!!)
removeTarget = null
onRemoveSub(target.guid)
},
onDismiss = { removeTarget = null }
)
@@ -345,7 +345,8 @@ internal fun UserAssetScreen(
deleteTarget?.let { asset ->
DeleteConfirmDialog(
message = stringResource(R.string.confirm_delete_asset_file, asset.name),
message = stringResource(R.string.confirm_delete_asset_file),
itemName = asset.name,
onConfirm = {
deleteTarget = null
onRemoveAsset(asset.guid, asset.name)
@@ -90,12 +90,7 @@ class UserAssetUrlActivity : BaseComponentActivity() {
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)
if (TextUtils.isEmpty(remarks) || TextUtils.isEmpty(url)) {
return false
}
@@ -143,7 +138,9 @@ fun UserAssetUrlScreen(
onDelete: () -> Unit
) {
var remarks by rememberSaveable(editAssetId, initialRemarks) { mutableStateOf(initialRemarks) }
var isRemarksError by rememberSaveable(editAssetId) { mutableStateOf(false) }
var url by rememberSaveable(editAssetId, initialUrl) { mutableStateOf(initialUrl) }
var isUrlError by rememberSaveable(editAssetId) { mutableStateOf(false) }
var showDeleteConfirm by rememberSaveable(editAssetId) { mutableStateOf(false) }
Scaffold(
@@ -161,7 +158,18 @@ fun UserAssetUrlScreen(
)
}
}
IconButton(onClick = { onSave(remarks, url) }) {
IconButton(onClick = {
val remarksErr = remarks.isBlank()
val urlErr = url.isBlank()
isRemarksError = remarksErr
isUrlError = urlErr
val hasError = remarksErr || urlErr
if (!hasError) {
onSave(remarks, url)
}
}) {
Icon(
painterResource(R.drawable.ic_fab_check),
contentDescription = stringResource(R.string.acc_save)
@@ -183,12 +191,14 @@ fun UserAssetUrlScreen(
FormTextField(
label = stringResource(R.string.sub_setting_remarks),
value = remarks,
onValueChange = { remarks = it }
onValueChange = { remarks = it },
isError = isRemarksError
)
FormTextField(
label = stringResource(R.string.title_url),
value = url,
onValueChange = { url = it }
onValueChange = { url = it },
isError = isUrlError
)
NavigationBarsSpacer()
}
@@ -197,6 +207,7 @@ fun UserAssetUrlScreen(
if (showDeleteConfirm) {
DeleteConfirmDialog(
message = stringResource(R.string.confirm_delete_asset_source),
itemName = initialRemarks,
onConfirm = onDelete,
onDismiss = { showDeleteConfirm = false }
)
@@ -1,12 +1,13 @@
package com.v2ray.ang.util
import android.content.Context
import coil.ImageLoader
import coil.decode.DataSource
import coil.fetch.DrawableResult
import coil.fetch.FetchResult
import coil.fetch.Fetcher
import coil.request.Options
import coil3.ImageLoader
import coil3.asImage
import coil3.decode.DataSource
import coil3.fetch.FetchResult
import coil3.fetch.Fetcher
import coil3.fetch.ImageFetchResult
import coil3.request.Options
import com.v2ray.ang.AppConfig
class AppIconFetcher(
@@ -25,8 +26,8 @@ class AppIconFetcher(
null
} ?: return null
return DrawableResult(
drawable = drawable,
return ImageFetchResult(
image = drawable.asImage(),
isSampled = false,
dataSource = DataSource.DISK
)
@@ -14,6 +14,9 @@
<string name="pull_down_to_refresh">اسحب لأسفل للتحديث!</string>
<!-- Notifications -->
<string name="notification_channel_service">خدمة v2rayNG</string>
<string name="notification_channel_subscription_updates">تحديثات الاشتراكات</string>
<string name="notification_channel_connection_checks">فحوص الاتصال</string>
<string name="notification_action_stop_v2ray">إيقاف</string>
<string name="toast_permission_denied">تعذر الحصول على الإذن</string>
<string name="toast_permission_denied_notification">تعذر الحصول على إذن الإشعارات</string>
@@ -50,7 +53,7 @@
<string name="confirm_delete_proxy_chain_member">هل أنت متأكد من أنك تريد حذف هذا العضو من سلسلة الوكلاء؟</string>
<string name="confirm_delete_routing_rule">هل أنت متأكد من أنك تريد حذف قاعدة التوجيه هذه؟</string>
<string name="confirm_delete_subscription_group">هل أنت متأكد من أنك تريد حذف مجموعة الاشتراك هذه؟</string>
<string name="confirm_delete_asset_file">هل أنت متأكد من أنك تريد حذف ملف الأصول \"%1$s\"؟</string>
<string name="confirm_delete_asset_file">هل أنت متأكد من أنك تريد حذف ملف الأصول هذا؟</string>
<string name="confirm_delete_asset_source">هل أنت متأكد من أنك تريد حذف مصدر الأصول هذا؟</string>
<string name="server_lab_remarks">ملاحظات</string>
<string name="server_lab_address">العنوان</string>
@@ -100,6 +103,7 @@
<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_remote_dns">remoteDNS (اختياري، عناوين IPv4/IPv6 مفصولة بفواصل، أو "local" لاستخدام DNS الجهاز)</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>
@@ -14,6 +14,9 @@
<string name="pull_down_to_refresh">রিফ্রেশ করতে নিচে টানুন!</string>
<!-- Notifications -->
<string name="notification_channel_service">v2rayNG সার্ভিস</string>
<string name="notification_channel_subscription_updates">সাবস্ক্রিপশন আপডেট</string>
<string name="notification_channel_connection_checks">সংযোগ পরীক্ষা</string>
<string name="notification_action_stop_v2ray">বন্ধ করুন</string>
<string name="toast_permission_denied">অনুমতি পাওয়া যাচ্ছে না</string>
<string name="toast_permission_denied_notification">বিজ্ঞপ্তির অনুমতি পাওয়া যায়নি</string>
@@ -50,7 +53,7 @@
<string name="confirm_delete_proxy_chain_member">আপনি কি এই প্রক্সি চেইন সদস্যটিকে মুছে ফেলতে চান?</string>
<string name="confirm_delete_routing_rule">আপনি কি এই রাউটিং নিয়মটি মুছে ফেলতে চান?</string>
<string name="confirm_delete_subscription_group">আপনি কি এই সাবস্ক্রিপশন গ্রুপটি মুছে ফেলতে চান?</string>
<string name="confirm_delete_asset_file">আপনি কি \"%1$s\" অ্যাসেট ফাইলটি মুছে ফেলতে চান?</string>
<string name="confirm_delete_asset_file">আপনি কি এই অ্যাসেট ফাইলটি মুছে ফেলতে চান?</string>
<string name="confirm_delete_asset_source">আপনি কি এই অ্যাসেট উৎসটি মুছে ফেলতে চান?</string>
<string name="server_lab_remarks">মন্তব্য</string>
<string name="server_lab_address">ঠিকানা</string>
@@ -100,6 +103,7 @@
<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_remote_dns">remoteDNS (ঐচ্ছিক, কমা দ্বারা পৃথক করা IPv4/IPv6 ঠিকানা, অথবা ডিভাইসের DNS ব্যবহার করতে "local")</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>
@@ -14,6 +14,9 @@
<string name="pull_down_to_refresh">سی وانۊ کردن، بلگه ن بلم بکشین!</string>
<!-- Notifications -->
<string name="notification_channel_service">سرویس v2rayNG</string>
<string name="notification_channel_subscription_updates">ورۊ کردن اشتراکا</string>
<string name="notification_channel_connection_checks">واجۊری منپیز</string>
<string name="notification_action_stop_v2ray">واڌاشتن</string>
<string name="toast_permission_denied">گرؽڌن موجوز مومکن نؽڌ</string>
<string name="toast_permission_denied_notification">گرؽڌن موجوز وارسۊوی مومکن نؽڌ</string>
@@ -50,7 +53,7 @@
<string name="confirm_delete_proxy_chain_member">ای عوزو زنجیره پروکسی پاک ابۊ، هنی هم اخۊی پاکس کۊنی؟</string>
<string name="confirm_delete_routing_rule">ای قانووݩ تور جوستن پاک ابۊ، هنی هم اخۊی پاکس کۊنی؟</string>
<string name="confirm_delete_subscription_group">ای بونکۊ اشتراک پاک ابۊ، هنی هم اخۊی پاکس کۊنی؟</string>
<string name="confirm_delete_asset_file">فایل بونچک جغرافیایی «%1$s» پاک ابۊ، هنی هم اخۊی پاکس کۊنی؟</string>
<string name="confirm_delete_asset_file">ای فایل بونچک جغرافیایی پاک ابۊ، هنی هم اخۊی پاکس کۊنی؟</string>
<string name="confirm_delete_asset_source">ای بونچک فایلا جغرافیایی پاک ابۊ، هنی هم اخۊی پاکس کۊنی؟</string>
<string name="server_lab_remarks">نیشتنا</string>
<string name="server_lab_address">نشۊوی</string>
@@ -100,6 +103,7 @@
<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_remote_dns">remoteDNS (اختياری، آدرس‌های IPv4/IPv6 جدا شده با کاما، یا "local" برای استفاده از DNS دستگاه)</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>
@@ -14,6 +14,9 @@
<string name="pull_down_to_refresh">برای بروزرسانی، صفحه را به پایین بکشید!</string>
<!-- Notifications -->
<string name="notification_channel_service">سرویس v2rayNG</string>
<string name="notification_channel_subscription_updates">به‌روزرسانی اشتراک‌ها</string>
<string name="notification_channel_connection_checks">بررسی اتصال</string>
<string name="notification_action_stop_v2ray">توقف</string>
<string name="toast_permission_denied">امکان دریافت مجوز وجود ندارد</string>
<string name="toast_permission_denied_notification">امکان دریافت مجوز ارسال اعلان وجود ندارد</string>
@@ -50,7 +53,7 @@
<string name="confirm_delete_proxy_chain_member">آیا مطمئن هستید که می‌خواهید این عضو زنجیره پراکسی را حذف کنید؟</string>
<string name="confirm_delete_routing_rule">آیا مطمئن هستید که می‌خواهید این قانون مسیریابی را حذف کنید؟</string>
<string name="confirm_delete_subscription_group">آیا مطمئن هستید که می‌خواهید این گروه اشتراک را حذف کنید؟</string>
<string name="confirm_delete_asset_file">آیا مطمئن هستید که می‌خواهید فایل دارایی \"%1$s\" را حذف کنید؟</string>
<string name="confirm_delete_asset_file">آیا مطمئن هستید که می‌خواهید این فایل دارایی را حذف کنید؟</string>
<string name="confirm_delete_asset_source">آیا مطمئن هستید که می‌خواهید این منبع دارایی را حذف کنید؟</string>
<string name="server_lab_remarks">ملاحظات</string>
<string name="server_lab_address">نشانی</string>
@@ -100,6 +103,7 @@
<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_remote_dns">remoteDNS (اختیاری، آدرس‌های IPv4/IPv6 جدا شده با کاما، یا "local" برای استفاده از DNS دستگاه)</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>
+65 -61
View File
@@ -14,6 +14,9 @@
<string name="pull_down_to_refresh">Потяните вниз для обновления!</string>
<!-- Notifications -->
<string name="notification_channel_service">Служба v2rayNG</string>
<string name="notification_channel_subscription_updates">Обновления подписок</string>
<string name="notification_channel_connection_checks">Проверки соединения</string>
<string name="notification_action_stop_v2ray">Остановить</string>
<string name="toast_permission_denied">Разрешение не получено</string>
<string name="toast_permission_denied_notification">Разрешение на отображение уведомлений не получено</string>
@@ -46,11 +49,11 @@
<string name="confirm_delete_duplicate_profiles">Удалить все отображаемые дубликаты профилей?</string>
<string name="confirm_delete_invalid_profiles">Выполните проверку перед удалением! Удалить все отображаемые нерабочие профили?</string>
<string name="confirm_delete_profile">Удалить этот профиль?</string>
<string name="confirm_delete_policy_group">Удалить эту политику группы?</string>
<string name="confirm_delete_policy_group">Удалить эту группу политик?</string>
<string name="confirm_delete_proxy_chain_member">Удалить этого участника цепочки прокси?</string>
<string name="confirm_delete_routing_rule">Удалить это правило маршрутизации?</string>
<string name="confirm_delete_subscription_group">Удалить эту подписку?</string>
<string name="confirm_delete_asset_file">Удалить файл ресурсов «%1$s»?</string>
<string name="confirm_delete_asset_file">Удалить этот файл ресурсов?</string>
<string name="confirm_delete_asset_source">Удалить этот источник ресурсов?</string>
<string name="server_lab_remarks">Название</string>
<string name="server_lab_address">Адрес</string>
@@ -81,7 +84,7 @@
<string name="server_lab_stream_security" translatable="false">TLS</string>
<string name="server_lab_stream_fingerprint">Отпечаток</string>
<string name="server_lab_stream_alpn" translatable="false">ALPN</string>
<string name="server_lab_allow_insecure">Пропускать проверку сертификата (allowInsecure)</string>
<string name="server_lab_allow_insecure">Не проверять сертификат (allowInsecure)</string>
<string name="server_lab_sni" translatable="false">SNI</string>
<string name="server_lab_address3">Адрес</string>
<string name="server_lab_port3">Порт</string>
@@ -100,6 +103,7 @@
<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_remote_dns">remoteDNS (необязательно, IPv4/IPv6-адреса через запятую, либо "local" для использования локального DNS)</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>
@@ -116,12 +120,12 @@
<string name="toast_invalid_url">Неправильный URL</string>
<string name="toast_insecure_url_protocol">Не используйте небезопасный HTTP-протокол в адресе подписки</string>
<string name="server_lab_need_inbound">Убедитесь, что входящий порт соответствует настройкам</string>
<string name="toast_malformed_json">Некорректная конфигурация.</string>
<string name="toast_malformed_json">Профиль повреждён</string>
<string name="server_lab_request_host6">Узел (SNI) (необязательно)</string>
<string name="toast_action_not_allowed">Это действие запрещено</string>
<string name="server_obfs_password">Пароль obfs</string>
<string name="server_lab_port_hop">Смена портов (переопределяет порт)</string>
<string name="server_lab_port_hop_interval">Интервал смены портов (секунды)</string>
<string name="server_lab_port_hop_interval">Интервал смены портов (сек.)</string>
<string name="server_lab_bandwidth_down">Входящая пропускная способность (допускаются: k/m/g/t)</string>
<string name="server_lab_bandwidth_up">Исходящая пропускная способность (допускаются: k/m/g/t)</string>
<string name="server_lab_xhttp_mode">Режим XHTTP</string>
@@ -130,8 +134,8 @@
<string name="server_lab_ech_config_list" translatable="false">echConfigList</string>
<string name="server_lab_verify_peer_cert_by_name">Проверять сертификат узла по имени</string>
<string name="server_lab_pinned_ca256">Отпечаток сертификата (SHA-256)</string>
<string name="server_lab_browser_dialer">Включить Browser Dialer</string>
<string name="server_lab_browser_dialer_tip">Поддерживаются только исходящие соединения XHTTP (packet-up) и WS; настройки, связанные с TLS, могут быть проигнорированы или конфликтовать</string>
<string name="server_lab_browser_dialer">Использовать Browser Dialer</string>
<string name="server_lab_browser_dialer_tip">Поддерживаются только исходящие соединения XHTTP (packet-up) и WS; настройки, связанные с TLS, могут быть проигнорированы или конфликтовать.</string>
<string name="toast_allow_insecure_deprecated">Этот узел использует незашифрованное соединение. Ваши данные могут быть перехвачены и просмотрены государственными структурами через промежуточное сетевое оборудование.\nВ целях безопасности такие узлы больше не поддерживаются ядром Xray версии 26.2.6+.\nДля исправления: включите TLS (или другое шифрование) на своём сервере или используйте отпечаток сертификата (pinnedCA256).\nЕсли вы используете платный сервис, обратитесь в поддержку для обновления. Если провайдер отказывается, рекомендуется сменить его на более безопасный.\nПодробнее: https://github.com/2dust/v2rayN/discussions/9460</string>
<string name="pinned_ca256_action_fetch">Получить отпечаток сертификата</string>
<string name="toast_fetch_cert_sha256_success">Отпечаток сертификата успешно получен</string>
@@ -156,13 +160,13 @@
<string name="menu_item_select_all">Выбрать все</string>
<string name="menu_item_invert_selection">Инвертировать выбор</string>
<string name="msg_enter_keywords">Введите ключевое слово</string>
<string name="switch_bypass_apps_mode">Режим обхода</string>
<string name="switch_bypass_apps_mode">Обход</string>
<string name="menu_item_select_proxy_app">Автовыбор проксируемых приложений</string>
<string name="msg_downloading_content">Загрузка данных</string>
<string name="menu_item_export_proxy_app">Экспорт в буфер обмена</string>
<string name="menu_item_import_proxy_app">Импорт из буфера обмена</string>
<string name="per_app_proxy_settings">Выбор приложений</string>
<string name="per_app_proxy_settings_enable">Использовать выбор приложений</string>
<string name="per_app_proxy_settings_enable">Использовать выбор</string>
<string name="app_picker_unknown_app">Неизвестное приложение (неопознанный UID)</string>
<!-- Preferences -->
@@ -171,24 +175,24 @@
<string name="title_core_settings">Настройки ядра</string>
<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_per_app_proxy">Основной: выбранное приложение соединяется через прокси, не выбранное — напрямую;\nОбход: выбранное приложение соединяется напрямую, не выбранное — через прокси.\nВ меню можно автоматически выбрать приложения, которые должны использовать прокси.</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="summary_system_vpn_settings">Открыть настройки VPN в Android для включения постоянного VPN-подключения, если устройство его поддерживает</string>
<string name="toast_system_vpn_settings_unavailable">Системные настройки VPN недоступны.</string>
<string name="subscription_updater_job_tips">Задача будет выполняться в фоновом режиме, информация о ходе выполнения будет отображаться в уведомлении.</string>
<string name="title_pref_auto_test_after_update_subscription">Автопроверка после обновления подписки</string>
<string name="summary_pref_auto_test_after_update_subscription">Автоматическая проверка занимает много времени. Если параметр включён, задача выполняется в фоновом режиме; иначе — на текущей странице.</string>
<string name="summary_pref_auto_test_after_update_subscription">Автоматическая проверка занимает много времени. Если включено, задача выполняется в фоновом режиме; иначе — для текущей вкладки.</string>
<string name="title_pref_auto_remove_invalid_after_test">Автоудаление нерабочих профилей после проверки</string>
<string name="summary_pref_auto_remove_invalid_after_test">Результаты проверки могут быть неточными, восстановление удалённых профилей невозможно</string>
<string name="title_pref_auto_sort_after_test">Автосортировка профилей</string>
<string name="summary_pref_auto_sort_after_test">Автоматическая сортировка профилей после проверки (результаты проверки могут быть неточными)</string>
<string name="title_mux_settings">Настройки мультиплексирования (Mux)</string>
<string name="title_pref_mux_enabled">Использовать мультиплексирование (Mux)</string>
<string name="title_mux_settings">Настройки мультиплексирования</string>
<string name="title_pref_mux_enabled">Использовать мультиплексирование</string>
<string name="summary_pref_mux_enabled">Быстрее, но это может привести к нестабильному соединению.\nНиже можно настроить обработку TCP, UDP и QUIC.</string>
<string name="title_pref_mux_concurrency">TCP-соединения (диапазон от -1 до 1024)</string>
<string name="title_pref_mux_xudp_concurrency">XUDP-соединения (диапазон от -1 до 1024)</string>
@@ -202,24 +206,24 @@
<string name="title_pref_speed_enabled">Показывать скорость</string>
<string name="summary_pref_speed_enabled">Показывать текущую скорость в уведомлении.\nЗначок указывает, какой трафик преобладает: через прокси или напрямую.</string>
<string name="title_pref_sniffing_enabled">Определять домены в трафике (Sniffing)</string>
<string name="summary_pref_sniffing_enabled">Определять домены назначения в трафике (по умолчанию включено).</string>
<string name="title_pref_route_only_enabled">Включить routeOnly</string>
<string name="summary_pref_route_only_enabled">Использовать доменное имя только для маршрутизации и сохранять целевой адрес в виде IP.</string>
<string name="title_pref_sniffing_enabled">Анализировать трафик (sniffing)</string>
<string name="summary_pref_sniffing_enabled">Определять домены назначения в трафике (по умолчанию включено)</string>
<string name="title_pref_route_only_enabled">Домен только для маршрутизации (routeOnly)</string>
<string name="summary_pref_route_only_enabled">Использовать доменное имя только для маршрутизации и сохранять целевой адрес в виде IP</string>
<string name="title_pref_local_dns_enabled">Использовать локальный DNS</string>
<string name="title_pref_local_dns_enabled">Использовать локальный сервер DNS</string>
<string name="summary_pref_local_dns_enabled">Обслуживание выполняется DNS-модулем ядра (в настройках маршрутизации рекомендуется выбрать режим «Все, кроме LAN и Китая»)</string>
<string name="title_pref_fake_dns_enabled">Использовать виртуальный DNS (FakeDNS)</string>
<string name="summary_pref_fake_dns_enabled">FakeDNS возвращает виртуальные IP-адреса при разрешении доменов (быстрее, но может не работать с некоторыми приложениями).</string>
<string name="title_pref_fake_dns_enabled">Использовать виртуальный сервер DNS (FakeDNS)</string>
<string name="summary_pref_fake_dns_enabled">FakeDNS возвращает виртуальные IP-адреса при определении доменов (быстрее, но может не работать с некоторыми приложениями)</string>
<string name="title_pref_ipv6_enabled">Включить IPv6</string>
<string name="title_pref_ipv6_enabled">Использовать IPv6</string>
<string name="summary_pref_ipv6_enabled">Добавить IPv6-адрес и маршруты в VPN-интерфейс</string>
<string name="title_pref_prefer_ipv6">Предпочитать IPv6</string>
<string name="summary_pref_prefer_ipv6">Предпочитать IPv6-адреса при определении доменных имён</string>
<string name="title_pref_remote_dns">Удалённый DNS (UDP/TCP/HTTPS/QUIC) (необязательно)</string>
<string name="title_pref_remote_dns">Удалённый сервер DNS (UDP/TCP/HTTPS/QUIC) (необязательно)</string>
<string name="summary_pref_remote_dns" translatable="false">DNS</string>
<string name="title_pref_vpn_dns">VPN DNS (только IPv4/IPv6)</string>
@@ -236,15 +240,15 @@
<string name="title_pref_dns_hosts">Сопоставления DNS (формат: domain:address,…)</string>
<string name="summary_pref_dns_hosts">домен:адрес,…</string>
<string name="title_pref_delay_test_url">Сервис проверки задержки</string>
<string name="title_pref_delay_test_url">Сервис проверки реальной задержки</string>
<string name="summary_pref_delay_test_url" translatable="false">URL</string>
<string name="title_pref_real_ping_concurrency">Параллельная проверка задержки</string>
<string name="title_pref_real_ping_concurrency">Параллельные тесты реальной задержки</string>
<string name="title_pref_ip_api_url">Сервис проверки текущего соединения</string>
<string name="summary_pref_ip_api_url" translatable="false">URL</string>
<string name="title_pref_proxy_sharing_enabled">Разрешать соединения из LAN</string>
<string name="summary_pref_proxy_sharing_enabled">Другие устройства могут подключаться, используя ваш IP-адрес, чтобы использовать локальный прокси. Используйте только в доверенной сети, чтобы избежать несанкционированного подключения.</string>
<string name="summary_pref_proxy_sharing_enabled">Другие устройства могут подключаться, используя ваш IP-адрес, чтобы использовать локальный прокси. Используйте только в доверенной сети, чтобы избежать несанкционированного подключения</string>
<string name="toast_warning_pref_proxysharing_short">Доступ из LAN разрешён, убедитесь, что вы находитесь в доверенной сети</string>
<string name="title_pref_socks_port">Порт локального прокси</string>
@@ -254,27 +258,27 @@
<string name="summary_pref_socks_enable_udp">Аутентификация SOCKS5 не полностью безопасна при использовании UDP</string>
<string name="summary_pref_socks_port">Порт локального прокси</string>
<string name="title_pref_dynamic_socks_port">Динамически менять порт локального прокси</string>
<string name="summary_pref_dynamic_socks_port">Использовать случайный порт локального прокси для каждого входящего подключения (inbound)</string>
<string name="summary_pref_dynamic_socks_port">Использовать случайный порт локального прокси для каждого входящего подключения</string>
<string name="title_pref_socks_username">Пользователь локального прокси (необязательно)</string>
<string name="summary_pref_socks_username">Имя пользователя</string>
<string name="title_pref_socks_password">Пароль локального прокси (необязательно)</string>
<string name="summary_pref_socks_password">Пароль</string>
<string name="title_pref_local_dns_port">Порт локального DNS</string>
<string name="summary_pref_local_dns_port">Порт локального DNS</string>
<string name="title_pref_local_dns_port">Порт локального сервера DNS</string>
<string name="summary_pref_local_dns_port">Порт локального сервера DNS</string>
<string name="title_pref_confirm_remove">Подтверждать удаление профиля</string>
<string name="summary_pref_confirm_remove">Обязательное подтверждение удаления профиля</string>
<string name="title_pref_append_http_proxy">Добавить HTTP-прокси в VPN</string>
<string name="summary_pref_append_http_proxy">Браузеры и поддерживаемые приложения смогут использовать HTTP-прокси напрямую, минуя виртуальный сетевой адаптер (Android 10+).</string>
<string name="title_pref_append_http_proxy">Дополнительный HTTP-прокси</string>
<string name="summary_pref_append_http_proxy">Браузеры и поддерживаемые приложения смогут использовать HTTP-прокси напрямую, минуя виртуальный сетевой адаптер (Android 10+)</string>
<string name="title_pref_double_column_display">Профили в два столбца</string>
<string name="summary_pref_double_column_display">Список профилей отображается двумя столбцами, что позволяет показать больше информации на экране.</string>
<string name="summary_pref_double_column_display">Список профилей отображается двумя столбцами, что позволяет показать больше информации на экране</string>
<string name="title_pref_group_all_display">Показывать вкладку «Все»</string>
<string name="summary_pref_group_all_display">Объединять профили из всех групп подписок на одной вкладке.</string>
<string name="summary_pref_group_all_display">Вкладка, объединяющая профили из всех групп</string>
<!-- AboutActivity -->
<string name="title_pref_feedback">Обратная связь</string>
@@ -296,24 +300,24 @@
<string name="title_pref_auto_update_interval">Интервал автообновления (минут, не менее 15)</string>
<string name="title_core_loglevel">Подробность ведения журнала</string>
<string name="title_outbound_domain_resolve_method">Разрешение доменов исходящих подключений (outbound)</string>
<string name="title_outbound_domain_resolve_method">Определение доменов исходящих подключений</string>
<string name="title_mode">Режим</string>
<string name="title_mode_help">Нажмите для получения дополнительной информации</string>
<string name="title_language">Язык</string>
<string name="title_ui_settings">Настройки интерфейса</string>
<string name="title_pref_ui_mode_night">Тема интерфейса</string>
<string name="title_pref_dynamic_color">Включить динамические цвета</string>
<string name="title_pref_dynamic_color">Динамические цвета</string>
<string name="summary_pref_dynamic_color">Работает только на Android 12+</string>
<string name="title_pref_use_hev_tunnel">Использовать Hev TUN</string>
<string name="summary_pref_use_hev_tunnel">Если включено, TUN будет использовать hev-socks5-tunnel; иначе будет использован xray-core</string>
<string name="title_pref_hev_tunnel_loglevel">Уровень журналирования Hev TUN</string>
<string name="title_pref_hev_tunnel_rw_timeout">Тайм-аут чтения/записи Hev TUN (секунды; значения TCP/UDP по умолчанию: 300/60)</string>
<string name="title_pref_hev_tunnel_rw_timeout">Время ожидания чтения/записи Hev TUN (секунды; значения TCP/UDP по умолчанию: 300/60)</string>
<string name="title_mode_settings">Настройки режима</string>
<string name="title_root_mode_enabled">Использовать root-режим</string>
<string name="summary_root_mode_enabled">Использовать root-доступ для низкоуровневого прозрачного проксирования. Root-режим управляет сетевым трафиком напрямую вместо службы VPN Android. Пока он включён, обычный режим, маршрутизация отдельных приложений и связанные настройки VPN не действуют.</string>
<string name="toast_root_required">Для этой функции требуется доступ с правами root</string>
<string name="title_root_lan_sharing">Доступ к VPN для устройств LAN/точки доступа (root)</string>
<string name="summary_root_lan_sharing">Направлять через VPN трафик устройств, подключённых к точке доступа Wi-Fi или по USB.</string>
<string name="summary_root_lan_sharing">Направлять через VPN трафик устройств, подключённых к точке доступа Wi-Fi или по USB</string>
<string name="title_logcat" translatable="false">Logcat</string>
<string name="logcat_copy">Копировать</string>
@@ -323,24 +327,24 @@
<string name="title_del_all_config">Удалить профили</string>
<string name="title_del_duplicate_config">Удалить дубликаты профилей</string>
<string name="title_del_invalid_config">Удалить нерабочие профили</string>
<string name="title_export_all">Экспортировать профили в буфер обмена</string>
<string name="title_sub_setting">Подписки</string>
<string name="title_export_all">Экспорт профилей в буфер обмена</string>
<string name="title_sub_setting">Группы</string>
<string name="sub_setting_remarks">Название</string>
<string name="sub_setting_url">URL (необязательно)</string>
<string name="sub_setting_user_agent" translatable="false">User Agent</string>
<string name="sub_setting_request_headers">Заголовки запроса (формат JSON)</string>
<string name="sub_setting_filter">Фильтр названий (регулярное выражение)</string>
<string name="sub_setting_enable">Включить обновление</string>
<string name="sub_setting_enable">Использовать обновление</string>
<string name="sub_auto_update">Использовать автообновление</string>
<string name="sub_allow_insecure_url">Разрешать незащищённые HTTP-адреса</string>
<string name="sub_setting_pre_profile">Входной прокси</string>
<string name="sub_setting_next_profile">Выходной прокси</string>
<string name="sub_setting_pre_profile_tip">Убедитесь, что профиль с таким названием существует и его название уникально</string>
<string name="sub_setting_entry_proxy_tip">Выбранный прокси добавляется перед каждым профилем подписки и становится входным звеном цепочки прокси для этого профиля.</string>
<string name="sub_setting_exit_proxy_tip">Выбранный прокси добавляется после каждого профиля подписки и становится выходным звеном цепочки прокси для этого профиля.</string>
<string name="sub_setting_pre_profile_tip">Убедитесь, что профиль с таким названием существует и его название уникально.</string>
<string name="sub_setting_entry_proxy_tip">Выбранный прокси добавляется перед каждым профилем подписки и становится входным участником цепочки прокси для этого профиля.</string>
<string name="sub_setting_exit_proxy_tip">Выбранный прокси добавляется после каждого профиля подписки и становится выходным участником цепочки прокси для этого профиля.</string>
<string name="toast_invalid_update_interval">Неправильный интервал обновления. Минимум 15 минут.</string>
<string name="title_sub_update">Обновить подписку</string>
<string name="title_ping_all_server">Проверить TCP-задержку (TCPing)</string>
<string name="title_ping_all_server">Проверить TCP-задержку</string>
<string name="title_real_ping_all_server">Проверить реальную задержку</string>
<string name="title_user_asset_setting">Файлы ресурсов</string>
<string name="title_sort_by_test_results">Сортировать по результатам теста</string>
@@ -374,7 +378,7 @@
<!-- RoutingSettingActivity -->
<string name="routing_settings_domain_strategy">Доменная стратегия</string>
<string name="routing_settings_title">Маршрутизация</string>
<string name="routing_settings_tips">Правило применяется, только если одновременно выполняются все заданные условия сопоставления (AND). Правила проверяются сверху вниз; применяется первое совпавшее правило.</string>
<string name="routing_settings_tips">Правило применяется, только если одновременно выполняются все заданные условия сопоставления (логический оператор «И»). Правила проверяются сверху вниз; применяется первое совпавшее правило.</string>
<string name="routing_settings_comma_tip">Разделяйте несколько значений запятыми (,).</string>
<string name="routing_settings_save">Сохранить</string>
<string name="routing_settings_delete">Очистить</string>
@@ -388,7 +392,7 @@
<string name="routing_settings_locked">Постоянное (сохранится при импорте правил)</string>
<string name="routing_settings_domain" translatable="false">domain</string>
<string name="routing_settings_ip" translatable="false">ip</string>
<string name="routing_settings_process">process (имя пакета — поддерживается только при использовании Xray TUN, включённом routeOnly и Android 10+)</string>
<string name="routing_settings_process">process (имя пакета — поддерживается только при использовании Xray TUN, включённой функции «Домен только для маршрутизации (routeOnly)» и Android 10+)</string>
<string name="routing_settings_process_select">Выбор названия пакета</string>
<string name="routing_settings_port" translatable="false">port</string>
<string name="routing_settings_protocol" translatable="false">protocol</string>
@@ -411,13 +415,13 @@
<string name="import_subscription_success">Подписка импортирована</string>
<string name="import_subscription_failure">Невозможно импортировать подписку</string>
<string name="title_fragment_settings">Настройки фрагментирования (Fragment)</string>
<string name="title_fragment_settings">Настройки фрагментирования</string>
<string name="title_pref_fragment_packets">Фрагментирование пакетов</string>
<string name="title_pref_fragment_length">Длина фрагмента (от — до)</string>
<string name="title_pref_fragment_interval">Интервал фрагментов (от — до)</string>
<string name="title_pref_fragment_enabled">Использовать фрагментирование</string>
<string name="title_pref_fragment_maxsplit">Максимальное число фрагментов</string>
<string name="title_observatory_settings">Настройки мониторинга соединений (Observatory)</string>
<string name="title_observatory_settings">Настройки контроля соединений</string>
<string name="title_pref_observatory_least_ping_interval">Интервал leastPing</string>
<string name="title_pref_observatory_least_load_interval">Интервал leastLoad</string>
<string name="title_pref_observatory_least_load_method">HTTP-метод leastLoad</string>
@@ -436,8 +440,8 @@
<string name="title_policy_group_type">Тип политики группы</string>
<string name="title_policy_group_subscription_id">Из группы подписки</string>
<string name="title_policy_group_subscription_filter">Фильтр примечаний (регулярное выражение)</string>
<string name="title_policy_group_test_outbounds">Тестировать исходящие подключения (outbounds)</string>
<string name="title_policy_group_fallback">Резервное исходящее подключение (outbound, необязательно)</string>
<string name="title_policy_group_test_outbounds">Тестировать исходящие подключения</string>
<string name="title_policy_group_fallback">Резервное исходящее подключение (необязательно)</string>
<string name="server_proxy_chain_members">Участники цепочки прокси</string>
<string name="server_proxy_chain_pick_members">Нажмите здесь, чтобы выбрать участника</string>
@@ -447,8 +451,8 @@
<string name="server_proxy_chain_members_invalid">Недопустимые участники цепочки: %1$s</string>
<!-- BackupActivity -->
<string name="title_configuration_backup_restore">Резервное копирование и восстановление</string>
<string name="title_configuration_backup">Создать резервную копию</string>
<string name="title_configuration_backup_restore">Резервирование и восстановление</string>
<string name="title_configuration_backup">Резервирование конфигурации</string>
<string name="title_configuration_restore">Восстановление конфигурации</string>
<string name="title_profile_storage_cleanup">Исправить хранилище профилей</string>
<string name="summary_profile_storage_cleanup">Удалить данные неиспользуемых профилей</string>
@@ -471,7 +475,7 @@
<string name="action_close">Закрыть</string>
<string name="share_subscription_qrcode">QR-код</string>
<string name="share_subscription_clipboard">Экспорт в буфер обмена</string>
<string name="share_subscription_clipboard">Копировать ссылку</string>
<string-array name="mode_entries">
<item>VPN</item>
@@ -492,7 +496,7 @@
<string name="routing_preset_china_whitelist">Белый список Китая</string>
<string name="routing_preset_china_blacklist">Чёрный список Китая</string>
<string name="routing_preset_global">Глобальная</string>
<string name="routing_preset_global">Общие</string>
<string name="routing_preset_iran_whitelist">Белый список Ирана</string>
<string name="routing_preset_russia_whitelist">Белый список России</string>
@@ -503,9 +507,9 @@
</string-array>
<string-array name="outbound_domain_resolve_method">
<item>Не разрешать</item>
<item>Разрешать и добавлять в DNS Hosts</item>
<item>Разрешать и заменять домен</item>
<item>Не определять</item>
<item>Определять и добавлять к узлам DNS</item>
<item>Определять и заменять домен</item>
</string-array>
<string-array name="policy_group_type">
@@ -534,7 +538,7 @@
<string name="acc_search">Поиск</string>
<string name="acc_add">Добавить профиль</string>
<string name="acc_more">Дополнительные параметры</string>
<string name="acc_selected_server">Selected</string>
<string name="acc_selected_server">Выбран</string>
<string name="acc_share_subscription">Поделиться ссылкой на подписку</string>
<string name="acc_share_log">Поделиться журналом</string>
<string name="acc_remove">Удалить участника цепочки прокси</string>
@@ -542,9 +546,9 @@
<string name="acc_start_scanner">Запустить сканер QR-кодов</string>
<string name="acc_stop_scanner">Остановить сканер QR-кодов</string>
<string name="acc_turn_torch_on">Включить фонарик</string>
<string name="acc_turn_torch_off">Выключить фонарик</string>
<string name="acc_turn_torch_off">Отключить фонарик</string>
<string name="acc_select_image">Выбрать изображение для сканирования</string>
<string name="acc_locked">Правило сохраняется при импорте предустановок</string>
<string name="acc_locked">Правило сохраняется при импорте набора</string>
<string name="acc_open_menu">Открыть меню навигации</string>
<string name="acc_edit">Изменить</string>
<string name="acc_delete">Удалить</string>
@@ -14,6 +14,9 @@
<string name="pull_down_to_refresh">Kéo xuống để làm mới!</string>
<!-- Notifications -->
<string name="notification_channel_service">Dịch vụ v2rayNG</string>
<string name="notification_channel_subscription_updates">Cập nhật gói đăng ký</string>
<string name="notification_channel_connection_checks">Kiểm tra kết nối</string>
<string name="notification_action_stop_v2ray">Ngắt kết nối v2rayNG</string>
<string name="toast_permission_denied">Vui lòng cấp các quyền cần thiết cho v2rayNG. Bạn có thể đã từ chối quyền truy cập máy ảnh hoặc bộ nhớ.</string>
<string name="toast_permission_denied_notification">Không thể nhận quyền thông báo</string>
@@ -50,7 +53,7 @@
<string name="confirm_delete_proxy_chain_member">Bạn có chắc muốn xóa thành viên này khỏi chuỗi proxy không?</string>
<string name="confirm_delete_routing_rule">Bạn có chắc muốn xóa quy tắc định tuyến này không?</string>
<string name="confirm_delete_subscription_group">Bạn có chắc muốn xóa nhóm đăng ký này không?</string>
<string name="confirm_delete_asset_file">Bạn có chắc muốn xóa tệp tài nguyên \"%1$s\" không?</string>
<string name="confirm_delete_asset_file">Bạn có chắc muốn xóa tệp tài nguyên này không?</string>
<string name="confirm_delete_asset_source">Bạn có chắc muốn xóa nguồn tài nguyên này không?</string>
<string name="server_lab_remarks">Tên cấu hình</string>
<string name="server_lab_address">Địa chỉ</string>
@@ -100,6 +103,7 @@
<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_remote_dns">remoteDNS (tùy chọn, các địa chỉ IPv4/IPv6 cách nhau bằng dấu phẩy, hoặc "local" để dùng DNS của thiết bị)</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>
@@ -14,6 +14,9 @@
<string name="pull_down_to_refresh">请下拉刷新!</string>
<!-- Notifications -->
<string name="notification_channel_service">v2rayNG 服务</string>
<string name="notification_channel_subscription_updates">订阅更新</string>
<string name="notification_channel_connection_checks">连接检查</string>
<string name="notification_action_stop_v2ray">停止</string>
<string name="toast_permission_denied">无法取得权限</string>
<string name="toast_permission_denied_notification">无法取得通知权限</string>
@@ -50,7 +53,7 @@
<string name="confirm_delete_proxy_chain_member">确定要删除此代理链成员吗?</string>
<string name="confirm_delete_routing_rule">确定要删除此路由规则吗?</string>
<string name="confirm_delete_subscription_group">确定要删除此订阅组吗?</string>
<string name="confirm_delete_asset_file">确定要删除资源文件“%1$s”吗?</string>
<string name="confirm_delete_asset_file">确定要删除此资源文件吗?</string>
<string name="confirm_delete_asset_source">确定要删除此资源来源吗?</string>
<string name="server_lab_remarks">别名 (remarks)</string>
<string name="server_lab_address">地址 (address)</string>
@@ -100,6 +103,7 @@
<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_remote_dns">remoteDNS(可选,多个 IPv4/IPv6 地址用逗号分隔,或填写 "local" 使用设备本地 DNS)</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>
@@ -14,6 +14,9 @@
<string name="pull_down_to_refresh">請下拉重新整理!</string>
<!-- Notifications -->
<string name="notification_channel_service">v2rayNG 服務</string>
<string name="notification_channel_subscription_updates">訂閱更新</string>
<string name="notification_channel_connection_checks">連線檢查</string>
<string name="notification_action_stop_v2ray">停止</string>
<string name="toast_permission_denied">無法取得此權限</string>
<string name="toast_permission_denied_notification">無法取得此通知權限</string>
@@ -50,7 +53,7 @@
<string name="confirm_delete_proxy_chain_member">確定要刪除此代理鏈成員嗎?</string>
<string name="confirm_delete_routing_rule">確定要刪除此路由規則嗎?</string>
<string name="confirm_delete_subscription_group">確定要刪除此訂閱群組嗎?</string>
<string name="confirm_delete_asset_file">確定要刪除資源檔案「%1$s」嗎?</string>
<string name="confirm_delete_asset_file">確定要刪除此資源檔案嗎?</string>
<string name="confirm_delete_asset_source">確定要刪除此資源來源嗎?</string>
<string name="server_lab_remarks">備註</string>
<string name="server_lab_address">位址</string>
@@ -100,6 +103,7 @@
<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_remote_dns">remoteDNS(選填,多個 IPv4/IPv6 位址以逗號分隔,或輸入 "local" 使用裝置本機 DNS)</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>
+5 -1
View File
@@ -14,6 +14,9 @@
<string name="pull_down_to_refresh">Please pull down to refresh!</string>
<!-- Notifications -->
<string name="notification_channel_service">v2rayNG service</string>
<string name="notification_channel_subscription_updates">Subscription updates</string>
<string name="notification_channel_connection_checks">Connection checks</string>
<string name="notification_action_stop_v2ray">Stop</string>
<string name="toast_permission_denied">Permission denied</string>
<string name="toast_permission_denied_notification">Notification permission denied</string>
@@ -50,7 +53,7 @@
<string name="confirm_delete_proxy_chain_member">Are you sure you want to delete this chain member?</string>
<string name="confirm_delete_routing_rule">Are you sure you want to delete this routing rule?</string>
<string name="confirm_delete_subscription_group">Are you sure you want to delete this subscription group?</string>
<string name="confirm_delete_asset_file">Are you sure you want to delete the asset file \"%1$s\"?</string>
<string name="confirm_delete_asset_file">Are you sure you want to delete this asset file?</string>
<string name="confirm_delete_asset_source">Are you sure you want to delete this asset source?</string>
<string name="server_lab_remarks">remarks</string>
<string name="server_lab_address">address</string>
@@ -100,6 +103,7 @@
<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_remote_dns">remoteDNS (optional, comma-separated IPv4/IPv6, or "local" to use device DNS)</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>
@@ -0,0 +1,31 @@
package com.v2ray.ang.root
import org.junit.Assert.*
import org.junit.Test
import java.util.concurrent.TimeUnit
class RootProcessRunnerTest {
@Test fun `a silent blocked command respects its deadline`() {
val start = System.nanoTime()
val result = RootProcessRunner.run(listOf("sh", "-c", "sleep 5"), 150)
assertEquals(-1, result.code)
assertTrue(TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start) < 1500)
}
@Test fun `inherited stdout cannot hold a completed command open`() {
val start = System.nanoTime()
val result = RootProcessRunner.run(listOf("sh", "-c", "sleep 5 & echo complete"), 250)
assertEquals(0, result.code)
assertTrue(result.output.contains("complete"))
assertTrue(TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start) < 1500)
}
@Test fun `large output is drained without unbounded retention`() {
val result = RootProcessRunner.run(listOf("sh", "-c", "yes x | head -c 1048576"), 3000, 4096)
assertEquals(0, result.code)
assertEquals(4096, result.output.length)
}
@Test fun `failure status and diagnostic output survive`() {
val result = RootProcessRunner.run(listOf("sh", "-c", "echo lock-unavailable >&2; exit 4"), 1000)
assertEquals(4, result.code)
assertTrue(result.output.contains("lock-unavailable"))
}
}
@@ -0,0 +1,206 @@
package com.v2ray.ang.service
import android.content.Context
import android.os.Handler
import android.os.Looper
import android.webkit.WebSettings
import android.webkit.WebView
import android.webkit.WebViewClient
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertSame
import org.junit.Assert.assertThrows
import org.junit.Assert.assertTrue
import org.junit.Test
import org.mockito.Mockito.mockConstruction
import org.mockito.Mockito.mockStatic
import org.mockito.kotlin.any
import org.mockito.kotlin.argumentCaptor
import org.mockito.kotlin.clearInvocations
import org.mockito.kotlin.doThrow
import org.mockito.kotlin.mock
import org.mockito.kotlin.never
import org.mockito.kotlin.times
import org.mockito.kotlin.verify
import org.mockito.kotlin.whenever
import java.util.concurrent.ExecutionException
import java.util.concurrent.Executors
import java.util.concurrent.Future
import java.util.concurrent.FutureTask
import java.util.concurrent.LinkedBlockingQueue
import java.util.concurrent.TimeUnit
class DialerWebviewServiceTest {
@Test
fun workerStartWaitsForMainThreadSetup() = Fixture().use { f ->
val start = f.onWorker { f.dialer.start(f.context, "127.0.0.1:10001") }
val queued = f.nextUpdate()
assertFalse(start.isDone)
assertTrue(f.views.isEmpty())
queued.run()
start.get(5, TimeUnit.SECONDS)
verify(f.views.single()).loadUrl("http://127.0.0.1:10001/")
}
@Test
fun workerStopCompletesCleanupBeforeReturning() = Fixture().use { f ->
f.dialer.start(f.context, "127.0.0.1:10001")
val view = f.views.single()
val stop = f.onWorker { f.dialer.stop() }
val queued = f.nextUpdate()
assertFalse(stop.isDone)
verify(view, never()).destroy()
queued.run()
stop.get(5, TimeUnit.SECONDS)
verify(view).stopLoading()
verify(view).pauseTimers()
verify(view).onPause()
verify(view).destroy()
}
@Test
fun restartReplacesTheViewAndRepeatedStopDestroysItOnce() = Fixture().use { f ->
f.dialer.start(f.context, "127.0.0.1:10001")
f.dialer.start(f.context, "127.0.0.1:10002")
verify(f.views.first()).destroy()
verify(f.views.last()).loadUrl("http://127.0.0.1:10002/")
f.dialer.stop()
f.dialer.stop()
f.views.forEach { verify(it, times(1)).destroy() }
}
@Test
fun mainThreadStopInvalidatesAQueuedWorkerStart() = Fixture().use { f ->
val start = f.onWorker { f.dialer.start(f.context, "127.0.0.1:10001") }
val queued = f.nextUpdate()
f.dialer.stop()
queued.run()
start.get(5, TimeUnit.SECONDS)
assertTrue(f.views.isEmpty())
}
@Test
fun interruptedWorkerCannotLeaveAQueuedStartBehind() = Fixture().use { f ->
val start = f.onWorker { f.dialer.start(f.context, "127.0.0.1:10001") }
val queued = f.nextUpdate()
start.cancel(true)
// The single worker reaches this marker only after handling the interruption.
f.onWorker {}.get(5, TimeUnit.SECONDS)
queued.run()
assertTrue(f.views.isEmpty())
}
@Test
fun rejectedMainThreadDispatchFailsWithoutWaiting() = Fixture().use { f ->
whenever(f.handler.post(any())).thenReturn(false)
val start = f.onWorker { f.dialer.start(f.context, "127.0.0.1:10001") }
val thrown = assertThrows(ExecutionException::class.java) { start.get(5, TimeUnit.SECONDS) }
assertTrue(thrown.cause is IllegalStateException)
assertTrue(f.views.isEmpty())
}
@Test
fun oldQueuedStopCannotDestroyANewerStart() = Fixture().use { f ->
f.dialer.start(f.context, "127.0.0.1:10001")
val stop = f.onWorker { f.dialer.stop() }
val queued = f.nextUpdate()
f.dialer.start(f.context, "127.0.0.1:10002")
queued.run()
stop.get(5, TimeUnit.SECONDS)
verify(f.views.first()).destroy()
verify(f.views.last(), never()).destroy()
}
@Test
fun setupFailureCleansUpAndReachesTheWorkerUnwrapped() = Fixture().use { f ->
val failure = IllegalStateException("WebView setup failed")
f.loadFailure = failure
val start = f.onWorker { f.dialer.start(f.context, "127.0.0.1:10001") }
f.nextUpdate().run()
val thrown = assertThrows(ExecutionException::class.java) { start.get(5, TimeUnit.SECONDS) }
assertSame(failure, thrown.cause)
verify(f.views.single()).destroy()
f.dialer.stop()
verify(f.views.single(), times(1)).destroy()
}
@Test
fun cleanupFailureStillDestroysTheViewAndReachesTheCaller() = Fixture().use { f ->
f.dialer.start(f.context, "127.0.0.1:10001")
val view = f.views.single()
val failure = IllegalStateException("WebView stop failed")
doThrow(failure).whenever(view).stopLoading()
assertSame(failure, assertThrows(IllegalStateException::class.java) { f.dialer.stop() })
f.dialer.stop()
verify(view, times(1)).destroy()
}
@Test
fun callbacksCannotResumeADestroyedViewOrRescheduleKeepAlive(): Unit = Fixture().use { f ->
f.dialer.start(f.context, "127.0.0.1:10001")
val view = f.views.single()
val client = argumentCaptor<WebViewClient>()
verify(view).webViewClient = client.capture()
val keepAlive = f.keepAlives.single()
f.dialer.stop()
clearInvocations(view, f.handler)
client.firstValue.onPageFinished(view, "http://127.0.0.1:10001/")
keepAlive.run()
verify(view, never()).onResume()
verify(view, never()).resumeTimers()
verify(f.handler, never()).postDelayed(any(), any<Long>())
}
@Test
fun emptyAddressStopsThePreviousViewWithoutCreatingAnother() = Fixture().use { f ->
f.dialer.start(f.context, "127.0.0.1:10001")
f.dialer.start(f.context, "")
assertEquals(1, f.views.size)
verify(f.views.single()).destroy()
}
private class Fixture : AutoCloseable {
private val mainLooper = mock<Looper>()
private val loopers = mockStatic(Looper::class.java).apply {
`when`<Looper> { Looper.getMainLooper() }.thenReturn(mainLooper)
`when`<Looper> { Looper.myLooper() }.thenReturn(mainLooper)
}
private val updates = LinkedBlockingQueue<Runnable>()
private val worker = Executors.newSingleThreadExecutor()
val keepAlives = mutableListOf<Runnable>()
var loadFailure: Exception? = null
val context = mock<Context>().also { whenever(it.applicationContext).thenReturn(it) }
private val handlers = mockConstruction(Handler::class.java) { handler, _ ->
whenever(handler.looper).thenReturn(mainLooper)
whenever(handler.post(any())).thenAnswer {
val task = it.getArgument<Runnable>(0)
if (task is FutureTask<*>) updates.add(task) else keepAlives.add(task)
true
}
}
private val webViews = mockConstruction(WebView::class.java) { view, _ ->
whenever(view.settings).thenReturn(mock<WebSettings>())
loadFailure?.let { doThrow(it).whenever(view).loadUrl(any()) }
}
val dialer = DialerWebviewService()
val handler get() = handlers.constructed().single()
val views get() = webViews.constructed()
fun onWorker(action: () -> Unit): Future<*> = worker.submit {
// Static mocks are thread-local: this worker has no Android looper.
mockStatic(Looper::class.java).use { action() }
}
fun nextUpdate(): Runnable = checkNotNull(updates.poll(5, TimeUnit.SECONDS)) {
"Worker did not dispatch its WebView operation to the main looper"
}
override fun close() {
worker.shutdownNow()
worker.awaitTermination(5, TimeUnit.SECONDS)
webViews.close()
handlers.close()
loopers.close()
}
}
}
@@ -0,0 +1,62 @@
package com.v2ray.ang.ui.main
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class MainTestRequestsTest {
@Test
fun onlyTheLatestCurrentRequestCanFinishOnce() {
val requests = MainTestRequests()
assertFalse(requests.isTesting)
assertFalse(requests.completeCurrent(""))
val first = requests.beginCurrent()
val second = requests.beginCurrent()
assertNotEquals(first, second)
assertFalse(requests.completeCurrent(first))
assertTrue(requests.isTesting)
assertTrue(requests.completeCurrent(second))
assertFalse(requests.completeCurrent(second))
assertFalse(requests.isTesting)
}
@Test
fun stopAndRestartInvalidateThePreviousConnectionRequest() {
val requests = MainTestRequests()
val stopped = requests.beginCurrent()
requests.invalidateCurrent()
assertFalse(requests.completeCurrent(stopped))
val restarted = requests.beginCurrent()
assertFalse(requests.completeCurrent(stopped))
assertTrue(requests.completeCurrent(restarted))
}
@Test
fun bulkCancellationAndReplacementRejectLateCompletionEvenInTheSameGroup() {
val requests = MainTestRequests()
val cancelled = requests.beginBulk("group")
requests.cancelBulk()
assertNull(requests.completeBulk(cancelled.id))
val replaced = requests.beginBulk("group")
val current = requests.beginBulk("group")
assertNull(requests.completeBulk(replaced.id))
assertEquals(current, requests.bulk)
assertEquals(current, requests.completeBulk(current.id))
assertNull(requests.completeBulk(current.id))
assertFalse(requests.isTesting)
}
@Test
fun currentResultDoesNotFinishAnIndependentBulkTest() {
val requests = MainTestRequests()
val bulk = requests.beginBulk("") // All servers is a valid group ID.
val current = requests.beginCurrent()
assertTrue(requests.completeCurrent(current))
assertTrue(requests.isTesting)
assertEquals(bulk, requests.completeBulk(bulk.id))
assertFalse(requests.isTesting)
}
}
@@ -0,0 +1,29 @@
package com.v2ray.ang.ui.main
import androidx.compose.runtime.saveable.SaverScope
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class ServerDeleteTargetTest {
@Test
fun pendingDeletionRestoresItsGuidAndDisplayName() {
val scope = SaverScope { it is String }
for (guid in listOf("first-guid", "second-guid")) {
for (name in listOf("Same name", "", "Профиль\n服务器 100%")) {
val target = ServerDeleteTarget(guid, name)
val saved = with(ServerDeleteTarget.Saver) { scope.save(target) }
assertEquals(listOf(guid, name), saved)
assertEquals(target, ServerDeleteTarget.Saver.restore(requireNotNull(saved)))
}
}
}
@Test
fun noPendingDeletionHasNoSavedTarget() {
val saved = with(ServerDeleteTarget.Saver) { SaverScope { it is String }.save(null) }
assertNull(saved)
}
}
+9 -9
View File
@@ -1,26 +1,26 @@
[versions]
agp = "9.3.2"
agp = "9.4.1"
desugarJdkLibs = "2.1.5"
gradleLicensePlugin = "0.9.9"
kotlin = "2.4.10"
gradleLicensePlugin = "0.9.91"
kotlin = "2.4.20"
coreKtx = "1.19.0"
junit = "4.13.2"
junitVersion = "1.3.0"
espressoCore = "3.7.0"
activity = "1.13.0"
appcompat = "1.7.1"
appcompat = "1.8.0"
mmkvStatic = "1.3.16"
gson = "2.14.0"
okhttp = "5.4.0"
okhttp = "5.5.0"
kotlinxCoroutines = "1.11.0"
core = "3.5.4"
workRuntimeKtx = "2.11.2"
lifecycleViewmodelKtx = "2.11.0"
mockitoMockitoInline = "5.2.0"
camerax = "1.6.1"
composeBom = "2026.06.01"
camerax = "1.6.2"
composeBom = "2026.09.00"
reorderable = "3.1.0"
coil = "2.7.0"
coil = "3.6.3"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
desugar-jdk-libs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugarJdkLibs" }
@@ -57,7 +57,7 @@ androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycleViewmodelKtx" }
coil-compose = { module = "io.coil-kt:coil-compose", version.ref = "coil" }
coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
android-library = { id = "com.android.library", version.ref = "agp" }
+1 -1
View File
@@ -1,6 +1,6 @@
#Thu Nov 14 12:42:51 BDT 2024
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.0-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists