mirror of
https://github.com/2dust/v2rayNG.git
synced 2026-10-30 15:06:25 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd21bbc3f9 | ||
|
|
6e5d742e40 | ||
|
|
4472162614 | ||
|
|
1f7cef32dc | ||
|
|
7e68e2eaad | ||
|
|
4c7c964e65 | ||
|
|
1bd1bceadb | ||
|
|
0e80f6b431 | ||
|
|
1ad5f30ede | ||
|
|
e4c0b09e1b | ||
|
|
073376e46d | ||
|
|
08c2a4deb1 | ||
|
|
60ab1af384 | ||
|
|
c48fbd3fc8 | ||
|
|
b98895219a | ||
|
|
0f65b1d348 | ||
|
|
a1cfa2aa7a | ||
|
|
53fce9a8f5 | ||
|
|
cb39a10ada | ||
|
|
c1c93b62d1 | ||
|
|
a9b1242686 | ||
|
|
173f60a1da | ||
|
|
b9c2decb34 |
No files matched your search
@@ -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'
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
Reference in new issue
Block a user