Compare commits
81 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3a32373abe | |||
| befb2937d1 | |||
| 0a375bec67 | |||
| 029f8b67e7 | |||
| 7475d1757b | |||
| 5a16614a7b | |||
| edb06b466a | |||
| 30b5391895 | |||
| 6cd04b831f | |||
| 1e616198f0 | |||
| a710146e4f | |||
| 9cd530d223 | |||
| fc89f25591 | |||
| 84ab54fea2 | |||
| d642d09844 | |||
| 6ae46c50e2 | |||
| fe4b451988 | |||
| 5c9b634039 | |||
| 09461663cb | |||
| 50c1145815 | |||
| edc4856cb8 | |||
| 4dd7eee8fe | |||
| 516637954c | |||
| 9e714bfcb3 | |||
| 858dc5237a | |||
| 2b4a4f7ecc | |||
| f9f0c905a4 | |||
| f997335113 | |||
| ed344cd281 | |||
| 94775f120a | |||
| abd23b3227 | |||
| 7d3e002140 | |||
| 1adb22944a | |||
| f48253371d | |||
| 74f701096e | |||
| 8eca5fe8af | |||
| ff40b53e2f | |||
| 5080d94bb7 | |||
| 24539be29f | |||
| e951e3d913 | |||
| 83e5f1d10a | |||
| 47a6623e62 | |||
| d6e3fc5c91 | |||
| 685d23f2d1 | |||
| d46bbdc219 | |||
| be95e6a88e | |||
| 2435708628 | |||
| 6cf6057dfa | |||
| af92cf5a7d | |||
| 2277de95c0 | |||
| 3c90ee0206 | |||
| 41fd237a87 | |||
| f6bded88dd | |||
| 8600fdba35 | |||
| 2c47abc219 | |||
| 10b0f8c9de | |||
| 53572877fa | |||
| 69e000b23d | |||
| a8e09611eb | |||
| 4fdc46b8ca | |||
| ac043da8a2 | |||
| a192fec127 | |||
| b0b09273cf | |||
| 01a943b7ea | |||
| 06a3738b59 | |||
| fa4432584e | |||
| bb1c3d915f | |||
| d8983a4761 | |||
| 9815041c77 | |||
| 41b0983582 | |||
| bb1f677d45 | |||
| de1c8bcfcc | |||
| ca6e963575 | |||
| bfb6813dc7 | |||
| cc2b4eec65 | |||
| dd11569969 | |||
| 11c3dd97c3 | |||
| 92f15150cd | |||
| f8e809da8b | |||
| 12b9349e62 | |||
| a580494bde |
@@ -0,0 +1,6 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: 'recursive'
|
||||
fetch-depth: '0'
|
||||
@@ -25,8 +25,8 @@ jobs:
|
||||
uses: android-actions/setup-android@v3.2.0
|
||||
with:
|
||||
log-accepted-android-sdk-licenses: false
|
||||
cmdline-tools-version: '12266719'
|
||||
packages: 'platforms;android-35 build-tools;35.0.0 platform-tools'
|
||||
cmdline-tools-version: '13114758'
|
||||
packages: 'platforms;android-36.1 build-tools;36.1.0 platform-tools'
|
||||
|
||||
- name: Install NDK
|
||||
run: |
|
||||
@@ -78,7 +78,7 @@ jobs:
|
||||
out-file-path: V2rayNG/app/libs/
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v4.7.0
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
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 }}
|
||||
|
||||
- name: Upload arm64-v8a APK
|
||||
uses: actions/upload-artifact@v4.6.2
|
||||
uses: actions/upload-artifact@v7
|
||||
if: ${{ success() }}
|
||||
with:
|
||||
name: arm64-v8a
|
||||
path: ${{ github.workspace }}/V2rayNG/app/build/outputs/apk/*/release/*arm64-v8a*.apk
|
||||
|
||||
- name: Upload armeabi-v7a APK
|
||||
uses: actions/upload-artifact@v4.6.2
|
||||
uses: actions/upload-artifact@v7
|
||||
if: ${{ success() }}
|
||||
with:
|
||||
name: armeabi-v7a
|
||||
path: ${{ github.workspace }}/V2rayNG/app/build/outputs/apk/*/release/*armeabi-v7a*.apk
|
||||
|
||||
- name: Upload x86 APK
|
||||
uses: actions/upload-artifact@v4.6.2
|
||||
uses: actions/upload-artifact@v7
|
||||
if: ${{ success() }}
|
||||
with:
|
||||
name: x86-apk
|
||||
|
||||
@@ -11,6 +11,6 @@ jobs:
|
||||
go:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
- name: Validate Fastlane Supply Metadata
|
||||
uses: ashutoshgngwr/validate-fastlane-supply-metadata@v2.1.0
|
||||
|
||||
+1
-1
Submodule AndroidLibXrayLite updated: 16222c3387...d783dc8ea7
@@ -12,8 +12,8 @@ android {
|
||||
applicationId = "com.v2ray.ang"
|
||||
minSdk = 24
|
||||
targetSdk = 36
|
||||
versionCode = 702
|
||||
versionName = "2.0.2"
|
||||
versionCode = 712
|
||||
versionName = "2.0.12"
|
||||
multiDexEnabled = true
|
||||
|
||||
val abiFilterList = (properties["ABI_FILTERS"] as? String)?.split(';')
|
||||
|
||||
@@ -42,6 +42,13 @@
|
||||
"name": "xray0",
|
||||
"MTU": 1500,
|
||||
"userLevel": 8
|
||||
},
|
||||
"sniffing": {
|
||||
"enabled": true,
|
||||
"destOverride": [
|
||||
"http",
|
||||
"tls"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
@@ -34,16 +34,15 @@ class AngApplication : MultiDexApplication() {
|
||||
|
||||
MMKV.initialize(this)
|
||||
|
||||
// Ensure critical preference defaults are present in MMKV early
|
||||
SettingsManager.ensureDefaultSettings()
|
||||
SettingsManager.setNightMode()
|
||||
// Initialize WorkManager with the custom configuration
|
||||
WorkManager.initialize(this, workManagerConfiguration)
|
||||
|
||||
SettingsManager.initRoutingRulesets(this)
|
||||
// Ensure critical preference defaults are present in MMKV early
|
||||
SettingsManager.initApp(this)
|
||||
SettingsManager.setNightMode()
|
||||
|
||||
es.dmoral.toasty.Toasty.Config.getInstance()
|
||||
.setGravity(android.view.Gravity.BOTTOM, 0, 200)
|
||||
.setGravity(android.view.Gravity.BOTTOM, 0, 300)
|
||||
.apply()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,9 @@ object AppConfig {
|
||||
/** Legacy configuration keys. */
|
||||
const val ANG_CONFIG = "ang_config"
|
||||
|
||||
// Default subscription ID for ungrouped servers
|
||||
const val DEFAULT_SUBSCRIPTION_ID = "__default_subscription__"
|
||||
|
||||
/** Preferences mapped to MMKV storage. */
|
||||
const val PREF_SNIFFING_ENABLED = "pref_sniffing_enabled"
|
||||
const val PREF_ROUTE_ONLY_ENABLED = "pref_route_only_enabled"
|
||||
@@ -48,6 +51,7 @@ object AppConfig {
|
||||
const val PREF_CONFIRM_REMOVE = "pref_confirm_remove"
|
||||
const val PREF_START_SCAN_IMMEDIATE = "pref_start_scan_immediate"
|
||||
const val PREF_DOUBLE_COLUMN_DISPLAY = "pref_double_column_display"
|
||||
const val PREF_GROUP_ALL_DISPLAY = "pref_group_all_display"
|
||||
const val PREF_LANGUAGE = "pref_language"
|
||||
const val PREF_UI_MODE_NIGHT = "pref_ui_mode_night"
|
||||
const val PREF_PREFER_IPV6 = "pref_prefer_ipv6"
|
||||
@@ -73,7 +77,6 @@ object AppConfig {
|
||||
|
||||
/** Cache keys. */
|
||||
const val CACHE_SUBSCRIPTION_ID = "cache_subscription_id"
|
||||
const val CACHE_KEYWORD_FILTER = "cache_keyword_filter"
|
||||
|
||||
/** Protocol identifiers. */
|
||||
const val PROTOCOL_FREEDOM = "freedom"
|
||||
@@ -97,6 +100,7 @@ object AppConfig {
|
||||
const val TAG_FRAGMENT = "fragment"
|
||||
const val TAG_DNS = "dns-module"
|
||||
const val TAG_DOMESTIC_DNS = "domestic-dns"
|
||||
const val TAG_BALANCER = "proxy-round"
|
||||
|
||||
/** Network-related constants. */
|
||||
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
-1
@@ -1,4 +1,4 @@
|
||||
package com.v2ray.ang.service
|
||||
package com.v2ray.ang.contracts
|
||||
|
||||
import android.app.Service
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package com.v2ray.ang.service
|
||||
package com.v2ray.ang.contracts
|
||||
|
||||
/**
|
||||
* 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_DIRECT
|
||||
import com.v2ray.ang.AppConfig.TAG_PROXY
|
||||
import com.v2ray.ang.enums.EConfigType
|
||||
import com.v2ray.ang.util.Utils
|
||||
|
||||
data class ProfileItem(
|
||||
@@ -14,6 +15,7 @@ data class ProfileItem(
|
||||
var addedTime: Long = System.currentTimeMillis(),
|
||||
|
||||
var remarks: String = "",
|
||||
var description: String? = null,
|
||||
var server: String? = null,
|
||||
var serverPort: String? = null,
|
||||
|
||||
@@ -42,6 +44,7 @@ data class ProfileItem(
|
||||
var insecure: Boolean? = null,
|
||||
var echConfigList: String? = null,
|
||||
var echForceQuery: String? = null,
|
||||
var pinnedCA256: String? = null,
|
||||
|
||||
var publicKey: String? = null,
|
||||
var shortId: String? = null,
|
||||
@@ -121,7 +124,7 @@ data class ProfileItem(
|
||||
&& this.obfsPassword == obj.obfsPassword
|
||||
&& this.portHopping == obj.portHopping
|
||||
&& this.portHoppingInterval == obj.portHoppingInterval
|
||||
&& this.pinSHA256 == obj.pinSHA256
|
||||
&& this.pinnedCA256 == obj.pinnedCA256
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.v2ray.ang.dto
|
||||
|
||||
data class SubscriptionCache(
|
||||
val guid: String,
|
||||
val subscription: SubscriptionItem
|
||||
)
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.v2ray.ang.dto
|
||||
|
||||
/**
|
||||
* Result of subscription update operation
|
||||
*/
|
||||
data class SubscriptionUpdateResult(
|
||||
val configCount: Int = 0, // Total configs updated
|
||||
val successCount: Int = 0, // Subscriptions updated successfully
|
||||
val failureCount: Int = 0, // Subscriptions failed to update
|
||||
val skipCount: Int = 0 // Subscriptions skipped (disabled)
|
||||
) {
|
||||
/**
|
||||
* Combine two results by adding their counts
|
||||
*/
|
||||
operator fun plus(other: SubscriptionUpdateResult): SubscriptionUpdateResult {
|
||||
return SubscriptionUpdateResult(
|
||||
configCount = this.configCount + other.configCount,
|
||||
successCount = this.successCount + other.successCount,
|
||||
failureCount = this.failureCount + other.failureCount,
|
||||
skipCount = this.skipCount + other.skipCount
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.v2ray.ang.dto
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.enums.EConfigType
|
||||
|
||||
data class V2rayConfig(
|
||||
var remarks: String? = null,
|
||||
@@ -34,7 +35,7 @@ data class V2rayConfig(
|
||||
var protocol: String,
|
||||
var listen: String? = null,
|
||||
var settings: InSettingsBean? = null,
|
||||
val sniffing: SniffingBean? = null,
|
||||
var sniffing: SniffingBean? = null,
|
||||
val streamSettings: Any? = null,
|
||||
val allocate: Any? = null
|
||||
) {
|
||||
@@ -88,6 +89,7 @@ data class V2rayConfig(
|
||||
var reserved: List<Int>? = null,
|
||||
var mtu: Int? = null,
|
||||
var obfsPassword: String? = null,
|
||||
var version: Int? = null,
|
||||
) {
|
||||
|
||||
data class VnextBean(
|
||||
@@ -160,7 +162,7 @@ data class V2rayConfig(
|
||||
var realitySettings: TlsSettingsBean? = null,
|
||||
var grpcSettings: GrpcSettingsBean? = null,
|
||||
var hysteriaSettings: HysteriaSettingsBean? = null,
|
||||
var udpmasks: List<UdpMasksBean>? = null,
|
||||
var finalmask: FinalMaskBean? = null,
|
||||
val dsSettings: Any? = null,
|
||||
var sockopt: SockoptBean? = null
|
||||
) {
|
||||
@@ -200,15 +202,8 @@ data class V2rayConfig(
|
||||
var downlinkCapacity: Int = 100,
|
||||
var congestion: Boolean = false,
|
||||
var readBufferSize: Int = 1,
|
||||
var writeBufferSize: Int = 1,
|
||||
var header: HeaderBean = HeaderBean(),
|
||||
var seed: String? = null
|
||||
) {
|
||||
data class HeaderBean(
|
||||
var type: String = "none",
|
||||
var domain: String? = null
|
||||
)
|
||||
}
|
||||
var writeBufferSize: Int = 1
|
||||
)
|
||||
|
||||
data class WsSettingsBean(
|
||||
var path: String? = null,
|
||||
@@ -270,6 +265,7 @@ data class V2rayConfig(
|
||||
val enableSessionResumption: Boolean? = null,
|
||||
var echConfigList: String? = null,
|
||||
var echForceQuery: String? = null,
|
||||
var pinnedPeerCertSha256: String? = null,
|
||||
// REALITY settings
|
||||
val show: Boolean = false,
|
||||
var publicKey: String? = null,
|
||||
@@ -307,13 +303,19 @@ data class V2rayConfig(
|
||||
)
|
||||
}
|
||||
|
||||
data class UdpMasksBean(
|
||||
var type: String,
|
||||
var settings: UdpMasksSettingsBean? = null
|
||||
data class FinalMaskBean(
|
||||
var tcp: List<MaskBean>? = null,
|
||||
var udp: List<MaskBean>? = null
|
||||
) {
|
||||
data class UdpMasksSettingsBean(
|
||||
var password: String? = null
|
||||
)
|
||||
data class MaskBean(
|
||||
var type: String,
|
||||
var settings: MaskSettingsBean? = null
|
||||
) {
|
||||
data class MaskSettingsBean(
|
||||
var password: String? = null,
|
||||
var domain: String? = null
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
-3
@@ -1,8 +1,7 @@
|
||||
package com.v2ray.ang.dto
|
||||
package com.v2ray.ang.enums
|
||||
|
||||
import com.v2ray.ang.AppConfig
|
||||
|
||||
|
||||
enum class EConfigType(val value: Int, val protocolScheme: String) {
|
||||
VMESS(1, AppConfig.VMESS),
|
||||
CUSTOM(2, AppConfig.CUSTOM),
|
||||
@@ -21,4 +20,4 @@ enum class EConfigType(val value: Int, val protocolScheme: String) {
|
||||
companion object {
|
||||
fun fromInt(value: Int) = entries.firstOrNull { it.value == value }
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package com.v2ray.ang.dto
|
||||
package com.v2ray.ang.enums
|
||||
|
||||
enum class Language(val code: String) {
|
||||
AUTO("auto"),
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package com.v2ray.ang.dto
|
||||
package com.v2ray.ang.enums
|
||||
|
||||
enum class NetworkType(val type: String) {
|
||||
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
-1
@@ -1,4 +1,4 @@
|
||||
package com.v2ray.ang.dto
|
||||
package com.v2ray.ang.enums
|
||||
|
||||
enum class RoutingType(val fileName: String) {
|
||||
WHITE("custom_routing_white"),
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package com.v2ray.ang.dto
|
||||
package com.v2ray.ang.enums
|
||||
|
||||
/**
|
||||
* VPN interface address configuration enum class
|
||||
@@ -11,6 +11,7 @@ import com.v2ray.ang.AngApplication
|
||||
import es.dmoral.toasty.Toasty
|
||||
import java.io.Serializable
|
||||
import java.net.URI
|
||||
import java.util.Locale
|
||||
|
||||
val Context.v2RayApplication: AngApplication?
|
||||
get() = applicationContext as? AngApplication
|
||||
@@ -92,7 +93,7 @@ fun Long.toTrafficString(): String {
|
||||
size /= DIVISOR
|
||||
unitIndex++
|
||||
}
|
||||
return String.format("%.1f %s", size, units[unitIndex])
|
||||
return String.format(Locale.getDefault(), "%.1f %s", size, units[unitIndex])
|
||||
}
|
||||
|
||||
val URI.idnHost: String
|
||||
@@ -105,6 +106,13 @@ val URI.idnHost: String
|
||||
*/
|
||||
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.
|
||||
*
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
package com.v2ray.ang.fmt
|
||||
|
||||
import com.v2ray.ang.dto.EConfigType
|
||||
import com.v2ray.ang.dto.ProfileItem
|
||||
import com.v2ray.ang.dto.V2rayConfig
|
||||
import com.v2ray.ang.enums.EConfigType
|
||||
import com.v2ray.ang.util.JsonUtil
|
||||
|
||||
object CustomFmt : FmtBase() {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
package com.v2ray.ang.fmt
|
||||
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.dto.NetworkType
|
||||
import com.v2ray.ang.dto.ProfileItem
|
||||
import com.v2ray.ang.extension.isNotNullEmpty
|
||||
import com.v2ray.ang.enums.NetworkType
|
||||
import com.v2ray.ang.extension.nullIfBlank
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.util.HttpUtil
|
||||
import com.v2ray.ang.util.Utils
|
||||
@@ -22,17 +22,17 @@ open class FmtBase {
|
||||
val query = if (dicQuery != null)
|
||||
"?" + dicQuery.toList().joinToString(
|
||||
separator = "&",
|
||||
transform = { it.first + "=" + Utils.urlEncode(it.second) })
|
||||
transform = { it.first + "=" + Utils.encodeURIComponent(it.second) })
|
||||
else ""
|
||||
|
||||
val url = String.format(
|
||||
"%s@%s:%s",
|
||||
Utils.urlEncode(userInfo ?: ""),
|
||||
Utils.encodeURIComponent(userInfo ?: ""),
|
||||
Utils.getIpv6Address(HttpUtil.toIdnDomain(config.server.orEmpty())),
|
||||
config.serverPort
|
||||
)
|
||||
|
||||
return "${url}${query}#${Utils.urlEncode(config.remarks)}"
|
||||
return "${url}${query}#${Utils.encodeURIComponent(config.remarks)}"
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -43,7 +43,7 @@ open class FmtBase {
|
||||
*/
|
||||
fun getQueryParam(uri: URI): Map<String, String> {
|
||||
return uri.rawQuery.split("&")
|
||||
.associate { it.split("=").let { (k, v) -> k to Utils.urlDecode(v) } }
|
||||
.associate { it.split("=").let { (k, v) -> k to Utils.decodeURIComponent(v) } }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -83,6 +83,7 @@ open class FmtBase {
|
||||
config.fingerPrint = queryParam["fp"]
|
||||
config.alpn = queryParam["alpn"]
|
||||
config.echConfigList = queryParam["ech"]
|
||||
config.pinnedCA256 = queryParam["pcs"]
|
||||
config.publicKey = queryParam["pbk"]
|
||||
config.shortId = queryParam["sid"]
|
||||
config.spiderX = queryParam["spx"]
|
||||
@@ -99,15 +100,16 @@ open class FmtBase {
|
||||
fun getQueryDic(config: ProfileItem): HashMap<String, String> {
|
||||
val dicQuery = HashMap<String, String>()
|
||||
dicQuery["security"] = config.security?.ifEmpty { "none" }.orEmpty()
|
||||
config.sni.let { if (it.isNotNullEmpty()) dicQuery["sni"] = it.orEmpty() }
|
||||
config.alpn.let { if (it.isNotNullEmpty()) dicQuery["alpn"] = it.orEmpty() }
|
||||
config.echConfigList.let { if (it.isNotNullEmpty()) dicQuery["ech"] = it.orEmpty() }
|
||||
config.fingerPrint.let { if (it.isNotNullEmpty()) dicQuery["fp"] = it.orEmpty() }
|
||||
config.publicKey.let { if (it.isNotNullEmpty()) dicQuery["pbk"] = it.orEmpty() }
|
||||
config.shortId.let { if (it.isNotNullEmpty()) dicQuery["sid"] = it.orEmpty() }
|
||||
config.spiderX.let { if (it.isNotNullEmpty()) dicQuery["spx"] = it.orEmpty() }
|
||||
config.mldsa65Verify.let { if (it.isNotNullEmpty()) dicQuery["pqv"] = it.orEmpty() }
|
||||
config.flow.let { if (it.isNotNullEmpty()) dicQuery["flow"] = it.orEmpty() }
|
||||
config.sni?.nullIfBlank()?.let { dicQuery["sni"] = it }
|
||||
config.alpn?.nullIfBlank()?.let { dicQuery["alpn"] = it }
|
||||
config.echConfigList?.nullIfBlank()?.let { dicQuery["ech"] = it }
|
||||
config.pinnedCA256?.nullIfBlank()?.let { dicQuery["pcs"] = it }
|
||||
config.fingerPrint?.nullIfBlank()?.let { dicQuery["fp"] = it }
|
||||
config.publicKey?.nullIfBlank()?.let { dicQuery["pbk"] = it }
|
||||
config.shortId?.nullIfBlank()?.let { dicQuery["sid"] = it }
|
||||
config.spiderX?.nullIfBlank()?.let { dicQuery["spx"] = it }
|
||||
config.mldsa65Verify?.nullIfBlank()?.let { dicQuery["pqv"] = it }
|
||||
config.flow?.nullIfBlank()?.let { dicQuery["flow"] = it }
|
||||
// Add two keys for compatibility: "insecure" and "allowInsecure"
|
||||
if (config.security == AppConfig.TLS) {
|
||||
val insecureFlag = if (config.insecure == true) "1" else "0"
|
||||
@@ -121,42 +123,42 @@ open class FmtBase {
|
||||
when (networkType) {
|
||||
NetworkType.TCP -> {
|
||||
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 -> {
|
||||
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 -> {
|
||||
config.host.let { if (it.isNotNullEmpty()) dicQuery["host"] = it.orEmpty() }
|
||||
config.path.let { if (it.isNotNullEmpty()) dicQuery["path"] = it.orEmpty() }
|
||||
config.host?.nullIfBlank()?.let { dicQuery["host"] = it }
|
||||
config.path?.nullIfBlank()?.let { dicQuery["path"] = it }
|
||||
}
|
||||
|
||||
NetworkType.XHTTP -> {
|
||||
config.host.let { if (it.isNotNullEmpty()) dicQuery["host"] = it.orEmpty() }
|
||||
config.path.let { if (it.isNotNullEmpty()) dicQuery["path"] = it.orEmpty() }
|
||||
config.xhttpMode.let { if (it.isNotNullEmpty()) dicQuery["mode"] = it.orEmpty() }
|
||||
config.xhttpExtra.let { if (it.isNotNullEmpty()) dicQuery["extra"] = it.orEmpty() }
|
||||
config.host?.nullIfBlank()?.let { dicQuery["host"] = it }
|
||||
config.path?.nullIfBlank()?.let { dicQuery["path"] = it }
|
||||
config.xhttpMode?.nullIfBlank()?.let { dicQuery["mode"] = it }
|
||||
config.xhttpExtra?.nullIfBlank()?.let { dicQuery["extra"] = it }
|
||||
}
|
||||
|
||||
NetworkType.HTTP, NetworkType.H2 -> {
|
||||
dicQuery["type"] = "http"
|
||||
config.host.let { if (it.isNotNullEmpty()) dicQuery["host"] = it.orEmpty() }
|
||||
config.path.let { if (it.isNotNullEmpty()) dicQuery["path"] = it.orEmpty() }
|
||||
config.host?.nullIfBlank()?.let { dicQuery["host"] = it }
|
||||
config.path?.nullIfBlank()?.let { dicQuery["path"] = it }
|
||||
}
|
||||
|
||||
// NetworkType.QUIC -> {
|
||||
// dicQuery["headerType"] = config.headerType?.ifEmpty { "none" }.orEmpty()
|
||||
// config.quicSecurity.let { if (it.isNotNullEmpty()) dicQuery["quicSecurity"] = it.orEmpty() }
|
||||
// config.quicKey.let { if (it.isNotNullEmpty()) dicQuery["key"] = it.orEmpty() }
|
||||
// config.quicSecurity?.nullIfBlank()?.let { dicQuery["quicSecurity"] = it }
|
||||
// config.quicKey?.nullIfBlank()?.let { dicQuery["key"] = it }
|
||||
// }
|
||||
|
||||
NetworkType.GRPC -> {
|
||||
config.mode.let { if (it.isNotNullEmpty()) dicQuery["mode"] = it.orEmpty() }
|
||||
config.authority.let { if (it.isNotNullEmpty()) dicQuery["authority"] = it.orEmpty() }
|
||||
config.serviceName.let { if (it.isNotNullEmpty()) dicQuery["serviceName"] = it.orEmpty() }
|
||||
config.mode?.nullIfBlank()?.let { dicQuery["mode"] = it }
|
||||
config.authority?.nullIfBlank()?.let { dicQuery["authority"] = it }
|
||||
config.serviceName?.nullIfBlank()?.let { dicQuery["serviceName"] = it }
|
||||
}
|
||||
|
||||
else -> {}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
package com.v2ray.ang.fmt
|
||||
|
||||
import com.v2ray.ang.dto.EConfigType
|
||||
import com.v2ray.ang.dto.ProfileItem
|
||||
import com.v2ray.ang.dto.V2rayConfig.OutboundBean
|
||||
import com.v2ray.ang.enums.EConfigType
|
||||
import com.v2ray.ang.extension.isNotNullEmpty
|
||||
import com.v2ray.ang.handler.V2rayConfigManager
|
||||
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
package com.v2ray.ang.fmt
|
||||
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.dto.EConfigType
|
||||
import com.v2ray.ang.dto.NetworkType
|
||||
import com.v2ray.ang.dto.ProfileItem
|
||||
import com.v2ray.ang.dto.V2rayConfig.OutboundBean
|
||||
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.FinalMaskBean
|
||||
import com.v2ray.ang.enums.EConfigType
|
||||
import com.v2ray.ang.enums.NetworkType
|
||||
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.V2rayConfigManager
|
||||
import com.v2ray.ang.util.Utils
|
||||
@@ -26,7 +26,7 @@ object Hysteria2Fmt : FmtBase() {
|
||||
val config = ProfileItem.create(EConfigType.HYSTERIA2)
|
||||
|
||||
val uri = URI(Utils.fixIllegalUrl(str))
|
||||
config.remarks = Utils.urlDecode(uri.fragment.orEmpty()).let { if (it.isEmpty()) "none" else it }
|
||||
config.remarks = Utils.decodeURIComponent(uri.fragment.orEmpty()).let { it.ifEmpty { "none" } }
|
||||
config.server = uri.idnHost
|
||||
config.serverPort = uri.port.toString()
|
||||
config.password = uri.userInfo
|
||||
@@ -44,7 +44,7 @@ object Hysteria2Fmt : FmtBase() {
|
||||
if (config.portHopping.isNotNullEmpty()) {
|
||||
config.portHoppingInterval = queryParam["mportHopInt"]
|
||||
}
|
||||
config.pinSHA256 = queryParam["pinSHA256"]
|
||||
config.pinnedCA256 = queryParam["pinSHA256"]
|
||||
|
||||
}
|
||||
|
||||
@@ -61,8 +61,8 @@ object Hysteria2Fmt : FmtBase() {
|
||||
val dicQuery = HashMap<String, String>()
|
||||
|
||||
config.security.let { if (it != null) dicQuery["security"] = it }
|
||||
config.sni.let { if (it.isNotNullEmpty()) dicQuery["sni"] = it.orEmpty() }
|
||||
config.alpn.let { if (it.isNotNullEmpty()) dicQuery["alpn"] = it.orEmpty() }
|
||||
config.sni?.nullIfBlank()?.let { dicQuery["sni"] = it }
|
||||
config.alpn?.nullIfBlank()?.let { dicQuery["alpn"] = it }
|
||||
config.insecure.let { dicQuery["insecure"] = if (it == true) "1" else "0" }
|
||||
|
||||
if (config.obfsPassword.isNotNullEmpty()) {
|
||||
@@ -75,8 +75,8 @@ object Hysteria2Fmt : FmtBase() {
|
||||
if (config.portHoppingInterval.isNotNullEmpty()) {
|
||||
dicQuery["mportHopInt"] = config.portHoppingInterval.orEmpty()
|
||||
}
|
||||
if (config.pinSHA256.isNotNullEmpty()) {
|
||||
dicQuery["pinSHA256"] = config.pinSHA256.orEmpty()
|
||||
if (config.pinnedCA256.isNotNullEmpty()) {
|
||||
dicQuery["pinSHA256"] = config.pinnedCA256.orEmpty()
|
||||
}
|
||||
|
||||
return toUri(config, config.password, dicQuery)
|
||||
@@ -96,6 +96,7 @@ object Hysteria2Fmt : FmtBase() {
|
||||
outboundBean.settings?.let { server ->
|
||||
server.address = getServerAddress(profileItem)
|
||||
server.port = profileItem.serverPort.orEmpty().toInt()
|
||||
server.version = 2
|
||||
}
|
||||
|
||||
val sni = outboundBean.streamSettings?.let {
|
||||
@@ -107,11 +108,13 @@ object Hysteria2Fmt : FmtBase() {
|
||||
}
|
||||
|
||||
if (profileItem.obfsPassword.isNotNullEmpty()) {
|
||||
outboundBean.streamSettings?.udpmasks = mutableListOf(
|
||||
StreamSettingsBean.UdpMasksBean(
|
||||
type = "salamander",
|
||||
settings = UdpMasksSettingsBean(
|
||||
password = profileItem.obfsPassword
|
||||
outboundBean.streamSettings?.finalmask = FinalMaskBean(
|
||||
udp = listOf(
|
||||
FinalMaskBean.MaskBean(
|
||||
type = "salamander",
|
||||
settings = FinalMaskBean.MaskBean.MaskSettingsBean(
|
||||
password = profileItem.obfsPassword
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -2,10 +2,10 @@ package com.v2ray.ang.fmt
|
||||
|
||||
import android.util.Log
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.dto.EConfigType
|
||||
import com.v2ray.ang.dto.NetworkType
|
||||
import com.v2ray.ang.dto.ProfileItem
|
||||
import com.v2ray.ang.dto.V2rayConfig.OutboundBean
|
||||
import com.v2ray.ang.enums.EConfigType
|
||||
import com.v2ray.ang.enums.NetworkType
|
||||
import com.v2ray.ang.extension.idnHost
|
||||
import com.v2ray.ang.handler.V2rayConfigManager
|
||||
import com.v2ray.ang.util.Utils
|
||||
@@ -36,7 +36,7 @@ object ShadowsocksFmt : FmtBase() {
|
||||
if (uri.port <= 0) 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.decodeURIComponent(uri.fragment.orEmpty()).let { it.ifEmpty { "none" } }
|
||||
config.server = uri.idnHost
|
||||
config.serverPort = uri.port.toString()
|
||||
|
||||
@@ -83,7 +83,7 @@ object ShadowsocksFmt : FmtBase() {
|
||||
if (indexSplit > 0) {
|
||||
try {
|
||||
config.remarks =
|
||||
Utils.urlDecode(result.substring(indexSplit + 1, result.length))
|
||||
Utils.decodeURIComponent(result.substring(indexSplit + 1, result.length))
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Failed to decode remarks in SS legacy URL", e)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
package com.v2ray.ang.fmt
|
||||
|
||||
import com.v2ray.ang.dto.EConfigType
|
||||
import com.v2ray.ang.dto.ProfileItem
|
||||
import com.v2ray.ang.dto.V2rayConfig.OutboundBean
|
||||
import com.v2ray.ang.enums.EConfigType
|
||||
import com.v2ray.ang.extension.idnHost
|
||||
import com.v2ray.ang.extension.isNotNullEmpty
|
||||
import com.v2ray.ang.handler.V2rayConfigManager
|
||||
@@ -23,7 +23,7 @@ object SocksFmt : FmtBase() {
|
||||
if (uri.idnHost.isEmpty()) 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.decodeURIComponent(uri.fragment.orEmpty()).let { it.ifEmpty { "none" } }
|
||||
config.server = uri.idnHost
|
||||
config.serverPort = uri.port.toString()
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
package com.v2ray.ang.fmt
|
||||
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.dto.EConfigType
|
||||
import com.v2ray.ang.dto.NetworkType
|
||||
import com.v2ray.ang.dto.ProfileItem
|
||||
import com.v2ray.ang.dto.V2rayConfig.OutboundBean
|
||||
import com.v2ray.ang.enums.EConfigType
|
||||
import com.v2ray.ang.enums.NetworkType
|
||||
import com.v2ray.ang.extension.idnHost
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.handler.V2rayConfigManager
|
||||
@@ -23,7 +23,7 @@ object TrojanFmt : FmtBase() {
|
||||
val config = ProfileItem.create(EConfigType.TROJAN)
|
||||
|
||||
val uri = URI(Utils.fixIllegalUrl(str))
|
||||
config.remarks = Utils.urlDecode(uri.fragment.orEmpty()).let { if (it.isEmpty()) "none" else it }
|
||||
config.remarks = Utils.decodeURIComponent(uri.fragment.orEmpty()).let { it.ifEmpty { "none" } }
|
||||
config.server = uri.idnHost
|
||||
config.serverPort = uri.port.toString()
|
||||
config.password = uri.userInfo
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
package com.v2ray.ang.fmt
|
||||
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.dto.EConfigType
|
||||
import com.v2ray.ang.dto.ProfileItem
|
||||
import com.v2ray.ang.dto.V2rayConfig.OutboundBean
|
||||
import com.v2ray.ang.enums.EConfigType
|
||||
import com.v2ray.ang.extension.idnHost
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.handler.V2rayConfigManager
|
||||
@@ -26,7 +26,7 @@ object VlessFmt : FmtBase() {
|
||||
if (uri.rawQuery.isNullOrEmpty()) return null
|
||||
val queryParam = getQueryParam(uri)
|
||||
|
||||
config.remarks = Utils.urlDecode(uri.fragment.orEmpty()).let { if (it.isEmpty()) "none" else it }
|
||||
config.remarks = Utils.decodeURIComponent(uri.fragment.orEmpty()).let { it.ifEmpty { "none" } }
|
||||
config.server = uri.idnHost
|
||||
config.serverPort = uri.port.toString()
|
||||
config.password = uri.userInfo
|
||||
|
||||
@@ -3,13 +3,13 @@ package com.v2ray.ang.fmt
|
||||
import android.text.TextUtils
|
||||
import android.util.Log
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.dto.EConfigType
|
||||
import com.v2ray.ang.dto.NetworkType
|
||||
import com.v2ray.ang.dto.ProfileItem
|
||||
import com.v2ray.ang.dto.V2rayConfig.OutboundBean
|
||||
import com.v2ray.ang.dto.VmessQRCode
|
||||
import com.v2ray.ang.enums.EConfigType
|
||||
import com.v2ray.ang.enums.NetworkType
|
||||
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.V2rayConfigManager
|
||||
import com.v2ray.ang.util.JsonUtil
|
||||
@@ -132,8 +132,8 @@ object VmessFmt : FmtBase() {
|
||||
else -> {}
|
||||
}
|
||||
|
||||
config.host.let { if (it.isNotNullEmpty()) vmessQRCode.host = it.orEmpty() }
|
||||
config.path.let { if (it.isNotNullEmpty()) vmessQRCode.path = it.orEmpty() }
|
||||
config.host?.nullIfBlank()?.let { vmessQRCode.host = it }
|
||||
config.path?.nullIfBlank()?.let { vmessQRCode.path = it }
|
||||
|
||||
vmessQRCode.tls = config.security.orEmpty()
|
||||
vmessQRCode.sni = config.sni.orEmpty()
|
||||
@@ -163,7 +163,7 @@ object VmessFmt : FmtBase() {
|
||||
if (uri.rawQuery.isNullOrEmpty()) return null
|
||||
val queryParam = getQueryParam(uri)
|
||||
|
||||
config.remarks = Utils.urlDecode(uri.fragment.orEmpty()).let { if (it.isEmpty()) "none" else it }
|
||||
config.remarks = Utils.decodeURIComponent(uri.fragment.orEmpty()).let { it.ifEmpty { "none" } }
|
||||
config.server = uri.idnHost
|
||||
config.serverPort = uri.port.toString()
|
||||
config.password = uri.userInfo
|
||||
|
||||
@@ -2,10 +2,11 @@ package com.v2ray.ang.fmt
|
||||
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.AppConfig.WIREGUARD_LOCAL_ADDRESS_V4
|
||||
import com.v2ray.ang.dto.EConfigType
|
||||
import com.v2ray.ang.dto.ProfileItem
|
||||
import com.v2ray.ang.dto.V2rayConfig.OutboundBean
|
||||
import com.v2ray.ang.enums.EConfigType
|
||||
import com.v2ray.ang.extension.idnHost
|
||||
import com.v2ray.ang.extension.nullIfBlank
|
||||
import com.v2ray.ang.extension.removeWhiteSpace
|
||||
import com.v2ray.ang.handler.V2rayConfigManager
|
||||
import com.v2ray.ang.util.Utils
|
||||
@@ -25,14 +26,14 @@ object WireguardFmt : FmtBase() {
|
||||
if (uri.rawQuery.isNullOrEmpty()) return null
|
||||
val queryParam = getQueryParam(uri)
|
||||
|
||||
config.remarks = Utils.urlDecode(uri.fragment.orEmpty()).let { if (it.isEmpty()) "none" else it }
|
||||
config.remarks = Utils.decodeURIComponent(uri.fragment.orEmpty()).let { it.ifEmpty { "none" } }
|
||||
config.server = uri.idnHost
|
||||
config.serverPort = uri.port.toString()
|
||||
|
||||
config.secretKey = uri.userInfo.orEmpty()
|
||||
config.localAddress = queryParam["address"] ?: WIREGUARD_LOCAL_ADDRESS_V4
|
||||
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.reserved = queryParam["reserved"] ?: "0,0,0"
|
||||
|
||||
@@ -84,7 +85,7 @@ object WireguardFmt : FmtBase() {
|
||||
config.localAddress = interfaceParams["address"] ?: WIREGUARD_LOCAL_ADDRESS_V4
|
||||
config.mtu = Utils.parseInt(interfaceParams["mtu"] ?: AppConfig.WIREGUARD_LOCAL_MTU)
|
||||
config.publicKey = peerParams["publickey"].orEmpty()
|
||||
config.preSharedKey = peerParams["presharedkey"]?.takeIf { it.isNotEmpty() }
|
||||
config.preSharedKey = peerParams["presharedkey"]?.nullIfBlank()
|
||||
val endpoint = peerParams["endpoint"].orEmpty()
|
||||
val endpointParts = endpoint.split(":", limit = 2)
|
||||
if (endpointParts.size == 2) {
|
||||
@@ -113,7 +114,7 @@ object WireguardFmt : FmtBase() {
|
||||
wireguard.address = (profileItem.localAddress ?: WIREGUARD_LOCAL_ADDRESS_V4).split(",")
|
||||
wireguard.peers?.firstOrNull()?.let { peer ->
|
||||
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}"
|
||||
}
|
||||
wireguard.mtu = profileItem.mtu
|
||||
|
||||
@@ -7,9 +7,12 @@ import android.util.Log
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.AppConfig.HY2
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.dto.EConfigType
|
||||
import com.v2ray.ang.dto.ProfileItem
|
||||
import com.v2ray.ang.dto.SubscriptionCache
|
||||
import com.v2ray.ang.dto.SubscriptionItem
|
||||
import com.v2ray.ang.dto.SubscriptionUpdateResult
|
||||
import com.v2ray.ang.enums.EConfigType
|
||||
import com.v2ray.ang.extension.isNotNullEmpty
|
||||
import com.v2ray.ang.fmt.CustomFmt
|
||||
import com.v2ray.ang.fmt.Hysteria2Fmt
|
||||
import com.v2ray.ang.fmt.ShadowsocksFmt
|
||||
@@ -236,23 +239,77 @@ object AngConfigManager {
|
||||
}
|
||||
|
||||
val subItem = MmkvManager.decodeSubscription(subid)
|
||||
var count = 0
|
||||
|
||||
// Parse all configs first (no I/O during parsing)
|
||||
val configs = mutableListOf<ProfileItem>()
|
||||
servers.lines()
|
||||
.distinct()
|
||||
.reversed()
|
||||
.forEach {
|
||||
val resId = parseConfig(it, subid, subItem, removedSelectedServer)
|
||||
if (resId == 0) {
|
||||
count++
|
||||
val config = parseConfig(it, subid, subItem)
|
||||
if (config != null) {
|
||||
configs.add(config)
|
||||
}
|
||||
}
|
||||
return count
|
||||
|
||||
// Batch save all parsed configs (only one serverList read/write)
|
||||
if (configs.isNotEmpty()) {
|
||||
val keys = batchSaveConfigs(configs, subid)
|
||||
|
||||
// Handle removed selected server
|
||||
removedSelectedServer?.let { removed ->
|
||||
val matchKey = keys.find { key ->
|
||||
val savedConfig = MmkvManager.decodeServerConfig(key)
|
||||
savedConfig != null &&
|
||||
savedConfig.server == removed.server &&
|
||||
savedConfig.serverPort == removed.serverPort
|
||||
}
|
||||
matchKey?.let { MmkvManager.setSelectServer(it) }
|
||||
}
|
||||
}
|
||||
|
||||
return configs.size
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Failed to parse batch config", e)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch save configurations to reduce serverList read/write operations.
|
||||
* Reads serverList once, saves all configs, then writes serverList once.
|
||||
*
|
||||
* @param configs The list of ProfileItem to save.
|
||||
* @param subid The subscription ID.
|
||||
* @return The list of generated keys.
|
||||
*/
|
||||
private fun batchSaveConfigs(configs: List<ProfileItem>, subid: String): List<String> {
|
||||
val keys = mutableListOf<String>()
|
||||
|
||||
// Read serverList once
|
||||
val serverList = MmkvManager.decodeServerList(subid)
|
||||
var needSetSelected = MmkvManager.getSelectServer().isNullOrBlank()
|
||||
|
||||
configs.forEach { config ->
|
||||
val key = Utils.getUuid()
|
||||
// Save profile directly without updating serverList
|
||||
MmkvManager.encodeProfileDirect(key, JsonUtil.toJson(config))
|
||||
|
||||
if (!serverList.contains(key)) {
|
||||
serverList.add(0, key)
|
||||
if (needSetSelected) {
|
||||
MmkvManager.setSelectServer(key)
|
||||
needSetSelected = false
|
||||
}
|
||||
}
|
||||
keys.add(key)
|
||||
}
|
||||
|
||||
// Write serverList once
|
||||
MmkvManager.encodeServerList(serverList, subid)
|
||||
return keys
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a custom configuration server.
|
||||
*
|
||||
@@ -270,13 +327,14 @@ object AngConfigManager {
|
||||
) {
|
||||
try {
|
||||
val serverList: Array<Any> =
|
||||
JsonUtil.fromJson(server, Array<Any>::class.java)?: arrayOf()
|
||||
JsonUtil.fromJson(server, Array<Any>::class.java) ?: arrayOf()
|
||||
|
||||
if (serverList.isNotEmpty()) {
|
||||
var count = 0
|
||||
for (srv in serverList.reversed()) {
|
||||
val config = CustomFmt.parse(JsonUtil.toJson(srv)) ?: continue
|
||||
config.subscriptionId = subid
|
||||
config.description = generateDescription(config)
|
||||
val key = MmkvManager.encodeServerConfig("", config)
|
||||
MmkvManager.encodeServerRaw(key, JsonUtil.toJsonPretty(srv) ?: "")
|
||||
count += 1
|
||||
@@ -291,6 +349,7 @@ object AngConfigManager {
|
||||
// For compatibility
|
||||
val config = CustomFmt.parse(server) ?: return 0
|
||||
config.subscriptionId = subid
|
||||
config.description = generateDescription(config)
|
||||
val key = MmkvManager.encodeServerConfig("", config)
|
||||
MmkvManager.encodeServerRaw(key, server)
|
||||
return 1
|
||||
@@ -301,6 +360,7 @@ object AngConfigManager {
|
||||
} else if (server.startsWith("[Interface]") && server.contains("[Peer]")) {
|
||||
try {
|
||||
val config = WireguardFmt.parseWireguardConfFile(server) ?: return R.string.toast_incorrect_protocol
|
||||
config.description = generateDescription(config)
|
||||
val key = MmkvManager.encodeServerConfig("", config)
|
||||
MmkvManager.encodeServerRaw(key, server)
|
||||
return 1
|
||||
@@ -315,22 +375,21 @@ object AngConfigManager {
|
||||
|
||||
/**
|
||||
* Parses the configuration from a QR code or string.
|
||||
* Only parses and returns ProfileItem, does not save.
|
||||
*
|
||||
* @param str The configuration string.
|
||||
* @param subid The subscription ID.
|
||||
* @param subItem The subscription item.
|
||||
* @param removedSelectedServer The removed selected server.
|
||||
* @return The result code.
|
||||
* @return The parsed ProfileItem or null if parsing fails or filtered out.
|
||||
*/
|
||||
private fun parseConfig(
|
||||
str: String?,
|
||||
subid: String,
|
||||
subItem: SubscriptionItem?,
|
||||
removedSelectedServer: ProfileItem?
|
||||
): Int {
|
||||
subItem: SubscriptionItem?
|
||||
): ProfileItem? {
|
||||
try {
|
||||
if (str == null || TextUtils.isEmpty(str)) {
|
||||
return R.string.toast_none_data
|
||||
return null
|
||||
}
|
||||
|
||||
val config = if (str.startsWith(EConfigType.VMESS.protocolScheme)) {
|
||||
@@ -352,75 +411,75 @@ object AngConfigManager {
|
||||
}
|
||||
|
||||
if (config == null) {
|
||||
return R.string.toast_incorrect_protocol
|
||||
return null
|
||||
}
|
||||
//filter
|
||||
if (subItem?.filter != null && subItem.filter?.isNotEmpty() == true && config.remarks.isNotEmpty()) {
|
||||
val matched = Regex(pattern = subItem.filter ?: "")
|
||||
|
||||
// Apply filter
|
||||
if (subItem?.filter.isNotNullEmpty() && config.remarks.isNotNullEmpty()) {
|
||||
val matched = Regex(pattern = subItem?.filter.orEmpty())
|
||||
.containsMatchIn(input = config.remarks)
|
||||
if (!matched) return -1
|
||||
if (!matched) return null
|
||||
}
|
||||
|
||||
config.subscriptionId = subid
|
||||
val guid = MmkvManager.encodeServerConfig("", config)
|
||||
if (removedSelectedServer != null &&
|
||||
config.server == removedSelectedServer.server && config.serverPort == removedSelectedServer.serverPort
|
||||
) {
|
||||
MmkvManager.setSelectServer(guid)
|
||||
}
|
||||
config.description = generateDescription(config)
|
||||
|
||||
return config
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Failed to parse config", e)
|
||||
return -1
|
||||
return null
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the configuration via all subscriptions.
|
||||
*
|
||||
* @return The number of configurations updated.
|
||||
* @return Detailed result of the subscription update operation.
|
||||
*/
|
||||
fun updateConfigViaSubAll(): Int {
|
||||
var count = 0
|
||||
try {
|
||||
MmkvManager.decodeSubscriptions().forEach {
|
||||
count += updateConfigViaSub(it)
|
||||
fun updateConfigViaSubAll(): SubscriptionUpdateResult {
|
||||
return try {
|
||||
val subscriptions = MmkvManager.decodeSubscriptions()
|
||||
subscriptions.fold(SubscriptionUpdateResult()) { acc, subscription ->
|
||||
acc + updateConfigViaSub(subscription)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Failed to update config via all subscriptions", e)
|
||||
return 0
|
||||
SubscriptionUpdateResult()
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the configuration via a subscription.
|
||||
*
|
||||
* @param it The subscription item.
|
||||
* @return The number of configurations updated.
|
||||
* @return Subscription update result.
|
||||
*/
|
||||
fun updateConfigViaSub(it: Pair<String, SubscriptionItem>): Int {
|
||||
fun updateConfigViaSub(it: SubscriptionCache): SubscriptionUpdateResult {
|
||||
try {
|
||||
if (TextUtils.isEmpty(it.first)
|
||||
|| TextUtils.isEmpty(it.second.remarks)
|
||||
|| TextUtils.isEmpty(it.second.url)
|
||||
// Check if disabled
|
||||
if (!it.subscription.enabled) {
|
||||
return SubscriptionUpdateResult(skipCount = 1)
|
||||
}
|
||||
|
||||
// Validate subscription info
|
||||
if (TextUtils.isEmpty(it.guid)
|
||||
|| TextUtils.isEmpty(it.subscription.remarks)
|
||||
|| TextUtils.isEmpty(it.subscription.url)
|
||||
) {
|
||||
return 0
|
||||
return SubscriptionUpdateResult(skipCount = 1)
|
||||
}
|
||||
if (!it.second.enabled) {
|
||||
return 0
|
||||
}
|
||||
val url = HttpUtil.toIdnUrl(it.second.url)
|
||||
|
||||
val url = HttpUtil.toIdnUrl(it.subscription.url)
|
||||
if (!Utils.isValidUrl(url)) {
|
||||
return 0
|
||||
return SubscriptionUpdateResult(failureCount = 1)
|
||||
}
|
||||
if (!it.second.allowInsecureUrl) {
|
||||
if (!it.subscription.allowInsecureUrl) {
|
||||
if (!Utils.isValidSubUrl(url)) {
|
||||
return 0
|
||||
return SubscriptionUpdateResult(failureCount = 1)
|
||||
}
|
||||
}
|
||||
Log.i(AppConfig.TAG, url)
|
||||
val userAgent = it.second.userAgent
|
||||
val userAgent = it.subscription.userAgent
|
||||
|
||||
var configText = try {
|
||||
val httpPort = SettingsManager.getHttpPort()
|
||||
@@ -438,18 +497,25 @@ object AngConfigManager {
|
||||
}
|
||||
}
|
||||
if (configText.isEmpty()) {
|
||||
return 0
|
||||
return SubscriptionUpdateResult(failureCount = 1)
|
||||
}
|
||||
val count = parseConfigViaSub(configText, it.first, false)
|
||||
|
||||
val count = parseConfigViaSub(configText, it.guid, false)
|
||||
if (count > 0) {
|
||||
it.second.lastUpdated = System.currentTimeMillis()
|
||||
MmkvManager.encodeSubscription(it.first, it.second)
|
||||
Log.i(AppConfig.TAG, "Subscription updated: ${it.second.remarks}, $count configs")
|
||||
it.subscription.lastUpdated = System.currentTimeMillis()
|
||||
MmkvManager.encodeSubscription(it.guid, it.subscription)
|
||||
Log.i(AppConfig.TAG, "Subscription updated: ${it.subscription.remarks}, $count configs")
|
||||
return SubscriptionUpdateResult(
|
||||
configCount = count,
|
||||
successCount = 1
|
||||
)
|
||||
} else {
|
||||
// Got response but no valid configs parsed
|
||||
return SubscriptionUpdateResult(failureCount = 1)
|
||||
}
|
||||
return count
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Failed to update config via subscription", e)
|
||||
return 0
|
||||
return SubscriptionUpdateResult(failureCount = 1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -481,7 +547,7 @@ object AngConfigManager {
|
||||
private fun importUrlAsSubscription(url: String): Int {
|
||||
val subscriptions = MmkvManager.decodeSubscriptions()
|
||||
subscriptions.forEach {
|
||||
if (it.second.url == url) {
|
||||
if (it.subscription.url == url) {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
@@ -492,4 +558,25 @@ object AngConfigManager {
|
||||
MmkvManager.encodeSubscription("", subItem)
|
||||
return 1
|
||||
}
|
||||
|
||||
/** Generates a description for the profile.
|
||||
*
|
||||
* @param profile The profile item.
|
||||
* @return The generated description.
|
||||
*/
|
||||
fun generateDescription(profile: ProfileItem): String {
|
||||
// Hide xxx:xxx:***/xxx.xxx.xxx.***
|
||||
val server = profile.server
|
||||
val port = profile.serverPort
|
||||
if (server.isNullOrBlank() && port.isNullOrBlank()) return ""
|
||||
|
||||
val addrPart = server?.let {
|
||||
if (it.contains(":"))
|
||||
it.split(":").take(2).joinToString(":", postfix = ":***")
|
||||
else
|
||||
it.split('.').dropLast(1).joinToString(".", postfix = ".***")
|
||||
} ?: ""
|
||||
|
||||
return "$addrPart : ${port ?: ""}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
package com.v2ray.ang.handler
|
||||
|
||||
import com.tencent.mmkv.MMKV
|
||||
import com.v2ray.ang.AppConfig.DEFAULT_SUBSCRIPTION_ID
|
||||
import com.v2ray.ang.AppConfig.PREF_IS_BOOTED
|
||||
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.ProfileItem
|
||||
import com.v2ray.ang.dto.RulesetItem
|
||||
import com.v2ray.ang.dto.ServerAffiliationInfo
|
||||
import com.v2ray.ang.dto.SubscriptionCache
|
||||
import com.v2ray.ang.dto.SubscriptionItem
|
||||
import com.v2ray.ang.dto.WebDavConfig
|
||||
import com.v2ray.ang.util.JsonUtil
|
||||
@@ -16,7 +19,6 @@ object MmkvManager {
|
||||
|
||||
//region private
|
||||
|
||||
//private const val ID_PROFILE_CONFIG = "PROFILE_CONFIG"
|
||||
private const val ID_MAIN = "MAIN"
|
||||
private const val ID_PROFILE_FULL_CONFIG = "PROFILE_FULL_CONFIG"
|
||||
private const val ID_SERVER_RAW = "SERVER_RAW"
|
||||
@@ -26,10 +28,10 @@ object MmkvManager {
|
||||
private const val ID_SETTING = "SETTING"
|
||||
private const val KEY_SELECTED_SERVER = "SELECTED_SERVER"
|
||||
private const val KEY_ANG_CONFIGS = "ANG_CONFIGS"
|
||||
private const val KEY_SUB_SERVER_PREFIX = "SUB_SERVERS_"
|
||||
private const val KEY_SUB_IDS = "SUB_IDS"
|
||||
private const val KEY_WEBDAV_CONFIG = "WEBDAV_CONFIG"
|
||||
|
||||
//private val profileStorage by lazy { MMKV.mmkvWithID(ID_PROFILE_CONFIG, MMKV.MULTI_PROCESS_MODE) }
|
||||
private val mainStorage by lazy { MMKV.mmkvWithID(ID_MAIN, MMKV.MULTI_PROCESS_MODE) }
|
||||
private val profileFullStorage by lazy { MMKV.mmkvWithID(ID_PROFILE_FULL_CONFIG, MMKV.MULTI_PROCESS_MODE) }
|
||||
private val serverRawStorage by lazy { MMKV.mmkvWithID(ID_SERVER_RAW, MMKV.MULTI_PROCESS_MODE) }
|
||||
@@ -42,6 +44,17 @@ object MmkvManager {
|
||||
|
||||
//region Server
|
||||
|
||||
/**
|
||||
* Reads the legacy server list from KEY_ANG_CONFIGS for migration.
|
||||
* This method is for migration purposes only.
|
||||
*
|
||||
* @return The JSON string of legacy server list, or null if not exists.
|
||||
*/
|
||||
fun readLegacyServerList(): String? {
|
||||
return mainStorage.decodeString(KEY_ANG_CONFIGS)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Gets the selected server GUID.
|
||||
*
|
||||
@@ -61,21 +74,31 @@ object MmkvManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes the server list.
|
||||
* Encodes the server list for a given subscription.
|
||||
* Saves to the subscription's serverList (including default subscription for ungrouped servers).
|
||||
*
|
||||
* @param serverList The list of server GUIDs.
|
||||
* @param subscriptionId The subscription ID.
|
||||
*/
|
||||
fun encodeServerList(serverList: MutableList<String>) {
|
||||
mainStorage.encode(KEY_ANG_CONFIGS, JsonUtil.toJson(serverList))
|
||||
fun encodeServerList(serverList: MutableList<String>, subscriptionId: String) {
|
||||
val subId = getSubscriptionId(subscriptionId)
|
||||
val key = "$KEY_SUB_SERVER_PREFIX$subId"
|
||||
mainStorage.encode(key, JsonUtil.toJson(serverList))
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Decodes the server list.
|
||||
* Decodes the server list for a given subscription.
|
||||
* If subscriptionId is empty, returns ungrouped servers.
|
||||
* Otherwise, returns servers from the specified subscription's serverList.
|
||||
*
|
||||
* @param subscriptionId The subscription ID.
|
||||
* @return The list of server GUIDs.
|
||||
*/
|
||||
fun decodeServerList(): MutableList<String> {
|
||||
val json = mainStorage.decodeString(KEY_ANG_CONFIGS)
|
||||
fun decodeServerList(subscriptionId: String): MutableList<String> {
|
||||
val subId = getSubscriptionId(subscriptionId)
|
||||
val key = "$KEY_SUB_SERVER_PREFIX$subId"
|
||||
val json = mainStorage.decodeString(key)
|
||||
return if (json.isNullOrBlank()) {
|
||||
mutableListOf()
|
||||
} else {
|
||||
@@ -83,6 +106,24 @@ object MmkvManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes all server list (merged from all subscriptions including default subscription).
|
||||
* Use this when you need the complete server list.
|
||||
*
|
||||
* @return The list of all server GUIDs.
|
||||
*/
|
||||
fun decodeAllServerList(): MutableList<String> {
|
||||
val allServers = mutableListOf<String>()
|
||||
|
||||
// Add servers from all subscriptions (including default subscription)
|
||||
decodeSubsList().forEach { guid ->
|
||||
allServers.addAll(decodeServerList(guid))
|
||||
}
|
||||
|
||||
return allServers
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Decodes the server configuration.
|
||||
*
|
||||
@@ -100,16 +141,6 @@ object MmkvManager {
|
||||
return JsonUtil.fromJson(json, ProfileItem::class.java)
|
||||
}
|
||||
|
||||
// fun decodeProfileConfig(guid: String): ProfileLiteItem? {
|
||||
// if (guid.isBlank()) {
|
||||
// return null
|
||||
// }
|
||||
// val json = profileStorage.decodeString(guid)
|
||||
// if (json.isNullOrBlank()) {
|
||||
// return null
|
||||
// }
|
||||
// return JsonUtil.fromJson(json, ProfileLiteItem::class.java)
|
||||
// }
|
||||
|
||||
/**
|
||||
* Encodes the server configuration.
|
||||
@@ -121,25 +152,32 @@ object MmkvManager {
|
||||
fun encodeServerConfig(guid: String, config: ProfileItem): String {
|
||||
val key = guid.ifBlank { Utils.getUuid() }
|
||||
profileFullStorage.encode(key, JsonUtil.toJson(config))
|
||||
val serverList = decodeServerList()
|
||||
|
||||
// Use default subscription for servers without subscription
|
||||
val subId = getSubscriptionId(config.subscriptionId)
|
||||
val serverList = decodeServerList(subId)
|
||||
|
||||
if (!serverList.contains(key)) {
|
||||
serverList.add(0, key)
|
||||
encodeServerList(serverList)
|
||||
encodeServerList(serverList, subId)
|
||||
if (getSelectServer().isNullOrBlank()) {
|
||||
mainStorage.encode(KEY_SELECTED_SERVER, key)
|
||||
}
|
||||
}
|
||||
// val profile = ProfileLiteItem(
|
||||
// configType = config.configType,
|
||||
// subscriptionId = config.subscriptionId,
|
||||
// remarks = config.remarks,
|
||||
// server = config.getProxyOutbound()?.getServerAddress(),
|
||||
// serverPort = config.getProxyOutbound()?.getServerPort(),
|
||||
// )
|
||||
// profileStorage.encode(key, JsonUtil.toJson(profile))
|
||||
|
||||
return key
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes the server configuration directly without updating serverList.
|
||||
*
|
||||
* @param key The server GUID.
|
||||
* @param configJson The server configuration JSON string.
|
||||
*/
|
||||
fun encodeProfileDirect(key: String, configJson: String) {
|
||||
profileFullStorage.encode(key, configJson)
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the server configuration.
|
||||
*
|
||||
@@ -149,33 +187,44 @@ object MmkvManager {
|
||||
if (guid.isBlank()) {
|
||||
return
|
||||
}
|
||||
|
||||
// Get config to determine which subscription to update
|
||||
val config = decodeServerConfig(guid)
|
||||
val subId = getSubscriptionId(config?.subscriptionId)
|
||||
|
||||
// Remove from appropriate server list
|
||||
val serverList = decodeServerList(subId)
|
||||
serverList.remove(guid)
|
||||
encodeServerList(serverList, subId)
|
||||
|
||||
// Clean up storage
|
||||
if (getSelectServer() == guid) {
|
||||
mainStorage.remove(KEY_SELECTED_SERVER)
|
||||
}
|
||||
val serverList = decodeServerList()
|
||||
serverList.remove(guid)
|
||||
encodeServerList(serverList)
|
||||
profileFullStorage.remove(guid)
|
||||
//profileStorage.remove(guid)
|
||||
serverAffStorage.remove(guid)
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the server configurations via subscription ID.
|
||||
*
|
||||
* @param subid The subscription ID.
|
||||
* @param subscriptionId The subscription ID.
|
||||
*/
|
||||
fun removeServerViaSubid(subid: String) {
|
||||
if (subid.isBlank()) {
|
||||
return
|
||||
}
|
||||
profileFullStorage.allKeys()?.forEach { key ->
|
||||
decodeServerConfig(key)?.let { config ->
|
||||
if (config.subscriptionId == subid) {
|
||||
removeServer(key)
|
||||
}
|
||||
fun removeServerViaSubid(subscriptionId: String?) {
|
||||
val subId = getSubscriptionId(subscriptionId)
|
||||
val serverList = decodeServerList(subId)
|
||||
|
||||
// Remove all servers in the list
|
||||
serverList.forEach { guid ->
|
||||
if (getSelectServer() == guid) {
|
||||
mainStorage.remove(KEY_SELECTED_SERVER)
|
||||
}
|
||||
profileFullStorage.remove(guid)
|
||||
serverAffStorage.remove(guid)
|
||||
}
|
||||
|
||||
serverList.clear()
|
||||
encodeServerList(serverList, subId)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -233,7 +282,6 @@ object MmkvManager {
|
||||
val count = profileFullStorage.allKeys()?.count() ?: 0
|
||||
mainStorage.clearAll()
|
||||
profileFullStorage.clearAll()
|
||||
//profileStorage.clearAll()
|
||||
serverAffStorage.clearAll()
|
||||
return count
|
||||
}
|
||||
@@ -290,6 +338,10 @@ object MmkvManager {
|
||||
|
||||
//region Subscriptions
|
||||
|
||||
private fun getSubscriptionId(subscriptionId: String?):String {
|
||||
return subscriptionId?.ifEmpty { DEFAULT_SUBSCRIPTION_ID } ?: DEFAULT_SUBSCRIPTION_ID
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the subscription list.
|
||||
*/
|
||||
@@ -309,15 +361,15 @@ object MmkvManager {
|
||||
*
|
||||
* @return The list of subscriptions.
|
||||
*/
|
||||
fun decodeSubscriptions(): List<Pair<String, SubscriptionItem>> {
|
||||
fun decodeSubscriptions(): List<SubscriptionCache> {
|
||||
initSubsList()
|
||||
|
||||
val subscriptions = mutableListOf<Pair<String, SubscriptionItem>>()
|
||||
val subscriptions = mutableListOf<SubscriptionCache>()
|
||||
decodeSubsList().forEach { key ->
|
||||
val json = subStorage.decodeString(key)
|
||||
if (!json.isNullOrBlank()) {
|
||||
val item = JsonUtil.fromJson(json, SubscriptionItem::class.java)?: SubscriptionItem()
|
||||
subscriptions.add(Pair(key, item))
|
||||
subscriptions.add(SubscriptionCache(key, item))
|
||||
}
|
||||
}
|
||||
return subscriptions
|
||||
@@ -329,6 +381,11 @@ object MmkvManager {
|
||||
* @param subid The subscription ID.
|
||||
*/
|
||||
fun removeSubscription(subid: String) {
|
||||
// Protect default subscription from being deleted
|
||||
if (subid == DEFAULT_SUBSCRIPTION_ID) {
|
||||
return
|
||||
}
|
||||
|
||||
subStorage.remove(subid)
|
||||
val subsList = decodeSubsList()
|
||||
subsList.remove(subid)
|
||||
@@ -397,16 +454,16 @@ object MmkvManager {
|
||||
*
|
||||
* @return The list of asset URLs.
|
||||
*/
|
||||
fun decodeAssetUrls(): List<Pair<String, AssetUrlItem>> {
|
||||
val assetUrlItems = mutableListOf<Pair<String, AssetUrlItem>>()
|
||||
fun decodeAssetUrls(): List<AssetUrlCache> {
|
||||
val assetUrlItems = mutableListOf<AssetUrlCache>()
|
||||
assetStorage.allKeys()?.forEach { key ->
|
||||
val json = assetStorage.decodeString(key)
|
||||
if (!json.isNullOrBlank()) {
|
||||
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 }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,6 +9,7 @@ import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.Color
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.core.app.NotificationCompat
|
||||
import com.v2ray.ang.AppConfig
|
||||
@@ -30,6 +31,7 @@ object NotificationManager {
|
||||
private const val NOTIFICATION_PENDING_INTENT_STOP_V2RAY = 1
|
||||
private const val NOTIFICATION_PENDING_INTENT_RESTART_V2RAY = 2
|
||||
private const val NOTIFICATION_ICON_THRESHOLD = 3000
|
||||
private const val QUERY_INTERVAL_MS = 3000L
|
||||
|
||||
private var lastQueryTime = 0L
|
||||
private var mBuilder: NotificationCompat.Builder? = null
|
||||
@@ -44,7 +46,6 @@ object NotificationManager {
|
||||
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_SPEED_ENABLED) != true) return
|
||||
if (speedNotificationJob != null || V2RayServiceManager.isRunning() == false) return
|
||||
|
||||
lastQueryTime = System.currentTimeMillis()
|
||||
var lastZeroSpeed = false
|
||||
val outboundTags = currentConfig?.getAllOutboundTags()
|
||||
outboundTags?.remove(AppConfig.TAG_DIRECT)
|
||||
@@ -52,7 +53,17 @@ object NotificationManager {
|
||||
speedNotificationJob = CoroutineScope(Dispatchers.IO).launch {
|
||||
while (isActive) {
|
||||
val queryTime = System.currentTimeMillis()
|
||||
val sinceLastQueryInSeconds = (queryTime - lastQueryTime) / 1000.0
|
||||
val sinceLastQueryIn = (queryTime - lastQueryTime)
|
||||
|
||||
// If the query interval is too short, skip this round to avoid excessive CPU usage
|
||||
if (sinceLastQueryIn < QUERY_INTERVAL_MS) {
|
||||
Log.w(AppConfig.TAG, "Query interval too short: ${sinceLastQueryIn}ms, skipping")
|
||||
lastQueryTime = queryTime
|
||||
delay(QUERY_INTERVAL_MS)
|
||||
continue
|
||||
}
|
||||
val sinceLastQueryInSeconds = sinceLastQueryIn / 1000.0
|
||||
|
||||
var proxyTotal = 0L
|
||||
val text = StringBuilder()
|
||||
outboundTags?.forEach {
|
||||
@@ -78,7 +89,7 @@ object NotificationManager {
|
||||
}
|
||||
lastZeroSpeed = zeroSpeed
|
||||
lastQueryTime = queryTime
|
||||
delay(3000)
|
||||
delay(QUERY_INTERVAL_MS)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -89,6 +100,10 @@ object NotificationManager {
|
||||
*/
|
||||
fun showNotification(currentConfig: ProfileItem?) {
|
||||
val service = getService() ?: return
|
||||
|
||||
// Reset last query time to avoid querying stats too soon after showing the notification
|
||||
lastQueryTime = System.currentTimeMillis()
|
||||
|
||||
val flags = PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||
|
||||
val startMainIntent = Intent(service, MainActivity::class.java)
|
||||
|
||||
@@ -7,18 +7,24 @@ import android.util.Log
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.AppConfig.ANG_PACKAGE
|
||||
import com.v2ray.ang.AppConfig.DEFAULT_SUBSCRIPTION_ID
|
||||
import com.v2ray.ang.AppConfig.GEOIP_PRIVATE
|
||||
import com.v2ray.ang.AppConfig.GEOSITE_PRIVATE
|
||||
import com.v2ray.ang.AppConfig.TAG_DIRECT
|
||||
import com.v2ray.ang.dto.EConfigType
|
||||
import com.v2ray.ang.dto.Language
|
||||
import com.v2ray.ang.AppConfig.VPN
|
||||
import com.v2ray.ang.dto.ProfileItem
|
||||
import com.v2ray.ang.dto.RoutingType
|
||||
import com.v2ray.ang.dto.RulesetItem
|
||||
import com.v2ray.ang.dto.SubscriptionItem
|
||||
import com.v2ray.ang.dto.V2rayConfig
|
||||
import com.v2ray.ang.dto.VpnInterfaceAddressConfig
|
||||
import com.v2ray.ang.enums.EConfigType
|
||||
import com.v2ray.ang.enums.Language
|
||||
import com.v2ray.ang.enums.RoutingType
|
||||
import com.v2ray.ang.enums.VpnInterfaceAddressConfig
|
||||
import com.v2ray.ang.handler.MmkvManager.decodeAllServerList
|
||||
import com.v2ray.ang.handler.MmkvManager.decodeServerConfig
|
||||
import com.v2ray.ang.handler.MmkvManager.decodeServerList
|
||||
import com.v2ray.ang.handler.MmkvManager.decodeSubsList
|
||||
import com.v2ray.ang.handler.MmkvManager.decodeSubscription
|
||||
import com.v2ray.ang.handler.MmkvManager.encodeSubscription
|
||||
import com.v2ray.ang.util.JsonUtil
|
||||
import com.v2ray.ang.util.Utils
|
||||
import java.io.File
|
||||
@@ -28,11 +34,19 @@ import java.util.Locale
|
||||
|
||||
object SettingsManager {
|
||||
|
||||
fun initApp(context: Context) {
|
||||
ensureDefaultSettings()
|
||||
ensureDefaultSubscription()
|
||||
initRoutingRulesets(context)
|
||||
migrateServerListToSubscriptions()
|
||||
migrateHysteria2PinSHA256()
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize routing rulesets.
|
||||
* @param context The application context.
|
||||
*/
|
||||
fun initRoutingRulesets(context: Context) {
|
||||
private fun initRoutingRulesets(context: Context) {
|
||||
val exist = MmkvManager.decodeRoutingRulesets()
|
||||
if (exist.isNullOrEmpty()) {
|
||||
val rulesetList = getPresetRoutingRulesets(context)
|
||||
@@ -220,14 +234,10 @@ object SettingsManager {
|
||||
if (remarks.isNullOrEmpty()) {
|
||||
return null
|
||||
}
|
||||
val serverList = decodeServerList()
|
||||
for (guid in serverList) {
|
||||
val profile = decodeServerConfig(guid)
|
||||
if (profile != null && profile.remarks == remarks) {
|
||||
return profile
|
||||
}
|
||||
}
|
||||
return null
|
||||
val serverList = decodeAllServerList()
|
||||
return serverList
|
||||
.mapNotNull { guid -> decodeServerConfig(guid) }
|
||||
.firstOrNull { it.remarks == remarks }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -386,10 +396,19 @@ object SettingsManager {
|
||||
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.
|
||||
*/
|
||||
fun ensureDefaultSettings() {
|
||||
private fun ensureDefaultSettings() {
|
||||
// Write defaults in the exact order requested by the user
|
||||
ensureDefaultValue(AppConfig.PREF_MODE, AppConfig.VPN)
|
||||
ensureDefaultValue(AppConfig.PREF_VPN_DNS, AppConfig.DNS_VPN)
|
||||
@@ -412,4 +431,100 @@ object SettingsManager {
|
||||
MmkvManager.encodeSettings(key, default)
|
||||
}
|
||||
}
|
||||
|
||||
private fun migrateHysteria2PinSHA256() {
|
||||
// Check if migration has already been done
|
||||
val migrationKey = "hysteria2_pin_sha256_migrated"
|
||||
if (MmkvManager.decodeSettingsBool(migrationKey, false)) {
|
||||
return
|
||||
}
|
||||
|
||||
val serverList = decodeAllServerList()
|
||||
|
||||
for (guid in serverList) {
|
||||
val profile = decodeServerConfig(guid) ?: continue
|
||||
if (profile.configType != EConfigType.HYSTERIA2) {
|
||||
continue
|
||||
}
|
||||
if (profile.pinSHA256.isNullOrEmpty() || !profile.pinnedCA256.isNullOrEmpty()) {
|
||||
continue
|
||||
}
|
||||
profile.pinnedCA256 = profile.pinSHA256
|
||||
profile.pinSHA256 = null
|
||||
MmkvManager.encodeServerConfig(guid, profile)
|
||||
}
|
||||
|
||||
MmkvManager.encodeSettings(migrationKey, true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrates server list from legacy KEY_ANG_CONFIGS to subscription-based storage.
|
||||
* This method should be called once during app initialization after the storage structure change.
|
||||
* Servers are grouped by their subscriptionId into respective subscription's serverList.
|
||||
* Servers without subscription are moved to the default subscription.
|
||||
* After migration, KEY_ANG_CONFIGS is removed.
|
||||
*/
|
||||
private fun migrateServerListToSubscriptions() {
|
||||
// Check if migration has already been done
|
||||
val migrationKey = "server_list_to_subscriptions_migrated"
|
||||
if (MmkvManager.decodeSettingsBool(migrationKey, false)) {
|
||||
return
|
||||
}
|
||||
|
||||
// Ensure default subscription exists before migration
|
||||
ensureDefaultSubscription()
|
||||
|
||||
// Read existing server list from legacy KEY_ANG_CONFIGS
|
||||
val oldJson = MmkvManager.readLegacyServerList()
|
||||
if (oldJson.isNullOrBlank()) {
|
||||
// No data to migrate, mark as done
|
||||
MmkvManager.encodeSettings(migrationKey, true)
|
||||
return
|
||||
}
|
||||
|
||||
val guids = JsonUtil.fromJson(oldJson, Array<String>::class.java) ?: run {
|
||||
MmkvManager.encodeSettings(migrationKey, true)
|
||||
return
|
||||
}
|
||||
|
||||
val subscriptionServerMap = mutableMapOf<String, MutableList<String>>()
|
||||
|
||||
// Group servers by subscription (use default subscription for empty subscriptionId)
|
||||
guids.forEach { guid ->
|
||||
val config = decodeServerConfig(guid) ?: return@forEach
|
||||
val subId = config.subscriptionId.ifEmpty { DEFAULT_SUBSCRIPTION_ID }
|
||||
|
||||
subscriptionServerMap.getOrPut(subId) { mutableListOf() }.add(guid)
|
||||
}
|
||||
|
||||
// Update each subscription's serverList (including default subscription)
|
||||
subscriptionServerMap.forEach { (subId, serverGuids) ->
|
||||
MmkvManager.encodeServerList(serverGuids, subId)
|
||||
}
|
||||
|
||||
|
||||
// Mark migration as complete
|
||||
MmkvManager.encodeSettings(migrationKey, true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures the default subscription exists for ungrouped servers.
|
||||
* This subscription is used internally to store servers without a subscription.
|
||||
* Made public for migration in SettingsManager.
|
||||
*/
|
||||
private fun ensureDefaultSubscription() {
|
||||
if (decodeSubscription(DEFAULT_SUBSCRIPTION_ID) == null) {
|
||||
val defaultSub = SubscriptionItem(
|
||||
remarks = "Default",
|
||||
)
|
||||
encodeSubscription(DEFAULT_SUBSCRIPTION_ID, defaultSub)
|
||||
|
||||
// Move top
|
||||
val subsList = decodeSubsList()
|
||||
if (subsList.count() > 1) {
|
||||
swapSubscriptions(0, subsList.count() - 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -36,10 +36,10 @@ object SubscriptionUpdater {
|
||||
override suspend fun doWork(): Result {
|
||||
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) {
|
||||
val subItem = sub.second
|
||||
val subItem = sub.subscription
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
notification.setChannelId(AppConfig.SUBSCRIPTION_UPDATE_CHANNEL)
|
||||
@@ -53,7 +53,7 @@ object SubscriptionUpdater {
|
||||
}
|
||||
notificationManager.notify(3, notification.build())
|
||||
Log.i(AppConfig.TAG, "subscription automatic update: ---${subItem.remarks}")
|
||||
AngConfigManager.updateConfigViaSub(Pair(sub.first, subItem))
|
||||
AngConfigManager.updateConfigViaSub(sub)
|
||||
notification.setContentText("Updating ${subItem.remarks}")
|
||||
}
|
||||
notificationManager.cancel(3)
|
||||
|
||||
@@ -10,10 +10,10 @@ import android.util.Log
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.dto.EConfigType
|
||||
import com.v2ray.ang.contracts.ServiceControl
|
||||
import com.v2ray.ang.dto.ProfileItem
|
||||
import com.v2ray.ang.enums.EConfigType
|
||||
import com.v2ray.ang.extension.toast
|
||||
import com.v2ray.ang.service.ServiceControl
|
||||
import com.v2ray.ang.service.V2RayProxyOnlyService
|
||||
import com.v2ray.ang.service.V2RayVpnService
|
||||
import com.v2ray.ang.util.MessageUtil
|
||||
@@ -108,7 +108,7 @@ object V2RayServiceManager {
|
||||
} else {
|
||||
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)
|
||||
} else {
|
||||
Intent(context.applicationContext, V2RayProxyOnlyService::class.java)
|
||||
@@ -151,6 +151,7 @@ object V2RayServiceManager {
|
||||
}
|
||||
|
||||
try {
|
||||
NotificationManager.showNotification(currentConfig)
|
||||
coreController.startLoop(result.content, tunFd)
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Failed to start Core loop", e)
|
||||
@@ -165,7 +166,7 @@ object V2RayServiceManager {
|
||||
|
||||
try {
|
||||
MessageUtil.sendMsg2UI(service, AppConfig.MSG_STATE_START_SUCCESS, "")
|
||||
NotificationManager.showNotification(currentConfig)
|
||||
//NotificationManager.showNotification(currentConfig)
|
||||
NotificationManager.startSpeedNotification(currentConfig)
|
||||
|
||||
} catch (e: Exception) {
|
||||
|
||||
@@ -6,8 +6,6 @@ import android.util.Log
|
||||
import com.google.gson.JsonArray
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.dto.ConfigResult
|
||||
import com.v2ray.ang.dto.EConfigType
|
||||
import com.v2ray.ang.dto.NetworkType
|
||||
import com.v2ray.ang.dto.ProfileItem
|
||||
import com.v2ray.ang.dto.RulesetItem
|
||||
import com.v2ray.ang.dto.V2rayConfig
|
||||
@@ -15,7 +13,10 @@ import com.v2ray.ang.dto.V2rayConfig.OutboundBean
|
||||
import com.v2ray.ang.dto.V2rayConfig.OutboundBean.OutSettingsBean
|
||||
import com.v2ray.ang.dto.V2rayConfig.OutboundBean.StreamSettingsBean
|
||||
import com.v2ray.ang.dto.V2rayConfig.RoutingBean.RulesBean
|
||||
import com.v2ray.ang.enums.EConfigType
|
||||
import com.v2ray.ang.enums.NetworkType
|
||||
import com.v2ray.ang.extension.isNotNullEmpty
|
||||
import com.v2ray.ang.extension.nullIfBlank
|
||||
import com.v2ray.ang.fmt.HttpFmt
|
||||
import com.v2ray.ang.fmt.Hysteria2Fmt
|
||||
import com.v2ray.ang.fmt.ShadowsocksFmt
|
||||
@@ -91,7 +92,7 @@ object V2rayConfigManager {
|
||||
private fun getV2rayCustomConfig(context: Context, guid: String, config: ProfileItem): ConfigResult {
|
||||
val raw = MmkvManager.decodeServerRaw(guid) ?: return ConfigResult(false)
|
||||
val result = ConfigResult(true, guid, raw)
|
||||
if (SettingsManager.isUsingHevTun()) {
|
||||
if (!needTun()) {
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -138,7 +139,7 @@ object V2rayConfigManager {
|
||||
private fun getV2rayGroupConfig(context: Context, guid: String, config: ProfileItem): ConfigResult {
|
||||
val result = ConfigResult(false)
|
||||
|
||||
val serverList = MmkvManager.decodeServerList()
|
||||
val serverList = MmkvManager.decodeAllServerList()
|
||||
val configList = serverList
|
||||
.mapNotNull { id -> MmkvManager.decodeServerConfig(id) }
|
||||
.filter { profile ->
|
||||
@@ -334,18 +335,18 @@ object V2rayConfigManager {
|
||||
*/
|
||||
private fun initV2rayConfig(context: Context): V2rayConfig? {
|
||||
var assets = ""
|
||||
if (SettingsManager.isUsingHevTun()) {
|
||||
assets = initConfigCache ?: Utils.readTextFromAssets(context, "v2ray_config.json")
|
||||
if (TextUtils.isEmpty(assets)) {
|
||||
return null
|
||||
}
|
||||
initConfigCache = assets
|
||||
} else {
|
||||
if (needTun()) {
|
||||
assets = initConfigCacheWithTun ?: Utils.readTextFromAssets(context, "v2ray_config_with_tun.json")
|
||||
if (TextUtils.isEmpty(assets)) {
|
||||
return null
|
||||
}
|
||||
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)
|
||||
return config
|
||||
@@ -357,6 +358,10 @@ object V2rayConfigManager {
|
||||
|
||||
//region some sub function
|
||||
|
||||
private fun needTun(): Boolean {
|
||||
return SettingsManager.isVpnMode() && !SettingsManager.isUsingHevTun()
|
||||
}
|
||||
|
||||
/**
|
||||
* Configures the inbound settings for V2ray.
|
||||
*
|
||||
@@ -395,9 +400,10 @@ object V2rayConfigManager {
|
||||
v2rayConfig.inbounds.add(inbound2)
|
||||
}
|
||||
|
||||
if (!SettingsManager.isUsingHevTun()) {
|
||||
if (needTun()) {
|
||||
val inboundTun = v2rayConfig.inbounds.firstOrNull { e -> e.tag == "tun" }
|
||||
inboundTun?.settings?.mtu = SettingsManager.getVpnMtu()
|
||||
inboundTun?.sniffing = inbound1.sniffing
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Failed to configure inbounds", e)
|
||||
@@ -519,23 +525,25 @@ object V2rayConfigManager {
|
||||
)
|
||||
}
|
||||
|
||||
if (SettingsManager.isUsingHevTun()) {
|
||||
//hev-socks5-tunnel dns routing
|
||||
v2rayConfig.routing.rules.add(
|
||||
0, RulesBean(
|
||||
inboundTag = arrayListOf("socks"),
|
||||
outboundTag = "dns-out",
|
||||
port = "53",
|
||||
if(SettingsManager.isVpnMode()) {
|
||||
if (SettingsManager.isUsingHevTun()) {
|
||||
//hev-socks5-tunnel dns routing
|
||||
v2rayConfig.routing.rules.add(
|
||||
0, RulesBean(
|
||||
inboundTag = arrayListOf("socks"),
|
||||
outboundTag = "dns-out",
|
||||
port = "53",
|
||||
)
|
||||
)
|
||||
)
|
||||
} else {
|
||||
v2rayConfig.routing.rules.add(
|
||||
0, RulesBean(
|
||||
inboundTag = arrayListOf("tun"),
|
||||
outboundTag = "dns-out",
|
||||
port = "53",
|
||||
} else {
|
||||
v2rayConfig.routing.rules.add(
|
||||
0, RulesBean(
|
||||
inboundTag = arrayListOf("tun"),
|
||||
outboundTag = "dns-out",
|
||||
port = "53",
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// DNS outbound
|
||||
@@ -768,13 +776,14 @@ object V2rayConfigManager {
|
||||
|| protocol.equals(EConfigType.TROJAN.name, true)
|
||||
|| protocol.equals(EConfigType.WIREGUARD.name, true)
|
||||
|| protocol.equals(EConfigType.HYSTERIA2.name, true)
|
||||
|| protocol.equals(EConfigType.HYSTERIA.name, true)
|
||||
) {
|
||||
muxEnabled = false
|
||||
} else if (outbound.streamSettings?.network == NetworkType.XHTTP.type) {
|
||||
muxEnabled = false
|
||||
}
|
||||
|
||||
if (muxEnabled == true) {
|
||||
if (muxEnabled) {
|
||||
outbound.mux?.enabled = true
|
||||
outbound.mux?.concurrency = MmkvManager.decodeSettingsString(AppConfig.PREF_MUX_CONCURRENCY, "8").orEmpty().toInt()
|
||||
outbound.mux?.xudpConcurrency = MmkvManager.decodeSettingsString(AppConfig.PREF_MUX_XUDP_CONCURRENCY, "16").orEmpty().toInt()
|
||||
@@ -833,65 +842,93 @@ object V2rayConfigManager {
|
||||
* Configures load balancing settings for the V2ray configuration.
|
||||
*
|
||||
* @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) {
|
||||
try {
|
||||
v2rayConfig.routing.rules.forEach { rule ->
|
||||
if (rule.outboundTag == "proxy") {
|
||||
rule.outboundTag = null
|
||||
rule.balancerTag = "proxy-round"
|
||||
rule.balancerTag = AppConfig.TAG_BALANCER
|
||||
}
|
||||
}
|
||||
|
||||
if (config.policyGroupType == "0") {
|
||||
val balancer = V2rayConfig.RoutingBean.BalancerBean(
|
||||
tag = "proxy-round",
|
||||
selector = listOf("proxy-"),
|
||||
strategy = V2rayConfig.RoutingBean.StrategyObject(
|
||||
type = "leastPing"
|
||||
val lstSelector = listOf("proxy-")
|
||||
when (config.policyGroupType) {
|
||||
// Least Ping goto else
|
||||
"1" -> {
|
||||
// Least Load
|
||||
val balancer = V2rayConfig.RoutingBean.BalancerBean(
|
||||
tag = AppConfig.TAG_BALANCER,
|
||||
selector = lstSelector,
|
||||
strategy = V2rayConfig.RoutingBean.StrategyObject(
|
||||
type = "leastLoad"
|
||||
)
|
||||
)
|
||||
)
|
||||
v2rayConfig.routing.balancers = listOf(balancer)
|
||||
v2rayConfig.observatory = V2rayConfig.ObservatoryObject(
|
||||
subjectSelector = listOf("proxy-"),
|
||||
probeUrl = MmkvManager.decodeSettingsString(AppConfig.PREF_DELAY_TEST_URL) ?: AppConfig.DELAY_TEST_URL,
|
||||
probeInterval = "3m",
|
||||
enableConcurrency = true
|
||||
)
|
||||
} else {
|
||||
val balancer = V2rayConfig.RoutingBean.BalancerBean(
|
||||
tag = "proxy-round",
|
||||
selector = listOf("proxy-"),
|
||||
strategy = V2rayConfig.RoutingBean.StrategyObject(
|
||||
type = "leastLoad"
|
||||
v2rayConfig.routing.balancers = listOf(balancer)
|
||||
v2rayConfig.burstObservatory = V2rayConfig.BurstObservatoryObject(
|
||||
subjectSelector = lstSelector,
|
||||
pingConfig = V2rayConfig.BurstObservatoryObject.PingConfigObject(
|
||||
destination = MmkvManager.decodeSettingsString(AppConfig.PREF_DELAY_TEST_URL) ?: AppConfig.DELAY_TEST_URL,
|
||||
interval = "5m",
|
||||
sampling = 2,
|
||||
timeout = "30s"
|
||||
)
|
||||
)
|
||||
)
|
||||
v2rayConfig.routing.balancers = listOf(balancer)
|
||||
v2rayConfig.burstObservatory = V2rayConfig.BurstObservatoryObject(
|
||||
subjectSelector = listOf("proxy-"),
|
||||
pingConfig = V2rayConfig.BurstObservatoryObject.PingConfigObject(
|
||||
destination = MmkvManager.decodeSettingsString(AppConfig.PREF_DELAY_TEST_URL) ?: AppConfig.DELAY_TEST_URL,
|
||||
interval = "5m",
|
||||
sampling = 2,
|
||||
timeout = "30s"
|
||||
}
|
||||
"2" -> {
|
||||
// Random
|
||||
val balancer = V2rayConfig.RoutingBean.BalancerBean(
|
||||
tag = AppConfig.TAG_BALANCER,
|
||||
selector = lstSelector,
|
||||
strategy = V2rayConfig.RoutingBean.StrategyObject(
|
||||
type = "random"
|
||||
)
|
||||
)
|
||||
)
|
||||
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") {
|
||||
v2rayConfig.routing.rules.add(
|
||||
RulesBean(
|
||||
ip = arrayListOf("0.0.0.0/0", "::/0"),
|
||||
balancerTag = "proxy-round",
|
||||
type = "field"
|
||||
balancerTag = AppConfig.TAG_BALANCER,
|
||||
)
|
||||
)
|
||||
} else {
|
||||
v2rayConfig.routing.rules.add(
|
||||
RulesBean(
|
||||
network = "tcp,udp",
|
||||
balancerTag = "proxy-round",
|
||||
type = "field"
|
||||
balancerTag = AppConfig.TAG_BALANCER,
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -1147,19 +1184,39 @@ object V2rayConfigManager {
|
||||
}
|
||||
|
||||
NetworkType.KCP.type -> {
|
||||
val kcpsetting = StreamSettingsBean.KcpSettingsBean()
|
||||
kcpsetting.header.type = headerType ?: "none"
|
||||
streamSettings.kcpSettings = StreamSettingsBean.KcpSettingsBean()
|
||||
val udpMaskList = mutableListOf<StreamSettingsBean.FinalMaskBean.MaskBean>()
|
||||
if (!headerType.isNullOrEmpty() && headerType != "none") {
|
||||
val kcpHeaderType = when {
|
||||
headerType == "wechat-video" -> "header-wechat"
|
||||
else -> "header-$headerType"
|
||||
}
|
||||
udpMaskList.add(StreamSettingsBean.FinalMaskBean.MaskBean(
|
||||
type = kcpHeaderType,
|
||||
settings = if (headerType == "dns" && !host.isNullOrEmpty()) {
|
||||
StreamSettingsBean.FinalMaskBean.MaskBean.MaskSettingsBean(
|
||||
domain = host
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
))
|
||||
}
|
||||
if (seed.isNullOrEmpty()) {
|
||||
kcpsetting.seed = null
|
||||
udpMaskList.add(StreamSettingsBean.FinalMaskBean.MaskBean(
|
||||
type = "mkcp-original"
|
||||
))
|
||||
} else {
|
||||
kcpsetting.seed = seed
|
||||
udpMaskList.add(StreamSettingsBean.FinalMaskBean.MaskBean(
|
||||
type = "mkcp-aes128gcm",
|
||||
settings = StreamSettingsBean.FinalMaskBean.MaskBean.MaskSettingsBean(
|
||||
password = seed
|
||||
)
|
||||
))
|
||||
}
|
||||
if (host.isNullOrEmpty()) {
|
||||
kcpsetting.header.domain = null
|
||||
} else {
|
||||
kcpsetting.header.domain = host
|
||||
}
|
||||
streamSettings.kcpSettings = kcpsetting
|
||||
streamSettings.finalmask = StreamSettingsBean.FinalMaskBean(
|
||||
udp = udpMaskList.toList()
|
||||
)
|
||||
}
|
||||
|
||||
NetworkType.WS.type -> {
|
||||
@@ -1261,28 +1318,21 @@ object V2rayConfigManager {
|
||||
} else {
|
||||
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
|
||||
val tlsSetting = StreamSettingsBean.TlsSettingsBean(
|
||||
allowInsecure = allowInsecure,
|
||||
serverName = if (sni.isNullOrEmpty()) null else sni,
|
||||
fingerprint = if (fingerprint.isNullOrEmpty()) null else fingerprint,
|
||||
alpn = if (alpns.isNullOrEmpty()) null else alpns.split(",").map { it.trim() }.filter { it.isNotEmpty() },
|
||||
echConfigList = if (echConfigList.isNullOrEmpty()) null else echConfigList,
|
||||
echForceQuery = if (echForceQuery.isNullOrEmpty()) null else echForceQuery,
|
||||
publicKey = if (publicKey.isNullOrEmpty()) null else publicKey,
|
||||
shortId = if (shortId.isNullOrEmpty()) null else shortId,
|
||||
spiderX = if (spiderX.isNullOrEmpty()) null else spiderX,
|
||||
mldsa65Verify = if (mldsa65Verify.isNullOrEmpty()) null else mldsa65Verify,
|
||||
serverName = sni.nullIfBlank(),
|
||||
fingerprint = profileItem.fingerPrint.nullIfBlank(),
|
||||
alpn = profileItem.alpn?.split(",")?.map { it.trim() }?.filter { it.isNotEmpty() }.takeIf { !it.isNullOrEmpty() },
|
||||
echConfigList = profileItem.echConfigList.nullIfBlank(),
|
||||
echForceQuery = profileItem.echForceQuery.nullIfBlank(),
|
||||
pinnedPeerCertSha256 = profileItem.pinnedCA256.nullIfBlank(),
|
||||
publicKey = profileItem.publicKey.nullIfBlank(),
|
||||
shortId = profileItem.shortId.nullIfBlank(),
|
||||
spiderX = profileItem.spiderX.nullIfBlank(),
|
||||
mldsa65Verify = profileItem.mldsa65Verify.nullIfBlank(),
|
||||
)
|
||||
if (streamSettings.security == AppConfig.TLS) {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
-1
@@ -1,7 +1,10 @@
|
||||
package com.v2ray.ang.handler
|
||||
package com.v2ray.ang.helper
|
||||
|
||||
import androidx.preference.PreferenceDataStore
|
||||
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.
|
||||
@@ -76,5 +79,6 @@ class MmkvPreferenceDataStore : PreferenceDataStore() {
|
||||
}
|
||||
// Notify listeners that require service restart or reinit
|
||||
SettingsChangeManager.makeRestartService()
|
||||
SettingsChangeManager.makeSetupGroupTab()
|
||||
}
|
||||
}
|
||||
@@ -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.util.Log
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.contracts.Tun2SocksControl
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.handler.SettingsManager
|
||||
import java.io.File
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.IBinder
|
||||
import com.v2ray.ang.contracts.ServiceControl
|
||||
import com.v2ray.ang.handler.SettingsManager
|
||||
import com.v2ray.ang.handler.V2RayServiceManager
|
||||
import com.v2ray.ang.util.MyContextWrapper
|
||||
|
||||
@@ -6,7 +6,7 @@ import android.os.IBinder
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.AppConfig.MSG_MEASURE_CONFIG
|
||||
import com.v2ray.ang.AppConfig.MSG_MEASURE_CONFIG_CANCEL
|
||||
import com.v2ray.ang.extension.serializable
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.handler.V2RayNativeManager
|
||||
import com.v2ray.ang.util.MessageUtil
|
||||
import java.util.Collections
|
||||
@@ -54,8 +54,14 @@ class V2RayTestService : Service() {
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
when (intent?.getIntExtra("key", 0)) {
|
||||
MSG_MEASURE_CONFIG -> {
|
||||
val guidsList = intent.serializable<ArrayList<String>>("content")
|
||||
if (guidsList != null && guidsList.isNotEmpty()) {
|
||||
val subscriptionId = intent.getStringExtra("content").orEmpty()
|
||||
val guidsList = if (subscriptionId.isEmpty()) {
|
||||
MmkvManager.decodeAllServerList()
|
||||
} else {
|
||||
MmkvManager.decodeServerList(subscriptionId)
|
||||
}
|
||||
|
||||
if (guidsList.isNotEmpty()) {
|
||||
lateinit var worker: RealPingWorkerService
|
||||
worker = RealPingWorkerService(this, guidsList) { status ->
|
||||
// notify UI and remove the worker from active list when finished
|
||||
|
||||
@@ -18,6 +18,8 @@ import androidx.annotation.RequiresApi
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.AppConfig.LOOPBACK
|
||||
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.NotificationManager
|
||||
import com.v2ray.ang.handler.SettingsManager
|
||||
@@ -76,7 +78,7 @@ class V2RayVpnService : VpnService(), ServiceControl {
|
||||
}
|
||||
|
||||
override fun onRevoke() {
|
||||
stopV2Ray()
|
||||
stopAllService()
|
||||
}
|
||||
|
||||
// override fun onLowMemory() {
|
||||
@@ -105,11 +107,15 @@ class V2RayVpnService : VpnService(), ServiceControl {
|
||||
Log.e(AppConfig.TAG, "Failed to create VPN interface")
|
||||
return
|
||||
}
|
||||
V2RayServiceManager.startCoreLoop(mInterface)
|
||||
if (!V2RayServiceManager.startCoreLoop(mInterface)) {
|
||||
Log.e(AppConfig.TAG, "Failed to start V2Ray core loop")
|
||||
stopAllService()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
override fun stopService() {
|
||||
stopV2Ray(true)
|
||||
stopAllService(true)
|
||||
}
|
||||
|
||||
override fun vpnProtect(socket: Int): Boolean {
|
||||
@@ -130,10 +136,14 @@ class V2RayVpnService : VpnService(), ServiceControl {
|
||||
private fun setupVpnService() {
|
||||
val prepare = prepare(this)
|
||||
if (prepare != null) {
|
||||
Log.e(AppConfig.TAG, "VPN preparation failed")
|
||||
stopSelf()
|
||||
return
|
||||
}
|
||||
|
||||
if (configureVpnService() != true) {
|
||||
Log.e(AppConfig.TAG, "VPN configuration failed")
|
||||
stopSelf()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -170,7 +180,7 @@ class V2RayVpnService : VpnService(), ServiceControl {
|
||||
return true
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Failed to establish VPN interface", e)
|
||||
stopV2Ray()
|
||||
stopAllService()
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -311,11 +321,7 @@ class V2RayVpnService : VpnService(), ServiceControl {
|
||||
tun2SocksService?.startTun2Socks()
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the V2Ray service.
|
||||
* @param isForced Whether to force stop the service.
|
||||
*/
|
||||
private fun stopV2Ray(isForced: Boolean = true) {
|
||||
private fun stopAllService(isForced: Boolean = true) {
|
||||
// val configName = defaultDPreference.getPrefString(PREF_CURR_CONFIG_GUID, "")
|
||||
// val emptyInfo = VpnNetworkInfo()
|
||||
// val info = loadVpnNetworkInfo(configName, emptyInfo)!! + (lastNetworkInfo ?: emptyInfo)
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
package com.v2ray.ang.ui
|
||||
|
||||
import android.Manifest
|
||||
import android.app.AlertDialog
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.tencent.mmkv.MMKV
|
||||
@@ -20,11 +14,11 @@ import com.v2ray.ang.R
|
||||
import com.v2ray.ang.databinding.ActivityBackupBinding
|
||||
import com.v2ray.ang.databinding.DialogWebdavBinding
|
||||
import com.v2ray.ang.dto.WebDavConfig
|
||||
import com.v2ray.ang.extension.toast
|
||||
import com.v2ray.ang.extension.toastError
|
||||
import com.v2ray.ang.extension.toastSuccess
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.handler.SettingsChangeManager
|
||||
import com.v2ray.ang.handler.SettingsManager
|
||||
import com.v2ray.ang.handler.WebDavManager
|
||||
import com.v2ray.ang.util.ZipUtil
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -34,50 +28,13 @@ import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
|
||||
class BackupActivity : BaseActivity() {
|
||||
class BackupActivity : HelperBaseActivity() {
|
||||
private val binding by lazy { ActivityBackupBinding.inflate(layoutInflater) }
|
||||
|
||||
private val config_backup_options: Array<out String> by lazy {
|
||||
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?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
@@ -168,46 +125,35 @@ class BackupActivity : BaseActivity() {
|
||||
val count = MMKV.restoreAllFromDirectory(backupDir)
|
||||
SettingsChangeManager.makeSetupGroupTab()
|
||||
SettingsChangeManager.makeRestartService()
|
||||
|
||||
SettingsManager.initApp(this)
|
||||
return count > 0
|
||||
}
|
||||
|
||||
private fun showFileChooser() {
|
||||
val intent = Intent(Intent.ACTION_GET_CONTENT).apply {
|
||||
type = "*/*"
|
||||
addCategory(Intent.CATEGORY_OPENABLE)
|
||||
}
|
||||
|
||||
try {
|
||||
chooseFile.launch(Intent.createChooser(intent, getString(R.string.title_file_chooser)))
|
||||
} catch (ex: ActivityNotFoundException) {
|
||||
Log.e(AppConfig.TAG, "File chooser activity not found", ex)
|
||||
toast(R.string.toast_require_file_manager)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
launchFileChooser { uri ->
|
||||
if (uri == null) {
|
||||
return@launchFileChooser
|
||||
}
|
||||
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)
|
||||
} else {
|
||||
toastError(R.string.toast_failure)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Error during file restore", e)
|
||||
}
|
||||
if (restoreConfiguration(targetFile)) {
|
||||
toastSuccess(R.string.toast_success)
|
||||
} else {
|
||||
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() {
|
||||
val dateFormatted = SimpleDateFormat(
|
||||
@@ -215,25 +161,34 @@ class BackupActivity : BaseActivity() {
|
||||
Locale.getDefault()
|
||||
).format(System.currentTimeMillis())
|
||||
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() {
|
||||
val permission =
|
||||
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)
|
||||
}
|
||||
showFileChooser()
|
||||
}
|
||||
|
||||
private fun backupViaWebDav() {
|
||||
|
||||
@@ -39,6 +39,7 @@ abstract class BaseActivity : AppCompatActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||
if (!Utils.getDarkModeStatus(this)) {
|
||||
WindowCompat.getInsetsController(window, window.decorView).apply {
|
||||
@@ -212,5 +213,4 @@ abstract class BaseActivity : AppCompatActivity() {
|
||||
protected fun isLoadingVisible(): Boolean {
|
||||
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) {
|
||||
override fun getItemCount(): Int = groups.size
|
||||
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")
|
||||
fun update(groups: List<GroupMapItem>) {
|
||||
|
||||
@@ -1,27 +1,48 @@
|
||||
package com.v2ray.ang.ui
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.ItemTouchHelper
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.AppConfig.TAG
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.contracts.MainAdapterListener
|
||||
import com.v2ray.ang.databinding.FragmentGroupServerBinding
|
||||
import com.v2ray.ang.databinding.ItemQrcodeBinding
|
||||
import com.v2ray.ang.dto.ProfileItem
|
||||
import com.v2ray.ang.enums.EConfigType
|
||||
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.helper.SimpleItemTouchHelperCallback
|
||||
import com.v2ray.ang.viewmodel.MainViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class GroupServerFragment : BaseFragment<FragmentGroupServerBinding>() {
|
||||
private val ownerActivity: MainActivity
|
||||
get() = requireActivity() as MainActivity
|
||||
private val mainViewModel: MainViewModel by activityViewModels()
|
||||
private lateinit var adapter: MainRecyclerAdapter
|
||||
private var itemTouchHelper: ItemTouchHelper? = null
|
||||
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 {
|
||||
private const val ARG_SUB_ID = "subscriptionId"
|
||||
fun newInstance(subId: String) = GroupServerFragment().apply {
|
||||
@@ -34,7 +55,7 @@ class GroupServerFragment : BaseFragment<FragmentGroupServerBinding>() {
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
|
||||
adapter = MainRecyclerAdapter(requireActivity() as MainActivity)
|
||||
adapter = MainRecyclerAdapter(mainViewModel, ActivityAdapterListener())
|
||||
binding.recyclerView.setHasFixedSize(true)
|
||||
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_DOUBLE_COLUMN_DISPLAY, false)) {
|
||||
binding.recyclerView.layoutManager = GridLayoutManager(requireContext(), 2)
|
||||
@@ -51,18 +72,203 @@ class GroupServerFragment : BaseFragment<FragmentGroupServerBinding>() {
|
||||
if (mainViewModel.subscriptionId != subId) {
|
||||
return@observe
|
||||
}
|
||||
Log.d(TAG, "GroupServerFragment updateListAction subId=$subId")
|
||||
// Log.d(TAG, "GroupServerFragment updateListAction subId=$subId")
|
||||
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() {
|
||||
super.onResume()
|
||||
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)
|
||||
.putExtra("subscriptionId", subId)
|
||||
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.os.Bundle
|
||||
import android.util.Log
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import androidx.activity.viewModels
|
||||
import androidx.appcompat.widget.SearchView
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
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.databinding.ActivityLogcatBinding
|
||||
import com.v2ray.ang.extension.toast
|
||||
import com.v2ray.ang.extension.toastSuccess
|
||||
import com.v2ray.ang.util.Utils
|
||||
import com.v2ray.ang.viewmodel.LogcatViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.IOException
|
||||
|
||||
|
||||
class LogcatActivity : BaseActivity(), SwipeRefreshLayout.OnRefreshListener {
|
||||
private val binding by lazy { ActivityLogcatBinding.inflate(layoutInflater) }
|
||||
|
||||
private var logsetsAll: MutableList<String> = mutableListOf()
|
||||
var logsets: MutableList<String> = mutableListOf()
|
||||
private val adapter by lazy { LogcatRecyclerAdapter(this) }
|
||||
private val viewModel: LogcatViewModel by viewModels()
|
||||
private lateinit var adapter: LogcatRecyclerAdapter
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
//setContentView(binding.root)
|
||||
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.title_logcat))
|
||||
|
||||
adapter = LogcatRecyclerAdapter(viewModel, ::onLogLongClick)
|
||||
|
||||
binding.recyclerView.setHasFixedSize(true)
|
||||
binding.recyclerView.layoutManager = LinearLayoutManager(this)
|
||||
addCustomDividerToRecyclerView(binding.recyclerView, this, R.drawable.custom_divider)
|
||||
@@ -40,58 +37,12 @@ class LogcatActivity : BaseActivity(), SwipeRefreshLayout.OnRefreshListener {
|
||||
|
||||
binding.refreshLayout.setOnRefreshListener(this)
|
||||
|
||||
logsets.add(getString(R.string.pull_down_to_refresh))
|
||||
toast(getString(R.string.pull_down_to_refresh))
|
||||
}
|
||||
|
||||
private fun getLogcat() {
|
||||
|
||||
try {
|
||||
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)
|
||||
}
|
||||
private fun onLogLongClick(log: String): Boolean {
|
||||
Utils.setClipboard(this, log)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||
@@ -104,12 +55,14 @@ class LogcatActivity : BaseActivity(), SwipeRefreshLayout.OnRefreshListener {
|
||||
override fun onQueryTextSubmit(query: String?): Boolean = false
|
||||
|
||||
override fun onQueryTextChange(newText: String?): Boolean {
|
||||
filterLogs(newText)
|
||||
viewModel.filter(newText)
|
||||
refreshData()
|
||||
return false
|
||||
}
|
||||
})
|
||||
searchView.setOnCloseListener {
|
||||
filterLogs("")
|
||||
viewModel.filter("")
|
||||
refreshData()
|
||||
false
|
||||
}
|
||||
}
|
||||
@@ -119,33 +72,33 @@ class LogcatActivity : BaseActivity(), SwipeRefreshLayout.OnRefreshListener {
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
|
||||
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)
|
||||
true
|
||||
}
|
||||
|
||||
R.id.clear_all -> {
|
||||
clearLogcat()
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
viewModel.clearLogcat()
|
||||
withContext(Dispatchers.Main) {
|
||||
refreshData()
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
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() {
|
||||
getLogcat()
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
viewModel.loadLogcat()
|
||||
withContext(Dispatchers.Main) {
|
||||
binding.refreshLayout.isRefreshing = false
|
||||
refreshData()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("NotifyDataSetChanged")
|
||||
|
||||
@@ -6,17 +6,20 @@ import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.v2ray.ang.AppConfig
|
||||
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>() {
|
||||
private var mActivity: LogcatActivity = activity
|
||||
class LogcatRecyclerAdapter(
|
||||
private val viewModel: LogcatViewModel,
|
||||
private val onLongClick: ((String) -> Boolean)? = null
|
||||
) : RecyclerView.Adapter<LogcatRecyclerAdapter.MainViewHolder>() {
|
||||
|
||||
|
||||
override fun getItemCount() = mActivity.logsets.size
|
||||
override fun getItemCount() = viewModel.getAll().size
|
||||
|
||||
override fun onBindViewHolder(holder: MainViewHolder, position: Int) {
|
||||
try {
|
||||
val log = mActivity.logsets[position]
|
||||
val logs = viewModel.getAll()
|
||||
val log = logs[position]
|
||||
|
||||
if (log.isEmpty()) {
|
||||
holder.itemSubSettingBinding.logTag.text = ""
|
||||
holder.itemSubSettingBinding.logContent.text = ""
|
||||
@@ -27,8 +30,7 @@ class LogcatRecyclerAdapter(val activity: LogcatActivity) : RecyclerView.Adapter
|
||||
}
|
||||
|
||||
holder.itemView.setOnLongClickListener {
|
||||
Utils.setClipboard(mActivity, log)
|
||||
true
|
||||
onLongClick?.invoke(log) ?: false
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Error binding log view data", e)
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
package com.v2ray.ang.ui
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.res.ColorStateList
|
||||
import android.net.Uri
|
||||
import android.net.VpnService
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.view.KeyEvent
|
||||
@@ -25,15 +22,16 @@ import androidx.lifecycle.lifecycleScope
|
||||
import com.google.android.material.navigation.NavigationView
|
||||
import com.google.android.material.tabs.TabLayoutMediator
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.AppConfig.VPN
|
||||
import com.v2ray.ang.R
|
||||
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.toastError
|
||||
import com.v2ray.ang.handler.AngConfigManager
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.handler.SettingsChangeManager
|
||||
import com.v2ray.ang.handler.SettingsManager
|
||||
import com.v2ray.ang.handler.V2RayServiceManager
|
||||
import com.v2ray.ang.util.Utils
|
||||
import com.v2ray.ang.viewmodel.MainViewModel
|
||||
@@ -42,7 +40,7 @@ import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedListener {
|
||||
class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelectedListener {
|
||||
private val binding by lazy {
|
||||
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?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
@@ -148,11 +100,7 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
|
||||
setupViewModel()
|
||||
mainViewModel.reloadServerList()
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
|
||||
pendingAction = Action.POST_NOTIFICATIONS
|
||||
requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||
}
|
||||
checkAndRequestPermission(PermissionType.POST_NOTIFICATIONS) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,7 +136,7 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
|
||||
|
||||
if (mainViewModel.isRunning.value == true) {
|
||||
V2RayServiceManager.stopVService(this)
|
||||
} else if ((MmkvManager.decodeSettingsString(AppConfig.PREF_MODE) ?: VPN) == VPN) {
|
||||
} else if (SettingsManager.isVpnMode()) {
|
||||
val intent = VpnService.prepare(this)
|
||||
if (intent == null) {
|
||||
startV2Ray()
|
||||
@@ -416,12 +364,10 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
|
||||
* import config from qrcode
|
||||
*/
|
||||
private fun importQRcode(): Boolean {
|
||||
val permission = Manifest.permission.CAMERA
|
||||
if (ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED) {
|
||||
scanQRCodeForConfig.launch(Intent(this, ScannerActivity::class.java))
|
||||
} else {
|
||||
pendingAction = Action.IMPORT_QR_CODE_CONFIG
|
||||
requestPermissionLauncher.launch(permission)
|
||||
launchQRCodeScanner { scanResult ->
|
||||
if (scanResult != null) {
|
||||
importBatchConfig(scanResult)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -491,14 +437,23 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
|
||||
showLoading()
|
||||
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
val count = mainViewModel.updateConfigViaSubAll()
|
||||
val result = mainViewModel.updateConfigViaSubAll()
|
||||
delay(500L)
|
||||
launch(Dispatchers.Main) {
|
||||
if (count > 0) {
|
||||
toast(getString(R.string.title_update_config_count, count))
|
||||
mainViewModel.reloadServerList()
|
||||
if (result.successCount + result.failureCount + result.skipCount == 0) {
|
||||
toast(R.string.title_update_subscription_no_subscription)
|
||||
} else if (result.successCount > 0 && result.failureCount + result.skipCount == 0) {
|
||||
toast(getString(R.string.title_update_config_count, result.configCount))
|
||||
} else {
|
||||
toastError(R.string.toast_failure)
|
||||
toast(
|
||||
getString(
|
||||
R.string.title_update_subscription_result,
|
||||
result.configCount, result.successCount, result.failureCount, result.skipCount
|
||||
)
|
||||
)
|
||||
}
|
||||
if (result.configCount > 0) {
|
||||
mainViewModel.reloadServerList()
|
||||
}
|
||||
hideLoading()
|
||||
}
|
||||
@@ -592,21 +547,12 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
|
||||
* show file chooser
|
||||
*/
|
||||
private fun showFileChooser() {
|
||||
val intent = Intent(Intent.ACTION_GET_CONTENT)
|
||||
intent.type = "*/*"
|
||||
intent.addCategory(Intent.CATEGORY_OPENABLE)
|
||||
launchFileChooser { uri ->
|
||||
if (uri == null) {
|
||||
return@launchFileChooser
|
||||
}
|
||||
|
||||
val permission = 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) {
|
||||
pendingAction = Action.READ_CONTENT_FROM_URI
|
||||
chooseFileForCustomConfig.launch(Intent.createChooser(intent, getString(R.string.title_file_chooser)))
|
||||
} else {
|
||||
requestPermissionLauncher.launch(permission)
|
||||
readContentFromUri(uri)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -614,22 +560,12 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
|
||||
* read content from uri
|
||||
*/
|
||||
private fun readContentFromUri(uri: Uri) {
|
||||
val permission = 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 {
|
||||
contentResolver.openInputStream(uri).use { input ->
|
||||
importBatchConfig(input?.bufferedReader()?.readText())
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Failed to read content from URI", e)
|
||||
try {
|
||||
contentResolver.openInputStream(uri).use { input ->
|
||||
importBatchConfig(input?.bufferedReader()?.readText())
|
||||
}
|
||||
} else {
|
||||
requestPermissionLauncher.launch(permission)
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Failed to read content from URI", e)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,60 +1,41 @@
|
||||
package com.v2ray.ang.ui
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Intent
|
||||
import android.graphics.Color
|
||||
import android.text.TextUtils
|
||||
import android.util.Log
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.v2ray.ang.AngApplication.Companion.application
|
||||
import com.v2ray.ang.AppConfig
|
||||
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.ItemRecyclerMainBinding
|
||||
import com.v2ray.ang.dto.EConfigType
|
||||
import com.v2ray.ang.dto.ProfileItem
|
||||
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.extension.nullIfBlank
|
||||
import com.v2ray.ang.handler.AngConfigManager
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.helper.ItemTouchHelperAdapter
|
||||
import com.v2ray.ang.helper.ItemTouchHelperViewHolder
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import com.v2ray.ang.viewmodel.MainViewModel
|
||||
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 {
|
||||
private const val VIEW_TYPE_ITEM = 1
|
||||
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 var data: MutableList<ServersCache> = mutableListOf()
|
||||
|
||||
@SuppressLint("NotifyDataSetChanged")
|
||||
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()
|
||||
|
||||
if (position >= 0 && position in data.indices) {
|
||||
@@ -68,9 +49,9 @@ class MainRecyclerAdapter(val activity: MainActivity) : RecyclerView.Adapter<Mai
|
||||
|
||||
override fun onBindViewHolder(holder: BaseViewHolder, position: Int) {
|
||||
if (holder is MainViewHolder) {
|
||||
val context = holder.itemMainBinding.root.context
|
||||
val guid = data[position].guid
|
||||
val profile = data[position].profile
|
||||
val isCustom = profile.configType == EConfigType.CUSTOM || profile.configType == EConfigType.POLICYGROUP
|
||||
|
||||
holder.itemView.setBackgroundColor(Color.TRANSPARENT)
|
||||
|
||||
@@ -83,9 +64,9 @@ class MainRecyclerAdapter(val activity: MainActivity) : RecyclerView.Adapter<Mai
|
||||
val aff = MmkvManager.decodeServerAffiliationInfo(guid)
|
||||
holder.itemMainBinding.tvTestResult.text = aff?.getTestDelayString().orEmpty()
|
||||
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 {
|
||||
holder.itemMainBinding.tvTestResult.setTextColor(ContextCompat.getColor(mActivity, R.color.colorPing))
|
||||
holder.itemMainBinding.tvTestResult.setTextColor(ContextCompat.getColor(context, R.color.colorPing))
|
||||
}
|
||||
|
||||
//layoutIndicator
|
||||
@@ -107,11 +88,8 @@ class MainRecyclerAdapter(val activity: MainActivity) : RecyclerView.Adapter<Mai
|
||||
holder.itemMainBinding.layoutRemove.visibility = View.GONE
|
||||
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 {
|
||||
shareServer(guid, profile, position, shareOptions, if (isCustom) 2 else 0)
|
||||
adapterListener?.onShare(guid, profile, position, true)
|
||||
}
|
||||
} else {
|
||||
holder.itemMainBinding.layoutShare.visibility = View.VISIBLE
|
||||
@@ -119,34 +97,23 @@ class MainRecyclerAdapter(val activity: MainActivity) : RecyclerView.Adapter<Mai
|
||||
holder.itemMainBinding.layoutRemove.visibility = View.VISIBLE
|
||||
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 {
|
||||
shareServer(guid, profile, position, shareOptions, if (isCustom) 2 else 0)
|
||||
adapterListener?.onShare(guid, profile, position, false)
|
||||
}
|
||||
|
||||
holder.itemMainBinding.layoutEdit.setOnClickListener {
|
||||
editServer(guid, profile)
|
||||
adapterListener?.onEdit(guid, position, profile)
|
||||
}
|
||||
holder.itemMainBinding.layoutRemove.setOnClickListener {
|
||||
removeServer(guid, position)
|
||||
adapterListener?.onRemove(guid, position)
|
||||
}
|
||||
}
|
||||
|
||||
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()}")
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -156,19 +123,7 @@ class MainRecyclerAdapter(val activity: MainActivity) : RecyclerView.Adapter<Mai
|
||||
* @return Formatted address string
|
||||
*/
|
||||
private fun getAddress(profile: ProfileItem): String {
|
||||
// Hide xxx:xxx:***/xxx.xxx.xxx.***
|
||||
val server = profile.server
|
||||
val port = profile.serverPort
|
||||
if (server.isNullOrBlank() && port.isNullOrBlank()) return ""
|
||||
|
||||
val addrPart = server?.let {
|
||||
if (it.contains(":"))
|
||||
it.split(":").take(2).joinToString(":", postfix = ":***")
|
||||
else
|
||||
it.split('.').dropLast(1).joinToString(".", postfix = ".***")
|
||||
} ?: ""
|
||||
|
||||
return "$addrPart : ${port ?: ""}"
|
||||
return profile.description.nullIfBlank() ?: AngConfigManager.generateDescription(profile)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -178,135 +133,14 @@ class MainRecyclerAdapter(val activity: MainActivity) : RecyclerView.Adapter<Mai
|
||||
*/
|
||||
private fun getSubscriptionRemarks(profile: ProfileItem): String {
|
||||
val subRemarks =
|
||||
if (mActivity.mainViewModel.subscriptionId.isEmpty())
|
||||
if (mainViewModel.subscriptionId.isEmpty())
|
||||
MmkvManager.decodeSubscription(profile.subscriptionId)?.remarks?.firstOrNull()
|
||||
else
|
||||
null
|
||||
return subRemarks?.toString() ?: ""
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
fun removeServerSub(guid: String, position: Int) {
|
||||
val idx = data.indexOfFirst { it.guid == guid }
|
||||
if (idx >= 0) {
|
||||
data.removeAt(idx)
|
||||
@@ -315,23 +149,9 @@ class MainRecyclerAdapter(val activity: MainActivity) : RecyclerView.Adapter<Mai
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
if (!TextUtils.isEmpty(selected)) {
|
||||
notifyItemChanged(mActivity.mainViewModel.getPosition(selected.orEmpty()))
|
||||
}
|
||||
notifyItemChanged(mActivity.mainViewModel.getPosition(guid))
|
||||
if (isRunning) {
|
||||
mActivity.restartV2Ray()
|
||||
}
|
||||
}
|
||||
fun setSelectServer(fromPosition: Int, toPosition: Int) {
|
||||
notifyItemChanged(fromPosition)
|
||||
notifyItemChanged(toPosition)
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BaseViewHolder {
|
||||
@@ -369,7 +189,7 @@ class MainRecyclerAdapter(val activity: MainActivity) : RecyclerView.Adapter<Mai
|
||||
BaseViewHolder(itemFooterBinding.root)
|
||||
|
||||
override fun onItemMove(fromPosition: Int, toPosition: Int): Boolean {
|
||||
mActivity.mainViewModel.swapServer(fromPosition, toPosition)
|
||||
mainViewModel.swapServer(fromPosition, toPosition)
|
||||
if (fromPosition < data.size && toPosition < data.size) {
|
||||
Collections.swap(data, fromPosition, toPosition)
|
||||
}
|
||||
|
||||
@@ -95,7 +95,7 @@ class PerAppProxyActivity : BaseActivity() {
|
||||
}
|
||||
|
||||
appsAll = apps
|
||||
adapter = PerAppProxyAdapter(this@PerAppProxyActivity, apps, viewModel)
|
||||
adapter = PerAppProxyAdapter(apps, viewModel)
|
||||
binding.recyclerView.adapter = adapter
|
||||
|
||||
} 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
|
||||
refreshData()
|
||||
return true
|
||||
|
||||
@@ -8,8 +8,10 @@ import com.v2ray.ang.databinding.ItemRecyclerBypassListBinding
|
||||
import com.v2ray.ang.dto.AppInfo
|
||||
import com.v2ray.ang.viewmodel.PerAppProxyViewModel
|
||||
|
||||
class PerAppProxyAdapter(val activity: BaseActivity, val apps: List<AppInfo>, val viewModel: PerAppProxyViewModel) :
|
||||
RecyclerView.Adapter<PerAppProxyAdapter.BaseViewHolder>() {
|
||||
class PerAppProxyAdapter(
|
||||
val apps: List<AppInfo>,
|
||||
val viewModel: PerAppProxyViewModel
|
||||
) :RecyclerView.Adapter<PerAppProxyAdapter.BaseViewHolder>() {
|
||||
|
||||
companion object {
|
||||
private const val VIEW_TYPE_HEADER = 0
|
||||
@@ -37,6 +39,7 @@ class PerAppProxyAdapter(val activity: BaseActivity, val apps: List<AppInfo>, va
|
||||
)
|
||||
BaseViewHolder(view)
|
||||
}
|
||||
|
||||
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.databinding.ActivityRoutingEditBinding
|
||||
import com.v2ray.ang.dto.RulesetItem
|
||||
import com.v2ray.ang.extension.nullIfBlank
|
||||
import com.v2ray.ang.extension.toast
|
||||
import com.v2ray.ang.extension.toastSuccess
|
||||
import com.v2ray.ang.handler.SettingsManager
|
||||
@@ -62,14 +63,11 @@ class RoutingEditActivity : BaseActivity() {
|
||||
rulesetItem.apply {
|
||||
remarks = binding.etRemarks.text.toString()
|
||||
locked = binding.chkLocked.isChecked
|
||||
domain = binding.etDomain.text.toString().takeIf { it.isNotEmpty() }
|
||||
?.split(",")?.map { it.trim() }?.filter { it.isNotEmpty() }
|
||||
ip = binding.etIp.text.toString().takeIf { it.isNotEmpty() }
|
||||
?.split(",")?.map { it.trim() }?.filter { it.isNotEmpty() }
|
||||
protocol = binding.etProtocol.text.toString().takeIf { it.isNotEmpty() }
|
||||
?.split(",")?.map { it.trim() }?.filter { it.isNotEmpty() }
|
||||
port = binding.etPort.text.toString().takeIf { it.isNotEmpty() }
|
||||
network = binding.etNetwork.text.toString().takeIf { it.isNotEmpty() }
|
||||
domain = binding.etDomain.text.toString().nullIfBlank()?.split(",")?.map { it.trim() }?.filter { it.isNotEmpty() }
|
||||
ip = binding.etIp.text.toString().nullIfBlank()?.split(",")?.map { it.trim() }?.filter { it.isNotEmpty() }
|
||||
protocol = binding.etProtocol.text.toString().nullIfBlank()?.split(",")?.map { it.trim() }?.filter { it.isNotEmpty() }
|
||||
port = binding.etPort.text.toString().nullIfBlank()
|
||||
network = binding.etNetwork.text.toString().nullIfBlank()
|
||||
outboundTag = outbound_tag[binding.spOutboundTag.selectedItemPosition]
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
package com.v2ray.ang.ui
|
||||
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.activity.viewModels
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
@@ -15,8 +13,8 @@ import androidx.recyclerview.widget.ItemTouchHelper
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.contracts.BaseAdapterListener
|
||||
import com.v2ray.ang.databinding.ActivityRoutingSettingBinding
|
||||
import com.v2ray.ang.extension.toast
|
||||
import com.v2ray.ang.extension.toastError
|
||||
import com.v2ray.ang.extension.toastSuccess
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
@@ -29,9 +27,10 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class RoutingSettingActivity : BaseActivity() {
|
||||
class RoutingSettingActivity : HelperBaseActivity() {
|
||||
private val binding by lazy { ActivityRoutingSettingBinding.inflate(layoutInflater) }
|
||||
|
||||
private val ownerActivity: RoutingSettingActivity
|
||||
get() = this
|
||||
private val viewModel: RoutingSettingsViewModel by viewModels()
|
||||
private lateinit var adapter: RoutingSettingRecyclerAdapter
|
||||
private var mItemTouchHelper: ItemTouchHelper? = null
|
||||
@@ -42,22 +41,12 @@ class RoutingSettingActivity : BaseActivity() {
|
||||
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?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
//setContentView(binding.root)
|
||||
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.layoutManager = LinearLayoutManager(this)
|
||||
@@ -87,7 +76,7 @@ class RoutingSettingActivity : BaseActivity() {
|
||||
R.id.add_rule -> startActivity(Intent(this, RoutingEditActivity::class.java)).let { true }
|
||||
R.id.import_predefined_rulesets -> importPredefined().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 }
|
||||
else -> super.onOptionsItemSelected(item)
|
||||
}
|
||||
@@ -159,6 +148,15 @@ class RoutingSettingActivity : BaseActivity() {
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun importQRcode(): Boolean {
|
||||
launchQRCodeScanner { scanResult ->
|
||||
if (scanResult != null) {
|
||||
importRulesetsFromQRcode(scanResult)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun export2Clipboard() {
|
||||
val rulesetList = MmkvManager.decodeRoutingRulesets()
|
||||
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 {
|
||||
AlertDialog.Builder(this).setMessage(R.string.routing_settings_import_rulesets_tip)
|
||||
@@ -202,4 +195,23 @@ class RoutingSettingActivity : BaseActivity() {
|
||||
viewModel.reload()
|
||||
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
|
||||
|
||||
import android.content.Intent
|
||||
import android.graphics.Color
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.v2ray.ang.contracts.BaseAdapterListener
|
||||
import com.v2ray.ang.databinding.ItemRecyclerRoutingSettingBinding
|
||||
import com.v2ray.ang.helper.ItemTouchHelperAdapter
|
||||
import com.v2ray.ang.helper.ItemTouchHelperViewHolder
|
||||
import com.v2ray.ang.viewmodel.RoutingSettingsViewModel
|
||||
|
||||
class RoutingSettingRecyclerAdapter(
|
||||
val activity: RoutingSettingActivity,
|
||||
private val viewModel: RoutingSettingsViewModel
|
||||
private val viewModel: RoutingSettingsViewModel,
|
||||
private val adapterListener: BaseAdapterListener?
|
||||
) : RecyclerView.Adapter<RoutingSettingRecyclerAdapter.MainViewHolder>(),
|
||||
ItemTouchHelperAdapter {
|
||||
|
||||
private var mActivity: RoutingSettingActivity = activity
|
||||
override fun getItemCount() = viewModel.getAll().size
|
||||
|
||||
override fun onBindViewHolder(holder: MainViewHolder, position: Int) {
|
||||
@@ -33,10 +32,7 @@ class RoutingSettingRecyclerAdapter(
|
||||
holder.itemView.setBackgroundColor(Color.TRANSPARENT)
|
||||
|
||||
holder.itemRoutingSettingBinding.layoutEdit.setOnClickListener {
|
||||
mActivity.startActivity(
|
||||
Intent(mActivity, RoutingEditActivity::class.java)
|
||||
.putExtra("position", position)
|
||||
)
|
||||
adapterListener?.onEdit("", position)
|
||||
}
|
||||
|
||||
holder.itemRoutingSettingBinding.chkEnable.setOnCheckedChangeListener { it, isChecked ->
|
||||
@@ -76,7 +72,7 @@ class RoutingSettingRecyclerAdapter(
|
||||
}
|
||||
|
||||
override fun onItemMoveCompleted() {
|
||||
mActivity.refreshData()
|
||||
adapterListener?.onRefreshData()
|
||||
}
|
||||
|
||||
override fun onItemDismiss(position: Int) {
|
||||
|
||||
@@ -1,27 +1,13 @@
|
||||
package com.v2ray.ang.ui
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.extension.toast
|
||||
import com.v2ray.ang.extension.toastError
|
||||
import com.v2ray.ang.extension.toastSuccess
|
||||
import com.v2ray.ang.handler.AngConfigManager
|
||||
|
||||
class ScScannerActivity : BaseActivity() {
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
class ScScannerActivity : HelperBaseActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
@@ -29,24 +15,20 @@ class ScScannerActivity : BaseActivity() {
|
||||
importQRcode()
|
||||
}
|
||||
|
||||
private fun importQRcode(): Boolean {
|
||||
requestCameraPermissionLauncher.launch(Manifest.permission.CAMERA)
|
||||
return true
|
||||
}
|
||||
private fun importQRcode() {
|
||||
launchQRCodeScanner { scanResult ->
|
||||
if (scanResult != null) {
|
||||
val (count, countSub) = AngConfigManager.importBatchConfig(scanResult, "", false)
|
||||
|
||||
private val scanQRCode = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
|
||||
if (it.resultCode == RESULT_OK) {
|
||||
val scanResult = it.data?.getStringExtra("SCAN_RESULT").orEmpty()
|
||||
val (count, countSub) = AngConfigManager.importBatchConfig(scanResult, "", false)
|
||||
if (count + countSub > 0) {
|
||||
toastSuccess(R.string.toast_success)
|
||||
} else {
|
||||
toastError(R.string.toast_failure)
|
||||
}
|
||||
|
||||
if (count + countSub > 0) {
|
||||
toastSuccess(R.string.toast_success)
|
||||
} else {
|
||||
toastError(R.string.toast_failure)
|
||||
startActivity(Intent(this, MainActivity::class.java))
|
||||
}
|
||||
|
||||
startActivity(Intent(this, MainActivity::class.java))
|
||||
finish()
|
||||
}
|
||||
finish()
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,11 @@
|
||||
package com.v2ray.ang.ui
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.BitmapFactory
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.databinding.ActivityNoneBinding
|
||||
@@ -19,43 +14,13 @@ import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.util.QRCodeDecoder
|
||||
import io.github.g00fy2.quickie.QRResult
|
||||
import io.github.g00fy2.quickie.ScanCustomCode
|
||||
import io.github.g00fy2.quickie.config.BarcodeFormat
|
||||
import io.github.g00fy2.quickie.config.ScannerConfig
|
||||
|
||||
class ScannerActivity : BaseActivity() {
|
||||
class ScannerActivity : HelperBaseActivity() {
|
||||
private val binding by lazy { ActivityNoneBinding.inflate(layoutInflater) }
|
||||
|
||||
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?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
@@ -73,6 +38,7 @@ class ScannerActivity : BaseActivity() {
|
||||
setHapticSuccessFeedback(true) // enable (default) or disable haptic feedback when a barcode was detected
|
||||
setShowTorchToggle(true) // show or hide (default) torch/flashlight toggle 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 -> {
|
||||
val permission = 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) {
|
||||
showFileChooser()
|
||||
} else {
|
||||
requestPermissionLauncher.launch(permission)
|
||||
}
|
||||
showFileChooser()
|
||||
true
|
||||
}
|
||||
|
||||
@@ -123,15 +79,25 @@ class ScannerActivity : BaseActivity() {
|
||||
}
|
||||
|
||||
private fun showFileChooser() {
|
||||
val intent = Intent(Intent.ACTION_GET_CONTENT)
|
||||
intent.type = "image/*"
|
||||
intent.addCategory(Intent.CATEGORY_OPENABLE)
|
||||
//intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true)
|
||||
launchFileChooser("image/*") { uri ->
|
||||
if (uri == null) {
|
||||
return@launchFileChooser
|
||||
}
|
||||
try {
|
||||
val inputStream = contentResolver.openInputStream(uri)
|
||||
val bitmap = BitmapFactory.decodeStream(inputStream)
|
||||
inputStream?.close()
|
||||
|
||||
try {
|
||||
chooseFile.launch(Intent.createChooser(intent, getString(R.string.title_file_chooser)))
|
||||
} catch (ex: android.content.ActivityNotFoundException) {
|
||||
toast(R.string.toast_require_file_manager)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,12 +20,13 @@ import com.v2ray.ang.AppConfig.TLS
|
||||
import com.v2ray.ang.AppConfig.WIREGUARD_LOCAL_ADDRESS_V4
|
||||
import com.v2ray.ang.AppConfig.WIREGUARD_LOCAL_MTU
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.dto.EConfigType
|
||||
import com.v2ray.ang.dto.NetworkType
|
||||
import com.v2ray.ang.dto.ProfileItem
|
||||
import com.v2ray.ang.enums.EConfigType
|
||||
import com.v2ray.ang.enums.NetworkType
|
||||
import com.v2ray.ang.extension.isNotNullEmpty
|
||||
import com.v2ray.ang.extension.toast
|
||||
import com.v2ray.ang.extension.toastSuccess
|
||||
import com.v2ray.ang.handler.AngConfigManager
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.util.JsonUtil
|
||||
import com.v2ray.ang.util.Utils
|
||||
@@ -128,7 +129,6 @@ class ServerActivity : BaseActivity() {
|
||||
private val et_obfs_password: EditText? by lazy { findViewById(R.id.et_obfs_password) }
|
||||
private val et_port_hop: EditText? by lazy { findViewById(R.id.et_port_hop) }
|
||||
private val et_port_hop_interval: EditText? by lazy { findViewById(R.id.et_port_hop_interval) }
|
||||
private val et_pinsha256: EditText? by lazy { findViewById(R.id.et_pinsha256) }
|
||||
private val et_bandwidth_down: EditText? by lazy { findViewById(R.id.et_bandwidth_down) }
|
||||
private val et_bandwidth_up: EditText? by lazy { findViewById(R.id.et_bandwidth_up) }
|
||||
private val et_extra: EditText? by lazy { findViewById(R.id.et_extra) }
|
||||
@@ -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 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 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?) {
|
||||
@@ -274,7 +276,8 @@ class ServerActivity : BaseActivity() {
|
||||
container_spider_x,
|
||||
container_mldsa65_verify,
|
||||
container_ech_config_list,
|
||||
container_ech_force_query
|
||||
container_ech_force_query,
|
||||
container_pinned_ca256
|
||||
).forEach { it?.visibility = View.GONE }
|
||||
}
|
||||
|
||||
@@ -286,7 +289,8 @@ class ServerActivity : BaseActivity() {
|
||||
container_alpn,
|
||||
container_allow_insecure,
|
||||
container_ech_config_list,
|
||||
container_ech_force_query
|
||||
container_ech_force_query,
|
||||
container_pinned_ca256
|
||||
).forEach { it?.visibility = View.VISIBLE }
|
||||
listOf(
|
||||
container_public_key,
|
||||
@@ -306,7 +310,8 @@ class ServerActivity : BaseActivity() {
|
||||
container_alpn,
|
||||
container_allow_insecure,
|
||||
container_ech_config_list,
|
||||
container_ech_force_query
|
||||
container_ech_force_query,
|
||||
container_pinned_ca256
|
||||
).forEach { it?.visibility = View.GONE }
|
||||
listOf(
|
||||
container_public_key,
|
||||
@@ -361,7 +366,6 @@ class ServerActivity : BaseActivity() {
|
||||
et_obfs_password?.text = Utils.getEditable(config.obfsPassword)
|
||||
et_port_hop?.text = Utils.getEditable(config.portHopping)
|
||||
et_port_hop_interval?.text = Utils.getEditable(config.portHoppingInterval)
|
||||
et_pinsha256?.text = Utils.getEditable(config.pinSHA256)
|
||||
et_bandwidth_down?.text = Utils.getEditable(config.bandwidthDown)
|
||||
et_bandwidth_up?.text = Utils.getEditable(config.bandwidthUp)
|
||||
}
|
||||
@@ -394,6 +398,7 @@ class ServerActivity : BaseActivity() {
|
||||
val index = Utils.arrayFind(echForceQuerys, it)
|
||||
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) {
|
||||
et_public_key?.text = Utils.getEditable(config.publicKey.orEmpty())
|
||||
et_short_id?.text = Utils.getEditable(config.shortId.orEmpty())
|
||||
@@ -488,6 +493,8 @@ class ServerActivity : BaseActivity() {
|
||||
saveStreamSettings(config)
|
||||
saveTls(config)
|
||||
|
||||
config.description = AngConfigManager.generateDescription(config)
|
||||
|
||||
if (config.subscriptionId.isEmpty() && !subscriptionId.isNullOrEmpty()) {
|
||||
config.subscriptionId = subscriptionId.orEmpty()
|
||||
}
|
||||
@@ -527,7 +534,6 @@ class ServerActivity : BaseActivity() {
|
||||
config.obfsPassword = et_obfs_password?.text?.toString()
|
||||
config.portHopping = et_port_hop?.text?.toString()
|
||||
config.portHoppingInterval = et_port_hop_interval?.text?.toString()
|
||||
config.pinSHA256 = et_pinsha256?.text?.toString()
|
||||
config.bandwidthDown = et_bandwidth_down?.text?.toString()
|
||||
config.bandwidthUp = et_bandwidth_up?.text?.toString()
|
||||
}
|
||||
@@ -566,6 +572,7 @@ class ServerActivity : BaseActivity() {
|
||||
val mldsa65Verify = et_mldsa65_verify?.text?.toString()
|
||||
val echConfigList = et_ech_config_list?.text?.toString()
|
||||
val echForceQueryIndex = sp_ech_force_query?.selectedItemPosition ?: 0
|
||||
val pinnedCA256 = et_pinned_ca256?.text?.toString()
|
||||
|
||||
val allowInsecure =
|
||||
if (allowInsecureField == null || allowinsecures[allowInsecureField].isBlank()) {
|
||||
@@ -585,6 +592,7 @@ class ServerActivity : BaseActivity() {
|
||||
config.mldsa65Verify = mldsa65Verify
|
||||
config.echConfigList = echConfigList
|
||||
config.echForceQuery = echForceQuerys[echForceQueryIndex]
|
||||
config.pinnedCA256 = pinnedCA256
|
||||
}
|
||||
|
||||
private fun transportTypes(network: String?): Array<out String> {
|
||||
@@ -632,7 +640,7 @@ class ServerActivity : BaseActivity() {
|
||||
finish()
|
||||
}
|
||||
} else {
|
||||
application.toast(R.string.toast_action_not_allowed)
|
||||
toast(R.string.toast_action_not_allowed)
|
||||
}
|
||||
}
|
||||
return true
|
||||
|
||||
@@ -11,11 +11,12 @@ import com.blacksquircle.ui.language.json.JsonLanguage
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.databinding.ActivityServerCustomConfigBinding
|
||||
import com.v2ray.ang.dto.EConfigType
|
||||
import com.v2ray.ang.dto.ProfileItem
|
||||
import com.v2ray.ang.enums.EConfigType
|
||||
import com.v2ray.ang.extension.toast
|
||||
import com.v2ray.ang.extension.toastSuccess
|
||||
import com.v2ray.ang.fmt.CustomFmt
|
||||
import com.v2ray.ang.handler.AngConfigManager
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.util.Utils
|
||||
|
||||
@@ -89,6 +90,7 @@ class ServerCustomConfigActivity : BaseActivity() {
|
||||
}
|
||||
config.server = profileItem?.server
|
||||
config.serverPort = profileItem?.serverPort
|
||||
config.description = AngConfigManager.generateDescription(config)
|
||||
|
||||
MmkvManager.encodeServerConfig(editGuid, config)
|
||||
MmkvManager.encodeServerRaw(editGuid, binding.editor.text.toString())
|
||||
|
||||
@@ -8,8 +8,9 @@ import android.widget.ArrayAdapter
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.databinding.ActivityServerGroupBinding
|
||||
import com.v2ray.ang.dto.EConfigType
|
||||
import com.v2ray.ang.dto.ProfileItem
|
||||
import com.v2ray.ang.enums.EConfigType
|
||||
import com.v2ray.ang.extension.isNotNullEmpty
|
||||
import com.v2ray.ang.extension.toast
|
||||
import com.v2ray.ang.extension.toastSuccess
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
@@ -66,6 +67,11 @@ class ServerGroupActivity : BaseActivity() {
|
||||
private fun clearServer(): Boolean {
|
||||
binding.etRemarks.text = null
|
||||
binding.etPolicyGroupFilter.text = null
|
||||
|
||||
if (subscriptionId.isNotNullEmpty()) {
|
||||
val pos = subIds.indexOf(subscriptionId).let { if (it >= 0) it else 0 }
|
||||
binding.spPolicyGroupSubId.setSelection(pos)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -90,6 +96,9 @@ class ServerGroupActivity : BaseActivity() {
|
||||
if (config.subscriptionId.isEmpty() && !subscriptionId.isNullOrEmpty()) {
|
||||
config.subscriptionId = subscriptionId.orEmpty()
|
||||
}
|
||||
|
||||
config.description = "${binding.spPolicyGroupType.selectedItem} - ${binding.spPolicyGroupSubId.selectedItem} - ${config.policyGroupFilter}"
|
||||
|
||||
MmkvManager.encodeServerConfig(editGuid, config)
|
||||
toastSuccess(R.string.toast_success)
|
||||
finish()
|
||||
@@ -119,13 +128,13 @@ class ServerGroupActivity : BaseActivity() {
|
||||
val displayList = mutableListOf(getString(R.string.filter_config_all)) //none
|
||||
subIds.clear()
|
||||
subIds.add("") // index 0 => All
|
||||
subs.forEach { (id, item) ->
|
||||
subs.forEach { sub ->
|
||||
val name = when {
|
||||
item.remarks.isNotBlank() -> item.remarks
|
||||
else -> id
|
||||
sub.subscription.remarks.isNotBlank() -> sub.subscription.remarks
|
||||
else -> sub.guid
|
||||
}
|
||||
displayList.add(name)
|
||||
subIds.add(id)
|
||||
subIds.add(sub.guid)
|
||||
}
|
||||
val subAdapter = ArrayAdapter(this, android.R.layout.simple_spinner_item, displayList)
|
||||
subAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
|
||||
|
||||
@@ -15,8 +15,8 @@ import com.v2ray.ang.AppConfig.VPN
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.extension.toLongEx
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.handler.MmkvPreferenceDataStore
|
||||
import com.v2ray.ang.handler.SubscriptionUpdater
|
||||
import com.v2ray.ang.helper.MmkvPreferenceDataStore
|
||||
import com.v2ray.ang.util.Utils
|
||||
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 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?) {
|
||||
// Use MMKV as the storage backend for all Preferences
|
||||
// This prevents inconsistencies between SharedPreferences and MMKV
|
||||
@@ -93,12 +97,21 @@ class SettingsActivity : BaseActivity() {
|
||||
}
|
||||
true
|
||||
}
|
||||
mode?.setOnPreferenceChangeListener { _, newValue ->
|
||||
updateMode(newValue.toString())
|
||||
mode?.setOnPreferenceChangeListener { pref, newValue ->
|
||||
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
|
||||
}
|
||||
mode?.dialogLayoutResource = R.layout.preference_with_help_link
|
||||
|
||||
useHevTun?.setOnPreferenceChangeListener { _, newValue ->
|
||||
updateHevTunSettings(newValue as Boolean)
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
private fun initPreferenceSummaries() {
|
||||
@@ -141,6 +154,8 @@ class SettingsActivity : BaseActivity() {
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
updateHevTunSettings(MmkvManager.decodeSettingsBool(AppConfig.PREF_USE_HEV_TUNNEL, true))
|
||||
|
||||
// Initialize mode-dependent UI states
|
||||
updateMode(MmkvManager.decodeSettingsString(AppConfig.PREF_MODE, VPN))
|
||||
|
||||
@@ -152,11 +167,10 @@ class SettingsActivity : BaseActivity() {
|
||||
|
||||
// Initialize auto-update interval state
|
||||
autoUpdateInterval?.isEnabled = MmkvManager.decodeSettingsBool(AppConfig.SUBSCRIPTION_AUTO_UPDATE, false)
|
||||
|
||||
}
|
||||
|
||||
private fun updateMode(mode: String?) {
|
||||
val vpn = mode == VPN
|
||||
private fun updateMode(value: String?) {
|
||||
val vpn = value == VPN
|
||||
localDns?.isEnabled = vpn
|
||||
fakeDns?.isEnabled = vpn
|
||||
appendHttpProxy?.isEnabled = vpn
|
||||
@@ -165,6 +179,8 @@ class SettingsActivity : BaseActivity() {
|
||||
vpnBypassLan?.isEnabled = vpn
|
||||
vpnInterfaceAddress?.isEnabled = vpn
|
||||
vpnMtu?.isEnabled = vpn
|
||||
useHevTun?.isEnabled = vpn
|
||||
updateHevTunSettings(false)
|
||||
if (vpn) {
|
||||
updateLocalDns(
|
||||
MmkvManager.decodeSettingsBool(
|
||||
@@ -172,6 +188,12 @@ class SettingsActivity : BaseActivity() {
|
||||
false
|
||||
)
|
||||
)
|
||||
updateHevTunSettings(
|
||||
MmkvManager.decodeSettingsBool(
|
||||
AppConfig.PREF_USE_HEV_TUNNEL,
|
||||
false
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -235,6 +257,11 @@ class SettingsActivity : BaseActivity() {
|
||||
fragmentLength?.isEnabled = enabled
|
||||
fragmentInterval?.isEnabled = enabled
|
||||
}
|
||||
|
||||
private fun updateHevTunSettings(enabled: Boolean) {
|
||||
hevTunLogLevel?.isEnabled = enabled
|
||||
hevTunRwTimeout?.isEnabled = enabled
|
||||
}
|
||||
}
|
||||
|
||||
fun onModeHelpClicked(view: View) {
|
||||
|
||||
@@ -145,7 +145,7 @@ class SubEditActivity : BaseActivity() {
|
||||
del_config = menu.findItem(R.id.del_config)
|
||||
save_config = menu.findItem(R.id.save_config)
|
||||
|
||||
if (editSubId.isEmpty()) {
|
||||
if (editSubId.isEmpty() || editSubId == AppConfig.DEFAULT_SUBSCRIPTION_ID) {
|
||||
del_config?.isVisible = false
|
||||
}
|
||||
|
||||
|
||||
@@ -3,18 +3,26 @@ package com.v2ray.ang.ui
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.view.LayoutInflater
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import androidx.activity.viewModels
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.ItemTouchHelper
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.contracts.BaseAdapterListener
|
||||
import com.v2ray.ang.databinding.ActivitySubSettingBinding
|
||||
import com.v2ray.ang.extension.toastError
|
||||
import com.v2ray.ang.extension.toastSuccess
|
||||
import com.v2ray.ang.databinding.ItemQrcodeBinding
|
||||
import com.v2ray.ang.extension.toast
|
||||
import com.v2ray.ang.handler.AngConfigManager
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
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 kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
@@ -22,17 +30,21 @@ import kotlinx.coroutines.launch
|
||||
|
||||
class SubSettingActivity : BaseActivity() {
|
||||
private val binding by lazy { ActivitySubSettingBinding.inflate(layoutInflater) }
|
||||
|
||||
private val ownerActivity: SubSettingActivity
|
||||
get() = this
|
||||
private val viewModel: SubscriptionsViewModel by viewModels()
|
||||
private lateinit var adapter: SubSettingRecyclerAdapter
|
||||
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?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
//setContentView(binding.root)
|
||||
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.layoutManager = LinearLayoutManager(this)
|
||||
@@ -63,14 +75,20 @@ class SubSettingActivity : BaseActivity() {
|
||||
showLoading()
|
||||
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
val count = AngConfigManager.updateConfigViaSubAll()
|
||||
val result = AngConfigManager.updateConfigViaSubAll()
|
||||
delay(500L)
|
||||
launch(Dispatchers.Main) {
|
||||
if (count > 0) {
|
||||
toastSuccess(R.string.toast_success)
|
||||
refreshData()
|
||||
if (result.successCount + result.failureCount + result.skipCount == 0) {
|
||||
toast(R.string.title_update_subscription_no_subscription)
|
||||
} else if (result.successCount > 0 && result.failureCount + result.skipCount == 0) {
|
||||
toast(getString(R.string.title_update_config_count, result.configCount))
|
||||
} else {
|
||||
toastError(R.string.toast_failure)
|
||||
toast(
|
||||
getString(
|
||||
R.string.title_update_subscription_result,
|
||||
result.configCount, result.successCount, result.failureCount, result.skipCount
|
||||
)
|
||||
)
|
||||
}
|
||||
hideLoading()
|
||||
}
|
||||
@@ -88,4 +106,62 @@ class SubSettingActivity : BaseActivity() {
|
||||
viewModel.reload()
|
||||
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,31 @@
|
||||
package com.v2ray.ang.ui
|
||||
|
||||
import android.content.Intent
|
||||
import android.graphics.Color
|
||||
import android.text.TextUtils
|
||||
import android.util.Log
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.databinding.ItemQrcodeBinding
|
||||
import com.v2ray.ang.contracts.BaseAdapterListener
|
||||
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.ItemTouchHelperViewHolder
|
||||
import com.v2ray.ang.util.QRCodeDecoder
|
||||
import com.v2ray.ang.util.Utils
|
||||
import com.v2ray.ang.viewmodel.SubscriptionsViewModel
|
||||
|
||||
class SubSettingRecyclerAdapter(
|
||||
val activity: SubSettingActivity,
|
||||
private val viewModel: SubscriptionsViewModel
|
||||
private val viewModel: SubscriptionsViewModel,
|
||||
private val adapterListener: BaseAdapterListener?
|
||||
) : 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 onBindViewHolder(holder: MainViewHolder, position: Int) {
|
||||
val subscriptions = viewModel.getAll()
|
||||
val subId = subscriptions[position].first
|
||||
val subItem = subscriptions[position].second
|
||||
val subId = subscriptions[position].guid
|
||||
val subItem = subscriptions[position].subscription
|
||||
holder.itemSubSettingBinding.tvName.text = subItem.remarks
|
||||
holder.itemSubSettingBinding.tvUrl.text = subItem.url
|
||||
holder.itemSubSettingBinding.chkEnable.isChecked = subItem.enabled
|
||||
@@ -45,15 +33,13 @@ class SubSettingRecyclerAdapter(
|
||||
holder.itemView.setBackgroundColor(Color.TRANSPARENT)
|
||||
|
||||
holder.itemSubSettingBinding.layoutEdit.setOnClickListener {
|
||||
mActivity.startActivity(
|
||||
Intent(mActivity, SubEditActivity::class.java)
|
||||
.putExtra("subId", subId)
|
||||
)
|
||||
adapterListener?.onEdit(subId, position)
|
||||
}
|
||||
|
||||
holder.itemSubSettingBinding.layoutRemove.setOnClickListener {
|
||||
removeSubscription(subId, position)
|
||||
adapterListener?.onRemove(subId, position)
|
||||
}
|
||||
holder.itemSubSettingBinding.layoutRemove.isVisible = subId != AppConfig.DEFAULT_SUBSCRIPTION_ID
|
||||
|
||||
holder.itemSubSettingBinding.chkEnable.setOnCheckedChangeListener { it, isChecked ->
|
||||
if (!it.isPressed) return@setOnCheckedChangeListener
|
||||
@@ -72,58 +58,11 @@ class SubSettingRecyclerAdapter(
|
||||
holder.itemSubSettingBinding.chkEnable.visibility = View.VISIBLE
|
||||
holder.itemSubSettingBinding.layoutLastUpdated.visibility = View.VISIBLE
|
||||
holder.itemSubSettingBinding.layoutShare.setOnClickListener {
|
||||
AlertDialog.Builder(mActivity)
|
||||
.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()
|
||||
adapterListener?.onShare(subItem.url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
return MainViewHolder(
|
||||
ItemRecyclerSubSettingBinding.inflate(
|
||||
@@ -154,7 +93,7 @@ class SubSettingRecyclerAdapter(
|
||||
}
|
||||
|
||||
override fun onItemMoveCompleted() {
|
||||
mActivity.refreshData()
|
||||
adapterListener?.onRefreshData()
|
||||
}
|
||||
|
||||
override fun onItemDismiss(position: Int) {
|
||||
|
||||
@@ -23,13 +23,14 @@ class TaskerActivity : BaseActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(binding.root)
|
||||
//setContentView(binding.root)
|
||||
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = "")
|
||||
|
||||
//add def value
|
||||
lstData.add("Default")
|
||||
lstGuid.add(AppConfig.TASKER_DEFAULT_GUID)
|
||||
|
||||
MmkvManager.decodeServerList().forEach { key ->
|
||||
MmkvManager.decodeAllServerList().forEach { key ->
|
||||
MmkvManager.decodeServerConfig(key)?.let { config ->
|
||||
lstData.add(config.remarks)
|
||||
lstGuid.add(key)
|
||||
|
||||
@@ -1,95 +1,52 @@
|
||||
package com.v2ray.ang.ui
|
||||
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.AlertDialog
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.provider.OpenableColumns
|
||||
import android.util.Log
|
||||
import android.view.LayoutInflater
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import android.view.View.GONE
|
||||
import android.view.View.VISIBLE
|
||||
import android.view.ViewGroup
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.activity.viewModels
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.contracts.BaseAdapterListener
|
||||
import com.v2ray.ang.databinding.ActivityUserAssetBinding
|
||||
import com.v2ray.ang.databinding.ItemRecyclerUserAssetBinding
|
||||
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.toastError
|
||||
import com.v2ray.ang.extension.toastSuccess
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.handler.SettingsManager
|
||||
import com.v2ray.ang.util.HttpUtil
|
||||
import com.v2ray.ang.util.Utils
|
||||
import com.v2ray.ang.viewmodel.UserAssetViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
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 ownerActivity: UserAssetActivity
|
||||
get() = this
|
||||
private val viewModel: UserAssetViewModel by viewModels()
|
||||
private lateinit var adapter: UserAssetAdapter
|
||||
|
||||
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?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
//setContentView(binding.root)
|
||||
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.title_user_asset_setting))
|
||||
|
||||
binding.recyclerView.setHasFixedSize(true)
|
||||
binding.recyclerView.layoutManager = LinearLayoutManager(this)
|
||||
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.layoutGeoFilesSources.setOnClickListener {
|
||||
@@ -133,17 +90,11 @@ class UserAssetActivity : BaseActivity() {
|
||||
}
|
||||
|
||||
private fun showFileChooser() {
|
||||
val permission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
Manifest.permission.READ_MEDIA_IMAGES
|
||||
} else {
|
||||
Manifest.permission.READ_EXTERNAL_STORAGE
|
||||
}
|
||||
requestStoragePermissionLauncher.launch(permission)
|
||||
}
|
||||
launchFileChooser { uri ->
|
||||
if (uri == null) {
|
||||
return@launchFileChooser
|
||||
}
|
||||
|
||||
private val chooseFile = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
val uri = result.data?.data
|
||||
if (result.resultCode == RESULT_OK && uri != null) {
|
||||
val assetId = Utils.getUuid()
|
||||
runCatching {
|
||||
val assetItem = AssetUrlItem(
|
||||
@@ -152,7 +103,7 @@ class UserAssetActivity : BaseActivity() {
|
||||
)
|
||||
|
||||
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)
|
||||
} else {
|
||||
MmkvManager.encodeAsset(assetId, assetItem)
|
||||
@@ -190,15 +141,14 @@ class UserAssetActivity : BaseActivity() {
|
||||
}
|
||||
|
||||
private fun importAssetFromQRcode(): Boolean {
|
||||
requestCameraPermissionLauncher.launch(Manifest.permission.CAMERA)
|
||||
launchQRCodeScanner { scanResult ->
|
||||
if (scanResult != null) {
|
||||
importAsset(scanResult)
|
||||
}
|
||||
}
|
||||
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 {
|
||||
try {
|
||||
@@ -219,81 +169,25 @@ class UserAssetActivity : BaseActivity() {
|
||||
}
|
||||
|
||||
private fun downloadGeoFiles() {
|
||||
refreshData()
|
||||
showLoading()
|
||||
toast(R.string.msg_downloading_content)
|
||||
|
||||
val httpPort = SettingsManager.getHttpPort()
|
||||
var assets = MmkvManager.decodeAssetUrls()
|
||||
assets = addBuiltInGeoItems(assets)
|
||||
|
||||
var resultCount = 0
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
assets.forEach {
|
||||
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)
|
||||
}
|
||||
}
|
||||
val result = viewModel.downloadGeoFiles(extDir, httpPort)
|
||||
withContext(Dispatchers.Main) {
|
||||
if (resultCount > 0) {
|
||||
toast(getString(R.string.title_update_config_count, resultCount))
|
||||
refreshData()
|
||||
if (result.successCount > 0) {
|
||||
toast(getString(R.string.title_update_config_count, result.successCount))
|
||||
} else {
|
||||
toast(getString(R.string.toast_failure))
|
||||
}
|
||||
refreshData()
|
||||
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() {
|
||||
lifecycleScope.launch(Dispatchers.Default) {
|
||||
SettingsManager.initAssets(this@UserAssetActivity, assets)
|
||||
@@ -305,72 +199,41 @@ class UserAssetActivity : BaseActivity() {
|
||||
|
||||
@SuppressLint("NotifyDataSetChanged")
|
||||
fun refreshData() {
|
||||
binding.recyclerView.adapter?.notifyDataSetChanged()
|
||||
viewModel.reload(getGeoFilesSources())
|
||||
adapter.notifyDataSetChanged()
|
||||
}
|
||||
|
||||
inner class UserAssetAdapter : RecyclerView.Adapter<UserAssetViewHolder>() {
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): UserAssetViewHolder {
|
||||
return UserAssetViewHolder(
|
||||
ItemRecyclerUserAssetBinding.inflate(
|
||||
LayoutInflater.from(parent.context),
|
||||
parent,
|
||||
false
|
||||
)
|
||||
private inner class ActivityAdapterListener : BaseAdapterListener {
|
||||
override fun onEdit(guid: String, position: Int) {
|
||||
startActivity(
|
||||
Intent(ownerActivity, UserAssetUrlActivity::class.java)
|
||||
.putExtra("assetId", guid)
|
||||
)
|
||||
}
|
||||
|
||||
@SuppressLint("SetTextI18n")
|
||||
override fun onBindViewHolder(holder: UserAssetViewHolder, position: Int) {
|
||||
var assets = MmkvManager.decodeAssetUrls()
|
||||
assets = addBuiltInGeoItems(assets)
|
||||
val item = assets.getOrNull(position) ?: return
|
||||
// file with name == item.second.remarks
|
||||
val file = extDir.listFiles()?.find { it.name == item.second.remarks }
|
||||
override fun onRemove(guid: String, position: Int) {
|
||||
val asset = viewModel.getAsset(position)?.takeIf { it.guid == guid }
|
||||
?: viewModel.getAssets().find { it.guid == guid }
|
||||
?: return
|
||||
val file = extDir.listFiles()?.find { it.name == asset.assetUrl.remarks }
|
||||
|
||||
holder.itemUserAssetBinding.assetName.text = item.second.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 = getString(R.string.msg_file_not_found)
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
AlertDialog.Builder(ownerActivity).setMessage(R.string.del_config_comfirm)
|
||||
.setPositiveButton(android.R.string.ok) { _, _ ->
|
||||
file?.delete()
|
||||
MmkvManager.removeAssetUrl(guid)
|
||||
initAssets()
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel) { _, _ ->
|
||||
// do nothing
|
||||
}
|
||||
.show()
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int {
|
||||
var assets = MmkvManager.decodeAssetUrls()
|
||||
assets = addBuiltInGeoItems(assets)
|
||||
return assets.size
|
||||
override fun onShare(url: String) {
|
||||
}
|
||||
|
||||
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
|
||||
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)
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -65,9 +65,9 @@ object QRCodeDecoder {
|
||||
val qrReader = QRCodeReader()
|
||||
|
||||
try {
|
||||
qrReader.decode(BinaryBitmap(GlobalHistogramBinarizer(source)), mapOf(DecodeHintType.TRY_HARDER to true)).text
|
||||
qrReader.decode(BinaryBitmap(GlobalHistogramBinarizer(source)), HINTS).text
|
||||
} 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()
|
||||
}
|
||||
@@ -97,27 +97,9 @@ object QRCodeDecoder {
|
||||
}
|
||||
|
||||
init {
|
||||
val allFormats: List<BarcodeFormat> = arrayListOf(
|
||||
BarcodeFormat.AZTEC,
|
||||
BarcodeFormat.CODABAR,
|
||||
BarcodeFormat.CODE_39,
|
||||
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
|
||||
// Keep decoding hints focused on QR codes and enable TRY_HARDER + UTF-8 charset for better success rate.
|
||||
HINTS[DecodeHintType.TRY_HARDER] = true
|
||||
HINTS[DecodeHintType.POSSIBLE_FORMATS] = listOf(BarcodeFormat.QR_CODE)
|
||||
HINTS[DecodeHintType.CHARACTER_SET] = Charsets.UTF_8.name()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -328,13 +328,48 @@ object Utils {
|
||||
*/
|
||||
fun urlEncode(url: String): String {
|
||||
return try {
|
||||
URLEncoder.encode(url, Charsets.UTF_8.toString()).replace("+", "%20")
|
||||
URLEncoder.encode(url, Charsets.UTF_8.toString())
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Failed to encode URL", e)
|
||||
url
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a "encodeURIComponent" string.
|
||||
*
|
||||
* @param url The "encodeURIComponent" string.
|
||||
* @return The decoded string, or the original string if decoding fails.
|
||||
*/
|
||||
fun decodeURIComponent(url: String): String {
|
||||
return try {
|
||||
// Decode strictly according to RFC 3986 / encodeURIComponent semantics.
|
||||
// '+' is a literal plus and MUST NOT be interpreted as space.
|
||||
// Inputs using '+' for spaces are non-conforming and rejected deliberately
|
||||
// to avoid cross-language interoperability issues.
|
||||
URLDecoder.decode(url.replace("+", "%2B"), Charsets.UTF_8.toString())
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Failed to decode encodeURIComponent", e)
|
||||
url
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a string to "encodeURIComponent" format.
|
||||
*
|
||||
* @param url The string to encode.
|
||||
* @return The "encodeURIComponent" encoded string, or the original string if encoding fails.
|
||||
*/
|
||||
fun encodeURIComponent(url: String): String {
|
||||
return try {
|
||||
// Replace '+' with '%20' to conform to encodeURIComponent semantics.
|
||||
URLEncoder.encode(url, Charsets.UTF_8.toString()).replace("+", "%20")
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Failed to encode encodeURIComponent", e)
|
||||
url
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read text from an asset file.
|
||||
*
|
||||
|
||||
@@ -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,9 @@ import com.v2ray.ang.AngApplication
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.dto.GroupMapItem
|
||||
import com.v2ray.ang.dto.ProfileItem
|
||||
import com.v2ray.ang.dto.ServersCache
|
||||
import com.v2ray.ang.dto.SubscriptionCache
|
||||
import com.v2ray.ang.dto.SubscriptionUpdateResult
|
||||
import com.v2ray.ang.extension.serializable
|
||||
import com.v2ray.ang.extension.toastError
|
||||
import com.v2ray.ang.extension.toastSuccess
|
||||
@@ -35,10 +36,8 @@ import kotlinx.coroutines.withContext
|
||||
import java.util.Collections
|
||||
|
||||
class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
private var serverList = MmkvManager.decodeServerList()
|
||||
private var serverList = mutableListOf<String>() // MmkvManager.decodeServerList()
|
||||
var subscriptionId: String = MmkvManager.decodeSettingsString(AppConfig.CACHE_SUBSCRIPTION_ID, "").orEmpty()
|
||||
|
||||
//var keywordFilter: String = MmkvManager.MmkvManager.decodeSettingsString(AppConfig.CACHE_KEYWORD_FILTER, "")?:""
|
||||
var keywordFilter = ""
|
||||
val serversCache = mutableListOf<ServersCache>()
|
||||
val isRunning by lazy { MutableLiveData<Boolean>() }
|
||||
@@ -69,10 +68,15 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Reloads the server list.
|
||||
* Reloads the server list based on current subscription filter.
|
||||
*/
|
||||
fun reloadServerList() {
|
||||
serverList = MmkvManager.decodeServerList()
|
||||
serverList = if (subscriptionId.isEmpty()) {
|
||||
MmkvManager.decodeAllServerList()
|
||||
} else {
|
||||
MmkvManager.decodeServerList(subscriptionId)
|
||||
}
|
||||
|
||||
updateCache()
|
||||
updateListAction.value = -1
|
||||
}
|
||||
@@ -90,38 +94,6 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
}
|
||||
}
|
||||
|
||||
// /**
|
||||
// * Appends a custom configuration server.
|
||||
// * @param server The server configuration to append.
|
||||
// * @return True if the server was successfully appended, false otherwise.
|
||||
// */
|
||||
// fun appendCustomConfigServer(server: String): Boolean {
|
||||
// if (server.contains("inbounds")
|
||||
// && server.contains("outbounds")
|
||||
// && server.contains("routing")
|
||||
// ) {
|
||||
// try {
|
||||
// val config = CustomFmt.parse(server) ?: return false
|
||||
// config.subscriptionId = subscriptionId
|
||||
// val key = MmkvManager.encodeServerConfig("", config)
|
||||
// MmkvManager.encodeServerRaw(key, server)
|
||||
// serverList.add(0, key)
|
||||
//// val profile = ProfileLiteItem(
|
||||
//// configType = config.configType,
|
||||
//// subscriptionId = config.subscriptionId,
|
||||
//// remarks = config.remarks,
|
||||
//// server = config.getProxyOutbound()?.getServerAddress(),
|
||||
//// serverPort = config.getProxyOutbound()?.getServerPort(),
|
||||
//// )
|
||||
// serversCache.add(0, ServersCache(key, config))
|
||||
// return true
|
||||
// } catch (e: Exception) {
|
||||
// e.printStackTrace()
|
||||
// }
|
||||
// }
|
||||
// return false
|
||||
// }
|
||||
|
||||
/**
|
||||
* Swaps the positions of two servers.
|
||||
* @param fromPosition The initial position of the server.
|
||||
@@ -129,14 +101,13 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
*/
|
||||
fun swapServer(fromPosition: Int, toPosition: Int) {
|
||||
if (subscriptionId.isEmpty()) {
|
||||
Collections.swap(serverList, fromPosition, toPosition)
|
||||
} else {
|
||||
val fromPosition2 = serverList.indexOf(serversCache[fromPosition].guid)
|
||||
val toPosition2 = serverList.indexOf(serversCache[toPosition].guid)
|
||||
Collections.swap(serverList, fromPosition2, toPosition2)
|
||||
return
|
||||
}
|
||||
|
||||
Collections.swap(serverList, fromPosition, toPosition)
|
||||
Collections.swap(serversCache, fromPosition, toPosition)
|
||||
MmkvManager.encodeServerList(serverList)
|
||||
|
||||
MmkvManager.encodeServerList(serverList, subscriptionId)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -145,26 +116,19 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
@Synchronized
|
||||
fun updateCache() {
|
||||
serversCache.clear()
|
||||
val kw = keywordFilter.trim().lowercase()
|
||||
for (guid in serverList) {
|
||||
val profile = MmkvManager.decodeServerConfig(guid) ?: continue
|
||||
// var profile = MmkvManager.decodeProfileConfig(guid)
|
||||
// if (profile == null) {
|
||||
// val config = MmkvManager.decodeServerConfig(guid) ?: continue
|
||||
// profile = ProfileLiteItem(
|
||||
// configType = config.configType,
|
||||
// subscriptionId = config.subscriptionId,
|
||||
// remarks = config.remarks,
|
||||
// server = config.getProxyOutbound()?.getServerAddress(),
|
||||
// serverPort = config.getProxyOutbound()?.getServerPort(),
|
||||
// )
|
||||
// MmkvManager.encodeServerConfig(guid, config)
|
||||
// }
|
||||
|
||||
if (subscriptionId.isNotEmpty() && subscriptionId != profile.subscriptionId) {
|
||||
if (kw.isEmpty()) {
|
||||
serversCache.add(ServersCache(guid, profile))
|
||||
continue
|
||||
}
|
||||
|
||||
if (keywordFilter.isEmpty() || profile.remarks.lowercase().contains(keywordFilter.lowercase())) {
|
||||
val remarks = profile.remarks.lowercase()
|
||||
val description = profile.description.orEmpty().lowercase()
|
||||
val server = profile.server.orEmpty().lowercase()
|
||||
|
||||
if (remarks.contains(kw) || description.contains(kw) || server.contains(kw)) {
|
||||
serversCache.add(ServersCache(guid, profile))
|
||||
}
|
||||
}
|
||||
@@ -172,14 +136,14 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
/**
|
||||
* Updates the configuration via subscription for all servers.
|
||||
* @return The number of updated configurations.
|
||||
* @return Detailed result of the subscription update operation.
|
||||
*/
|
||||
fun updateConfigViaSubAll(): Int {
|
||||
fun updateConfigViaSubAll(): SubscriptionUpdateResult {
|
||||
if (subscriptionId.isEmpty()) {
|
||||
return AngConfigManager.updateConfigViaSubAll()
|
||||
} else {
|
||||
val subItem = MmkvManager.decodeSubscription(subscriptionId) ?: return 0
|
||||
return AngConfigManager.updateConfigViaSub(Pair(subscriptionId, subItem))
|
||||
val subItem = MmkvManager.decodeSubscription(subscriptionId) ?: return SubscriptionUpdateResult()
|
||||
return AngConfigManager.updateConfigViaSub(SubscriptionCache(subscriptionId, subItem))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -236,13 +200,11 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
MmkvManager.clearAllTestDelayResults(serversCache.map { it.guid }.toList())
|
||||
updateListAction.value = -1
|
||||
|
||||
val serversCopy = serversCache.toList()
|
||||
viewModelScope.launch(Dispatchers.Default) {
|
||||
val guids = ArrayList<String>(serversCopy.map { it.guid })
|
||||
if (guids.isEmpty()) {
|
||||
if (serversCache.isEmpty()) {
|
||||
return@launch
|
||||
}
|
||||
MessageUtil.sendMsg2TestService(getApplication(), AppConfig.MSG_MEASURE_CONFIG, guids)
|
||||
MessageUtil.sendMsg2TestService(getApplication(), AppConfig.MSG_MEASURE_CONFIG, subscriptionId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -273,20 +235,29 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
fun getSubscriptions(context: Context): List<GroupMapItem> {
|
||||
val subscriptions = MmkvManager.decodeSubscriptions()
|
||||
if (subscriptionId.isNotEmpty()
|
||||
&& !subscriptions.map { it.first }.contains(subscriptionId)
|
||||
&& !subscriptions.map { it.guid }.contains(subscriptionId)
|
||||
) {
|
||||
subscriptionIdChanged("")
|
||||
}
|
||||
|
||||
val groups = mutableListOf<GroupMapItem>()
|
||||
groups.add(
|
||||
GroupMapItem(
|
||||
id = "",
|
||||
remarks = context.getString(R.string.filter_config_all)
|
||||
if (subscriptions.size > 1
|
||||
&& MmkvManager.decodeSettingsBool(AppConfig.PREF_GROUP_ALL_DISPLAY)
|
||||
) {
|
||||
groups.add(
|
||||
GroupMapItem(
|
||||
id = "",
|
||||
remarks = context.getString(R.string.filter_config_all)
|
||||
)
|
||||
)
|
||||
}
|
||||
subscriptions.forEach { sub ->
|
||||
groups.add(
|
||||
GroupMapItem(
|
||||
id = sub.guid,
|
||||
remarks = sub.subscription.remarks
|
||||
)
|
||||
)
|
||||
)
|
||||
subscriptions.forEach { (id, item) ->
|
||||
groups.add(GroupMapItem(id = id, remarks = item.remarks))
|
||||
}
|
||||
return groups
|
||||
}
|
||||
@@ -309,20 +280,15 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
* @return The number of removed servers.
|
||||
*/
|
||||
fun removeDuplicateServer(): Int {
|
||||
val serversCacheCopy = mutableListOf<Pair<String, ProfileItem>>()
|
||||
for (it in serversCache) {
|
||||
val config = MmkvManager.decodeServerConfig(it.guid) ?: continue
|
||||
serversCacheCopy.add(Pair(it.guid, config))
|
||||
}
|
||||
|
||||
val serversCacheCopy = serversCache.toList().toMutableList()
|
||||
val deleteServer = mutableListOf<String>()
|
||||
serversCacheCopy.forEachIndexed { index, it ->
|
||||
val outbound = it.second
|
||||
serversCacheCopy.forEachIndexed { index2, it2 ->
|
||||
serversCacheCopy.forEachIndexed { index, sc ->
|
||||
val profile = sc.profile
|
||||
serversCacheCopy.forEachIndexed { index2, sc2 ->
|
||||
if (index2 > index) {
|
||||
val outbound2 = it2.second
|
||||
if (outbound.equals(outbound2) && !deleteServer.contains(it2.first)) {
|
||||
deleteServer.add(it2.first)
|
||||
val profile2 = sc2.profile
|
||||
if (profile == profile2 && !deleteServer.contains(sc2.guid)) {
|
||||
deleteServer.add(sc2.guid)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -373,24 +339,38 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
* Sorts servers by their test results.
|
||||
*/
|
||||
fun sortByTestResults() {
|
||||
if (subscriptionId.isEmpty()) {
|
||||
MmkvManager.decodeSubsList().forEach { guid ->
|
||||
sortByTestResultsForSub(guid)
|
||||
}
|
||||
} else {
|
||||
sortByTestResultsForSub(subscriptionId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sorts servers by their test results for a specific subscription.
|
||||
* @param subId The subscription ID to sort servers for.
|
||||
*/
|
||||
private fun sortByTestResultsForSub(subId: String) {
|
||||
data class ServerDelay(var guid: String, var testDelayMillis: Long)
|
||||
|
||||
val serverDelays = mutableListOf<ServerDelay>()
|
||||
val serverList = MmkvManager.decodeServerList()
|
||||
serverList.forEach { key ->
|
||||
val serverListToSort = MmkvManager.decodeServerList(subId)
|
||||
|
||||
serverListToSort.forEach { key ->
|
||||
val delay = MmkvManager.decodeServerAffiliationInfo(key)?.testDelayMillis ?: 0L
|
||||
serverDelays.add(ServerDelay(key, if (delay <= 0L) 999999 else delay))
|
||||
}
|
||||
serverDelays.sortBy { it.testDelayMillis }
|
||||
|
||||
serverDelays.forEach {
|
||||
serverList.remove(it.guid)
|
||||
serverList.add(it.guid)
|
||||
}
|
||||
val sortedServerList = serverDelays.map { it.guid }.toMutableList()
|
||||
|
||||
MmkvManager.encodeServerList(serverList)
|
||||
// Save the sorted list for this subscription
|
||||
MmkvManager.encodeServerList(sortedServerList, subId)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Initializes assets.
|
||||
* @param assets The asset manager.
|
||||
@@ -410,7 +390,6 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
return
|
||||
}
|
||||
keywordFilter = keyword
|
||||
MmkvManager.encodeSettings(AppConfig.CACHE_KEYWORD_FILTER, keywordFilter)
|
||||
reloadServerList()
|
||||
}
|
||||
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
package com.v2ray.ang.viewmodel
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import com.v2ray.ang.dto.SubscriptionCache
|
||||
import com.v2ray.ang.dto.SubscriptionItem
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.handler.SettingsChangeManager
|
||||
import com.v2ray.ang.handler.SettingsManager
|
||||
|
||||
class SubscriptionsViewModel : ViewModel() {
|
||||
private val subscriptions: MutableList<Pair<String, SubscriptionItem>> =
|
||||
private val subscriptions: MutableList<SubscriptionCache> =
|
||||
MmkvManager.decodeSubscriptions().toMutableList()
|
||||
|
||||
fun getAll(): List<Pair<String, SubscriptionItem>> = subscriptions.toList()
|
||||
fun getAll(): List<SubscriptionCache> = subscriptions.toList()
|
||||
|
||||
fun reload() {
|
||||
subscriptions.clear()
|
||||
@@ -18,7 +19,7 @@ class SubscriptionsViewModel : ViewModel() {
|
||||
}
|
||||
|
||||
fun remove(subId: String): Boolean {
|
||||
val changed = subscriptions.removeAll { it.first == subId }
|
||||
val changed = subscriptions.removeAll { it.guid == subId }
|
||||
if (changed) {
|
||||
MmkvManager.removeSubscription(subId)
|
||||
SettingsChangeManager.makeSetupGroupTab()
|
||||
@@ -27,9 +28,9 @@ class SubscriptionsViewModel : ViewModel() {
|
||||
}
|
||||
|
||||
fun update(subId: String, item: SubscriptionItem) {
|
||||
val idx = subscriptions.indexOfFirst { it.first == subId }
|
||||
val idx = subscriptions.indexOfFirst { it.guid == subId }
|
||||
if (idx >= 0) {
|
||||
subscriptions[idx] = Pair(subId, item)
|
||||
subscriptions[idx] = SubscriptionCache(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>
|
||||
)
|
||||
}
|
||||
@@ -29,6 +29,7 @@
|
||||
<ImageView
|
||||
android:layout_width="@dimen/image_size_dp24"
|
||||
android:layout_height="@dimen/image_size_dp24"
|
||||
android:importantForAccessibility="no"
|
||||
app:srcCompat="@drawable/ic_source_code_24dp" />
|
||||
|
||||
<TextView
|
||||
@@ -53,6 +54,7 @@
|
||||
<ImageView
|
||||
android:layout_width="@dimen/image_size_dp24"
|
||||
android:layout_height="@dimen/image_size_dp24"
|
||||
android:importantForAccessibility="no"
|
||||
app:srcCompat="@drawable/license_24px" />
|
||||
|
||||
<TextView
|
||||
@@ -77,6 +79,7 @@
|
||||
<ImageView
|
||||
android:layout_width="@dimen/image_size_dp24"
|
||||
android:layout_height="@dimen/image_size_dp24"
|
||||
android:importantForAccessibility="no"
|
||||
app:srcCompat="@drawable/ic_feedback_24dp" />
|
||||
|
||||
<TextView
|
||||
@@ -102,6 +105,7 @@
|
||||
<ImageView
|
||||
android:layout_width="@dimen/image_size_dp24"
|
||||
android:layout_height="@dimen/image_size_dp24"
|
||||
android:importantForAccessibility="no"
|
||||
app:srcCompat="@drawable/ic_telegram_24dp" />
|
||||
|
||||
<TextView
|
||||
@@ -126,6 +130,7 @@
|
||||
<ImageView
|
||||
android:layout_width="@dimen/image_size_dp24"
|
||||
android:layout_height="@dimen/image_size_dp24"
|
||||
android:importantForAccessibility="no"
|
||||
app:srcCompat="@drawable/ic_privacy_24dp" />
|
||||
|
||||
<TextView
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
<ImageView
|
||||
android:layout_width="@dimen/image_size_dp24"
|
||||
android:layout_height="@dimen/image_size_dp24"
|
||||
android:importantForAccessibility="no"
|
||||
app:srcCompat="@drawable/ic_backup_24dp" />
|
||||
|
||||
<LinearLayout
|
||||
@@ -61,6 +62,7 @@
|
||||
<ImageView
|
||||
android:layout_width="@dimen/image_size_dp24"
|
||||
android:layout_height="@dimen/image_size_dp24"
|
||||
android:importantForAccessibility="no"
|
||||
app:srcCompat="@drawable/ic_share_24dp" />
|
||||
|
||||
<TextView
|
||||
@@ -85,6 +87,7 @@
|
||||
<ImageView
|
||||
android:layout_width="@dimen/image_size_dp24"
|
||||
android:layout_height="@dimen/image_size_dp24"
|
||||
android:importantForAccessibility="no"
|
||||
app:srcCompat="@drawable/ic_restore_24dp" />
|
||||
|
||||
<TextView
|
||||
@@ -117,6 +120,7 @@
|
||||
<ImageView
|
||||
android:layout_width="@dimen/image_size_dp24"
|
||||
android:layout_height="@dimen/image_size_dp24"
|
||||
android:importantForAccessibility="no"
|
||||
app:srcCompat="@drawable/ic_settings_24dp" />
|
||||
|
||||
<LinearLayout
|
||||
|
||||
@@ -68,6 +68,7 @@
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:clickable="true"
|
||||
android:contentDescription="@string/switch_bypass_apps_mode"
|
||||
android:focusable="true"
|
||||
android:gravity="center"
|
||||
android:padding="@dimen/padding_spacing_dp8">
|
||||
@@ -75,6 +76,7 @@
|
||||
<ImageView
|
||||
android:layout_width="@dimen/image_size_dp24"
|
||||
android:layout_height="@dimen/image_size_dp24"
|
||||
android:importantForAccessibility="no"
|
||||
app:srcCompat="@drawable/ic_about_24dp" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
<ImageView
|
||||
android:layout_width="@dimen/image_size_dp24"
|
||||
android:layout_height="@dimen/image_size_dp24"
|
||||
android:importantForAccessibility="no"
|
||||
app:srcCompat="@drawable/ic_source_code_24dp" />
|
||||
|
||||
<androidx.appcompat.widget.SwitchCompat
|
||||
@@ -49,6 +50,7 @@
|
||||
<ImageView
|
||||
android:layout_width="@dimen/image_size_dp24"
|
||||
android:layout_height="@dimen/image_size_dp24"
|
||||
android:importantForAccessibility="no"
|
||||
app:srcCompat="@drawable/ic_check_update_24dp" />
|
||||
|
||||
<TextView
|
||||
|
||||
@@ -65,6 +65,7 @@
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="@dimen/view_height_dp64"
|
||||
android:clickable="true"
|
||||
android:contentDescription="@string/connection_test_pending"
|
||||
android:focusable="true"
|
||||
android:nextFocusLeft="@+id/view_pager"
|
||||
android:nextFocusRight="@+id/fab"
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
android:id="@+id/iv_qcode"
|
||||
android:layout_width="336dp"
|
||||
android:layout_height="336dp"
|
||||
android:contentDescription="@string/title_qr_code"
|
||||
android:scaleType="fitXY"
|
||||
app:srcCompat="@drawable/ic_fab_check" />
|
||||
</LinearLayout>
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
android:layout_height="@dimen/padding_spacing_dp16"
|
||||
android:layout_gravity="center"
|
||||
android:layout_marginStart="@dimen/padding_spacing_dp16"
|
||||
android:importantForAccessibility="no"
|
||||
app:srcCompat="@drawable/ic_lock_24dp" />
|
||||
|
||||
</LinearLayout>
|
||||
@@ -78,6 +79,7 @@
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:clickable="true"
|
||||
android:contentDescription="@string/menu_item_edit_config"
|
||||
android:focusable="true"
|
||||
android:gravity="center"
|
||||
android:nextFocusLeft="@+id/info_container"
|
||||
@@ -87,6 +89,7 @@
|
||||
<ImageView
|
||||
android:layout_width="@dimen/image_size_dp24"
|
||||
android:layout_height="@dimen/image_size_dp24"
|
||||
android:importantForAccessibility="no"
|
||||
app:srcCompat="@drawable/ic_edit_24dp" />
|
||||
</LinearLayout>
|
||||
|
||||
|
||||
@@ -58,6 +58,7 @@
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:clickable="true"
|
||||
android:contentDescription="@string/title_configuration_share"
|
||||
android:focusable="true"
|
||||
android:gravity="center"
|
||||
android:nextFocusLeft="@+id/info_container"
|
||||
@@ -67,6 +68,7 @@
|
||||
<ImageView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="@dimen/image_size_dp24"
|
||||
android:importantForAccessibility="no"
|
||||
app:srcCompat="@drawable/ic_share_24dp" />
|
||||
|
||||
</LinearLayout>
|
||||
@@ -77,6 +79,7 @@
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:clickable="true"
|
||||
android:contentDescription="@string/menu_item_edit_config"
|
||||
android:focusable="true"
|
||||
android:gravity="center"
|
||||
android:orientation="vertical"
|
||||
@@ -85,6 +88,7 @@
|
||||
<ImageView
|
||||
android:layout_width="@dimen/image_size_dp24"
|
||||
android:layout_height="@dimen/image_size_dp24"
|
||||
android:importantForAccessibility="no"
|
||||
app:srcCompat="@drawable/ic_edit_24dp" />
|
||||
|
||||
</LinearLayout>
|
||||
@@ -95,6 +99,7 @@
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:clickable="true"
|
||||
android:contentDescription="@string/menu_item_del_config"
|
||||
android:focusable="true"
|
||||
android:gravity="center"
|
||||
android:orientation="vertical"
|
||||
@@ -103,6 +108,7 @@
|
||||
<ImageView
|
||||
android:layout_width="@dimen/image_size_dp24"
|
||||
android:layout_height="@dimen/image_size_dp24"
|
||||
android:importantForAccessibility="no"
|
||||
app:srcCompat="@drawable/ic_delete_24dp" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
@@ -58,12 +58,14 @@
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:clickable="true"
|
||||
android:contentDescription="@string/menu_item_edit_config"
|
||||
android:focusable="true"
|
||||
android:padding="@dimen/padding_spacing_dp8">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="@dimen/image_size_dp24"
|
||||
android:layout_height="@dimen/image_size_dp24"
|
||||
android:importantForAccessibility="no"
|
||||
app:srcCompat="@drawable/ic_edit_24dp" />
|
||||
</LinearLayout>
|
||||
|
||||
@@ -73,12 +75,14 @@
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:clickable="true"
|
||||
android:contentDescription="@string/menu_item_del_config"
|
||||
android:focusable="true"
|
||||
android:padding="@dimen/padding_spacing_dp8">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="@dimen/image_size_dp24"
|
||||
android:layout_height="@dimen/image_size_dp24"
|
||||
android:importantForAccessibility="no"
|
||||
app:srcCompat="@drawable/ic_delete_24dp" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
@@ -158,6 +158,27 @@
|
||||
android:entries="@array/ech_force_query_value" />
|
||||
</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
|
||||
android:id="@+id/lay_public_key"
|
||||
android:layout_width="match_parent"
|
||||
|
||||
@@ -80,10 +80,10 @@
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/server_lab_stream_pinsha256" />
|
||||
android:text="@string/server_lab_pinned_ca256" />
|
||||
|
||||
<EditText
|
||||
android:id="@+id/et_pinsha256"
|
||||
android:id="@+id/et_pinned_ca256"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="text" />
|
||||
|
||||
@@ -8,4 +8,5 @@
|
||||
android:text="@string/title_mode_help"
|
||||
android:textAlignment="textStart"
|
||||
android:textStyle="italic"
|
||||
android:textColor="?attr/colorPrimary"
|
||||
tools:ignore="UsingOnClickInXml" />
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
android:id="@+id/image_switch"
|
||||
android:layout_width="45dp"
|
||||
android:layout_height="45dp"
|
||||
android:importantForAccessibility="no"
|
||||
android:padding="@dimen/padding_spacing_dp16"
|
||||
app:srcCompat="@drawable/ic_stat_name" />
|
||||
</LinearLayout>
|
||||
|
||||
@@ -109,13 +109,13 @@
|
||||
<string name="server_obfs_password">Obfs password</string>
|
||||
<string name="server_lab_port_hop">Port Hopping</string>
|
||||
<string name="server_lab_port_hop_interval">Port Hopping Interval</string>
|
||||
<string name="server_lab_stream_pinsha256">pinSHA256</string>
|
||||
<string name="server_lab_bandwidth_down">Bandwidth down (Supported units: k/m/g/t)</string>
|
||||
<string name="server_lab_bandwidth_up">Bandwidth up (Supported units: k/m/g/t)</string>
|
||||
<string name="server_lab_xhttp_mode">XHTTP Mode</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_force_query">EchForceQuery</string>
|
||||
<string name="server_lab_pinned_ca256">Certificate fingerprint (SHA-256)</string>
|
||||
|
||||
<!-- UserAssetActivity -->
|
||||
<string name="toast_asset_copy_failed">فشل نسخ الملف، يرجى استخدام مدير الملفات</string>
|
||||
@@ -234,6 +234,8 @@
|
||||
<string name="title_pref_double_column_display">Enable double column display</string>
|
||||
<string name="summary_pref_double_column_display">The profile list is displayed in double columns, allowing more content to be displayed on the screen. You need to restart the application to take effect.</string>
|
||||
|
||||
<string name="title_pref_group_all_display">Enable show all groups</string>
|
||||
<string name="summary_pref_group_all_display">Add an extra "All Tabs" page</string>
|
||||
<!-- AboutActivity -->
|
||||
<string name="title_pref_feedback">ملاحظات</string>
|
||||
<string name="summary_pref_feedback">ملاحظات التحسينات أو الأخطاء إلى GitHub</string>
|
||||
@@ -295,6 +297,8 @@
|
||||
<string name="title_import_config_count">Import %d configurations</string>
|
||||
<string name="title_export_config_count">Export %d configurations</string>
|
||||
<string name="title_update_config_count">Update %d configurations</string>
|
||||
<string name="title_update_subscription_result">Updated %1$d configs (%2$d success, %3$d failed, %4$d skipped)</string>
|
||||
<string name="title_update_subscription_no_subscription">No subscriptions</string>
|
||||
<string name="tasker_start_service">بدء الخدمة</string>
|
||||
<string name="tasker_setting_confirm">تأكيد</string>
|
||||
|
||||
@@ -399,6 +403,8 @@
|
||||
<string-array name="policy_group_type">
|
||||
<item>Least Ping</item>
|
||||
<item>Least Load</item>
|
||||
<item>Random</item>
|
||||
<item>Round Robin</item>
|
||||
</string-array>
|
||||
|
||||
<string-array name="config_backup_options">
|
||||
|
||||
@@ -108,13 +108,13 @@
|
||||
<string name="server_obfs_password">Obfs password</string>
|
||||
<string name="server_lab_port_hop">Port Hopping</string>
|
||||
<string name="server_lab_port_hop_interval">Port Hopping Interval</string>
|
||||
<string name="server_lab_stream_pinsha256">pinSHA256</string>
|
||||
<string name="server_lab_bandwidth_down">Bandwidth down (Supported units: k/m/g/t)</string>
|
||||
<string name="server_lab_bandwidth_up">Bandwidth up (Supported units: k/m/g/t)</string>
|
||||
<string name="server_lab_xhttp_mode">XHTTP Mode</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_force_query">EchForceQuery</string>
|
||||
<string name="server_lab_pinned_ca256">Certificate fingerprint (SHA-256)</string>
|
||||
|
||||
<!-- UserAssetActivity -->
|
||||
<string name="toast_asset_copy_failed">ফাইল কপি ব্যর্থ, অনুগ্রহ করে ফাইল ম্যানেজার ব্যবহার করুন</string>
|
||||
@@ -234,6 +234,8 @@
|
||||
<string name="title_pref_double_column_display">Enable double column display</string>
|
||||
<string name="summary_pref_double_column_display">The profile list is displayed in double columns, allowing more content to be displayed on the screen. You need to restart the application to take effect.</string>
|
||||
|
||||
<string name="title_pref_group_all_display">Enable show all groups</string>
|
||||
<string name="summary_pref_group_all_display">Add an extra "All Tabs" page</string>
|
||||
<!-- AboutActivity -->
|
||||
<string name="title_pref_feedback">মতামত</string>
|
||||
<string name="summary_pref_feedback">মতামত উন্নয়ন বা বাগগুলি GitHub-এ পাঠান</string>
|
||||
@@ -294,6 +296,8 @@
|
||||
<string name="title_import_config_count">Import %d configurations</string>
|
||||
<string name="title_export_config_count">Export %d configurations</string>
|
||||
<string name="title_update_config_count">Update %d configurations</string>
|
||||
<string name="title_update_subscription_result">Updated %1$d configs (%2$d success, %3$d failed, %4$d skipped)</string>
|
||||
<string name="title_update_subscription_no_subscription">No subscriptions</string>
|
||||
<string name="tasker_start_service">সার্ভিস শুরু করুন</string>
|
||||
<string name="tasker_setting_confirm">নিশ্চিত করুন</string>
|
||||
|
||||
@@ -405,6 +409,8 @@
|
||||
<string-array name="policy_group_type">
|
||||
<item>Least Ping</item>
|
||||
<item>Least Load</item>
|
||||
<item>Random</item>
|
||||
<item>Round Robin</item>
|
||||
</string-array>
|
||||
|
||||
<string-array name="config_backup_options">
|
||||
|
||||
@@ -15,10 +15,10 @@
|
||||
<string name="toast_permission_denied">گرؽڌن موجوز مومکن نؽڌ</string>
|
||||
<string name="toast_permission_denied_notification">گرؽڌن موجوز وارسۊوی مومکن نؽڌ</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_success">ر وستن خدمات وا مووفقیت ٱنجوم وابی</string>
|
||||
<string name="toast_services_failure">ر وستن خدمات وا مووفقیت ٱنجوم نوابی</string>
|
||||
<string name="toast_services_success">ره وستن خدمات وا مووفقیت ٱنجوم وابی</string>
|
||||
<string name="toast_services_failure">ره وستن خدمات وا مووفقیت ٱنجوم نوابی</string>
|
||||
|
||||
<!--ServerActivity-->
|
||||
<string name="title_server">فایل کانفیگ</string>
|
||||
@@ -108,13 +108,13 @@
|
||||
<string name="server_obfs_password">رزم obfs</string>
|
||||
<string name="server_lab_port_hop">پورت گوم (درگا سرورن ز نۊ هؽل اکونه)</string>
|
||||
<string name="server_lab_port_hop_interval">فاسله پورت گوم (سانیه)</string>
|
||||
<string name="server_lab_stream_pinsha256">pinSHA256</string>
|
||||
<string name="server_lab_bandwidth_down">ب لم ٱووڌن پئنا باند (واهڌ)</string>
|
||||
<string name="server_lab_bandwidth_up">وا روء رئڌن پئنا باند (واهڌ)</string>
|
||||
<string name="server_lab_xhttp_mode">هالت XHTTP</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_force_query">پورس وو جۊ اجباری Ech</string>
|
||||
<string name="server_lab_pinned_ca256">Certificate fingerprint (SHA-256)</string>
|
||||
|
||||
<!-- UserAssetActivity -->
|
||||
<string name="toast_asset_copy_failed">لف گیری فایل ٱنجوم نوابی، ز ی برنومه دؽوۉداری فایل هیاری بگرین</string>
|
||||
@@ -133,7 +133,7 @@
|
||||
<string name="msg_dialog_progress">هونی بار ونی بۊ</string>
|
||||
<string name="menu_item_search">پیتینیڌن</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="switch_bypass_apps_mode">هالت Bypass</string>
|
||||
<string name="menu_item_select_proxy_app">پسند خوتکار پروکسی برنومه</string>
|
||||
@@ -141,7 +141,7 @@
|
||||
<string name="menu_item_export_proxy_app">و در کشیڌن من کلیپ بورد</string>
|
||||
<string name="menu_item_import_proxy_app">و من ٱووردن ز کلیپ بورد</string>
|
||||
<string name="per_app_proxy_settings">سامووا ب تفکیک برنومه</string>
|
||||
<string name="per_app_proxy_settings_enable">ر وندن ب تفکیک برنومه</string>
|
||||
<string name="per_app_proxy_settings_enable">ره وندن ب تفکیک برنومه</string>
|
||||
|
||||
|
||||
<!-- Preferences -->
|
||||
@@ -151,16 +151,16 @@
|
||||
<string name="title_vpn_settings">سامووا VPN</string>
|
||||
<string name="title_pref_per_app_proxy">پروکسی و ری برنومه</string>
|
||||
<string name="summary_pref_per_app_proxy">پوی وولاتی: برنومه واجۊری بیڌه پروکسی هڌ، منپیز موستقیم بؽ نشووه هڌ. هالت دور زیڌن: برنومه نشووک ناڌه موستقیمن منپیز هڌ، پروکسی نشووک زیڌه نؽڌ. گۊزینه پسند خوتکار برنومه پروکسی من نومگه</string>
|
||||
<string name="title_pref_is_booted">منپیز خوتکار مجال ر ونی</string>
|
||||
<string name="summary_pref_is_booted">مجال ر وندن، خوساخوس و سرور پسند بیڌه منپیز ابۊ که گاشڌ نا مووفق بۊ.</string>
|
||||
<string name="title_pref_is_booted">منپیز خوتکار مجال ره ونی</string>
|
||||
<string name="summary_pref_is_booted">مجال ره وندن، خوساخوس و سرور پسند بیڌه منپیز ابۊ که گاشڌ نا مووفق بۊ.</string>
|
||||
|
||||
<string name="title_pref_auto_remove_invalid_after_test">Auto delete invalid config after testing</string>
|
||||
<string name="summary_pref_auto_remove_invalid_after_test">Test results may not be accurate; deleted config cannot be recovered.</string>
|
||||
<string name="title_pref_auto_sort_after_test">Auto sort after testing</string>
|
||||
<string name="summary_pref_auto_sort_after_test">Test results may not be accurate;</string>
|
||||
<string name="title_pref_auto_remove_invalid_after_test">پاک کردن خوتکار کانفیگ نا موئتبر بئڌ آزمایش</string>
|
||||
<string name="summary_pref_auto_remove_invalid_after_test">نتیجه یل آزمایش گاشڌ دییق نبۊن؛ کانفیگ پاک وابیڌه ن نتری وورگنی.</string>
|
||||
<string name="title_pref_auto_sort_after_test">ترتیب خوتکار بئڌ آزمایش</string>
|
||||
<string name="summary_pref_auto_sort_after_test">نتیجه یل آزمایش گاشڌ دییق نبۊن؛</string>
|
||||
|
||||
<string name="title_mux_settings">سامووا Mux</string>
|
||||
<string name="title_pref_mux_enabled">ر وندن Mux</string>
|
||||
<string name="title_pref_mux_enabled">ره وندن Mux</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_xudp_concurency">منپیزا XUDP (تلایه منجا 1-1024)</string>
|
||||
@@ -171,24 +171,24 @@
|
||||
<item>گوم زیڌن</item>
|
||||
</string-array>
|
||||
|
||||
<string name="title_pref_speed_enabled">ر وندن نشووݩ داڌن سورعت</string>
|
||||
<string name="summary_pref_speed_enabled">نشووݩ داڌن سورعت هیم سکویی من وارسۊویا. نماڌ وارسۊوی و ری و کار گرؽڌن آلشت ابۊ.</string>
|
||||
<string name="title_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="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="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="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="title_pref_prefer_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="title_pref_vpn_dns">VPN DNS (تینا IPv4/v6)</string>
|
||||
@@ -203,14 +203,14 @@
|
||||
<string name="title_pref_dns_hosts">DNS هاست موستقیم (قالوو: دامنه: نشۊوی،...)</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="title_pref_ip_api_url">نشۊوی اینترنتی آزمایش دووسمندیا منپیز هیم سکویی</string>
|
||||
<string name="summary_pref_ip_api_url">نشۊوی اینترنتی</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="title_pref_allow_insecure">اجازه نا ٱمن</string>
|
||||
@@ -225,15 +225,17 @@
|
||||
<string name="title_pref_confirm_remove">قوۊل کردن پاک کردن کانفیگ</string>
|
||||
<string name="summary_pref_confirm_remove">سی پاک وابیڌن فایل کانفیگ نیاز به قوۊل کردن دووارته ز سمت منتور هڌ.</string>
|
||||
|
||||
<string name="title_pref_start_scan_immediate">زی اسکنن ر ون</string>
|
||||
<string name="summary_pref_start_scan_immediate">شؽواتگرن سی اسکن، زی مجال ر وندن بۊگۊشین، ٱندی ترین کودن اسکن کۊنین یا شؽواتی ن منه نوار ٱوزار پسند کۊنین.</string>
|
||||
<string name="title_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="summary_pref_append_http_proxy">پروکسی HTTP ن موسقیمن ز (مۊرۊرگر/ی قرد ز برنومه یل لادراری بیڌه)، بؽ استفاڌه ز دسگا NIC مجازی (Android 10+) استفاڌه ابۊ.</string>
|
||||
|
||||
<string name="title_pref_double_column_display">ر وندن نشووݩ داڌن دو سۊتۊنی</string>
|
||||
<string name="summary_pref_double_column_display">نومگه نمایه یل من دو سۊتۊن نشووݩ داڌه ابۊن وو چینۉ ترین موئتوا بیشتری ن سیل کۊنین. سی ر وستن، وا برنومه ن ز نۊ ر ونین.</string>
|
||||
<string name="title_pref_double_column_display">ره وندن نشووݩ داڌن دو سۊتۊنی</string>
|
||||
<string name="summary_pref_double_column_display">نومگه نمایه یل من دو سۊتۊن نشووݩ داڌه ابۊن وو چینۉ ترین موئتوا بیشتری ن سیل کۊنین. سی ره وستن، وا برنومه ن ز نۊ ره ونین.</string>
|
||||
|
||||
<string name="title_pref_group_all_display">نشووݩ داڌن پوی بونکۊ یل ن فعال کۊنین</string>
|
||||
<string name="summary_pref_group_all_display">ی بلگه «پوی بلگه یل» ازاف کۊنین</string>
|
||||
<!-- AboutActivity -->
|
||||
<string name="title_pref_feedback">فشناڌن منشڌ</string>
|
||||
<string name="summary_pref_feedback">فشناڌن منشڌ یا گوزارش موشکلا من Github</string>
|
||||
@@ -258,15 +260,15 @@
|
||||
<string name="title_language">زووݩ</string>
|
||||
<string name="title_ui_settings">سامووا رابت منتوری</string>
|
||||
<string name="title_pref_ui_mode_night">سامووا هالت رابت منتوری</string>
|
||||
<string name="title_pref_use_hev_tunnel">Enable Hev TUN Feature</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="title_pref_use_hev_tunnel">فعال کردن ویژیی Hev TUN</string>
|
||||
<string name="summary_pref_use_hev_tunnel">مجالی ک فعال بۊ، TUN ز hev-socks5-tunnel استفاڌه اکونه؛ ٱر فعال نبۊ، زهمو xray-core استفاڌه اکونه.</string>
|
||||
<string name="title_pref_hev_tunnel_loglevel">Hev Tun سئت گوزارشا</string>
|
||||
<string name="title_pref_hev_tunnel_rw_timeout">زمووݩ مندیر بیڌن خوندن/هؽل کردن Hev Tun (سانیه) (پؽش فرز tcp,udp 300،60)</string>
|
||||
<string name="title_pref_hev_tunnel_rw_timeout">زمووݩ مندیر بیڌن خوندن/هؽل کردن Hev Tun (سانیه) (پؽش فرز tcp، udp 300،60)</string>
|
||||
|
||||
<string name="title_logcat">گوزارشا</string>
|
||||
<string name="logcat_copy">لف گیری</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_duplicate_config">پاک کردن کانفیگا تکراری بونکۊ سکویی</string>
|
||||
<string name="title_del_invalid_config">پاک کردن کانفیگا نا موئتبر بونکۊ سکویی</string>
|
||||
@@ -295,7 +297,9 @@
|
||||
<string name="title_import_config_count">و من ٱووردن %d کانفیگ</string>
|
||||
<string name="title_export_config_count">و در کشیڌن %d کانفیگ</string>
|
||||
<string name="title_update_config_count">ورۊ کردن %d کانفیگ</string>
|
||||
<string name="tasker_start_service">ر وندن خدمات</string>
|
||||
<string name="title_update_subscription_result">%1$d کانفیگ ورۊ وابی (مووفق: %2$d، شکست خرده: %3$d، رڌ وابیڌه: %4$d)</string>
|
||||
<string name="title_update_subscription_no_subscription">بؽ اشتراک</string>
|
||||
<string name="tasker_start_service">ره وندن خدمات</string>
|
||||
<string name="tasker_setting_confirm">قوۊل</string>
|
||||
|
||||
<!-- RoutingSettingActivity -->
|
||||
@@ -330,7 +334,7 @@
|
||||
<string name="connection_test_error_status_code">کود ختا: #%d</string>
|
||||
<string name="connection_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_failure">اشتراک زفت نوابی</string>
|
||||
@@ -338,7 +342,7 @@
|
||||
<string name="title_pref_fragment_packets">کتنا فرگمنت</string>
|
||||
<string name="title_pref_fragment_length">تۊل کتنا فرگمنت (هدقل-هدکسر)</string>
|
||||
<string name="title_pref_fragment_interval">فاسله منجا کتنا فرگمنت (هدقل-هدکسر)</string>
|
||||
<string name="title_pref_fragment_enabled">ر وندن فرگمنت</string>
|
||||
<string name="title_pref_fragment_enabled">ره وندن فرگمنت</string>
|
||||
|
||||
<string name="update_check_for_update">واجۊری سی ورۊ رسۊوی</string>
|
||||
<string name="update_already_latest_version">سکو نوسخه دیندایی پۊرنیڌه هڌ</string>
|
||||
@@ -361,7 +365,7 @@
|
||||
<string name="title_webdav_url">WebDAV نشۊوی اینترنتی سرور</string>
|
||||
<string name="title_webdav_user">نوم منتوری</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">
|
||||
<item>QRcode</item>
|
||||
@@ -398,7 +402,7 @@
|
||||
<item>نومگه شه چین</item>
|
||||
<item>جهۊوی (Global)</item>
|
||||
<item>نومگه اسبؽڌ ایران</item>
|
||||
<item>Russia Whitelist</item>
|
||||
<item>نومگه اسبؽڌ رۊسیه</item>
|
||||
</string-array>
|
||||
|
||||
<string-array name="vpn_bypass_lan">
|
||||
@@ -415,6 +419,8 @@
|
||||
<string-array name="policy_group_type">
|
||||
<item>کم ترین پینگ</item>
|
||||
<item>کم ترین بار(لود)</item>
|
||||
<item>تساڌۊفی</item>
|
||||
<item>گردشی</item>
|
||||
</string-array>
|
||||
|
||||
<string-array name="config_backup_options">
|
||||
|
||||
@@ -108,13 +108,13 @@
|
||||
<string name="server_obfs_password">رمز عبور obfs</string>
|
||||
<string name="server_lab_port_hop">پورت پرش (درگاه سرور را بازنویسی می کند)</string>
|
||||
<string name="server_lab_port_hop_interval">فاصله پورت پرش (ثانیه)</string>
|
||||
<string name="server_lab_stream_pinsha256">pinSHA256</string>
|
||||
<string name="server_lab_bandwidth_down">کاهش پهنای باند (واحد)</string>
|
||||
<string name="server_lab_bandwidth_up">افزایش پهنای باند (واحد)</string>
|
||||
<string name="server_lab_xhttp_mode">حالت XHTTP</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_force_query">EchForceQuery</string>
|
||||
<string name="server_lab_pinned_ca256">Certificate fingerprint (SHA-256)</string>
|
||||
|
||||
<!-- UserAssetActivity -->
|
||||
<string name="toast_asset_copy_failed">کپی فایل انجام نشد، لطفا از برنامه مدیریت فایل استفاده کنید</string>
|
||||
@@ -232,6 +232,8 @@
|
||||
<string name="title_pref_double_column_display">فعال کردن نمایش دو ستون</string>
|
||||
<string name="summary_pref_double_column_display">لیست نمایه در دو ستون نمایش داده می شود و امکان نمایش محتوای بیشتری را بر روی صفحه نمایش می دهد. برای اجرا باید برنامه را مجددا راه اندازی کنید.</string>
|
||||
|
||||
<string name="title_pref_group_all_display">Enable show all groups</string>
|
||||
<string name="summary_pref_group_all_display">Add an extra "All Tabs" page</string>
|
||||
<!-- AboutActivity -->
|
||||
<string name="title_pref_feedback">بازخورد</string>
|
||||
<string name="summary_pref_feedback">بازخورد یا گزارش اشکالات در گیت هاب</string>
|
||||
@@ -292,6 +294,8 @@
|
||||
<string name="title_import_config_count">وارد کردن %d کانفیگ</string>
|
||||
<string name="title_export_config_count">صادر کردن %d کانفیگ</string>
|
||||
<string name="title_update_config_count">آپدیت کردن %d کانفیگ</string>
|
||||
<string name="title_update_subscription_result">Updated %1$d configs (%2$d success, %3$d failed, %4$d skipped)</string>
|
||||
<string name="title_update_subscription_no_subscription">No subscriptions</string>
|
||||
<string name="tasker_start_service">شروع خدمات</string>
|
||||
<string name="tasker_setting_confirm">تایید</string>
|
||||
|
||||
@@ -414,6 +418,8 @@
|
||||
<string-array name="policy_group_type">
|
||||
<item>کمترین پینگ</item>
|
||||
<item>کمترین بار(لود)</item>
|
||||
<item>تصادفی</item>
|
||||
<item>گردشی</item>
|
||||
</string-array>
|
||||
|
||||
<string-array name="config_backup_options">
|
||||
|
||||
@@ -108,13 +108,13 @@
|
||||
<string name="server_obfs_password">Пароль obfs</string>
|
||||
<string name="server_lab_port_hop">Смена портов (переопределяет порт)</string>
|
||||
<string name="server_lab_port_hop_interval">Интервал смены портов</string>
|
||||
<string name="server_lab_stream_pinsha256">pinSHA256</string>
|
||||
<string name="server_lab_bandwidth_down">Входящая пропускная способность (допускаются: k/m/g/t)</string>
|
||||
<string name="server_lab_bandwidth_up">Исходящая пропускная способность (допускаются: k/m/g/t)</string>
|
||||
<string name="server_lab_xhttp_mode">Режим XHTTP</string>
|
||||
<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_force_query">EchForceQuery</string>
|
||||
<string name="server_lab_pinned_ca256">Отпечаток сертификата (SHA-256)</string>
|
||||
|
||||
<!-- UserAssetActivity -->
|
||||
<string name="toast_asset_copy_failed">Невозможно скопировать файл, используйте файловый менеджер</string>
|
||||
@@ -233,6 +233,8 @@
|
||||
<string name="title_pref_double_column_display">Профили в два столбца</string>
|
||||
<string name="summary_pref_double_column_display">Список профилей отображается двумя столбцами, что позволяет показать больше информации на экране. Требуется перезапуск приложения.</string>
|
||||
|
||||
<string name="title_pref_group_all_display">Общая вкладка групп</string>
|
||||
<string name="summary_pref_group_all_display">Показывать дополнительную вкладку со всеми профилями групп</string>
|
||||
<!-- AboutActivity -->
|
||||
<string name="title_pref_feedback">Обратная связь</string>
|
||||
<string name="summary_pref_feedback">Предложить улучшение или сообщить об ошибке на GitHub</string>
|
||||
@@ -293,6 +295,8 @@
|
||||
<string name="title_import_config_count">Импортировано профилей: %d</string>
|
||||
<string name="title_export_config_count">Экспортировано профилей: %d</string>
|
||||
<string name="title_update_config_count">Обновлено профилей: %d</string>
|
||||
<string name="title_update_subscription_result">Обновлено профилей: %1$d (успешно: %2$d, ошибок: %3$d, пропущено: %4$d)</string>
|
||||
<string name="title_update_subscription_no_subscription">Нет подписок</string>
|
||||
|
||||
<string name="tasker_start_service">Запуск службы</string>
|
||||
<string name="tasker_setting_confirm">Подтвердить</string>
|
||||
@@ -356,7 +360,7 @@
|
||||
<string name="title_configuration_restore">Восстановление конфигурации</string>
|
||||
<string name="title_configuration_share">Поделиться конфигурацией</string>
|
||||
<string name="title_webdav_config_setting">Настройки WebDAV</string>
|
||||
<string name="title_webdav_config_setting_unknown">Необходимо настроить WebDAV.</string>
|
||||
<string name="title_webdav_config_setting_unknown">Необходимо настроить WebDAV</string>
|
||||
<string name="title_webdav_url">URL сервера</string>
|
||||
<string name="title_webdav_user">Пользователь</string>
|
||||
<string name="title_webdav_pass">Пароль</string>
|
||||
@@ -414,6 +418,8 @@
|
||||
<string-array name="policy_group_type">
|
||||
<item>Наименьшая задержка</item>
|
||||
<item>Наименьшая нагрузка</item>
|
||||
<item>Случайно</item>
|
||||
<item>По очереди</item>
|
||||
</string-array>
|
||||
|
||||
<string-array name="config_backup_options">
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user