Compare commits

..

43 Commits

Author SHA1 Message Date
2dust 24539be29f up 2.0.7 2026-01-29 17:18:05 +08:00
2dust e951e3d913 Bug fix for tasker plugin 2026-01-29 17:07:42 +08:00
2dust 83e5f1d10a Refactor VPN service stop logic and improve error handling 2026-01-29 16:54:01 +08:00
2dust 47a6623e62 Refactor: move interfaces to contracts package 2026-01-29 15:21:36 +08:00
2dust d6e3fc5c91 Move enums from dto to enums package and update imports 2026-01-29 14:51:59 +08:00
2dust 685d23f2d1 Refactor asset URL handling with AssetUrlCache data class 2026-01-29 14:43:31 +08:00
2dust d46bbdc219 Refactor duplicate server removal logic 2026-01-29 14:34:15 +08:00
2dust be95e6a88e Refactor subscription handling with SubscriptionCache 2026-01-29 14:28:34 +08:00
2dust 2435708628 Refactor to use nullIfBlank 2026-01-29 11:26:48 +08:00
2dust 6cf6057dfa Add support for pinned certificate SHA-256 fingerprints 2026-01-29 11:00:19 +08:00
2dust af92cf5a7d Refactor activity helpers into HelperBaseActivity 2026-01-29 10:12:19 +08:00
2dust 2277de95c0 Add support for file creation using Storage Access Framework 2026-01-28 19:39:42 +08:00
2dust 3c90ee0206 Refactor QR code and file chooser handling in activities 2026-01-28 19:29:31 +08:00
2dust 41fd237a87 Refactor file and permission helpers, update usage 2026-01-28 19:13:34 +08:00
2dust f6bded88dd Refactor file chooser logic into reusable helper 2026-01-28 17:07:57 +08:00
2dust 8600fdba35 Refactor permission handling with PermissionRequester 2026-01-28 16:18:19 +08:00
2dust 2c47abc219 Update dependabot.yml to retain only github-actions ecosystem 2026-01-27 10:02:14 +08:00
Pádraic Slattery 10b0f8c9de chore: Update outdated GitHub Actions versions (#5208) 2026-01-27 09:59:15 +08:00
2dust 53572877fa Add Dependabot configuration 2026-01-27 09:55:35 +08:00
2dust 69e000b23d Add Random and Round Robin policy group types 2026-01-26 20:11:30 +08:00
Hossein Abaspanah a8e09611eb Update strings.xml (#5203) 2026-01-26 08:52:12 +08:00
2dust 4fdc46b8ca Update AGP version and add Gradle build properties
Upgraded Android Gradle Plugin version from 8.13.2 to 9.0.0 in libs.versions.toml. Added and updated several Android and Kotlin build properties in gradle.properties to refine build behavior and feature flags.
2026-01-24 14:16:39 +08:00
2dust ac043da8a2 up 2.0.6 2026-01-24 10:20:18 +08:00
2dust a192fec127 Update AndroidLibXrayLite 2026-01-24 10:19:46 +08:00
2dust b0b09273cf Restrict scanner and decoder to QR codes only 2026-01-23 19:41:21 +08:00
2dust 01a943b7ea Refactor main list adapter with listener interface 2026-01-23 17:09:35 +08:00
2dust 06a3738b59 Remove activity parameter from PerAppProxyAdapter 2026-01-22 18:34:05 +08:00
2dust fa4432584e up 2.0.5 2026-01-21 17:44:03 +08:00
2dust bb1c3d915f Refactor user asset management with ViewModel and adapter 2026-01-21 17:43:34 +08:00
2dust d8983a4761 Bug fix
https://github.com/2dust/v2rayNG/issues/5186
2026-01-20 21:07:51 +08:00
2dust 9815041c77 Remove unused imports and commented debug logs 2026-01-20 10:52:52 +08:00
2dust 41b0983582 Refactor logcat handling to use LogcatViewModel 2026-01-20 10:46:46 +08:00
2dust bb1f677d45 Refactor RoutingSetting adapter to use listener interface 2026-01-20 10:17:57 +08:00
2dust de1c8bcfcc Refactor VPN mode checks and TUN config logic
Centralizes VPN mode detection in SettingsManager with a new isVpnMode() method and updates all usages to reference it. Refactors TUN configuration logic in V2rayConfigManager for clarity and correctness, ensuring proper config and routing rules are applied based on VPN and HEV TUN settings.
2026-01-19 19:43:09 +08:00
2dust ca6e963575 Refactor remarks assignment using ifEmpty in formatters 2026-01-19 19:18:26 +08:00
2dust bfb6813dc7 Fix HevTun settings to preferences UI 2026-01-19 19:18:15 +08:00
2dust cc2b4eec65 up 2.0.4 2026-01-18 15:23:02 +08:00
2dust dd11569969 Add version field to Hysteria2 config 2026-01-18 15:22:33 +08:00
2dust 11c3dd97c3 Update AndroidLibXrayLite 2026-01-18 15:00:07 +08:00
2dust 92f15150cd up 2.0.3 2026-01-18 14:58:40 +08:00
2dust f8e809da8b Refactor adapter callbacks with BaseAdapterListener 2026-01-18 14:58:15 +08:00
DHR60 12b9349e62 Fix sniffing (#5173)
* Fix sniffing

* Fix custom config
2026-01-16 19:52:18 +08:00
DHR60 a580494bde Update sdk (#5174) 2026-01-16 19:22:43 +08:00
90 changed files with 1615 additions and 1164 deletions
+6
View File
@@ -0,0 +1,6 @@
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "daily"
+7 -7
View File
@@ -16,7 +16,7 @@ jobs:
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v5 uses: actions/checkout@v6
with: with:
submodules: 'recursive' submodules: 'recursive'
fetch-depth: '0' fetch-depth: '0'
@@ -25,8 +25,8 @@ jobs:
uses: android-actions/setup-android@v3.2.0 uses: android-actions/setup-android@v3.2.0
with: with:
log-accepted-android-sdk-licenses: false log-accepted-android-sdk-licenses: false
cmdline-tools-version: '12266719' cmdline-tools-version: '13114758'
packages: 'platforms;android-35 build-tools;35.0.0 platform-tools' packages: 'platforms;android-36.1 build-tools;36.1.0 platform-tools'
- name: Install NDK - name: Install NDK
run: | run: |
@@ -78,7 +78,7 @@ jobs:
out-file-path: V2rayNG/app/libs/ out-file-path: V2rayNG/app/libs/
- name: Setup Java - name: Setup Java
uses: actions/setup-java@v4.7.0 uses: actions/setup-java@v5
with: with:
distribution: 'temurin' distribution: 'temurin'
java-version: '21' java-version: '21'
@@ -99,21 +99,21 @@ jobs:
./gradlew assembleRelease -Pandroid.injected.signing.store.file=${{ steps.android_keystore.outputs.filePath }} -Pandroid.injected.signing.store.password=${{ secrets.APP_KEYSTORE_PASSWORD }} -Pandroid.injected.signing.key.alias=${{ secrets.APP_KEYSTORE_ALIAS }} -Pandroid.injected.signing.key.password=${{ secrets.APP_KEY_PASSWORD }} ./gradlew assembleRelease -Pandroid.injected.signing.store.file=${{ steps.android_keystore.outputs.filePath }} -Pandroid.injected.signing.store.password=${{ secrets.APP_KEYSTORE_PASSWORD }} -Pandroid.injected.signing.key.alias=${{ secrets.APP_KEYSTORE_ALIAS }} -Pandroid.injected.signing.key.password=${{ secrets.APP_KEY_PASSWORD }}
- name: Upload arm64-v8a APK - name: Upload arm64-v8a APK
uses: actions/upload-artifact@v4.6.2 uses: actions/upload-artifact@v6
if: ${{ success() }} if: ${{ success() }}
with: with:
name: arm64-v8a name: arm64-v8a
path: ${{ github.workspace }}/V2rayNG/app/build/outputs/apk/*/release/*arm64-v8a*.apk path: ${{ github.workspace }}/V2rayNG/app/build/outputs/apk/*/release/*arm64-v8a*.apk
- name: Upload armeabi-v7a APK - name: Upload armeabi-v7a APK
uses: actions/upload-artifact@v4.6.2 uses: actions/upload-artifact@v6
if: ${{ success() }} if: ${{ success() }}
with: with:
name: armeabi-v7a name: armeabi-v7a
path: ${{ github.workspace }}/V2rayNG/app/build/outputs/apk/*/release/*armeabi-v7a*.apk path: ${{ github.workspace }}/V2rayNG/app/build/outputs/apk/*/release/*armeabi-v7a*.apk
- name: Upload x86 APK - name: Upload x86 APK
uses: actions/upload-artifact@v4.6.2 uses: actions/upload-artifact@v6
if: ${{ success() }} if: ${{ success() }}
with: with:
name: x86-apk name: x86-apk
+1 -1
View File
@@ -11,6 +11,6 @@ jobs:
go: go:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v6
- name: Validate Fastlane Supply Metadata - name: Validate Fastlane Supply Metadata
uses: ashutoshgngwr/validate-fastlane-supply-metadata@v2.1.0 uses: ashutoshgngwr/validate-fastlane-supply-metadata@v2.1.0
+2 -2
View File
@@ -12,8 +12,8 @@ android {
applicationId = "com.v2ray.ang" applicationId = "com.v2ray.ang"
minSdk = 24 minSdk = 24
targetSdk = 36 targetSdk = 36
versionCode = 702 versionCode = 707
versionName = "2.0.2" versionName = "2.0.7"
multiDexEnabled = true multiDexEnabled = true
val abiFilterList = (properties["ABI_FILTERS"] as? String)?.split(';') val abiFilterList = (properties["ABI_FILTERS"] as? String)?.split(';')
@@ -42,6 +42,13 @@
"name": "xray0", "name": "xray0",
"MTU": 1500, "MTU": 1500,
"userLevel": 8 "userLevel": 8
},
"sniffing": {
"enabled": true,
"destOverride": [
"http",
"tls"
]
} }
} }
], ],
@@ -97,6 +97,7 @@ object AppConfig {
const val TAG_FRAGMENT = "fragment" const val TAG_FRAGMENT = "fragment"
const val TAG_DNS = "dns-module" const val TAG_DNS = "dns-module"
const val TAG_DOMESTIC_DNS = "domestic-dns" const val TAG_DOMESTIC_DNS = "domestic-dns"
const val TAG_BALANCER = "proxy-round"
/** Network-related constants. */ /** Network-related constants. */
const val UPLINK = "uplink" const val UPLINK = "uplink"
@@ -0,0 +1,32 @@
package com.v2ray.ang.contracts
/**
* A common Adapter -> host callback interface that includes common actions: edit, remove and refresh.
* Extend this interface or define more specific interfaces for different adapters as needed.
*/
interface BaseAdapterListener {
/**
* Request the host to edit the specified item.
* @param guid Unique identifier (GUID) of the item
* @param position Current position in the adapter (optional; host should validate it)
*/
fun onEdit(guid: String, position: Int)
/**
* Request the host to remove the specified item. Position is provided for optional animation or validation.
* @param guid Unique identifier (GUID) of the item
* @param position Current position in the adapter (optional; host should validate it)
*/
fun onRemove(guid: String, position: Int)
/**
* Request the host to share the specified URL.
* @param url The URL to be shared
*/
fun onShare(url: String)
/**
* Request the host to refresh data (for example, reload from the ViewModel or call notifyDataSetChanged).
*/
fun onRefreshData()
}
@@ -0,0 +1,13 @@
package com.v2ray.ang.contracts
import com.v2ray.ang.dto.ProfileItem
interface MainAdapterListener :BaseAdapterListener {
fun onEdit(guid: String, position: Int, profile: ProfileItem)
fun onSelectServer(guid: String)
fun onShare(guid: String, profile: ProfileItem, position: Int, more: Boolean)
}
@@ -1,4 +1,4 @@
package com.v2ray.ang.service package com.v2ray.ang.contracts
import android.app.Service import android.app.Service
@@ -1,4 +1,4 @@
package com.v2ray.ang.service package com.v2ray.ang.contracts
/** /**
* Interface that defines the control operations for tun2socks implementations. * Interface that defines the control operations for tun2socks implementations.
@@ -0,0 +1,6 @@
package com.v2ray.ang.dto
data class AssetUrlCache(
val guid: String,
val assetUrl: AssetUrlItem
)
@@ -5,6 +5,7 @@ import com.v2ray.ang.AppConfig.PORT_SOCKS
import com.v2ray.ang.AppConfig.TAG_BLOCKED import com.v2ray.ang.AppConfig.TAG_BLOCKED
import com.v2ray.ang.AppConfig.TAG_DIRECT import com.v2ray.ang.AppConfig.TAG_DIRECT
import com.v2ray.ang.AppConfig.TAG_PROXY import com.v2ray.ang.AppConfig.TAG_PROXY
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.util.Utils import com.v2ray.ang.util.Utils
data class ProfileItem( data class ProfileItem(
@@ -42,6 +43,7 @@ data class ProfileItem(
var insecure: Boolean? = null, var insecure: Boolean? = null,
var echConfigList: String? = null, var echConfigList: String? = null,
var echForceQuery: String? = null, var echForceQuery: String? = null,
var pinnedCA256: String? = null,
var publicKey: String? = null, var publicKey: String? = null,
var shortId: String? = null, var shortId: String? = null,
@@ -0,0 +1,6 @@
package com.v2ray.ang.dto
data class SubscriptionCache(
val guid: String,
val subscription: SubscriptionItem
)
@@ -2,6 +2,7 @@ package com.v2ray.ang.dto
import com.google.gson.annotations.SerializedName import com.google.gson.annotations.SerializedName
import com.v2ray.ang.AppConfig import com.v2ray.ang.AppConfig
import com.v2ray.ang.enums.EConfigType
data class V2rayConfig( data class V2rayConfig(
var remarks: String? = null, var remarks: String? = null,
@@ -34,7 +35,7 @@ data class V2rayConfig(
var protocol: String, var protocol: String,
var listen: String? = null, var listen: String? = null,
var settings: InSettingsBean? = null, var settings: InSettingsBean? = null,
val sniffing: SniffingBean? = null, var sniffing: SniffingBean? = null,
val streamSettings: Any? = null, val streamSettings: Any? = null,
val allocate: Any? = null val allocate: Any? = null
) { ) {
@@ -88,6 +89,7 @@ data class V2rayConfig(
var reserved: List<Int>? = null, var reserved: List<Int>? = null,
var mtu: Int? = null, var mtu: Int? = null,
var obfsPassword: String? = null, var obfsPassword: String? = null,
var version: Int? = null,
) { ) {
data class VnextBean( data class VnextBean(
@@ -270,6 +272,7 @@ data class V2rayConfig(
val enableSessionResumption: Boolean? = null, val enableSessionResumption: Boolean? = null,
var echConfigList: String? = null, var echConfigList: String? = null,
var echForceQuery: String? = null, var echForceQuery: String? = null,
var pinnedPeerCertSha256: String? = null,
// REALITY settings // REALITY settings
val show: Boolean = false, val show: Boolean = false,
var publicKey: String? = null, var publicKey: String? = null,
@@ -1,8 +1,7 @@
package com.v2ray.ang.dto package com.v2ray.ang.enums
import com.v2ray.ang.AppConfig import com.v2ray.ang.AppConfig
enum class EConfigType(val value: Int, val protocolScheme: String) { enum class EConfigType(val value: Int, val protocolScheme: String) {
VMESS(1, AppConfig.VMESS), VMESS(1, AppConfig.VMESS),
CUSTOM(2, AppConfig.CUSTOM), CUSTOM(2, AppConfig.CUSTOM),
@@ -21,4 +20,4 @@ enum class EConfigType(val value: Int, val protocolScheme: String) {
companion object { companion object {
fun fromInt(value: Int) = entries.firstOrNull { it.value == value } fun fromInt(value: Int) = entries.firstOrNull { it.value == value }
} }
} }
@@ -1,4 +1,4 @@
package com.v2ray.ang.dto package com.v2ray.ang.enums
enum class Language(val code: String) { enum class Language(val code: String) {
AUTO("auto"), AUTO("auto"),
@@ -1,4 +1,4 @@
package com.v2ray.ang.dto package com.v2ray.ang.enums
enum class NetworkType(val type: String) { enum class NetworkType(val type: String) {
TCP("tcp"), TCP("tcp"),
@@ -0,0 +1,44 @@
package com.v2ray.ang.enums
import android.Manifest
import android.os.Build
import androidx.annotation.RequiresApi
/**
* Permission types used in the app, handling API level differences.
*/
enum class PermissionType {
/** Camera permission (used for scanning QR codes) */
CAMERA {
override fun getPermission(): String = Manifest.permission.CAMERA
},
/** Read storage / media permission (adapts to Android version) */
READ_STORAGE {
override fun getPermission(): String {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
Manifest.permission.READ_MEDIA_IMAGES
} else {
Manifest.permission.READ_EXTERNAL_STORAGE
}
}
},
/** Notification permission (Android 13+) */
POST_NOTIFICATIONS {
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
override fun getPermission(): String = Manifest.permission.POST_NOTIFICATIONS
};
/** Return the actual Android permission string */
abstract fun getPermission(): String
/** Return a human-readable label for the permission */
fun getLabel(): String {
return when (this) {
CAMERA -> "Camera"
READ_STORAGE -> "Storage"
POST_NOTIFICATIONS -> "Notification"
}
}
}
@@ -1,4 +1,4 @@
package com.v2ray.ang.dto package com.v2ray.ang.enums
enum class RoutingType(val fileName: String) { enum class RoutingType(val fileName: String) {
WHITE("custom_routing_white"), WHITE("custom_routing_white"),
@@ -1,4 +1,4 @@
package com.v2ray.ang.dto package com.v2ray.ang.enums
/** /**
* VPN interface address configuration enum class * VPN interface address configuration enum class
@@ -11,6 +11,7 @@ import com.v2ray.ang.AngApplication
import es.dmoral.toasty.Toasty import es.dmoral.toasty.Toasty
import java.io.Serializable import java.io.Serializable
import java.net.URI import java.net.URI
import java.util.Locale
val Context.v2RayApplication: AngApplication? val Context.v2RayApplication: AngApplication?
get() = applicationContext as? AngApplication get() = applicationContext as? AngApplication
@@ -92,7 +93,7 @@ fun Long.toTrafficString(): String {
size /= DIVISOR size /= DIVISOR
unitIndex++ unitIndex++
} }
return String.format("%.1f %s", size, units[unitIndex]) return String.format(Locale.getDefault(), "%.1f %s", size, units[unitIndex])
} }
val URI.idnHost: String val URI.idnHost: String
@@ -105,6 +106,13 @@ val URI.idnHost: String
*/ */
fun String?.removeWhiteSpace(): String? = this?.replace(" ", "") fun String?.removeWhiteSpace(): String? = this?.replace(" ", "")
/**
* Returns null if the string is null or blank, otherwise returns the string itself.
*
* @return The string or null.
*/
fun String?.nullIfBlank(): String? = this?.takeIf { it.isNotBlank() }
/** /**
* Converts the string to a Long value, or returns 0 if the conversion fails. * Converts the string to a Long value, or returns 0 if the conversion fails.
* *
@@ -1,6 +1,6 @@
package com.v2ray.ang.fmt package com.v2ray.ang.fmt
import com.v2ray.ang.dto.EConfigType import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.dto.ProfileItem import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.V2rayConfig import com.v2ray.ang.dto.V2rayConfig
import com.v2ray.ang.util.JsonUtil import com.v2ray.ang.util.JsonUtil
@@ -1,9 +1,9 @@
package com.v2ray.ang.fmt package com.v2ray.ang.fmt
import com.v2ray.ang.AppConfig import com.v2ray.ang.AppConfig
import com.v2ray.ang.dto.NetworkType import com.v2ray.ang.enums.NetworkType
import com.v2ray.ang.dto.ProfileItem import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.extension.isNotNullEmpty import com.v2ray.ang.extension.nullIfBlank
import com.v2ray.ang.handler.MmkvManager import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.util.HttpUtil import com.v2ray.ang.util.HttpUtil
import com.v2ray.ang.util.Utils import com.v2ray.ang.util.Utils
@@ -83,6 +83,7 @@ open class FmtBase {
config.fingerPrint = queryParam["fp"] config.fingerPrint = queryParam["fp"]
config.alpn = queryParam["alpn"] config.alpn = queryParam["alpn"]
config.echConfigList = queryParam["ech"] config.echConfigList = queryParam["ech"]
config.pinnedCA256 = queryParam["pcs"]
config.publicKey = queryParam["pbk"] config.publicKey = queryParam["pbk"]
config.shortId = queryParam["sid"] config.shortId = queryParam["sid"]
config.spiderX = queryParam["spx"] config.spiderX = queryParam["spx"]
@@ -99,15 +100,16 @@ open class FmtBase {
fun getQueryDic(config: ProfileItem): HashMap<String, String> { fun getQueryDic(config: ProfileItem): HashMap<String, String> {
val dicQuery = HashMap<String, String>() val dicQuery = HashMap<String, String>()
dicQuery["security"] = config.security?.ifEmpty { "none" }.orEmpty() dicQuery["security"] = config.security?.ifEmpty { "none" }.orEmpty()
config.sni.let { if (it.isNotNullEmpty()) dicQuery["sni"] = it.orEmpty() } config.sni?.nullIfBlank()?.let { dicQuery["sni"] = it }
config.alpn.let { if (it.isNotNullEmpty()) dicQuery["alpn"] = it.orEmpty() } config.alpn?.nullIfBlank()?.let { dicQuery["alpn"] = it }
config.echConfigList.let { if (it.isNotNullEmpty()) dicQuery["ech"] = it.orEmpty() } config.echConfigList?.nullIfBlank()?.let { dicQuery["ech"] = it }
config.fingerPrint.let { if (it.isNotNullEmpty()) dicQuery["fp"] = it.orEmpty() } config.pinnedCA256?.nullIfBlank()?.let { dicQuery["pcs"] = it }
config.publicKey.let { if (it.isNotNullEmpty()) dicQuery["pbk"] = it.orEmpty() } config.fingerPrint?.nullIfBlank()?.let { dicQuery["fp"] = it }
config.shortId.let { if (it.isNotNullEmpty()) dicQuery["sid"] = it.orEmpty() } config.publicKey?.nullIfBlank()?.let { dicQuery["pbk"] = it }
config.spiderX.let { if (it.isNotNullEmpty()) dicQuery["spx"] = it.orEmpty() } config.shortId?.nullIfBlank()?.let { dicQuery["sid"] = it }
config.mldsa65Verify.let { if (it.isNotNullEmpty()) dicQuery["pqv"] = it.orEmpty() } config.spiderX?.nullIfBlank()?.let { dicQuery["spx"] = it }
config.flow.let { if (it.isNotNullEmpty()) dicQuery["flow"] = it.orEmpty() } config.mldsa65Verify?.nullIfBlank()?.let { dicQuery["pqv"] = it }
config.flow?.nullIfBlank()?.let { dicQuery["flow"] = it }
// Add two keys for compatibility: "insecure" and "allowInsecure" // Add two keys for compatibility: "insecure" and "allowInsecure"
if (config.security == AppConfig.TLS) { if (config.security == AppConfig.TLS) {
val insecureFlag = if (config.insecure == true) "1" else "0" val insecureFlag = if (config.insecure == true) "1" else "0"
@@ -121,42 +123,42 @@ open class FmtBase {
when (networkType) { when (networkType) {
NetworkType.TCP -> { NetworkType.TCP -> {
dicQuery["headerType"] = config.headerType?.ifEmpty { "none" }.orEmpty() dicQuery["headerType"] = config.headerType?.ifEmpty { "none" }.orEmpty()
config.host.let { if (it.isNotNullEmpty()) dicQuery["host"] = it.orEmpty() } config.host?.nullIfBlank()?.let { dicQuery["host"] = it }
} }
NetworkType.KCP -> { NetworkType.KCP -> {
dicQuery["headerType"] = config.headerType?.ifEmpty { "none" }.orEmpty() dicQuery["headerType"] = config.headerType?.ifEmpty { "none" }.orEmpty()
config.seed.let { if (it.isNotNullEmpty()) dicQuery["seed"] = it.orEmpty() } config.seed?.nullIfBlank()?.let { dicQuery["seed"] = it }
} }
NetworkType.WS, NetworkType.HTTP_UPGRADE -> { NetworkType.WS, NetworkType.HTTP_UPGRADE -> {
config.host.let { if (it.isNotNullEmpty()) dicQuery["host"] = it.orEmpty() } config.host?.nullIfBlank()?.let { dicQuery["host"] = it }
config.path.let { if (it.isNotNullEmpty()) dicQuery["path"] = it.orEmpty() } config.path?.nullIfBlank()?.let { dicQuery["path"] = it }
} }
NetworkType.XHTTP -> { NetworkType.XHTTP -> {
config.host.let { if (it.isNotNullEmpty()) dicQuery["host"] = it.orEmpty() } config.host?.nullIfBlank()?.let { dicQuery["host"] = it }
config.path.let { if (it.isNotNullEmpty()) dicQuery["path"] = it.orEmpty() } config.path?.nullIfBlank()?.let { dicQuery["path"] = it }
config.xhttpMode.let { if (it.isNotNullEmpty()) dicQuery["mode"] = it.orEmpty() } config.xhttpMode?.nullIfBlank()?.let { dicQuery["mode"] = it }
config.xhttpExtra.let { if (it.isNotNullEmpty()) dicQuery["extra"] = it.orEmpty() } config.xhttpExtra?.nullIfBlank()?.let { dicQuery["extra"] = it }
} }
NetworkType.HTTP, NetworkType.H2 -> { NetworkType.HTTP, NetworkType.H2 -> {
dicQuery["type"] = "http" dicQuery["type"] = "http"
config.host.let { if (it.isNotNullEmpty()) dicQuery["host"] = it.orEmpty() } config.host?.nullIfBlank()?.let { dicQuery["host"] = it }
config.path.let { if (it.isNotNullEmpty()) dicQuery["path"] = it.orEmpty() } config.path?.nullIfBlank()?.let { dicQuery["path"] = it }
} }
// NetworkType.QUIC -> { // NetworkType.QUIC -> {
// dicQuery["headerType"] = config.headerType?.ifEmpty { "none" }.orEmpty() // dicQuery["headerType"] = config.headerType?.ifEmpty { "none" }.orEmpty()
// config.quicSecurity.let { if (it.isNotNullEmpty()) dicQuery["quicSecurity"] = it.orEmpty() } // config.quicSecurity?.nullIfBlank()?.let { dicQuery["quicSecurity"] = it }
// config.quicKey.let { if (it.isNotNullEmpty()) dicQuery["key"] = it.orEmpty() } // config.quicKey?.nullIfBlank()?.let { dicQuery["key"] = it }
// } // }
NetworkType.GRPC -> { NetworkType.GRPC -> {
config.mode.let { if (it.isNotNullEmpty()) dicQuery["mode"] = it.orEmpty() } config.mode?.nullIfBlank()?.let { dicQuery["mode"] = it }
config.authority.let { if (it.isNotNullEmpty()) dicQuery["authority"] = it.orEmpty() } config.authority?.nullIfBlank()?.let { dicQuery["authority"] = it }
config.serviceName.let { if (it.isNotNullEmpty()) dicQuery["serviceName"] = it.orEmpty() } config.serviceName?.nullIfBlank()?.let { dicQuery["serviceName"] = it }
} }
else -> {} else -> {}
@@ -1,6 +1,6 @@
package com.v2ray.ang.fmt package com.v2ray.ang.fmt
import com.v2ray.ang.dto.EConfigType import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.dto.ProfileItem import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.V2rayConfig.OutboundBean import com.v2ray.ang.dto.V2rayConfig.OutboundBean
import com.v2ray.ang.extension.isNotNullEmpty import com.v2ray.ang.extension.isNotNullEmpty
@@ -1,14 +1,15 @@
package com.v2ray.ang.fmt package com.v2ray.ang.fmt
import com.v2ray.ang.AppConfig import com.v2ray.ang.AppConfig
import com.v2ray.ang.dto.EConfigType import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.dto.NetworkType import com.v2ray.ang.enums.NetworkType
import com.v2ray.ang.dto.ProfileItem import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.V2rayConfig.OutboundBean import com.v2ray.ang.dto.V2rayConfig.OutboundBean
import com.v2ray.ang.dto.V2rayConfig.OutboundBean.StreamSettingsBean import com.v2ray.ang.dto.V2rayConfig.OutboundBean.StreamSettingsBean
import com.v2ray.ang.dto.V2rayConfig.OutboundBean.StreamSettingsBean.UdpMasksBean.UdpMasksSettingsBean import com.v2ray.ang.dto.V2rayConfig.OutboundBean.StreamSettingsBean.UdpMasksBean.UdpMasksSettingsBean
import com.v2ray.ang.extension.idnHost import com.v2ray.ang.extension.idnHost
import com.v2ray.ang.extension.isNotNullEmpty import com.v2ray.ang.extension.isNotNullEmpty
import com.v2ray.ang.extension.nullIfBlank
import com.v2ray.ang.handler.MmkvManager import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.V2rayConfigManager import com.v2ray.ang.handler.V2rayConfigManager
import com.v2ray.ang.util.Utils import com.v2ray.ang.util.Utils
@@ -26,7 +27,7 @@ object Hysteria2Fmt : FmtBase() {
val config = ProfileItem.create(EConfigType.HYSTERIA2) val config = ProfileItem.create(EConfigType.HYSTERIA2)
val uri = URI(Utils.fixIllegalUrl(str)) val uri = URI(Utils.fixIllegalUrl(str))
config.remarks = Utils.urlDecode(uri.fragment.orEmpty()).let { if (it.isEmpty()) "none" else it } config.remarks = Utils.urlDecode(uri.fragment.orEmpty()).let { it.ifEmpty { "none" } }
config.server = uri.idnHost config.server = uri.idnHost
config.serverPort = uri.port.toString() config.serverPort = uri.port.toString()
config.password = uri.userInfo config.password = uri.userInfo
@@ -61,8 +62,8 @@ object Hysteria2Fmt : FmtBase() {
val dicQuery = HashMap<String, String>() val dicQuery = HashMap<String, String>()
config.security.let { if (it != null) dicQuery["security"] = it } config.security.let { if (it != null) dicQuery["security"] = it }
config.sni.let { if (it.isNotNullEmpty()) dicQuery["sni"] = it.orEmpty() } config.sni?.nullIfBlank()?.let { dicQuery["sni"] = it }
config.alpn.let { if (it.isNotNullEmpty()) dicQuery["alpn"] = it.orEmpty() } config.alpn?.nullIfBlank()?.let { dicQuery["alpn"] = it }
config.insecure.let { dicQuery["insecure"] = if (it == true) "1" else "0" } config.insecure.let { dicQuery["insecure"] = if (it == true) "1" else "0" }
if (config.obfsPassword.isNotNullEmpty()) { if (config.obfsPassword.isNotNullEmpty()) {
@@ -96,6 +97,7 @@ object Hysteria2Fmt : FmtBase() {
outboundBean.settings?.let { server -> outboundBean.settings?.let { server ->
server.address = getServerAddress(profileItem) server.address = getServerAddress(profileItem)
server.port = profileItem.serverPort.orEmpty().toInt() server.port = profileItem.serverPort.orEmpty().toInt()
server.version = 2
} }
val sni = outboundBean.streamSettings?.let { val sni = outboundBean.streamSettings?.let {
@@ -2,8 +2,8 @@ package com.v2ray.ang.fmt
import android.util.Log import android.util.Log
import com.v2ray.ang.AppConfig import com.v2ray.ang.AppConfig
import com.v2ray.ang.dto.EConfigType import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.dto.NetworkType import com.v2ray.ang.enums.NetworkType
import com.v2ray.ang.dto.ProfileItem import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.V2rayConfig.OutboundBean import com.v2ray.ang.dto.V2rayConfig.OutboundBean
import com.v2ray.ang.extension.idnHost import com.v2ray.ang.extension.idnHost
@@ -36,7 +36,7 @@ object ShadowsocksFmt : FmtBase() {
if (uri.port <= 0) return null if (uri.port <= 0) return null
if (uri.userInfo.isNullOrEmpty()) return null if (uri.userInfo.isNullOrEmpty()) return null
config.remarks = Utils.urlDecode(uri.fragment.orEmpty()).let { if (it.isEmpty()) "none" else it } config.remarks = Utils.urlDecode(uri.fragment.orEmpty()).let { it.ifEmpty { "none" } }
config.server = uri.idnHost config.server = uri.idnHost
config.serverPort = uri.port.toString() config.serverPort = uri.port.toString()
@@ -1,6 +1,6 @@
package com.v2ray.ang.fmt package com.v2ray.ang.fmt
import com.v2ray.ang.dto.EConfigType import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.dto.ProfileItem import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.V2rayConfig.OutboundBean import com.v2ray.ang.dto.V2rayConfig.OutboundBean
import com.v2ray.ang.extension.idnHost import com.v2ray.ang.extension.idnHost
@@ -23,7 +23,7 @@ object SocksFmt : FmtBase() {
if (uri.idnHost.isEmpty()) return null if (uri.idnHost.isEmpty()) return null
if (uri.port <= 0) return null if (uri.port <= 0) return null
config.remarks = Utils.urlDecode(uri.fragment.orEmpty()).let { if (it.isEmpty()) "none" else it } config.remarks = Utils.urlDecode(uri.fragment.orEmpty()).let { it.ifEmpty { "none" } }
config.server = uri.idnHost config.server = uri.idnHost
config.serverPort = uri.port.toString() config.serverPort = uri.port.toString()
@@ -1,8 +1,8 @@
package com.v2ray.ang.fmt package com.v2ray.ang.fmt
import com.v2ray.ang.AppConfig import com.v2ray.ang.AppConfig
import com.v2ray.ang.dto.EConfigType import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.dto.NetworkType import com.v2ray.ang.enums.NetworkType
import com.v2ray.ang.dto.ProfileItem import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.V2rayConfig.OutboundBean import com.v2ray.ang.dto.V2rayConfig.OutboundBean
import com.v2ray.ang.extension.idnHost import com.v2ray.ang.extension.idnHost
@@ -23,7 +23,7 @@ object TrojanFmt : FmtBase() {
val config = ProfileItem.create(EConfigType.TROJAN) val config = ProfileItem.create(EConfigType.TROJAN)
val uri = URI(Utils.fixIllegalUrl(str)) val uri = URI(Utils.fixIllegalUrl(str))
config.remarks = Utils.urlDecode(uri.fragment.orEmpty()).let { if (it.isEmpty()) "none" else it } config.remarks = Utils.urlDecode(uri.fragment.orEmpty()).let { it.ifEmpty { "none" } }
config.server = uri.idnHost config.server = uri.idnHost
config.serverPort = uri.port.toString() config.serverPort = uri.port.toString()
config.password = uri.userInfo config.password = uri.userInfo
@@ -1,7 +1,7 @@
package com.v2ray.ang.fmt package com.v2ray.ang.fmt
import com.v2ray.ang.AppConfig import com.v2ray.ang.AppConfig
import com.v2ray.ang.dto.EConfigType import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.dto.ProfileItem import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.V2rayConfig.OutboundBean import com.v2ray.ang.dto.V2rayConfig.OutboundBean
import com.v2ray.ang.extension.idnHost import com.v2ray.ang.extension.idnHost
@@ -26,7 +26,7 @@ object VlessFmt : FmtBase() {
if (uri.rawQuery.isNullOrEmpty()) return null if (uri.rawQuery.isNullOrEmpty()) return null
val queryParam = getQueryParam(uri) val queryParam = getQueryParam(uri)
config.remarks = Utils.urlDecode(uri.fragment.orEmpty()).let { if (it.isEmpty()) "none" else it } config.remarks = Utils.urlDecode(uri.fragment.orEmpty()).let { it.ifEmpty { "none" } }
config.server = uri.idnHost config.server = uri.idnHost
config.serverPort = uri.port.toString() config.serverPort = uri.port.toString()
config.password = uri.userInfo config.password = uri.userInfo
@@ -3,13 +3,13 @@ package com.v2ray.ang.fmt
import android.text.TextUtils import android.text.TextUtils
import android.util.Log import android.util.Log
import com.v2ray.ang.AppConfig import com.v2ray.ang.AppConfig
import com.v2ray.ang.dto.EConfigType import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.dto.NetworkType import com.v2ray.ang.enums.NetworkType
import com.v2ray.ang.dto.ProfileItem import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.V2rayConfig.OutboundBean import com.v2ray.ang.dto.V2rayConfig.OutboundBean
import com.v2ray.ang.dto.VmessQRCode import com.v2ray.ang.dto.VmessQRCode
import com.v2ray.ang.extension.idnHost import com.v2ray.ang.extension.idnHost
import com.v2ray.ang.extension.isNotNullEmpty import com.v2ray.ang.extension.nullIfBlank
import com.v2ray.ang.handler.MmkvManager import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.V2rayConfigManager import com.v2ray.ang.handler.V2rayConfigManager
import com.v2ray.ang.util.JsonUtil import com.v2ray.ang.util.JsonUtil
@@ -132,8 +132,8 @@ object VmessFmt : FmtBase() {
else -> {} else -> {}
} }
config.host.let { if (it.isNotNullEmpty()) vmessQRCode.host = it.orEmpty() } config.host?.nullIfBlank()?.let { vmessQRCode.host = it }
config.path.let { if (it.isNotNullEmpty()) vmessQRCode.path = it.orEmpty() } config.path?.nullIfBlank()?.let { vmessQRCode.path = it }
vmessQRCode.tls = config.security.orEmpty() vmessQRCode.tls = config.security.orEmpty()
vmessQRCode.sni = config.sni.orEmpty() vmessQRCode.sni = config.sni.orEmpty()
@@ -163,7 +163,7 @@ object VmessFmt : FmtBase() {
if (uri.rawQuery.isNullOrEmpty()) return null if (uri.rawQuery.isNullOrEmpty()) return null
val queryParam = getQueryParam(uri) val queryParam = getQueryParam(uri)
config.remarks = Utils.urlDecode(uri.fragment.orEmpty()).let { if (it.isEmpty()) "none" else it } config.remarks = Utils.urlDecode(uri.fragment.orEmpty()).let { it.ifEmpty { "none" } }
config.server = uri.idnHost config.server = uri.idnHost
config.serverPort = uri.port.toString() config.serverPort = uri.port.toString()
config.password = uri.userInfo config.password = uri.userInfo
@@ -2,10 +2,11 @@ package com.v2ray.ang.fmt
import com.v2ray.ang.AppConfig import com.v2ray.ang.AppConfig
import com.v2ray.ang.AppConfig.WIREGUARD_LOCAL_ADDRESS_V4 import com.v2ray.ang.AppConfig.WIREGUARD_LOCAL_ADDRESS_V4
import com.v2ray.ang.dto.EConfigType import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.dto.ProfileItem import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.V2rayConfig.OutboundBean import com.v2ray.ang.dto.V2rayConfig.OutboundBean
import com.v2ray.ang.extension.idnHost import com.v2ray.ang.extension.idnHost
import com.v2ray.ang.extension.nullIfBlank
import com.v2ray.ang.extension.removeWhiteSpace import com.v2ray.ang.extension.removeWhiteSpace
import com.v2ray.ang.handler.V2rayConfigManager import com.v2ray.ang.handler.V2rayConfigManager
import com.v2ray.ang.util.Utils import com.v2ray.ang.util.Utils
@@ -25,14 +26,14 @@ object WireguardFmt : FmtBase() {
if (uri.rawQuery.isNullOrEmpty()) return null if (uri.rawQuery.isNullOrEmpty()) return null
val queryParam = getQueryParam(uri) val queryParam = getQueryParam(uri)
config.remarks = Utils.urlDecode(uri.fragment.orEmpty()).let { if (it.isEmpty()) "none" else it } config.remarks = Utils.urlDecode(uri.fragment.orEmpty()).let { it.ifEmpty { "none" } }
config.server = uri.idnHost config.server = uri.idnHost
config.serverPort = uri.port.toString() config.serverPort = uri.port.toString()
config.secretKey = uri.userInfo.orEmpty() config.secretKey = uri.userInfo.orEmpty()
config.localAddress = queryParam["address"] ?: WIREGUARD_LOCAL_ADDRESS_V4 config.localAddress = queryParam["address"] ?: WIREGUARD_LOCAL_ADDRESS_V4
config.publicKey = queryParam["publickey"].orEmpty() config.publicKey = queryParam["publickey"].orEmpty()
config.preSharedKey = queryParam["presharedkey"]?.takeIf { it.isNotEmpty() } config.preSharedKey = queryParam["presharedkey"]?.nullIfBlank()
config.mtu = Utils.parseInt(queryParam["mtu"] ?: AppConfig.WIREGUARD_LOCAL_MTU) config.mtu = Utils.parseInt(queryParam["mtu"] ?: AppConfig.WIREGUARD_LOCAL_MTU)
config.reserved = queryParam["reserved"] ?: "0,0,0" config.reserved = queryParam["reserved"] ?: "0,0,0"
@@ -84,7 +85,7 @@ object WireguardFmt : FmtBase() {
config.localAddress = interfaceParams["address"] ?: WIREGUARD_LOCAL_ADDRESS_V4 config.localAddress = interfaceParams["address"] ?: WIREGUARD_LOCAL_ADDRESS_V4
config.mtu = Utils.parseInt(interfaceParams["mtu"] ?: AppConfig.WIREGUARD_LOCAL_MTU) config.mtu = Utils.parseInt(interfaceParams["mtu"] ?: AppConfig.WIREGUARD_LOCAL_MTU)
config.publicKey = peerParams["publickey"].orEmpty() config.publicKey = peerParams["publickey"].orEmpty()
config.preSharedKey = peerParams["presharedkey"]?.takeIf { it.isNotEmpty() } config.preSharedKey = peerParams["presharedkey"]?.nullIfBlank()
val endpoint = peerParams["endpoint"].orEmpty() val endpoint = peerParams["endpoint"].orEmpty()
val endpointParts = endpoint.split(":", limit = 2) val endpointParts = endpoint.split(":", limit = 2)
if (endpointParts.size == 2) { if (endpointParts.size == 2) {
@@ -113,7 +114,7 @@ object WireguardFmt : FmtBase() {
wireguard.address = (profileItem.localAddress ?: WIREGUARD_LOCAL_ADDRESS_V4).split(",") wireguard.address = (profileItem.localAddress ?: WIREGUARD_LOCAL_ADDRESS_V4).split(",")
wireguard.peers?.firstOrNull()?.let { peer -> wireguard.peers?.firstOrNull()?.let { peer ->
peer.publicKey = profileItem.publicKey.orEmpty() peer.publicKey = profileItem.publicKey.orEmpty()
peer.preSharedKey = profileItem.preSharedKey?.takeIf { it.isNotEmpty() } peer.preSharedKey = profileItem.preSharedKey?.nullIfBlank()
peer.endpoint = Utils.getIpv6Address(profileItem.server) + ":${profileItem.serverPort}" peer.endpoint = Utils.getIpv6Address(profileItem.server) + ":${profileItem.serverPort}"
} }
wireguard.mtu = profileItem.mtu wireguard.mtu = profileItem.mtu
@@ -7,8 +7,9 @@ import android.util.Log
import com.v2ray.ang.AppConfig import com.v2ray.ang.AppConfig
import com.v2ray.ang.AppConfig.HY2 import com.v2ray.ang.AppConfig.HY2
import com.v2ray.ang.R import com.v2ray.ang.R
import com.v2ray.ang.dto.EConfigType import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.dto.ProfileItem import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.SubscriptionCache
import com.v2ray.ang.dto.SubscriptionItem import com.v2ray.ang.dto.SubscriptionItem
import com.v2ray.ang.fmt.CustomFmt import com.v2ray.ang.fmt.CustomFmt
import com.v2ray.ang.fmt.Hysteria2Fmt import com.v2ray.ang.fmt.Hysteria2Fmt
@@ -399,28 +400,28 @@ object AngConfigManager {
* @param it The subscription item. * @param it The subscription item.
* @return The number of configurations updated. * @return The number of configurations updated.
*/ */
fun updateConfigViaSub(it: Pair<String, SubscriptionItem>): Int { fun updateConfigViaSub(it: SubscriptionCache): Int {
try { try {
if (TextUtils.isEmpty(it.first) if (TextUtils.isEmpty(it.guid)
|| TextUtils.isEmpty(it.second.remarks) || TextUtils.isEmpty(it.subscription.remarks)
|| TextUtils.isEmpty(it.second.url) || TextUtils.isEmpty(it.subscription.url)
) { ) {
return 0 return 0
} }
if (!it.second.enabled) { if (!it.subscription.enabled) {
return 0 return 0
} }
val url = HttpUtil.toIdnUrl(it.second.url) val url = HttpUtil.toIdnUrl(it.subscription.url)
if (!Utils.isValidUrl(url)) { if (!Utils.isValidUrl(url)) {
return 0 return 0
} }
if (!it.second.allowInsecureUrl) { if (!it.subscription.allowInsecureUrl) {
if (!Utils.isValidSubUrl(url)) { if (!Utils.isValidSubUrl(url)) {
return 0 return 0
} }
} }
Log.i(AppConfig.TAG, url) Log.i(AppConfig.TAG, url)
val userAgent = it.second.userAgent val userAgent = it.subscription.userAgent
var configText = try { var configText = try {
val httpPort = SettingsManager.getHttpPort() val httpPort = SettingsManager.getHttpPort()
@@ -440,11 +441,11 @@ object AngConfigManager {
if (configText.isEmpty()) { if (configText.isEmpty()) {
return 0 return 0
} }
val count = parseConfigViaSub(configText, it.first, false) val count = parseConfigViaSub(configText, it.guid, false)
if (count > 0) { if (count > 0) {
it.second.lastUpdated = System.currentTimeMillis() it.subscription.lastUpdated = System.currentTimeMillis()
MmkvManager.encodeSubscription(it.first, it.second) MmkvManager.encodeSubscription(it.guid, it.subscription)
Log.i(AppConfig.TAG, "Subscription updated: ${it.second.remarks}, $count configs") Log.i(AppConfig.TAG, "Subscription updated: ${it.subscription.remarks}, $count configs")
} }
return count return count
} catch (e: Exception) { } catch (e: Exception) {
@@ -481,7 +482,7 @@ object AngConfigManager {
private fun importUrlAsSubscription(url: String): Int { private fun importUrlAsSubscription(url: String): Int {
val subscriptions = MmkvManager.decodeSubscriptions() val subscriptions = MmkvManager.decodeSubscriptions()
subscriptions.forEach { subscriptions.forEach {
if (it.second.url == url) { if (it.subscription.url == url) {
return 0 return 0
} }
} }
@@ -3,10 +3,12 @@ package com.v2ray.ang.handler
import com.tencent.mmkv.MMKV import com.tencent.mmkv.MMKV
import com.v2ray.ang.AppConfig.PREF_IS_BOOTED import com.v2ray.ang.AppConfig.PREF_IS_BOOTED
import com.v2ray.ang.AppConfig.PREF_ROUTING_RULESET import com.v2ray.ang.AppConfig.PREF_ROUTING_RULESET
import com.v2ray.ang.dto.AssetUrlCache
import com.v2ray.ang.dto.AssetUrlItem import com.v2ray.ang.dto.AssetUrlItem
import com.v2ray.ang.dto.ProfileItem import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.RulesetItem import com.v2ray.ang.dto.RulesetItem
import com.v2ray.ang.dto.ServerAffiliationInfo import com.v2ray.ang.dto.ServerAffiliationInfo
import com.v2ray.ang.dto.SubscriptionCache
import com.v2ray.ang.dto.SubscriptionItem import com.v2ray.ang.dto.SubscriptionItem
import com.v2ray.ang.dto.WebDavConfig import com.v2ray.ang.dto.WebDavConfig
import com.v2ray.ang.util.JsonUtil import com.v2ray.ang.util.JsonUtil
@@ -309,15 +311,15 @@ object MmkvManager {
* *
* @return The list of subscriptions. * @return The list of subscriptions.
*/ */
fun decodeSubscriptions(): List<Pair<String, SubscriptionItem>> { fun decodeSubscriptions(): List<SubscriptionCache> {
initSubsList() initSubsList()
val subscriptions = mutableListOf<Pair<String, SubscriptionItem>>() val subscriptions = mutableListOf<SubscriptionCache>()
decodeSubsList().forEach { key -> decodeSubsList().forEach { key ->
val json = subStorage.decodeString(key) val json = subStorage.decodeString(key)
if (!json.isNullOrBlank()) { if (!json.isNullOrBlank()) {
val item = JsonUtil.fromJson(json, SubscriptionItem::class.java)?: SubscriptionItem() val item = JsonUtil.fromJson(json, SubscriptionItem::class.java)?: SubscriptionItem()
subscriptions.add(Pair(key, item)) subscriptions.add(SubscriptionCache(key, item))
} }
} }
return subscriptions return subscriptions
@@ -397,16 +399,16 @@ object MmkvManager {
* *
* @return The list of asset URLs. * @return The list of asset URLs.
*/ */
fun decodeAssetUrls(): List<Pair<String, AssetUrlItem>> { fun decodeAssetUrls(): List<AssetUrlCache> {
val assetUrlItems = mutableListOf<Pair<String, AssetUrlItem>>() val assetUrlItems = mutableListOf<AssetUrlCache>()
assetStorage.allKeys()?.forEach { key -> assetStorage.allKeys()?.forEach { key ->
val json = assetStorage.decodeString(key) val json = assetStorage.decodeString(key)
if (!json.isNullOrBlank()) { if (!json.isNullOrBlank()) {
val item = JsonUtil.fromJson(json, AssetUrlItem::class.java)?: AssetUrlItem() val item = JsonUtil.fromJson(json, AssetUrlItem::class.java)?: AssetUrlItem()
assetUrlItems.add(Pair(key, item)) assetUrlItems.add(AssetUrlCache(key, item))
} }
} }
return assetUrlItems.sortedBy { (_, value) -> value.addedTime } return assetUrlItems.sortedBy { it.assetUrl.addedTime }
} }
/** /**
@@ -10,13 +10,14 @@ import com.v2ray.ang.AppConfig.ANG_PACKAGE
import com.v2ray.ang.AppConfig.GEOIP_PRIVATE import com.v2ray.ang.AppConfig.GEOIP_PRIVATE
import com.v2ray.ang.AppConfig.GEOSITE_PRIVATE import com.v2ray.ang.AppConfig.GEOSITE_PRIVATE
import com.v2ray.ang.AppConfig.TAG_DIRECT import com.v2ray.ang.AppConfig.TAG_DIRECT
import com.v2ray.ang.dto.EConfigType import com.v2ray.ang.AppConfig.VPN
import com.v2ray.ang.dto.Language import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.enums.Language
import com.v2ray.ang.dto.ProfileItem import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.RoutingType import com.v2ray.ang.enums.RoutingType
import com.v2ray.ang.dto.RulesetItem import com.v2ray.ang.dto.RulesetItem
import com.v2ray.ang.dto.V2rayConfig import com.v2ray.ang.dto.V2rayConfig
import com.v2ray.ang.dto.VpnInterfaceAddressConfig import com.v2ray.ang.enums.VpnInterfaceAddressConfig
import com.v2ray.ang.handler.MmkvManager.decodeServerConfig import com.v2ray.ang.handler.MmkvManager.decodeServerConfig
import com.v2ray.ang.handler.MmkvManager.decodeServerList import com.v2ray.ang.handler.MmkvManager.decodeServerList
import com.v2ray.ang.util.JsonUtil import com.v2ray.ang.util.JsonUtil
@@ -386,6 +387,15 @@ object SettingsManager {
return MmkvManager.decodeSettingsBool(AppConfig.PREF_USE_HEV_TUNNEL, true) return MmkvManager.decodeSettingsBool(AppConfig.PREF_USE_HEV_TUNNEL, true)
} }
/**
* Check if VPN mode is enabled.
* @return True if VPN mode is enabled, false otherwise.
*/
fun isVpnMode(): Boolean {
val mode = MmkvManager.decodeSettingsString(AppConfig.PREF_MODE)
return mode == null || mode == VPN
}
/** /**
* Ensure default settings are present in MMKV. * Ensure default settings are present in MMKV.
*/ */
@@ -36,10 +36,10 @@ object SubscriptionUpdater {
override suspend fun doWork(): Result { override suspend fun doWork(): Result {
Log.i(AppConfig.TAG, "subscription automatic update starting") Log.i(AppConfig.TAG, "subscription automatic update starting")
val subs = MmkvManager.decodeSubscriptions().filter { it.second.autoUpdate } val subs = MmkvManager.decodeSubscriptions().filter { it.subscription.autoUpdate }
for (sub in subs) { for (sub in subs) {
val subItem = sub.second val subItem = sub.subscription
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
notification.setChannelId(AppConfig.SUBSCRIPTION_UPDATE_CHANNEL) notification.setChannelId(AppConfig.SUBSCRIPTION_UPDATE_CHANNEL)
@@ -53,7 +53,7 @@ object SubscriptionUpdater {
} }
notificationManager.notify(3, notification.build()) notificationManager.notify(3, notification.build())
Log.i(AppConfig.TAG, "subscription automatic update: ---${subItem.remarks}") Log.i(AppConfig.TAG, "subscription automatic update: ---${subItem.remarks}")
AngConfigManager.updateConfigViaSub(Pair(sub.first, subItem)) AngConfigManager.updateConfigViaSub(sub)
notification.setContentText("Updating ${subItem.remarks}") notification.setContentText("Updating ${subItem.remarks}")
} }
notificationManager.cancel(3) notificationManager.cancel(3)
@@ -10,10 +10,10 @@ import android.util.Log
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import com.v2ray.ang.AppConfig import com.v2ray.ang.AppConfig
import com.v2ray.ang.R import com.v2ray.ang.R
import com.v2ray.ang.dto.EConfigType import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.dto.ProfileItem import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.extension.toast import com.v2ray.ang.extension.toast
import com.v2ray.ang.service.ServiceControl import com.v2ray.ang.contracts.ServiceControl
import com.v2ray.ang.service.V2RayProxyOnlyService import com.v2ray.ang.service.V2RayProxyOnlyService
import com.v2ray.ang.service.V2RayVpnService import com.v2ray.ang.service.V2RayVpnService
import com.v2ray.ang.util.MessageUtil import com.v2ray.ang.util.MessageUtil
@@ -108,7 +108,7 @@ object V2RayServiceManager {
} else { } else {
context.toast(R.string.toast_services_start) context.toast(R.string.toast_services_start)
} }
val intent = if ((MmkvManager.decodeSettingsString(AppConfig.PREF_MODE) ?: AppConfig.VPN) == AppConfig.VPN) { val intent = if (SettingsManager.isVpnMode()) {
Intent(context.applicationContext, V2RayVpnService::class.java) Intent(context.applicationContext, V2RayVpnService::class.java)
} else { } else {
Intent(context.applicationContext, V2RayProxyOnlyService::class.java) Intent(context.applicationContext, V2RayProxyOnlyService::class.java)
@@ -151,6 +151,7 @@ object V2RayServiceManager {
} }
try { try {
NotificationManager.showNotification(currentConfig)
coreController.startLoop(result.content, tunFd) coreController.startLoop(result.content, tunFd)
} catch (e: Exception) { } catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to start Core loop", e) Log.e(AppConfig.TAG, "Failed to start Core loop", e)
@@ -165,7 +166,7 @@ object V2RayServiceManager {
try { try {
MessageUtil.sendMsg2UI(service, AppConfig.MSG_STATE_START_SUCCESS, "") MessageUtil.sendMsg2UI(service, AppConfig.MSG_STATE_START_SUCCESS, "")
NotificationManager.showNotification(currentConfig) //NotificationManager.showNotification(currentConfig)
NotificationManager.startSpeedNotification(currentConfig) NotificationManager.startSpeedNotification(currentConfig)
} catch (e: Exception) { } catch (e: Exception) {
@@ -6,8 +6,8 @@ import android.util.Log
import com.google.gson.JsonArray import com.google.gson.JsonArray
import com.v2ray.ang.AppConfig import com.v2ray.ang.AppConfig
import com.v2ray.ang.dto.ConfigResult import com.v2ray.ang.dto.ConfigResult
import com.v2ray.ang.dto.EConfigType import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.dto.NetworkType import com.v2ray.ang.enums.NetworkType
import com.v2ray.ang.dto.ProfileItem import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.RulesetItem import com.v2ray.ang.dto.RulesetItem
import com.v2ray.ang.dto.V2rayConfig import com.v2ray.ang.dto.V2rayConfig
@@ -16,6 +16,7 @@ import com.v2ray.ang.dto.V2rayConfig.OutboundBean.OutSettingsBean
import com.v2ray.ang.dto.V2rayConfig.OutboundBean.StreamSettingsBean import com.v2ray.ang.dto.V2rayConfig.OutboundBean.StreamSettingsBean
import com.v2ray.ang.dto.V2rayConfig.RoutingBean.RulesBean import com.v2ray.ang.dto.V2rayConfig.RoutingBean.RulesBean
import com.v2ray.ang.extension.isNotNullEmpty import com.v2ray.ang.extension.isNotNullEmpty
import com.v2ray.ang.extension.nullIfBlank
import com.v2ray.ang.fmt.HttpFmt import com.v2ray.ang.fmt.HttpFmt
import com.v2ray.ang.fmt.Hysteria2Fmt import com.v2ray.ang.fmt.Hysteria2Fmt
import com.v2ray.ang.fmt.ShadowsocksFmt import com.v2ray.ang.fmt.ShadowsocksFmt
@@ -91,7 +92,7 @@ object V2rayConfigManager {
private fun getV2rayCustomConfig(context: Context, guid: String, config: ProfileItem): ConfigResult { private fun getV2rayCustomConfig(context: Context, guid: String, config: ProfileItem): ConfigResult {
val raw = MmkvManager.decodeServerRaw(guid) ?: return ConfigResult(false) val raw = MmkvManager.decodeServerRaw(guid) ?: return ConfigResult(false)
val result = ConfigResult(true, guid, raw) val result = ConfigResult(true, guid, raw)
if (SettingsManager.isUsingHevTun()) { if (!needTun()) {
return result return result
} }
@@ -334,18 +335,18 @@ object V2rayConfigManager {
*/ */
private fun initV2rayConfig(context: Context): V2rayConfig? { private fun initV2rayConfig(context: Context): V2rayConfig? {
var assets = "" var assets = ""
if (SettingsManager.isUsingHevTun()) { if (needTun()) {
assets = initConfigCache ?: Utils.readTextFromAssets(context, "v2ray_config.json")
if (TextUtils.isEmpty(assets)) {
return null
}
initConfigCache = assets
} else {
assets = initConfigCacheWithTun ?: Utils.readTextFromAssets(context, "v2ray_config_with_tun.json") assets = initConfigCacheWithTun ?: Utils.readTextFromAssets(context, "v2ray_config_with_tun.json")
if (TextUtils.isEmpty(assets)) { if (TextUtils.isEmpty(assets)) {
return null return null
} }
initConfigCacheWithTun = assets initConfigCacheWithTun = assets
} else {
assets = initConfigCache ?: Utils.readTextFromAssets(context, "v2ray_config.json")
if (TextUtils.isEmpty(assets)) {
return null
}
initConfigCache = assets
} }
val config = JsonUtil.fromJson(assets, V2rayConfig::class.java) val config = JsonUtil.fromJson(assets, V2rayConfig::class.java)
return config return config
@@ -357,6 +358,10 @@ object V2rayConfigManager {
//region some sub function //region some sub function
private fun needTun(): Boolean {
return SettingsManager.isVpnMode() && !SettingsManager.isUsingHevTun()
}
/** /**
* Configures the inbound settings for V2ray. * Configures the inbound settings for V2ray.
* *
@@ -395,9 +400,10 @@ object V2rayConfigManager {
v2rayConfig.inbounds.add(inbound2) v2rayConfig.inbounds.add(inbound2)
} }
if (!SettingsManager.isUsingHevTun()) { if (needTun()) {
val inboundTun = v2rayConfig.inbounds.firstOrNull { e -> e.tag == "tun" } val inboundTun = v2rayConfig.inbounds.firstOrNull { e -> e.tag == "tun" }
inboundTun?.settings?.mtu = SettingsManager.getVpnMtu() inboundTun?.settings?.mtu = SettingsManager.getVpnMtu()
inboundTun?.sniffing = inbound1.sniffing
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to configure inbounds", e) Log.e(AppConfig.TAG, "Failed to configure inbounds", e)
@@ -519,23 +525,25 @@ object V2rayConfigManager {
) )
} }
if (SettingsManager.isUsingHevTun()) { if(SettingsManager.isVpnMode()) {
//hev-socks5-tunnel dns routing if (SettingsManager.isUsingHevTun()) {
v2rayConfig.routing.rules.add( //hev-socks5-tunnel dns routing
0, RulesBean( v2rayConfig.routing.rules.add(
inboundTag = arrayListOf("socks"), 0, RulesBean(
outboundTag = "dns-out", inboundTag = arrayListOf("socks"),
port = "53", outboundTag = "dns-out",
port = "53",
)
) )
) } else {
} else { v2rayConfig.routing.rules.add(
v2rayConfig.routing.rules.add( 0, RulesBean(
0, RulesBean( inboundTag = arrayListOf("tun"),
inboundTag = arrayListOf("tun"), outboundTag = "dns-out",
outboundTag = "dns-out", port = "53",
port = "53", )
) )
) }
} }
// DNS outbound // DNS outbound
@@ -833,65 +841,93 @@ object V2rayConfigManager {
* Configures load balancing settings for the V2ray configuration. * Configures load balancing settings for the V2ray configuration.
* *
* @param v2rayConfig The V2ray configuration object to be modified with balancing settings * @param v2rayConfig The V2ray configuration object to be modified with balancing settings
* @param config The profile item containing policy group settings
*/ */
private fun getBalance(v2rayConfig: V2rayConfig, config: ProfileItem) { private fun getBalance(v2rayConfig: V2rayConfig, config: ProfileItem) {
try { try {
v2rayConfig.routing.rules.forEach { rule -> v2rayConfig.routing.rules.forEach { rule ->
if (rule.outboundTag == "proxy") { if (rule.outboundTag == "proxy") {
rule.outboundTag = null rule.outboundTag = null
rule.balancerTag = "proxy-round" rule.balancerTag = AppConfig.TAG_BALANCER
} }
} }
if (config.policyGroupType == "0") { val lstSelector = listOf("proxy-")
val balancer = V2rayConfig.RoutingBean.BalancerBean( when (config.policyGroupType) {
tag = "proxy-round", // Least Ping goto else
selector = listOf("proxy-"), "1" -> {
strategy = V2rayConfig.RoutingBean.StrategyObject( // Least Load
type = "leastPing" val balancer = V2rayConfig.RoutingBean.BalancerBean(
tag = AppConfig.TAG_BALANCER,
selector = lstSelector,
strategy = V2rayConfig.RoutingBean.StrategyObject(
type = "leastLoad"
)
) )
) v2rayConfig.routing.balancers = listOf(balancer)
v2rayConfig.routing.balancers = listOf(balancer) v2rayConfig.burstObservatory = V2rayConfig.BurstObservatoryObject(
v2rayConfig.observatory = V2rayConfig.ObservatoryObject( subjectSelector = lstSelector,
subjectSelector = listOf("proxy-"), pingConfig = V2rayConfig.BurstObservatoryObject.PingConfigObject(
probeUrl = MmkvManager.decodeSettingsString(AppConfig.PREF_DELAY_TEST_URL) ?: AppConfig.DELAY_TEST_URL, destination = MmkvManager.decodeSettingsString(AppConfig.PREF_DELAY_TEST_URL) ?: AppConfig.DELAY_TEST_URL,
probeInterval = "3m", interval = "5m",
enableConcurrency = true sampling = 2,
) timeout = "30s"
} else { )
val balancer = V2rayConfig.RoutingBean.BalancerBean(
tag = "proxy-round",
selector = listOf("proxy-"),
strategy = V2rayConfig.RoutingBean.StrategyObject(
type = "leastLoad"
) )
) }
v2rayConfig.routing.balancers = listOf(balancer) "2" -> {
v2rayConfig.burstObservatory = V2rayConfig.BurstObservatoryObject( // Random
subjectSelector = listOf("proxy-"), val balancer = V2rayConfig.RoutingBean.BalancerBean(
pingConfig = V2rayConfig.BurstObservatoryObject.PingConfigObject( tag = AppConfig.TAG_BALANCER,
destination = MmkvManager.decodeSettingsString(AppConfig.PREF_DELAY_TEST_URL) ?: AppConfig.DELAY_TEST_URL, selector = lstSelector,
interval = "5m", strategy = V2rayConfig.RoutingBean.StrategyObject(
sampling = 2, type = "random"
timeout = "30s" )
) )
) v2rayConfig.routing.balancers = listOf(balancer)
}
"3" -> {
// Round Robin
val balancer = V2rayConfig.RoutingBean.BalancerBean(
tag = AppConfig.TAG_BALANCER,
selector = lstSelector,
strategy = V2rayConfig.RoutingBean.StrategyObject(
type = "roundRobin"
)
)
v2rayConfig.routing.balancers = listOf(balancer)
}
else -> {
// Default: Least Ping
val balancer = V2rayConfig.RoutingBean.BalancerBean(
tag = AppConfig.TAG_BALANCER,
selector = lstSelector,
strategy = V2rayConfig.RoutingBean.StrategyObject(
type = "leastPing"
)
)
v2rayConfig.routing.balancers = listOf(balancer)
v2rayConfig.observatory = V2rayConfig.ObservatoryObject(
subjectSelector = lstSelector,
probeUrl = MmkvManager.decodeSettingsString(AppConfig.PREF_DELAY_TEST_URL) ?: AppConfig.DELAY_TEST_URL,
probeInterval = "3m",
enableConcurrency = true
)
}
} }
if (v2rayConfig.routing.domainStrategy == "IPIfNonMatch") { if (v2rayConfig.routing.domainStrategy == "IPIfNonMatch") {
v2rayConfig.routing.rules.add( v2rayConfig.routing.rules.add(
RulesBean( RulesBean(
ip = arrayListOf("0.0.0.0/0", "::/0"), ip = arrayListOf("0.0.0.0/0", "::/0"),
balancerTag = "proxy-round", balancerTag = AppConfig.TAG_BALANCER,
type = "field"
) )
) )
} else { } else {
v2rayConfig.routing.rules.add( v2rayConfig.routing.rules.add(
RulesBean( RulesBean(
network = "tcp,udp", network = "tcp,udp",
balancerTag = "proxy-round", balancerTag = AppConfig.TAG_BALANCER,
type = "field"
) )
) )
} }
@@ -1261,28 +1297,21 @@ object V2rayConfigManager {
} else { } else {
profileItem.sni profileItem.sni
} }
val fingerprint = profileItem.fingerPrint
val alpns = profileItem.alpn
val echConfigList = profileItem.echConfigList
val echForceQuery = profileItem.echForceQuery
val publicKey = profileItem.publicKey
val shortId = profileItem.shortId
val spiderX = profileItem.spiderX
val mldsa65Verify = profileItem.mldsa65Verify
streamSettings.security = if (streamSecurity.isEmpty()) null else streamSecurity streamSettings.security = streamSecurity.nullIfBlank()
if (streamSettings.security == null) return if (streamSettings.security == null) return
val tlsSetting = StreamSettingsBean.TlsSettingsBean( val tlsSetting = StreamSettingsBean.TlsSettingsBean(
allowInsecure = allowInsecure, allowInsecure = allowInsecure,
serverName = if (sni.isNullOrEmpty()) null else sni, serverName = sni.nullIfBlank(),
fingerprint = if (fingerprint.isNullOrEmpty()) null else fingerprint, fingerprint = profileItem.fingerPrint.nullIfBlank(),
alpn = if (alpns.isNullOrEmpty()) null else alpns.split(",").map { it.trim() }.filter { it.isNotEmpty() }, alpn = profileItem.alpn?.split(",")?.map { it.trim() }?.filter { it.isNotEmpty() }.takeIf { !it.isNullOrEmpty() },
echConfigList = if (echConfigList.isNullOrEmpty()) null else echConfigList, echConfigList = profileItem.echConfigList.nullIfBlank(),
echForceQuery = if (echForceQuery.isNullOrEmpty()) null else echForceQuery, echForceQuery = profileItem.echForceQuery.nullIfBlank(),
publicKey = if (publicKey.isNullOrEmpty()) null else publicKey, pinnedPeerCertSha256 = profileItem.pinnedCA256.nullIfBlank(),
shortId = if (shortId.isNullOrEmpty()) null else shortId, publicKey = profileItem.publicKey.nullIfBlank(),
spiderX = if (spiderX.isNullOrEmpty()) null else spiderX, shortId = profileItem.shortId.nullIfBlank(),
mldsa65Verify = if (mldsa65Verify.isNullOrEmpty()) null else mldsa65Verify, spiderX = profileItem.spiderX.nullIfBlank(),
mldsa65Verify = profileItem.mldsa65Verify.nullIfBlank(),
) )
if (streamSettings.security == AppConfig.TLS) { if (streamSettings.security == AppConfig.TLS) {
streamSettings.tlsSettings = tlsSetting streamSettings.tlsSettings = tlsSetting
@@ -0,0 +1,88 @@
package com.v2ray.ang.helper
import android.content.ActivityNotFoundException
import android.content.Intent
import android.net.Uri
import android.util.Log
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import com.v2ray.ang.AppConfig
import com.v2ray.ang.R
import com.v2ray.ang.extension.toast
/**
* Helper for choosing and creating files using Android Storage Access Framework.
* Supports both file selection (ACTION_GET_CONTENT) and file creation (CreateDocument).
*/
class FileChooserHelper(private val activity: AppCompatActivity) {
private var fileChooserCallback: ((Uri?) -> Unit)? = null
private var documentCreateCallback: ((Uri?) -> Unit)? = null
private val fileChooserLauncher: ActivityResultLauncher<Intent> =
activity.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
val uri = result.data?.data
if (result.resultCode == AppCompatActivity.RESULT_OK && uri != null) {
fileChooserCallback?.invoke(uri)
} else {
fileChooserCallback?.invoke(null)
}
fileChooserCallback = null
}
private val documentCreateLauncher: ActivityResultLauncher<String> =
activity.registerForActivityResult(ActivityResultContracts.CreateDocument("application/zip")) { uri ->
documentCreateCallback?.invoke(uri)
documentCreateCallback = null
}
/**
* Launch file chooser with ACTION_GET_CONTENT intent to select an existing file.
*
* @param mimeType MIME type filter for files
* @param onResult Callback invoked with the selected file URI (null if cancelled)
*/
fun launch(
mimeType: String = "*/*",
onResult: (Uri?) -> Unit
) {
fileChooserCallback = onResult
val intent = Intent(Intent.ACTION_GET_CONTENT).apply {
type = mimeType
addCategory(Intent.CATEGORY_OPENABLE)
}
try {
fileChooserLauncher.launch(
Intent.createChooser(intent, activity.getString(R.string.title_file_chooser))
)
} catch (ex: ActivityNotFoundException) {
Log.e(AppConfig.TAG, "File chooser activity not found", ex)
activity.toast(R.string.toast_require_file_manager)
fileChooserCallback?.invoke(null)
fileChooserCallback = null
}
}
/**
* Launch document creator to create a new file at user-selected location.
*
* @param fileName Default file name for the new document
* @param onResult Callback invoked with the created file URI (null if cancelled)
*/
fun createDocument(
fileName: String,
onResult: (Uri?) -> Unit
) {
documentCreateCallback = onResult
try {
documentCreateLauncher.launch(fileName)
} catch (ex: ActivityNotFoundException) {
Log.e(AppConfig.TAG, "Document creator activity not found", ex)
activity.toast(R.string.toast_require_file_manager)
documentCreateCallback?.invoke(null)
documentCreateCallback = null
}
}
}
@@ -1,7 +1,10 @@
package com.v2ray.ang.handler package com.v2ray.ang.helper
import androidx.preference.PreferenceDataStore import androidx.preference.PreferenceDataStore
import com.v2ray.ang.AppConfig import com.v2ray.ang.AppConfig
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.SettingsChangeManager
import com.v2ray.ang.handler.SettingsManager
/** /**
* PreferenceDataStore implementation that bridges AndroidX Preference framework to MMKV storage. * PreferenceDataStore implementation that bridges AndroidX Preference framework to MMKV storage.
@@ -0,0 +1,46 @@
package com.v2ray.ang.helper
import android.content.pm.PackageManager
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import com.v2ray.ang.R
import com.v2ray.ang.enums.PermissionType
import com.v2ray.ang.extension.toast
/**
* Helper for requesting permissions.
*/
class PermissionHelper(private val activity: AppCompatActivity) {
private var permissionCallback: ((Boolean) -> Unit)? = null
private val permissionLauncher: ActivityResultLauncher<String> =
activity.registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted ->
permissionCallback?.invoke(isGranted)
permissionCallback = null
}
/**
* Check the permission and request it if not granted.
*
* @param permissionType the type of permission
* @param onGranted called when permission is granted (called immediately if already granted)
*/
fun request(permissionType: PermissionType, onGranted: () -> Unit) {
val permission = permissionType.getPermission()
if (ContextCompat.checkSelfPermission(activity, permission) == PackageManager.PERMISSION_GRANTED) {
onGranted()
} else {
permissionCallback = { isGranted ->
if (isGranted) {
onGranted()
} else {
val message = "${activity.getString(R.string.toast_permission_denied)} ${permissionType.getLabel()}"
activity.toast(message)
}
}
permissionLauncher.launch(permission)
}
}
}
@@ -0,0 +1,38 @@
package com.v2ray.ang.helper
import android.content.Intent
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import com.v2ray.ang.ui.ScannerActivity
/**
* Helper for scanning QR codes.
*
* This class encapsulates the logic for launching the QR code scanner activity
* and handling the scan result.
*/
class QRCodeScannerHelper(private val activity: AppCompatActivity) {
private var scanCallback: ((String?) -> Unit)? = null
private val scanLauncher: ActivityResultLauncher<Intent> =
activity.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == AppCompatActivity.RESULT_OK) {
val scanResult = result.data?.getStringExtra("SCAN_RESULT")
scanCallback?.invoke(scanResult)
} else {
scanCallback?.invoke(null)
}
scanCallback = null
}
/**
* Launch the QR code scanner activity.
*
* @param onResult Callback invoked with the scan result (null if cancelled or failed)
*/
fun launch(onResult: (String?) -> Unit) {
scanCallback = onResult
scanLauncher.launch(Intent(activity, ScannerActivity::class.java))
}
}
@@ -4,6 +4,7 @@ import android.content.Context
import android.os.ParcelFileDescriptor import android.os.ParcelFileDescriptor
import android.util.Log import android.util.Log
import com.v2ray.ang.AppConfig import com.v2ray.ang.AppConfig
import com.v2ray.ang.contracts.Tun2SocksControl
import com.v2ray.ang.handler.MmkvManager import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.SettingsManager import com.v2ray.ang.handler.SettingsManager
import java.io.File import java.io.File
@@ -4,6 +4,7 @@ import android.app.Service
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.IBinder import android.os.IBinder
import com.v2ray.ang.contracts.ServiceControl
import com.v2ray.ang.handler.SettingsManager import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.handler.V2RayServiceManager import com.v2ray.ang.handler.V2RayServiceManager
import com.v2ray.ang.util.MyContextWrapper import com.v2ray.ang.util.MyContextWrapper
@@ -18,6 +18,8 @@ import androidx.annotation.RequiresApi
import com.v2ray.ang.AppConfig import com.v2ray.ang.AppConfig
import com.v2ray.ang.AppConfig.LOOPBACK import com.v2ray.ang.AppConfig.LOOPBACK
import com.v2ray.ang.BuildConfig import com.v2ray.ang.BuildConfig
import com.v2ray.ang.contracts.ServiceControl
import com.v2ray.ang.contracts.Tun2SocksControl
import com.v2ray.ang.handler.MmkvManager import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.NotificationManager import com.v2ray.ang.handler.NotificationManager
import com.v2ray.ang.handler.SettingsManager import com.v2ray.ang.handler.SettingsManager
@@ -76,7 +78,7 @@ class V2RayVpnService : VpnService(), ServiceControl {
} }
override fun onRevoke() { override fun onRevoke() {
stopV2Ray() stopAllService()
} }
// override fun onLowMemory() { // override fun onLowMemory() {
@@ -105,11 +107,15 @@ class V2RayVpnService : VpnService(), ServiceControl {
Log.e(AppConfig.TAG, "Failed to create VPN interface") Log.e(AppConfig.TAG, "Failed to create VPN interface")
return return
} }
V2RayServiceManager.startCoreLoop(mInterface) if (!V2RayServiceManager.startCoreLoop(mInterface)) {
Log.e(AppConfig.TAG, "Failed to start V2Ray core loop")
stopAllService()
return
}
} }
override fun stopService() { override fun stopService() {
stopV2Ray(true) stopAllService(true)
} }
override fun vpnProtect(socket: Int): Boolean { override fun vpnProtect(socket: Int): Boolean {
@@ -130,10 +136,14 @@ class V2RayVpnService : VpnService(), ServiceControl {
private fun setupVpnService() { private fun setupVpnService() {
val prepare = prepare(this) val prepare = prepare(this)
if (prepare != null) { if (prepare != null) {
Log.e(AppConfig.TAG, "VPN preparation failed")
stopSelf()
return return
} }
if (configureVpnService() != true) { if (configureVpnService() != true) {
Log.e(AppConfig.TAG, "VPN configuration failed")
stopSelf()
return return
} }
@@ -170,7 +180,7 @@ class V2RayVpnService : VpnService(), ServiceControl {
return true return true
} catch (e: Exception) { } catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to establish VPN interface", e) Log.e(AppConfig.TAG, "Failed to establish VPN interface", e)
stopV2Ray() stopAllService()
} }
return false return false
} }
@@ -311,11 +321,7 @@ class V2RayVpnService : VpnService(), ServiceControl {
tun2SocksService?.startTun2Socks() tun2SocksService?.startTun2Socks()
} }
/** private fun stopAllService(isForced: Boolean = true) {
* Stops the V2Ray service.
* @param isForced Whether to force stop the service.
*/
private fun stopV2Ray(isForced: Boolean = true) {
// val configName = defaultDPreference.getPrefString(PREF_CURR_CONFIG_GUID, "") // val configName = defaultDPreference.getPrefString(PREF_CURR_CONFIG_GUID, "")
// val emptyInfo = VpnNetworkInfo() // val emptyInfo = VpnNetworkInfo()
// val info = loadVpnNetworkInfo(configName, emptyInfo)!! + (lastNetworkInfo ?: emptyInfo) // val info = loadVpnNetworkInfo(configName, emptyInfo)!! + (lastNetworkInfo ?: emptyInfo)
@@ -1,15 +1,9 @@
package com.v2ray.ang.ui package com.v2ray.ang.ui
import android.Manifest
import android.app.AlertDialog import android.app.AlertDialog
import android.content.ActivityNotFoundException
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.util.Log import android.util.Log
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.tencent.mmkv.MMKV import com.tencent.mmkv.MMKV
@@ -20,7 +14,6 @@ import com.v2ray.ang.R
import com.v2ray.ang.databinding.ActivityBackupBinding import com.v2ray.ang.databinding.ActivityBackupBinding
import com.v2ray.ang.databinding.DialogWebdavBinding import com.v2ray.ang.databinding.DialogWebdavBinding
import com.v2ray.ang.dto.WebDavConfig import com.v2ray.ang.dto.WebDavConfig
import com.v2ray.ang.extension.toast
import com.v2ray.ang.extension.toastError import com.v2ray.ang.extension.toastError
import com.v2ray.ang.extension.toastSuccess import com.v2ray.ang.extension.toastSuccess
import com.v2ray.ang.handler.MmkvManager import com.v2ray.ang.handler.MmkvManager
@@ -34,50 +27,13 @@ import java.io.File
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Locale import java.util.Locale
class BackupActivity : BaseActivity() { class BackupActivity : HelperBaseActivity() {
private val binding by lazy { ActivityBackupBinding.inflate(layoutInflater) } private val binding by lazy { ActivityBackupBinding.inflate(layoutInflater) }
private val config_backup_options: Array<out String> by lazy { private val config_backup_options: Array<out String> by lazy {
resources.getStringArray(R.array.config_backup_options) resources.getStringArray(R.array.config_backup_options)
} }
private val requestPermissionLauncher =
registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted ->
if (isGranted) {
try {
showFileChooser()
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to show file chooser", e)
}
} else {
toast(R.string.toast_permission_denied)
}
}
private val createBackupFile =
registerForActivityResult(ActivityResultContracts.CreateDocument("application/zip")) { uri ->
if (uri != null) {
try {
val ret = backupConfigurationToCache()
if (ret.first) {
// Copy the cached zip file to user-selected location
contentResolver.openOutputStream(uri)?.use { output ->
File(ret.second).inputStream().use { input ->
input.copyTo(output)
}
}
// Clean up cache file
File(ret.second).delete()
toastSuccess(R.string.toast_success)
} else {
toastError(R.string.toast_failure)
}
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to backup configuration", e)
toastError(R.string.toast_failure)
}
}
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@@ -172,42 +128,29 @@ class BackupActivity : BaseActivity() {
} }
private fun showFileChooser() { private fun showFileChooser() {
val intent = Intent(Intent.ACTION_GET_CONTENT).apply { launchFileChooser { uri ->
type = "*/*" if (uri == null) {
addCategory(Intent.CATEGORY_OPENABLE) return@launchFileChooser
} }
try {
try { val targetFile =
chooseFile.launch(Intent.createChooser(intent, getString(R.string.title_file_chooser))) File(this.cacheDir.absolutePath, "${System.currentTimeMillis()}.zip")
} catch (ex: ActivityNotFoundException) { contentResolver.openInputStream(uri).use { input ->
Log.e(AppConfig.TAG, "File chooser activity not found", ex) targetFile.outputStream().use { fileOut ->
toast(R.string.toast_require_file_manager) input?.copyTo(fileOut)
}
}
private val chooseFile =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
val uri = result.data?.data
if (result.resultCode == RESULT_OK && uri != null) {
try {
val targetFile =
File(this.cacheDir.absolutePath, "${System.currentTimeMillis()}.zip")
contentResolver.openInputStream(uri).use { input ->
targetFile.outputStream().use { fileOut ->
input?.copyTo(fileOut)
}
} }
if (restoreConfiguration(targetFile)) { }
toastSuccess(R.string.toast_success) if (restoreConfiguration(targetFile)) {
} else { toastSuccess(R.string.toast_success)
toastError(R.string.toast_failure) } else {
}
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Error during file restore", e)
toastError(R.string.toast_failure) toastError(R.string.toast_failure)
} }
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Error during file restore", e)
toastError(R.string.toast_failure)
} }
} }
}
private fun backupViaLocal() { private fun backupViaLocal() {
val dateFormatted = SimpleDateFormat( val dateFormatted = SimpleDateFormat(
@@ -215,25 +158,34 @@ class BackupActivity : BaseActivity() {
Locale.getDefault() Locale.getDefault()
).format(System.currentTimeMillis()) ).format(System.currentTimeMillis())
val defaultFileName = "${getString(R.string.app_name)}_${dateFormatted}.zip" val defaultFileName = "${getString(R.string.app_name)}_${dateFormatted}.zip"
createBackupFile.launch(defaultFileName)
launchCreateDocument(defaultFileName) { uri ->
if (uri != null) {
try {
val ret = backupConfigurationToCache()
if (ret.first) {
// Copy the cached zip file to user-selected location
contentResolver.openOutputStream(uri)?.use { output ->
File(ret.second).inputStream().use { input ->
input.copyTo(output)
}
}
// Clean up cache file
File(ret.second).delete()
toastSuccess(R.string.toast_success)
} else {
toastError(R.string.toast_failure)
}
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to backup configuration", e)
toastError(R.string.toast_failure)
}
}
}
} }
private fun restoreViaLocal() { private fun restoreViaLocal() {
val permission = showFileChooser()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
Manifest.permission.READ_MEDIA_IMAGES
} else {
Manifest.permission.READ_EXTERNAL_STORAGE
}
if (ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED) {
try {
showFileChooser()
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to show file chooser", e)
}
} else {
requestPermissionLauncher.launch(permission)
}
} }
private fun backupViaWebDav() { private fun backupViaWebDav() {
@@ -39,6 +39,7 @@ abstract class BaseActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
supportActionBar?.setDisplayHomeAsUpEnabled(true) supportActionBar?.setDisplayHomeAsUpEnabled(true)
if (!Utils.getDarkModeStatus(this)) { if (!Utils.getDarkModeStatus(this)) {
WindowCompat.getInsetsController(window, window.decorView).apply { WindowCompat.getInsetsController(window, window.decorView).apply {
@@ -212,5 +213,4 @@ abstract class BaseActivity : AppCompatActivity() {
protected fun isLoadingVisible(): Boolean { protected fun isLoadingVisible(): Boolean {
return progressBar?.visibility == View.VISIBLE return progressBar?.visibility == View.VISIBLE
} }
} }
@@ -11,6 +11,8 @@ import com.v2ray.ang.dto.GroupMapItem
class GroupPagerAdapter(activity: FragmentActivity, var groups: List<GroupMapItem>) : FragmentStateAdapter(activity) { class GroupPagerAdapter(activity: FragmentActivity, var groups: List<GroupMapItem>) : FragmentStateAdapter(activity) {
override fun getItemCount(): Int = groups.size override fun getItemCount(): Int = groups.size
override fun createFragment(position: Int) = GroupServerFragment.newInstance(groups[position].id) override fun createFragment(position: Int) = GroupServerFragment.newInstance(groups[position].id)
override fun getItemId(position: Int): Long = groups[position].id.hashCode().toLong()
override fun containsItem(itemId: Long): Boolean = groups.any { it.id.hashCode().toLong() == itemId }
@SuppressLint("NotifyDataSetChanged") @SuppressLint("NotifyDataSetChanged")
fun update(groups: List<GroupMapItem>) { fun update(groups: List<GroupMapItem>) {
@@ -1,27 +1,48 @@
package com.v2ray.ang.ui package com.v2ray.ang.ui
import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.util.Log import android.util.Log
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.ItemTouchHelper
import com.v2ray.ang.AppConfig import com.v2ray.ang.AppConfig
import com.v2ray.ang.AppConfig.TAG
import com.v2ray.ang.R import com.v2ray.ang.R
import com.v2ray.ang.contracts.MainAdapterListener
import com.v2ray.ang.databinding.FragmentGroupServerBinding import com.v2ray.ang.databinding.FragmentGroupServerBinding
import com.v2ray.ang.databinding.ItemQrcodeBinding
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.extension.toast
import com.v2ray.ang.extension.toastError
import com.v2ray.ang.extension.toastSuccess
import com.v2ray.ang.handler.AngConfigManager
import com.v2ray.ang.handler.MmkvManager import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.helper.SimpleItemTouchHelperCallback import com.v2ray.ang.helper.SimpleItemTouchHelperCallback
import com.v2ray.ang.viewmodel.MainViewModel import com.v2ray.ang.viewmodel.MainViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class GroupServerFragment : BaseFragment<FragmentGroupServerBinding>() { class GroupServerFragment : BaseFragment<FragmentGroupServerBinding>() {
private val ownerActivity: MainActivity
get() = requireActivity() as MainActivity
private val mainViewModel: MainViewModel by activityViewModels() private val mainViewModel: MainViewModel by activityViewModels()
private lateinit var adapter: MainRecyclerAdapter private lateinit var adapter: MainRecyclerAdapter
private var itemTouchHelper: ItemTouchHelper? = null private var itemTouchHelper: ItemTouchHelper? = null
private val subId: String by lazy { arguments?.getString(ARG_SUB_ID).orEmpty() } private val subId: String by lazy { arguments?.getString(ARG_SUB_ID).orEmpty() }
private val share_method: Array<out String> by lazy {
ownerActivity.resources.getStringArray(R.array.share_method)
}
private val share_method_more: Array<out String> by lazy {
ownerActivity.resources.getStringArray(R.array.share_method_more)
}
companion object { companion object {
private const val ARG_SUB_ID = "subscriptionId" private const val ARG_SUB_ID = "subscriptionId"
fun newInstance(subId: String) = GroupServerFragment().apply { fun newInstance(subId: String) = GroupServerFragment().apply {
@@ -34,7 +55,7 @@ class GroupServerFragment : BaseFragment<FragmentGroupServerBinding>() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
adapter = MainRecyclerAdapter(requireActivity() as MainActivity) adapter = MainRecyclerAdapter(mainViewModel, ActivityAdapterListener())
binding.recyclerView.setHasFixedSize(true) binding.recyclerView.setHasFixedSize(true)
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_DOUBLE_COLUMN_DISPLAY, false)) { if (MmkvManager.decodeSettingsBool(AppConfig.PREF_DOUBLE_COLUMN_DISPLAY, false)) {
binding.recyclerView.layoutManager = GridLayoutManager(requireContext(), 2) binding.recyclerView.layoutManager = GridLayoutManager(requireContext(), 2)
@@ -51,18 +72,202 @@ class GroupServerFragment : BaseFragment<FragmentGroupServerBinding>() {
if (mainViewModel.subscriptionId != subId) { if (mainViewModel.subscriptionId != subId) {
return@observe return@observe
} }
Log.d(TAG, "GroupServerFragment updateListAction subId=$subId") // Log.d(TAG, "GroupServerFragment updateListAction subId=$subId")
adapter.setData(mainViewModel.serversCache, index) adapter.setData(mainViewModel.serversCache, index)
} }
mainViewModel.isRunning.observe(viewLifecycleOwner) { isRunning ->
adapter.isRunning = isRunning
}
Log.d(TAG, "GroupServerFragment onViewCreated: subId=$subId") // Log.d(TAG, "GroupServerFragment onViewCreated: subId=$subId")
} }
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
mainViewModel.subscriptionIdChanged(subId) mainViewModel.subscriptionIdChanged(subId)
} }
}
/**
* Shares server configuration
* Displays a dialog with sharing options and executes the selected action
* @param guid The server unique identifier
* @param profile The server configuration
* @param position The position in the list
* @param shareOptions The list of share options
* @param skip The number of options to skip
*/
private fun shareServer(guid: String, profile: ProfileItem, position: Int, shareOptions: List<String>, skip: Int) {
AlertDialog.Builder(ownerActivity).setItems(shareOptions.toTypedArray()) { _, i ->
try {
when (i + skip) {
0 -> showQRCode(guid)
1 -> share2Clipboard(guid)
2 -> shareFullContent(guid)
3 -> editServer(guid, profile)
4 -> removeServer(guid, position)
else -> ownerActivity.toast("else")
}
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Error when sharing server", e)
}
}.show()
}
/**
* Displays QR code for the server configuration
* @param guid The server unique identifier
*/
private fun showQRCode(guid: String) {
val ivBinding = ItemQrcodeBinding.inflate(LayoutInflater.from(ownerActivity))
ivBinding.ivQcode.setImageBitmap(AngConfigManager.share2QRCode(guid))
if (share_method.isNotEmpty()) {
ivBinding.ivQcode.contentDescription = share_method[0]
} else {
ivBinding.ivQcode.contentDescription = "QR Code"
}
AlertDialog.Builder(ownerActivity).setView(ivBinding.root).show()
}
/**
* Shares server configuration to clipboard
* @param guid The server unique identifier
*/
private fun share2Clipboard(guid: String) {
if (AngConfigManager.share2Clipboard(ownerActivity, guid) == 0) {
ownerActivity.toastSuccess(R.string.toast_success)
} else {
ownerActivity.toastError(R.string.toast_failure)
}
}
/**
* Shares full server configuration content to clipboard
* @param guid The server unique identifier
*/
private fun shareFullContent(guid: String) {
ownerActivity.lifecycleScope.launch(Dispatchers.IO) {
val result = AngConfigManager.shareFullContent2Clipboard(ownerActivity, guid)
launch(Dispatchers.Main) {
if (result == 0) {
ownerActivity.toastSuccess(R.string.toast_success)
} else {
ownerActivity.toastError(R.string.toast_failure)
}
}
}
}
/**
* Edits server configuration
* Opens appropriate editing interface based on configuration type
* @param guid The server unique identifier
* @param profile The server configuration
*/
private fun editServer(guid: String, profile: ProfileItem) {
val intent = Intent().putExtra("guid", guid)
.putExtra("isRunning", mainViewModel.isRunning.value)
.putExtra("createConfigType", profile.configType.value)
when (profile.configType) {
EConfigType.CUSTOM -> {
ownerActivity.startActivity(intent.setClass(ownerActivity, ServerCustomConfigActivity::class.java))
}
EConfigType.POLICYGROUP -> {
ownerActivity.startActivity(intent.setClass(ownerActivity, ServerGroupActivity::class.java))
}
else -> {
ownerActivity.startActivity(intent.setClass(ownerActivity, ServerActivity::class.java))
}
}
}
/**
* Removes server configuration
* Handles confirmation dialog and related checks
* @param guid The server unique identifier
* @param position The position in the list
*/
private fun removeServer(guid: String, position: Int) {
if (guid == MmkvManager.getSelectServer()) {
ownerActivity.toast(R.string.toast_action_not_allowed)
return
}
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_CONFIRM_REMOVE)) {
AlertDialog.Builder(ownerActivity).setMessage(R.string.del_config_comfirm)
.setPositiveButton(android.R.string.ok) { _, _ ->
removeServerSub(guid, position)
}
.setNegativeButton(android.R.string.cancel) { _, _ ->
//do noting
}
.show()
} else {
removeServerSub(guid, position)
}
}
/**
* Executes the actual server removal process
* @param guid The server unique identifier
* @param position The position in the list
*/
private fun removeServerSub(guid: String, position: Int) {
ownerActivity.mainViewModel.removeServer(guid)
adapter.removeServerSub(guid, position)
}
/**
* Sets the selected server
* Updates UI and restarts service if needed
* @param guid The server unique identifier to select
*/
private fun setSelectServer(guid: String) {
val selected = MmkvManager.getSelectServer()
if (guid != selected) {
MmkvManager.setSelectServer(guid)
val fromPosition = mainViewModel.getPosition(selected.orEmpty())
val toPosition = mainViewModel.getPosition(guid)
adapter.setSelectServer(fromPosition, toPosition)
if (mainViewModel.isRunning.value == true) {
ownerActivity.restartV2Ray()
}
}
}
private inner class ActivityAdapterListener : MainAdapterListener {
override fun onEdit(guid: String, position: Int) {
}
override fun onShare(url: String) {
}
override fun onRefreshData() {
}
override fun onRemove(guid: String, position: Int) {
removeServer(guid, position)
}
override fun onEdit(guid: String, position: Int, profile: ProfileItem) {
editServer(guid, profile)
}
override fun onSelectServer(guid: String) {
setSelectServer(guid)
}
override fun onShare(guid: String, profile: ProfileItem, position: Int, more: Boolean) {
val isCustom = profile.configType == EConfigType.CUSTOM || profile.configType == EConfigType.POLICYGROUP
val (shareOptions, skip) = if (more) {
val options = if (isCustom) share_method_more.asList().takeLast(3) else share_method_more.asList()
options to if (isCustom) 2 else 0
} else {
val options = if (isCustom) share_method.asList().takeLast(1) else share_method.asList()
options to if (isCustom) 2 else 0
}
shareServer(guid, profile, position, shareOptions, skip)
}
}
}
@@ -0,0 +1,90 @@
package com.v2ray.ang.ui
import android.net.Uri
import android.os.Bundle
import com.v2ray.ang.enums.PermissionType
import com.v2ray.ang.helper.FileChooserHelper
import com.v2ray.ang.helper.PermissionHelper
import com.v2ray.ang.helper.QRCodeScannerHelper
/**
* HelperBaseActivity extends BaseActivity and provides additional helpers for
* activities that need file chooser, permission requesting, or QR code scanning functionality.
*
* Activities that don't need these features should extend BaseActivity directly.
* Activities that need file selection, permissions, or QR code scanning should extend this class.
*
* Additional Responsibilities:
* - Provide file chooser helpers for selecting and creating files.
* - Provide permission request helpers with callbacks.
* - Provide QR code scanning helpers with camera permission handling.
*/
abstract class HelperBaseActivity : BaseActivity() {
private lateinit var fileChooser : FileChooserHelper
private lateinit var permissionRequester : PermissionHelper
private lateinit var qrCodeScanner : QRCodeScannerHelper
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
fileChooser = FileChooserHelper(this)
permissionRequester = PermissionHelper(this)
qrCodeScanner = QRCodeScannerHelper(this)
}
/**
* Check if permission is granted and request it if not.
* Convenience method that delegates to permissionRequester.
*
* @param permissionType The type of permission to check and request
* @param onGranted Callback to execute when permission is granted
*/
protected fun checkAndRequestPermission(
permissionType: PermissionType,
onGranted: () -> Unit
) {
permissionRequester.request(permissionType, onGranted)
}
/**
* Launch file chooser with ACTION_GET_CONTENT intent.
* Convenience method that delegates to fileChooser helper.
*
* @param mimeType MIME type filter for files
* @param onResult Callback invoked with the selected file URI (null if cancelled)
*/
protected fun launchFileChooser(
mimeType: String = "*/*",
onResult: (Uri?) -> Unit
) {
checkAndRequestPermission(PermissionType.READ_STORAGE) {
fileChooser.launch(mimeType, onResult)
}
}
/**
* Launch document creator to create a new file at user-selected location.
* Convenience method that delegates to fileChooser helper.
* Note: No permission check needed as CreateDocument uses Storage Access Framework.
*
* @param fileName Default file name for the new document
* @param onResult Callback invoked with the created file URI (null if cancelled)
*/
protected fun launchCreateDocument(
fileName: String,
onResult: (Uri?) -> Unit
) {
fileChooser.createDocument(fileName, onResult)
}
/**
* Launch QR code scanner with camera permission check.
* Convenience method that delegates to qrCodeScanner helper.
*
* @param onResult Callback invoked with the scan result string (null if cancelled or failed)
*/
protected fun launchQRCodeScanner(onResult: (String?) -> Unit) {
checkAndRequestPermission(PermissionType.CAMERA) {
qrCodeScanner.launch(onResult)
}
}
}
@@ -2,37 +2,34 @@ package com.v2ray.ang.ui
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.os.Bundle import android.os.Bundle
import android.util.Log
import android.view.Menu import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
import androidx.activity.viewModels
import androidx.appcompat.widget.SearchView import androidx.appcompat.widget.SearchView
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import com.v2ray.ang.AppConfig
import com.v2ray.ang.AppConfig.ANG_PACKAGE
import com.v2ray.ang.R import com.v2ray.ang.R
import com.v2ray.ang.databinding.ActivityLogcatBinding import com.v2ray.ang.databinding.ActivityLogcatBinding
import com.v2ray.ang.extension.toast
import com.v2ray.ang.extension.toastSuccess import com.v2ray.ang.extension.toastSuccess
import com.v2ray.ang.util.Utils import com.v2ray.ang.util.Utils
import com.v2ray.ang.viewmodel.LogcatViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.IOException
class LogcatActivity : BaseActivity(), SwipeRefreshLayout.OnRefreshListener { class LogcatActivity : BaseActivity(), SwipeRefreshLayout.OnRefreshListener {
private val binding by lazy { ActivityLogcatBinding.inflate(layoutInflater) } private val binding by lazy { ActivityLogcatBinding.inflate(layoutInflater) }
private val viewModel: LogcatViewModel by viewModels()
private var logsetsAll: MutableList<String> = mutableListOf() private lateinit var adapter: LogcatRecyclerAdapter
var logsets: MutableList<String> = mutableListOf()
private val adapter by lazy { LogcatRecyclerAdapter(this) }
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
//setContentView(binding.root)
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.title_logcat)) setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.title_logcat))
adapter = LogcatRecyclerAdapter(viewModel, ::onLogLongClick)
binding.recyclerView.setHasFixedSize(true) binding.recyclerView.setHasFixedSize(true)
binding.recyclerView.layoutManager = LinearLayoutManager(this) binding.recyclerView.layoutManager = LinearLayoutManager(this)
addCustomDividerToRecyclerView(binding.recyclerView, this, R.drawable.custom_divider) addCustomDividerToRecyclerView(binding.recyclerView, this, R.drawable.custom_divider)
@@ -40,58 +37,12 @@ class LogcatActivity : BaseActivity(), SwipeRefreshLayout.OnRefreshListener {
binding.refreshLayout.setOnRefreshListener(this) binding.refreshLayout.setOnRefreshListener(this)
logsets.add(getString(R.string.pull_down_to_refresh)) toast(getString(R.string.pull_down_to_refresh))
} }
private fun getLogcat() { private fun onLogLongClick(log: String): Boolean {
Utils.setClipboard(this, log)
try { return true
binding.refreshLayout.isRefreshing = true
lifecycleScope.launch(Dispatchers.Default) {
val lst = LinkedHashSet<String>()
lst.add("logcat")
lst.add("-d")
lst.add("-v")
lst.add("time")
lst.add("-s")
lst.add("GoLog,${ANG_PACKAGE},AndroidRuntime,System.err")
val process = withContext(Dispatchers.IO) {
Runtime.getRuntime().exec(lst.toTypedArray())
}
val allText = process.inputStream.bufferedReader().use { it.readLines() }.reversed()
launch(Dispatchers.Main) {
logsetsAll = allText.toMutableList()
logsets = allText.toMutableList()
refreshData()
binding.refreshLayout.isRefreshing = false
}
}
} catch (e: IOException) {
Log.e(AppConfig.TAG, "Failed to get logcat", e)
}
}
private fun clearLogcat() {
try {
lifecycleScope.launch(Dispatchers.Default) {
val lst = LinkedHashSet<String>()
lst.add("logcat")
lst.add("-c")
withContext(Dispatchers.IO) {
val process = Runtime.getRuntime().exec(lst.toTypedArray())
process.waitFor()
}
launch(Dispatchers.Main) {
logsetsAll.clear()
logsets.clear()
refreshData()
}
}
} catch (e: IOException) {
Log.e(AppConfig.TAG, "Failed to clear logcat", e)
}
} }
override fun onCreateOptionsMenu(menu: Menu): Boolean { override fun onCreateOptionsMenu(menu: Menu): Boolean {
@@ -104,12 +55,14 @@ class LogcatActivity : BaseActivity(), SwipeRefreshLayout.OnRefreshListener {
override fun onQueryTextSubmit(query: String?): Boolean = false override fun onQueryTextSubmit(query: String?): Boolean = false
override fun onQueryTextChange(newText: String?): Boolean { override fun onQueryTextChange(newText: String?): Boolean {
filterLogs(newText) viewModel.filter(newText)
refreshData()
return false return false
} }
}) })
searchView.setOnCloseListener { searchView.setOnCloseListener {
filterLogs("") viewModel.filter("")
refreshData()
false false
} }
} }
@@ -119,33 +72,33 @@ class LogcatActivity : BaseActivity(), SwipeRefreshLayout.OnRefreshListener {
override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) { override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
R.id.copy_all -> { R.id.copy_all -> {
Utils.setClipboard(this, logsets.joinToString("\n")) val all = viewModel.getAll().joinToString("\n")
Utils.setClipboard(this, all)
toastSuccess(R.string.toast_success) toastSuccess(R.string.toast_success)
true true
} }
R.id.clear_all -> { R.id.clear_all -> {
clearLogcat() lifecycleScope.launch(Dispatchers.IO) {
viewModel.clearLogcat()
withContext(Dispatchers.Main) {
refreshData()
}
}
true true
} }
else -> super.onOptionsItemSelected(item) else -> super.onOptionsItemSelected(item)
} }
private fun filterLogs(content: String?): Boolean {
val key = content?.trim()
logsets = if (key.isNullOrEmpty()) {
logsetsAll.toMutableList()
} else {
logsetsAll.filter { it.contains(key) }.toMutableList()
}
refreshData()
return true
}
override fun onRefresh() { override fun onRefresh() {
getLogcat() lifecycleScope.launch(Dispatchers.IO) {
viewModel.loadLogcat()
withContext(Dispatchers.Main) {
binding.refreshLayout.isRefreshing = false
refreshData()
}
}
} }
@SuppressLint("NotifyDataSetChanged") @SuppressLint("NotifyDataSetChanged")
@@ -6,17 +6,20 @@ import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.v2ray.ang.AppConfig import com.v2ray.ang.AppConfig
import com.v2ray.ang.databinding.ItemRecyclerLogcatBinding import com.v2ray.ang.databinding.ItemRecyclerLogcatBinding
import com.v2ray.ang.util.Utils import com.v2ray.ang.viewmodel.LogcatViewModel
class LogcatRecyclerAdapter(val activity: LogcatActivity) : RecyclerView.Adapter<LogcatRecyclerAdapter.MainViewHolder>() { class LogcatRecyclerAdapter(
private var mActivity: LogcatActivity = activity private val viewModel: LogcatViewModel,
private val onLongClick: ((String) -> Boolean)? = null
) : RecyclerView.Adapter<LogcatRecyclerAdapter.MainViewHolder>() {
override fun getItemCount() = viewModel.getAll().size
override fun getItemCount() = mActivity.logsets.size
override fun onBindViewHolder(holder: MainViewHolder, position: Int) { override fun onBindViewHolder(holder: MainViewHolder, position: Int) {
try { try {
val log = mActivity.logsets[position] val logs = viewModel.getAll()
val log = logs[position]
if (log.isEmpty()) { if (log.isEmpty()) {
holder.itemSubSettingBinding.logTag.text = "" holder.itemSubSettingBinding.logTag.text = ""
holder.itemSubSettingBinding.logContent.text = "" holder.itemSubSettingBinding.logContent.text = ""
@@ -27,8 +30,7 @@ class LogcatRecyclerAdapter(val activity: LogcatActivity) : RecyclerView.Adapter
} }
holder.itemView.setOnLongClickListener { holder.itemView.setOnLongClickListener {
Utils.setClipboard(mActivity, log) onLongClick?.invoke(log) ?: false
true
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.e(AppConfig.TAG, "Error binding log view data", e) Log.e(AppConfig.TAG, "Error binding log view data", e)
@@ -1,12 +1,9 @@
package com.v2ray.ang.ui package com.v2ray.ang.ui
import android.Manifest
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager
import android.content.res.ColorStateList import android.content.res.ColorStateList
import android.net.Uri import android.net.Uri
import android.net.VpnService import android.net.VpnService
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.util.Log import android.util.Log
import android.view.KeyEvent import android.view.KeyEvent
@@ -25,15 +22,16 @@ import androidx.lifecycle.lifecycleScope
import com.google.android.material.navigation.NavigationView import com.google.android.material.navigation.NavigationView
import com.google.android.material.tabs.TabLayoutMediator import com.google.android.material.tabs.TabLayoutMediator
import com.v2ray.ang.AppConfig import com.v2ray.ang.AppConfig
import com.v2ray.ang.AppConfig.VPN
import com.v2ray.ang.R import com.v2ray.ang.R
import com.v2ray.ang.databinding.ActivityMainBinding import com.v2ray.ang.databinding.ActivityMainBinding
import com.v2ray.ang.dto.EConfigType import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.enums.PermissionType
import com.v2ray.ang.extension.toast import com.v2ray.ang.extension.toast
import com.v2ray.ang.extension.toastError import com.v2ray.ang.extension.toastError
import com.v2ray.ang.handler.AngConfigManager import com.v2ray.ang.handler.AngConfigManager
import com.v2ray.ang.handler.MmkvManager import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.SettingsChangeManager import com.v2ray.ang.handler.SettingsChangeManager
import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.handler.V2RayServiceManager import com.v2ray.ang.handler.V2RayServiceManager
import com.v2ray.ang.util.Utils import com.v2ray.ang.util.Utils
import com.v2ray.ang.viewmodel.MainViewModel import com.v2ray.ang.viewmodel.MainViewModel
@@ -42,7 +40,7 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedListener { class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelectedListener {
private val binding by lazy { private val binding by lazy {
ActivityMainBinding.inflate(layoutInflater) ActivityMainBinding.inflate(layoutInflater)
} }
@@ -65,52 +63,6 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
} }
} }
// register activity result for requesting permission
private val requestPermissionLauncher =
registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { isGranted: Boolean ->
if (isGranted) {
when (pendingAction) {
Action.IMPORT_QR_CODE_CONFIG ->
scanQRCodeForConfig.launch(Intent(this, ScannerActivity::class.java))
Action.READ_CONTENT_FROM_URI ->
chooseFileForCustomConfig.launch(Intent.createChooser(Intent(Intent.ACTION_GET_CONTENT).apply {
type = "*/*"
addCategory(Intent.CATEGORY_OPENABLE)
}, getString(R.string.title_file_chooser)))
Action.POST_NOTIFICATIONS -> {}
else -> {}
}
} else {
toast(R.string.toast_permission_denied)
}
pendingAction = Action.NONE
}
private var pendingAction: Action = Action.NONE
enum class Action {
NONE,
IMPORT_QR_CODE_CONFIG,
READ_CONTENT_FROM_URI,
POST_NOTIFICATIONS
}
private val chooseFileForCustomConfig = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
val uri = it.data?.data
if (it.resultCode == RESULT_OK && uri != null) {
readContentFromUri(uri)
}
}
private val scanQRCodeForConfig = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (it.resultCode == RESULT_OK) {
importBatchConfig(it.data?.getStringExtra("SCAN_RESULT"))
}
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@@ -148,11 +100,7 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
setupViewModel() setupViewModel()
mainViewModel.reloadServerList() mainViewModel.reloadServerList()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { checkAndRequestPermission(PermissionType.POST_NOTIFICATIONS) {
if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
pendingAction = Action.POST_NOTIFICATIONS
requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
} }
} }
@@ -188,7 +136,7 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
if (mainViewModel.isRunning.value == true) { if (mainViewModel.isRunning.value == true) {
V2RayServiceManager.stopVService(this) V2RayServiceManager.stopVService(this)
} else if ((MmkvManager.decodeSettingsString(AppConfig.PREF_MODE) ?: VPN) == VPN) { } else if (SettingsManager.isVpnMode()) {
val intent = VpnService.prepare(this) val intent = VpnService.prepare(this)
if (intent == null) { if (intent == null) {
startV2Ray() startV2Ray()
@@ -416,12 +364,10 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
* import config from qrcode * import config from qrcode
*/ */
private fun importQRcode(): Boolean { private fun importQRcode(): Boolean {
val permission = Manifest.permission.CAMERA launchQRCodeScanner { scanResult ->
if (ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED) { if (scanResult != null) {
scanQRCodeForConfig.launch(Intent(this, ScannerActivity::class.java)) importBatchConfig(scanResult)
} else { }
pendingAction = Action.IMPORT_QR_CODE_CONFIG
requestPermissionLauncher.launch(permission)
} }
return true return true
} }
@@ -592,21 +538,12 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
* show file chooser * show file chooser
*/ */
private fun showFileChooser() { private fun showFileChooser() {
val intent = Intent(Intent.ACTION_GET_CONTENT) launchFileChooser { uri ->
intent.type = "*/*" if (uri == null) {
intent.addCategory(Intent.CATEGORY_OPENABLE) return@launchFileChooser
}
val permission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { readContentFromUri(uri)
Manifest.permission.READ_MEDIA_IMAGES
} else {
Manifest.permission.READ_EXTERNAL_STORAGE
}
if (ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED) {
pendingAction = Action.READ_CONTENT_FROM_URI
chooseFileForCustomConfig.launch(Intent.createChooser(intent, getString(R.string.title_file_chooser)))
} else {
requestPermissionLauncher.launch(permission)
} }
} }
@@ -614,22 +551,12 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
* read content from uri * read content from uri
*/ */
private fun readContentFromUri(uri: Uri) { private fun readContentFromUri(uri: Uri) {
val permission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { try {
Manifest.permission.READ_MEDIA_IMAGES contentResolver.openInputStream(uri).use { input ->
} else { importBatchConfig(input?.bufferedReader()?.readText())
Manifest.permission.READ_EXTERNAL_STORAGE
}
if (ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED) {
try {
contentResolver.openInputStream(uri).use { input ->
importBatchConfig(input?.bufferedReader()?.readText())
}
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to read content from URI", e)
} }
} else { } catch (e: Exception) {
requestPermissionLauncher.launch(permission) Log.e(AppConfig.TAG, "Failed to read content from URI", e)
} }
} }
@@ -1,60 +1,39 @@
package com.v2ray.ang.ui package com.v2ray.ang.ui
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.Intent
import android.graphics.Color import android.graphics.Color
import android.text.TextUtils
import android.util.Log
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.appcompat.app.AlertDialog
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.v2ray.ang.AngApplication.Companion.application
import com.v2ray.ang.AppConfig import com.v2ray.ang.AppConfig
import com.v2ray.ang.R import com.v2ray.ang.R
import com.v2ray.ang.databinding.ItemQrcodeBinding import com.v2ray.ang.contracts.MainAdapterListener
import com.v2ray.ang.databinding.ItemRecyclerFooterBinding import com.v2ray.ang.databinding.ItemRecyclerFooterBinding
import com.v2ray.ang.databinding.ItemRecyclerMainBinding import com.v2ray.ang.databinding.ItemRecyclerMainBinding
import com.v2ray.ang.dto.EConfigType
import com.v2ray.ang.dto.ProfileItem import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.ServersCache import com.v2ray.ang.dto.ServersCache
import com.v2ray.ang.extension.toast
import com.v2ray.ang.extension.toastError
import com.v2ray.ang.extension.toastSuccess
import com.v2ray.ang.handler.AngConfigManager
import com.v2ray.ang.handler.MmkvManager import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.helper.ItemTouchHelperAdapter import com.v2ray.ang.helper.ItemTouchHelperAdapter
import com.v2ray.ang.helper.ItemTouchHelperViewHolder import com.v2ray.ang.helper.ItemTouchHelperViewHolder
import kotlinx.coroutines.Dispatchers import com.v2ray.ang.viewmodel.MainViewModel
import kotlinx.coroutines.launch
import java.util.Collections import java.util.Collections
class MainRecyclerAdapter(val activity: MainActivity) : RecyclerView.Adapter<MainRecyclerAdapter.BaseViewHolder>(), ItemTouchHelperAdapter { class MainRecyclerAdapter(
private val mainViewModel: MainViewModel,
private val adapterListener: MainAdapterListener?
) : RecyclerView.Adapter<MainRecyclerAdapter.BaseViewHolder>(), ItemTouchHelperAdapter {
companion object { companion object {
private const val VIEW_TYPE_ITEM = 1 private const val VIEW_TYPE_ITEM = 1
private const val VIEW_TYPE_FOOTER = 2 private const val VIEW_TYPE_FOOTER = 2
} }
private var mActivity: MainActivity = activity
private val share_method: Array<out String> by lazy {
mActivity.resources.getStringArray(R.array.share_method)
}
private val share_method_more: Array<out String> by lazy {
mActivity.resources.getStringArray(R.array.share_method_more)
}
var isRunning = false
private val doubleColumnDisplay = MmkvManager.decodeSettingsBool(AppConfig.PREF_DOUBLE_COLUMN_DISPLAY, false) private val doubleColumnDisplay = MmkvManager.decodeSettingsBool(AppConfig.PREF_DOUBLE_COLUMN_DISPLAY, false)
private var data: MutableList<ServersCache> = mutableListOf() private var data: MutableList<ServersCache> = mutableListOf()
@SuppressLint("NotifyDataSetChanged") @SuppressLint("NotifyDataSetChanged")
fun setData(newData: MutableList<ServersCache>?, position: Int = -1) { fun setData(newData: MutableList<ServersCache>?, position: Int = -1) {
if (android.os.Looper.myLooper() != android.os.Looper.getMainLooper()) {
mActivity.runOnUiThread { setData(newData, position) }
return
}
data = newData?.toMutableList() ?: mutableListOf() data = newData?.toMutableList() ?: mutableListOf()
if (position >= 0 && position in data.indices) { if (position >= 0 && position in data.indices) {
@@ -68,9 +47,9 @@ class MainRecyclerAdapter(val activity: MainActivity) : RecyclerView.Adapter<Mai
override fun onBindViewHolder(holder: BaseViewHolder, position: Int) { override fun onBindViewHolder(holder: BaseViewHolder, position: Int) {
if (holder is MainViewHolder) { if (holder is MainViewHolder) {
val context = holder.itemMainBinding.root.context
val guid = data[position].guid val guid = data[position].guid
val profile = data[position].profile val profile = data[position].profile
val isCustom = profile.configType == EConfigType.CUSTOM || profile.configType == EConfigType.POLICYGROUP
holder.itemView.setBackgroundColor(Color.TRANSPARENT) holder.itemView.setBackgroundColor(Color.TRANSPARENT)
@@ -83,9 +62,9 @@ class MainRecyclerAdapter(val activity: MainActivity) : RecyclerView.Adapter<Mai
val aff = MmkvManager.decodeServerAffiliationInfo(guid) val aff = MmkvManager.decodeServerAffiliationInfo(guid)
holder.itemMainBinding.tvTestResult.text = aff?.getTestDelayString().orEmpty() holder.itemMainBinding.tvTestResult.text = aff?.getTestDelayString().orEmpty()
if ((aff?.testDelayMillis ?: 0L) < 0L) { if ((aff?.testDelayMillis ?: 0L) < 0L) {
holder.itemMainBinding.tvTestResult.setTextColor(ContextCompat.getColor(mActivity, R.color.colorPingRed)) holder.itemMainBinding.tvTestResult.setTextColor(ContextCompat.getColor(context, R.color.colorPingRed))
} else { } else {
holder.itemMainBinding.tvTestResult.setTextColor(ContextCompat.getColor(mActivity, R.color.colorPing)) holder.itemMainBinding.tvTestResult.setTextColor(ContextCompat.getColor(context, R.color.colorPing))
} }
//layoutIndicator //layoutIndicator
@@ -107,11 +86,8 @@ class MainRecyclerAdapter(val activity: MainActivity) : RecyclerView.Adapter<Mai
holder.itemMainBinding.layoutRemove.visibility = View.GONE holder.itemMainBinding.layoutRemove.visibility = View.GONE
holder.itemMainBinding.layoutMore.visibility = View.VISIBLE holder.itemMainBinding.layoutMore.visibility = View.VISIBLE
//share method
val shareOptions = if (isCustom) share_method_more.asList().takeLast(3) else share_method_more.asList()
holder.itemMainBinding.layoutMore.setOnClickListener { holder.itemMainBinding.layoutMore.setOnClickListener {
shareServer(guid, profile, position, shareOptions, if (isCustom) 2 else 0) adapterListener?.onShare(guid, profile, position, true)
} }
} else { } else {
holder.itemMainBinding.layoutShare.visibility = View.VISIBLE holder.itemMainBinding.layoutShare.visibility = View.VISIBLE
@@ -119,34 +95,23 @@ class MainRecyclerAdapter(val activity: MainActivity) : RecyclerView.Adapter<Mai
holder.itemMainBinding.layoutRemove.visibility = View.VISIBLE holder.itemMainBinding.layoutRemove.visibility = View.VISIBLE
holder.itemMainBinding.layoutMore.visibility = View.GONE holder.itemMainBinding.layoutMore.visibility = View.GONE
//share method
val shareOptions = if (isCustom) share_method.asList().takeLast(1) else share_method.asList()
holder.itemMainBinding.layoutShare.setOnClickListener { holder.itemMainBinding.layoutShare.setOnClickListener {
shareServer(guid, profile, position, shareOptions, if (isCustom) 2 else 0) adapterListener?.onShare(guid, profile, position, false)
} }
holder.itemMainBinding.layoutEdit.setOnClickListener { holder.itemMainBinding.layoutEdit.setOnClickListener {
editServer(guid, profile) adapterListener?.onEdit(guid, position, profile)
} }
holder.itemMainBinding.layoutRemove.setOnClickListener { holder.itemMainBinding.layoutRemove.setOnClickListener {
removeServer(guid, position) adapterListener?.onRemove(guid, position)
} }
} }
holder.itemMainBinding.infoContainer.setOnClickListener { holder.itemMainBinding.infoContainer.setOnClickListener {
setSelectServer(guid) adapterListener?.onSelectServer(guid)
} }
} }
// if (holder is FooterViewHolder) {
// if (true) {
// holder.itemFooterBinding.layoutEdit.visibility = View.INVISIBLE
// } else {
// holder.itemFooterBinding.layoutEdit.setOnClickListener {
// Utils.openUri(mActivity, "${Utils.decode(AppConfig.PromotionUrl)}?t=${System.currentTimeMillis()}")
// }
// }
// }
} }
/** /**
@@ -178,135 +143,14 @@ class MainRecyclerAdapter(val activity: MainActivity) : RecyclerView.Adapter<Mai
*/ */
private fun getSubscriptionRemarks(profile: ProfileItem): String { private fun getSubscriptionRemarks(profile: ProfileItem): String {
val subRemarks = val subRemarks =
if (mActivity.mainViewModel.subscriptionId.isEmpty()) if (mainViewModel.subscriptionId.isEmpty())
MmkvManager.decodeSubscription(profile.subscriptionId)?.remarks?.firstOrNull() MmkvManager.decodeSubscription(profile.subscriptionId)?.remarks?.firstOrNull()
else else
null null
return subRemarks?.toString() ?: "" return subRemarks?.toString() ?: ""
} }
/** fun removeServerSub(guid: String, position: Int) {
* Shares server configuration
* Displays a dialog with sharing options and executes the selected action
* @param guid The server unique identifier
* @param profile The server configuration
* @param position The position in the list
* @param shareOptions The list of share options
* @param skip The number of options to skip
*/
private fun shareServer(guid: String, profile: ProfileItem, position: Int, shareOptions: List<String>, skip: Int) {
AlertDialog.Builder(mActivity).setItems(shareOptions.toTypedArray()) { _, i ->
try {
when (i + skip) {
0 -> showQRCode(guid)
1 -> share2Clipboard(guid)
2 -> shareFullContent(guid)
3 -> editServer(guid, profile)
4 -> removeServer(guid, position)
else -> mActivity.toast("else")
}
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Error when sharing server", e)
}
}.show()
}
/**
* Displays QR code for the server configuration
* @param guid The server unique identifier
*/
private fun showQRCode(guid: String) {
val ivBinding = ItemQrcodeBinding.inflate(LayoutInflater.from(mActivity))
ivBinding.ivQcode.setImageBitmap(AngConfigManager.share2QRCode(guid))
if (share_method.isNotEmpty()) {
ivBinding.ivQcode.contentDescription = share_method[0]
} else {
ivBinding.ivQcode.contentDescription = "QR Code"
}
AlertDialog.Builder(mActivity).setView(ivBinding.root).show()
}
/**
* Shares server configuration to clipboard
* @param guid The server unique identifier
*/
private fun share2Clipboard(guid: String) {
if (AngConfigManager.share2Clipboard(mActivity, guid) == 0) {
mActivity.toastSuccess(R.string.toast_success)
} else {
mActivity.toastError(R.string.toast_failure)
}
}
/**
* Shares full server configuration content to clipboard
* @param guid The server unique identifier
*/
private fun shareFullContent(guid: String) {
mActivity.lifecycleScope.launch(Dispatchers.IO) {
val result = AngConfigManager.shareFullContent2Clipboard(mActivity, guid)
launch(Dispatchers.Main) {
if (result == 0) {
mActivity.toastSuccess(R.string.toast_success)
} else {
mActivity.toastError(R.string.toast_failure)
}
}
}
}
/**
* Edits server configuration
* Opens appropriate editing interface based on configuration type
* @param guid The server unique identifier
* @param profile The server configuration
*/
private fun editServer(guid: String, profile: ProfileItem) {
val intent = Intent().putExtra("guid", guid)
.putExtra("isRunning", isRunning)
.putExtra("createConfigType", profile.configType.value)
if (profile.configType == EConfigType.CUSTOM) {
mActivity.startActivity(intent.setClass(mActivity, ServerCustomConfigActivity::class.java))
} else if (profile.configType == EConfigType.POLICYGROUP) {
mActivity.startActivity(intent.setClass(mActivity, ServerGroupActivity::class.java))
} else {
mActivity.startActivity(intent.setClass(mActivity, ServerActivity::class.java))
}
}
/**
* Removes server configuration
* Handles confirmation dialog and related checks
* @param guid The server unique identifier
* @param position The position in the list
*/
private fun removeServer(guid: String, position: Int) {
if (guid == MmkvManager.getSelectServer()) {
application.toast(R.string.toast_action_not_allowed)
return
}
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_CONFIRM_REMOVE)) {
AlertDialog.Builder(mActivity).setMessage(R.string.del_config_comfirm)
.setPositiveButton(android.R.string.ok) { _, _ ->
removeServerSub(guid, position)
}
.setNegativeButton(android.R.string.cancel) { _, _ ->
//do noting
}
.show()
} else {
removeServerSub(guid, position)
}
}
/**
* Executes the actual server removal process
* @param guid The server unique identifier
* @param position The position in the list
*/
private fun removeServerSub(guid: String, position: Int) {
mActivity.mainViewModel.removeServer(guid)
val idx = data.indexOfFirst { it.guid == guid } val idx = data.indexOfFirst { it.guid == guid }
if (idx >= 0) { if (idx >= 0) {
data.removeAt(idx) data.removeAt(idx)
@@ -315,23 +159,9 @@ class MainRecyclerAdapter(val activity: MainActivity) : RecyclerView.Adapter<Mai
} }
} }
/** fun setSelectServer(fromPosition: Int, toPosition: Int) {
* Sets the selected server notifyItemChanged(fromPosition)
* Updates UI and restarts service if needed notifyItemChanged(toPosition)
* @param guid The server unique identifier to select
*/
private fun setSelectServer(guid: String) {
val selected = MmkvManager.getSelectServer()
if (guid != selected) {
MmkvManager.setSelectServer(guid)
if (!TextUtils.isEmpty(selected)) {
notifyItemChanged(mActivity.mainViewModel.getPosition(selected.orEmpty()))
}
notifyItemChanged(mActivity.mainViewModel.getPosition(guid))
if (isRunning) {
mActivity.restartV2Ray()
}
}
} }
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BaseViewHolder { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BaseViewHolder {
@@ -369,7 +199,7 @@ class MainRecyclerAdapter(val activity: MainActivity) : RecyclerView.Adapter<Mai
BaseViewHolder(itemFooterBinding.root) BaseViewHolder(itemFooterBinding.root)
override fun onItemMove(fromPosition: Int, toPosition: Int): Boolean { override fun onItemMove(fromPosition: Int, toPosition: Int): Boolean {
mActivity.mainViewModel.swapServer(fromPosition, toPosition) mainViewModel.swapServer(fromPosition, toPosition)
if (fromPosition < data.size && toPosition < data.size) { if (fromPosition < data.size && toPosition < data.size) {
Collections.swap(data, fromPosition, toPosition) Collections.swap(data, fromPosition, toPosition)
} }
@@ -95,7 +95,7 @@ class PerAppProxyActivity : BaseActivity() {
} }
appsAll = apps appsAll = apps
adapter = PerAppProxyAdapter(this@PerAppProxyActivity, apps, viewModel) adapter = PerAppProxyAdapter(apps, viewModel)
binding.recyclerView.adapter = adapter binding.recyclerView.adapter = adapter
} catch (e: Exception) { } catch (e: Exception) {
@@ -292,7 +292,7 @@ class PerAppProxyActivity : BaseActivity() {
} }
} }
adapter = PerAppProxyAdapter(this, apps, adapter?.viewModel ?: viewModel) adapter = PerAppProxyAdapter(apps, adapter?.viewModel ?: viewModel)
binding.recyclerView.adapter = adapter binding.recyclerView.adapter = adapter
refreshData() refreshData()
return true return true
@@ -8,8 +8,10 @@ import com.v2ray.ang.databinding.ItemRecyclerBypassListBinding
import com.v2ray.ang.dto.AppInfo import com.v2ray.ang.dto.AppInfo
import com.v2ray.ang.viewmodel.PerAppProxyViewModel import com.v2ray.ang.viewmodel.PerAppProxyViewModel
class PerAppProxyAdapter(val activity: BaseActivity, val apps: List<AppInfo>, val viewModel: PerAppProxyViewModel) : class PerAppProxyAdapter(
RecyclerView.Adapter<PerAppProxyAdapter.BaseViewHolder>() { val apps: List<AppInfo>,
val viewModel: PerAppProxyViewModel
) :RecyclerView.Adapter<PerAppProxyAdapter.BaseViewHolder>() {
companion object { companion object {
private const val VIEW_TYPE_HEADER = 0 private const val VIEW_TYPE_HEADER = 0
@@ -37,6 +39,7 @@ class PerAppProxyAdapter(val activity: BaseActivity, val apps: List<AppInfo>, va
) )
BaseViewHolder(view) BaseViewHolder(view)
} }
else -> AppViewHolder(ItemRecyclerBypassListBinding.inflate(LayoutInflater.from(ctx), parent, false)) else -> AppViewHolder(ItemRecyclerBypassListBinding.inflate(LayoutInflater.from(ctx), parent, false))
} }
} }
@@ -8,6 +8,7 @@ import androidx.lifecycle.lifecycleScope
import com.v2ray.ang.R import com.v2ray.ang.R
import com.v2ray.ang.databinding.ActivityRoutingEditBinding import com.v2ray.ang.databinding.ActivityRoutingEditBinding
import com.v2ray.ang.dto.RulesetItem import com.v2ray.ang.dto.RulesetItem
import com.v2ray.ang.extension.nullIfBlank
import com.v2ray.ang.extension.toast import com.v2ray.ang.extension.toast
import com.v2ray.ang.extension.toastSuccess import com.v2ray.ang.extension.toastSuccess
import com.v2ray.ang.handler.SettingsManager import com.v2ray.ang.handler.SettingsManager
@@ -62,14 +63,11 @@ class RoutingEditActivity : BaseActivity() {
rulesetItem.apply { rulesetItem.apply {
remarks = binding.etRemarks.text.toString() remarks = binding.etRemarks.text.toString()
locked = binding.chkLocked.isChecked locked = binding.chkLocked.isChecked
domain = binding.etDomain.text.toString().takeIf { it.isNotEmpty() } domain = binding.etDomain.text.toString().nullIfBlank()?.split(",")?.map { it.trim() }?.filter { it.isNotEmpty() }
?.split(",")?.map { it.trim() }?.filter { it.isNotEmpty() } ip = binding.etIp.text.toString().nullIfBlank()?.split(",")?.map { it.trim() }?.filter { it.isNotEmpty() }
ip = binding.etIp.text.toString().takeIf { it.isNotEmpty() } protocol = binding.etProtocol.text.toString().nullIfBlank()?.split(",")?.map { it.trim() }?.filter { it.isNotEmpty() }
?.split(",")?.map { it.trim() }?.filter { it.isNotEmpty() } port = binding.etPort.text.toString().nullIfBlank()
protocol = binding.etProtocol.text.toString().takeIf { it.isNotEmpty() } network = binding.etNetwork.text.toString().nullIfBlank()
?.split(",")?.map { it.trim() }?.filter { it.isNotEmpty() }
port = binding.etPort.text.toString().takeIf { it.isNotEmpty() }
network = binding.etNetwork.text.toString().takeIf { it.isNotEmpty() }
outboundTag = outbound_tag[binding.spOutboundTag.selectedItemPosition] outboundTag = outbound_tag[binding.spOutboundTag.selectedItemPosition]
} }
@@ -1,13 +1,11 @@
package com.v2ray.ang.ui package com.v2ray.ang.ui
import android.Manifest
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.util.Log import android.util.Log
import android.view.Menu import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
@@ -15,8 +13,8 @@ import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import com.v2ray.ang.AppConfig import com.v2ray.ang.AppConfig
import com.v2ray.ang.R import com.v2ray.ang.R
import com.v2ray.ang.contracts.BaseAdapterListener
import com.v2ray.ang.databinding.ActivityRoutingSettingBinding import com.v2ray.ang.databinding.ActivityRoutingSettingBinding
import com.v2ray.ang.extension.toast
import com.v2ray.ang.extension.toastError import com.v2ray.ang.extension.toastError
import com.v2ray.ang.extension.toastSuccess import com.v2ray.ang.extension.toastSuccess
import com.v2ray.ang.handler.MmkvManager import com.v2ray.ang.handler.MmkvManager
@@ -29,9 +27,10 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
class RoutingSettingActivity : BaseActivity() { class RoutingSettingActivity : HelperBaseActivity() {
private val binding by lazy { ActivityRoutingSettingBinding.inflate(layoutInflater) } private val binding by lazy { ActivityRoutingSettingBinding.inflate(layoutInflater) }
private val ownerActivity: RoutingSettingActivity
get() = this
private val viewModel: RoutingSettingsViewModel by viewModels() private val viewModel: RoutingSettingsViewModel by viewModels()
private lateinit var adapter: RoutingSettingRecyclerAdapter private lateinit var adapter: RoutingSettingRecyclerAdapter
private var mItemTouchHelper: ItemTouchHelper? = null private var mItemTouchHelper: ItemTouchHelper? = null
@@ -42,22 +41,12 @@ class RoutingSettingActivity : BaseActivity() {
resources.getStringArray(R.array.preset_rulesets) resources.getStringArray(R.array.preset_rulesets)
} }
private val requestCameraPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { isGranted: Boolean ->
if (isGranted) {
scanQRcodeForRulesets.launch(Intent(this, ScannerActivity::class.java))
} else {
toast(R.string.toast_permission_denied)
}
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
//setContentView(binding.root) //setContentView(binding.root)
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.routing_settings_title)) setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.routing_settings_title))
adapter = RoutingSettingRecyclerAdapter(this, viewModel) adapter = RoutingSettingRecyclerAdapter(viewModel, ActivityAdapterListener())
binding.recyclerView.setHasFixedSize(true) binding.recyclerView.setHasFixedSize(true)
binding.recyclerView.layoutManager = LinearLayoutManager(this) binding.recyclerView.layoutManager = LinearLayoutManager(this)
@@ -87,7 +76,7 @@ class RoutingSettingActivity : BaseActivity() {
R.id.add_rule -> startActivity(Intent(this, RoutingEditActivity::class.java)).let { true } R.id.add_rule -> startActivity(Intent(this, RoutingEditActivity::class.java)).let { true }
R.id.import_predefined_rulesets -> importPredefined().let { true } R.id.import_predefined_rulesets -> importPredefined().let { true }
R.id.import_rulesets_from_clipboard -> importFromClipboard().let { true } R.id.import_rulesets_from_clipboard -> importFromClipboard().let { true }
R.id.import_rulesets_from_qrcode -> requestCameraPermissionLauncher.launch(Manifest.permission.CAMERA).let { true } R.id.import_rulesets_from_qrcode -> importQRcode()
R.id.export_rulesets_to_clipboard -> export2Clipboard().let { true } R.id.export_rulesets_to_clipboard -> export2Clipboard().let { true }
else -> super.onOptionsItemSelected(item) else -> super.onOptionsItemSelected(item)
} }
@@ -159,6 +148,15 @@ class RoutingSettingActivity : BaseActivity() {
.show() .show()
} }
private fun importQRcode(): Boolean {
launchQRCodeScanner { scanResult ->
if (scanResult != null) {
importRulesetsFromQRcode(scanResult)
}
}
return true
}
private fun export2Clipboard() { private fun export2Clipboard() {
val rulesetList = MmkvManager.decodeRoutingRulesets() val rulesetList = MmkvManager.decodeRoutingRulesets()
if (rulesetList.isNullOrEmpty()) { if (rulesetList.isNullOrEmpty()) {
@@ -169,11 +167,6 @@ class RoutingSettingActivity : BaseActivity() {
} }
} }
private val scanQRcodeForRulesets = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (it.resultCode == RESULT_OK) {
importRulesetsFromQRcode(it.data?.getStringExtra("SCAN_RESULT"))
}
}
private fun importRulesetsFromQRcode(qrcode: String?): Boolean { private fun importRulesetsFromQRcode(qrcode: String?): Boolean {
AlertDialog.Builder(this).setMessage(R.string.routing_settings_import_rulesets_tip) AlertDialog.Builder(this).setMessage(R.string.routing_settings_import_rulesets_tip)
@@ -202,4 +195,23 @@ class RoutingSettingActivity : BaseActivity() {
viewModel.reload() viewModel.reload()
adapter.notifyDataSetChanged() adapter.notifyDataSetChanged()
} }
private inner class ActivityAdapterListener : BaseAdapterListener {
override fun onEdit(guid: String, position: Int) {
startActivity(
Intent(ownerActivity, RoutingEditActivity::class.java)
.putExtra("position", position)
)
}
override fun onRemove(guid: String, position: Int) {
}
override fun onShare(url: String) {
}
override fun onRefreshData() {
refreshData()
}
}
} }
@@ -1,24 +1,23 @@
package com.v2ray.ang.ui package com.v2ray.ang.ui
import android.content.Intent
import android.graphics.Color import android.graphics.Color
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.v2ray.ang.contracts.BaseAdapterListener
import com.v2ray.ang.databinding.ItemRecyclerRoutingSettingBinding import com.v2ray.ang.databinding.ItemRecyclerRoutingSettingBinding
import com.v2ray.ang.helper.ItemTouchHelperAdapter import com.v2ray.ang.helper.ItemTouchHelperAdapter
import com.v2ray.ang.helper.ItemTouchHelperViewHolder import com.v2ray.ang.helper.ItemTouchHelperViewHolder
import com.v2ray.ang.viewmodel.RoutingSettingsViewModel import com.v2ray.ang.viewmodel.RoutingSettingsViewModel
class RoutingSettingRecyclerAdapter( class RoutingSettingRecyclerAdapter(
val activity: RoutingSettingActivity, private val viewModel: RoutingSettingsViewModel,
private val viewModel: RoutingSettingsViewModel private val adapterListener: BaseAdapterListener?
) : RecyclerView.Adapter<RoutingSettingRecyclerAdapter.MainViewHolder>(), ) : RecyclerView.Adapter<RoutingSettingRecyclerAdapter.MainViewHolder>(),
ItemTouchHelperAdapter { ItemTouchHelperAdapter {
private var mActivity: RoutingSettingActivity = activity
override fun getItemCount() = viewModel.getAll().size override fun getItemCount() = viewModel.getAll().size
override fun onBindViewHolder(holder: MainViewHolder, position: Int) { override fun onBindViewHolder(holder: MainViewHolder, position: Int) {
@@ -33,10 +32,7 @@ class RoutingSettingRecyclerAdapter(
holder.itemView.setBackgroundColor(Color.TRANSPARENT) holder.itemView.setBackgroundColor(Color.TRANSPARENT)
holder.itemRoutingSettingBinding.layoutEdit.setOnClickListener { holder.itemRoutingSettingBinding.layoutEdit.setOnClickListener {
mActivity.startActivity( adapterListener?.onEdit("", position)
Intent(mActivity, RoutingEditActivity::class.java)
.putExtra("position", position)
)
} }
holder.itemRoutingSettingBinding.chkEnable.setOnCheckedChangeListener { it, isChecked -> holder.itemRoutingSettingBinding.chkEnable.setOnCheckedChangeListener { it, isChecked ->
@@ -76,7 +72,7 @@ class RoutingSettingRecyclerAdapter(
} }
override fun onItemMoveCompleted() { override fun onItemMoveCompleted() {
mActivity.refreshData() adapterListener?.onRefreshData()
} }
override fun onItemDismiss(position: Int) { override fun onItemDismiss(position: Int) {
@@ -1,27 +1,13 @@
package com.v2ray.ang.ui package com.v2ray.ang.ui
import android.Manifest
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import androidx.activity.result.contract.ActivityResultContracts
import com.v2ray.ang.R import com.v2ray.ang.R
import com.v2ray.ang.extension.toast
import com.v2ray.ang.extension.toastError import com.v2ray.ang.extension.toastError
import com.v2ray.ang.extension.toastSuccess import com.v2ray.ang.extension.toastSuccess
import com.v2ray.ang.handler.AngConfigManager import com.v2ray.ang.handler.AngConfigManager
class ScScannerActivity : BaseActivity() { class ScScannerActivity : HelperBaseActivity() {
private val requestCameraPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { isGranted: Boolean ->
if (isGranted) {
scanQRCode.launch(Intent(this, ScannerActivity::class.java))
} else {
toast(R.string.toast_permission_denied)
finish()
}
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@@ -29,24 +15,20 @@ class ScScannerActivity : BaseActivity() {
importQRcode() importQRcode()
} }
private fun importQRcode(): Boolean { private fun importQRcode() {
requestCameraPermissionLauncher.launch(Manifest.permission.CAMERA) launchQRCodeScanner { scanResult ->
return true if (scanResult != null) {
} val (count, countSub) = AngConfigManager.importBatchConfig(scanResult, "", false)
private val scanQRCode = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { if (count + countSub > 0) {
if (it.resultCode == RESULT_OK) { toastSuccess(R.string.toast_success)
val scanResult = it.data?.getStringExtra("SCAN_RESULT").orEmpty() } else {
val (count, countSub) = AngConfigManager.importBatchConfig(scanResult, "", false) toastError(R.string.toast_failure)
}
if (count + countSub > 0) { startActivity(Intent(this, MainActivity::class.java))
toastSuccess(R.string.toast_success)
} else {
toastError(R.string.toast_failure)
} }
finish()
startActivity(Intent(this, MainActivity::class.java))
} }
finish()
} }
} }
@@ -1,16 +1,11 @@
package com.v2ray.ang.ui package com.v2ray.ang.ui
import android.Manifest
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.util.Log import android.util.Log
import android.view.Menu import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
import com.v2ray.ang.AppConfig import com.v2ray.ang.AppConfig
import com.v2ray.ang.R import com.v2ray.ang.R
import com.v2ray.ang.databinding.ActivityNoneBinding import com.v2ray.ang.databinding.ActivityNoneBinding
@@ -19,43 +14,13 @@ import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.util.QRCodeDecoder import com.v2ray.ang.util.QRCodeDecoder
import io.github.g00fy2.quickie.QRResult import io.github.g00fy2.quickie.QRResult
import io.github.g00fy2.quickie.ScanCustomCode import io.github.g00fy2.quickie.ScanCustomCode
import io.github.g00fy2.quickie.config.BarcodeFormat
import io.github.g00fy2.quickie.config.ScannerConfig import io.github.g00fy2.quickie.config.ScannerConfig
class ScannerActivity : BaseActivity() { class ScannerActivity : HelperBaseActivity() {
private val binding by lazy { ActivityNoneBinding.inflate(layoutInflater) } private val binding by lazy { ActivityNoneBinding.inflate(layoutInflater) }
private val scanQrCode = registerForActivityResult(ScanCustomCode(), ::handleResult) private val scanQrCode = registerForActivityResult(ScanCustomCode(), ::handleResult)
private val chooseFile = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
val uri = it.data?.data
if (it.resultCode == RESULT_OK && uri != null) {
try {
val inputStream = contentResolver.openInputStream(uri)
val bitmap = BitmapFactory.decodeStream(inputStream)
inputStream?.close()
val text = QRCodeDecoder.syncDecodeQRCode(bitmap)
if (text.isNullOrEmpty()) {
toast(R.string.toast_decoding_failed)
} else {
finished(text)
}
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to decode QR code from file", e)
toast(R.string.toast_decoding_failed)
}
}
}
private val requestPermissionLauncher =
registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { isGranted: Boolean ->
if (isGranted) {
showFileChooser()
} else {
toast(R.string.toast_permission_denied)
}
}
public override fun onCreate(savedInstanceState: Bundle?) { public override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@@ -73,6 +38,7 @@ class ScannerActivity : BaseActivity() {
setHapticSuccessFeedback(true) // enable (default) or disable haptic feedback when a barcode was detected setHapticSuccessFeedback(true) // enable (default) or disable haptic feedback when a barcode was detected
setShowTorchToggle(true) // show or hide (default) torch/flashlight toggle button setShowTorchToggle(true) // show or hide (default) torch/flashlight toggle button
setShowCloseButton(true) // show or hide (default) close button setShowCloseButton(true) // show or hide (default) close button
setBarcodeFormats(listOf(BarcodeFormat.QR_CODE))
} }
) )
} }
@@ -104,17 +70,7 @@ class ScannerActivity : BaseActivity() {
} }
R.id.select_photo -> { R.id.select_photo -> {
val permission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { showFileChooser()
Manifest.permission.READ_MEDIA_IMAGES
} else {
Manifest.permission.READ_EXTERNAL_STORAGE
}
if (ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED) {
showFileChooser()
} else {
requestPermissionLauncher.launch(permission)
}
true true
} }
@@ -123,15 +79,25 @@ class ScannerActivity : BaseActivity() {
} }
private fun showFileChooser() { private fun showFileChooser() {
val intent = Intent(Intent.ACTION_GET_CONTENT) launchFileChooser("image/*") { uri ->
intent.type = "image/*" if (uri == null) {
intent.addCategory(Intent.CATEGORY_OPENABLE) return@launchFileChooser
//intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true) }
try {
val inputStream = contentResolver.openInputStream(uri)
val bitmap = BitmapFactory.decodeStream(inputStream)
inputStream?.close()
try { val text = QRCodeDecoder.syncDecodeQRCode(bitmap)
chooseFile.launch(Intent.createChooser(intent, getString(R.string.title_file_chooser))) if (text.isNullOrEmpty()) {
} catch (ex: android.content.ActivityNotFoundException) { toast(R.string.toast_decoding_failed)
toast(R.string.toast_require_file_manager) } else {
finished(text)
}
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to decode QR code from file", e)
toast(R.string.toast_decoding_failed)
}
} }
} }
} }
@@ -20,8 +20,8 @@ import com.v2ray.ang.AppConfig.TLS
import com.v2ray.ang.AppConfig.WIREGUARD_LOCAL_ADDRESS_V4 import com.v2ray.ang.AppConfig.WIREGUARD_LOCAL_ADDRESS_V4
import com.v2ray.ang.AppConfig.WIREGUARD_LOCAL_MTU import com.v2ray.ang.AppConfig.WIREGUARD_LOCAL_MTU
import com.v2ray.ang.R import com.v2ray.ang.R
import com.v2ray.ang.dto.EConfigType import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.dto.NetworkType import com.v2ray.ang.enums.NetworkType
import com.v2ray.ang.dto.ProfileItem import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.extension.isNotNullEmpty import com.v2ray.ang.extension.isNotNullEmpty
import com.v2ray.ang.extension.toast import com.v2ray.ang.extension.toast
@@ -137,6 +137,8 @@ class ServerActivity : BaseActivity() {
private val container_ech_config_list: LinearLayout? by lazy { findViewById(R.id.lay_ech_config_list) } private val container_ech_config_list: LinearLayout? by lazy { findViewById(R.id.lay_ech_config_list) }
private val sp_ech_force_query: Spinner? by lazy { findViewById(R.id.sp_ech_force_query) } private val sp_ech_force_query: Spinner? by lazy { findViewById(R.id.sp_ech_force_query) }
private val container_ech_force_query: LinearLayout? by lazy { findViewById(R.id.lay_ech_force_query) } private val container_ech_force_query: LinearLayout? by lazy { findViewById(R.id.lay_ech_force_query) }
private val et_pinned_ca256: EditText? by lazy { findViewById(R.id.et_pinned_ca256) }
private val container_pinned_ca256: LinearLayout? by lazy { findViewById(R.id.lay_pinned_ca256) }
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
@@ -274,7 +276,8 @@ class ServerActivity : BaseActivity() {
container_spider_x, container_spider_x,
container_mldsa65_verify, container_mldsa65_verify,
container_ech_config_list, container_ech_config_list,
container_ech_force_query container_ech_force_query,
container_pinned_ca256
).forEach { it?.visibility = View.GONE } ).forEach { it?.visibility = View.GONE }
} }
@@ -286,7 +289,8 @@ class ServerActivity : BaseActivity() {
container_alpn, container_alpn,
container_allow_insecure, container_allow_insecure,
container_ech_config_list, container_ech_config_list,
container_ech_force_query container_ech_force_query,
container_pinned_ca256
).forEach { it?.visibility = View.VISIBLE } ).forEach { it?.visibility = View.VISIBLE }
listOf( listOf(
container_public_key, container_public_key,
@@ -306,7 +310,8 @@ class ServerActivity : BaseActivity() {
container_alpn, container_alpn,
container_allow_insecure, container_allow_insecure,
container_ech_config_list, container_ech_config_list,
container_ech_force_query container_ech_force_query,
container_pinned_ca256
).forEach { it?.visibility = View.GONE } ).forEach { it?.visibility = View.GONE }
listOf( listOf(
container_public_key, container_public_key,
@@ -394,6 +399,7 @@ class ServerActivity : BaseActivity() {
val index = Utils.arrayFind(echForceQuerys, it) val index = Utils.arrayFind(echForceQuerys, it)
index.let { sp_ech_force_query?.setSelection(if (it >= 0) it else 0) } index.let { sp_ech_force_query?.setSelection(if (it >= 0) it else 0) }
} }
et_pinned_ca256?.text = Utils.getEditable(config.pinnedCA256)
} else if (config.security == REALITY) { } else if (config.security == REALITY) {
et_public_key?.text = Utils.getEditable(config.publicKey.orEmpty()) et_public_key?.text = Utils.getEditable(config.publicKey.orEmpty())
et_short_id?.text = Utils.getEditable(config.shortId.orEmpty()) et_short_id?.text = Utils.getEditable(config.shortId.orEmpty())
@@ -566,6 +572,7 @@ class ServerActivity : BaseActivity() {
val mldsa65Verify = et_mldsa65_verify?.text?.toString() val mldsa65Verify = et_mldsa65_verify?.text?.toString()
val echConfigList = et_ech_config_list?.text?.toString() val echConfigList = et_ech_config_list?.text?.toString()
val echForceQueryIndex = sp_ech_force_query?.selectedItemPosition ?: 0 val echForceQueryIndex = sp_ech_force_query?.selectedItemPosition ?: 0
val pinnedCA256 = et_pinned_ca256?.text?.toString()
val allowInsecure = val allowInsecure =
if (allowInsecureField == null || allowinsecures[allowInsecureField].isBlank()) { if (allowInsecureField == null || allowinsecures[allowInsecureField].isBlank()) {
@@ -585,6 +592,7 @@ class ServerActivity : BaseActivity() {
config.mldsa65Verify = mldsa65Verify config.mldsa65Verify = mldsa65Verify
config.echConfigList = echConfigList config.echConfigList = echConfigList
config.echForceQuery = echForceQuerys[echForceQueryIndex] config.echForceQuery = echForceQuerys[echForceQueryIndex]
config.pinnedCA256 = pinnedCA256
} }
private fun transportTypes(network: String?): Array<out String> { private fun transportTypes(network: String?): Array<out String> {
@@ -632,7 +640,7 @@ class ServerActivity : BaseActivity() {
finish() finish()
} }
} else { } else {
application.toast(R.string.toast_action_not_allowed) toast(R.string.toast_action_not_allowed)
} }
} }
return true return true
@@ -11,7 +11,7 @@ import com.blacksquircle.ui.language.json.JsonLanguage
import com.v2ray.ang.AppConfig import com.v2ray.ang.AppConfig
import com.v2ray.ang.R import com.v2ray.ang.R
import com.v2ray.ang.databinding.ActivityServerCustomConfigBinding import com.v2ray.ang.databinding.ActivityServerCustomConfigBinding
import com.v2ray.ang.dto.EConfigType import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.dto.ProfileItem import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.extension.toast import com.v2ray.ang.extension.toast
import com.v2ray.ang.extension.toastSuccess import com.v2ray.ang.extension.toastSuccess
@@ -8,7 +8,7 @@ import android.widget.ArrayAdapter
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import com.v2ray.ang.R import com.v2ray.ang.R
import com.v2ray.ang.databinding.ActivityServerGroupBinding import com.v2ray.ang.databinding.ActivityServerGroupBinding
import com.v2ray.ang.dto.EConfigType import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.dto.ProfileItem import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.extension.toast import com.v2ray.ang.extension.toast
import com.v2ray.ang.extension.toastSuccess import com.v2ray.ang.extension.toastSuccess
@@ -119,13 +119,13 @@ class ServerGroupActivity : BaseActivity() {
val displayList = mutableListOf(getString(R.string.filter_config_all)) //none val displayList = mutableListOf(getString(R.string.filter_config_all)) //none
subIds.clear() subIds.clear()
subIds.add("") // index 0 => All subIds.add("") // index 0 => All
subs.forEach { (id, item) -> subs.forEach { sub ->
val name = when { val name = when {
item.remarks.isNotBlank() -> item.remarks sub.subscription.remarks.isNotBlank() -> sub.subscription.remarks
else -> id else -> sub.guid
} }
displayList.add(name) displayList.add(name)
subIds.add(id) subIds.add(sub.guid)
} }
val subAdapter = ArrayAdapter(this, android.R.layout.simple_spinner_item, displayList) val subAdapter = ArrayAdapter(this, android.R.layout.simple_spinner_item, displayList)
subAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) subAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
@@ -15,7 +15,7 @@ import com.v2ray.ang.AppConfig.VPN
import com.v2ray.ang.R import com.v2ray.ang.R
import com.v2ray.ang.extension.toLongEx import com.v2ray.ang.extension.toLongEx
import com.v2ray.ang.handler.MmkvManager import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.MmkvPreferenceDataStore import com.v2ray.ang.helper.MmkvPreferenceDataStore
import com.v2ray.ang.handler.SubscriptionUpdater import com.v2ray.ang.handler.SubscriptionUpdater
import com.v2ray.ang.util.Utils import com.v2ray.ang.util.Utils
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
@@ -52,6 +52,10 @@ class SettingsActivity : BaseActivity() {
private val autoUpdateInterval by lazy { findPreference<EditTextPreference>(AppConfig.SUBSCRIPTION_AUTO_UPDATE_INTERVAL) } private val autoUpdateInterval by lazy { findPreference<EditTextPreference>(AppConfig.SUBSCRIPTION_AUTO_UPDATE_INTERVAL) }
private val mode by lazy { findPreference<ListPreference>(AppConfig.PREF_MODE) } private val mode by lazy { findPreference<ListPreference>(AppConfig.PREF_MODE) }
private val hevTunLogLevel by lazy { findPreference<ListPreference>(AppConfig.PREF_HEV_TUNNEL_LOGLEVEL) }
private val hevTunRwTimeout by lazy { findPreference<EditTextPreference>(AppConfig.PREF_HEV_TUNNEL_RW_TIMEOUT) }
private val useHevTun by lazy { findPreference<CheckBoxPreference>(AppConfig.PREF_USE_HEV_TUNNEL) }
override fun onCreatePreferences(bundle: Bundle?, s: String?) { override fun onCreatePreferences(bundle: Bundle?, s: String?) {
// Use MMKV as the storage backend for all Preferences // Use MMKV as the storage backend for all Preferences
// This prevents inconsistencies between SharedPreferences and MMKV // This prevents inconsistencies between SharedPreferences and MMKV
@@ -93,12 +97,21 @@ class SettingsActivity : BaseActivity() {
} }
true true
} }
mode?.setOnPreferenceChangeListener { _, newValue -> mode?.setOnPreferenceChangeListener { pref, newValue ->
updateMode(newValue.toString()) val valueStr = newValue.toString()
(pref as? ListPreference)?.let { lp ->
val idx = lp.findIndexOfValue(valueStr)
lp.summary = if (idx >= 0) lp.entries[idx] else valueStr
}
updateMode(valueStr)
true true
} }
mode?.dialogLayoutResource = R.layout.preference_with_help_link mode?.dialogLayoutResource = R.layout.preference_with_help_link
useHevTun?.setOnPreferenceChangeListener { _, newValue ->
updateHevTunSettings(newValue as Boolean)
true
}
} }
private fun initPreferenceSummaries() { private fun initPreferenceSummaries() {
@@ -141,6 +154,8 @@ class SettingsActivity : BaseActivity() {
override fun onStart() { override fun onStart() {
super.onStart() super.onStart()
updateHevTunSettings(MmkvManager.decodeSettingsBool(AppConfig.PREF_USE_HEV_TUNNEL, true))
// Initialize mode-dependent UI states // Initialize mode-dependent UI states
updateMode(MmkvManager.decodeSettingsString(AppConfig.PREF_MODE, VPN)) updateMode(MmkvManager.decodeSettingsString(AppConfig.PREF_MODE, VPN))
@@ -152,11 +167,10 @@ class SettingsActivity : BaseActivity() {
// Initialize auto-update interval state // Initialize auto-update interval state
autoUpdateInterval?.isEnabled = MmkvManager.decodeSettingsBool(AppConfig.SUBSCRIPTION_AUTO_UPDATE, false) autoUpdateInterval?.isEnabled = MmkvManager.decodeSettingsBool(AppConfig.SUBSCRIPTION_AUTO_UPDATE, false)
} }
private fun updateMode(mode: String?) { private fun updateMode(value: String?) {
val vpn = mode == VPN val vpn = value == VPN
localDns?.isEnabled = vpn localDns?.isEnabled = vpn
fakeDns?.isEnabled = vpn fakeDns?.isEnabled = vpn
appendHttpProxy?.isEnabled = vpn appendHttpProxy?.isEnabled = vpn
@@ -165,6 +179,8 @@ class SettingsActivity : BaseActivity() {
vpnBypassLan?.isEnabled = vpn vpnBypassLan?.isEnabled = vpn
vpnInterfaceAddress?.isEnabled = vpn vpnInterfaceAddress?.isEnabled = vpn
vpnMtu?.isEnabled = vpn vpnMtu?.isEnabled = vpn
useHevTun?.isEnabled = vpn
updateHevTunSettings(false)
if (vpn) { if (vpn) {
updateLocalDns( updateLocalDns(
MmkvManager.decodeSettingsBool( MmkvManager.decodeSettingsBool(
@@ -172,6 +188,12 @@ class SettingsActivity : BaseActivity() {
false false
) )
) )
updateHevTunSettings(
MmkvManager.decodeSettingsBool(
AppConfig.PREF_USE_HEV_TUNNEL,
false
)
)
} }
} }
@@ -235,6 +257,11 @@ class SettingsActivity : BaseActivity() {
fragmentLength?.isEnabled = enabled fragmentLength?.isEnabled = enabled
fragmentInterval?.isEnabled = enabled fragmentInterval?.isEnabled = enabled
} }
private fun updateHevTunSettings(enabled: Boolean) {
hevTunLogLevel?.isEnabled = enabled
hevTunRwTimeout?.isEnabled = enabled
}
} }
fun onModeHelpClicked(view: View) { fun onModeHelpClicked(view: View) {
@@ -3,18 +3,28 @@ package com.v2ray.ang.ui
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater
import android.view.Menu import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.appcompat.app.AlertDialog
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import com.v2ray.ang.AppConfig
import com.v2ray.ang.R import com.v2ray.ang.R
import com.v2ray.ang.contracts.BaseAdapterListener
import com.v2ray.ang.databinding.ActivitySubSettingBinding import com.v2ray.ang.databinding.ActivitySubSettingBinding
import com.v2ray.ang.databinding.ItemQrcodeBinding
import com.v2ray.ang.extension.toast
import com.v2ray.ang.extension.toastError import com.v2ray.ang.extension.toastError
import com.v2ray.ang.extension.toastSuccess import com.v2ray.ang.extension.toastSuccess
import com.v2ray.ang.handler.AngConfigManager import com.v2ray.ang.handler.AngConfigManager
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.helper.SimpleItemTouchHelperCallback import com.v2ray.ang.helper.SimpleItemTouchHelperCallback
import com.v2ray.ang.util.QRCodeDecoder
import com.v2ray.ang.util.Utils
import com.v2ray.ang.viewmodel.SubscriptionsViewModel import com.v2ray.ang.viewmodel.SubscriptionsViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@@ -22,17 +32,21 @@ import kotlinx.coroutines.launch
class SubSettingActivity : BaseActivity() { class SubSettingActivity : BaseActivity() {
private val binding by lazy { ActivitySubSettingBinding.inflate(layoutInflater) } private val binding by lazy { ActivitySubSettingBinding.inflate(layoutInflater) }
private val ownerActivity: SubSettingActivity
get() = this
private val viewModel: SubscriptionsViewModel by viewModels() private val viewModel: SubscriptionsViewModel by viewModels()
private lateinit var adapter: SubSettingRecyclerAdapter private lateinit var adapter: SubSettingRecyclerAdapter
private var mItemTouchHelper: ItemTouchHelper? = null private var mItemTouchHelper: ItemTouchHelper? = null
private val share_method: Array<out String> by lazy {
resources.getStringArray(R.array.share_sub_method)
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
//setContentView(binding.root) //setContentView(binding.root)
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.title_sub_setting)) setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.title_sub_setting))
adapter = SubSettingRecyclerAdapter(this, viewModel) adapter = SubSettingRecyclerAdapter(viewModel, ActivityAdapterListener())
binding.recyclerView.setHasFixedSize(true) binding.recyclerView.setHasFixedSize(true)
binding.recyclerView.layoutManager = LinearLayoutManager(this) binding.recyclerView.layoutManager = LinearLayoutManager(this)
@@ -88,4 +102,62 @@ class SubSettingActivity : BaseActivity() {
viewModel.reload() viewModel.reload()
adapter.notifyDataSetChanged() adapter.notifyDataSetChanged()
} }
private inner class ActivityAdapterListener : BaseAdapterListener {
override fun onEdit(guid: String, position: Int) {
startActivity(
Intent(ownerActivity, SubEditActivity::class.java)
.putExtra("subId", guid)
)
}
override fun onRemove(guid: String, position: Int) {
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_CONFIRM_REMOVE)) {
AlertDialog.Builder(ownerActivity)
.setMessage(R.string.del_config_comfirm)
.setPositiveButton(android.R.string.ok) { _, _ ->
viewModel.remove(guid)
refreshData()
}
.setNegativeButton(android.R.string.cancel, null)
.show()
} else {
viewModel.remove(guid)
refreshData()
}
}
override fun onShare(url: String) {
AlertDialog.Builder(ownerActivity)
.setItems(share_method.asList().toTypedArray()) { _, i ->
try {
when (i) {
0 -> {
val ivBinding =
ItemQrcodeBinding.inflate(LayoutInflater.from(ownerActivity))
ivBinding.ivQcode.setImageBitmap(
QRCodeDecoder.createQRCode(
url
)
)
AlertDialog.Builder(ownerActivity).setView(ivBinding.root).show()
}
1 -> {
Utils.setClipboard(ownerActivity, url)
}
else -> ownerActivity.toast("else")
}
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Share subscription failed", e)
}
}.show()
}
override fun onRefreshData() {
refreshData()
}
}
} }
@@ -1,43 +1,29 @@
package com.v2ray.ang.ui package com.v2ray.ang.ui
import android.content.Intent
import android.graphics.Color import android.graphics.Color
import android.text.TextUtils import android.text.TextUtils
import android.util.Log
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.appcompat.app.AlertDialog
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.v2ray.ang.AppConfig import com.v2ray.ang.contracts.BaseAdapterListener
import com.v2ray.ang.R
import com.v2ray.ang.databinding.ItemQrcodeBinding
import com.v2ray.ang.databinding.ItemRecyclerSubSettingBinding import com.v2ray.ang.databinding.ItemRecyclerSubSettingBinding
import com.v2ray.ang.extension.toast
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.helper.ItemTouchHelperAdapter import com.v2ray.ang.helper.ItemTouchHelperAdapter
import com.v2ray.ang.helper.ItemTouchHelperViewHolder import com.v2ray.ang.helper.ItemTouchHelperViewHolder
import com.v2ray.ang.util.QRCodeDecoder
import com.v2ray.ang.util.Utils import com.v2ray.ang.util.Utils
import com.v2ray.ang.viewmodel.SubscriptionsViewModel import com.v2ray.ang.viewmodel.SubscriptionsViewModel
class SubSettingRecyclerAdapter( class SubSettingRecyclerAdapter(
val activity: SubSettingActivity, private val viewModel: SubscriptionsViewModel,
private val viewModel: SubscriptionsViewModel private val adapterListener: BaseAdapterListener?
) : RecyclerView.Adapter<SubSettingRecyclerAdapter.MainViewHolder>(), ItemTouchHelperAdapter { ) : RecyclerView.Adapter<SubSettingRecyclerAdapter.MainViewHolder>(), ItemTouchHelperAdapter {
private var mActivity: SubSettingActivity = activity
private val share_method: Array<out String> by lazy {
mActivity.resources.getStringArray(R.array.share_sub_method)
}
override fun getItemCount() = viewModel.getAll().size override fun getItemCount() = viewModel.getAll().size
override fun onBindViewHolder(holder: MainViewHolder, position: Int) { override fun onBindViewHolder(holder: MainViewHolder, position: Int) {
val subscriptions = viewModel.getAll() val subscriptions = viewModel.getAll()
val subId = subscriptions[position].first val subId = subscriptions[position].guid
val subItem = subscriptions[position].second val subItem = subscriptions[position].subscription
holder.itemSubSettingBinding.tvName.text = subItem.remarks holder.itemSubSettingBinding.tvName.text = subItem.remarks
holder.itemSubSettingBinding.tvUrl.text = subItem.url holder.itemSubSettingBinding.tvUrl.text = subItem.url
holder.itemSubSettingBinding.chkEnable.isChecked = subItem.enabled holder.itemSubSettingBinding.chkEnable.isChecked = subItem.enabled
@@ -45,14 +31,11 @@ class SubSettingRecyclerAdapter(
holder.itemView.setBackgroundColor(Color.TRANSPARENT) holder.itemView.setBackgroundColor(Color.TRANSPARENT)
holder.itemSubSettingBinding.layoutEdit.setOnClickListener { holder.itemSubSettingBinding.layoutEdit.setOnClickListener {
mActivity.startActivity( adapterListener?.onEdit(subId, position)
Intent(mActivity, SubEditActivity::class.java)
.putExtra("subId", subId)
)
} }
holder.itemSubSettingBinding.layoutRemove.setOnClickListener { holder.itemSubSettingBinding.layoutRemove.setOnClickListener {
removeSubscription(subId, position) adapterListener?.onRemove(subId, position)
} }
holder.itemSubSettingBinding.chkEnable.setOnCheckedChangeListener { it, isChecked -> holder.itemSubSettingBinding.chkEnable.setOnCheckedChangeListener { it, isChecked ->
@@ -72,58 +55,11 @@ class SubSettingRecyclerAdapter(
holder.itemSubSettingBinding.chkEnable.visibility = View.VISIBLE holder.itemSubSettingBinding.chkEnable.visibility = View.VISIBLE
holder.itemSubSettingBinding.layoutLastUpdated.visibility = View.VISIBLE holder.itemSubSettingBinding.layoutLastUpdated.visibility = View.VISIBLE
holder.itemSubSettingBinding.layoutShare.setOnClickListener { holder.itemSubSettingBinding.layoutShare.setOnClickListener {
AlertDialog.Builder(mActivity) adapterListener?.onShare(subItem.url)
.setItems(share_method.asList().toTypedArray()) { _, i ->
try {
when (i) {
0 -> {
val ivBinding =
ItemQrcodeBinding.inflate(LayoutInflater.from(mActivity))
ivBinding.ivQcode.setImageBitmap(
QRCodeDecoder.createQRCode(
subItem.url
)
)
AlertDialog.Builder(mActivity).setView(ivBinding.root).show()
}
1 -> {
Utils.setClipboard(mActivity, subItem.url)
}
else -> mActivity.toast("else")
}
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Share subscription failed", e)
}
}.show()
} }
} }
} }
private fun removeSubscription(subId: String, position: Int) {
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_CONFIRM_REMOVE)) {
AlertDialog.Builder(mActivity).setMessage(R.string.del_config_comfirm)
.setPositiveButton(android.R.string.ok) { _, _ ->
removeSubscriptionSub(subId, position)
}
.setNegativeButton(android.R.string.cancel) { _, _ ->
//do noting
}
.show()
} else {
removeSubscriptionSub(subId, position)
}
}
private fun removeSubscriptionSub(subId: String, position: Int) {
viewModel.remove(subId)
notifyItemRemoved(position)
notifyItemRangeChanged(position, viewModel.getAll().size)
mActivity.refreshData()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MainViewHolder { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MainViewHolder {
return MainViewHolder( return MainViewHolder(
ItemRecyclerSubSettingBinding.inflate( ItemRecyclerSubSettingBinding.inflate(
@@ -154,7 +90,7 @@ class SubSettingRecyclerAdapter(
} }
override fun onItemMoveCompleted() { override fun onItemMoveCompleted() {
mActivity.refreshData() adapterListener?.onRefreshData()
} }
override fun onItemDismiss(position: Int) { override fun onItemDismiss(position: Int) {
@@ -23,7 +23,8 @@ class TaskerActivity : BaseActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(binding.root) //setContentView(binding.root)
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = "")
//add def value //add def value
lstData.add("Default") lstData.add("Default")
@@ -1,95 +1,52 @@
package com.v2ray.ang.ui package com.v2ray.ang.ui
import android.Manifest
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.app.AlertDialog import android.app.AlertDialog
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.provider.OpenableColumns import android.provider.OpenableColumns
import android.util.Log import android.util.Log
import android.view.LayoutInflater
import android.view.Menu import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
import android.view.View.GONE import androidx.activity.viewModels
import android.view.View.VISIBLE
import android.view.ViewGroup
import androidx.activity.result.contract.ActivityResultContracts
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.v2ray.ang.AppConfig import com.v2ray.ang.AppConfig
import com.v2ray.ang.R import com.v2ray.ang.R
import com.v2ray.ang.contracts.BaseAdapterListener
import com.v2ray.ang.databinding.ActivityUserAssetBinding import com.v2ray.ang.databinding.ActivityUserAssetBinding
import com.v2ray.ang.databinding.ItemRecyclerUserAssetBinding
import com.v2ray.ang.dto.AssetUrlItem import com.v2ray.ang.dto.AssetUrlItem
import com.v2ray.ang.extension.concatUrl
import com.v2ray.ang.extension.toTrafficString
import com.v2ray.ang.extension.toast import com.v2ray.ang.extension.toast
import com.v2ray.ang.extension.toastError import com.v2ray.ang.extension.toastError
import com.v2ray.ang.extension.toastSuccess import com.v2ray.ang.extension.toastSuccess
import com.v2ray.ang.handler.MmkvManager import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.SettingsManager import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.util.HttpUtil
import com.v2ray.ang.util.Utils import com.v2ray.ang.util.Utils
import com.v2ray.ang.viewmodel.UserAssetViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.File import java.io.File
import java.io.FileOutputStream
import java.net.HttpURLConnection
import java.text.DateFormat
import java.util.Date
class UserAssetActivity : BaseActivity() { class UserAssetActivity : HelperBaseActivity() {
private val binding by lazy { ActivityUserAssetBinding.inflate(layoutInflater) } private val binding by lazy { ActivityUserAssetBinding.inflate(layoutInflater) }
private val ownerActivity: UserAssetActivity
get() = this
private val viewModel: UserAssetViewModel by viewModels()
private lateinit var adapter: UserAssetAdapter
val extDir by lazy { File(Utils.userAssetPath(this)) } val extDir by lazy { File(Utils.userAssetPath(this)) }
val builtInGeoFiles = arrayOf("geosite.dat", "geoip.dat")
private val requestStoragePermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { isGranted: Boolean ->
if (isGranted) {
val intent = Intent(Intent.ACTION_GET_CONTENT)
intent.type = "*/*"
intent.addCategory(Intent.CATEGORY_OPENABLE)
try {
chooseFile.launch(
Intent.createChooser(
intent,
getString(R.string.title_file_chooser)
)
)
} catch (ex: android.content.ActivityNotFoundException) {
toast(R.string.toast_require_file_manager)
}
} else {
toast(R.string.toast_permission_denied)
}
}
private val requestCameraPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { isGranted: Boolean ->
if (isGranted) {
scanQRCodeForAssetURL.launch(Intent(this, ScannerActivity::class.java))
} else {
toast(R.string.toast_permission_denied)
}
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
//setContentView(binding.root)
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.title_user_asset_setting)) setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.title_user_asset_setting))
binding.recyclerView.setHasFixedSize(true) binding.recyclerView.setHasFixedSize(true)
binding.recyclerView.layoutManager = LinearLayoutManager(this) binding.recyclerView.layoutManager = LinearLayoutManager(this)
addCustomDividerToRecyclerView(binding.recyclerView, this, R.drawable.custom_divider) addCustomDividerToRecyclerView(binding.recyclerView, this, R.drawable.custom_divider)
binding.recyclerView.adapter = UserAssetAdapter() adapter = UserAssetAdapter(viewModel, extDir, ActivityAdapterListener())
binding.recyclerView.adapter = adapter
binding.tvGeoFilesSourcesSummary.text = getGeoFilesSources() binding.tvGeoFilesSourcesSummary.text = getGeoFilesSources()
binding.layoutGeoFilesSources.setOnClickListener { binding.layoutGeoFilesSources.setOnClickListener {
@@ -133,17 +90,11 @@ class UserAssetActivity : BaseActivity() {
} }
private fun showFileChooser() { private fun showFileChooser() {
val permission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { launchFileChooser { uri ->
Manifest.permission.READ_MEDIA_IMAGES if (uri == null) {
} else { return@launchFileChooser
Manifest.permission.READ_EXTERNAL_STORAGE }
}
requestStoragePermissionLauncher.launch(permission)
}
private val chooseFile = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
val uri = result.data?.data
if (result.resultCode == RESULT_OK && uri != null) {
val assetId = Utils.getUuid() val assetId = Utils.getUuid()
runCatching { runCatching {
val assetItem = AssetUrlItem( val assetItem = AssetUrlItem(
@@ -152,7 +103,7 @@ class UserAssetActivity : BaseActivity() {
) )
val assetList = MmkvManager.decodeAssetUrls() val assetList = MmkvManager.decodeAssetUrls()
if (assetList.any { it.second.remarks == assetItem.remarks && it.first != assetId }) { if (assetList.any { it.assetUrl.remarks == assetItem.remarks && it.guid != assetId }) {
toast(R.string.msg_remark_is_duplicate) toast(R.string.msg_remark_is_duplicate)
} else { } else {
MmkvManager.encodeAsset(assetId, assetItem) MmkvManager.encodeAsset(assetId, assetItem)
@@ -190,15 +141,14 @@ class UserAssetActivity : BaseActivity() {
} }
private fun importAssetFromQRcode(): Boolean { private fun importAssetFromQRcode(): Boolean {
requestCameraPermissionLauncher.launch(Manifest.permission.CAMERA) launchQRCodeScanner { scanResult ->
if (scanResult != null) {
importAsset(scanResult)
}
}
return true return true
} }
private val scanQRCodeForAssetURL = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (it.resultCode == RESULT_OK) {
importAsset(it.data?.getStringExtra("SCAN_RESULT"))
}
}
private fun importAsset(url: String?): Boolean { private fun importAsset(url: String?): Boolean {
try { try {
@@ -219,81 +169,25 @@ class UserAssetActivity : BaseActivity() {
} }
private fun downloadGeoFiles() { private fun downloadGeoFiles() {
refreshData()
showLoading() showLoading()
toast(R.string.msg_downloading_content) toast(R.string.msg_downloading_content)
val httpPort = SettingsManager.getHttpPort() val httpPort = SettingsManager.getHttpPort()
var assets = MmkvManager.decodeAssetUrls()
assets = addBuiltInGeoItems(assets)
var resultCount = 0
lifecycleScope.launch(Dispatchers.IO) { lifecycleScope.launch(Dispatchers.IO) {
assets.forEach { val result = viewModel.downloadGeoFiles(extDir, httpPort)
try {
var result = downloadGeo(it.second, 15000, httpPort)
if (!result) {
result = downloadGeo(it.second, 15000, 0)
}
if (result)
resultCount++
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to download geo file: ${it.second.remarks}", e)
}
}
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
if (resultCount > 0) { if (result.successCount > 0) {
toast(getString(R.string.title_update_config_count, resultCount)) toast(getString(R.string.title_update_config_count, result.successCount))
refreshData()
} else { } else {
toast(getString(R.string.toast_failure)) toast(getString(R.string.toast_failure))
} }
refreshData()
hideLoading() hideLoading()
} }
} }
} }
private fun downloadGeo(item: AssetUrlItem, timeout: Int, httpPort: Int): Boolean {
val targetTemp = File(extDir, item.remarks + "_temp")
val target = File(extDir, item.remarks)
Log.i(AppConfig.TAG, "Downloading geo file: ${item.remarks} from ${item.url}")
val conn = HttpUtil.createProxyConnection(item.url, httpPort, timeout, timeout, needStream = true) ?: return false
try {
val inputStream = conn.inputStream
val responseCode = conn.responseCode
if (responseCode == HttpURLConnection.HTTP_OK) {
FileOutputStream(targetTemp).use { output ->
inputStream.copyTo(output)
}
targetTemp.renameTo(target)
}
return true
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to download geo file: ${item.remarks}", e)
return false
} finally {
conn.disconnect()
}
}
private fun addBuiltInGeoItems(assets: List<Pair<String, AssetUrlItem>>): List<Pair<String, AssetUrlItem>> {
val list = mutableListOf<Pair<String, AssetUrlItem>>()
builtInGeoFiles
.filter { geoFile -> assets.none { it.second.remarks == geoFile } }
.forEach {
list.add(
Utils.getUuid() to AssetUrlItem(
it,
String.format(AppConfig.GITHUB_DOWNLOAD_URL, getGeoFilesSources()).concatUrl(it),
locked = true
)
)
}
return list + assets
}
fun initAssets() { fun initAssets() {
lifecycleScope.launch(Dispatchers.Default) { lifecycleScope.launch(Dispatchers.Default) {
SettingsManager.initAssets(this@UserAssetActivity, assets) SettingsManager.initAssets(this@UserAssetActivity, assets)
@@ -305,72 +199,41 @@ class UserAssetActivity : BaseActivity() {
@SuppressLint("NotifyDataSetChanged") @SuppressLint("NotifyDataSetChanged")
fun refreshData() { fun refreshData() {
binding.recyclerView.adapter?.notifyDataSetChanged() viewModel.reload(getGeoFilesSources())
adapter.notifyDataSetChanged()
} }
inner class UserAssetAdapter : RecyclerView.Adapter<UserAssetViewHolder>() { private inner class ActivityAdapterListener : BaseAdapterListener {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): UserAssetViewHolder { override fun onEdit(guid: String, position: Int) {
return UserAssetViewHolder( startActivity(
ItemRecyclerUserAssetBinding.inflate( Intent(ownerActivity, UserAssetUrlActivity::class.java)
LayoutInflater.from(parent.context), .putExtra("assetId", guid)
parent,
false
)
) )
} }
@SuppressLint("SetTextI18n") override fun onRemove(guid: String, position: Int) {
override fun onBindViewHolder(holder: UserAssetViewHolder, position: Int) { val asset = viewModel.getAsset(position)?.takeIf { it.guid == guid }
var assets = MmkvManager.decodeAssetUrls() ?: viewModel.getAssets().find { it.guid == guid }
assets = addBuiltInGeoItems(assets) ?: return
val item = assets.getOrNull(position) ?: return val file = extDir.listFiles()?.find { it.name == asset.assetUrl.remarks }
// file with name == item.second.remarks
val file = extDir.listFiles()?.find { it.name == item.second.remarks }
holder.itemUserAssetBinding.assetName.text = item.second.remarks AlertDialog.Builder(ownerActivity).setMessage(R.string.del_config_comfirm)
.setPositiveButton(android.R.string.ok) { _, _ ->
if (file != null) { file?.delete()
val dateFormat = DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.MEDIUM) MmkvManager.removeAssetUrl(guid)
holder.itemUserAssetBinding.assetProperties.text = initAssets()
"${file.length().toTrafficString()} • ${dateFormat.format(Date(file.lastModified()))}" }
} else { .setNegativeButton(android.R.string.cancel) { _, _ ->
holder.itemUserAssetBinding.assetProperties.text = getString(R.string.msg_file_not_found) // do nothing
} }
.show()
if (item.second.locked == true) {
holder.itemUserAssetBinding.layoutEdit.visibility = GONE
//holder.itemUserAssetBinding.layoutRemove.visibility = GONE
} else {
holder.itemUserAssetBinding.layoutEdit.visibility = item.second.url.let { if (it == "file") GONE else VISIBLE }
//holder.itemUserAssetBinding.layoutRemove.visibility = VISIBLE
}
holder.itemUserAssetBinding.layoutEdit.setOnClickListener {
val intent = Intent(this@UserAssetActivity, UserAssetUrlActivity::class.java)
intent.putExtra("assetId", item.first)
startActivity(intent)
}
holder.itemUserAssetBinding.layoutRemove.setOnClickListener {
AlertDialog.Builder(this@UserAssetActivity).setMessage(R.string.del_config_comfirm)
.setPositiveButton(android.R.string.ok) { _, _ ->
file?.delete()
MmkvManager.removeAssetUrl(item.first)
initAssets()
}
.setNegativeButton(android.R.string.cancel) { _, _ ->
//do noting
}
.show()
}
} }
override fun getItemCount(): Int { override fun onShare(url: String) {
var assets = MmkvManager.decodeAssetUrls() }
assets = addBuiltInGeoItems(assets)
return assets.size override fun onRefreshData() {
refreshData()
} }
} }
class UserAssetViewHolder(val itemUserAssetBinding: ItemRecyclerUserAssetBinding) :
RecyclerView.ViewHolder(itemUserAssetBinding.root)
} }
@@ -0,0 +1,71 @@
package com.v2ray.ang.ui
import android.annotation.SuppressLint
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.v2ray.ang.R
import com.v2ray.ang.contracts.BaseAdapterListener
import com.v2ray.ang.databinding.ItemRecyclerUserAssetBinding
import com.v2ray.ang.extension.toTrafficString
import com.v2ray.ang.viewmodel.UserAssetViewModel
import java.io.File
import java.text.DateFormat
import java.util.Date
class UserAssetAdapter(
private val viewModel: UserAssetViewModel,
private val extDir: File,
private val adapterListener: BaseAdapterListener?
) : RecyclerView.Adapter<UserAssetAdapter.UserAssetViewHolder>() {
override fun getItemCount() = viewModel.itemCount
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): UserAssetViewHolder {
return UserAssetViewHolder(
ItemRecyclerUserAssetBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
)
}
@SuppressLint("SetTextI18n")
override fun onBindViewHolder(holder: UserAssetViewHolder, position: Int) {
val item = viewModel.getAsset(position) ?: return
val file = extDir.listFiles()?.find { it.name == item.assetUrl.remarks }
holder.itemUserAssetBinding.assetName.text = item.assetUrl.remarks
if (file != null) {
val dateFormat = DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.MEDIUM)
holder.itemUserAssetBinding.assetProperties.text =
"${file.length().toTrafficString()} • ${dateFormat.format(Date(file.lastModified()))}"
} else {
holder.itemUserAssetBinding.assetProperties.text =
holder.itemUserAssetBinding.root.context.getString(R.string.msg_file_not_found)
}
if (item.assetUrl.locked == true) {
holder.itemUserAssetBinding.layoutEdit.visibility = View.GONE
} else {
holder.itemUserAssetBinding.layoutEdit.visibility = if (item.assetUrl.url == "file") {
View.GONE
} else {
View.VISIBLE
}
}
holder.itemUserAssetBinding.layoutEdit.setOnClickListener {
adapterListener?.onEdit(item.guid, position)
}
holder.itemUserAssetBinding.layoutRemove.setOnClickListener {
adapterListener?.onRemove(item.guid, position)
}
}
class UserAssetViewHolder(val itemUserAssetBinding: ItemRecyclerUserAssetBinding) :
RecyclerView.ViewHolder(itemUserAssetBinding.root)
}
@@ -93,7 +93,7 @@ class UserAssetUrlActivity : BaseActivity() {
// check remarks unique // check remarks unique
val assetList = MmkvManager.decodeAssetUrls() val assetList = MmkvManager.decodeAssetUrls()
if (assetList.any { it.second.remarks == assetItem.remarks && it.first != assetId }) { if (assetList.any { it.assetUrl.remarks == assetItem.remarks && it.guid != assetId }) {
toast(R.string.msg_remark_is_duplicate) toast(R.string.msg_remark_is_duplicate)
return false return false
} }
@@ -65,9 +65,9 @@ object QRCodeDecoder {
val qrReader = QRCodeReader() val qrReader = QRCodeReader()
try { try {
qrReader.decode(BinaryBitmap(GlobalHistogramBinarizer(source)), mapOf(DecodeHintType.TRY_HARDER to true)).text qrReader.decode(BinaryBitmap(GlobalHistogramBinarizer(source)), HINTS).text
} catch (e: NotFoundException) { } catch (e: NotFoundException) {
qrReader.decode(BinaryBitmap(GlobalHistogramBinarizer(source.invert())), mapOf(DecodeHintType.TRY_HARDER to true)).text qrReader.decode(BinaryBitmap(GlobalHistogramBinarizer(source.invert())), HINTS).text
} }
}.getOrNull() }.getOrNull()
} }
@@ -97,27 +97,9 @@ object QRCodeDecoder {
} }
init { init {
val allFormats: List<BarcodeFormat> = arrayListOf( // Keep decoding hints focused on QR codes and enable TRY_HARDER + UTF-8 charset for better success rate.
BarcodeFormat.AZTEC, HINTS[DecodeHintType.TRY_HARDER] = true
BarcodeFormat.CODABAR, HINTS[DecodeHintType.POSSIBLE_FORMATS] = listOf(BarcodeFormat.QR_CODE)
BarcodeFormat.CODE_39, HINTS[DecodeHintType.CHARACTER_SET] = Charsets.UTF_8.name()
BarcodeFormat.CODE_93,
BarcodeFormat.CODE_128,
BarcodeFormat.DATA_MATRIX,
BarcodeFormat.EAN_8,
BarcodeFormat.EAN_13,
BarcodeFormat.ITF,
BarcodeFormat.MAXICODE,
BarcodeFormat.PDF_417,
BarcodeFormat.QR_CODE,
BarcodeFormat.RSS_14,
BarcodeFormat.RSS_EXPANDED,
BarcodeFormat.UPC_A,
BarcodeFormat.UPC_E,
BarcodeFormat.UPC_EAN_EXTENSION
)
HINTS[DecodeHintType.TRY_HARDER] = BarcodeFormat.QR_CODE
HINTS[DecodeHintType.POSSIBLE_FORMATS] = allFormats
HINTS[DecodeHintType.CHARACTER_SET] = Charsets.UTF_8
} }
} }
@@ -0,0 +1,62 @@
package com.v2ray.ang.viewmodel
import androidx.lifecycle.ViewModel
import com.v2ray.ang.AppConfig
import com.v2ray.ang.AppConfig.ANG_PACKAGE
import java.io.IOException
class LogcatViewModel : ViewModel() {
private val logsetsAll: MutableList<String> = mutableListOf()
private var filteredLogs: List<String> = emptyList()
private var currentFilter: String = ""
fun getAll(): List<String> = filteredLogs
fun loadLogcat() {
try {
val lst = LinkedHashSet<String>()
lst.add("logcat")
lst.add("-d")
lst.add("-v")
lst.add("time")
lst.add("-s")
lst.add("GoLog,${ANG_PACKAGE},AndroidRuntime,System.err")
val process = Runtime.getRuntime().exec(lst.toTypedArray())
val allText = process.inputStream.bufferedReader().use { it.readLines() }.reversed()
logsetsAll.clear()
logsetsAll.addAll(allText)
applyFilter()
} catch (e: IOException) {
android.util.Log.e(AppConfig.TAG, "Failed to get logcat", e)
}
}
fun clearLogcat() {
try {
val lst = LinkedHashSet<String>()
lst.add("logcat")
lst.add("-c")
val process = Runtime.getRuntime().exec(lst.toTypedArray())
process.waitFor()
logsetsAll.clear()
filteredLogs = emptyList()
} catch (e: IOException) {
android.util.Log.e(AppConfig.TAG, "Failed to clear logcat", e)
}
}
fun filter(content: String?) {
currentFilter = content?.trim() ?: ""
applyFilter()
}
private fun applyFilter() {
filteredLogs = if (currentFilter.isEmpty()) {
logsetsAll.toList()
} else {
logsetsAll.filter { it.contains(currentFilter) }
}
}
}
@@ -15,8 +15,8 @@ import com.v2ray.ang.AngApplication
import com.v2ray.ang.AppConfig import com.v2ray.ang.AppConfig
import com.v2ray.ang.R import com.v2ray.ang.R
import com.v2ray.ang.dto.GroupMapItem import com.v2ray.ang.dto.GroupMapItem
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.ServersCache import com.v2ray.ang.dto.ServersCache
import com.v2ray.ang.dto.SubscriptionCache
import com.v2ray.ang.extension.serializable import com.v2ray.ang.extension.serializable
import com.v2ray.ang.extension.toastError import com.v2ray.ang.extension.toastError
import com.v2ray.ang.extension.toastSuccess import com.v2ray.ang.extension.toastSuccess
@@ -179,7 +179,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
return AngConfigManager.updateConfigViaSubAll() return AngConfigManager.updateConfigViaSubAll()
} else { } else {
val subItem = MmkvManager.decodeSubscription(subscriptionId) ?: return 0 val subItem = MmkvManager.decodeSubscription(subscriptionId) ?: return 0
return AngConfigManager.updateConfigViaSub(Pair(subscriptionId, subItem)) return AngConfigManager.updateConfigViaSub(SubscriptionCache(subscriptionId, subItem))
} }
} }
@@ -273,7 +273,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
fun getSubscriptions(context: Context): List<GroupMapItem> { fun getSubscriptions(context: Context): List<GroupMapItem> {
val subscriptions = MmkvManager.decodeSubscriptions() val subscriptions = MmkvManager.decodeSubscriptions()
if (subscriptionId.isNotEmpty() if (subscriptionId.isNotEmpty()
&& !subscriptions.map { it.first }.contains(subscriptionId) && !subscriptions.map { it.guid }.contains(subscriptionId)
) { ) {
subscriptionIdChanged("") subscriptionIdChanged("")
} }
@@ -285,8 +285,8 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
remarks = context.getString(R.string.filter_config_all) remarks = context.getString(R.string.filter_config_all)
) )
) )
subscriptions.forEach { (id, item) -> subscriptions.forEach { sub ->
groups.add(GroupMapItem(id = id, remarks = item.remarks)) groups.add(GroupMapItem(id = sub.guid, remarks = sub.subscription.remarks))
} }
return groups return groups
} }
@@ -309,20 +309,15 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
* @return The number of removed servers. * @return The number of removed servers.
*/ */
fun removeDuplicateServer(): Int { fun removeDuplicateServer(): Int {
val serversCacheCopy = mutableListOf<Pair<String, ProfileItem>>() val serversCacheCopy = serversCache.toList().toMutableList()
for (it in serversCache) {
val config = MmkvManager.decodeServerConfig(it.guid) ?: continue
serversCacheCopy.add(Pair(it.guid, config))
}
val deleteServer = mutableListOf<String>() val deleteServer = mutableListOf<String>()
serversCacheCopy.forEachIndexed { index, it -> serversCacheCopy.forEachIndexed { index, sc ->
val outbound = it.second val profile = sc.profile
serversCacheCopy.forEachIndexed { index2, it2 -> serversCacheCopy.forEachIndexed { index2, sc2 ->
if (index2 > index) { if (index2 > index) {
val outbound2 = it2.second val profile2 = sc2.profile
if (outbound.equals(outbound2) && !deleteServer.contains(it2.first)) { if (profile == profile2 && !deleteServer.contains(sc2.guid)) {
deleteServer.add(it2.first) deleteServer.add(sc2.guid)
} }
} }
} }
@@ -1,16 +1,17 @@
package com.v2ray.ang.viewmodel package com.v2ray.ang.viewmodel
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import com.v2ray.ang.dto.SubscriptionCache
import com.v2ray.ang.dto.SubscriptionItem import com.v2ray.ang.dto.SubscriptionItem
import com.v2ray.ang.handler.MmkvManager import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.SettingsChangeManager import com.v2ray.ang.handler.SettingsChangeManager
import com.v2ray.ang.handler.SettingsManager import com.v2ray.ang.handler.SettingsManager
class SubscriptionsViewModel : ViewModel() { class SubscriptionsViewModel : ViewModel() {
private val subscriptions: MutableList<Pair<String, SubscriptionItem>> = private val subscriptions: MutableList<SubscriptionCache> =
MmkvManager.decodeSubscriptions().toMutableList() MmkvManager.decodeSubscriptions().toMutableList()
fun getAll(): List<Pair<String, SubscriptionItem>> = subscriptions.toList() fun getAll(): List<SubscriptionCache> = subscriptions.toList()
fun reload() { fun reload() {
subscriptions.clear() subscriptions.clear()
@@ -18,7 +19,7 @@ class SubscriptionsViewModel : ViewModel() {
} }
fun remove(subId: String): Boolean { fun remove(subId: String): Boolean {
val changed = subscriptions.removeAll { it.first == subId } val changed = subscriptions.removeAll { it.guid == subId }
if (changed) { if (changed) {
MmkvManager.removeSubscription(subId) MmkvManager.removeSubscription(subId)
SettingsChangeManager.makeSetupGroupTab() SettingsChangeManager.makeSetupGroupTab()
@@ -27,9 +28,9 @@ class SubscriptionsViewModel : ViewModel() {
} }
fun update(subId: String, item: SubscriptionItem) { fun update(subId: String, item: SubscriptionItem) {
val idx = subscriptions.indexOfFirst { it.first == subId } val idx = subscriptions.indexOfFirst { it.guid == subId }
if (idx >= 0) { if (idx >= 0) {
subscriptions[idx] = Pair(subId, item) subscriptions[idx] = SubscriptionCache(subId, item)
MmkvManager.encodeSubscription(subId, item) MmkvManager.encodeSubscription(subId, item)
} }
} }
@@ -0,0 +1,99 @@
package com.v2ray.ang.viewmodel
import android.util.Log
import androidx.lifecycle.ViewModel
import com.v2ray.ang.AppConfig
import com.v2ray.ang.dto.AssetUrlCache
import com.v2ray.ang.dto.AssetUrlItem
import com.v2ray.ang.extension.concatUrl
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.util.HttpUtil
import com.v2ray.ang.util.Utils
import java.io.File
import java.io.FileOutputStream
import java.net.HttpURLConnection
class UserAssetViewModel : ViewModel() {
private val assets = mutableListOf<AssetUrlCache>()
private val builtInGeoFiles = listOf("geosite.dat", "geoip.dat")
val itemCount: Int
get() = assets.size
fun getAssets(): List<AssetUrlCache> = assets.toList()
fun getAsset(position: Int): AssetUrlCache? = assets.getOrNull(position)
fun reload(geoFilesSource: String) {
val decoded = MmkvManager.decodeAssetUrls()
assets.clear()
assets.addAll(buildAssetList(decoded, geoFilesSource))
}
private fun buildAssetList(
decodedAssets: List<AssetUrlCache>?,
geoFilesSource: String
): List<AssetUrlCache> {
val savedAssets = decodedAssets ?: emptyList()
val builtInItems = builtInGeoFiles
.filter { geoFile -> savedAssets.none { it.assetUrl.remarks == geoFile } }
.map {
AssetUrlCache(
Utils.getUuid(),
AssetUrlItem(
it,
String.format(AppConfig.GITHUB_DOWNLOAD_URL, geoFilesSource).concatUrl(it),
locked = true
)
)
}
return builtInItems + savedAssets
}
fun downloadGeoFiles(extDir: File, httpPort: Int): GeoDownloadResult {
val snapshot = getAssets()
var successCount = 0
val failures = mutableListOf<String>()
snapshot.forEach { cache ->
val item = cache.assetUrl
val portsToTry = if (httpPort == 0) listOf(0) else listOf(httpPort, 0)
if (portsToTry.any { tryDownload(item, extDir, it) }) {
successCount++
} else {
failures.add(item.remarks)
}
}
return GeoDownloadResult(successCount, failures.size, failures)
}
private fun tryDownload(item: AssetUrlItem, extDir: File, httpPort: Int): Boolean {
val targetTemp = File(extDir, item.remarks + "_temp")
val target = File(extDir, item.remarks)
val conn = HttpUtil.createProxyConnection(item.url, httpPort, 15000, 15000, needStream = true) ?: return false
try {
val responseCode = conn.responseCode
if (responseCode == HttpURLConnection.HTTP_OK) {
conn.inputStream.use { inputStream ->
FileOutputStream(targetTemp).use { output ->
inputStream.copyTo(output)
}
}
targetTemp.renameTo(target)
return true
}
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to download geo file: ${item.remarks}", e)
} finally {
conn.disconnect()
}
return false
}
data class GeoDownloadResult(
val successCount: Int,
val failureCount: Int,
val failedAssets: List<String>
)
}
@@ -158,6 +158,27 @@
android:entries="@array/ech_force_query_value" /> android:entries="@array/ech_force_query_value" />
</LinearLayout> </LinearLayout>
<LinearLayout
android:id="@+id/lay_pinned_ca256"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="@dimen/padding_spacing_dp16"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/server_lab_pinned_ca256" />
<EditText
android:id="@+id/et_pinned_ca256"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="text"
android:nextFocusDown="@+id/sp_ech_force_query" />
</LinearLayout>
<LinearLayout <LinearLayout
android:id="@+id/lay_public_key" android:id="@+id/lay_public_key"
android:layout_width="match_parent" android:layout_width="match_parent"
@@ -116,6 +116,7 @@
<string name="server_lab_xhttp_extra">XHTTP Extra raw JSON, format: { XHTTPObject }</string> <string name="server_lab_xhttp_extra">XHTTP Extra raw JSON, format: { XHTTPObject }</string>
<string name="server_lab_ech_config_list">EchConfigList</string> <string name="server_lab_ech_config_list">EchConfigList</string>
<string name="server_lab_ech_force_query">EchForceQuery</string> <string name="server_lab_ech_force_query">EchForceQuery</string>
<string name="server_lab_pinned_ca256">Certificate fingerprint (SHA-256)</string>
<!-- UserAssetActivity --> <!-- UserAssetActivity -->
<string name="toast_asset_copy_failed">فشل نسخ الملف، يرجى استخدام مدير الملفات</string> <string name="toast_asset_copy_failed">فشل نسخ الملف، يرجى استخدام مدير الملفات</string>
@@ -399,6 +400,8 @@
<string-array name="policy_group_type"> <string-array name="policy_group_type">
<item>Least Ping</item> <item>Least Ping</item>
<item>Least Load</item> <item>Least Load</item>
<item>Random</item>
<item>Round Robin</item>
</string-array> </string-array>
<string-array name="config_backup_options"> <string-array name="config_backup_options">
@@ -115,6 +115,7 @@
<string name="server_lab_xhttp_extra">XHTTP Extra raw JSON, format: { XHTTPObject }</string> <string name="server_lab_xhttp_extra">XHTTP Extra raw JSON, format: { XHTTPObject }</string>
<string name="server_lab_ech_config_list">EchConfigList</string> <string name="server_lab_ech_config_list">EchConfigList</string>
<string name="server_lab_ech_force_query">EchForceQuery</string> <string name="server_lab_ech_force_query">EchForceQuery</string>
<string name="server_lab_pinned_ca256">Certificate fingerprint (SHA-256)</string>
<!-- UserAssetActivity --> <!-- UserAssetActivity -->
<string name="toast_asset_copy_failed">ফাইল কপি ব্যর্থ, অনুগ্রহ করে ফাইল ম্যানেজার ব্যবহার করুন</string> <string name="toast_asset_copy_failed">ফাইল কপি ব্যর্থ, অনুগ্রহ করে ফাইল ম্যানেজার ব্যবহার করুন</string>
@@ -405,6 +406,8 @@
<string-array name="policy_group_type"> <string-array name="policy_group_type">
<item>Least Ping</item> <item>Least Ping</item>
<item>Least Load</item> <item>Least Load</item>
<item>Random</item>
<item>Round Robin</item>
</string-array> </string-array>
<string-array name="config_backup_options"> <string-array name="config_backup_options">
@@ -15,10 +15,10 @@
<string name="toast_permission_denied">گرؽڌن موجوز مومکن نؽڌ</string> <string name="toast_permission_denied">گرؽڌن موجوز مومکن نؽڌ</string>
<string name="toast_permission_denied_notification">گرؽڌن موجوز وارسۊوی مومکن نؽڌ</string> <string name="toast_permission_denied_notification">گرؽڌن موجوز وارسۊوی مومکن نؽڌ</string>
<string name="notification_action_more">سی گرؽڌن دۉسمندیا بیشتر کیلیک کوݩ</string> <string name="notification_action_more">سی گرؽڌن دۉسمندیا بیشتر کیلیک کوݩ</string>
<string name="toast_services_start">ر وستن خدمات</string> <string name="toast_services_start">ره وستن خدمات</string>
<string name="toast_services_stop">واڌاشتن خدمات</string> <string name="toast_services_stop">واڌاشتن خدمات</string>
<string name="toast_services_success">ر وستن خدمات وا مووفقیت ٱنجوم وابی</string> <string name="toast_services_success">ره وستن خدمات وا مووفقیت ٱنجوم وابی</string>
<string name="toast_services_failure">ر وستن خدمات وا مووفقیت ٱنجوم نوابی</string> <string name="toast_services_failure">ره وستن خدمات وا مووفقیت ٱنجوم نوابی</string>
<!--ServerActivity--> <!--ServerActivity-->
<string name="title_server">فایل کانفیگ</string> <string name="title_server">فایل کانفیگ</string>
@@ -115,6 +115,7 @@
<string name="server_lab_xhttp_extra">XHTTP Extra خام JSON، قالوو: { XHTTPObject }</string> <string name="server_lab_xhttp_extra">XHTTP Extra خام JSON، قالوو: { XHTTPObject }</string>
<string name="server_lab_ech_config_list">نومگه کانفیگ Ech</string> <string name="server_lab_ech_config_list">نومگه کانفیگ Ech</string>
<string name="server_lab_ech_force_query">پورس وو جۊ اجباری Ech</string> <string name="server_lab_ech_force_query">پورس وو جۊ اجباری Ech</string>
<string name="server_lab_pinned_ca256">Certificate fingerprint (SHA-256)</string>
<!-- UserAssetActivity --> <!-- UserAssetActivity -->
<string name="toast_asset_copy_failed">لف گیری فایل ٱنجوم نوابی، ز ی برنومه دؽوۉداری فایل هیاری بگرین</string> <string name="toast_asset_copy_failed">لف گیری فایل ٱنجوم نوابی، ز ی برنومه دؽوۉداری فایل هیاری بگرین</string>
@@ -133,7 +134,7 @@
<string name="msg_dialog_progress">هونی بار ونی بۊ</string> <string name="msg_dialog_progress">هونی بار ونی بۊ</string>
<string name="menu_item_search">پیتینیڌن</string> <string name="menu_item_search">پیتینیڌن</string>
<string name="menu_item_select_all">پسند پوی</string> <string name="menu_item_select_all">پسند پوی</string>
<string name="menu_item_invert_selection">Invert selection</string> <string name="menu_item_invert_selection">پسند چواسه</string>
<string name="msg_enter_keywords">کلمه کلیدی را وارد کنید</string> <string name="msg_enter_keywords">کلمه کلیدی را وارد کنید</string>
<string name="switch_bypass_apps_mode">هالت Bypass</string> <string name="switch_bypass_apps_mode">هالت Bypass</string>
<string name="menu_item_select_proxy_app">پسند خوتکار پروکسی برنومه</string> <string name="menu_item_select_proxy_app">پسند خوتکار پروکسی برنومه</string>
@@ -141,7 +142,7 @@
<string name="menu_item_export_proxy_app">و در کشیڌن من کلیپ بورد</string> <string name="menu_item_export_proxy_app">و در کشیڌن من کلیپ بورد</string>
<string name="menu_item_import_proxy_app">و من ٱووردن ز کلیپ بورد</string> <string name="menu_item_import_proxy_app">و من ٱووردن ز کلیپ بورد</string>
<string name="per_app_proxy_settings">سامووا ب تفکیک برنومه</string> <string name="per_app_proxy_settings">سامووا ب تفکیک برنومه</string>
<string name="per_app_proxy_settings_enable">ر وندن ب تفکیک برنومه</string> <string name="per_app_proxy_settings_enable">ره وندن ب تفکیک برنومه</string>
<!-- Preferences --> <!-- Preferences -->
@@ -151,16 +152,16 @@
<string name="title_vpn_settings">سامووا VPN</string> <string name="title_vpn_settings">سامووا VPN</string>
<string name="title_pref_per_app_proxy">پروکسی و ری برنومه</string> <string name="title_pref_per_app_proxy">پروکسی و ری برنومه</string>
<string name="summary_pref_per_app_proxy">پوی وولاتی: برنومه واجۊری بیڌه پروکسی هڌ، منپیز موستقیم بؽ نشووه هڌ. هالت دور زیڌن: برنومه نشووک ناڌه موستقیمن منپیز هڌ، پروکسی نشووک زیڌه نؽڌ. گۊزینه پسند خوتکار برنومه پروکسی من نومگه</string> <string name="summary_pref_per_app_proxy">پوی وولاتی: برنومه واجۊری بیڌه پروکسی هڌ، منپیز موستقیم بؽ نشووه هڌ. هالت دور زیڌن: برنومه نشووک ناڌه موستقیمن منپیز هڌ، پروکسی نشووک زیڌه نؽڌ. گۊزینه پسند خوتکار برنومه پروکسی من نومگه</string>
<string name="title_pref_is_booted">منپیز خوتکار مجال ر ونی</string> <string name="title_pref_is_booted">منپیز خوتکار مجال ره ونی</string>
<string name="summary_pref_is_booted">مجال ر وندن، خوساخوس و سرور پسند بیڌه منپیز ابۊ که گاشڌ نا مووفق بۊ.</string> <string name="summary_pref_is_booted">مجال ره وندن، خوساخوس و سرور پسند بیڌه منپیز ابۊ که گاشڌ نا مووفق بۊ.</string>
<string name="title_pref_auto_remove_invalid_after_test">Auto delete invalid config after testing</string> <string name="title_pref_auto_remove_invalid_after_test">پاک کردن خوتکار کانفیگ نا موئتبر بئڌ آزمایش</string>
<string name="summary_pref_auto_remove_invalid_after_test">Test results may not be accurate; deleted config cannot be recovered.</string> <string name="summary_pref_auto_remove_invalid_after_test">نتیجه یل آزمایش گاشڌ دییق نبۊن؛ کانفیگ پاک وابیڌه ن نتری وورگنی.</string>
<string name="title_pref_auto_sort_after_test">Auto sort after testing</string> <string name="title_pref_auto_sort_after_test">ترتیب خوتکار بئڌ آزمایش</string>
<string name="summary_pref_auto_sort_after_test">Test results may not be accurate;</string> <string name="summary_pref_auto_sort_after_test">نتیجه یل آزمایش گاشڌ دییق نبۊن؛</string>
<string name="title_mux_settings">سامووا Mux</string> <string name="title_mux_settings">سامووا Mux</string>
<string name="title_pref_mux_enabled">ر وندن Mux</string> <string name="title_pref_mux_enabled">ره وندن Mux</string>
<string name="summary_pref_mux_enabled">زل تر، ٱما گاشڌ منپیز زی قت بۊ\nمخزن ترافیک TCP وا 8 منپیز پؽش فرز، بارت دؽوۉداری UDP وو QUIC ن ای لم سفارشی کۊنین.</string> <string name="summary_pref_mux_enabled">زل تر، ٱما گاشڌ منپیز زی قت بۊ\nمخزن ترافیک TCP وا 8 منپیز پؽش فرز، بارت دؽوۉداری UDP وو QUIC ن ای لم سفارشی کۊنین.</string>
<string name="title_pref_mux_concurency">منپیزا TCP (تلایه منجا 1-1024)</string> <string name="title_pref_mux_concurency">منپیزا TCP (تلایه منجا 1-1024)</string>
<string name="title_pref_mux_xudp_concurency">منپیزا XUDP (تلایه منجا 1-1024)</string> <string name="title_pref_mux_xudp_concurency">منپیزا XUDP (تلایه منجا 1-1024)</string>
@@ -171,24 +172,24 @@
<item>گوم زیڌن</item> <item>گوم زیڌن</item>
</string-array> </string-array>
<string name="title_pref_speed_enabled">ر وندن نشووݩ داڌن سورعت</string> <string name="title_pref_speed_enabled">ره وندن نشووݩ داڌن سورعت</string>
<string name="summary_pref_speed_enabled">نشووݩ داڌن سورعت هیم سکویی من وارسۊویا. نماڌ وارسۊوی و ری و کار گرؽڌن آلشت ابۊ.</string> <string name="summary_pref_speed_enabled">نشووݩ داڌن سورعت هیم سکویی من وارسۊویا. نماڌ وارسۊوی و ری و کار گرؽڌن کانفیگ آلشت ابۊ.</string>
<string name="title_pref_sniffing_enabled">ر وندن Sniffing</string> <string name="title_pref_sniffing_enabled">ره وندن Sniffing</string>
<string name="summary_pref_sniffing_enabled">دامنه sniff ن ز کتن امتهووݩ کۊنین (پؽش فرز رۊشن)</string> <string name="summary_pref_sniffing_enabled">دامنه sniff ن ز کتن امتهووݩ کۊنین (پؽش فرز رۊشن)</string>
<string name="title_pref_route_only_enabled">ر وندن routeOnly</string> <string name="title_pref_route_only_enabled">ره وندن routeOnly</string>
<string name="summary_pref_route_only_enabled">ز نوم دامنه sniffed تینا سی تور جوستن استفاڌه کۊنین وو نشۊوی موورد نزرن و عونوان نشۊوی IP ووردارین.</string> <string name="summary_pref_route_only_enabled">ز نوم دامنه sniffed تینا سی تور جوستن استفاڌه کۊنین وو نشۊوی موورد نزرن و عونوان نشۊوی 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 و هسته و من ایان وو و دست ماژول DNS پردازشت ابۊن (پؽشنهاڌ ابۊ ٱر لنگ تور جوستن سی دور زیڌن نشۊویا LAN وو وولات ٱسلی هڌین فعال بۊ)</string> <string name="summary_pref_local_dns_enabled">درخاستا DNS و هسته و من ایان وو و دست ماژول DNS پردازشت ابۊن (پؽشنهاڌ ابۊ ٱر لنگ تور جوستن سی دور زیڌن نشۊویا LAN وو وولات ٱسلی هڌین فعال بۊ)</string>
<string name="title_pref_fake_dns_enabled">ر وندن DNS جئلی</string> <string name="title_pref_fake_dns_enabled">ره وندن DNS جئلی</string>
<string name="summary_pref_fake_dns_enabled">DNS مهلی نشۊویا IP جئلی ن وورگنه (زل تر، ٱما گاشڌ من یقرد ز برنومه یل کار نکونه)</string> <string name="summary_pref_fake_dns_enabled">DNS مهلی نشۊویا IP جئلی ن وورگنه (زل تر، ٱما گاشڌ من یقرد ز برنومه یل کار نکونه)</string>
<string name="title_pref_prefer_ipv6">ترجی داڌن IPv6</string> <string name="title_pref_prefer_ipv6">ترجی داڌن IPv6</string>
<string name="summary_pref_prefer_ipv6">تورا IPv6 ن فعال کۊنین وو نشۊویا IPv6 ن ترجی بڌین</string> <string name="summary_pref_prefer_ipv6">تورا 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">DNS</string> <string name="summary_pref_remote_dns">DNS</string>
<string name="title_pref_vpn_dns">VPN DNS (تینا IPv4/v6)</string> <string name="title_pref_vpn_dns">VPN DNS (تینا IPv4/v6)</string>
@@ -203,14 +204,14 @@
<string name="title_pref_dns_hosts">DNS هاست موستقیم (قالوو: دامنه: نشۊوی،...)</string> <string name="title_pref_dns_hosts">DNS هاست موستقیم (قالوو: دامنه: نشۊوی،...)</string>
<string name="summary_pref_dns_hosts">دامنه:نشۊوی،...</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">نشۊوی اینترنتی</string> <string name="summary_pref_delay_test_url">نشۊوی اینترنتی</string>
<string name="title_pref_ip_api_url">نشۊوی اینترنتی آزمایش دووسمندیا منپیز هیم سکویی</string> <string name="title_pref_ip_api_url">نشۊوی اینترنتی آزمایش دووسمندیا منپیز هیم سکویی</string>
<string name="summary_pref_ip_api_url">نشۊوی اینترنتی</string> <string name="summary_pref_ip_api_url">نشۊوی اینترنتی</string>
<string name="title_pref_proxy_sharing_enabled">هشتن منپیزا ز شبکه مهلی</string> <string name="title_pref_proxy_sharing_enabled">هشتن منپیزا ز شبکه مهلی</string>
<string name="summary_pref_proxy_sharing_enabled">پوی دسگایل ترن وا نشۊوی IP ایسا، ز ر socks/http و پروکسی منپیز بۊن، تینا من شبکه قابل اعتماد فعال بۊ تا ز منپیز غیر موجاز جلو گری بۊ.</string> <string name="summary_pref_proxy_sharing_enabled">پوی دسگایل ترن وا نشۊوی IP ایسا، ز ره socks/http و پروکسی منپیز بۊن، تینا من شبکه قابل اعتماد فعال بۊ تا ز منپیز غیر موجاز جلو گری بۊ.</string>
<string name="toast_warning_pref_proxysharing_short">منپیزا ز شبکه مهلی ن موجار کۊنین، موتمعن بۊین ک من ی شبکه قابل ائتماڌ هڌین.</string> <string name="toast_warning_pref_proxysharing_short">منپیزا ز شبکه مهلی ن موجار کۊنین، موتمعن بۊین ک من ی شبکه قابل ائتماڌ هڌین.</string>
<string name="title_pref_allow_insecure">اجازه نا ٱمن</string> <string name="title_pref_allow_insecure">اجازه نا ٱمن</string>
@@ -225,14 +226,14 @@
<string name="title_pref_confirm_remove">قوۊل کردن پاک کردن کانفیگ</string> <string name="title_pref_confirm_remove">قوۊل کردن پاک کردن کانفیگ</string>
<string name="summary_pref_confirm_remove">سی پاک وابیڌن فایل کانفیگ نیاز به قوۊل کردن دووارته ز سمت منتور هڌ.</string> <string name="summary_pref_confirm_remove">سی پاک وابیڌن فایل کانفیگ نیاز به قوۊل کردن دووارته ز سمت منتور هڌ.</string>
<string name="title_pref_start_scan_immediate">زی اسکنن ر ون</string> <string name="title_pref_start_scan_immediate">زی اسکنن ره ون</string>
<string name="summary_pref_start_scan_immediate">شؽواتگرن سی اسکن، زی مجال ر وندن بۊگۊشین، ٱندی ترین کودن اسکن کۊنین یا شؽواتی ن منه نوار ٱوزار پسند کۊنین.</string> <string name="summary_pref_start_scan_immediate">شؽواتگرن سی اسکن، زی مجال ره وندن بۊگۊشین، ٱندی ترین کودن اسکن کۊنین یا شؽواتی ن منه نوار ٱوزار پسند کۊنین.</string>
<string name="title_pref_append_http_proxy">پروکسی HTTP ن و VPN ازاف کۊنین</string> <string name="title_pref_append_http_proxy">پروکسی HTTP ن و VPN ازاف کۊنین</string>
<string name="summary_pref_append_http_proxy">پروکسی HTTP ن موسقیمن ز (مۊرۊرگر/ی قرد ز برنومه یل لادراری بیڌه)، بؽ استفاڌه ز دسگا NIC مجازی (Android 10+) استفاڌه ابۊ.</string> <string name="summary_pref_append_http_proxy">پروکسی HTTP ن موسقیمن ز (مۊرۊرگر/ی قرد ز برنومه یل لادراری بیڌه)، بؽ استفاڌه ز دسگا NIC مجازی (Android 10+) استفاڌه ابۊ.</string>
<string name="title_pref_double_column_display">ر وندن نشووݩ داڌن دو سۊتۊنی</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>
<!-- AboutActivity --> <!-- AboutActivity -->
<string name="title_pref_feedback">فشناڌن منشڌ</string> <string name="title_pref_feedback">فشناڌن منشڌ</string>
@@ -258,15 +259,15 @@
<string name="title_language">زووݩ</string> <string name="title_language">زووݩ</string>
<string name="title_ui_settings">سامووا رابت منتوری</string> <string name="title_ui_settings">سامووا رابت منتوری</string>
<string name="title_pref_ui_mode_night">سامووا هالت رابت منتوری</string> <string name="title_pref_ui_mode_night">سامووا هالت رابت منتوری</string>
<string name="title_pref_use_hev_tunnel">Enable Hev TUN Feature</string> <string name="title_pref_use_hev_tunnel">فعال کردن ویژیی Hev TUN</string>
<string name="summary_pref_use_hev_tunnel">When enabled, TUN will use hev-socks5-tunnel; otherwise, it will use xray-core.</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_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_logcat">گوزارشا</string> <string name="title_logcat">گوزارشا</string>
<string name="logcat_copy">لف گیری</string> <string name="logcat_copy">لف گیری</string>
<string name="logcat_clear">روفتن</string> <string name="logcat_clear">روفتن</string>
<string name="title_service_restart">ر وندن دووارته خدمات</string> <string name="title_service_restart">ره وندن دووارته خدمات</string>
<string name="title_del_all_config">پاک کردن پوی کانفیگا بونکۊ سکویی</string> <string name="title_del_all_config">پاک کردن پوی کانفیگا بونکۊ سکویی</string>
<string name="title_del_duplicate_config">پاک کردن کانفیگا تکراری بونکۊ سکویی</string> <string name="title_del_duplicate_config">پاک کردن کانفیگا تکراری بونکۊ سکویی</string>
<string name="title_del_invalid_config">پاک کردن کانفیگا نا موئتبر بونکۊ سکویی</string> <string name="title_del_invalid_config">پاک کردن کانفیگا نا موئتبر بونکۊ سکویی</string>
@@ -295,7 +296,7 @@
<string name="title_import_config_count">و من ٱووردن %d کانفیگ</string> <string name="title_import_config_count">و من ٱووردن %d کانفیگ</string>
<string name="title_export_config_count">و در کشیڌن %d کانفیگ</string> <string name="title_export_config_count">و در کشیڌن %d کانفیگ</string>
<string name="title_update_config_count">ورۊ کردن %d کانفیگ</string> <string name="title_update_config_count">ورۊ کردن %d کانفیگ</string>
<string name="tasker_start_service">ر وندن خدمات</string> <string name="tasker_start_service">ره وندن خدمات</string>
<string name="tasker_setting_confirm">قوۊل</string> <string name="tasker_setting_confirm">قوۊل</string>
<!-- RoutingSettingActivity --> <!-- RoutingSettingActivity -->
@@ -330,7 +331,7 @@
<string name="connection_test_error_status_code">کود ختا: #%d</string> <string name="connection_test_error_status_code">کود ختا: #%d</string>
<string name="connection_connected">منپیز هڌ، سی واجۊری کیلیک کوݩ</string> <string name="connection_connected">منپیز هڌ، سی واجۊری کیلیک کوݩ</string>
<string name="connection_not_connected">منپیز نؽڌ</string> <string name="connection_not_connected">منپیز نؽڌ</string>
<string name="connection_runing_task_left">تعداد وزیفه یل آزمایشی ک ر اۊفتانه: %s</string> <string name="connection_runing_task_left">تعداد وزیفه یل آزمایشی ک ره اۊفتانه: %s</string>
<string name="import_subscription_success">اشتراک وا موفقیت زفت زابی</string> <string name="import_subscription_success">اشتراک وا موفقیت زفت زابی</string>
<string name="import_subscription_failure">اشتراک زفت نوابی</string> <string name="import_subscription_failure">اشتراک زفت نوابی</string>
@@ -338,7 +339,7 @@
<string name="title_pref_fragment_packets">کتنا فرگمنت</string> <string name="title_pref_fragment_packets">کتنا فرگمنت</string>
<string name="title_pref_fragment_length">تۊل کتنا فرگمنت (هدقل-هدکسر)</string> <string name="title_pref_fragment_length">تۊل کتنا فرگمنت (هدقل-هدکسر)</string>
<string name="title_pref_fragment_interval">فاسله منجا کتنا فرگمنت (هدقل-هدکسر)</string> <string name="title_pref_fragment_interval">فاسله منجا کتنا فرگمنت (هدقل-هدکسر)</string>
<string name="title_pref_fragment_enabled">ر وندن فرگمنت</string> <string name="title_pref_fragment_enabled">ره وندن فرگمنت</string>
<string name="update_check_for_update">واجۊری سی ورۊ رسۊوی</string> <string name="update_check_for_update">واجۊری سی ورۊ رسۊوی</string>
<string name="update_already_latest_version">سکو نوسخه دیندایی پۊرنیڌه هڌ</string> <string name="update_already_latest_version">سکو نوسخه دیندایی پۊرنیڌه هڌ</string>
@@ -361,7 +362,7 @@
<string name="title_webdav_url">WebDAV نشۊوی اینترنتی سرور</string> <string name="title_webdav_url">WebDAV نشۊوی اینترنتی سرور</string>
<string name="title_webdav_user">نوم منتوری</string> <string name="title_webdav_user">نوم منتوری</string>
<string name="title_webdav_pass">رزم</string> <string name="title_webdav_pass">رزم</string>
<string name="title_webdav_remote_path">دایرکتوری ر دیر (اختیاری)</string> <string name="title_webdav_remote_path">دایرکتوری ره دیر (اختیاری)</string>
<string-array name="share_method"> <string-array name="share_method">
<item>QRcode</item> <item>QRcode</item>
@@ -398,7 +399,7 @@
<item>نومگه شه چین</item> <item>نومگه شه چین</item>
<item>جهۊوی (Global)</item> <item>جهۊوی (Global)</item>
<item>نومگه اسبؽڌ ایران</item> <item>نومگه اسبؽڌ ایران</item>
<item>Russia Whitelist</item> <item>نومگه اسبؽڌ رۊسیه</item>
</string-array> </string-array>
<string-array name="vpn_bypass_lan"> <string-array name="vpn_bypass_lan">
@@ -415,6 +416,8 @@
<string-array name="policy_group_type"> <string-array name="policy_group_type">
<item>کم ترین پینگ</item> <item>کم ترین پینگ</item>
<item>کم ترین بار(لود)</item> <item>کم ترین بار(لود)</item>
<item>تصادفی</item>
<item>گردشی</item>
</string-array> </string-array>
<string-array name="config_backup_options"> <string-array name="config_backup_options">
@@ -115,6 +115,7 @@
<string name="server_lab_xhttp_extra">خام JSON XHTTP Extra، قالب: { XHTTPObject }</string> <string name="server_lab_xhttp_extra">خام JSON XHTTP Extra، قالب: { XHTTPObject }</string>
<string name="server_lab_ech_config_list">لیست کانفیگ Ech</string> <string name="server_lab_ech_config_list">لیست کانفیگ Ech</string>
<string name="server_lab_ech_force_query">EchForceQuery</string> <string name="server_lab_ech_force_query">EchForceQuery</string>
<string name="server_lab_pinned_ca256">Certificate fingerprint (SHA-256)</string>
<!-- UserAssetActivity --> <!-- UserAssetActivity -->
<string name="toast_asset_copy_failed">کپی فایل انجام نشد، لطفا از برنامه مدیریت فایل استفاده کنید</string> <string name="toast_asset_copy_failed">کپی فایل انجام نشد، لطفا از برنامه مدیریت فایل استفاده کنید</string>
@@ -414,6 +415,8 @@
<string-array name="policy_group_type"> <string-array name="policy_group_type">
<item>کمترین پینگ</item> <item>کمترین پینگ</item>
<item>کمترین بار(لود)</item> <item>کمترین بار(لود)</item>
<item>تصادفی</item>
<item>گردشی</item>
</string-array> </string-array>
<string-array name="config_backup_options"> <string-array name="config_backup_options">
@@ -115,6 +115,7 @@
<string name="server_lab_xhttp_extra">Необработанный JSON XHTTP Extra, формат: { XHTTPObject }</string> <string name="server_lab_xhttp_extra">Необработанный JSON XHTTP Extra, формат: { XHTTPObject }</string>
<string name="server_lab_ech_config_list">EchConfigList</string> <string name="server_lab_ech_config_list">EchConfigList</string>
<string name="server_lab_ech_force_query">EchForceQuery</string> <string name="server_lab_ech_force_query">EchForceQuery</string>
<string name="server_lab_pinned_ca256">Certificate fingerprint (SHA-256)</string>
<!-- UserAssetActivity --> <!-- UserAssetActivity -->
<string name="toast_asset_copy_failed">Невозможно скопировать файл, используйте файловый менеджер</string> <string name="toast_asset_copy_failed">Невозможно скопировать файл, используйте файловый менеджер</string>
@@ -414,6 +415,8 @@
<string-array name="policy_group_type"> <string-array name="policy_group_type">
<item>Наименьшая задержка</item> <item>Наименьшая задержка</item>
<item>Наименьшая нагрузка</item> <item>Наименьшая нагрузка</item>
<item>Случайный</item>
<item>По очереди</item>
</string-array> </string-array>
<string-array name="config_backup_options"> <string-array name="config_backup_options">
@@ -115,6 +115,7 @@
<string name="server_lab_xhttp_extra">XHTTP Extra raw JSON, format: { XHTTPObject }</string> <string name="server_lab_xhttp_extra">XHTTP Extra raw JSON, format: { XHTTPObject }</string>
<string name="server_lab_ech_config_list">EchConfigList</string> <string name="server_lab_ech_config_list">EchConfigList</string>
<string name="server_lab_ech_force_query">EchForceQuery</string> <string name="server_lab_ech_force_query">EchForceQuery</string>
<string name="server_lab_pinned_ca256">Certificate fingerprint (SHA-256)</string>
<!-- UserAssetActivity --> <!-- UserAssetActivity -->
<string name="toast_asset_copy_failed">Không thể sao chép tệp tin, hãy dùng trình quản lý tệp!</string> <string name="toast_asset_copy_failed">Không thể sao chép tệp tin, hãy dùng trình quản lý tệp!</string>
@@ -401,6 +402,8 @@
<string-array name="policy_group_type"> <string-array name="policy_group_type">
<item>Least Ping</item> <item>Least Ping</item>
<item>Least Load</item> <item>Least Load</item>
<item>Random</item>
<item>Round Robin</item>
</string-array> </string-array>
<string-array name="config_backup_options"> <string-array name="config_backup_options">
@@ -115,6 +115,7 @@
<string name="server_lab_xhttp_extra">XHTTP Extra 原始 JSON,格式: { XHTTPObject }</string> <string name="server_lab_xhttp_extra">XHTTP Extra 原始 JSON,格式: { XHTTPObject }</string>
<string name="server_lab_ech_config_list">EchConfigList</string> <string name="server_lab_ech_config_list">EchConfigList</string>
<string name="server_lab_ech_force_query">EchForceQuery</string> <string name="server_lab_ech_force_query">EchForceQuery</string>
<string name="server_lab_pinned_ca256">证书指纹 (SHA-256)</string>
<!-- UserAssetActivity --> <!-- UserAssetActivity -->
<string name="toast_asset_copy_failed">失败, 请使用文件管理器</string> <string name="toast_asset_copy_failed">失败, 请使用文件管理器</string>
@@ -406,6 +407,8 @@
<string-array name="policy_group_type"> <string-array name="policy_group_type">
<item>最低延迟</item> <item>最低延迟</item>
<item>最稳定</item> <item>最稳定</item>
<item>随机选择</item>
<item>轮询</item>
</string-array> </string-array>
<string-array name="config_backup_options"> <string-array name="config_backup_options">
@@ -115,6 +115,7 @@
<string name="server_lab_xhttp_extra">XHTTP Extra 原始 JSON,格式: { XHTTPObject }</string> <string name="server_lab_xhttp_extra">XHTTP Extra 原始 JSON,格式: { XHTTPObject }</string>
<string name="server_lab_ech_config_list">EchConfigList</string> <string name="server_lab_ech_config_list">EchConfigList</string>
<string name="server_lab_ech_force_query">EchForceQuery</string> <string name="server_lab_ech_force_query">EchForceQuery</string>
<string name="server_lab_pinned_ca256">證書指紋 (SHA-256)</string>
<!-- UserAssetActivity --> <!-- UserAssetActivity -->
<string name="toast_asset_copy_failed">失敗,請使用檔案總管</string> <string name="toast_asset_copy_failed">失敗,請使用檔案總管</string>
@@ -406,6 +407,8 @@
<string-array name="policy_group_type"> <string-array name="policy_group_type">
<item>最低延遲</item> <item>最低延遲</item>
<item>最少卡頓</item> <item>最少卡頓</item>
<item>隨機選擇</item>
<item>輪詢</item>
</string-array> </string-array>
<string-array name="config_backup_options"> <string-array name="config_backup_options">
@@ -116,6 +116,7 @@
<string name="server_lab_xhttp_extra">XHTTP Extra raw JSON, format: { XHTTPObject }</string> <string name="server_lab_xhttp_extra">XHTTP Extra raw JSON, format: { XHTTPObject }</string>
<string name="server_lab_ech_config_list">EchConfigList</string> <string name="server_lab_ech_config_list">EchConfigList</string>
<string name="server_lab_ech_force_query">EchForceQuery</string> <string name="server_lab_ech_force_query">EchForceQuery</string>
<string name="server_lab_pinned_ca256">Certificate fingerprint (SHA-256)</string>
<!-- UserAssetActivity --> <!-- UserAssetActivity -->
<string name="toast_asset_copy_failed">File copy failed, please use File Manager</string> <string name="toast_asset_copy_failed">File copy failed, please use File Manager</string>
@@ -419,6 +420,8 @@
<string-array name="policy_group_type"> <string-array name="policy_group_type">
<item>Least Ping</item> <item>Least Ping</item>
<item>Least Load</item> <item>Least Load</item>
<item>Random</item>
<item>Round Robin</item>
</string-array> </string-array>
<string-array name="config_backup_options"> <string-array name="config_backup_options">
@@ -2,7 +2,7 @@ package com.v2ray.ang.fmt
import android.util.Base64 import android.util.Base64
import android.util.Log import android.util.Log
import com.v2ray.ang.dto.EConfigType import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.dto.ProfileItem import com.v2ray.ang.dto.ProfileItem
import org.junit.After import org.junit.After
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
+11 -1
View File
@@ -21,4 +21,14 @@ kotlin.code.style=official
# resources declared in the library itself and none from the library's dependencies, # resources declared in the library itself and none from the library's dependencies,
# thereby reducing the size of the R class for that library # thereby reducing the size of the R class for that library
android.nonTransitiveRClass=true android.nonTransitiveRClass=true
kotlin.incremental=true kotlin.incremental=true
android.defaults.buildfeatures.resvalues=true
android.sdk.defaultTargetSdkToCompileSdkIfUnset=false
android.enableAppCompileTimeRClass=false
android.usesSdkInManifest.disallowed=false
android.uniquePackageNames=false
android.dependency.useConstraints=true
android.r8.strictFullModeForKeepRules=false
android.r8.optimizedResourceShrinking=false
android.builtInKotlin=false
android.newDsl=false
+1 -1
View File
@@ -1,5 +1,5 @@
[versions] [versions]
agp = "8.13.2" agp = "9.0.0"
desugarJdkLibs = "2.1.5" desugarJdkLibs = "2.1.5"
gradleLicensePlugin = "0.9.8" gradleLicensePlugin = "0.9.8"
kotlin = "2.3.0" kotlin = "2.3.0"