lilu hai 5 meses
achega
35a87748e9
Modificáronse 100 ficheiros con 15372 adicións e 0 borrados
  1. 66 0
      .gitignore
  2. 61 0
      README.md
  3. 203 0
      app/build.gradle.kts
  4. BIN=BIN
      app/libs/libv2ray.aar
  5. 21 0
      app/proguard-rules.pro
  6. 4 0
      app/src/dev/res/values/strings.xml
  7. 272 0
      app/src/main/AndroidManifest.xml
  8. 142 0
      app/src/main/assets/custom_routing_black
  9. 27 0
      app/src/main/assets/custom_routing_global
  10. 96 0
      app/src/main/assets/custom_routing_white
  11. 37 0
      app/src/main/assets/custom_routing_white_iran
  12. 1285 0
      app/src/main/assets/open_source_licenses.html
  13. 413 0
      app/src/main/assets/proxy_packagename.txt
  14. 107 0
      app/src/main/assets/v2ray_config.json
  15. BIN=BIN
      app/src/main/ic_launcher-web.png
  16. 47 0
      app/src/main/java/com/v2ray/ang/AngApplication.kt
  17. 259 0
      app/src/main/java/com/v2ray/ang/AppConfig.kt
  18. 11 0
      app/src/main/java/com/v2ray/ang/dto/AppInfo.kt
  19. 9 0
      app/src/main/java/com/v2ray/ang/dto/AssetUrlItem.kt
  20. 10 0
      app/src/main/java/com/v2ray/ang/dto/CheckUpdateResult.kt
  21. 9 0
      app/src/main/java/com/v2ray/ang/dto/ConfigResult.kt
  22. 22 0
      app/src/main/java/com/v2ray/ang/dto/EConfigType.kt
  23. 23 0
      app/src/main/java/com/v2ray/ang/dto/GitHubRelease.kt
  24. 46 0
      app/src/main/java/com/v2ray/ang/dto/Hysteria2Bean.kt
  25. 12 0
      app/src/main/java/com/v2ray/ang/dto/IPAPIInfo.kt
  26. 20 0
      app/src/main/java/com/v2ray/ang/dto/Language.kt
  27. 18 0
      app/src/main/java/com/v2ray/ang/dto/NetworkType.kt
  28. 121 0
      app/src/main/java/com/v2ray/ang/dto/ProfileItem.kt
  29. 20 0
      app/src/main/java/com/v2ray/ang/dto/RoutingType.kt
  30. 13 0
      app/src/main/java/com/v2ray/ang/dto/RulesetItem.kt
  31. 10 0
      app/src/main/java/com/v2ray/ang/dto/ServerAffiliationInfo.kt
  32. 86 0
      app/src/main/java/com/v2ray/ang/dto/ServerConfig.kt
  33. 6 0
      app/src/main/java/com/v2ray/ang/dto/ServersCache.kt
  34. 17 0
      app/src/main/java/com/v2ray/ang/dto/SubscriptionItem.kt
  35. 611 0
      app/src/main/java/com/v2ray/ang/dto/V2rayConfig.kt
  36. 19 0
      app/src/main/java/com/v2ray/ang/dto/VmessQRCode.kt
  37. 39 0
      app/src/main/java/com/v2ray/ang/dto/VpnInterfaceAddressConfig.kt
  38. 212 0
      app/src/main/java/com/v2ray/ang/extension/_Ext.kt
  39. 27 0
      app/src/main/java/com/v2ray/ang/fmt/CustomFmt.kt
  40. 172 0
      app/src/main/java/com/v2ray/ang/fmt/FmtBase.kt
  41. 32 0
      app/src/main/java/com/v2ray/ang/fmt/HttpFmt.kt
  42. 151 0
      app/src/main/java/com/v2ray/ang/fmt/Hysteria2Fmt.kt
  43. 154 0
      app/src/main/java/com/v2ray/ang/fmt/ShadowsocksFmt.kt
  44. 79 0
      app/src/main/java/com/v2ray/ang/fmt/SocksFmt.kt
  45. 83 0
      app/src/main/java/com/v2ray/ang/fmt/TrojanFmt.kt
  46. 80 0
      app/src/main/java/com/v2ray/ang/fmt/VlessFmt.kt
  47. 192 0
      app/src/main/java/com/v2ray/ang/fmt/VmessFmt.kt
  48. 149 0
      app/src/main/java/com/v2ray/ang/fmt/WireguardFmt.kt
  49. 520 0
      app/src/main/java/com/v2ray/ang/handler/AngConfigManager.kt
  50. 242 0
      app/src/main/java/com/v2ray/ang/handler/MigrateManager.kt
  51. 588 0
      app/src/main/java/com/v2ray/ang/handler/MmkvManager.kt
  52. 250 0
      app/src/main/java/com/v2ray/ang/handler/NotificationManager.kt
  53. 141 0
      app/src/main/java/com/v2ray/ang/handler/PluginServiceManager.kt
  54. 380 0
      app/src/main/java/com/v2ray/ang/handler/SettingsManager.kt
  55. 189 0
      app/src/main/java/com/v2ray/ang/handler/SpeedtestManager.kt
  56. 63 0
      app/src/main/java/com/v2ray/ang/handler/SubscriptionUpdater.kt
  57. 107 0
      app/src/main/java/com/v2ray/ang/handler/UpdateCheckerManager.kt
  58. 376 0
      app/src/main/java/com/v2ray/ang/handler/V2RayServiceManager.kt
  59. 1278 0
      app/src/main/java/com/v2ray/ang/handler/V2rayConfigManager.kt
  60. 68 0
      app/src/main/java/com/v2ray/ang/helper/CustomDividerItemDecoration.kt
  61. 53 0
      app/src/main/java/com/v2ray/ang/helper/ItemTouchHelperAdapter.kt
  62. 38 0
      app/src/main/java/com/v2ray/ang/helper/ItemTouchHelperViewHolder.kt
  63. 147 0
      app/src/main/java/com/v2ray/ang/helper/SimpleItemTouchHelperCallback.kt
  64. 32 0
      app/src/main/java/com/v2ray/ang/plugin/NativePlugin.kt
  65. 43 0
      app/src/main/java/com/v2ray/ang/plugin/Plugin.kt
  66. 33 0
      app/src/main/java/com/v2ray/ang/plugin/PluginContract.kt
  67. 54 0
      app/src/main/java/com/v2ray/ang/plugin/PluginList.kt
  68. 233 0
      app/src/main/java/com/v2ray/ang/plugin/PluginManager.kt
  69. 51 0
      app/src/main/java/com/v2ray/ang/plugin/ResolvedPlugin.kt
  70. 23 0
      app/src/main/java/com/v2ray/ang/receiver/BootReceiver.kt
  71. 41 0
      app/src/main/java/com/v2ray/ang/receiver/TaskerReceiver.kt
  72. 100 0
      app/src/main/java/com/v2ray/ang/receiver/WidgetProvider.kt
  73. 52 0
      app/src/main/java/com/v2ray/ang/service/ProcessService.kt
  74. 119 0
      app/src/main/java/com/v2ray/ang/service/QSTileService.kt
  75. 28 0
      app/src/main/java/com/v2ray/ang/service/ServiceControl.kt
  76. 94 0
      app/src/main/java/com/v2ray/ang/service/TProxyService.kt
  77. 19 0
      app/src/main/java/com/v2ray/ang/service/Tun2SocksControl.kt
  78. 128 0
      app/src/main/java/com/v2ray/ang/service/Tun2SocksService.kt
  79. 94 0
      app/src/main/java/com/v2ray/ang/service/V2RayProxyOnlyService.kt
  80. 91 0
      app/src/main/java/com/v2ray/ang/service/V2RayTestService.kt
  81. 356 0
      app/src/main/java/com/v2ray/ang/service/V2RayVpnService.kt
  82. 201 0
      app/src/main/java/com/v2ray/ang/ui/AboutActivity.kt
  83. 65 0
      app/src/main/java/com/v2ray/ang/ui/BaseActivity.kt
  84. 77 0
      app/src/main/java/com/v2ray/ang/ui/CheckUpdateActivity.kt
  85. 17 0
      app/src/main/java/com/v2ray/ang/ui/FragmentAdapter.kt
  86. 156 0
      app/src/main/java/com/v2ray/ang/ui/LogcatActivity.kt
  87. 44 0
      app/src/main/java/com/v2ray/ang/ui/LogcatRecyclerAdapter.kt
  88. 703 0
      app/src/main/java/com/v2ray/ang/ui/MainActivity.kt
  89. 362 0
      app/src/main/java/com/v2ray/ang/ui/MainRecyclerAdapter.kt
  90. 285 0
      app/src/main/java/com/v2ray/ang/ui/PerAppProxyActivity.kt
  91. 88 0
      app/src/main/java/com/v2ray/ang/ui/PerAppProxyAdapter.kt
  92. 132 0
      app/src/main/java/com/v2ray/ang/ui/RoutingEditActivity.kt
  93. 204 0
      app/src/main/java/com/v2ray/ang/ui/RoutingSettingActivity.kt
  94. 80 0
      app/src/main/java/com/v2ray/ang/ui/RoutingSettingRecyclerAdapter.kt
  95. 52 0
      app/src/main/java/com/v2ray/ang/ui/ScScannerActivity.kt
  96. 21 0
      app/src/main/java/com/v2ray/ang/ui/ScSwitchActivity.kt
  97. 134 0
      app/src/main/java/com/v2ray/ang/ui/ScannerActivity.kt
  98. 671 0
      app/src/main/java/com/v2ray/ang/ui/ServerActivity.kt
  99. 148 0
      app/src/main/java/com/v2ray/ang/ui/ServerCustomConfigActivity.kt
  100. 408 0
      app/src/main/java/com/v2ray/ang/ui/SettingsActivity.kt

+ 66 - 0
.gitignore

@@ -0,0 +1,66 @@
+# Ignore data and key store files
+*.dat
+*.jks
+
+# Ignore output JSON file
+app/release/output.json
+
+# Ignore IDE and build system directories
+.idea/
+.gradle/
+*.iml
+
+# Ignore local properties and DS_Store files
+/local.properties
+.DS_Store
+
+# Ignore build directories and captures
+/build
+/captures
+app/build
+build
+local.properties
+
+# Ignore APK and AAR files
+*.apk
+# *.aar
+
+# Ignore signing properties
+signing.properties
+
+# Ignore shared object files
+*.so
+
+# Ignore Google services JSON
+app/google-services.json
+
+# Additional common Android/Java ignores
+*.log
+*.tmp
+*.bak
+*.swp
+*.orig
+*.class
+*.jar
+*.war
+*.ear
+
+# Ignore executable files
+*.exe
+*.dll
+*.obj
+*.o
+*.pyc
+*.pyo
+
+# Ignore files from other IDEs
+.vscode/
+.classpath
+.project
+.settings/
+*.sublime-workspace
+*.sublime-project
+
+# Ignore OS-specific files
+Thumbs.db
+.DS_Store

+ 61 - 0
README.md

@@ -0,0 +1,61 @@
+# v2rayNG项目分析
+## 项目概述
+v2rayNG是一款基于Android平台的V2Ray客户端应用程序,支持Xray core和v2fly core,主要用于网络代理和VPN服务。
+
+## 技术栈
+- 开发语言 :主要使用Kotlin语言开发
+- 构建系统 :Gradle Kotlin DSL (.kts格式)
+- 数据存储 :采用MMKV进行高效键值存储
+- UI框架 :基于AndroidX和Material Design
+- 核心库 :通过JNI集成V2Ray/Xray核心功能
+- 支持架构 :arm64-v8a、armeabi-v7a、x86_64、x86
+## 核心功能
+1.多种运行模式 :
+
+    - VPN服务模式(通过V2RayVpnService实现)
+    - 代理模式(通过V2RayProxyOnlyService实现)
+2.支持多种协议 :
+
+    - VMess、VLESS、Trojan、Shadowsocks、Wireguard、HTTP、Socks等
+    - 每种协议都有对应的格式化器(如VmessFmt、VlessFmt等)
+3.订阅管理 :
+
+    - 支持批量导入服务器配置
+    - 自动更新订阅功能
+4.路由规则 :
+
+    - 支持自定义路由规则
+    - 内置多种路由规则集(白名单、黑名单等)
+    - 支持Geoip和Geosite规则
+5.分应用代理 :
+
+    - 可以为不同应用单独设置代理策略
+6.DNS设置 :
+
+    - 支持自定义DNS服务器
+    - 支持Fake DNS功能
+## 项目结构
+项目采用标准Android应用架构,主要代码位于 V2rayNG/app/src/main/java/com/v2ray/ang/ 目录下,按功能模块划分为多个包:
+
+- ui :界面相关代码,包括MainActivity、ServerActivity等
+- service :服务相关代码,包括V2RayVpnService、V2RayProxyOnlyService等
+- handler :处理器相关代码,包括V2RayServiceManager、V2rayConfigManager等
+- dto :数据传输对象
+- util :工具类
+- fmt :各种协议格式处理类
+## 主要工作流程
+1.用户在MainActivity中选择服务器配置
+2.点击启动按钮,通过V2RayServiceManager启动对应服务(VPN或代理模式)
+3.服务启动时,V2rayConfigManager生成配置文件
+4.调用底层V2Ray核心库启动代理服务
+5.通过NotificationManager显示通知和连接状态
+## 特色功能
+- 多语言支持 :包含中文、英文、俄文、阿拉伯文等多种语言
+- 深色模式 :支持系统深色模式
+- 性能统计 :可以显示连接速度等统计信息
+- 智能选择 :支持基于延迟自动选择最优服务器
+## 构建配置
+- 项目支持两个产品变种(productFlavors):fdroid和playstore
+- 支持多种CPU架构的APK拆分
+- 最低支持Android 5.0(API 21),目标API 35
+  总体而言,v2rayNG是一个功能全面、架构清晰的Android代理客户端,通过JNI集成V2Ray/Xray核心功能,为用户提供灵活、强大的网络代理解决方案。

+ 203 - 0
app/build.gradle.kts

@@ -0,0 +1,203 @@
+plugins {
+    alias(libs.plugins.android.application)
+    alias(libs.plugins.kotlin.android)
+    id("com.jaredsburrows.license")
+}
+
+android {
+    namespace = "com.v2ray.ang"
+    compileSdk = 35
+
+    defaultConfig {
+        applicationId = "com.v2ray.ang"
+        minSdk = 21
+        targetSdk = 35
+        versionCode = 673
+        versionName = "1.10.23"
+        multiDexEnabled = true
+
+        val abiFilterList = (properties["ABI_FILTERS"] as? String)?.split(';')
+        splits {
+            abi {
+                isEnable = true
+                reset()
+                if (abiFilterList != null && abiFilterList.isNotEmpty()) {
+                    include(*abiFilterList.toTypedArray())
+                } else {
+                    include(
+                        "arm64-v8a",
+                        "armeabi-v7a",
+                        "x86_64",
+                        "x86"
+                    )
+                }
+                isUniversalApk = abiFilterList.isNullOrEmpty()
+            }
+        }
+
+        testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+    }
+
+    buildTypes {
+        release {
+            isMinifyEnabled = false
+            proguardFiles(
+                getDefaultProguardFile("proguard-android-optimize.txt"),
+                "proguard-rules.pro"
+            )
+        }
+    }
+
+    flavorDimensions.add("distribution")
+    productFlavors {
+        create("fdroid") {
+            dimension = "distribution"
+            applicationIdSuffix = ".fdroid"
+            buildConfigField("String", "DISTRIBUTION", "\"F-Droid\"")
+        }
+        create("playstore") {
+            dimension = "distribution"
+            buildConfigField("String", "DISTRIBUTION", "\"Play Store\"")
+        }
+    }
+
+    sourceSets {
+        getByName("main") {
+            jniLibs.srcDirs("libs")
+        }
+    }
+
+
+    compileOptions {
+        isCoreLibraryDesugaringEnabled = true
+        sourceCompatibility = JavaVersion.VERSION_17
+        targetCompatibility = JavaVersion.VERSION_17
+    }
+    kotlinOptions {
+        jvmTarget = JavaVersion.VERSION_17.toString()
+    }
+
+    applicationVariants.all {
+        val variant = this
+        val isFdroid = variant.productFlavors.any { it.name == "fdroid" }
+        if (isFdroid) {
+            val versionCodes =
+                mapOf(
+                    "armeabi-v7a" to 2, "arm64-v8a" to 1, "x86" to 4, "x86_64" to 3, "universal" to 0
+                )
+
+            variant.outputs
+                .map { it as com.android.build.gradle.internal.api.ApkVariantOutputImpl }
+                .forEach { output ->
+                    val abi = output.getFilter("ABI") ?: "universal"
+                    output.outputFileName = "v2rayNG_${variant.versionName}-fdroid_${abi}.apk"
+                    if (versionCodes.containsKey(abi)) {
+                        output.versionCodeOverride =
+                            (100 * variant.versionCode + versionCodes[abi]!!).plus(5000000)
+                    } else {
+                        return@forEach
+                    }
+                }
+        } else {
+            val versionCodes =
+                mapOf("armeabi-v7a" to 4, "arm64-v8a" to 4, "x86" to 4, "x86_64" to 4, "universal" to 4)
+
+            variant.outputs
+                .map { it as com.android.build.gradle.internal.api.ApkVariantOutputImpl }
+                .forEach { output ->
+                    val abi = if (output.getFilter("ABI") != null)
+                        output.getFilter("ABI")
+                    else
+                        "universal"
+
+                    output.outputFileName = "v2rayNG_${variant.versionName}_${abi}.apk"
+                    if (versionCodes.containsKey(abi)) {
+                        output.versionCodeOverride =
+                            (1000000 * versionCodes[abi]!!).plus(variant.versionCode)
+                    } else {
+                        return@forEach
+                    }
+                }
+        }
+    }
+
+    buildFeatures {
+        viewBinding = true
+        buildConfig = true
+    }
+
+    packaging {
+        jniLibs {
+            useLegacyPackaging = true
+        }
+    }
+
+    // Fix for 16KB page size alignment required by Android 15+
+    // https://developer.android.com/16kb-page-size
+    // For pre-compiled libraries in AARs, we need to handle alignment
+    packaging {
+        resources {
+            excludes += ":META-INF/LICENSE"
+            excludes += ":META-INF/LICENSE.md"
+            excludes += ":META-INF/NOTICE"
+        }
+        jniLibs.keepDebugSymbols += "**/*.so"
+    }
+
+}
+
+dependencies {
+    // Core Libraries
+    implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.aar", "*.jar"))))
+
+    // AndroidX Core Libraries
+    implementation(libs.androidx.core.ktx)
+    implementation(libs.androidx.appcompat)
+    implementation(libs.androidx.activity)
+    implementation(libs.androidx.constraintlayout)
+    implementation(libs.preference.ktx)
+    implementation(libs.recyclerview)
+    implementation(libs.androidx.swiperefreshlayout)
+
+    // UI Libraries
+    implementation(libs.material)
+    implementation(libs.toasty)
+    implementation(libs.editorkit)
+    implementation(libs.flexbox)
+
+    // Data and Storage Libraries
+    implementation(libs.mmkv.static)
+    implementation(libs.gson)
+
+    // Reactive and Utility Libraries
+    implementation(libs.kotlinx.coroutines.android)
+    implementation(libs.kotlinx.coroutines.core)
+
+    // Language and Processing Libraries
+    implementation(libs.language.base)
+    implementation(libs.language.json)
+
+    // Intent and Utility Libraries
+    implementation(libs.quickie.foss)
+    implementation(libs.core)
+
+    // AndroidX Lifecycle and Architecture Components
+    implementation(libs.lifecycle.viewmodel.ktx)
+    implementation(libs.lifecycle.livedata.ktx)
+    implementation(libs.lifecycle.runtime.ktx)
+
+    // Background Task Libraries
+    implementation(libs.work.runtime.ktx)
+    implementation(libs.work.multiprocess)
+
+    // Multidex Support
+    implementation(libs.multidex)
+
+    // Testing Libraries
+    testImplementation(libs.junit)
+    androidTestImplementation(libs.androidx.junit)
+    androidTestImplementation(libs.androidx.espresso.core)
+    testImplementation(libs.org.mockito.mockito.inline)
+    testImplementation(libs.mockito.kotlin)
+    coreLibraryDesugaring(libs.desugar.jdk.libs)
+}

BIN=BIN
app/libs/libv2ray.aar


+ 21 - 0
app/proguard-rules.pro

@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+#   http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+#   public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile

+ 4 - 0
app/src/dev/res/values/strings.xml

@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+    <item name="app_name" type="string">v2rayNG (DEV)</item>
+</resources>

+ 272 - 0
app/src/main/AndroidManifest.xml

@@ -0,0 +1,272 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:tools="http://schemas.android.com/tools"
+    tools:ignore="MissingLeanbackLauncher">
+
+    <supports-screens
+        android:anyDensity="true"
+        android:largeScreens="true"
+        android:normalScreens="true"
+        android:smallScreens="true"
+        android:xlargeScreens="true" />
+
+    <uses-sdk
+        android:minSdkVersion="21"
+        tools:overrideLibrary="com.blacksquircle.ui.editorkit" />
+
+    <uses-feature
+        android:name="android.hardware.camera"
+        android:required="false" />
+    <uses-feature
+        android:name="android.hardware.camera.autofocus"
+        android:required="false" />
+    <uses-feature
+        android:name="android.software.leanback"
+        android:required="false" />
+    <uses-feature
+        android:name="android.hardware.touchscreen"
+        android:required="false" />
+
+    <!-- https://developer.android.com/about/versions/11/privacy/package-visibility -->
+    <uses-permission
+        android:name="android.permission.QUERY_ALL_PACKAGES"
+        tools:ignore="QueryAllPackagesPermission" />
+    <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
+    <uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
+    <uses-permission android:name="android.permission.INTERNET" />
+    <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
+    <uses-permission android:name="android.permission.CAMERA" />
+    <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
+    <uses-permission
+        android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE"
+        android:minSdkVersion="34" />
+    <!-- <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> -->
+    <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
+    <uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
+    <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
+
+    <application
+        android:name=".AngApplication"
+        android:allowBackup="true"
+        android:banner="@mipmap/ic_banner"
+        android:icon="@mipmap/ic_launcher"
+        android:label="@string/app_name"
+        android:networkSecurityConfig="@xml/network_security_config"
+        android:supportsRtl="true"
+        android:theme="@style/AppThemeDayNight"
+        android:usesCleartextTraffic="true"
+        tools:targetApi="m">
+
+        <activity
+            android:name=".ui.MainActivity"
+            android:exported="true"
+            android:launchMode="singleTask"
+            android:theme="@style/AppThemeDayNight.NoActionBar">
+            <intent-filter>
+                <action android:name="android.intent.action.MAIN" />
+
+                <category android:name="android.intent.category.LAUNCHER" />
+                <category android:name="android.intent.category.LEANBACK_LAUNCHER" />
+            </intent-filter>
+            <intent-filter>
+                <action android:name="android.service.quicksettings.action.QS_TILE_PREFERENCES" />
+            </intent-filter>
+
+            <meta-data
+                android:name="android.app.shortcuts"
+                android:resource="@xml/shortcuts" />
+        </activity>
+        <activity
+            android:name=".ui.ServerActivity"
+            android:exported="false"
+            android:windowSoftInputMode="stateUnchanged" />
+        <activity
+            android:name=".ui.ServerCustomConfigActivity"
+            android:exported="false"
+            android:windowSoftInputMode="stateUnchanged" />
+        <activity
+            android:name=".ui.SettingsActivity"
+            android:exported="false" />
+        <activity
+            android:name=".ui.PerAppProxyActivity"
+            android:exported="false" />
+        <activity
+            android:name=".ui.ScannerActivity"
+            android:exported="false" />
+        <activity
+            android:name=".ui.LogcatActivity"
+            android:exported="false" />
+        <activity
+            android:name=".ui.RoutingSettingActivity"
+            android:exported="false" />
+        <activity
+            android:name=".ui.RoutingEditActivity"
+            android:exported="false" />
+        <activity
+            android:name=".ui.SubSettingActivity"
+            android:exported="false" />
+        <activity
+            android:name=".ui.UserAssetActivity"
+            android:exported="false" />
+        <activity
+            android:name=".ui.UserAssetUrlActivity"
+            android:exported="false" />
+
+        <activity
+            android:name=".ui.SubEditActivity"
+            android:exported="false" />
+        <activity
+            android:name=".ui.ScScannerActivity"
+            android:exported="false" />
+        <activity
+            android:name=".ui.ScSwitchActivity"
+            android:excludeFromRecents="true"
+            android:exported="false"
+            android:process=":RunSoLibV2RayDaemon"
+            android:theme="@style/AppTheme.NoActionBar.Translucent" />
+
+        <activity
+            android:name=".ui.UrlSchemeActivity"
+            android:exported="true">
+            <intent-filter>
+                <action android:name="android.intent.action.SEND" />
+                <category android:name="android.intent.category.DEFAULT" />
+                <data android:mimeType="text/plain" />
+            </intent-filter>
+            <intent-filter>
+                <action android:name="android.intent.action.VIEW" />
+
+                <category android:name="android.intent.category.BROWSABLE" />
+                <category android:name="android.intent.category.DEFAULT" />
+
+                <data android:scheme="v2rayng" />
+                <data android:host="install-config" />
+                <data android:host="install-sub" />
+            </intent-filter>
+        </activity>
+        <activity
+            android:name=".ui.CheckUpdateActivity"
+            android:exported="false" />
+        <activity
+            android:name=".ui.AboutActivity"
+            android:exported="false" />
+
+        <service
+            android:name=".service.V2RayVpnService"
+            android:enabled="true"
+            android:exported="false"
+            android:foregroundServiceType="specialUse"
+            android:label="@string/app_name"
+            android:permission="android.permission.BIND_VPN_SERVICE"
+            android:process=":RunSoLibV2RayDaemon">
+            <intent-filter>
+                <action android:name="android.net.VpnService" />
+            </intent-filter>
+            <meta-data
+                android:name="android.net.VpnService.SUPPORTS_ALWAYS_ON"
+                android:value="true" />
+            <property
+                android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
+                android:value="vpn" />
+        </service>
+
+        <service
+            android:name=".service.V2RayProxyOnlyService"
+            android:exported="false"
+            android:foregroundServiceType="specialUse"
+            android:label="@string/app_name"
+            android:process=":RunSoLibV2RayDaemon">
+            <property
+                android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
+                android:value="proxy" />
+        </service>
+
+        <service
+            android:name=".service.V2RayTestService"
+            android:exported="false"
+            android:process=":RunSoLibV2RayDaemon" />
+
+        <receiver
+            android:name=".receiver.WidgetProvider"
+            android:exported="true"
+            android:process=":RunSoLibV2RayDaemon">
+            <meta-data
+                android:name="android.appwidget.provider"
+                android:resource="@xml/app_widget_provider" />
+            <intent-filter>
+                <action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
+                <action android:name="com.v2ray.ang.action.widget.click" />
+                <action android:name="com.v2ray.ang.action.activity" />
+            </intent-filter>
+        </receiver>
+        <receiver
+            android:name=".receiver.BootReceiver"
+            android:exported="true"
+            android:label="BootReceiver">
+            <intent-filter>
+                <action android:name="android.intent.action.BOOT_COMPLETED" />
+            </intent-filter>
+        </receiver>
+
+        <service
+            android:name=".service.QSTileService"
+            android:exported="true"
+            android:foregroundServiceType="specialUse"
+            android:icon="@drawable/ic_stat_name"
+            android:label="@string/app_tile_name"
+            android:permission="android.permission.BIND_QUICK_SETTINGS_TILE"
+            android:process=":RunSoLibV2RayDaemon"
+            tools:targetApi="24">
+            <intent-filter>
+                <action android:name="android.service.quicksettings.action.QS_TILE" />
+            </intent-filter>
+            <property
+                android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
+                android:value="tile" />
+        </service>
+        <!-- =====================Tasker===================== -->
+        <activity
+            android:name=".ui.TaskerActivity"
+            android:exported="true"
+            android:icon="@mipmap/ic_launcher">
+            <intent-filter>
+                <action android:name="com.twofortyfouram.locale.intent.action.EDIT_SETTING" />
+            </intent-filter>
+        </activity>
+
+        <receiver
+            android:name=".receiver.TaskerReceiver"
+            android:exported="true"
+            android:process=":RunSoLibV2RayDaemon"
+            tools:ignore="ExportedReceiver">
+            <intent-filter>
+                <action android:name="com.twofortyfouram.locale.intent.action.FIRE_SETTING" />
+            </intent-filter>
+        </receiver>
+        <!-- =====================Tasker===================== -->
+        <provider
+            android:name="androidx.startup.InitializationProvider"
+            android:authorities="${applicationId}.androidx-startup"
+            android:exported="false"
+            tools:node="merge">
+
+            <meta-data
+                android:name="androidx.work.WorkManagerInitializer"
+                android:value="androidx.startup"
+                tools:node="remove" />
+
+        </provider>
+
+        <provider
+            android:name="androidx.core.content.FileProvider"
+            android:authorities="${applicationId}.cache"
+            android:exported="false"
+            android:grantUriPermissions="true">
+            <meta-data
+                android:name="android.support.FILE_PROVIDER_PATHS"
+                android:resource="@xml/cache_paths" />
+        </provider>
+
+    </application>
+
+</manifest>

+ 142 - 0
app/src/main/assets/custom_routing_black

@@ -0,0 +1,142 @@
+[
+  {
+    "remarks": "绕过bittorrent",
+    "outboundTag": "direct",
+    "protocol": [
+      "bittorrent"
+    ]
+  },
+  {
+    "remarks": "Google cn",
+    "outboundTag": "proxy",
+    "domain": [
+      "domain:googleapis.cn",
+      "domain:gstatic.com"
+    ]
+  },
+  {
+    "remarks": "阻断udp443",
+    "outboundTag": "block",
+    "port": "443",
+    "network": "udp"
+  },
+  {
+    "remarks": "绕过局域网IP",
+    "outboundTag": "direct",
+    "ip": [
+      "geoip:private"
+    ]
+  },
+  {
+    "remarks": "绕过局域网域名",
+    "outboundTag": "direct",
+    "domain": [
+      "geosite:private"
+    ]
+  },
+  {
+    "remarks": "代理海外公共DNSIP",
+    "outboundTag": "proxy",
+    "ip": [
+      "1.1.1.1",
+      "1.0.0.1",
+      "2606:4700:4700::1111",
+      "2606:4700:4700::1001",
+      "1.1.1.2",
+      "1.0.0.2",
+      "2606:4700:4700::1112",
+      "2606:4700:4700::1002",
+      "1.1.1.3",
+      "1.0.0.3",
+      "2606:4700:4700::1113",
+      "2606:4700:4700::1003",
+      "8.8.8.8",
+      "8.8.4.4",
+      "2001:4860:4860::8888",
+      "2001:4860:4860::8844",
+      "94.140.14.14",
+      "94.140.15.15",
+      "2a10:50c0::ad1:ff",
+      "2a10:50c0::ad2:ff",
+      "94.140.14.15",
+      "94.140.15.16",
+      "2a10:50c0::bad1:ff",
+      "2a10:50c0::bad2:ff",
+      "94.140.14.140",
+      "94.140.14.141",
+      "2a10:50c0::1:ff",
+      "2a10:50c0::2:ff",
+      "208.67.222.222",
+      "208.67.220.220",
+      "2620:119:35::35",
+      "2620:119:53::53",
+      "208.67.222.123",
+      "208.67.220.123",
+      "2620:119:35::123",
+      "2620:119:53::123",
+      "9.9.9.9",
+      "149.112.112.112",
+      "2620:fe::9",
+      "2620:fe::fe",
+      "9.9.9.11",
+      "149.112.112.11",
+      "2620:fe::11",
+      "2620:fe::fe:11",
+      "9.9.9.10",
+      "149.112.112.10",
+      "2620:fe::10",
+      "2620:fe::fe:10",
+      "77.88.8.8",
+      "77.88.8.1",
+      "2a02:6b8::feed:0ff",
+      "2a02:6b8:0:1::feed:0ff",
+      "77.88.8.88",
+      "77.88.8.2",
+      "2a02:6b8::feed:bad",
+      "2a02:6b8:0:1::feed:bad",
+      "77.88.8.7",
+      "77.88.8.3",
+      "2a02:6b8::feed:a11",
+      "2a02:6b8:0:1::feed:a11"
+    ]
+  },
+  {
+    "remarks": "代理海外公共DNS域名",
+    "outboundTag": "proxy",
+    "domain": [
+      "domain:cloudflare-dns.com",
+      "domain:one.one.one.one",
+      "domain:dns.google",
+      "domain:adguard-dns.com",
+      "domain:opendns.com",
+      "domain:umbrella.com",
+      "domain:quad9.net",
+      "domain:yandex.net"
+    ]
+  },
+  {
+    "remarks": "代理IP",
+    "outboundTag": "proxy",
+    "ip": [
+      "geoip:facebook",
+      "geoip:fastly",
+      "geoip:google",
+      "geoip:netflix",
+      "geoip:telegram",
+      "geoip:twitter"
+    ]
+  },
+  {
+    "remarks": "代理GFW",
+    "outboundTag": "proxy",
+    "domain": [
+      "geosite:gfw",
+      "geosite:greatfire"
+    ]
+  },
+  {
+    "remarks": "最终直连",
+    "port": "0-65535",
+    "outboundTag": "direct"
+  }
+]

+ 27 - 0
app/src/main/assets/custom_routing_global

@@ -0,0 +1,27 @@
+[
+  {
+    "remarks": "阻断udp443",
+    "outboundTag": "block",
+    "port": "443",
+    "network": "udp"
+  },
+  {
+    "remarks": "绕过局域网IP",
+    "outboundTag": "direct",
+    "ip": [
+      "geoip:private"
+    ]
+  },
+  {
+    "remarks": "绕过局域网域名",
+    "outboundTag": "direct",
+    "domain": [
+      "geosite:private"
+    ]
+  },
+  {
+    "remarks": "最终代理",
+    "port": "0-65535",
+    "outboundTag": "proxy"
+  }
+]

+ 96 - 0
app/src/main/assets/custom_routing_white

@@ -0,0 +1,96 @@
+[
+  {
+    "remarks": "Google cn",
+    "outboundTag": "proxy",
+    "domain": [
+      "domain:googleapis.cn",
+      "domain:gstatic.com"
+    ]
+  },
+  {
+    "remarks": "阻断udp443",
+    "outboundTag": "block",
+    "port": "443",
+    "network": "udp"
+  },
+  {
+    "remarks": "绕过局域网IP",
+    "outboundTag": "direct",
+    "ip": [
+      "geoip:private"
+    ]
+  },
+  {
+    "remarks": "绕过局域网域名",
+    "outboundTag": "direct",
+    "domain": [
+      "geosite:private"
+    ]
+  },
+  {
+    "remarks": "绕过中国公共DNSIP",
+    "outboundTag": "direct",
+    "ip": [
+      "223.5.5.5",
+      "223.6.6.6",
+      "2400:3200::1",
+      "2400:3200:baba::1",
+      "119.29.29.29",
+      "1.12.12.12",
+      "120.53.53.53",
+      "2402:4e00::",
+      "2402:4e00:1::",
+      "180.76.76.76",
+      "2400:da00::6666",
+      "114.114.114.114",
+      "114.114.115.115",
+      "114.114.114.119",
+      "114.114.115.119",
+      "114.114.114.110",
+      "114.114.115.110",
+      "180.184.1.1",
+      "180.184.2.2",
+      "101.226.4.6",
+      "218.30.118.6",
+      "123.125.81.6",
+      "140.207.198.6",
+      "1.2.4.8",
+      "210.2.4.8",
+      "52.80.66.66",
+      "117.50.22.22",
+      "2400:7fc0:849e:200::4",
+      "2404:c2c0:85d8:901::4",
+      "117.50.10.10",
+      "52.80.52.52",
+      "2400:7fc0:849e:200::8",
+      "2404:c2c0:85d8:901::8",
+      "117.50.60.30",
+      "52.80.60.30"
+    ]
+  },
+  {
+    "remarks": "绕过中国公共DNS域名",
+    "outboundTag": "direct",
+    "domain": [
+      "domain:alidns.com",
+      "domain:doh.pub",
+      "domain:dot.pub",
+      "domain:360.cn",
+      "domain:onedns.net"
+    ]
+  },
+  {
+    "remarks": "绕过中国IP",
+    "outboundTag": "direct",
+    "ip": [
+      "geoip:cn"
+    ]
+  },
+  {
+    "remarks": "绕过中国域名",
+    "outboundTag": "direct",
+    "domain": [
+      "geosite:cn"
+    ]
+  }
+]

+ 37 - 0
app/src/main/assets/custom_routing_white_iran

@@ -0,0 +1,37 @@
+[
+  {
+    "remarks": "Block udp443",
+    "outboundTag": "block",
+    "port": "443",
+    "network": "udp"
+  },
+  {
+    "remarks": "Direct LAN IP",
+    "outboundTag": "direct",
+    "ip": [
+      "geoip:private"
+    ]
+  },
+  {
+    "remarks": "Direct LAN domains",
+    "outboundTag": "direct",
+    "domain": [
+      "geosite:private"
+    ]
+  },
+  {
+    "remarks": "Bypass Iran domains",
+    "outboundTag": "direct",
+    "domain": [
+      "domain:ir",
+      "geosite:category-ir"
+    ]
+  },
+  {
+    "remarks": "Bypass Iran IP",
+    "outboundTag": "direct",
+    "ip": [
+       "geoip:ir"
+    ]
+  }
+]

+ 1285 - 0
app/src/main/assets/open_source_licenses.html

@@ -0,0 +1,1285 @@
+<!DOCTYPE html>
+<html lang="en">
+  <head><meta http-equiv="content-type" content="text/html; charset=utf-8">
+    <style>body { font-family: sans-serif; background-color: #ffffff; color: #000000; } a { color: #0000EE; } pre { background-color: #eeeeee; padding: 1em; white-space: pre-wrap; word-break: break-word; display: inline-block; } @media (prefers-color-scheme: dark) { body { background-color: #121212; color: #E0E0E0; } a { color: #BB86FC; } pre { background-color: #333333; color: #E0E0E0; } }</style>
+    <title>Open source licenses</title>
+  </head>
+  <body>
+    <h3>Notice for packages:</h3>
+    <ul>
+      <li><a href="#189946331">Camera Core</a>
+        <dl>
+          <dt>Copyright &copy; 2019 The Android Open Source Project</dt>
+          <dd></dd>
+        </dl>
+      </li>
+    </ul>
+<a id="189946331"></a>
+    <pre>                                 Apache License
+                           Version 2.0, January 2004
+                        http://www.apache.org/licenses/
+
+   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+   1. Definitions.
+
+      "License" shall mean the terms and conditions for use, reproduction,
+      and distribution as defined by Sections 1 through 9 of this document.
+
+      "Licensor" shall mean the copyright owner or entity authorized by
+      the copyright owner that is granting the License.
+
+      "Legal Entity" shall mean the union of the acting entity and all
+      other entities that control, are controlled by, or are under common
+      control with that entity. For the purposes of this definition,
+      "control" means (i) the power, direct or indirect, to cause the
+      direction or management of such entity, whether by contract or
+      otherwise, or (ii) ownership of fifty percent (50%) or more of the
+      outstanding shares, or (iii) beneficial ownership of such entity.
+
+      "You" (or "Your") shall mean an individual or Legal Entity
+      exercising permissions granted by this License.
+
+      "Source" form shall mean the preferred form for making modifications,
+      including but not limited to software source code, documentation
+      source, and configuration files.
+
+      "Object" form shall mean any form resulting from mechanical
+      transformation or translation of a Source form, including but
+      not limited to compiled object code, generated documentation,
+      and conversions to other media types.
+
+      "Work" shall mean the work of authorship, whether in Source or
+      Object form, made available under the License, as indicated by a
+      copyright notice that is included in or attached to the work
+      (an example is provided in the Appendix below).
+
+      "Derivative Works" shall mean any work, whether in Source or Object
+      form, that is based on (or derived from) the Work and for which the
+      editorial revisions, annotations, elaborations, or other modifications
+      represent, as a whole, an original work of authorship. For the purposes
+      of this License, Derivative Works shall not include works that remain
+      separable from, or merely link (or bind by name) to the interfaces of,
+      the Work and Derivative Works thereof.
+
+      "Contribution" shall mean any work of authorship, including
+      the original version of the Work and any modifications or additions
+      to that Work or Derivative Works thereof, that is intentionally
+      submitted to Licensor for inclusion in the Work by the copyright owner
+      or by an individual or Legal Entity authorized to submit on behalf of
+      the copyright owner. For the purposes of this definition, "submitted"
+      means any form of electronic, verbal, or written communication sent
+      to the Licensor or its representatives, including but not limited to
+      communication on electronic mailing lists, source code control systems,
+      and issue tracking systems that are managed by, or on behalf of, the
+      Licensor for the purpose of discussing and improving the Work, but
+      excluding communication that is conspicuously marked or otherwise
+      designated in writing by the copyright owner as "Not a Contribution."
+
+      "Contributor" shall mean Licensor and any individual or Legal Entity
+      on behalf of whom a Contribution has been received by Licensor and
+      subsequently incorporated within the Work.
+
+   2. Grant of Copyright License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      copyright license to reproduce, prepare Derivative Works of,
+      publicly display, publicly perform, sublicense, and distribute the
+      Work and such Derivative Works in Source or Object form.
+
+   3. Grant of Patent License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      (except as stated in this section) patent license to make, have made,
+      use, offer to sell, sell, import, and otherwise transfer the Work,
+      where such license applies only to those patent claims licensable
+      by such Contributor that are necessarily infringed by their
+      Contribution(s) alone or by combination of their Contribution(s)
+      with the Work to which such Contribution(s) was submitted. If You
+      institute patent litigation against any entity (including a
+      cross-claim or counterclaim in a lawsuit) alleging that the Work
+      or a Contribution incorporated within the Work constitutes direct
+      or contributory patent infringement, then any patent licenses
+      granted to You under this License for that Work shall terminate
+      as of the date such litigation is filed.
+
+   4. Redistribution. You may reproduce and distribute copies of the
+      Work or Derivative Works thereof in any medium, with or without
+      modifications, and in Source or Object form, provided that You
+      meet the following conditions:
+
+      (a) You must give any other recipients of the Work or
+          Derivative Works a copy of this License; and
+
+      (b) You must cause any modified files to carry prominent notices
+          stating that You changed the files; and
+
+      (c) You must retain, in the Source form of any Derivative Works
+          that You distribute, all copyright, patent, trademark, and
+          attribution notices from the Source form of the Work,
+          excluding those notices that do not pertain to any part of
+          the Derivative Works; and
+
+      (d) If the Work includes a "NOTICE" text file as part of its
+          distribution, then any Derivative Works that You distribute must
+          include a readable copy of the attribution notices contained
+          within such NOTICE file, excluding those notices that do not
+          pertain to any part of the Derivative Works, in at least one
+          of the following places: within a NOTICE text file distributed
+          as part of the Derivative Works; within the Source form or
+          documentation, if provided along with the Derivative Works; or,
+          within a display generated by the Derivative Works, if and
+          wherever such third-party notices normally appear. The contents
+          of the NOTICE file are for informational purposes only and
+          do not modify the License. You may add Your own attribution
+          notices within Derivative Works that You distribute, alongside
+          or as an addendum to the NOTICE text from the Work, provided
+          that such additional attribution notices cannot be construed
+          as modifying the License.
+
+      You may add Your own copyright statement to Your modifications and
+      may provide additional or different license terms and conditions
+      for use, reproduction, or distribution of Your modifications, or
+      for any such Derivative Works as a whole, provided Your use,
+      reproduction, and distribution of the Work otherwise complies with
+      the conditions stated in this License.
+
+   5. Submission of Contributions. Unless You explicitly state otherwise,
+      any Contribution intentionally submitted for inclusion in the Work
+      by You to the Licensor shall be under the terms and conditions of
+      this License, without any additional terms or conditions.
+      Notwithstanding the above, nothing herein shall supersede or modify
+      the terms of any separate license agreement you may have executed
+      with Licensor regarding such Contributions.
+
+   6. Trademarks. This License does not grant permission to use the trade
+      names, trademarks, service marks, or product names of the Licensor,
+      except as required for reasonable and customary use in describing the
+      origin of the Work and reproducing the content of the NOTICE file.
+
+   7. Disclaimer of Warranty. Unless required by applicable law or
+      agreed to in writing, Licensor provides the Work (and each
+      Contributor provides its Contributions) on an "AS IS" BASIS,
+      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+      implied, including, without limitation, any warranties or conditions
+      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+      PARTICULAR PURPOSE. You are solely responsible for determining the
+      appropriateness of using or redistributing the Work and assume any
+      risks associated with Your exercise of permissions under this License.
+
+   8. Limitation of Liability. In no event and under no legal theory,
+      whether in tort (including negligence), contract, or otherwise,
+      unless required by applicable law (such as deliberate and grossly
+      negligent acts) or agreed to in writing, shall any Contributor be
+      liable to You for damages, including any direct, indirect, special,
+      incidental, or consequential damages of any character arising as a
+      result of this License or out of the use or inability to use the
+      Work (including but not limited to damages for loss of goodwill,
+      work stoppage, computer failure or malfunction, or any and all
+      other commercial damages or losses), even if such Contributor
+      has been advised of the possibility of such damages.
+
+   9. Accepting Warranty or Additional Liability. While redistributing
+      the Work or Derivative Works thereof, You may choose to offer,
+      and charge a fee for, acceptance of support, warranty, indemnity,
+      or other liability obligations and/or rights consistent with this
+      License. However, in accepting such obligations, You may act only
+      on Your own behalf and on Your sole responsibility, not on behalf
+      of any other Contributor, and only if You agree to indemnify,
+      defend, and hold each Contributor harmless for any liability
+      incurred by, or claims asserted against, such Contributor by reason
+      of your accepting any such warranty or additional liability.
+
+   END OF TERMS AND CONDITIONS
+
+   APPENDIX: How to apply the Apache License to your work.
+
+      To apply the Apache License to your work, attach the following
+      boilerplate notice, with the fields enclosed by brackets "[]"
+      replaced with your own identifying information. (Don't include
+      the brackets!)  The text should be enclosed in the appropriate
+      comment syntax for the file format. We also recommend that a
+      file or class name and description of purpose be included on the
+      same "printed page" as the copyright notice for easier
+      identification within third-party archives.
+
+   Copyright [yyyy] [name of copyright owner]
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
+</pre>
+<br>
+    <pre>BSD License
+<a href="https://chromium.googlesource.com/libyuv/libyuv/+/refs/heads/main/README.chromium">https://chromium.googlesource.com/libyuv/libyuv/+/refs/heads/main/README.chromium</a></pre>
+<br>
+    <hr>
+    <ul>
+      <li><a href="#1934118923">Activity</a>
+        <dl>
+          <dt>Copyright &copy; 2018 The Android Open Source Project</dt>
+          <dd></dd>
+        </dl>
+      </li>
+      <li><a href="#1934118923">Activity Kotlin Extensions</a>
+        <dl>
+          <dt>Copyright &copy; 2018 The Android Open Source Project</dt>
+          <dd></dd>
+        </dl>
+      </li>
+      <li><a href="#1934118923">Android App Startup Runtime</a>
+        <dl>
+          <dt>Copyright &copy; 2020 The Android Open Source Project</dt>
+          <dd></dd>
+        </dl>
+      </li>
+      <li><a href="#1934118923">Android Arch-Common</a>
+        <dl>
+          <dt>Copyright &copy; 2017 The Android Open Source Project</dt>
+          <dd></dd>
+        </dl>
+      </li>
+      <li><a href="#1934118923">Android Arch-Runtime</a>
+        <dl>
+          <dt>Copyright &copy; 2017 The Android Open Source Project</dt>
+          <dd></dd>
+        </dl>
+      </li>
+      <li><a href="#1934118923">Android Emoji2 Compat</a>
+        <dl>
+          <dt>Copyright &copy; 2017 The Android Open Source Project</dt>
+          <dd></dd>
+        </dl>
+      </li>
+      <li><a href="#1934118923">Android Emoji2 Compat view helpers</a>
+        <dl>
+          <dt>Copyright &copy; 2017 The Android Open Source Project</dt>
+          <dd></dd>
+        </dl>
+      </li>
+      <li><a href="#1934118923">Android Multi-Dex Library</a>
+        <dl>
+          <dt>Copyright &copy; 2013 The Android Open Source Project</dt>
+          <dd></dd>
+        </dl>
+      </li>
+      <li><a href="#1934118923">Android Preferences KTX</a>
+        <dl>
+          <dt>Copyright &copy; 2018 The Android Open Source Project</dt>
+          <dd></dd>
+        </dl>
+      </li>
+      <li><a href="#1934118923">Android Resource Inspection - Annotations</a>
+        <dl>
+          <dt>Copyright &copy; 2021 The Android Open Source Project</dt>
+          <dd></dd>
+        </dl>
+      </li>
+      <li><a href="#1934118923">Android Support AnimatedVectorDrawable</a>
+        <dl>
+          <dt>Copyright &copy; 2015 The Android Open Source Project</dt>
+          <dd></dd>
+        </dl>
+      </li>
+      <li><a href="#1934118923">Android Support CardView v7</a>
+        <dl>
+          <dt>Copyright &copy; 2011 The Android Open Source Project</dt>
+          <dd></dd>
+        </dl>
+      </li>
+      <li><a href="#1934118923">Android Support DynamicAnimation</a>
+        <dl>
+          <dt>Copyright &copy; 2017 The Android Open Source Project</dt>
+          <dd></dd>
+        </dl>
+      </li>
+      <li><a href="#1934118923">Android Support ExifInterface</a>
+        <dl>
+          <dt>Copyright &copy; 2016 The Android Open Source Project</dt>
+          <dd></dd>
+        </dl>
+      </li>
+      <li><a href="#1934118923">Android Support Library Annotations</a>
+        <dl>
+          <dt>Copyright &copy; 2013 The Android Open Source Project</dt>
+          <dd></dd>
+        </dl>
+      </li>
+      <li><a href="#1934118923">Android Support Library Annotations</a>
+        <dl>
+          <dt>Copyright &copy; 2013 The Android Open Source Project</dt>
+          <dd></dd>
+        </dl>
+      </li>
+      <li><a href="#1934118923">Android Support Library Coordinator Layout</a>
+        <dl>
+          <dt>Copyright &copy; 2011 The Android Open Source Project</dt>
+          <dd></dd>
+        </dl>
+      </li>
+      <li><a href="#1934118923">Android Support Library core utils</a>
+        <dl>
+          <dt>Copyright &copy; 2011 The Android Open Source Project</dt>
+          <dd></dd>
+        </dl>
+      </li>
+      <li><a href="#1934118923">Android Support Library Cursor Adapter</a>
+        <dl>
+          <dt>Copyright &copy; 2018 The Android Open Source Project</dt>
+          <dd></dd>
+        </dl>
+      </li>
+      <li><a href="#1934118923">Android Support Library Custom View</a>
+        <dl>
+          <dt>Copyright &copy; 2018 The Android Open Source Project</dt>
+          <dd></dd>
+        </dl>
+      </li>
+      <li><a href="#1934118923">Android Support Library Document File</a>
+        <dl>
+          <dt>Copyright &copy; 2018 The Android Open Source Project</dt>
+          <dd></dd>
+        </dl>
+      </li>
+      <li><a href="#1934118923">Android Support Library Drawer Layout</a>
+        <dl>
+          <dt>Copyright &copy; 2018 The Android Open Source Project</dt>
+          <dd></dd>
+        </dl>
+      </li>
+      <li><a href="#1934118923">Android Support Library fragment</a>
+        <dl>
+          <dt>Copyright &copy; 2011 The Android Open Source Project</dt>
+          <dd></dd>
+        </dl>
+      </li>
+      <li><a href="#1934118923">Android Support Library Interpolators</a>
+        <dl>
+          <dt>Copyright &copy; 2018 The Android Open Source Project</dt>
+          <dd></dd>
+        </dl>
+      </li>
+      <li><a href="#1934118923">Android Support Library loader</a>
+        <dl>
+          <dt>Copyright &copy; 2011 The Android Open Source Project</dt>
+          <dd></dd>
+        </dl>
+      </li>
+      <li><a href="#1934118923">Android Support Library Local Broadcast Manager</a>
+        <dl>
+          <dt>Copyright &copy; 2018 The Android Open Source Project</dt>
+          <dd></dd>
+        </dl>
+      </li>
+      <li><a href="#1934118923">Android Support Library Print</a>
+        <dl>
+          <dt>Copyright &copy; 2018 The Android Open Source Project</dt>
+          <dd></dd>
+        </dl>
+      </li>
+      <li><a href="#1934118923">Android Support Library Sliding Pane Layout</a>
+        <dl>
+          <dt>Copyright &copy; 2018 The Android Open Source Project</dt>
+          <dd></dd>
+        </dl>
+      </li>
+      <li><a href="#1934118923">Android Support Library View Pager</a>
+        <dl>
+          <dt>Copyright &copy; 2018 The Android Open Source Project</dt>
+          <dd></dd>
+        </dl>
+      </li>
+      <li><a href="#1934118923">Android Support RecyclerView</a>
+        <dl>
+          <dt>Copyright &copy; 2014 The Android Open Source Project</dt>
+          <dd></dd>
+        </dl>
+      </li>
+      <li><a href="#1934118923">Android Support VectorDrawable</a>
+        <dl>
+          <dt>Copyright &copy; 2015 The Android Open Source Project</dt>
+          <dd></dd>
+        </dl>
+      </li>
+      <li><a href="#1934118923">Android Tracing</a>
+        <dl>
+          <dt>Copyright &copy; 2020 The Android Open Source Project</dt>
+          <dd></dd>
+        </dl>
+      </li>
+      <li><a href="#1934118923">Android Tracing Runtime Kotlin Extensions</a>
+        <dl>
+          <dt>Copyright &copy; 2020 The Android Open Source Project</dt>
+          <dd></dd>
+        </dl>
+      </li>
+      <li><a href="#1934118923">AndroidX Preference</a>
+        <dl>
+          <dt>Copyright &copy; 2015 The Android Open Source Project</dt>
+          <dd></dd>
+        </dl>
+      </li>
+      <li><a href="#1934118923">androidx.customview:poolingcontainer</a>
+        <dl>
+          <dt>Copyright &copy; 2021 The Android Open Source Project</dt>
+          <dd></dd>
+        </dl>
+      </li>
+      <li><a href="#1934118923">Annotation</a>
+        <dl>
+          <dt>Copyright &copy; 2013 The Android Open Source Project</dt>
+          <dd></dd>
+        </dl>
+      </li>
+      <li><a href="#1934118923">AppCompat</a>
+        <dl>
+          <dt>Copyright &copy; 2011 The Android Open Source Project</dt>
+          <dd></dd>
+        </dl>
+      </li>
+      <li><a href="#1934118923">AppCompat Resources</a>
+        <dl>
+          <dt>Copyright &copy; 2019 The Android Open Source Project</dt>
+          <dd></dd>
+        </dl>
+      </li>
+      <li><a href="#1934118923">AutoValue Annotations</a>
+        <dl>
+          <dt>Copyright &copy; 20xx The original author or authors</dt>
+          <dd></dd>
+        </dl>
+      </li>
+      <li><a href="#1934118923">Camera Lifecycle</a>
+        <dl>
+          <dt>Copyright &copy; 2019 The Android Open Source Project</dt>
+          <dd></dd>
+        </dl>
+      </li>
+      <li><a href="#1934118923">Camera Video</a>
+        <dl>
+          <dt>Copyright &copy; 2020 The Android Open Source Project</dt>
+          <dd></dd>
+        </dl>
+      </li>
+      <li><a href="#1934118923">Camera View</a>
+        <dl>
+          <dt>Copyright &copy; 2019 The Android Open Source Project</dt>
+          <dd></dd>
+        </dl>
+      </li>
+      <li><a href="#1934118923">Camera2</a>
+        <dl>
+          <dt>Copyright &copy; 2019 The Android Open Source Project</dt>
+          <dd></dd>
+        </dl>
+      </li>
+      <li><a href="#1934118923">collections</a>
+        <dl>
+          <dt>Copyright &copy; 2018 The Android Open Source Project</dt>
+          <dd></dd>
+        </dl>
+      </li>
+      <li><a href="#1934118923">Collections Kotlin Extensions</a>
+        <dl>
+          <dt>Copyright &copy; 2018 The Android Open Source Project</dt>
+          <dd></dd>
+        </dl>
+      </li>
+      <li><a href="#1934118923">ConstraintLayout</a>
+        <dl>
+          <dt>Copyright &copy; 2022 The Android Open Source Project</dt>
+          <dd></dd>
+        </dl>
+      </li>
+      <li><a href="#1934118923">ConstraintLayout Core</a>
+        <dl>
+          <dt>Copyright &copy; 2022 The Android Open Source Project</dt>
+          <dd></dd>
+        </dl>
+      </li>
+      <li><a href="#1934118923">Core</a>
+        <dl>
+          <dt>Copyright &copy; 2015 The Android Open Source Project</dt>
+          <dd></dd>
+        </dl>
+      </li>
+      <li><a href="#1934118923">Core Kotlin Extensions</a>
+        <dl>
+          <dt>Copyright &copy; 2018 The Android Open Source Project</dt>
+          <dd></dd>
+        </dl>
+      </li>
+      <li><a href="#1934118923">error-prone annotations</a>
+        <dl>
+          <dt>Copyright &copy; 20xx The original author or authors</dt>
+          <dd></dd>
+        </dl>
+      </li>
+      <li><a href="#1934118923">Experimental annotation</a>
+        <dl>
+          <dt>Copyright &copy; 2019 The Android Open Source Project</dt>
+          <dd></dd>
+        </dl>
+      </li>
+      <li><a href="#1934118923">firebase-annotations</a>
+        <dl>
+          <dt>Copyright &copy; 20xx The original author or authors</dt>
+          <dd></dd>
+        </dl>
+      </li>
+      <li><a href="#1934118923">firebase-components</a>
+        <dl>
+          <dt>Copyright &copy; 20xx The original author or authors</dt>
+          <dd></dd>
+        </dl>
+      </li>
+      <li><a href="#1934118923">firebase-encoders</a>
+        <dl>
+          <dt>Copyright &copy; 20xx The original author or authors</dt>
+          <dd></dd>
+        </dl>
+      </li>
+      <li><a href="#1934118923">firebase-encoders-json</a>
+        <dl>
+          <dt>Copyright &copy; 20xx The original author or authors</dt>
+          <dd></dd>
+        </dl>
+      </li>
+      <li><a href="#1934118923">flexbox-layout</a>
+        <dl>
+          <dt>Copyright &copy; 20xx Google</dt>
+          <dd></dd>
+        </dl>
+      </li>
+      <li><a href="#1934118923">Fragment Kotlin Extensions</a>
+        <dl>
+          <dt>Copyright &copy; 2018 The Android Open Source Project</dt>
+          <dd></dd>
+        </dl>
+      </li>
+      <li><a href="#1934118923">Futures</a>
+        <dl>
+          <dt>Copyright &copy; 2018 The Android Open Source Project</dt>
+          <dd></dd>
+        </dl>
+      </li>
+      <li><a href="#1934118923">Futures Kotlin Extensions</a>
+        <dl>
+          <dt>Copyright &copy; 2019 The Android Open Source Project</dt>
+          <dd></dd>
+        </dl>
+      </li>
+      <li><a href="#1934118923">Gson</a>
+        <dl>
+          <dt>Copyright &copy; 20xx The original author or authors</dt>
+          <dd></dd>
+        </dl>
+      </li>
+      <li><a href="#1934118923">Guava ListenableFuture only</a>
+        <dl>
+          <dt>Copyright &copy; 20xx The original author or authors</dt>
+          <dd></dd>
+        </dl>
+      </li>
+      <li><a href="#1934118923">javax.inject</a>
+        <dl>
+          <dt>Copyright &copy; 20xx The original author or authors</dt>
+          <dd></dd>
+        </dl>
+      </li>
+      <li><a href="#1934118923">JetBrains Java Annotations</a>
+        <dl>
+          <dt>Copyright &copy; 20xx JetBrains Team</dt>
+          <dd></dd>
+        </dl>
+      </li>
+      <li><a href="#1934118923">Jetpack WindowManager Library</a>
+        <dl>
+          <dt>Copyright &copy; 2020 The Android Open Source Project</dt>
+          <dd></dd>
+        </dl>
+      </li>
+      <li><a href="#1934118923">Kotlin Android Extensions Runtime</a>
+        <dl>
+          <dt>Copyright &copy; 20xx Kotlin Team</dt>
+          <dd></dd>
+        </dl>
+      </li>
+      <li><a href="#1934118923">Kotlin Stdlib</a>
+        <dl>
+          <dt>Copyright &copy; 20xx Kotlin Team</dt>
+          <dd></dd>
+        </dl>
+      </li>
+      <li><a href="#1934118923">Kotlin Stdlib Jdk7</a>
+        <dl>
+          <dt>Copyright &copy; 20xx Kotlin Team</dt>
+          <dd></dd>
+        </dl>
+      </li>
+      <li><a href="#1934118923">Kotlin Stdlib Jdk8</a>
+        <dl>
+          <dt>Copyright &copy; 20xx Kotlin Team</dt>
+          <dd></dd>
+        </dl>
+      </li>
+      <li><a href="#1934118923">kotlinx-coroutines-android</a>
+        <dl>
+          <dt>Copyright &copy; 20xx JetBrains Team</dt>
+          <dd></dd>
+        </dl>
+      </li>
+      <li><a href="#1934118923">kotlinx-coroutines-core</a>
+        <dl>
+          <dt>Copyright &copy; 20xx JetBrains Team</dt>
+          <dd></dd>
+        </dl>
+      </li>
+      <li><a href="#1934118923">Lifecycle Kotlin Extensions</a>
+        <dl>
+          <dt>Copyright &copy; 2019 The Android Open Source Project</dt>
+          <dd></dd>
+        </dl>
+      </li>
+      <li><a href="#1934118923">Lifecycle LiveData</a>
+        <dl>
+          <dt>Copyright &copy; 2017 The Android Open Source Project</dt>
+          <dd></dd>
+        </dl>
+      </li>
+      <li><a href="#1934118923">Lifecycle LiveData Core</a>
+        <dl>
+          <dt>Copyright &copy; 2017 The Android Open Source Project</dt>
+          <dd></dd>
+        </dl>
+      </li>
+      <li><a href="#1934118923">Lifecycle Process</a>
+        <dl>
+          <dt>Copyright &copy; 2018 The Android Open Source Project</dt>
+          <dd></dd>
+        </dl>
+      </li>
+      <li><a href="#1934118923">Lifecycle Runtime</a>
+        <dl>
+          <dt>Copyright &copy; 2017 The Android Open Source Project</dt>
+          <dd></dd>
+        </dl>
+      </li>
+      <li><a href="#1934118923">Lifecycle Runtime</a>
+        <dl>
+          <dt>Copyright &copy; 2017 The Android Open Source Project</dt>
+          <dd></dd>
+        </dl>
+      </li>
+      <li><a href="#1934118923">Lifecycle Service</a>
+        <dl>
+          <dt>Copyright &copy; 2018 The Android Open Source Project</dt>
+          <dd></dd>
+        </dl>
+      </li>
+      <li><a href="#1934118923">Lifecycle ViewModel</a>
+        <dl>
+          <dt>Copyright &copy; 2017 The Android Open Source Project</dt>
+          <dd></dd>
+        </dl>
+      </li>
+      <li><a href="#1934118923">Lifecycle ViewModel</a>
+        <dl>
+          <dt>Copyright &copy; 2017 The Android Open Source Project</dt>
+          <dd></dd>
+        </dl>
+      </li>
+      <li><a href="#1934118923">Lifecycle ViewModel</a>
+        <dl>
+          <dt>Copyright &copy; 2017 The Android Open Source Project</dt>
+          <dd></dd>
+        </dl>
+      </li>
+      <li><a href="#1934118923">Lifecycle ViewModel Kotlin Extensions</a>
+        <dl>
+          <dt>Copyright &copy; 2018 The Android Open Source Project</dt>
+          <dd></dd>
+        </dl>
+      </li>
+      <li><a href="#1934118923">Lifecycle ViewModel with SavedState</a>
+        <dl>
+          <dt>Copyright &copy; 2018 The Android Open Source Project</dt>
+          <dd></dd>
+        </dl>
+      </li>
+      <li><a href="#1934118923">Lifecycle-Common</a>
+        <dl>
+          <dt>Copyright &copy; 2017 The Android Open Source Project</dt>
+          <dd></dd>
+        </dl>
+      </li>
+      <li><a href="#1934118923">LiveData Core Kotlin Extensions</a>
+        <dl>
+          <dt>Copyright &copy; 2018 The Android Open Source Project</dt>
+          <dd></dd>
+        </dl>
+      </li>
+      <li><a href="#1934118923">LiveData Kotlin Extensions</a>
+        <dl>
+          <dt>Copyright &copy; 2018 The Android Open Source Project</dt>
+          <dd></dd>
+        </dl>
+      </li>
+      <li><a href="#1934118923">Material Components for Android</a>
+        <dl>
+          <dt>Copyright &copy; 2015 The Android Open Source Project</dt>
+          <dd></dd>
+        </dl>
+      </li>
+      <li><a href="#1934118923">Parcelize Runtime</a>
+        <dl>
+          <dt>Copyright &copy; 20xx Kotlin Team</dt>
+          <dd></dd>
+        </dl>
+      </li>
+      <li><a href="#1934118923">Profile Installer</a>
+        <dl>
+          <dt>Copyright &copy; 2021 The Android Open Source Project</dt>
+          <dd></dd>
+        </dl>
+      </li>
+      <li><a href="#1934118923">Room Kotlin Extensions</a>
+        <dl>
+          <dt>Copyright &copy; 2019 The Android Open Source Project</dt>
+          <dd></dd>
+        </dl>
+      </li>
+      <li><a href="#1934118923">Room-Common</a>
+        <dl>
+          <dt>Copyright &copy; 2017 The Android Open Source Project</dt>
+          <dd></dd>
+        </dl>
+      </li>
+      <li><a href="#1934118923">Room-Runtime</a>
+        <dl>
+          <dt>Copyright &copy; 2017 The Android Open Source Project</dt>
+          <dd></dd>
+        </dl>
+      </li>
+      <li><a href="#1934118923">RxAndroid</a>
+        <dl>
+          <dt>Copyright &copy; 20xx ReactiveX</dt>
+          <dd></dd>
+        </dl>
+      </li>
+      <li><a href="#1934118923">RxJava</a>
+        <dl>
+          <dt>Copyright &copy; 2013 David Karnok</dt>
+          <dd></dd>
+        </dl>
+      </li>
+      <li><a href="#1934118923">Saved State</a>
+        <dl>
+          <dt>Copyright &copy; 2018 The Android Open Source Project</dt>
+          <dd></dd>
+        </dl>
+      </li>
+      <li><a href="#1934118923">SavedState Kotlin Extensions</a>
+        <dl>
+          <dt>Copyright &copy; 2020 The Android Open Source Project</dt>
+          <dd></dd>
+        </dl>
+      </li>
+      <li><a href="#1934118923">SQLite</a>
+        <dl>
+          <dt>Copyright &copy; 2017 The Android Open Source Project</dt>
+          <dd></dd>
+        </dl>
+      </li>
+      <li><a href="#1934118923">SQLite Framework Integration</a>
+        <dl>
+          <dt>Copyright &copy; 2017 The Android Open Source Project</dt>
+          <dd></dd>
+        </dl>
+      </li>
+      <li><a href="#1934118923">tbruyelle/RxPermissions</a>
+        <dl>
+          <dt>Copyright &copy; 2015 Thomas Bruyelle</dt>
+          <dd></dd>
+        </dl>
+      </li>
+      <li><a href="#1934118923">ToastCompat</a>
+        <dl>
+          <dt>Copyright &copy; 20xx drakeet</dt>
+          <dd></dd>
+        </dl>
+      </li>
+      <li><a href="#1934118923">Transition</a>
+        <dl>
+          <dt>Copyright &copy; 2016 The Android Open Source Project</dt>
+          <dd></dd>
+        </dl>
+      </li>
+      <li><a href="#1934118923">transport-api</a>
+        <dl>
+          <dt>Copyright &copy; 20xx The original author or authors</dt>
+          <dd></dd>
+        </dl>
+      </li>
+      <li><a href="#1934118923">transport-backend-cct</a>
+        <dl>
+          <dt>Copyright &copy; 20xx The original author or authors</dt>
+          <dd></dd>
+        </dl>
+      </li>
+      <li><a href="#1934118923">transport-runtime</a>
+        <dl>
+          <dt>Copyright &copy; 20xx The original author or authors</dt>
+          <dd></dd>
+        </dl>
+      </li>
+      <li><a href="#1934118923">VersionedParcelable</a>
+        <dl>
+          <dt>Copyright &copy; 2018 The Android Open Source Project</dt>
+          <dd></dd>
+        </dl>
+      </li>
+      <li><a href="#1934118923">viewbinding</a>
+        <dl>
+          <dt>Copyright &copy; 20xx The original author or authors</dt>
+          <dd></dd>
+        </dl>
+      </li>
+      <li><a href="#1934118923">ViewPager2</a>
+        <dl>
+          <dt>Copyright &copy; 2017 The Android Open Source Project</dt>
+          <dd></dd>
+        </dl>
+      </li>
+      <li><a href="#1934118923">WorkManager Kotlin Extensions</a>
+        <dl>
+          <dt>Copyright &copy; 2018 The Android Open Source Project</dt>
+          <dd></dd>
+        </dl>
+      </li>
+      <li><a href="#1934118923">WorkManager Multiprocess</a>
+        <dl>
+          <dt>Copyright &copy; 2020 The Android Open Source Project</dt>
+          <dd></dd>
+        </dl>
+      </li>
+      <li><a href="#1934118923">WorkManager Runtime</a>
+        <dl>
+          <dt>Copyright &copy; 2018 The Android Open Source Project</dt>
+          <dd></dd>
+        </dl>
+      </li>
+      <li><a href="#1934118923">ZXing Core</a>
+        <dl>
+          <dt>Copyright &copy; 20xx The original author or authors</dt>
+          <dd></dd>
+        </dl>
+      </li>
+    </ul>
+<a id="1934118923"></a>
+    <pre>                                 Apache License
+                           Version 2.0, January 2004
+                        http://www.apache.org/licenses/
+
+   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+   1. Definitions.
+
+      "License" shall mean the terms and conditions for use, reproduction,
+      and distribution as defined by Sections 1 through 9 of this document.
+
+      "Licensor" shall mean the copyright owner or entity authorized by
+      the copyright owner that is granting the License.
+
+      "Legal Entity" shall mean the union of the acting entity and all
+      other entities that control, are controlled by, or are under common
+      control with that entity. For the purposes of this definition,
+      "control" means (i) the power, direct or indirect, to cause the
+      direction or management of such entity, whether by contract or
+      otherwise, or (ii) ownership of fifty percent (50%) or more of the
+      outstanding shares, or (iii) beneficial ownership of such entity.
+
+      "You" (or "Your") shall mean an individual or Legal Entity
+      exercising permissions granted by this License.
+
+      "Source" form shall mean the preferred form for making modifications,
+      including but not limited to software source code, documentation
+      source, and configuration files.
+
+      "Object" form shall mean any form resulting from mechanical
+      transformation or translation of a Source form, including but
+      not limited to compiled object code, generated documentation,
+      and conversions to other media types.
+
+      "Work" shall mean the work of authorship, whether in Source or
+      Object form, made available under the License, as indicated by a
+      copyright notice that is included in or attached to the work
+      (an example is provided in the Appendix below).
+
+      "Derivative Works" shall mean any work, whether in Source or Object
+      form, that is based on (or derived from) the Work and for which the
+      editorial revisions, annotations, elaborations, or other modifications
+      represent, as a whole, an original work of authorship. For the purposes
+      of this License, Derivative Works shall not include works that remain
+      separable from, or merely link (or bind by name) to the interfaces of,
+      the Work and Derivative Works thereof.
+
+      "Contribution" shall mean any work of authorship, including
+      the original version of the Work and any modifications or additions
+      to that Work or Derivative Works thereof, that is intentionally
+      submitted to Licensor for inclusion in the Work by the copyright owner
+      or by an individual or Legal Entity authorized to submit on behalf of
+      the copyright owner. For the purposes of this definition, "submitted"
+      means any form of electronic, verbal, or written communication sent
+      to the Licensor or its representatives, including but not limited to
+      communication on electronic mailing lists, source code control systems,
+      and issue tracking systems that are managed by, or on behalf of, the
+      Licensor for the purpose of discussing and improving the Work, but
+      excluding communication that is conspicuously marked or otherwise
+      designated in writing by the copyright owner as "Not a Contribution."
+
+      "Contributor" shall mean Licensor and any individual or Legal Entity
+      on behalf of whom a Contribution has been received by Licensor and
+      subsequently incorporated within the Work.
+
+   2. Grant of Copyright License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      copyright license to reproduce, prepare Derivative Works of,
+      publicly display, publicly perform, sublicense, and distribute the
+      Work and such Derivative Works in Source or Object form.
+
+   3. Grant of Patent License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      (except as stated in this section) patent license to make, have made,
+      use, offer to sell, sell, import, and otherwise transfer the Work,
+      where such license applies only to those patent claims licensable
+      by such Contributor that are necessarily infringed by their
+      Contribution(s) alone or by combination of their Contribution(s)
+      with the Work to which such Contribution(s) was submitted. If You
+      institute patent litigation against any entity (including a
+      cross-claim or counterclaim in a lawsuit) alleging that the Work
+      or a Contribution incorporated within the Work constitutes direct
+      or contributory patent infringement, then any patent licenses
+      granted to You under this License for that Work shall terminate
+      as of the date such litigation is filed.
+
+   4. Redistribution. You may reproduce and distribute copies of the
+      Work or Derivative Works thereof in any medium, with or without
+      modifications, and in Source or Object form, provided that You
+      meet the following conditions:
+
+      (a) You must give any other recipients of the Work or
+          Derivative Works a copy of this License; and
+
+      (b) You must cause any modified files to carry prominent notices
+          stating that You changed the files; and
+
+      (c) You must retain, in the Source form of any Derivative Works
+          that You distribute, all copyright, patent, trademark, and
+          attribution notices from the Source form of the Work,
+          excluding those notices that do not pertain to any part of
+          the Derivative Works; and
+
+      (d) If the Work includes a "NOTICE" text file as part of its
+          distribution, then any Derivative Works that You distribute must
+          include a readable copy of the attribution notices contained
+          within such NOTICE file, excluding those notices that do not
+          pertain to any part of the Derivative Works, in at least one
+          of the following places: within a NOTICE text file distributed
+          as part of the Derivative Works; within the Source form or
+          documentation, if provided along with the Derivative Works; or,
+          within a display generated by the Derivative Works, if and
+          wherever such third-party notices normally appear. The contents
+          of the NOTICE file are for informational purposes only and
+          do not modify the License. You may add Your own attribution
+          notices within Derivative Works that You distribute, alongside
+          or as an addendum to the NOTICE text from the Work, provided
+          that such additional attribution notices cannot be construed
+          as modifying the License.
+
+      You may add Your own copyright statement to Your modifications and
+      may provide additional or different license terms and conditions
+      for use, reproduction, or distribution of Your modifications, or
+      for any such Derivative Works as a whole, provided Your use,
+      reproduction, and distribution of the Work otherwise complies with
+      the conditions stated in this License.
+
+   5. Submission of Contributions. Unless You explicitly state otherwise,
+      any Contribution intentionally submitted for inclusion in the Work
+      by You to the Licensor shall be under the terms and conditions of
+      this License, without any additional terms or conditions.
+      Notwithstanding the above, nothing herein shall supersede or modify
+      the terms of any separate license agreement you may have executed
+      with Licensor regarding such Contributions.
+
+   6. Trademarks. This License does not grant permission to use the trade
+      names, trademarks, service marks, or product names of the Licensor,
+      except as required for reasonable and customary use in describing the
+      origin of the Work and reproducing the content of the NOTICE file.
+
+   7. Disclaimer of Warranty. Unless required by applicable law or
+      agreed to in writing, Licensor provides the Work (and each
+      Contributor provides its Contributions) on an "AS IS" BASIS,
+      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+      implied, including, without limitation, any warranties or conditions
+      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+      PARTICULAR PURPOSE. You are solely responsible for determining the
+      appropriateness of using or redistributing the Work and assume any
+      risks associated with Your exercise of permissions under this License.
+
+   8. Limitation of Liability. In no event and under no legal theory,
+      whether in tort (including negligence), contract, or otherwise,
+      unless required by applicable law (such as deliberate and grossly
+      negligent acts) or agreed to in writing, shall any Contributor be
+      liable to You for damages, including any direct, indirect, special,
+      incidental, or consequential damages of any character arising as a
+      result of this License or out of the use or inability to use the
+      Work (including but not limited to damages for loss of goodwill,
+      work stoppage, computer failure or malfunction, or any and all
+      other commercial damages or losses), even if such Contributor
+      has been advised of the possibility of such damages.
+
+   9. Accepting Warranty or Additional Liability. While redistributing
+      the Work or Derivative Works thereof, You may choose to offer,
+      and charge a fee for, acceptance of support, warranty, indemnity,
+      or other liability obligations and/or rights consistent with this
+      License. However, in accepting such obligations, You may act only
+      on Your own behalf and on Your sole responsibility, not on behalf
+      of any other Contributor, and only if You agree to indemnify,
+      defend, and hold each Contributor harmless for any liability
+      incurred by, or claims asserted against, such Contributor by reason
+      of your accepting any such warranty or additional liability.
+
+   END OF TERMS AND CONDITIONS
+
+   APPENDIX: How to apply the Apache License to your work.
+
+      To apply the Apache License to your work, attach the following
+      boilerplate notice, with the fields enclosed by brackets "[]"
+      replaced with your own identifying information. (Don't include
+      the brackets!)  The text should be enclosed in the appropriate
+      comment syntax for the file format. We also recommend that a
+      file or class name and description of purpose be included on the
+      same "printed page" as the copyright notice for easier
+      identification within third-party archives.
+
+   Copyright [yyyy] [name of copyright owner]
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
+</pre>
+<br>
+    <hr>
+    <ul>
+      <li><a href="#2134416733">MMKV</a>
+        <dl>
+          <dt>Copyright &copy; 20xx Tencent Wechat, Inc.</dt>
+          <dd></dd>
+        </dl>
+      </li>
+    </ul>
+<a id="2134416733"></a>
+    <pre>BSD 3-Clause License
+
+Copyright (c) [year], [fullname]
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+* Redistributions of source code must retain the above copyright notice, this
+  list of conditions and the following disclaimer.
+
+* Redistributions in binary form must reproduce the above copyright notice,
+  this list of conditions and the following disclaimer in the documentation
+  and/or other materials provided with the distribution.
+
+* Neither the name of the copyright holder nor the names of its
+  contributors may be used to endorse or promote products derived from
+  this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+</pre>
+<br>
+    <hr>
+    <ul>
+      <li><a href="#1121107629">image</a>
+        <dl>
+          <dt>Copyright &copy; 20xx The original author or authors</dt>
+          <dd></dd>
+        </dl>
+      </li>
+      <li><a href="#1121107629">play-services-base</a>
+        <dl>
+          <dt>Copyright &copy; 20xx The original author or authors</dt>
+          <dd></dd>
+        </dl>
+      </li>
+      <li><a href="#1121107629">play-services-basement</a>
+        <dl>
+          <dt>Copyright &copy; 20xx The original author or authors</dt>
+          <dd></dd>
+        </dl>
+      </li>
+      <li><a href="#1121107629">play-services-oss-licenses</a>
+        <dl>
+          <dt>Copyright &copy; 20xx The original author or authors</dt>
+          <dd></dd>
+        </dl>
+      </li>
+      <li><a href="#1121107629">play-services-tasks</a>
+        <dl>
+          <dt>Copyright &copy; 20xx The original author or authors</dt>
+          <dd></dd>
+        </dl>
+      </li>
+    </ul>
+<a id="1121107629"></a>
+    <pre>Android Software Development Kit License
+<a href="https://developer.android.com/studio/terms.html">https://developer.android.com/studio/terms.html</a></pre>
+<br>
+    <hr>
+    <ul>
+      <li><a href="#-1837751947">barcode-scanning</a>
+        <dl>
+          <dt>Copyright &copy; 20xx The original author or authors</dt>
+          <dd></dd>
+        </dl>
+      </li>
+      <li><a href="#-1837751947">barcode-scanning-common</a>
+        <dl>
+          <dt>Copyright &copy; 20xx The original author or authors</dt>
+          <dd></dd>
+        </dl>
+      </li>
+      <li><a href="#-1837751947">common</a>
+        <dl>
+          <dt>Copyright &copy; 20xx The original author or authors</dt>
+          <dd></dd>
+        </dl>
+      </li>
+      <li><a href="#-1837751947">play-services-mlkit-barcode-scanning</a>
+        <dl>
+          <dt>Copyright &copy; 20xx The original author or authors</dt>
+          <dd></dd>
+        </dl>
+      </li>
+      <li><a href="#-1837751947">vision-common</a>
+        <dl>
+          <dt>Copyright &copy; 20xx The original author or authors</dt>
+          <dd></dd>
+        </dl>
+      </li>
+      <li><a href="#-1837751947">vision-interfaces</a>
+        <dl>
+          <dt>Copyright &copy; 20xx The original author or authors</dt>
+          <dd></dd>
+        </dl>
+      </li>
+    </ul>
+<a id="-1837751947"></a>
+    <pre>ML Kit Terms of Service
+<a href="https://developers.google.com/ml-kit/terms">https://developers.google.com/ml-kit/terms</a></pre>
+<br>
+    <hr>
+    <ul>
+      <li><a href="#1258221018">editorkit</a>
+        <dl>
+          <dt>Copyright &copy; 20xx Dmitrii Rubtsov</dt>
+          <dd></dd>
+        </dl>
+      </li>
+      <li><a href="#1258221018">language-base</a>
+        <dl>
+          <dt>Copyright &copy; 20xx Dmitrii Rubtsov</dt>
+          <dd></dd>
+        </dl>
+      </li>
+      <li><a href="#1258221018">language-json</a>
+        <dl>
+          <dt>Copyright &copy; 20xx Dmitrii Rubtsov</dt>
+          <dd></dd>
+        </dl>
+      </li>
+    </ul>
+<a id="1258221018"></a>
+    <pre>Apache 2.0 License
+<a href="https://github.com/massivemadness/EditorKit/blob/master/LICENSE">https://github.com/massivemadness/EditorKit/blob/master/LICENSE</a></pre>
+<br>
+    <hr>
+    <ul>
+      <li><a href="#402152448">reactive-streams</a>
+        <dl>
+          <dt>Copyright &copy; 2014 Reactive Streams SIG</dt>
+          <dd></dd>
+        </dl>
+      </li>
+    </ul>
+<a id="402152448"></a>
+    <pre>MIT-0
+<a href="https://spdx.org/licenses/MIT-0.html">https://spdx.org/licenses/MIT-0.html</a></pre>
+<br>
+    <hr>
+    <ul>
+      <li><a href="#1783810846">quickie-bundled</a>
+        <dl>
+          <dt>Copyright &copy; 20xx Thomas Wirth</dt>
+          <dd></dd>
+        </dl>
+      </li>
+    </ul>
+<a id="1783810846"></a>
+    <pre>MIT License
+
+Copyright (c) [year] [fullname]
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+</pre>
+<br>
+    <hr>
+  </body>
+</html>

+ 413 - 0
app/src/main/assets/proxy_packagename.txt

@@ -0,0 +1,413 @@
+amanita_design.samorost3.gp
+android
+au.com.shiftyjelly.pocketcasts
+bbc.mobile.news.ww
+be.mygod.vpnhotspot
+ch.protonmail.android
+cm.aptoide.pt
+co.wanqu.android
+com.alphainventor.filemanager
+com.amazon.kindle
+com.amazon.mshop.android.shopping
+com.android.chrome
+com.android.providers.downloads
+com.android.providers.downloads.ui
+com.android.providers.telephony
+com.android.settings
+com.android.vending
+com.android6park.m6park
+com.apkpure.aegon
+com.apkupdater
+com.app.pornhub
+com.arthurivanets.owly
+com.asahi.tida.tablet
+com.authy.authy
+com.avmovie
+com.ballistiq.artstation
+com.binance.dev
+com.bitly.app
+com.brave.browser
+com.brave.browser_beta
+com.breel.wallpapers18
+com.bvanced.android.youtube
+com.chrome.beta
+com.chrome.canary
+com.chrome.dev
+com.cl.newt66y
+com.cradle.iitc_mobile
+org.exarhteam.iitc_mobile
+com.cygames.shadowverse
+com.dcard.freedom
+com.devhd.feedly
+com.devolver.reigns2
+com.discord
+com.downloader.video.tumblr
+com.driverbrowser
+com.dropbox.android
+com.duolingo
+com.duckduckgo.mobile.android
+com.dv.adm
+com.estrongs.android.pop
+com.estrongs.android.pop.pro
+com.evernote
+com.facebook.katana
+com.facebook.lite
+com.facebook.mlite
+com.facebook.orca
+com.facebook.services
+com.facebook.system
+com.fastaccess.github
+com.felixfilip.scpae
+com.fireproofstudios.theroom4
+com.firstrowria.pushnotificationtester
+com.flyersoft.moonreaderp
+com.fooview.android.fooview
+com.fvd.eversync
+com.gameloft.android.anmp.glofta8hm
+com.gameloft.android.anmp.glofta9hm
+com.gianlu.aria2app
+com.github.yeriomin.yalpstore
+com.google.android.apps.adm
+com.google.android.apps.books
+com.google.android.apps.docs
+com.google.android.apps.docs.editors.sheets
+com.google.android.apps.fitness
+com.google.android.apps.googleassistant
+com.google.android.apps.googlevoice
+com.google.android.apps.hangoutsdialer
+com.google.android.apps.inbox
+com.google.android.apps.magazines
+com.google.android.apps.maps
+com.google.android.apps.nbu.files
+com.google.android.apps.paidtasks
+com.google.android.apps.pdfviewer
+com.google.android.apps.photos
+com.google.android.apps.plus
+com.google.android.apps.translate
+com.google.android.gm
+com.google.android.gms
+com.google.android.gms.setup
+com.google.android.googlequicksearchbox
+com.google.android.gsf
+com.google.android.gsf.login
+com.google.android.ims
+com.google.android.inputmethod.latin
+com.google.android.instantapps.supervisor
+com.google.android.keep
+com.google.android.music
+com.google.android.ogyoutube
+com.google.android.partnersetup
+com.google.android.play.games
+com.google.android.street
+com.google.android.syncadapters.calendar
+com.google.android.syncadapters.contacts
+com.google.android.talk
+com.google.android.tts
+com.google.android.videos
+com.google.android.youtube
+com.google.ar.lens
+com.hochan.coldsoup
+com.ifttt.ifttt
+com.imgur.mobile
+com.innologica.inoreader
+com.instagram.android
+com.instagram.lite
+com.instapaper.android
+com.jarvanh.vpntether
+com.kapp.youtube.final
+com.klinker.android.twitter_l
+com.lastpass.lpandroid
+com.linecorp.linelite
+com.lingodeer
+com.ltnnews.news
+com.mediapods.tumbpods
+com.mgoogle.android.gms
+com.microsoft.emmx
+com.microsoft.office.powerpoint
+com.microsoft.skydrive
+com.mixplorer
+com.msd.consumerchinese
+com.msd.professionalchinese
+com.mss2011c.sharehelper
+com.netflix.mediaclient
+com.newin.nplayer.pro
+com.nianticlabs.ingress.prime.qa
+com.nianticproject.ingress
+com.ninefolders.hd3
+com.ninegag.android.app
+com.nintendo.zara
+com.nytimes.cn
+com.oasisfeng.island
+com.ocnt.liveapp.hw
+com.orekie.search
+com.patreon.android
+com.paypal.android.p2pmobile
+com.perol.asdpl.pixivez
+com.pinterest
+com.popularapp.periodcalendar
+com.popularapp.videodownloaderforinstagram
+com.pushbullet.android
+com.quoord.tapatalkpro.activity
+com.quora.android
+com.rayark.cytus2
+com.rayark.implosion
+com.rayark.pluto
+com.reddit.frontpage
+com.resilio.sync
+com.rhmsoft.edit
+com.rubenmayayo.reddit
+com.sec.android.app.sbrowser
+com.sec.android.app.sbrowser.beta
+com.shanga.walli
+com.simplehabit.simplehabitapp
+com.slack
+com.snaptube.premium
+com.sololearn
+com.sonelli.juicessh
+com.sparkslab.dcardreader
+com.spotify.music
+com.tencent.huatuo
+com.termux
+com.teslacoilsw.launcher
+com.theinitium.news
+com.thomsonreuters.reuters
+com.thunkable.android.hritvik00.freenom
+com.topjohnwu.magisk
+com.tripadvisor.tripadvisor
+com.tumblr
+com.twitter.android
+com.u91porn
+com.u9porn
+com.ubisoft.dance.justdance2015companion
+com.udn.news
+com.utopia.pxview
+com.valvesoftware.android.steam.community
+com.vanced.manager
+com.vanced.android.youtube
+com.vanced.android.apps.youtube.music
+com.mgoogle.android.gms
+com.vimeo.android.videoapp
+com.vivaldi.browser
+com.vivaldi.browser.snapshot
+com.vkontakte.android
+com.whatsapp
+com.wire
+com.wuxiangai.refactor
+com.xda.labs
+com.xvideos.app
+com.yahoo.mobile.client.android.superapp
+com.yandex.browser
+com.yandex.browser.beta
+com.yandex.browser.alpha
+com.z28j.feel
+com.zhiliaoapp.musically
+con.medium.reader
+de.apkgrabber
+de.robv.android.xposed.installer
+dk.tacit.android.foldersync.full
+es.rafalense.telegram.themes
+es.rafalense.themes
+flipboard.app
+fm.moon.app
+fr.gouv.etalab.mastodon
+github.tornaco.xposedmoduletest
+idm.internet.download.manager
+idm.internet.download.manager.plus
+io.github.javiewer
+io.github.skyhacker2.magnetsearch
+io.va.exposed
+it.mvilla.android.fenix2
+jp.bokete.app.android
+jp.naver.line.android
+jp.pxv.android
+luo.speedometergpspro
+m.cna.com.tw.App
+mark.via.gp
+me.tshine.easymark
+net.teeha.android.url_shortener
+net.tsapps.appsales
+onion.fire
+org.fdroid.fdroid
+org.freedownloadmanager.fdm
+org.kustom.widget
+org.mozilla.fennec_aurora
+org.mozilla.fenix
+org.mozilla.fenix.nightly
+org.mozilla.firefox
+org.mozilla.firefox_beta
+org.mozilla.focus
+org.schabi.newpipe
+org.telegram.messenger
+org.telegram.messenger.web
+org.telegram.multi
+org.telegram.plus
+org.thunderdog.challegram
+org.torproject.android
+org.torproject.torbrowser_alpha
+org.wikipedia
+org.xbmc.kodi
+pl.zdunex25.updater
+tv.twitch.android.app
+tw.com.gamer.android.activecenter
+videodownloader.downloadvideo.downloader
+uk.co.bbc.learningenglish
+com.ted.android
+de.danoeh.antennapod
+com.kiwibrowser.browser
+nekox.messenger
+com.nextcloud.client
+com.aurora.store
+com.aurora.adroid
+chat.simplex.app
+im.vector.app
+network.loki.messenger
+eu.siacs.conversations
+xyz.nextalone.nagram
+net.programmierecke.radiodroid2
+im.fdx.v2ex
+ml.docilealligator.infinityforreddit
+com.bytemyth.ama
+app.vanadium.browser
+com.cakewallet.cake_wallet
+org.purplei2p.i2pd
+dk.tacit.android.foldersync.lite
+com.nononsenseapps.feeder
+com.m2049r.xmrwallet
+com.paypal.android.p2pmobile
+com.google.android.apps.googlevoice
+com.readdle.spark
+org.torproject.torbrowser
+com.deepl.mobiletranslator
+com.microsoft.bing
+com.keylesspalace.tusky
+com.ottplay.ottplay
+ru.iptvremote.android.iptv.pro
+jp.naver.line.android
+com.xmflsct.app.tooot
+com.forem.android
+app.revanced.android.youtube
+com.mgoogle.android.gms
+com.pionex.client
+vip.mytokenpocket
+im.token.app
+com.linekong.mars24
+com.feixiaohao
+com.aicoin.appandroid
+com.binance.dev
+com.kraken.trade
+com.okinc.okex.gp
+com.authy.authy
+air.com.rosettastone.mobile.CoursePlayer
+com.blizzard.bma
+com.amazon.kindle
+com.google.android.apps.fitness
+net.tsapps.appsales
+com.wemesh.android
+com.google.android.apps.googleassistant
+allen.town.focus.reader
+me.hyliu.fluent_reader_lite
+com.aljazeera.mobile
+com.ft.news
+de.marmaro.krt.ffupdater
+myradio.radio.fmradio.liveradio.radiostation
+com.google.earth
+eu.kanade.tachiyomi.j2k
+com.audials
+com.microsoft.skydrive
+com.mb.android.tg
+com.melodis.midomiMusicIdentifier.freemium
+com.foxnews.android
+ch.threema.app
+com.briarproject.briar.android
+foundation.e.apps
+com.valvesoftware.android.steam.friendsui
+com.imback.yeetalk
+so.onekey.app.wallet
+com.xc3fff0e.xmanager
+meditofoundation.medito
+com.picol.client
+com.streetwriters.notesnook
+shanghai.panewsApp.com
+org.coursera.android
+com.positron_it.zlib
+com.blizzard.messenger
+com.javdb.javrocket
+com.picacomic.fregata
+com.fxl.chacha
+me.proton.android.drive
+com.lastpass.lpandroid
+com.tradingview.tradingviewapp
+com.deviantart.android.damobile
+com.fusionmedia.investing
+com.ewa.ewaapp
+com.duolingo
+com.hellotalk
+io.github.huskydg.magisk
+com.jsy.xpgbox
+com.hostloc.app.hostloc
+com.dena.pokota
+com.vitorpamplona.amethyst
+com.zhiliaoapp.musically
+us.spotco.fennec_dos
+com.fongmi.android.tv
+com.pocketprep.android.itcybersecurity
+com.cloudtv
+com.glassdoor.app
+com.indeed.android.jobsearch
+com.linkedin.android
+com.github.tvbox.osc.bh
+com.example.douban
+com.sipnetic.app
+com.microsoft.rdc.androidx
+org.zwanoo.android.speedtest
+com.sonelli.juicessh
+com.scmp.newspulse
+org.lsposed.manager
+mnn.Android
+com.thomsonretuers.reuters
+com.guardian
+com.ttxapps.onesyncv2
+org.fcitx.fcitx5.android.updater
+com.tailscale.ipn
+tw.nekomimi.nekogram
+com.nexon.kartdrift
+io.syncapps.lemmy_sync
+com.seazon.feedme
+com.readwise
+de.spiritcroc.riotx
+com.openai.chatgpt
+io.changenow.changenow
+com.poe.android
+com.twingate
+com.blinkslabs.blinkist.android
+com.ichi2.anki
+md.obsidian
+com.musixmatch.android.lyrify
+com.cyber.turbo
+com.offsec.nethunter
+me.ghui.v2er
+com.samruston.twitter
+org.adaway
+org.swiftapps.swiftbackup
+com.zerotier.one
+com.quietmobile
+com.instagram.barcelona
+im.molly.app
+com.rvx.android.youtube
+com.deepl.mobiletranslator
+com.qingsong.yingmi
+com.lemurbrowser.exts
+com.silverdev.dnartdroid
+me.ash.reader
+de.tutao.tutanota
+dev.imranr.obtainium
+com.getsomeheadspace.android
+org.cromite.cromite
+com.nutomic.syncthingandroid
+com.bumble.app
+com.cnn.mobile.android.phone
+com.google.android.apps.authenticator2
+com.microsoft.copilot
+com.netflix.NGP.Storyteller
+com.Slack
+com.server.auditor.ssh.client

+ 107 - 0
app/src/main/assets/v2ray_config.json

@@ -0,0 +1,107 @@
+{
+  "stats":{},
+  "log": {
+    "loglevel": "warning"
+  },
+  "policy":{
+      "levels": {
+        "8": {
+          "handshake": 4,
+          "connIdle": 300,
+          "uplinkOnly": 1,
+          "downlinkOnly": 1
+        }
+      },
+      "system": {
+        "statsOutboundUplink": true,
+        "statsOutboundDownlink": true
+      }
+  },
+  "inbounds": [{
+    "tag": "socks",
+    "port": 10808,
+    "protocol": "socks",
+    "settings": {
+      "auth": "noauth",
+      "udp": true,
+      "userLevel": 8
+    },
+    "sniffing": {
+      "enabled": true,
+      "destOverride": [
+        "http",
+        "tls"
+      ]
+    }
+  },
+  {
+    "tag": "http",
+    "port": 10809,
+    "protocol": "http",
+    "settings": {
+      "userLevel": 8
+    }
+  }
+],
+  "outbounds": [{
+    "tag": "proxy",
+    "protocol": "vmess",
+    "settings": {
+      "vnext": [
+        {
+          "address": "v2ray.cool",
+          "port": 10086,
+          "users": [
+            {
+              "id": "a3482e88-686a-4a58-8126-99c9df64b7bf",
+              "alterId": 0,
+              "security": "auto",
+              "level": 8
+            }
+          ]
+        }
+      ],
+      "servers": [
+        {
+        "address": "v2ray.cool",
+        "method": "chacha20",
+        "ota": false,
+        "password": "123456",
+        "port": 10086,
+        "level": 8
+      }
+      ]
+    },
+    "streamSettings": {
+      "network": "tcp"
+    },
+    "mux": {
+      "enabled": false
+    }
+  },
+  {
+    "protocol": "freedom",
+    "settings": {
+      "domainStrategy": "UseIP"
+    },
+    "tag": "direct"
+  },
+  {
+    "protocol": "blackhole",
+    "tag": "block",
+    "settings": {
+      "response": {
+        "type": "http"
+      }
+    }
+  }
+  ],
+  "routing": {
+      "domainStrategy": "AsIs",
+      "rules": []
+  },
+  "dns": {
+      "hosts": {},
+      "servers": []
+  }
+}

BIN=BIN
app/src/main/ic_launcher-web.png


+ 47 - 0
app/src/main/java/com/v2ray/ang/AngApplication.kt

@@ -0,0 +1,47 @@
+package com.v2ray.ang
+
+import android.content.Context
+import androidx.multidex.MultiDexApplication
+import androidx.work.Configuration
+import androidx.work.WorkManager
+import com.tencent.mmkv.MMKV
+import com.v2ray.ang.AppConfig.ANG_PACKAGE
+import com.v2ray.ang.handler.SettingsManager
+
+class AngApplication : MultiDexApplication() {
+    companion object {
+        lateinit var application: AngApplication
+    }
+
+    /**
+     * Attaches the base context to the application.
+     * @param base The base context.
+     */
+    override fun attachBaseContext(base: Context?) {
+        super.attachBaseContext(base)
+        application = this
+    }
+
+    private val workManagerConfiguration: Configuration = Configuration.Builder()
+        .setDefaultProcessName("${ANG_PACKAGE}:bg")
+        .build()
+
+    /**
+     * Initializes the application.
+     */
+    override fun onCreate() {
+        super.onCreate()
+
+        MMKV.initialize(this)
+
+        SettingsManager.setNightMode()
+        // Initialize WorkManager with the custom configuration
+        WorkManager.initialize(this, workManagerConfiguration)
+
+        SettingsManager.initRoutingRulesets(this)
+
+        es.dmoral.toasty.Toasty.Config.getInstance()
+            .setGravity(android.view.Gravity.BOTTOM, 0, 200)
+            .apply()
+    }
+}

+ 259 - 0
app/src/main/java/com/v2ray/ang/AppConfig.kt

@@ -0,0 +1,259 @@
+package com.v2ray.ang
+
+
+object AppConfig {
+
+    /** The application's package name. */
+    const val ANG_PACKAGE = BuildConfig.APPLICATION_ID
+    const val TAG = BuildConfig.APPLICATION_ID
+
+    /** Directory names used in the app's file system. */
+    const val DIR_ASSETS = "assets"
+    const val DIR_BACKUPS = "backups"
+
+    /** Legacy configuration keys. */
+    const val ANG_CONFIG = "ang_config"
+
+    /** Preferences mapped to MMKV storage. */
+    const val PREF_SNIFFING_ENABLED = "pref_sniffing_enabled"
+    const val PREF_ROUTE_ONLY_ENABLED = "pref_route_only_enabled"
+    const val PREF_PER_APP_PROXY = "pref_per_app_proxy"
+    const val PREF_PER_APP_PROXY_SET = "pref_per_app_proxy_set"
+    const val PREF_BYPASS_APPS = "pref_bypass_apps"
+    const val PREF_LOCAL_DNS_ENABLED = "pref_local_dns_enabled"
+    const val PREF_FAKE_DNS_ENABLED = "pref_fake_dns_enabled"
+    const val PREF_APPEND_HTTP_PROXY = "pref_append_http_proxy"
+    const val PREF_LOCAL_DNS_PORT = "pref_local_dns_port"
+    const val PREF_VPN_DNS = "pref_vpn_dns"
+    const val PREF_VPN_BYPASS_LAN = "pref_vpn_bypass_lan"
+    const val PREF_VPN_INTERFACE_ADDRESS_CONFIG_INDEX = "pref_vpn_interface_address_config_index"
+    const val PREF_VPN_MTU = "pref_vpn_mtu"
+    const val PREF_ROUTING_DOMAIN_STRATEGY = "pref_routing_domain_strategy"
+    const val PREF_ROUTING_RULESET = "pref_routing_ruleset"
+    const val PREF_MUX_ENABLED = "pref_mux_enabled"
+    const val PREF_MUX_CONCURRENCY = "pref_mux_concurrency"
+    const val PREF_MUX_XUDP_CONCURRENCY = "pref_mux_xudp_concurrency"
+    const val PREF_MUX_XUDP_QUIC = "pref_mux_xudp_quic"
+    const val PREF_FRAGMENT_ENABLED = "pref_fragment_enabled"
+    const val PREF_FRAGMENT_PACKETS = "pref_fragment_packets"
+    const val PREF_FRAGMENT_LENGTH = "pref_fragment_length"
+    const val PREF_FRAGMENT_INTERVAL = "pref_fragment_interval"
+    const val SUBSCRIPTION_AUTO_UPDATE = "pref_auto_update_subscription"
+    const val SUBSCRIPTION_AUTO_UPDATE_INTERVAL = "pref_auto_update_interval"
+    const val SUBSCRIPTION_DEFAULT_UPDATE_INTERVAL = "1440" // Default is 24 hours
+    const val SUBSCRIPTION_UPDATE_TASK_NAME = "subscription_updater"
+    const val PREF_SPEED_ENABLED = "pref_speed_enabled"
+    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_LANGUAGE = "pref_language"
+    const val PREF_UI_MODE_NIGHT = "pref_ui_mode_night"
+    const val PREF_PREFER_IPV6 = "pref_prefer_ipv6"
+    const val PREF_PROXY_SHARING = "pref_proxy_sharing_enabled"
+    const val PREF_ALLOW_INSECURE = "pref_allow_insecure"
+    const val PREF_SOCKS_PORT = "pref_socks_port"
+    const val PREF_REMOTE_DNS = "pref_remote_dns"
+    const val PREF_DOMESTIC_DNS = "pref_domestic_dns"
+    const val PREF_DNS_HOSTS = "pref_dns_hosts"
+    const val PREF_DELAY_TEST_URL = "pref_delay_test_url"
+    const val PREF_LOGLEVEL = "pref_core_loglevel"
+    const val PREF_OUTBOUND_DOMAIN_RESOLVE_METHOD = "pref_outbound_domain_resolve_method"
+    const val PREF_INTELLIGENT_SELECTION_METHOD = "pref_intelligent_selection_method"
+    const val PREF_MODE = "pref_mode"
+    const val PREF_IS_BOOTED = "pref_is_booted"
+    const val PREF_CHECK_UPDATE_PRE_RELEASE = "pref_check_update_pre_release"
+    const val PREF_GEO_FILES_SOURCES = "pref_geo_files_sources"
+    const val PREF_USE_HEV_TUNNEL = "pref_use_hev_tunnel"
+    const val PREF_HEV_TUNNEL_LOGLEVEL = "pref_hev_tunnel_loglevel"
+    const val PREF_HEV_TUNNEL_RW_TIMEOUT = "pref_hev_tunnel_rw_timeout"
+
+    /** 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"
+
+    /** Broadcast actions. */
+    const val BROADCAST_ACTION_SERVICE = "com.v2ray.ang.action.service"
+    const val BROADCAST_ACTION_ACTIVITY = "com.v2ray.ang.action.activity"
+    const val BROADCAST_ACTION_WIDGET_CLICK = "com.v2ray.ang.action.widget.click"
+
+    /** Tasker extras. */
+    const val TASKER_EXTRA_BUNDLE = "com.twofortyfouram.locale.intent.extra.BUNDLE"
+    const val TASKER_EXTRA_STRING_BLURB = "com.twofortyfouram.locale.intent.extra.BLURB"
+    const val TASKER_EXTRA_BUNDLE_SWITCH = "tasker_extra_bundle_switch"
+    const val TASKER_EXTRA_BUNDLE_GUID = "tasker_extra_bundle_guid"
+    const val TASKER_DEFAULT_GUID = "Default"
+
+    /** Tags for different proxy modes. */
+    const val TAG_PROXY = "proxy"
+    const val TAG_DIRECT = "direct"
+    const val TAG_BLOCKED = "block"
+    const val TAG_FRAGMENT = "fragment"
+    const val TAG_DNS = "dns-module"
+    const val TAG_DOMESTIC_DNS = "domestic-dns"
+
+    /** Network-related constants. */
+    const val UPLINK = "uplink"
+    const val DOWNLINK = "downlink"
+
+    /** URLs for various resources. */
+    const val GITHUB_URL = "https://github.com"
+    const val GITHUB_RAW_URL = "https://raw.githubusercontent.com"
+    const val GITHUB_DOWNLOAD_URL = "$GITHUB_URL/%s/releases/latest/download"
+    const val ANDROID_PACKAGE_NAME_LIST_URL = "$GITHUB_RAW_URL/2dust/androidpackagenamelist/master/proxy.txt"
+    const val APP_URL = "$GITHUB_URL/2dust/v2rayNG"
+    const val APP_API_URL = "https://api.github.com/repos/2dust/v2rayNG/releases"
+    const val APP_ISSUES_URL = "$APP_URL/issues"
+    const val APP_WIKI_MODE = "$APP_URL/wiki/Mode"
+    const val APP_PRIVACY_POLICY = "$GITHUB_RAW_URL/2dust/v2rayNG/master/CR.md"
+    const val APP_PROMOTION_URL = "aHR0cHM6Ly85LjIzNDQ1Ni54eXovYWJjLmh0bWw="
+    const val TG_CHANNEL_URL = "https://t.me/github_2dust"
+    const val DELAY_TEST_URL = "https://www.gstatic.com/generate_204"
+    const val DELAY_TEST_URL2 = "https://www.google.com/generate_204"
+    const val IP_API_URL = "https://speed.cloudflare.com/meta"
+
+    /** DNS server addresses. */
+    const val DNS_PROXY = "1.1.1.1"
+    const val DNS_DIRECT = "223.5.5.5"
+    const val DNS_VPN = "1.1.1.1"
+    const val GEOSITE_PRIVATE = "geosite:private"
+    const val GEOSITE_CN = "geosite:cn"
+    const val GEOIP_PRIVATE = "geoip:private"
+    const val GEOIP_CN = "geoip:cn"
+
+    /** Ports and addresses for various services. */
+    const val PORT_LOCAL_DNS = "10853"
+    const val PORT_SOCKS = "10808"
+    const val WIREGUARD_LOCAL_ADDRESS_V4 = "172.16.0.2/32"
+    const val WIREGUARD_LOCAL_ADDRESS_V6 = "2606:4700:110:8f81:d551:a0:532e:a2b3/128"
+    const val WIREGUARD_LOCAL_MTU = "1420"
+    const val LOOPBACK = "127.0.0.1"
+
+    /** Message constants for communication. */
+    const val MSG_REGISTER_CLIENT = 1
+    const val MSG_STATE_RUNNING = 11
+    const val MSG_STATE_NOT_RUNNING = 12
+    const val MSG_UNREGISTER_CLIENT = 2
+    const val MSG_STATE_START = 3
+    const val MSG_STATE_START_SUCCESS = 31
+    const val MSG_STATE_START_FAILURE = 32
+    const val MSG_STATE_STOP = 4
+    const val MSG_STATE_STOP_SUCCESS = 41
+    const val MSG_STATE_RESTART = 5
+    const val MSG_MEASURE_DELAY = 6
+    const val MSG_MEASURE_DELAY_SUCCESS = 61
+    const val MSG_MEASURE_CONFIG = 7
+    const val MSG_MEASURE_CONFIG_SUCCESS = 71
+    const val MSG_MEASURE_CONFIG_CANCEL = 72
+
+    /** Notification channel IDs and names. */
+    const val RAY_NG_CHANNEL_ID = "RAY_NG_M_CH_ID"
+    const val RAY_NG_CHANNEL_NAME = "v2rayNG Background Service"
+    const val SUBSCRIPTION_UPDATE_CHANNEL = "subscription_update_channel"
+    const val SUBSCRIPTION_UPDATE_CHANNEL_NAME = "Subscription Update Service"
+
+    /** Protocols Scheme **/
+    const val VMESS = "vmess://"
+    const val CUSTOM = ""
+    const val SHADOWSOCKS = "ss://"
+    const val SOCKS = "socks://"
+    const val HTTP = "http://"
+    const val VLESS = "vless://"
+    const val TROJAN = "trojan://"
+    const val WIREGUARD = "wireguard://"
+    const val TUIC = "tuic://"
+    const val HYSTERIA2 = "hysteria2://"
+    const val HY2 = "hy2://"
+
+    /** Give a good name to this, IDK*/
+    const val VPN = "VPN"
+    const val VPN_MTU = 1500
+
+    /** hev-sock5-tunnel read-write-timeout value */
+    const val HEVTUN_RW_TIMEOUT = "300000"
+
+    // Google API rule constants
+    const val GOOGLEAPIS_CN_DOMAIN = "domain:googleapis.cn"
+    const val GOOGLEAPIS_COM_DOMAIN = "googleapis.com"
+
+    // Android Private DNS constants
+    const val DNS_DNSPOD_DOMAIN = "dot.pub"
+    const val DNS_ALIDNS_DOMAIN = "dns.alidns.com"
+    const val DNS_CLOUDFLARE_ONE_DOMAIN = "one.one.one.one"
+    const val DNS_CLOUDFLARE_DNS_COM_DOMAIN = "dns.cloudflare.com"
+    const val DNS_CLOUDFLARE_DNS_DOMAIN = "cloudflare-dns.com"
+    const val DNS_GOOGLE_DOMAIN = "dns.google"
+    const val DNS_QUAD9_DOMAIN = "dns.quad9.net"
+    const val DNS_YANDEX_DOMAIN = "common.dot.dns.yandex.net"
+
+    const val DEFAULT_PORT = 443
+    const val DEFAULT_SECURITY = "auto"
+    const val DEFAULT_LEVEL = 8
+    const val DEFAULT_NETWORK = "tcp"
+    const val TLS = "tls"
+    const val REALITY = "reality"
+    const val HEADER_TYPE_HTTP = "http"
+
+    val DNS_ALIDNS_ADDRESSES = arrayListOf("223.5.5.5", "223.6.6.6", "2400:3200::1", "2400:3200:baba::1")
+    val DNS_CLOUDFLARE_ONE_ADDRESSES = arrayListOf("1.1.1.1", "1.0.0.1", "2606:4700:4700::1111", "2606:4700:4700::1001")
+    val DNS_CLOUDFLARE_DNS_COM_ADDRESSES = arrayListOf("104.16.132.229", "104.16.133.229", "2606:4700::6810:84e5", "2606:4700::6810:85e5")
+    val DNS_CLOUDFLARE_DNS_ADDRESSES = arrayListOf("104.16.248.249", "104.16.249.249", "2606:4700::6810:f8f9", "2606:4700::6810:f9f9")
+    val DNS_DNSPOD_ADDRESSES = arrayListOf("1.12.12.12", "120.53.53.53")
+    val DNS_GOOGLE_ADDRESSES = arrayListOf("8.8.8.8", "8.8.4.4", "2001:4860:4860::8888", "2001:4860:4860::8844")
+    val DNS_QUAD9_ADDRESSES = arrayListOf("9.9.9.9", "149.112.112.112", "2620:fe::fe", "2620:fe::9")
+    val DNS_YANDEX_ADDRESSES = arrayListOf("77.88.8.8", "77.88.8.1", "2a02:6b8::feed:0ff", "2a02:6b8:0:1::feed:0ff")
+
+    //minimum list https://serverfault.com/a/304791
+    val ROUTED_IP_LIST = arrayListOf(
+        "0.0.0.0/5",
+        "8.0.0.0/7",
+        "11.0.0.0/8",
+        "12.0.0.0/6",
+        "16.0.0.0/4",
+        "32.0.0.0/3",
+        "64.0.0.0/2",
+        "128.0.0.0/3",
+        "160.0.0.0/5",
+        "168.0.0.0/6",
+        "172.0.0.0/12",
+        "172.32.0.0/11",
+        "172.64.0.0/10",
+        "172.128.0.0/9",
+        "173.0.0.0/8",
+        "174.0.0.0/7",
+        "176.0.0.0/4",
+        "192.0.0.0/9",
+        "192.128.0.0/11",
+        "192.160.0.0/13",
+        "192.169.0.0/16",
+        "192.170.0.0/15",
+        "192.172.0.0/14",
+        "192.176.0.0/12",
+        "192.192.0.0/10",
+        "193.0.0.0/8",
+        "194.0.0.0/7",
+        "196.0.0.0/6",
+        "200.0.0.0/5",
+        "208.0.0.0/4",
+        "240.0.0.0/4"
+    )
+
+    val PRIVATE_IP_LIST = arrayListOf(
+        "0.0.0.0/8",
+        "10.0.0.0/8",
+        "127.0.0.0/8",
+        "172.16.0.0/12",
+        "192.168.0.0/16",
+        "169.254.0.0/16",
+        "224.0.0.0/4"
+    )
+
+    val GEO_FILES_SOURCES = arrayListOf(
+        "Loyalsoldier/v2ray-rules-dat",
+        "runetfreedom/russia-v2ray-rules-dat",
+        "Chocolate4U/Iran-v2ray-rules"
+    )
+
+}

+ 11 - 0
app/src/main/java/com/v2ray/ang/dto/AppInfo.kt

@@ -0,0 +1,11 @@
+package com.v2ray.ang.dto
+
+import android.graphics.drawable.Drawable
+
+data class AppInfo(
+    val appName: String,
+    val packageName: String,
+    val appIcon: Drawable,
+    val isSystemApp: Boolean,
+    var isSelected: Int
+)

+ 9 - 0
app/src/main/java/com/v2ray/ang/dto/AssetUrlItem.kt

@@ -0,0 +1,9 @@
+package com.v2ray.ang.dto
+
+data class AssetUrlItem(
+    var remarks: String = "",
+    var url: String = "",
+    val addedTime: Long = System.currentTimeMillis(),
+    var lastUpdated: Long = -1,
+    var locked: Boolean? = false,
+)

+ 10 - 0
app/src/main/java/com/v2ray/ang/dto/CheckUpdateResult.kt

@@ -0,0 +1,10 @@
+package com.v2ray.ang.dto
+
+data class CheckUpdateResult(
+    val hasUpdate: Boolean,
+    val latestVersion: String? = null,
+    val releaseNotes: String? = null,
+    val downloadUrl: String? = null,
+    val error: String? = null,
+    val isPreRelease: Boolean = false
+)

+ 9 - 0
app/src/main/java/com/v2ray/ang/dto/ConfigResult.kt

@@ -0,0 +1,9 @@
+package com.v2ray.ang.dto
+
+data class ConfigResult(
+    var status: Boolean,
+    var guid: String? = null,
+    var content: String = "",
+    var socksPort: Int? = null,
+)
+

+ 22 - 0
app/src/main/java/com/v2ray/ang/dto/EConfigType.kt

@@ -0,0 +1,22 @@
+package com.v2ray.ang.dto
+
+import com.v2ray.ang.AppConfig
+
+
+enum class EConfigType(val value: Int, val protocolScheme: String) {
+    VMESS(1, AppConfig.VMESS),
+    CUSTOM(2, AppConfig.CUSTOM),
+    SHADOWSOCKS(3, AppConfig.SHADOWSOCKS),
+    SOCKS(4, AppConfig.SOCKS),
+    VLESS(5, AppConfig.VLESS),
+    TROJAN(6, AppConfig.TROJAN),
+    WIREGUARD(7, AppConfig.WIREGUARD),
+
+    //    TUIC(8, AppConfig.TUIC),
+    HYSTERIA2(9, AppConfig.HYSTERIA2),
+    HTTP(10, AppConfig.HTTP);
+
+    companion object {
+        fun fromInt(value: Int) = entries.firstOrNull { it.value == value }
+    }
+}

+ 23 - 0
app/src/main/java/com/v2ray/ang/dto/GitHubRelease.kt

@@ -0,0 +1,23 @@
+package com.v2ray.ang.dto
+
+import com.google.gson.annotations.SerializedName
+
+data class GitHubRelease(
+    @SerializedName("tag_name")
+    val tagName: String,
+    @SerializedName("body")
+    val body: String,
+    @SerializedName("assets")
+    val assets: List<Asset>,
+    @SerializedName("prerelease")
+    val prerelease: Boolean = false,
+    @SerializedName("published_at")
+    val publishedAt: String = ""
+) {
+    data class Asset(
+        @SerializedName("name")
+        val name: String,
+        @SerializedName("browser_download_url")
+        val browserDownloadUrl: String
+    )
+}

+ 46 - 0
app/src/main/java/com/v2ray/ang/dto/Hysteria2Bean.kt

@@ -0,0 +1,46 @@
+package com.v2ray.ang.dto
+
+data class Hysteria2Bean(
+    val server: String?,
+    val auth: String?,
+    val lazy: Boolean? = true,
+    val obfs: ObfsBean? = null,
+    val socks5: Socks5Bean? = null,
+    val http: Socks5Bean? = null,
+    val tls: TlsBean? = null,
+    val transport: TransportBean? = null,
+    val bandwidth: BandwidthBean? = null,
+) {
+    data class ObfsBean(
+        val type: String?,
+        val salamander: SalamanderBean?
+    ) {
+        data class SalamanderBean(
+            val password: String?,
+        )
+    }
+
+    data class Socks5Bean(
+        val listen: String?,
+    )
+
+    data class TlsBean(
+        val sni: String?,
+        val insecure: Boolean?,
+        val pinSHA256: String?,
+    )
+
+    data class TransportBean(
+        val type: String?,
+        val udp: TransportUdpBean?
+    ) {
+        data class TransportUdpBean(
+            val hopInterval: String?,
+        )
+    }
+
+    data class BandwidthBean(
+        val down: String?,
+        val up: String?,
+    )
+}

+ 12 - 0
app/src/main/java/com/v2ray/ang/dto/IPAPIInfo.kt

@@ -0,0 +1,12 @@
+package com.v2ray.ang.dto
+
+data class IPAPIInfo(
+    var ip: String? = null,
+    var clientIp: String? = null,
+    var ip_addr: String? = null,
+    var query: String? = null,
+    var country: String? = null,
+    var country_name: String? = null,
+    var country_code: String? = null,
+    var countryCode: String? = null
+)

+ 20 - 0
app/src/main/java/com/v2ray/ang/dto/Language.kt

@@ -0,0 +1,20 @@
+package com.v2ray.ang.dto
+
+enum class Language(val code: String) {
+    AUTO("auto"),
+    ENGLISH("en"),
+    CHINA("zh-rCN"),
+    TRADITIONAL_CHINESE("zh-rTW"),
+    VIETNAMESE("vi"),
+    RUSSIAN("ru"),
+    PERSIAN("fa"),
+    ARABIC("ar"),
+    BANGLA("bn"),
+    BAKHTIARI("bqi-rIR");
+
+    companion object {
+        fun fromCode(code: String): Language {
+            return entries.find { it.code == code } ?: AUTO
+        }
+    }
+}

+ 18 - 0
app/src/main/java/com/v2ray/ang/dto/NetworkType.kt

@@ -0,0 +1,18 @@
+package com.v2ray.ang.dto
+
+enum class NetworkType(val type: String) {
+    TCP("tcp"),
+    KCP("kcp"),
+    WS("ws"),
+    HTTP_UPGRADE("httpupgrade"),
+    XHTTP("xhttp"),
+    HTTP("http"),
+    H2("h2"),
+
+    //QUIC("quic"),
+    GRPC("grpc");
+
+    companion object {
+        fun fromString(type: String?) = entries.find { it.type == type } ?: TCP
+    }
+}

+ 121 - 0
app/src/main/java/com/v2ray/ang/dto/ProfileItem.kt

@@ -0,0 +1,121 @@
+package com.v2ray.ang.dto
+
+import com.v2ray.ang.AppConfig.LOOPBACK
+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.util.Utils
+
+data class ProfileItem(
+    val configVersion: Int = 4,
+    val configType: EConfigType,
+    var subscriptionId: String = "",
+    var addedTime: Long = System.currentTimeMillis(),
+
+    var remarks: String = "",
+    var server: String? = null,
+    var serverPort: String? = null,
+
+    var password: String? = null,
+    var method: String? = null,
+    var flow: String? = null,
+    var username: String? = null,
+
+    var network: String? = null,
+    var headerType: String? = null,
+    var host: String? = null,
+    var path: String? = null,
+    var seed: String? = null,
+    var quicSecurity: String? = null,
+    var quicKey: String? = null,
+    var mode: String? = null,
+    var serviceName: String? = null,
+    var authority: String? = null,
+    var xhttpMode: String? = null,
+    var xhttpExtra: String? = null,
+
+    var security: String? = null,
+    var sni: String? = null,
+    var alpn: String? = null,
+    var fingerPrint: String? = null,
+    var insecure: Boolean? = null,
+
+    var publicKey: String? = null,
+    var shortId: String? = null,
+    var spiderX: String? = null,
+    var mldsa65Verify: String? = null,
+
+    var secretKey: String? = null,
+    var preSharedKey: String? = null,
+    var localAddress: String? = null,
+    var reserved: String? = null,
+    var mtu: Int? = null,
+
+    var obfsPassword: String? = null,
+    var portHopping: String? = null,
+    var portHoppingInterval: String? = null,
+    var pinSHA256: String? = null,
+    var bandwidthDown: String? = null,
+    var bandwidthUp: String? = null,
+
+    ) {
+    companion object {
+        fun create(configType: EConfigType): ProfileItem {
+            return ProfileItem(configType = configType)
+        }
+    }
+
+    fun getAllOutboundTags(): MutableList<String> {
+        return mutableListOf(TAG_PROXY, TAG_DIRECT, TAG_BLOCKED)
+    }
+
+    fun getServerAddressAndPort(): String {
+        if (server.isNullOrEmpty() && configType == EConfigType.CUSTOM) {
+            return "$LOOPBACK:$PORT_SOCKS"
+        }
+        return Utils.getIpv6Address(server) + ":" + serverPort
+    }
+
+    override fun equals(other: Any?): Boolean {
+        if (other == null) return false
+        val obj = other as ProfileItem
+
+        return (this.server == obj.server
+                && this.serverPort == obj.serverPort
+                && this.password == obj.password
+                && this.method == obj.method
+                && this.flow == obj.flow
+                && this.username == obj.username
+
+                && this.network == obj.network
+                && this.headerType == obj.headerType
+                && this.host == obj.host
+                && this.path == obj.path
+                && this.seed == obj.seed
+                && this.quicSecurity == obj.quicSecurity
+                && this.quicKey == obj.quicKey
+                && this.mode == obj.mode
+                && this.serviceName == obj.serviceName
+                && this.authority == obj.authority
+                && this.xhttpMode == obj.xhttpMode
+
+                && this.security == obj.security
+                && this.sni == obj.sni
+                && this.alpn == obj.alpn
+                && this.fingerPrint == obj.fingerPrint
+                && this.publicKey == obj.publicKey
+                && this.shortId == obj.shortId
+
+                && this.secretKey == obj.secretKey
+                && this.localAddress == obj.localAddress
+                && this.reserved == obj.reserved
+                && this.mtu == obj.mtu
+
+                && this.obfsPassword == obj.obfsPassword
+                && this.portHopping == obj.portHopping
+                && this.portHoppingInterval == obj.portHoppingInterval
+                && this.pinSHA256 == obj.pinSHA256
+                )
+    }
+}

+ 20 - 0
app/src/main/java/com/v2ray/ang/dto/RoutingType.kt

@@ -0,0 +1,20 @@
+package com.v2ray.ang.dto
+
+enum class RoutingType(val fileName: String) {
+    WHITE("custom_routing_white"),
+    BLACK("custom_routing_black"),
+    GLOBAL("custom_routing_global"),
+    WHITE_IRAN("custom_routing_white_iran");
+
+    companion object {
+        fun fromIndex(index: Int): RoutingType {
+            return when (index) {
+                0 -> WHITE
+                1 -> BLACK
+                2 -> GLOBAL
+                3 -> WHITE_IRAN
+                else -> WHITE
+            }
+        }
+    }
+}

+ 13 - 0
app/src/main/java/com/v2ray/ang/dto/RulesetItem.kt

@@ -0,0 +1,13 @@
+package com.v2ray.ang.dto
+
+data class RulesetItem(
+    var remarks: String? = "",
+    var ip: List<String>? = null,
+    var domain: List<String>? = null,
+    var outboundTag: String = "",
+    var port: String? = null,
+    var network: String? = null,
+    var protocol: List<String>? = null,
+    var enabled: Boolean = true,
+    var locked: Boolean? = false,
+)

+ 10 - 0
app/src/main/java/com/v2ray/ang/dto/ServerAffiliationInfo.kt

@@ -0,0 +1,10 @@
+package com.v2ray.ang.dto
+
+data class ServerAffiliationInfo(var testDelayMillis: Long = 0L) {
+    fun getTestDelayString(): String {
+        if (testDelayMillis == 0L) {
+            return ""
+        }
+        return testDelayMillis.toString() + "ms"
+    }
+}

+ 86 - 0
app/src/main/java/com/v2ray/ang/dto/ServerConfig.kt

@@ -0,0 +1,86 @@
+package com.v2ray.ang.dto
+
+import com.v2ray.ang.AppConfig.TAG_BLOCKED
+import com.v2ray.ang.AppConfig.TAG_DIRECT
+import com.v2ray.ang.AppConfig.TAG_PROXY
+
+data class ServerConfig(
+    val configVersion: Int = 3,
+    val configType: EConfigType,
+    var subscriptionId: String = "",
+    val addedTime: Long = System.currentTimeMillis(),
+    var remarks: String = "",
+    val outboundBean: V2rayConfig.OutboundBean? = null,
+    var fullConfig: V2rayConfig? = null
+) {
+    companion object {
+        fun create(configType: EConfigType): ServerConfig {
+            when (configType) {
+                EConfigType.VMESS,
+                EConfigType.VLESS ->
+                    return ServerConfig(
+                        configType = configType,
+                        outboundBean = V2rayConfig.OutboundBean(
+                            protocol = configType.name.lowercase(),
+                            settings = V2rayConfig.OutboundBean.OutSettingsBean(
+                                vnext = listOf(
+                                    V2rayConfig.OutboundBean.OutSettingsBean.VnextBean(
+                                        users = listOf(V2rayConfig.OutboundBean.OutSettingsBean.VnextBean.UsersBean())
+                                    )
+                                )
+                            ),
+                            streamSettings = V2rayConfig.OutboundBean.StreamSettingsBean()
+                        )
+                    )
+
+                EConfigType.CUSTOM ->
+                    return ServerConfig(configType = configType)
+
+                EConfigType.SHADOWSOCKS,
+                EConfigType.SOCKS,
+                EConfigType.HTTP,
+                EConfigType.TROJAN,
+                EConfigType.HYSTERIA2 ->
+                    return ServerConfig(
+                        configType = configType,
+                        outboundBean = V2rayConfig.OutboundBean(
+                            protocol = configType.name.lowercase(),
+                            settings = V2rayConfig.OutboundBean.OutSettingsBean(
+                                servers = listOf(V2rayConfig.OutboundBean.OutSettingsBean.ServersBean())
+                            ),
+                            streamSettings = V2rayConfig.OutboundBean.StreamSettingsBean()
+                        )
+                    )
+
+                EConfigType.WIREGUARD ->
+                    return ServerConfig(
+                        configType = configType,
+                        outboundBean = V2rayConfig.OutboundBean(
+                            protocol = configType.name.lowercase(),
+                            settings = V2rayConfig.OutboundBean.OutSettingsBean(
+                                secretKey = "",
+                                peers = listOf(V2rayConfig.OutboundBean.OutSettingsBean.WireGuardBean())
+                            )
+                        )
+                    )
+            }
+        }
+    }
+
+    fun getProxyOutbound(): V2rayConfig.OutboundBean? {
+        if (configType != EConfigType.CUSTOM) {
+            return outboundBean
+        }
+        return fullConfig?.getProxyOutbound()
+    }
+
+    fun getAllOutboundTags(): MutableList<String> {
+        if (configType != EConfigType.CUSTOM) {
+            return mutableListOf(TAG_PROXY, TAG_DIRECT, TAG_BLOCKED)
+        }
+        fullConfig?.let { config ->
+            return config.outbounds.map { it.tag }.toMutableList()
+        }
+        return mutableListOf()
+    }
+}

+ 6 - 0
app/src/main/java/com/v2ray/ang/dto/ServersCache.kt

@@ -0,0 +1,6 @@
+package com.v2ray.ang.dto
+
+data class ServersCache(
+    val guid: String,
+    val profile: ProfileItem
+)

+ 17 - 0
app/src/main/java/com/v2ray/ang/dto/SubscriptionItem.kt

@@ -0,0 +1,17 @@
+package com.v2ray.ang.dto
+
+data class SubscriptionItem(
+    var remarks: String = "",
+    var url: String = "",
+    var enabled: Boolean = true,
+    val addedTime: Long = System.currentTimeMillis(),
+    var lastUpdated: Long = -1,
+    var autoUpdate: Boolean = false,
+    val updateInterval: Int? = null,
+    var prevProfile: String? = null,
+    var nextProfile: String? = null,
+    var filter: String? = null,
+    var intelligentSelectionFilter: String? = null,
+    var allowInsecureUrl: Boolean = false,
+)
+

+ 611 - 0
app/src/main/java/com/v2ray/ang/dto/V2rayConfig.kt

@@ -0,0 +1,611 @@
+package com.v2ray.ang.dto
+
+import com.google.gson.annotations.SerializedName
+import com.v2ray.ang.AppConfig
+import com.v2ray.ang.util.Utils
+
+data class V2rayConfig(
+    var remarks: String? = null,
+    var stats: Any? = null,
+    val log: LogBean,
+    var policy: PolicyBean? = null,
+    val inbounds: ArrayList<InboundBean>,
+    var outbounds: ArrayList<OutboundBean>,
+    var dns: DnsBean? = null,
+    val routing: RoutingBean,
+    val api: Any? = null,
+    val transport: Any? = null,
+    val reverse: Any? = null,
+    var fakedns: Any? = null,
+    val browserForwarder: Any? = null,
+    var observatory: Any? = null,
+    var burstObservatory: Any? = null
+) {
+
+    data class LogBean(
+        val access: String? = null,
+        val error: String? = null,
+        var loglevel: String? = null,
+        val dnsLog: Boolean? = null
+    )
+
+    data class InboundBean(
+        var tag: String,
+        var port: Int,
+        var protocol: String,
+        var listen: String? = null,
+        val settings: Any? = null,
+        val sniffing: SniffingBean? = null,
+        val streamSettings: Any? = null,
+        val allocate: Any? = null
+    ) {
+
+        data class InSettingsBean(
+            val auth: String? = null,
+            val udp: Boolean? = null,
+            val userLevel: Int? = null,
+            val address: String? = null,
+            val port: Int? = null,
+            val network: String? = null
+        )
+
+        data class SniffingBean(
+            var enabled: Boolean,
+            val destOverride: ArrayList<String>,
+            val metadataOnly: Boolean? = null,
+            var routeOnly: Boolean? = null
+        )
+    }
+
+    data class OutboundBean(
+        var tag: String = "proxy",
+        var protocol: String,
+        var settings: OutSettingsBean? = null,
+        var streamSettings: StreamSettingsBean? = null,
+        val proxySettings: Any? = null,
+        val sendThrough: String? = null,
+        var mux: MuxBean? = MuxBean(false)
+    ) {
+        data class OutSettingsBean(
+            var vnext: List<VnextBean>? = null,
+            var fragment: FragmentBean? = null,
+            var noises: List<NoiseBean>? = null,
+            var servers: List<ServersBean>? = null,
+            /*Blackhole*/
+            var response: Response? = null,
+            /*DNS*/
+            val network: String? = null,
+            var address: Any? = null,
+            val port: Int? = null,
+            /*Freedom*/
+            var domainStrategy: String? = null,
+            val redirect: String? = null,
+            val userLevel: Int? = null,
+            /*Loopback*/
+            val inboundTag: String? = null,
+            /*Wireguard*/
+            var secretKey: String? = null,
+            val peers: List<WireGuardBean>? = null,
+            var reserved: List<Int>? = null,
+            var mtu: Int? = null,
+            var obfsPassword: String? = null,
+        ) {
+
+            data class VnextBean(
+                var address: String = "",
+                var port: Int = AppConfig.DEFAULT_PORT,
+                var users: List<UsersBean>
+            ) {
+
+                data class UsersBean(
+                    var id: String = "",
+                    var alterId: Int? = null,
+                    var security: String? = null,
+                    var level: Int = AppConfig.DEFAULT_LEVEL,
+                    var encryption: String? = null,
+                    var flow: String? = null
+                )
+            }
+
+            data class FragmentBean(
+                var packets: String? = null,
+                var length: String? = null,
+                var interval: String? = null
+            )
+
+            data class NoiseBean(
+                var type: String? = null,
+                var packet: String? = null,
+                var delay: String? = null
+            )
+
+            data class ServersBean(
+                var address: String = "",
+                var method: String? = null,
+                var ota: Boolean = false,
+                var password: String? = null,
+                var port: Int = AppConfig.DEFAULT_PORT,
+                var level: Int = AppConfig.DEFAULT_LEVEL,
+                val email: String? = null,
+                var flow: String? = null,
+                val ivCheck: Boolean? = null,
+                var users: List<SocksUsersBean>? = null
+            ) {
+                data class SocksUsersBean(
+                    var user: String = "",
+                    var pass: String = "",
+                    var level: Int = AppConfig.DEFAULT_LEVEL
+                )
+            }
+
+            data class Response(var type: String)
+
+            data class WireGuardBean(
+                var publicKey: String = "",
+                var preSharedKey: String? = null,
+                var endpoint: String = ""
+            )
+        }
+
+        data class StreamSettingsBean(
+            var network: String = AppConfig.DEFAULT_NETWORK,
+            var security: String? = null,
+            var tcpSettings: TcpSettingsBean? = null,
+            var kcpSettings: KcpSettingsBean? = null,
+            var wsSettings: WsSettingsBean? = null,
+            var httpupgradeSettings: HttpupgradeSettingsBean? = null,
+            var xhttpSettings: XhttpSettingsBean? = null,
+            var httpSettings: HttpSettingsBean? = null,
+            var tlsSettings: TlsSettingsBean? = null,
+            var quicSettings: QuicSettingBean? = null,
+            var realitySettings: TlsSettingsBean? = null,
+            var grpcSettings: GrpcSettingsBean? = null,
+            var hy2steriaSettings: Hy2steriaSettingsBean? = null,
+            val dsSettings: Any? = null,
+            var sockopt: SockoptBean? = null
+        ) {
+
+            data class TcpSettingsBean(
+                var header: HeaderBean = HeaderBean(),
+                val acceptProxyProtocol: Boolean? = null
+            ) {
+                data class HeaderBean(
+                    var type: String = "none",
+                    var request: RequestBean? = null,
+                    var response: Any? = null
+                ) {
+                    data class RequestBean(
+                        var path: List<String> = ArrayList(),
+                        var headers: HeadersBean = HeadersBean(),
+                        val version: String? = null,
+                        val method: String? = null
+                    ) {
+                        data class HeadersBean(
+                            var Host: List<String>? = ArrayList(),
+                            @SerializedName("User-Agent")
+                            val userAgent: List<String>? = null,
+                            @SerializedName("Accept-Encoding")
+                            val acceptEncoding: List<String>? = null,
+                            val Connection: List<String>? = null,
+                            val Pragma: String? = null
+                        )
+                    }
+                }
+            }
+
+            data class KcpSettingsBean(
+                var mtu: Int = 1350,
+                var tti: Int = 50,
+                var uplinkCapacity: Int = 12,
+                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
+                )
+            }
+
+            data class WsSettingsBean(
+                var path: String? = null,
+                var headers: HeadersBean = HeadersBean(),
+                val maxEarlyData: Int? = null,
+                val useBrowserForwarding: Boolean? = null,
+                val acceptProxyProtocol: Boolean? = null
+            ) {
+                data class HeadersBean(var Host: String = "")
+            }
+
+            data class HttpupgradeSettingsBean(
+                var path: String? = null,
+                var host: String? = null,
+                val acceptProxyProtocol: Boolean? = null
+            )
+
+            data class XhttpSettingsBean(
+                var path: String? = null,
+                var host: String? = null,
+                var mode: String? = null,
+                var extra: Any? = null,
+            )
+
+            data class HttpSettingsBean(
+                var host: List<String> = ArrayList(),
+                var path: String? = null
+            )
+
+            data class SockoptBean(
+                var TcpNoDelay: Boolean? = null,
+                var tcpKeepAliveIdle: Int? = null,
+                var tcpFastOpen: Boolean? = null,
+                var tproxy: String? = null,
+                var mark: Int? = null,
+                var dialerProxy: String? = null,
+                var domainStrategy: String? = null,
+                var happyEyeballs: happyEyeballsBean? = null,
+                )
+            data class happyEyeballsBean(
+                var prioritizeIPv6: Boolean? = null,
+                var maxConcurrentTry: Int? = 4,
+                var tryDelayMs: Int? = 250, // ms
+                var interleave: Int? = null,
+            )
+
+            data class TlsSettingsBean(
+                var allowInsecure: Boolean = false,
+                var serverName: String? = null,
+                val alpn: List<String>? = null,
+                val minVersion: String? = null,
+                val maxVersion: String? = null,
+                val preferServerCipherSuites: Boolean? = null,
+                val cipherSuites: String? = null,
+                val fingerprint: String? = null,
+                val certificates: List<Any>? = null,
+                val disableSystemRoot: Boolean? = null,
+                val enableSessionResumption: Boolean? = null,
+                // REALITY settings
+                val show: Boolean = false,
+                var publicKey: String? = null,
+                var shortId: String? = null,
+                var spiderX: String? = null,
+                var mldsa65Verify: String? = null
+            )
+
+            data class QuicSettingBean(
+                var security: String = "none",
+                var key: String = "",
+                var header: HeaderBean = HeaderBean()
+            ) {
+                data class HeaderBean(var type: String = "none")
+            }
+
+            data class GrpcSettingsBean(
+                var serviceName: String = "",
+                var authority: String? = null,
+                var multiMode: Boolean? = null,
+                var idle_timeout: Int? = null,
+                var health_check_timeout: Int? = null
+            )
+
+            data class Hy2steriaSettingsBean(
+                var password: String? = null,
+                var use_udp_extension: Boolean? = true,
+                var congestion: Hy2CongestionBean? = null
+            ) {
+                data class Hy2CongestionBean(
+                    var type: String? = "bbr",
+                    var up_mbps: Int? = null,
+                    var down_mbps: Int? = null,
+                )
+            }
+
+        }
+
+        data class MuxBean(
+            var enabled: Boolean,
+            var concurrency: Int? = null,
+            var xudpConcurrency: Int? = null,
+            var xudpProxyUDP443: String? = null,
+        )
+
+        fun getServerAddress(): String? {
+            if (protocol.equals(EConfigType.VMESS.name, true)
+                || protocol.equals(EConfigType.VLESS.name, true)
+            ) {
+                return settings?.vnext?.first()?.address
+            } else if (protocol.equals(EConfigType.SHADOWSOCKS.name, true)
+                || protocol.equals(EConfigType.SOCKS.name, true)
+                || protocol.equals(EConfigType.HTTP.name, true)
+                || protocol.equals(EConfigType.TROJAN.name, true)
+                || protocol.equals(EConfigType.HYSTERIA2.name, true)
+            ) {
+                return settings?.servers?.first()?.address
+            } else if (protocol.equals(EConfigType.WIREGUARD.name, true)) {
+                return settings?.peers?.first()?.endpoint?.substringBeforeLast(":")
+            }
+            return null
+        }
+
+        fun getServerPort(): Int? {
+            if (protocol.equals(EConfigType.VMESS.name, true)
+                || protocol.equals(EConfigType.VLESS.name, true)
+            ) {
+                return settings?.vnext?.first()?.port
+            } else if (protocol.equals(EConfigType.SHADOWSOCKS.name, true)
+                || protocol.equals(EConfigType.SOCKS.name, true)
+                || protocol.equals(EConfigType.HTTP.name, true)
+                || protocol.equals(EConfigType.TROJAN.name, true)
+                || protocol.equals(EConfigType.HYSTERIA2.name, true)
+            ) {
+                return settings?.servers?.first()?.port
+            } else if (protocol.equals(EConfigType.WIREGUARD.name, true)) {
+                return settings?.peers?.first()?.endpoint?.substringAfterLast(":")?.toInt()
+            }
+            return null
+        }
+
+        fun getServerAddressAndPort(): String {
+            val address = getServerAddress().orEmpty()
+            val port = getServerPort()
+            return Utils.getIpv6Address(address) + ":" + port
+        }
+
+        fun getPassword(): String? {
+            if (protocol.equals(EConfigType.VMESS.name, true)
+                || protocol.equals(EConfigType.VLESS.name, true)
+            ) {
+                return settings?.vnext?.first()?.users?.first()?.id
+            } else if (protocol.equals(EConfigType.SHADOWSOCKS.name, true)
+                || protocol.equals(EConfigType.TROJAN.name, true)
+                || protocol.equals(EConfigType.HYSTERIA2.name, true)
+            ) {
+                return settings?.servers?.first()?.password
+            } else if (protocol.equals(EConfigType.SOCKS.name, true)
+                || protocol.equals(EConfigType.HTTP.name, true)
+            ) {
+                return settings?.servers?.first()?.users?.first()?.pass
+            } else if (protocol.equals(EConfigType.WIREGUARD.name, true)) {
+                return settings?.secretKey
+            }
+            return null
+        }
+
+        fun getSecurityEncryption(): String? {
+            return when {
+                protocol.equals(EConfigType.VMESS.name, true) -> settings?.vnext?.first()?.users?.first()?.security
+                protocol.equals(EConfigType.VLESS.name, true) -> settings?.vnext?.first()?.users?.first()?.encryption
+                protocol.equals(EConfigType.SHADOWSOCKS.name, true) -> settings?.servers?.first()?.method
+                else -> null
+            }
+        }
+
+        fun getTransportSettingDetails(): List<String?>? {
+            if (protocol.equals(EConfigType.VMESS.name, true)
+                || protocol.equals(EConfigType.VLESS.name, true)
+                || protocol.equals(EConfigType.TROJAN.name, true)
+                || protocol.equals(EConfigType.SHADOWSOCKS.name, true)
+            ) {
+                val transport = streamSettings?.network ?: return null
+                return when (transport) {
+                    NetworkType.TCP.type -> {
+                        val tcpSetting = streamSettings?.tcpSettings ?: return null
+                        listOf(
+                            tcpSetting.header.type,
+                            tcpSetting.header.request?.headers?.Host?.joinToString(",").orEmpty(),
+                            tcpSetting.header.request?.path?.joinToString(",").orEmpty()
+                        )
+                    }
+
+                    NetworkType.KCP.type -> {
+                        val kcpSetting = streamSettings?.kcpSettings ?: return null
+                        listOf(
+                            kcpSetting.header.type,
+                            "",
+                            kcpSetting.seed.orEmpty()
+                        )
+                    }
+
+                    NetworkType.WS.type -> {
+                        val wsSetting = streamSettings?.wsSettings ?: return null
+                        listOf(
+                            "",
+                            wsSetting.headers.Host,
+                            wsSetting.path
+                        )
+                    }
+
+                    NetworkType.HTTP_UPGRADE.type -> {
+                        val httpupgradeSetting = streamSettings?.httpupgradeSettings ?: return null
+                        listOf(
+                            "",
+                            httpupgradeSetting.host,
+                            httpupgradeSetting.path
+                        )
+                    }
+
+                    NetworkType.XHTTP.type -> {
+                        val xhttpSettings = streamSettings?.xhttpSettings ?: return null
+                        listOf(
+                            "",
+                            xhttpSettings.host,
+                            xhttpSettings.path
+                        )
+                    }
+
+                    NetworkType.H2.type -> {
+                        val h2Setting = streamSettings?.httpSettings ?: return null
+                        listOf(
+                            "",
+                            h2Setting.host.joinToString(","),
+                            h2Setting.path
+                        )
+                    }
+
+//                    "quic" -> {
+//                        val quicSetting = streamSettings?.quicSettings ?: return null
+//                        listOf(
+//                            quicSetting.header.type,
+//                            quicSetting.security,
+//                            quicSetting.key
+//                        )
+//                    }
+
+                    NetworkType.GRPC.type -> {
+                        val grpcSetting = streamSettings?.grpcSettings ?: return null
+                        listOf(
+                            if (grpcSetting.multiMode == true) "multi" else "gun",
+                            grpcSetting.authority.orEmpty(),
+                            grpcSetting.serviceName
+                        )
+                    }
+
+                    else -> null
+                }
+            }
+            return null
+        }
+
+        fun ensureSockopt(): V2rayConfig.OutboundBean.StreamSettingsBean.SockoptBean {
+            val stream = streamSettings ?: V2rayConfig.OutboundBean.StreamSettingsBean().also {
+                streamSettings = it
+            }
+
+            val sockopt = stream.sockopt ?: V2rayConfig.OutboundBean.StreamSettingsBean.SockoptBean().also {
+                stream.sockopt = it
+            }
+
+            return sockopt
+        }
+    }
+
+    data class DnsBean(
+        var servers: ArrayList<Any>? = null,
+        var hosts: Map<String, Any>? = null,
+        val clientIp: String? = null,
+        val disableCache: Boolean? = null,
+        val queryStrategy: String? = null,
+        val tag: String? = null
+    ) {
+        data class ServersBean(
+            var address: String = "",
+            var port: Int? = null,
+            var domains: List<String>? = null,
+            var expectIPs: List<String>? = null,
+            val clientIp: String? = null,
+            val skipFallback: Boolean? = null,
+            val tag: String? = null,
+        )
+    }
+
+    data class RoutingBean(
+        var domainStrategy: String,
+        var domainMatcher: String? = null,
+        var rules: ArrayList<RulesBean>,
+        var balancers: List<BalancerBean>? = null
+    ) {
+
+        data class RulesBean(
+            var type: String = "field",
+            var ip: ArrayList<String>? = null,
+            var domain: ArrayList<String>? = null,
+            var outboundTag: String? = null,
+            var balancerTag: String? = null,
+            var port: String? = null,
+            val sourcePort: String? = null,
+            val network: String? = null,
+            val source: List<String>? = null,
+            val user: List<String>? = null,
+            var inboundTag: List<String>? = null,
+            val protocol: List<String>? = null,
+            val attrs: String? = null,
+            val domainMatcher: String? = null
+        )
+
+        data class BalancerBean(
+            val tag: String,
+            val selector: List<String>,
+            val fallbackTag: String? = null,
+            val strategy: StrategyObject? = null
+        )
+
+        data class StrategyObject(
+            val type: String = "random", // "random" | "roundRobin" | "leastPing" | "leastLoad"
+            val settings: StrategySettingsObject? = null
+        )
+
+        data class StrategySettingsObject(
+            val expected: Int? = null,
+            val maxRTT: String? = null,
+            val tolerance: Double? = null,
+            val baselines: List<String>? = null,
+            val costs: List<CostObject>? = null
+        )
+
+        data class CostObject(
+            val regexp: Boolean = false,
+            val match: String,
+            val value: Double
+        )
+    }
+
+    data class PolicyBean(
+        var levels: Map<String, LevelBean>,
+        var system: Any? = null
+    ) {
+        data class LevelBean(
+            var handshake: Int? = null,
+            var connIdle: Int? = null,
+            var uplinkOnly: Int? = null,
+            var downlinkOnly: Int? = null,
+            val statsUserUplink: Boolean? = null,
+            val statsUserDownlink: Boolean? = null,
+            var bufferSize: Int? = null
+        )
+    }
+
+    data class ObservatoryObject(
+        val subjectSelector: List<String>,
+        val probeUrl: String,
+        val probeInterval: String,
+        val enableConcurrency: Boolean = false
+    )
+
+    data class BurstObservatoryObject(
+        val subjectSelector: List<String>,
+        val pingConfig: PingConfigObject
+    ) {
+        data class PingConfigObject(
+            val destination: String,
+            val connectivity: String? = null,
+            val interval: String,
+            val sampling: Int,
+            val timeout: String? = null
+        )
+    }
+
+    data class FakednsBean(
+        var ipPool: String = "198.18.0.0/15",
+        var poolSize: Int = 10000
+    ) // roughly 10 times smaller than total ip pool
+
+    fun getProxyOutbound(): OutboundBean? {
+        outbounds.forEach { outbound ->
+            EConfigType.entries.forEach {
+                if (outbound.protocol.equals(it.name, true)) {
+                    return outbound
+                }
+            }
+        }
+        return null
+    }
+
+    fun getAllProxyOutbound(): List<OutboundBean> {
+        return outbounds.filter { outbound ->
+            EConfigType.entries.any { it.name.equals(outbound.protocol, ignoreCase = true) }
+        }
+    }
+}

+ 19 - 0
app/src/main/java/com/v2ray/ang/dto/VmessQRCode.kt

@@ -0,0 +1,19 @@
+package com.v2ray.ang.dto
+
+data class VmessQRCode(
+    var v: String = "",
+    var ps: String = "",
+    var add: String = "",
+    var port: String = "",
+    var id: String = "",
+    var aid: String = "0",
+    var scy: String = "",
+    var net: String = "",
+    var type: String = "",
+    var host: String = "",
+    var path: String = "",
+    var tls: String = "",
+    var sni: String = "",
+    var alpn: String = "",
+    var fp: String = ""
+)

+ 39 - 0
app/src/main/java/com/v2ray/ang/dto/VpnInterfaceAddressConfig.kt

@@ -0,0 +1,39 @@
+package com.v2ray.ang.dto
+
+/**
+ * VPN interface address configuration enum class
+ * Defines predefined IPv4 and IPv6 address pairs for VPN TUN interface configuration.
+ * Each option provides client and router addresses to establish point-to-point VPN tunnels.
+ */
+enum class VpnInterfaceAddressConfig(
+    val displayName: String,
+    val ipv4Client: String,
+    val ipv4Router: String,
+    val ipv6Client: String,
+    val ipv6Router: String
+) {
+    OPTION_1("10.10.14.x", "10.10.14.1", "10.10.14.2", "fc00::10:10:14:1", "fc00::10:10:14:2"),
+    OPTION_2("10.1.0.x", "10.1.0.1", "10.1.0.2", "fc00::10:1:0:1", "fc00::10:1:0:2"),
+    OPTION_3("10.0.0.x", "10.0.0.1", "10.0.0.2", "fc00::10:0:0:1", "fc00::10:0:0:2"),
+    OPTION_4("172.31.0.x", "172.31.0.1", "172.31.0.2", "fc00::172:31:0:1", "fc00::172:31:0:2"),
+    OPTION_5("172.20.0.x", "172.20.0.1", "172.20.0.2", "fc00::172:20:0:1", "fc00::172:20:0:2"),
+    OPTION_6("172.16.0.x", "172.16.0.1", "172.16.0.2", "fc00::172:16:0:1", "fc00::172:16:0:2"),
+    OPTION_7("192.168.100.x", "192.168.100.1", "192.168.100.2", "fc00::192:168:100:1", "fc00::192:168:100:2");
+
+    companion object {
+        /**
+         * Retrieves the VPN interface address configuration based on the specified index.
+         *
+         * @param index The configuration index (0-based) corresponding to user selection
+         * @return The VpnInterfaceAddressConfig instance at the specified index,
+         *         or OPTION_1 (default) if the index is out of bounds
+         */
+        fun getConfigByIndex(index: Int): VpnInterfaceAddressConfig {
+            return if (index in values().indices) {
+                values()[index]
+            } else {
+                OPTION_1 // Default to the first configuration
+            }
+        }
+    }
+}

+ 212 - 0
app/src/main/java/com/v2ray/ang/extension/_Ext.kt

@@ -0,0 +1,212 @@
+package com.v2ray.ang.extension
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.os.Build
+import android.os.Bundle
+import android.widget.Toast
+import com.v2ray.ang.AngApplication
+import es.dmoral.toasty.Toasty
+import org.json.JSONObject
+import java.io.Serializable
+import java.net.URI
+import java.net.URLConnection
+
+val Context.v2RayApplication: AngApplication?
+    get() = applicationContext as? AngApplication
+
+/**
+ * Shows a toast message with the given resource ID.
+ *
+ * @param message The resource ID of the message to show.
+ */
+fun Context.toast(message: Int) {
+    Toasty.normal(this, message).show()
+}
+
+/**
+ * Shows a toast message with the given text.
+ *
+ * @param message The text of the message to show.
+ */
+fun Context.toast(message: CharSequence) {
+    Toasty.normal(this, message).show()
+}
+
+/**
+ * Shows a toast message with the given resource ID.
+ *
+ * @param message The resource ID of the message to show.
+ */
+fun Context.toastSuccess(message: Int) {
+    Toasty.success(this, message, Toast.LENGTH_SHORT, true).show()
+}
+
+/**
+ * Shows a toast message with the given text.
+ *
+ * @param message The text of the message to show.
+ */
+fun Context.toastSuccess(message: CharSequence) {
+    Toasty.success(this, message, Toast.LENGTH_SHORT, true).show()
+}
+
+/**
+ * Shows a toast message with the given resource ID.
+ *
+ * @param message The resource ID of the message to show.
+ */
+fun Context.toastError(message: Int) {
+    Toasty.error(this, message, Toast.LENGTH_SHORT, true).show()
+}
+
+/**
+ * Shows a toast message with the given text.
+ *
+ * @param message The text of the message to show.
+ */
+fun Context.toastError(message: CharSequence) {
+    Toasty.error(this, message, Toast.LENGTH_SHORT, true).show()
+}
+
+
+/**
+ * Puts a key-value pair into the JSONObject.
+ *
+ * @param pair The key-value pair to put.
+ */
+fun JSONObject.putOpt(pair: Pair<String, Any?>) {
+    put(pair.first, pair.second)
+}
+
+/**
+ * Puts multiple key-value pairs into the JSONObject.
+ *
+ * @param pairs The map of key-value pairs to put.
+ */
+fun JSONObject.putOpt(pairs: Map<String, Any?>) {
+    pairs.forEach { put(it.key, it.value) }
+}
+
+const val THRESHOLD = 1000L
+const val DIVISOR = 1024.0
+
+/**
+ * Converts a Long value to a speed string.
+ *
+ * @return The speed string.
+ */
+fun Long.toSpeedString(): String = this.toTrafficString() + "/s"
+
+/**
+ * Converts a Long value to a traffic string.
+ *
+ * @return The traffic string.
+ */
+fun Long.toTrafficString(): String {
+    val units = arrayOf("B", "KB", "MB", "GB", "TB", "PB")
+    var size = this.toDouble()
+    var unitIndex = 0
+    while (size >= THRESHOLD && unitIndex < units.size - 1) {
+        size /= DIVISOR
+        unitIndex++
+    }
+    return String.format("%.1f %s", size, units[unitIndex])
+}
+
+val URLConnection.responseLength: Long
+    get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
+        contentLengthLong
+    } else {
+        contentLength.toLong()
+    }
+
+val URI.idnHost: String
+    get() = host?.replace("[", "")?.replace("]", "").orEmpty()
+
+/**
+ * Removes all whitespace from the string.
+ *
+ * @return The string without whitespace.
+ */
+fun String?.removeWhiteSpace(): String? = this?.replace(" ", "")
+
+/**
+ * Converts the string to a Long value, or returns 0 if the conversion fails.
+ *
+ * @return The Long value.
+ */
+fun String.toLongEx(): Long = toLongOrNull() ?: 0
+
+/**
+ * Listens for package changes and executes a callback when a change occurs.
+ *
+ * @param onetime Whether to unregister the receiver after the first callback.
+ * @param callback The callback to execute when a package change occurs.
+ * @return The BroadcastReceiver that was registered.
+ */
+fun Context.listenForPackageChanges(onetime: Boolean = true, callback: () -> Unit) =
+    object : BroadcastReceiver() {
+        override fun onReceive(context: Context, intent: Intent) {
+            callback()
+            if (onetime) context.unregisterReceiver(this)
+        }
+    }.apply {
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+            registerReceiver(this, IntentFilter().apply {
+                addAction(Intent.ACTION_PACKAGE_ADDED)
+                addAction(Intent.ACTION_PACKAGE_REMOVED)
+                addDataScheme("package")
+            }, Context.RECEIVER_EXPORTED)
+        } else {
+            registerReceiver(this, IntentFilter().apply {
+                addAction(Intent.ACTION_PACKAGE_ADDED)
+                addAction(Intent.ACTION_PACKAGE_REMOVED)
+                addDataScheme("package")
+            })
+        }
+    }
+
+/**
+ * Retrieves a serializable object from the Bundle.
+ *
+ * @param key The key of the serializable object.
+ * @return The serializable object, or null if not found.
+ */
+inline fun <reified T : Serializable> Bundle.serializable(key: String): T? = when {
+    Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU -> getSerializable(key, T::class.java)
+    else -> @Suppress("DEPRECATION") getSerializable(key) as? T
+}
+
+/**
+ * Retrieves a serializable object from the Intent.
+ *
+ * @param key The key of the serializable object.
+ * @return The serializable object, or null if not found.
+ */
+inline fun <reified T : Serializable> Intent.serializable(key: String): T? = when {
+    Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU -> getSerializableExtra(key, T::class.java)
+    else -> @Suppress("DEPRECATION") getSerializableExtra(key) as? T
+}
+
+/**
+ * Checks if the CharSequence is not null and not empty.
+ *
+ * @return True if the CharSequence is not null and not empty, false otherwise.
+ */
+fun CharSequence?.isNotNullEmpty(): Boolean = this != null && this.isNotEmpty()
+
+fun String.concatUrl(vararg paths: String): String {
+    val builder = StringBuilder(this.trimEnd('/'))
+
+    paths.forEach { path ->
+        val trimmedPath = path.trim('/')
+        if (trimmedPath.isNotEmpty()) {
+            builder.append('/').append(trimmedPath)
+        }
+    }
+
+    return builder.toString()
+}

+ 27 - 0
app/src/main/java/com/v2ray/ang/fmt/CustomFmt.kt

@@ -0,0 +1,27 @@
+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.util.JsonUtil
+
+object CustomFmt : FmtBase() {
+    /**
+     * Parses a JSON string into a ProfileItem object.
+     *
+     * @param str the JSON string to parse
+     * @return the parsed ProfileItem object, or null if parsing fails
+     */
+    fun parse(str: String): ProfileItem? {
+        val config = ProfileItem.create(EConfigType.CUSTOM)
+
+        val fullConfig = JsonUtil.fromJson(str, V2rayConfig::class.java)
+        val outbound = fullConfig.getProxyOutbound()
+
+        config.remarks = fullConfig?.remarks ?: System.currentTimeMillis().toString()
+        config.server = outbound?.getServerAddress()
+        config.serverPort = outbound?.getServerPort().toString()
+
+        return config
+    }
+}

+ 172 - 0
app/src/main/java/com/v2ray/ang/fmt/FmtBase.kt

@@ -0,0 +1,172 @@
+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.handler.MmkvManager
+import com.v2ray.ang.util.HttpUtil
+import com.v2ray.ang.util.Utils
+import java.net.URI
+
+open class FmtBase {
+    /**
+     * Converts a ProfileItem object to a URI string.
+     *
+     * @param config the ProfileItem object to convert
+     * @param userInfo the user information to include in the URI
+     * @param dicQuery the query parameters to include in the URI
+     * @return the converted URI string
+     */
+    fun toUri(config: ProfileItem, userInfo: String?, dicQuery: HashMap<String, String>?): String {
+        val query = if (dicQuery != null)
+            "?" + dicQuery.toList().joinToString(
+                separator = "&",
+                transform = { it.first + "=" + Utils.urlEncode(it.second) })
+        else ""
+
+        val url = String.format(
+            "%s@%s:%s",
+            Utils.urlEncode(userInfo ?: ""),
+            Utils.getIpv6Address(HttpUtil.toIdnDomain(config.server.orEmpty())),
+            config.serverPort
+        )
+
+        return "${url}${query}#${Utils.urlEncode(config.remarks)}"
+    }
+
+    /**
+     * Extracts query parameters from a URI.
+     *
+     * @param uri the URI to extract query parameters from
+     * @return a map of query parameters
+     */
+    fun getQueryParam(uri: URI): Map<String, String> {
+        return uri.rawQuery.split("&")
+            .associate { it.split("=").let { (k, v) -> k to Utils.urlDecode(v) } }
+    }
+
+    /**
+     * Populates a ProfileItem object with values from query parameters.
+     *
+     * @param config the ProfileItem object to populate
+     * @param queryParam the query parameters to use for populating the ProfileItem
+     * @param allowInsecure whether to allow insecure connections
+     */
+    fun getItemFormQuery(config: ProfileItem, queryParam: Map<String, String>, allowInsecure: Boolean) {
+        config.network = queryParam["type"] ?: NetworkType.TCP.type
+        config.headerType = queryParam["headerType"]
+        config.host = queryParam["host"]
+        config.path = queryParam["path"]
+
+        config.seed = queryParam["seed"]
+        config.quicSecurity = queryParam["quicSecurity"]
+        config.quicKey = queryParam["key"]
+        config.mode = queryParam["mode"]
+        config.serviceName = queryParam["serviceName"]
+        config.authority = queryParam["authority"]
+        config.xhttpMode = queryParam["mode"]
+        config.xhttpExtra = queryParam["extra"]
+
+        config.security = queryParam["security"]
+        if (config.security != AppConfig.TLS && config.security != AppConfig.REALITY) {
+            config.security = null
+        }
+        config.insecure = if (queryParam["allowInsecure"].isNullOrEmpty()) {
+            allowInsecure
+        } else {
+            queryParam["allowInsecure"].orEmpty() == "1"
+        }
+        config.sni = queryParam["sni"]
+        config.fingerPrint = queryParam["fp"]
+        config.alpn = queryParam["alpn"]
+        config.publicKey = queryParam["pbk"]
+        config.shortId = queryParam["sid"]
+        config.spiderX = queryParam["spx"]
+        config.mldsa65Verify = queryParam["pqv"]
+        config.flow = queryParam["flow"]
+    }
+
+    /**
+     * Creates a map of query parameters from a ProfileItem object.
+     *
+     * @param config the ProfileItem object to create query parameters from
+     * @return a map of query parameters
+     */
+    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.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() }
+
+        val networkType = NetworkType.fromString(config.network)
+        dicQuery["type"] = networkType.type
+
+        when (networkType) {
+            NetworkType.TCP -> {
+                dicQuery["headerType"] = config.headerType?.ifEmpty { "none" }.orEmpty()
+                config.host.let { if (it.isNotNullEmpty()) dicQuery["host"] = it.orEmpty() }
+            }
+
+            NetworkType.KCP -> {
+                dicQuery["headerType"] = config.headerType?.ifEmpty { "none" }.orEmpty()
+                config.seed.let { if (it.isNotNullEmpty()) dicQuery["seed"] = it.orEmpty() }
+            }
+
+            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() }
+            }
+
+            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() }
+            }
+
+            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() }
+            }
+
+//            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() }
+//            }
+
+            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() }
+            }
+        }
+
+        return dicQuery
+    }
+
+    fun getServerAddress(profileItem: ProfileItem): String {
+        if (Utils.isPureIpAddress(profileItem.server.orEmpty())) {
+            return profileItem.server.orEmpty()
+        }
+
+        val domain = HttpUtil.toIdnDomain(profileItem.server.orEmpty())
+        if (MmkvManager.decodeSettingsString(AppConfig.PREF_OUTBOUND_DOMAIN_RESOLVE_METHOD, "1") != "2") {
+            return domain
+        }
+        //Resolve and replace domain
+        val resolvedIps = HttpUtil.resolveHostToIP(domain, MmkvManager.decodeSettingsBool(AppConfig.PREF_PREFER_IPV6))
+        if (resolvedIps.isNullOrEmpty()) {
+            return domain
+        }
+        return resolvedIps.first()
+    }
+}

+ 32 - 0
app/src/main/java/com/v2ray/ang/fmt/HttpFmt.kt

@@ -0,0 +1,32 @@
+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.extension.isNotNullEmpty
+import com.v2ray.ang.handler.V2rayConfigManager
+
+object HttpFmt : FmtBase() {
+    /**
+     * Converts a ProfileItem object to an OutboundBean object.
+     *
+     * @param profileItem the ProfileItem object to convert
+     * @return the converted OutboundBean object, or null if conversion fails
+     */
+    fun toOutbound(profileItem: ProfileItem): OutboundBean? {
+        val outboundBean = V2rayConfigManager.createInitOutbound(EConfigType.HTTP)
+
+        outboundBean?.settings?.servers?.first()?.let { server ->
+            server.address = getServerAddress(profileItem)
+            server.port = profileItem.serverPort.orEmpty().toInt()
+            if (profileItem.username.isNotNullEmpty()) {
+                val socksUsersBean = OutboundBean.OutSettingsBean.ServersBean.SocksUsersBean()
+                socksUsersBean.user = profileItem.username.orEmpty()
+                socksUsersBean.pass = profileItem.password.orEmpty()
+                server.users = listOf(socksUsersBean)
+            }
+        }
+
+        return outboundBean
+    }
+}

+ 151 - 0
app/src/main/java/com/v2ray/ang/fmt/Hysteria2Fmt.kt

@@ -0,0 +1,151 @@
+package com.v2ray.ang.fmt
+
+import com.v2ray.ang.AppConfig
+import com.v2ray.ang.AppConfig.LOOPBACK
+import com.v2ray.ang.dto.EConfigType
+import com.v2ray.ang.dto.Hysteria2Bean
+import com.v2ray.ang.dto.ProfileItem
+import com.v2ray.ang.dto.V2rayConfig.OutboundBean
+import com.v2ray.ang.extension.idnHost
+import com.v2ray.ang.extension.isNotNullEmpty
+import com.v2ray.ang.handler.MmkvManager
+import com.v2ray.ang.handler.V2rayConfigManager
+import com.v2ray.ang.util.Utils
+import java.net.URI
+
+object Hysteria2Fmt : FmtBase() {
+    /**
+     * Parses a Hysteria2 URI string into a ProfileItem object.
+     *
+     * @param str the Hysteria2 URI string to parse
+     * @return the parsed ProfileItem object, or null if parsing fails
+     */
+    fun parse(str: String): ProfileItem? {
+        var allowInsecure = MmkvManager.decodeSettingsBool(AppConfig.PREF_ALLOW_INSECURE, false)
+        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.server = uri.idnHost
+        config.serverPort = uri.port.toString()
+        config.password = uri.userInfo
+        config.security = AppConfig.TLS
+
+        if (!uri.rawQuery.isNullOrEmpty()) {
+            val queryParam = getQueryParam(uri)
+
+            config.security = queryParam["security"] ?: AppConfig.TLS
+            config.insecure = if (queryParam["insecure"].isNullOrEmpty()) {
+                allowInsecure
+            } else {
+                queryParam["insecure"].orEmpty() == "1"
+            }
+            config.sni = queryParam["sni"]
+            config.alpn = queryParam["alpn"]
+
+            config.obfsPassword = queryParam["obfs-password"]
+            config.portHopping = queryParam["mport"]
+            config.pinSHA256 = queryParam["pinSHA256"]
+
+        }
+
+        return config
+    }
+
+    /**
+     * Converts a ProfileItem object to a URI string.
+     *
+     * @param config the ProfileItem object to convert
+     * @return the converted URI string
+     */
+    fun toUri(config: ProfileItem): String {
+        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.insecure.let { dicQuery["insecure"] = if (it == true) "1" else "0" }
+
+        if (config.obfsPassword.isNotNullEmpty()) {
+            dicQuery["obfs"] = "salamander"
+            dicQuery["obfs-password"] = config.obfsPassword.orEmpty()
+        }
+        if (config.portHopping.isNotNullEmpty()) {
+            dicQuery["mport"] = config.portHopping.orEmpty()
+        }
+        if (config.pinSHA256.isNotNullEmpty()) {
+            dicQuery["pinSHA256"] = config.pinSHA256.orEmpty()
+        }
+
+        return toUri(config, config.password, dicQuery)
+    }
+
+    /**
+     * Converts a ProfileItem object to a Hysteria2Bean object.
+     *
+     * @param config the ProfileItem object to convert
+     * @param socksPort the port number for the socks5 proxy
+     * @return the converted Hysteria2Bean object, or null if conversion fails
+     */
+    fun toNativeConfig(config: ProfileItem, socksPort: Int): Hysteria2Bean? {
+
+        val obfs = if (config.obfsPassword.isNullOrEmpty()) null else
+            Hysteria2Bean.ObfsBean(
+                type = "salamander",
+                salamander = Hysteria2Bean.ObfsBean.SalamanderBean(
+                    password = config.obfsPassword
+                )
+            )
+
+        val transport = if (config.portHopping.isNullOrEmpty()) null else
+            Hysteria2Bean.TransportBean(
+                type = "udp",
+                udp = Hysteria2Bean.TransportBean.TransportUdpBean(
+                    hopInterval = (config.portHoppingInterval ?: "30") + "s"
+                )
+            )
+
+        val bandwidth = if (config.bandwidthDown.isNullOrEmpty() || config.bandwidthUp.isNullOrEmpty()) null else
+            Hysteria2Bean.BandwidthBean(
+                down = config.bandwidthDown,
+                up = config.bandwidthUp,
+            )
+
+        val server =
+            if (config.portHopping.isNullOrEmpty())
+                config.getServerAddressAndPort()
+            else
+                Utils.getIpv6Address(config.server) + ":" + config.portHopping
+
+        val bean = Hysteria2Bean(
+            server = server,
+            auth = config.password,
+            obfs = obfs,
+            transport = transport,
+            bandwidth = bandwidth,
+            socks5 = Hysteria2Bean.Socks5Bean(
+                listen = "$LOOPBACK:${socksPort}",
+            ),
+            http = Hysteria2Bean.Socks5Bean(
+                listen = "$LOOPBACK:${socksPort}",
+            ),
+            tls = Hysteria2Bean.TlsBean(
+                sni = config.sni ?: config.server,
+                insecure = config.insecure,
+                pinSHA256 = if (config.pinSHA256.isNullOrEmpty()) null else config.pinSHA256
+            )
+        )
+        return bean
+    }
+
+    /**
+     * Converts a ProfileItem object to an OutboundBean object.
+     *
+     * @param profileItem the ProfileItem object to convert
+     * @return the converted OutboundBean object, or null if conversion fails
+     */
+    fun toOutbound(profileItem: ProfileItem): OutboundBean? {
+        val outboundBean = V2rayConfigManager.createInitOutbound(EConfigType.HYSTERIA2)
+        return outboundBean
+    }
+}

+ 154 - 0
app/src/main/java/com/v2ray/ang/fmt/ShadowsocksFmt.kt

@@ -0,0 +1,154 @@
+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.extension.idnHost
+import com.v2ray.ang.handler.V2rayConfigManager
+import com.v2ray.ang.util.Utils
+import java.net.URI
+
+object ShadowsocksFmt : FmtBase() {
+    /**
+     * Parses a Shadowsocks URI string into a ProfileItem object.
+     *
+     * @param str the Shadowsocks URI string to parse
+     * @return the parsed ProfileItem object, or null if parsing fails
+     */
+    fun parse(str: String): ProfileItem? {
+        return parseSip002(str) ?: parseLegacy(str)
+    }
+
+    /**
+     * Parses a SIP002 Shadowsocks URI string into a ProfileItem object.
+     *
+     * @param str the SIP002 Shadowsocks URI string to parse
+     * @return the parsed ProfileItem object, or null if parsing fails
+     */
+    fun parseSip002(str: String): ProfileItem? {
+        val config = ProfileItem.create(EConfigType.SHADOWSOCKS)
+
+        val uri = URI(Utils.fixIllegalUrl(str))
+        if (uri.idnHost.isEmpty()) return null
+        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.server = uri.idnHost
+        config.serverPort = uri.port.toString()
+
+        val result = if (uri.userInfo.contains(":")) {
+            uri.userInfo.split(":", limit = 2)
+        } else {
+            Utils.decode(uri.userInfo).split(":", limit = 2)
+        }
+        if (result.count() == 2) {
+            config.method = result.first()
+            config.password = result.last()
+        }
+
+        if (!uri.rawQuery.isNullOrEmpty()) {
+            val queryParam = getQueryParam(uri)
+            if (queryParam["plugin"]?.contains("obfs=http") == true) {
+                val queryPairs = HashMap<String, String>()
+                for (pair in queryParam["plugin"]?.split(";") ?: listOf()) {
+                    val idx = pair.split("=")
+                    if (idx.count() == 2) {
+                        queryPairs.put(idx.first(), idx.last())
+                    }
+                }
+                config.network = NetworkType.TCP.type
+                config.headerType = "http"
+                config.host = queryPairs["obfs-host"]
+                config.path = queryPairs["path"]
+            }
+        }
+
+        return config
+    }
+
+    /**
+     * Parses a legacy Shadowsocks URI string into a ProfileItem object.
+     *
+     * @param str the legacy Shadowsocks URI string to parse
+     * @return the parsed ProfileItem object, or null if parsing fails
+     */
+    fun parseLegacy(str: String): ProfileItem? {
+        val config = ProfileItem.create(EConfigType.SHADOWSOCKS)
+        var result = str.replace(EConfigType.SHADOWSOCKS.protocolScheme, "")
+        val indexSplit = result.indexOf("#")
+        if (indexSplit > 0) {
+            try {
+                config.remarks =
+                    Utils.urlDecode(result.substring(indexSplit + 1, result.length))
+            } catch (e: Exception) {
+                Log.e(AppConfig.TAG, "Failed to decode remarks in SS legacy URL", e)
+            }
+
+            result = result.substring(0, indexSplit)
+        }
+
+        //part decode
+        val indexS = result.indexOf("@")
+        result = if (indexS > 0) {
+            Utils.decode(result.substring(0, indexS)) + result.substring(
+                indexS,
+                result.length
+            )
+        } else {
+            Utils.decode(result)
+        }
+
+        val legacyPattern = "^(.+?):(.*)@(.+?):(\\d+?)/?$".toRegex()
+        val match = legacyPattern.matchEntire(result) ?: return null
+
+        config.server = match.groupValues[3].removeSurrounding("[", "]")
+        config.serverPort = match.groupValues[4]
+        config.password = match.groupValues[2]
+        config.method = match.groupValues[1].lowercase()
+
+        return config
+    }
+
+    /**
+     * Converts a ProfileItem object to a URI string.
+     *
+     * @param config the ProfileItem object to convert
+     * @return the converted URI string
+     */
+    fun toUri(config: ProfileItem): String {
+        val pw = "${config.method}:${config.password}"
+
+        return toUri(config, Utils.encode(pw), null)
+    }
+
+    /**
+     * Converts a ProfileItem object to an OutboundBean object.
+     *
+     * @param profileItem the ProfileItem object to convert
+     * @return the converted OutboundBean object, or null if conversion fails
+     */
+    fun toOutbound(profileItem: ProfileItem): OutboundBean? {
+        val outboundBean = V2rayConfigManager.createInitOutbound(EConfigType.SHADOWSOCKS)
+
+        outboundBean?.settings?.servers?.first()?.let { server ->
+            server.address = getServerAddress(profileItem)
+            server.port = profileItem.serverPort.orEmpty().toInt()
+            server.password = profileItem.password
+            server.method = profileItem.method
+        }
+
+        val sni = outboundBean?.streamSettings?.let {
+            V2rayConfigManager.populateTransportSettings(it, profileItem)
+        }
+
+        outboundBean?.streamSettings?.let {
+            V2rayConfigManager.populateTlsSettings(it, profileItem, sni)
+        }
+
+        return outboundBean
+    }
+}

+ 79 - 0
app/src/main/java/com/v2ray/ang/fmt/SocksFmt.kt

@@ -0,0 +1,79 @@
+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.extension.idnHost
+import com.v2ray.ang.extension.isNotNullEmpty
+import com.v2ray.ang.handler.V2rayConfigManager
+import com.v2ray.ang.util.Utils
+import java.net.URI
+
+object SocksFmt : FmtBase() {
+    /**
+     * Parses a Socks URI string into a ProfileItem object.
+     *
+     * @param str the Socks URI string to parse
+     * @return the parsed ProfileItem object, or null if parsing fails
+     */
+    fun parse(str: String): ProfileItem? {
+        val config = ProfileItem.create(EConfigType.SOCKS)
+
+        val uri = URI(Utils.fixIllegalUrl(str))
+        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.server = uri.idnHost
+        config.serverPort = uri.port.toString()
+
+        if (uri.userInfo?.isEmpty() == false) {
+            val result = Utils.decode(uri.userInfo).split(":", limit = 2)
+            if (result.count() == 2) {
+                config.username = result.first()
+                config.password = result.last()
+            }
+        }
+
+        return config
+    }
+
+    /**
+     * Converts a ProfileItem object to a URI string.
+     *
+     * @param config the ProfileItem object to convert
+     * @return the converted URI string
+     */
+    fun toUri(config: ProfileItem): String {
+        val pw =
+            if (config.username.isNotNullEmpty())
+                "${config.username}:${config.password}"
+            else
+                ":"
+
+        return toUri(config, Utils.encode(pw), null)
+    }
+
+    /**
+     * Converts a ProfileItem object to an OutboundBean object.
+     *
+     * @param profileItem the ProfileItem object to convert
+     * @return the converted OutboundBean object, or null if conversion fails
+     */
+    fun toOutbound(profileItem: ProfileItem): OutboundBean? {
+        val outboundBean = V2rayConfigManager.createInitOutbound(EConfigType.SOCKS)
+
+        outboundBean?.settings?.servers?.first()?.let { server ->
+            server.address = getServerAddress(profileItem)
+            server.port = profileItem.serverPort.orEmpty().toInt()
+            if (profileItem.username.isNotNullEmpty()) {
+                val socksUsersBean = OutboundBean.OutSettingsBean.ServersBean.SocksUsersBean()
+                socksUsersBean.user = profileItem.username.orEmpty()
+                socksUsersBean.pass = profileItem.password.orEmpty()
+                server.users = listOf(socksUsersBean)
+            }
+        }
+
+        return outboundBean
+    }
+}

+ 83 - 0
app/src/main/java/com/v2ray/ang/fmt/TrojanFmt.kt

@@ -0,0 +1,83 @@
+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.extension.idnHost
+import com.v2ray.ang.handler.MmkvManager
+import com.v2ray.ang.handler.V2rayConfigManager
+import com.v2ray.ang.util.Utils
+import java.net.URI
+
+object TrojanFmt : FmtBase() {
+    /**
+     * Parses a Trojan URI string into a ProfileItem object.
+     *
+     * @param str the Trojan URI string to parse
+     * @return the parsed ProfileItem object, or null if parsing fails
+     */
+    fun parse(str: String): ProfileItem? {
+        var allowInsecure = MmkvManager.decodeSettingsBool(AppConfig.PREF_ALLOW_INSECURE, false)
+        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.server = uri.idnHost
+        config.serverPort = uri.port.toString()
+        config.password = uri.userInfo
+
+        if (uri.rawQuery.isNullOrEmpty()) {
+            config.network = NetworkType.TCP.type
+            config.security = AppConfig.TLS
+            config.insecure = allowInsecure
+        } else {
+            val queryParam = getQueryParam(uri)
+
+            getItemFormQuery(config, queryParam, allowInsecure)
+            config.security = queryParam["security"] ?: AppConfig.TLS
+        }
+
+        return config
+    }
+
+    /**
+     * Converts a ProfileItem object to a URI string.
+     *
+     * @param config the ProfileItem object to convert
+     * @return the converted URI string
+     */
+    fun toUri(config: ProfileItem): String {
+        val dicQuery = getQueryDic(config)
+
+        return toUri(config, config.password, dicQuery)
+    }
+
+    /**
+     * Converts a ProfileItem object to an OutboundBean object.
+     *
+     * @param profileItem the ProfileItem object to convert
+     * @return the converted OutboundBean object, or null if conversion fails
+     */
+    fun toOutbound(profileItem: ProfileItem): OutboundBean? {
+        val outboundBean = V2rayConfigManager.createInitOutbound(EConfigType.TROJAN)
+
+        outboundBean?.settings?.servers?.first()?.let { server ->
+            server.address = getServerAddress(profileItem)
+            server.port = profileItem.serverPort.orEmpty().toInt()
+            server.password = profileItem.password
+            server.flow = profileItem.flow
+        }
+
+        val sni = outboundBean?.streamSettings?.let {
+            V2rayConfigManager.populateTransportSettings(it, profileItem)
+        }
+
+        outboundBean?.streamSettings?.let {
+            V2rayConfigManager.populateTlsSettings(it, profileItem, sni)
+        }
+
+        return outboundBean
+    }
+}

+ 80 - 0
app/src/main/java/com/v2ray/ang/fmt/VlessFmt.kt

@@ -0,0 +1,80 @@
+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.extension.idnHost
+import com.v2ray.ang.handler.MmkvManager
+import com.v2ray.ang.handler.V2rayConfigManager
+import com.v2ray.ang.util.Utils
+import java.net.URI
+
+object VlessFmt : FmtBase() {
+
+    /**
+     * Parses a Vless URI string into a ProfileItem object.
+     *
+     * @param str the Vless URI string to parse
+     * @return the parsed ProfileItem object, or null if parsing fails
+     */
+    fun parse(str: String): ProfileItem? {
+        var allowInsecure = MmkvManager.decodeSettingsBool(AppConfig.PREF_ALLOW_INSECURE, false)
+        val config = ProfileItem.create(EConfigType.VLESS)
+
+        val uri = URI(Utils.fixIllegalUrl(str))
+        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.server = uri.idnHost
+        config.serverPort = uri.port.toString()
+        config.password = uri.userInfo
+        config.method = queryParam["encryption"] ?: "none"
+
+        getItemFormQuery(config, queryParam, allowInsecure)
+
+        return config
+    }
+
+    /**
+     * Converts a ProfileItem object to a URI string.
+     *
+     * @param config the ProfileItem object to convert
+     * @return the converted URI string
+     */
+    fun toUri(config: ProfileItem): String {
+        val dicQuery = getQueryDic(config)
+        dicQuery["encryption"] = config.method ?: "none"
+
+        return toUri(config, config.password, dicQuery)
+    }
+
+    /**
+     * Converts a ProfileItem object to an OutboundBean object.
+     *
+     * @param profileItem the ProfileItem object to convert
+     * @return the converted OutboundBean object, or null if conversion fails
+     */
+    fun toOutbound(profileItem: ProfileItem): OutboundBean? {
+        val outboundBean = V2rayConfigManager.createInitOutbound(EConfigType.VLESS)
+
+        outboundBean?.settings?.vnext?.first()?.let { vnext ->
+            vnext.address = getServerAddress(profileItem)
+            vnext.port = profileItem.serverPort.orEmpty().toInt()
+            vnext.users[0].id = profileItem.password.orEmpty()
+            vnext.users[0].encryption = profileItem.method
+            vnext.users[0].flow = profileItem.flow
+        }
+
+        val sni = outboundBean?.streamSettings?.let {
+            V2rayConfigManager.populateTransportSettings(it, profileItem)
+        }
+
+        outboundBean?.streamSettings?.let {
+            V2rayConfigManager.populateTlsSettings(it, profileItem, sni)
+        }
+
+        return outboundBean
+    }
+}

+ 192 - 0
app/src/main/java/com/v2ray/ang/fmt/VmessFmt.kt

@@ -0,0 +1,192 @@
+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.extension.idnHost
+import com.v2ray.ang.extension.isNotNullEmpty
+import com.v2ray.ang.handler.MmkvManager
+import com.v2ray.ang.handler.V2rayConfigManager
+import com.v2ray.ang.util.JsonUtil
+import com.v2ray.ang.util.Utils
+import java.net.URI
+
+object VmessFmt : FmtBase() {
+    /**
+     * Parses a Vmess string into a ProfileItem object.
+     *
+     * @param str the Vmess string to parse
+     * @return the parsed ProfileItem object, or null if parsing fails
+     */
+    fun parse(str: String): ProfileItem? {
+        if (str.indexOf('?') > 0 && str.indexOf('&') > 0) {
+            return parseVmessStd(str)
+        }
+
+        var allowInsecure = MmkvManager.decodeSettingsBool(AppConfig.PREF_ALLOW_INSECURE, false)
+        val config = ProfileItem.create(EConfigType.VMESS)
+
+        var result = str.replace(EConfigType.VMESS.protocolScheme, "")
+        result = Utils.decode(result)
+        if (TextUtils.isEmpty(result)) {
+            Log.w(AppConfig.TAG, "Toast decoding failed")
+            return null
+        }
+        val vmessQRCode = JsonUtil.fromJson(result, VmessQRCode::class.java)
+        // Although VmessQRCode fields are non null, looks like Gson may still create null fields
+        if (TextUtils.isEmpty(vmessQRCode.add)
+            || TextUtils.isEmpty(vmessQRCode.port)
+            || TextUtils.isEmpty(vmessQRCode.id)
+            || TextUtils.isEmpty(vmessQRCode.net)
+        ) {
+            Log.w(AppConfig.TAG, "Toast incorrect protocol")
+            return null
+        }
+
+        config.remarks = vmessQRCode.ps
+        config.server = vmessQRCode.add
+        config.serverPort = vmessQRCode.port
+        config.password = vmessQRCode.id
+        config.method = if (TextUtils.isEmpty(vmessQRCode.scy)) AppConfig.DEFAULT_SECURITY else vmessQRCode.scy
+
+        config.network = vmessQRCode.net ?: NetworkType.TCP.type
+        config.headerType = vmessQRCode.type
+        config.host = vmessQRCode.host
+        config.path = vmessQRCode.path
+
+        when (NetworkType.fromString(config.network)) {
+            NetworkType.KCP -> {
+                config.seed = vmessQRCode.path
+            }
+
+//            NetworkType.QUIC -> {
+//                config.quicSecurity = vmessQRCode.host
+//                config.quicKey = vmessQRCode.path
+//            }
+
+            NetworkType.GRPC -> {
+                config.mode = vmessQRCode.type
+                config.serviceName = vmessQRCode.path
+                config.authority = vmessQRCode.host
+            }
+
+            else -> {}
+        }
+
+        config.security = vmessQRCode.tls
+        config.insecure = allowInsecure
+        config.sni = vmessQRCode.sni
+        config.fingerPrint = vmessQRCode.fp
+        config.alpn = vmessQRCode.alpn
+
+        return config
+    }
+
+    /**
+     * Converts a ProfileItem object to a URI string.
+     *
+     * @param config the ProfileItem object to convert
+     * @return the converted URI string
+     */
+    fun toUri(config: ProfileItem): String {
+        val vmessQRCode = VmessQRCode()
+
+        vmessQRCode.v = "2"
+        vmessQRCode.ps = config.remarks
+        vmessQRCode.add = config.server.orEmpty()
+        vmessQRCode.port = config.serverPort.orEmpty()
+        vmessQRCode.id = config.password.orEmpty()
+        vmessQRCode.scy = config.method.orEmpty()
+        vmessQRCode.aid = "0"
+
+        vmessQRCode.net = config.network.orEmpty()
+        vmessQRCode.type = config.headerType.orEmpty()
+        when (NetworkType.fromString(config.network)) {
+            NetworkType.KCP -> {
+                vmessQRCode.path = config.seed.orEmpty()
+            }
+
+//            NetworkType.QUIC -> {
+//                vmessQRCode.host = config.quicSecurity.orEmpty()
+//                vmessQRCode.path = config.quicKey.orEmpty()
+//            }
+
+            NetworkType.GRPC -> {
+                vmessQRCode.type = config.mode.orEmpty()
+                vmessQRCode.path = config.serviceName.orEmpty()
+                vmessQRCode.host = config.authority.orEmpty()
+            }
+
+            else -> {}
+        }
+
+        config.host.let { if (it.isNotNullEmpty()) vmessQRCode.host = it.orEmpty() }
+        config.path.let { if (it.isNotNullEmpty()) vmessQRCode.path = it.orEmpty() }
+
+        vmessQRCode.tls = config.security.orEmpty()
+        vmessQRCode.sni = config.sni.orEmpty()
+        vmessQRCode.fp = config.fingerPrint.orEmpty()
+        vmessQRCode.alpn = config.alpn.orEmpty()
+
+        val json = JsonUtil.toJson(vmessQRCode)
+        return Utils.encode(json)
+    }
+
+    /**
+     * Parses a standard Vmess URI string into a ProfileItem object.
+     *
+     * @param str the standard Vmess URI string to parse
+     * @return the parsed ProfileItem object, or null if parsing fails
+     */
+    fun parseVmessStd(str: String): ProfileItem? {
+        val allowInsecure = MmkvManager.decodeSettingsBool(AppConfig.PREF_ALLOW_INSECURE, false)
+        val config = ProfileItem.create(EConfigType.VMESS)
+
+        val uri = URI(Utils.fixIllegalUrl(str))
+        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.server = uri.idnHost
+        config.serverPort = uri.port.toString()
+        config.password = uri.userInfo
+        config.method = AppConfig.DEFAULT_SECURITY
+
+        getItemFormQuery(config, queryParam, allowInsecure)
+
+        return config
+    }
+
+    /**
+     * Converts a ProfileItem object to an OutboundBean object.
+     *
+     * @param profileItem the ProfileItem object to convert
+     * @return the converted OutboundBean object, or null if conversion fails
+     */
+    fun toOutbound(profileItem: ProfileItem): OutboundBean? {
+        val outboundBean = V2rayConfigManager.createInitOutbound(EConfigType.VMESS)
+
+        outboundBean?.settings?.vnext?.first()?.let { vnext ->
+            vnext.address = getServerAddress(profileItem)
+            vnext.port = profileItem.serverPort.orEmpty().toInt()
+            vnext.users[0].id = profileItem.password.orEmpty()
+            vnext.users[0].security = profileItem.method
+        }
+
+        val sni = outboundBean?.streamSettings?.let {
+            V2rayConfigManager.populateTransportSettings(it, profileItem)
+        }
+
+        outboundBean?.streamSettings?.let {
+            V2rayConfigManager.populateTlsSettings(it, profileItem, sni)
+        }
+
+        return outboundBean
+    }
+
+}

+ 149 - 0
app/src/main/java/com/v2ray/ang/fmt/WireguardFmt.kt

@@ -0,0 +1,149 @@
+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.extension.idnHost
+import com.v2ray.ang.extension.removeWhiteSpace
+import com.v2ray.ang.handler.V2rayConfigManager
+import com.v2ray.ang.util.Utils
+import java.net.URI
+
+object WireguardFmt : FmtBase() {
+    /**
+     * Parses a URI string into a ProfileItem object.
+     *
+     * @param str the URI string to parse
+     * @return the parsed ProfileItem object, or null if parsing fails
+     */
+    fun parse(str: String): ProfileItem? {
+        val config = ProfileItem.create(EConfigType.WIREGUARD)
+
+        val uri = URI(Utils.fixIllegalUrl(str))
+        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.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.mtu = Utils.parseInt(queryParam["mtu"] ?: AppConfig.WIREGUARD_LOCAL_MTU)
+        config.reserved = queryParam["reserved"] ?: "0,0,0"
+
+        return config
+    }
+
+    /**
+     * Parses a Wireguard configuration file string into a ProfileItem object.
+     *
+     * @param str the Wireguard configuration file string to parse
+     * @return the parsed ProfileItem object, or null if parsing fails
+     */
+    fun parseWireguardConfFile(str: String): ProfileItem? {
+        val config = ProfileItem.create(EConfigType.WIREGUARD)
+
+        val interfaceParams: MutableMap<String, String> = mutableMapOf()
+        val peerParams: MutableMap<String, String> = mutableMapOf()
+
+        var currentSection: String? = null
+
+        str.lines().forEach { line ->
+            val trimmedLine = line.trim()
+
+            if (trimmedLine.isEmpty() || trimmedLine.startsWith("#")) {
+                return@forEach
+            }
+
+            when {
+                trimmedLine.startsWith("[Interface]", ignoreCase = true) -> currentSection = "Interface"
+                trimmedLine.startsWith("[Peer]", ignoreCase = true) -> currentSection = "Peer"
+                else -> {
+                    if (currentSection != null) {
+                        val parts = trimmedLine.split("=", limit = 2).map { it.trim() }
+                        if (parts.size == 2) {
+                            val key = parts[0].lowercase()
+                            val value = parts[1]
+                            when (currentSection) {
+                                "Interface" -> interfaceParams[key] = value
+                                "Peer" -> peerParams[key] = value
+                            }
+                        }
+                    }
+                }
+            }
+        }
+
+        config.secretKey = interfaceParams["privatekey"].orEmpty()
+        config.remarks = System.currentTimeMillis().toString()
+        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() }
+        val endpoint = peerParams["endpoint"].orEmpty()
+        val endpointParts = endpoint.split(":", limit = 2)
+        if (endpointParts.size == 2) {
+            config.server = endpointParts[0]
+            config.serverPort = endpointParts[1]
+        } else {
+            config.server = endpoint
+            config.serverPort = ""
+        }
+        config.reserved = peerParams["reserved"] ?: "0,0,0"
+
+        return config
+    }
+
+    /**
+     * Converts a ProfileItem object to an OutboundBean object.
+     *
+     * @param profileItem the ProfileItem object to convert
+     * @return the converted OutboundBean object, or null if conversion fails
+     */
+    fun toOutbound(profileItem: ProfileItem): OutboundBean? {
+        val outboundBean = V2rayConfigManager.createInitOutbound(EConfigType.WIREGUARD)
+
+        outboundBean?.settings?.let { wireguard ->
+            wireguard.secretKey = profileItem.secretKey
+            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.endpoint = Utils.getIpv6Address(profileItem.server) + ":${profileItem.serverPort}"
+            }
+            wireguard.mtu = profileItem.mtu
+            wireguard.reserved = profileItem.reserved?.takeIf { it.isNotBlank() }?.split(",")?.filter { it.isNotBlank() }?.map { it.trim().toInt() }
+        }
+
+        return outboundBean
+    }
+
+    /**
+     * Converts a ProfileItem object to a URI string.
+     *
+     * @param config the ProfileItem object to convert
+     * @return the converted URI string
+     */
+    fun toUri(config: ProfileItem): String {
+        val dicQuery = HashMap<String, String>()
+
+        dicQuery["publickey"] = config.publicKey.orEmpty()
+        if (config.reserved != null) {
+            dicQuery["reserved"] = config.reserved.removeWhiteSpace().orEmpty()
+        }
+        dicQuery["address"] = config.localAddress.removeWhiteSpace().orEmpty()
+        if (config.mtu != null) {
+            dicQuery["mtu"] = config.mtu.toString()
+        }
+        if (config.preSharedKey != null) {
+            dicQuery["presharedkey"] = config.preSharedKey.removeWhiteSpace().orEmpty()
+        }
+
+        return toUri(config, config.secretKey, dicQuery)
+    }
+}

+ 520 - 0
app/src/main/java/com/v2ray/ang/handler/AngConfigManager.kt

@@ -0,0 +1,520 @@
+package com.v2ray.ang.handler
+
+import android.content.Context
+import android.graphics.Bitmap
+import android.text.TextUtils
+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.SubscriptionItem
+import com.v2ray.ang.fmt.CustomFmt
+import com.v2ray.ang.fmt.Hysteria2Fmt
+import com.v2ray.ang.fmt.ShadowsocksFmt
+import com.v2ray.ang.fmt.SocksFmt
+import com.v2ray.ang.fmt.TrojanFmt
+import com.v2ray.ang.fmt.VlessFmt
+import com.v2ray.ang.fmt.VmessFmt
+import com.v2ray.ang.fmt.WireguardFmt
+import com.v2ray.ang.util.HttpUtil
+import com.v2ray.ang.util.JsonUtil
+import com.v2ray.ang.util.QRCodeDecoder
+import com.v2ray.ang.util.Utils
+import java.net.URI
+
+object AngConfigManager {
+
+
+    /**
+     * Shares the configuration to the clipboard.
+     *
+     * @param context The context.
+     * @param guid The GUID of the configuration.
+     * @return The result code.
+     */
+    fun share2Clipboard(context: Context, guid: String): Int {
+        try {
+            val conf = shareConfig(guid)
+            if (TextUtils.isEmpty(conf)) {
+                return -1
+            }
+
+            Utils.setClipboard(context, conf)
+
+        } catch (e: Exception) {
+            Log.e(AppConfig.TAG, "Failed to share config to clipboard", e)
+            return -1
+        }
+        return 0
+    }
+
+    /**
+     * Shares non-custom configurations to the clipboard.
+     *
+     * @param context The context.
+     * @param serverList The list of server GUIDs.
+     * @return The number of configurations shared.
+     */
+    fun shareNonCustomConfigsToClipboard(context: Context, serverList: List<String>): Int {
+        try {
+            val sb = StringBuilder()
+            for (guid in serverList) {
+                val url = shareConfig(guid)
+                if (TextUtils.isEmpty(url)) {
+                    continue
+                }
+                sb.append(url)
+                sb.appendLine()
+            }
+            if (sb.count() > 0) {
+                Utils.setClipboard(context, sb.toString())
+            }
+            return sb.lines().count() - 1
+        } catch (e: Exception) {
+            Log.e(AppConfig.TAG, "Failed to share non-custom configs to clipboard", e)
+            return -1
+        }
+    }
+
+    /**
+     * Shares the configuration as a QR code.
+     *
+     * @param guid The GUID of the configuration.
+     * @return The QR code bitmap.
+     */
+    fun share2QRCode(guid: String): Bitmap? {
+        try {
+            val conf = shareConfig(guid)
+            if (TextUtils.isEmpty(conf)) {
+                return null
+            }
+            return QRCodeDecoder.createQRCode(conf)
+
+        } catch (e: Exception) {
+            Log.e(AppConfig.TAG, "Failed to share config as QR code", e)
+            return null
+        }
+    }
+
+    /**
+     * Shares the full content of the configuration to the clipboard.
+     *
+     * @param context The context.
+     * @param guid The GUID of the configuration.
+     * @return The result code.
+     */
+    fun shareFullContent2Clipboard(context: Context, guid: String?): Int {
+        try {
+            if (guid == null) return -1
+            val result = V2rayConfigManager.getV2rayConfig(context, guid)
+            if (result.status) {
+                val config = MmkvManager.decodeServerConfig(guid)
+                if (config?.configType == EConfigType.HYSTERIA2) {
+                    val socksPort = Utils.findFreePort(listOf(100 + SettingsManager.getSocksPort(), 0))
+                    val hy2Config = Hysteria2Fmt.toNativeConfig(config, socksPort)
+                    Utils.setClipboard(context, JsonUtil.toJsonPretty(hy2Config) + "\n" + result.content)
+                    return 0
+                }
+                Utils.setClipboard(context, result.content)
+            } else {
+                return -1
+            }
+        } catch (e: Exception) {
+            Log.e(AppConfig.TAG, "Failed to share full content to clipboard", e)
+            return -1
+        }
+        return 0
+    }
+
+    /**
+     * Shares the configuration.
+     *
+     * @param guid The GUID of the configuration.
+     * @return The configuration string.
+     */
+    private fun shareConfig(guid: String): String {
+        try {
+            val config = MmkvManager.decodeServerConfig(guid) ?: return ""
+
+            return config.configType.protocolScheme + when (config.configType) {
+                EConfigType.VMESS -> VmessFmt.toUri(config)
+                EConfigType.CUSTOM -> ""
+                EConfigType.SHADOWSOCKS -> ShadowsocksFmt.toUri(config)
+                EConfigType.SOCKS -> SocksFmt.toUri(config)
+                EConfigType.HTTP -> ""
+                EConfigType.VLESS -> VlessFmt.toUri(config)
+                EConfigType.TROJAN -> TrojanFmt.toUri(config)
+                EConfigType.WIREGUARD -> WireguardFmt.toUri(config)
+                EConfigType.HYSTERIA2 -> Hysteria2Fmt.toUri(config)
+            }
+        } catch (e: Exception) {
+            Log.e(AppConfig.TAG, "Failed to share config for GUID: $guid", e)
+            return ""
+        }
+    }
+
+    /**
+     * Imports a batch of configurations.
+     *
+     * @param server The server string.
+     * @param subid The subscription ID.
+     * @param append Whether to append the configurations.
+     * @return A pair containing the number of configurations and subscriptions imported.
+     */
+    fun importBatchConfig(server: String?, subid: String, append: Boolean): Pair<Int, Int> {
+        var count = parseBatchConfig(Utils.decode(server), subid, append)
+        if (count <= 0) {
+            count = parseBatchConfig(server, subid, append)
+        }
+        if (count <= 0) {
+            count = parseCustomConfigServer(server, subid)
+        }
+
+        var countSub = parseBatchSubscription(server)
+        if (countSub <= 0) {
+            countSub = parseBatchSubscription(Utils.decode(server))
+        }
+        if (countSub > 0) {
+            updateConfigViaSubAll()
+        }
+
+        return count to countSub
+    }
+
+    /**
+     * Parses a batch of subscriptions.
+     *
+     * @param servers The servers string.
+     * @return The number of subscriptions parsed.
+     */
+    private fun parseBatchSubscription(servers: String?): Int {
+        try {
+            if (servers == null) {
+                return 0
+            }
+
+            var count = 0
+            servers.lines()
+                .distinct()
+                .forEach { str ->
+                    if (Utils.isValidSubUrl(str)) {
+                        count += importUrlAsSubscription(str)
+                    }
+                }
+            return count
+        } catch (e: Exception) {
+            Log.e(AppConfig.TAG, "Failed to parse batch subscription", e)
+        }
+        return 0
+    }
+
+    /**
+     * Parses a batch of configurations.
+     *
+     * @param servers The servers string.
+     * @param subid The subscription ID.
+     * @param append Whether to append the configurations.
+     * @return The number of configurations parsed.
+     */
+    private fun parseBatchConfig(servers: String?, subid: String, append: Boolean): Int {
+        try {
+            if (servers == null) {
+                return 0
+            }
+            val removedSelectedServer =
+                if (!TextUtils.isEmpty(subid) && !append) {
+                    MmkvManager.decodeServerConfig(
+                        MmkvManager.getSelectServer().orEmpty()
+                    )?.let {
+                        if (it.subscriptionId == subid) {
+                            return@let it
+                        }
+                        return@let null
+                    }
+                } else {
+                    null
+                }
+            if (!append) {
+                MmkvManager.removeServerViaSubid(subid)
+            }
+
+            val subItem = MmkvManager.decodeSubscription(subid)
+            var count = 0
+            servers.lines()
+                .distinct()
+                .reversed()
+                .forEach {
+                    val resId = parseConfig(it, subid, subItem, removedSelectedServer)
+                    if (resId == 0) {
+                        count++
+                    }
+                }
+            return count
+        } catch (e: Exception) {
+            Log.e(AppConfig.TAG, "Failed to parse batch config", e)
+        }
+        return 0
+    }
+
+    /**
+     * Parses a custom configuration server.
+     *
+     * @param server The server string.
+     * @param subid The subscription ID.
+     * @return The number of configurations parsed.
+     */
+    private fun parseCustomConfigServer(server: String?, subid: String): Int {
+        if (server == null) {
+            return 0
+        }
+        if (server.contains("inbounds")
+            && server.contains("outbounds")
+            && server.contains("routing")
+        ) {
+            try {
+                val serverList: Array<Any> =
+                    JsonUtil.fromJson(server, Array<Any>::class.java)
+
+                if (serverList.isNotEmpty()) {
+                    var count = 0
+                    for (srv in serverList.reversed()) {
+                        val config = CustomFmt.parse(JsonUtil.toJson(srv)) ?: continue
+                        config.subscriptionId = subid
+                        val key = MmkvManager.encodeServerConfig("", config)
+                        MmkvManager.encodeServerRaw(key, JsonUtil.toJsonPretty(srv) ?: "")
+                        count += 1
+                    }
+                    return count
+                }
+            } catch (e: Exception) {
+                Log.e(AppConfig.TAG, "Failed to parse custom config server JSON array", e)
+            }
+
+            try {
+                // For compatibility
+                val config = CustomFmt.parse(server) ?: return 0
+                config.subscriptionId = subid
+                val key = MmkvManager.encodeServerConfig("", config)
+                MmkvManager.encodeServerRaw(key, server)
+                return 1
+            } catch (e: Exception) {
+                Log.e(AppConfig.TAG, "Failed to parse custom config server as single config", e)
+            }
+            return 0
+        } else if (server.startsWith("[Interface]") && server.contains("[Peer]")) {
+            try {
+                val config = WireguardFmt.parseWireguardConfFile(server) ?: return R.string.toast_incorrect_protocol
+                val key = MmkvManager.encodeServerConfig("", config)
+                MmkvManager.encodeServerRaw(key, server)
+                return 1
+            } catch (e: Exception) {
+                Log.e(AppConfig.TAG, "Failed to parse WireGuard config file", e)
+            }
+            return 0
+        } else {
+            return 0
+        }
+    }
+
+    /**
+     * Parses the configuration from a QR code or string.
+     *
+     * @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.
+     */
+    private fun parseConfig(
+        str: String?,
+        subid: String,
+        subItem: SubscriptionItem?,
+        removedSelectedServer: ProfileItem?
+    ): Int {
+        try {
+            if (str == null || TextUtils.isEmpty(str)) {
+                return R.string.toast_none_data
+            }
+
+            val config = if (str.startsWith(EConfigType.VMESS.protocolScheme)) {
+                VmessFmt.parse(str)
+            } else if (str.startsWith(EConfigType.SHADOWSOCKS.protocolScheme)) {
+                ShadowsocksFmt.parse(str)
+            } else if (str.startsWith(EConfigType.SOCKS.protocolScheme)) {
+                SocksFmt.parse(str)
+            } else if (str.startsWith(EConfigType.TROJAN.protocolScheme)) {
+                TrojanFmt.parse(str)
+            } else if (str.startsWith(EConfigType.VLESS.protocolScheme)) {
+                VlessFmt.parse(str)
+            } else if (str.startsWith(EConfigType.WIREGUARD.protocolScheme)) {
+                WireguardFmt.parse(str)
+            } else if (str.startsWith(EConfigType.HYSTERIA2.protocolScheme) || str.startsWith(HY2)) {
+                Hysteria2Fmt.parse(str)
+            } else {
+                null
+            }
+
+            if (config == null) {
+                return R.string.toast_incorrect_protocol
+            }
+            //filter
+            if (subItem?.filter != null && subItem.filter?.isNotEmpty() == true && config.remarks.isNotEmpty()) {
+                val matched = Regex(pattern = subItem.filter ?: "")
+                    .containsMatchIn(input = config.remarks)
+                if (!matched) return -1
+            }
+
+            config.subscriptionId = subid
+            val guid = MmkvManager.encodeServerConfig("", config)
+            if (removedSelectedServer != null &&
+                config.server == removedSelectedServer.server && config.serverPort == removedSelectedServer.serverPort
+            ) {
+                MmkvManager.setSelectServer(guid)
+            }
+        } catch (e: Exception) {
+            Log.e(AppConfig.TAG, "Failed to parse config", e)
+            return -1
+        }
+        return 0
+    }
+
+    /**
+     * Updates the configuration via all subscriptions.
+     *
+     * @return The number of configurations updated.
+     */
+    fun updateConfigViaSubAll(): Int {
+        var count = 0
+        try {
+            MmkvManager.decodeSubscriptions().forEach {
+                count += updateConfigViaSub(it)
+            }
+        } catch (e: Exception) {
+            Log.e(AppConfig.TAG, "Failed to update config via all subscriptions", e)
+            return 0
+        }
+        return count
+    }
+
+    /**
+     * Updates the configuration via a subscription.
+     *
+     * @param it The subscription item.
+     * @return The number of configurations updated.
+     */
+    fun updateConfigViaSub(it: Pair<String, SubscriptionItem>): Int {
+        try {
+            if (TextUtils.isEmpty(it.first)
+                || TextUtils.isEmpty(it.second.remarks)
+                || TextUtils.isEmpty(it.second.url)
+            ) {
+                return 0
+            }
+            if (!it.second.enabled) {
+                return 0
+            }
+            val url = HttpUtil.toIdnUrl(it.second.url)
+            if (!Utils.isValidUrl(url)) {
+                return 0
+            }
+            if (!it.second.allowInsecureUrl) {
+                if (!Utils.isValidSubUrl(url)) {
+                    return 0
+                }
+            }
+            Log.i(AppConfig.TAG, url)
+
+            var configText = try {
+                val httpPort = SettingsManager.getHttpPort()
+                HttpUtil.getUrlContentWithUserAgent(url, 15000, httpPort)
+            } catch (e: Exception) {
+                Log.e(AppConfig.ANG_PACKAGE, "Update subscription: proxy not ready or other error", e)
+                ""
+            }
+            if (configText.isEmpty()) {
+                configText = try {
+                    HttpUtil.getUrlContentWithUserAgent(url)
+                } catch (e: Exception) {
+                    Log.e(AppConfig.TAG, "Update subscription: Failed to get URL content with user agent", e)
+                    ""
+                }
+            }
+            if (configText.isEmpty()) {
+                return 0
+            }
+            return parseConfigViaSub(configText, it.first, false)
+        } catch (e: Exception) {
+            Log.e(AppConfig.TAG, "Failed to update config via subscription", e)
+            return 0
+        }
+    }
+
+    /**
+     * Parses the configuration via a subscription.
+     *
+     * @param server The server string.
+     * @param subid The subscription ID.
+     * @param append Whether to append the configurations.
+     * @return The number of configurations parsed.
+     */
+    private fun parseConfigViaSub(server: String?, subid: String, append: Boolean): Int {
+        var count = parseBatchConfig(Utils.decode(server), subid, append)
+        if (count <= 0) {
+            count = parseBatchConfig(server, subid, append)
+        }
+        if (count <= 0) {
+            count = parseCustomConfigServer(server, subid)
+        }
+        return count
+    }
+
+    /**
+     * Imports a URL as a subscription.
+     *
+     * @param url The URL.
+     * @return The number of subscriptions imported.
+     */
+    private fun importUrlAsSubscription(url: String): Int {
+        val subscriptions = MmkvManager.decodeSubscriptions()
+        subscriptions.forEach {
+            if (it.second.url == url) {
+                return 0
+            }
+        }
+        val uri = URI(Utils.fixIllegalUrl(url))
+        val subItem = SubscriptionItem()
+        subItem.remarks = uri.fragment ?: "import sub"
+        subItem.url = url
+        MmkvManager.encodeSubscription("", subItem)
+        return 1
+    }
+
+    /**
+     * Creates an intelligent selection configuration based on multiple server configurations.
+     *
+     * @param context The application context used for configuration generation.
+     * @param guidList The list of server GUIDs to be included in the intelligent selection.
+     *                 Each GUID represents a server configuration that will be combined.
+     * @param subid The subscription ID to associate with the generated configuration.
+     *              This helps organize the configuration under a specific subscription.
+     * @return The GUID key of the newly created intelligent selection configuration,
+     *         or null if the operation fails (e.g., empty guidList or configuration parsing error).
+     */
+    fun createIntelligentSelection(
+        context: Context,
+        guidList: List<String>,
+        subid: String
+    ): String? {
+        if (guidList.isEmpty()) {
+            return null
+        }
+        val result = V2rayConfigManager.genV2rayConfig(context, guidList) ?: return null
+        val config = CustomFmt.parse(JsonUtil.toJson(result)) ?: return null
+        config.subscriptionId = subid
+        val key = MmkvManager.encodeServerConfig("", config)
+        MmkvManager.encodeServerRaw(key, JsonUtil.toJsonPretty(result) ?: "")
+        return key
+    }
+}

+ 242 - 0
app/src/main/java/com/v2ray/ang/handler/MigrateManager.kt

@@ -0,0 +1,242 @@
+package com.v2ray.ang.handler
+
+import android.util.Log
+import com.tencent.mmkv.MMKV
+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.ServerConfig
+import com.v2ray.ang.extension.removeWhiteSpace
+import com.v2ray.ang.handler.MmkvManager.decodeServerConfig
+import com.v2ray.ang.util.JsonUtil
+
+object MigrateManager {
+    private const val ID_SERVER_CONFIG = "SERVER_CONFIG"
+    private val serverStorage by lazy { MMKV.mmkvWithID(ID_SERVER_CONFIG, MMKV.MULTI_PROCESS_MODE) }
+
+    /**
+     * Migrates server configurations to profile items.
+     *
+     * @return True if migration was successful, false otherwise.
+     */
+    fun migrateServerConfig2Profile(): Boolean {
+        if (serverStorage.count().toInt() == 0) {
+            return false
+        }
+        val serverList = serverStorage.allKeys() ?: return false
+        Log.i(AppConfig.TAG, "migrateServerConfig2Profile-" + serverList.count())
+
+        for (guid in serverList) {
+            var configOld = decodeServerConfigOld(guid) ?: continue
+            var config = decodeServerConfig(guid)
+            if (config != null) {
+                serverStorage.remove(guid)
+                continue
+            }
+            config = migrateServerConfig2ProfileSub(configOld) ?: continue
+            config.subscriptionId = configOld.subscriptionId
+
+            MmkvManager.encodeServerConfig(guid, config)
+
+            //check and remove old
+            decodeServerConfig(guid) ?: continue
+            serverStorage.remove(guid)
+            Log.i(AppConfig.TAG, "migrateServerConfig2Profile-" + config.remarks)
+        }
+        Log.i(AppConfig.TAG, "migrateServerConfig2Profile-end")
+        return true
+    }
+
+    /**
+     * Migrates a server configuration to a profile item.
+     *
+     * @param configOld The old server configuration.
+     * @return The profile item.
+     */
+    private fun migrateServerConfig2ProfileSub(configOld: ServerConfig): ProfileItem? {
+        return when (configOld.getProxyOutbound()?.protocol) {
+            EConfigType.VMESS.name.lowercase() -> migrate2ProfileCommon(configOld)
+            EConfigType.VLESS.name.lowercase() -> migrate2ProfileCommon(configOld)
+            EConfigType.TROJAN.name.lowercase() -> migrate2ProfileCommon(configOld)
+            EConfigType.SHADOWSOCKS.name.lowercase() -> migrate2ProfileCommon(configOld)
+
+            EConfigType.SOCKS.name.lowercase() -> migrate2ProfileSocks(configOld)
+            EConfigType.HTTP.name.lowercase() -> migrate2ProfileHttp(configOld)
+            EConfigType.WIREGUARD.name.lowercase() -> migrate2ProfileWireguard(configOld)
+            EConfigType.HYSTERIA2.name.lowercase() -> migrate2ProfileHysteria2(configOld)
+
+            EConfigType.CUSTOM.name.lowercase() -> migrate2ProfileCustom(configOld)
+
+            else -> null
+        }
+    }
+
+    /**
+     * Migrates a common server configuration to a profile item.
+     *
+     * @param configOld The old server configuration.
+     * @return The profile item.
+     */
+    private fun migrate2ProfileCommon(configOld: ServerConfig): ProfileItem? {
+        val config = ProfileItem.create(configOld.configType)
+
+        val outbound = configOld.getProxyOutbound() ?: return null
+        config.remarks = configOld.remarks
+        config.server = outbound.getServerAddress()
+        config.serverPort = outbound.getServerPort().toString()
+        config.method = outbound.getSecurityEncryption()
+        config.password = outbound.getPassword()
+        config.flow = outbound?.settings?.vnext?.first()?.users?.first()?.flow ?: outbound?.settings?.servers?.first()?.flow
+
+        config.network = outbound?.streamSettings?.network ?: NetworkType.TCP.type
+        outbound.getTransportSettingDetails()?.let { transportDetails ->
+            config.headerType = transportDetails[0].orEmpty()
+            config.host = transportDetails[1].orEmpty()
+            config.path = transportDetails[2].orEmpty()
+        }
+
+        config.seed = outbound?.streamSettings?.kcpSettings?.seed
+        config.quicSecurity = outbound?.streamSettings?.quicSettings?.security
+        config.quicKey = outbound?.streamSettings?.quicSettings?.key
+        config.mode = if (outbound?.streamSettings?.grpcSettings?.multiMode == true) "multi" else "gun"
+        config.serviceName = outbound?.streamSettings?.grpcSettings?.serviceName
+        config.authority = outbound?.streamSettings?.grpcSettings?.authority
+
+        config.security = outbound.streamSettings?.security
+        val tlsSettings = outbound?.streamSettings?.realitySettings ?: outbound?.streamSettings?.tlsSettings
+        config.insecure = tlsSettings?.allowInsecure
+        config.sni = tlsSettings?.serverName
+        config.fingerPrint = tlsSettings?.fingerprint
+        config.alpn = tlsSettings?.alpn?.joinToString(",").removeWhiteSpace().toString()
+
+        config.publicKey = tlsSettings?.publicKey
+        config.shortId = tlsSettings?.shortId
+        config.spiderX = tlsSettings?.spiderX
+
+        return config
+    }
+
+    /**
+     * Migrates a SOCKS server configuration to a profile item.
+     *
+     * @param configOld The old server configuration.
+     * @return The profile item.
+     */
+    private fun migrate2ProfileSocks(configOld: ServerConfig): ProfileItem? {
+        val config = ProfileItem.create(EConfigType.SOCKS)
+
+        val outbound = configOld.getProxyOutbound() ?: return null
+        config.remarks = configOld.remarks
+        config.server = outbound.getServerAddress()
+        config.serverPort = outbound.getServerPort().toString()
+        config.username = outbound.settings?.servers?.first()?.users?.first()?.user
+        config.password = outbound.getPassword()
+
+        return config
+    }
+
+    /**
+     * Migrates an HTTP server configuration to a profile item.
+     *
+     * @param configOld The old server configuration.
+     * @return The profile item.
+     */
+    private fun migrate2ProfileHttp(configOld: ServerConfig): ProfileItem? {
+        val config = ProfileItem.create(EConfigType.HTTP)
+
+        val outbound = configOld.getProxyOutbound() ?: return null
+        config.remarks = configOld.remarks
+        config.server = outbound.getServerAddress()
+        config.serverPort = outbound.getServerPort().toString()
+        config.username = outbound.settings?.servers?.first()?.users?.first()?.user
+        config.password = outbound.getPassword()
+
+        return config
+    }
+
+    /**
+     * Migrates a WireGuard server configuration to a profile item.
+     *
+     * @param configOld The old server configuration.
+     * @return The profile item.
+     */
+    private fun migrate2ProfileWireguard(configOld: ServerConfig): ProfileItem? {
+        val config = ProfileItem.create(EConfigType.WIREGUARD)
+
+        val outbound = configOld.getProxyOutbound() ?: return null
+        config.remarks = configOld.remarks
+        config.server = outbound.getServerAddress()
+        config.serverPort = outbound.getServerPort().toString()
+
+        outbound.settings?.let { wireguard ->
+            config.secretKey = wireguard.secretKey
+            config.localAddress = (wireguard.address as List<*>).joinToString(",").removeWhiteSpace().toString()
+            config.publicKey = wireguard.peers?.getOrNull(0)?.publicKey
+            config.mtu = wireguard.mtu
+            config.reserved = wireguard.reserved?.joinToString(",").removeWhiteSpace().toString()
+        }
+        return config
+    }
+
+    /**
+     * Migrates a Hysteria2 server configuration to a profile item.
+     *
+     * @param configOld The old server configuration.
+     * @return The profile item.
+     */
+    private fun migrate2ProfileHysteria2(configOld: ServerConfig): ProfileItem? {
+        val config = ProfileItem.create(EConfigType.HYSTERIA2)
+
+        val outbound = configOld.getProxyOutbound() ?: return null
+        config.remarks = configOld.remarks
+        config.server = outbound.getServerAddress()
+        config.serverPort = outbound.getServerPort().toString()
+        config.password = outbound.getPassword()
+
+        config.security = AppConfig.TLS
+        outbound.streamSettings?.tlsSettings?.let { tlsSetting ->
+            config.insecure = tlsSetting.allowInsecure
+            config.sni = tlsSetting.serverName
+            config.alpn = tlsSetting.alpn?.joinToString(",").removeWhiteSpace().orEmpty()
+
+        }
+        config.obfsPassword = outbound.settings?.obfsPassword
+
+        return config
+    }
+
+    /**
+     * Migrates a custom server configuration to a profile item.
+     *
+     * @param configOld The old server configuration.
+     * @return The profile item.
+     */
+    private fun migrate2ProfileCustom(configOld: ServerConfig): ProfileItem? {
+        val config = ProfileItem.create(EConfigType.CUSTOM)
+
+        val outbound = configOld.getProxyOutbound() ?: return null
+        config.remarks = configOld.remarks
+        config.server = outbound.getServerAddress()
+        config.serverPort = outbound.getServerPort().toString()
+
+        return config
+    }
+
+    /**
+     * Decodes the old server configuration.
+     *
+     * @param guid The server GUID.
+     * @return The old server configuration.
+     */
+    private fun decodeServerConfigOld(guid: String): ServerConfig? {
+        if (guid.isBlank()) {
+            return null
+        }
+        val json = serverStorage.decodeString(guid)
+        if (json.isNullOrBlank()) {
+            return null
+        }
+        return JsonUtil.fromJson(json, ServerConfig::class.java)
+    }
+}

+ 588 - 0
app/src/main/java/com/v2ray/ang/handler/MmkvManager.kt

@@ -0,0 +1,588 @@
+package com.v2ray.ang.handler
+
+import com.tencent.mmkv.MMKV
+import com.v2ray.ang.AppConfig.PREF_IS_BOOTED
+import com.v2ray.ang.AppConfig.PREF_ROUTING_RULESET
+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.SubscriptionItem
+import com.v2ray.ang.util.JsonUtil
+import com.v2ray.ang.util.Utils
+
+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"
+    private const val ID_SERVER_AFF = "SERVER_AFF"
+    private const val ID_SUB = "SUB"
+    private const val ID_ASSET = "ASSET"
+    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_IDS = "SUB_IDS"
+
+    //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) }
+    private val serverAffStorage by lazy { MMKV.mmkvWithID(ID_SERVER_AFF, MMKV.MULTI_PROCESS_MODE) }
+    private val subStorage by lazy { MMKV.mmkvWithID(ID_SUB, MMKV.MULTI_PROCESS_MODE) }
+    private val assetStorage by lazy { MMKV.mmkvWithID(ID_ASSET, MMKV.MULTI_PROCESS_MODE) }
+    private val settingsStorage by lazy { MMKV.mmkvWithID(ID_SETTING, MMKV.MULTI_PROCESS_MODE) }
+
+    //endregion
+
+    //region Server
+
+    /**
+     * Gets the selected server GUID.
+     *
+     * @return The selected server GUID.
+     */
+    fun getSelectServer(): String? {
+        return mainStorage.decodeString(KEY_SELECTED_SERVER)
+    }
+
+    /**
+     * Sets the selected server GUID.
+     *
+     * @param guid The server GUID.
+     */
+    fun setSelectServer(guid: String) {
+        mainStorage.encode(KEY_SELECTED_SERVER, guid)
+    }
+
+    /**
+     * Encodes the server list.
+     *
+     * @param serverList The list of server GUIDs.
+     */
+    fun encodeServerList(serverList: MutableList<String>) {
+        mainStorage.encode(KEY_ANG_CONFIGS, JsonUtil.toJson(serverList))
+    }
+
+    /**
+     * Decodes the server list.
+     *
+     * @return The list of server GUIDs.
+     */
+    fun decodeServerList(): MutableList<String> {
+        val json = mainStorage.decodeString(KEY_ANG_CONFIGS)
+        return if (json.isNullOrBlank()) {
+            mutableListOf()
+        } else {
+            JsonUtil.fromJson(json, Array<String>::class.java).toMutableList()
+        }
+    }
+
+    /**
+     * Decodes the server configuration.
+     *
+     * @param guid The server GUID.
+     * @return The server configuration.
+     */
+    fun decodeServerConfig(guid: String): ProfileItem? {
+        if (guid.isBlank()) {
+            return null
+        }
+        val json = profileFullStorage.decodeString(guid)
+        if (json.isNullOrBlank()) {
+            return null
+        }
+        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.
+     *
+     * @param guid The server GUID.
+     * @param config The server configuration.
+     * @return The server GUID.
+     */
+    fun encodeServerConfig(guid: String, config: ProfileItem): String {
+        val key = guid.ifBlank { Utils.getUuid() }
+        profileFullStorage.encode(key, JsonUtil.toJson(config))
+        val serverList = decodeServerList()
+        if (!serverList.contains(key)) {
+            serverList.add(0, key)
+            encodeServerList(serverList)
+            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
+    }
+
+    /**
+     * Removes the server configuration.
+     *
+     * @param guid The server GUID.
+     */
+    fun removeServer(guid: String) {
+        if (guid.isBlank()) {
+            return
+        }
+        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.
+     */
+    fun removeServerViaSubid(subid: String) {
+        if (subid.isBlank()) {
+            return
+        }
+        profileFullStorage.allKeys()?.forEach { key ->
+            decodeServerConfig(key)?.let { config ->
+                if (config.subscriptionId == subid) {
+                    removeServer(key)
+                }
+            }
+        }
+    }
+
+    /**
+     * Decodes the server affiliation information.
+     *
+     * @param guid The server GUID.
+     * @return The server affiliation information.
+     */
+    fun decodeServerAffiliationInfo(guid: String): ServerAffiliationInfo? {
+        if (guid.isBlank()) {
+            return null
+        }
+        val json = serverAffStorage.decodeString(guid)
+        if (json.isNullOrBlank()) {
+            return null
+        }
+        return JsonUtil.fromJson(json, ServerAffiliationInfo::class.java)
+    }
+
+    /**
+     * Encodes the server test delay in milliseconds.
+     *
+     * @param guid The server GUID.
+     * @param testResult The test delay in milliseconds.
+     */
+    fun encodeServerTestDelayMillis(guid: String, testResult: Long) {
+        if (guid.isBlank()) {
+            return
+        }
+        val aff = decodeServerAffiliationInfo(guid) ?: ServerAffiliationInfo()
+        aff.testDelayMillis = testResult
+        serverAffStorage.encode(guid, JsonUtil.toJson(aff))
+    }
+
+    /**
+     * Clears all test delay results.
+     *
+     * @param keys The list of server GUIDs.
+     */
+    fun clearAllTestDelayResults(keys: List<String>?) {
+        keys?.forEach { key ->
+            decodeServerAffiliationInfo(key)?.let { aff ->
+                aff.testDelayMillis = 0
+                serverAffStorage.encode(key, JsonUtil.toJson(aff))
+            }
+        }
+    }
+
+    /**
+     * Removes all server configurations.
+     *
+     * @return The number of server configurations removed.
+     */
+    fun removeAllServer(): Int {
+        val count = profileFullStorage.allKeys()?.count() ?: 0
+        mainStorage.clearAll()
+        profileFullStorage.clearAll()
+        //profileStorage.clearAll()
+        serverAffStorage.clearAll()
+        return count
+    }
+
+    /**
+     * Removes invalid server configurations.
+     *
+     * @param guid The server GUID.
+     * @return The number of server configurations removed.
+     */
+    fun removeInvalidServer(guid: String): Int {
+        var count = 0
+        if (guid.isNotEmpty()) {
+            decodeServerAffiliationInfo(guid)?.let { aff ->
+                if (aff.testDelayMillis < 0L) {
+                    removeServer(guid)
+                    count++
+                }
+            }
+        } else {
+            serverAffStorage.allKeys()?.forEach { key ->
+                decodeServerAffiliationInfo(key)?.let { aff ->
+                    if (aff.testDelayMillis < 0L) {
+                        removeServer(key)
+                        count++
+                    }
+                }
+            }
+        }
+        return count
+    }
+
+    /**
+     * Encodes the raw server configuration.
+     *
+     * @param guid The server GUID.
+     * @param config The raw server configuration.
+     */
+    fun encodeServerRaw(guid: String, config: String) {
+        serverRawStorage.encode(guid, config)
+    }
+
+    /**
+     * Decodes the raw server configuration.
+     *
+     * @param guid The server GUID.
+     * @return The raw server configuration.
+     */
+    fun decodeServerRaw(guid: String): String? {
+        return serverRawStorage.decodeString(guid)
+    }
+
+    //endregion
+
+    //region Subscriptions
+
+    /**
+     * Initializes the subscription list.
+     */
+    private fun initSubsList() {
+        val subsList = decodeSubsList()
+        if (subsList.isNotEmpty()) {
+            return
+        }
+        subStorage.allKeys()?.forEach { key ->
+            subsList.add(key)
+        }
+        encodeSubsList(subsList)
+    }
+
+    /**
+     * Decodes the subscriptions.
+     *
+     * @return The list of subscriptions.
+     */
+    fun decodeSubscriptions(): List<Pair<String, SubscriptionItem>> {
+        initSubsList()
+
+        val subscriptions = mutableListOf<Pair<String, SubscriptionItem>>()
+        decodeSubsList().forEach { key ->
+            val json = subStorage.decodeString(key)
+            if (!json.isNullOrBlank()) {
+                subscriptions.add(Pair(key, JsonUtil.fromJson(json, SubscriptionItem::class.java)))
+            }
+        }
+        return subscriptions
+    }
+
+    /**
+     * Removes the subscription.
+     *
+     * @param subid The subscription ID.
+     */
+    fun removeSubscription(subid: String) {
+        subStorage.remove(subid)
+        val subsList = decodeSubsList()
+        subsList.remove(subid)
+        encodeSubsList(subsList)
+
+        removeServerViaSubid(subid)
+    }
+
+    /**
+     * Encodes the subscription.
+     *
+     * @param guid The subscription GUID.
+     * @param subItem The subscription item.
+     */
+    fun encodeSubscription(guid: String, subItem: SubscriptionItem) {
+        val key = guid.ifBlank { Utils.getUuid() }
+        subStorage.encode(key, JsonUtil.toJson(subItem))
+
+        val subsList = decodeSubsList()
+        if (!subsList.contains(key)) {
+            subsList.add(key)
+            encodeSubsList(subsList)
+        }
+    }
+
+    /**
+     * Decodes the subscription.
+     *
+     * @param subscriptionId The subscription ID.
+     * @return The subscription item.
+     */
+    fun decodeSubscription(subscriptionId: String): SubscriptionItem? {
+        val json = subStorage.decodeString(subscriptionId) ?: return null
+        return JsonUtil.fromJson(json, SubscriptionItem::class.java)
+    }
+
+    /**
+     * Encodes the subscription list.
+     *
+     * @param subsList The list of subscription IDs.
+     */
+    fun encodeSubsList(subsList: MutableList<String>) {
+        mainStorage.encode(KEY_SUB_IDS, JsonUtil.toJson(subsList))
+    }
+
+    /**
+     * Decodes the subscription list.
+     *
+     * @return The list of subscription IDs.
+     */
+    fun decodeSubsList(): MutableList<String> {
+        val json = mainStorage.decodeString(KEY_SUB_IDS)
+        return if (json.isNullOrBlank()) {
+            mutableListOf()
+        } else {
+            JsonUtil.fromJson(json, Array<String>::class.java).toMutableList()
+        }
+    }
+
+    //endregion
+
+    //region Asset
+
+    /**
+     * Decodes the asset URLs.
+     *
+     * @return The list of asset URLs.
+     */
+    fun decodeAssetUrls(): List<Pair<String, AssetUrlItem>> {
+        val assetUrlItems = mutableListOf<Pair<String, AssetUrlItem>>()
+        assetStorage.allKeys()?.forEach { key ->
+            val json = assetStorage.decodeString(key)
+            if (!json.isNullOrBlank()) {
+                assetUrlItems.add(Pair(key, JsonUtil.fromJson(json, AssetUrlItem::class.java)))
+            }
+        }
+        return assetUrlItems.sortedBy { (_, value) -> value.addedTime }
+    }
+
+    /**
+     * Removes the asset URL.
+     *
+     * @param assetid The asset ID.
+     */
+    fun removeAssetUrl(assetid: String) {
+        assetStorage.remove(assetid)
+    }
+
+    /**
+     * Encodes the asset.
+     *
+     * @param assetid The asset ID.
+     * @param assetItem The asset item.
+     */
+    fun encodeAsset(assetid: String, assetItem: AssetUrlItem) {
+        val key = assetid.ifBlank { Utils.getUuid() }
+        assetStorage.encode(key, JsonUtil.toJson(assetItem))
+    }
+
+    /**
+     * Decodes the asset.
+     *
+     * @param assetid The asset ID.
+     * @return The asset item.
+     */
+    fun decodeAsset(assetid: String): AssetUrlItem? {
+        val json = assetStorage.decodeString(assetid) ?: return null
+        return JsonUtil.fromJson(json, AssetUrlItem::class.java)
+    }
+
+    //endregion
+
+    //region Routing
+
+    /**
+     * Decodes the routing rulesets.
+     *
+     * @return The list of routing rulesets.
+     */
+    fun decodeRoutingRulesets(): MutableList<RulesetItem>? {
+        val ruleset = settingsStorage.decodeString(PREF_ROUTING_RULESET)
+        if (ruleset.isNullOrEmpty()) return null
+        return JsonUtil.fromJson(ruleset, Array<RulesetItem>::class.java).toMutableList()
+    }
+
+    /**
+     * Encodes the routing rulesets.
+     *
+     * @param rulesetList The list of routing rulesets.
+     */
+    fun encodeRoutingRulesets(rulesetList: MutableList<RulesetItem>?) {
+        if (rulesetList.isNullOrEmpty())
+            encodeSettings(PREF_ROUTING_RULESET, "")
+        else
+            encodeSettings(PREF_ROUTING_RULESET, JsonUtil.toJson(rulesetList))
+    }
+
+    //endregion
+
+    /**
+     * Encodes the settings.
+     *
+     * @param key The settings key.
+     * @param value The settings value.
+     * @return Whether the encoding was successful.
+     */
+    fun encodeSettings(key: String, value: String?): Boolean {
+        return settingsStorage.encode(key, value)
+    }
+
+    /**
+     * Encodes the settings.
+     *
+     * @param key The settings key.
+     * @param value The settings value.
+     * @return Whether the encoding was successful.
+     */
+    fun encodeSettings(key: String, value: Int): Boolean {
+        return settingsStorage.encode(key, value)
+    }
+
+    /**
+     * Encodes the settings.
+     *
+     * @param key The settings key.
+     * @param value The settings value.
+     * @return Whether the encoding was successful.
+     */
+    fun encodeSettings(key: String, value: Boolean): Boolean {
+        return settingsStorage.encode(key, value)
+    }
+
+    /**
+     * Encodes the settings.
+     *
+     * @param key The settings key.
+     * @param value The settings value.
+     * @return Whether the encoding was successful.
+     */
+    fun encodeSettings(key: String, value: MutableSet<String>): Boolean {
+        return settingsStorage.encode(key, value)
+    }
+
+    /**
+     * Decodes the settings string.
+     *
+     * @param key The settings key.
+     * @return The settings value.
+     */
+    fun decodeSettingsString(key: String): String? {
+        return settingsStorage.decodeString(key)
+    }
+
+    /**
+     * Decodes the settings string.
+     *
+     * @param key The settings key.
+     * @param defaultValue The default value.
+     * @return The settings value.
+     */
+    fun decodeSettingsString(key: String, defaultValue: String?): String? {
+        return settingsStorage.decodeString(key, defaultValue)
+    }
+
+    /**
+     * Decodes the settings boolean.
+     *
+     * @param key The settings key.
+     * @return The settings value.
+     */
+    fun decodeSettingsBool(key: String): Boolean {
+        return settingsStorage.decodeBool(key, false)
+    }
+
+    /**
+     * Decodes the settings boolean.
+     *
+     * @param key The settings key.
+     * @param defaultValue The default value.
+     * @return The settings value.
+     */
+    fun decodeSettingsBool(key: String, defaultValue: Boolean): Boolean {
+        return settingsStorage.decodeBool(key, defaultValue)
+    }
+
+    /**
+     * Decodes the settings string set.
+     *
+     * @param key The settings key.
+     * @return The settings value.
+     */
+    fun decodeSettingsStringSet(key: String): MutableSet<String>? {
+        return settingsStorage.decodeStringSet(key)
+    }
+
+    //endregion
+
+    //region Others
+
+    /**
+     * Encodes the start on boot setting.
+     *
+     * @param startOnBoot Whether to start on boot.
+     */
+    fun encodeStartOnBoot(startOnBoot: Boolean) {
+        encodeSettings(PREF_IS_BOOTED, startOnBoot)
+    }
+
+    /**
+     * Decodes the start on boot setting.
+     *
+     * @return Whether to start on boot.
+     */
+    fun decodeStartOnBoot(): Boolean {
+        return decodeSettingsBool(PREF_IS_BOOTED, false)
+    }
+
+    //endregion
+
+}

+ 250 - 0
app/src/main/java/com/v2ray/ang/handler/NotificationManager.kt

@@ -0,0 +1,250 @@
+package com.v2ray.ang.handler
+
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.app.Service
+import android.content.Context
+import android.content.Intent
+import android.graphics.Color
+import android.os.Build
+import androidx.annotation.RequiresApi
+import androidx.core.app.NotificationCompat
+import com.v2ray.ang.AppConfig
+import com.v2ray.ang.R
+import com.v2ray.ang.dto.ProfileItem
+import com.v2ray.ang.extension.toSpeedString
+import com.v2ray.ang.handler.V2RayServiceManager
+import com.v2ray.ang.ui.MainActivity
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
+import kotlin.math.min
+
+object NotificationManager {
+    private const val NOTIFICATION_ID = 1
+    private const val NOTIFICATION_PENDING_INTENT_CONTENT = 0
+    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 var lastQueryTime = 0L
+    private var mBuilder: NotificationCompat.Builder? = null
+    private var speedNotificationJob: Job? = null
+    private var mNotificationManager: NotificationManager? = null
+
+    /**
+     * Starts the speed notification.
+     * @param currentConfig The current profile configuration.
+     */
+    fun startSpeedNotification(currentConfig: ProfileItem?) {
+        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)
+
+        speedNotificationJob = CoroutineScope(Dispatchers.IO).launch {
+            while (isActive) {
+                val queryTime = System.currentTimeMillis()
+                val sinceLastQueryInSeconds = (queryTime - lastQueryTime) / 1000.0
+                var proxyTotal = 0L
+                val text = StringBuilder()
+                outboundTags?.forEach {
+                    val up = V2RayServiceManager.queryStats(it, AppConfig.UPLINK)
+                    val down = V2RayServiceManager.queryStats(it, AppConfig.DOWNLINK)
+                    if (up + down > 0) {
+                        appendSpeedString(text, it, up / sinceLastQueryInSeconds, down / sinceLastQueryInSeconds)
+                        proxyTotal += up + down
+                    }
+                }
+                val directUplink = V2RayServiceManager.queryStats(AppConfig.TAG_DIRECT, AppConfig.UPLINK)
+                val directDownlink = V2RayServiceManager.queryStats(AppConfig.TAG_DIRECT, AppConfig.DOWNLINK)
+                val zeroSpeed = proxyTotal == 0L && directUplink == 0L && directDownlink == 0L
+                if (!zeroSpeed || !lastZeroSpeed) {
+                    if (proxyTotal == 0L) {
+                        appendSpeedString(text, outboundTags?.firstOrNull(), 0.0, 0.0)
+                    }
+                    appendSpeedString(
+                        text, AppConfig.TAG_DIRECT, directUplink / sinceLastQueryInSeconds,
+                        directDownlink / sinceLastQueryInSeconds
+                    )
+                    updateNotification(text.toString(), proxyTotal, directDownlink + directUplink)
+                }
+                lastZeroSpeed = zeroSpeed
+                lastQueryTime = queryTime
+                delay(3000)
+            }
+        }
+    }
+
+    /**
+     * Shows the notification.
+     * @param currentConfig The current profile configuration.
+     */
+    fun showNotification(currentConfig: ProfileItem?) {
+        val service = getService() ?: return
+        val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+            PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
+        } else {
+            PendingIntent.FLAG_UPDATE_CURRENT
+        }
+
+        val startMainIntent = Intent(service, MainActivity::class.java)
+        val contentPendingIntent = PendingIntent.getActivity(service, NOTIFICATION_PENDING_INTENT_CONTENT, startMainIntent, flags)
+
+        val stopV2RayIntent = Intent(AppConfig.BROADCAST_ACTION_SERVICE)
+        stopV2RayIntent.`package` = AppConfig.ANG_PACKAGE
+        stopV2RayIntent.putExtra("key", AppConfig.MSG_STATE_STOP)
+        val stopV2RayPendingIntent = PendingIntent.getBroadcast(service, NOTIFICATION_PENDING_INTENT_STOP_V2RAY, stopV2RayIntent, flags)
+
+        val restartV2RayIntent = Intent(AppConfig.BROADCAST_ACTION_SERVICE)
+        restartV2RayIntent.`package` = AppConfig.ANG_PACKAGE
+        restartV2RayIntent.putExtra("key", AppConfig.MSG_STATE_RESTART)
+        val restartV2RayPendingIntent = PendingIntent.getBroadcast(service, NOTIFICATION_PENDING_INTENT_RESTART_V2RAY, restartV2RayIntent, flags)
+
+        val channelId =
+            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+                createNotificationChannel()
+            } else {
+                // If earlier version channel ID is not used
+                // https://developer.android.com/reference/android/support/v4/app/NotificationCompat.Builder.html#NotificationCompat.Builder(android.content.Context)
+                ""
+            }
+
+        mBuilder = NotificationCompat.Builder(service, channelId)
+            .setSmallIcon(R.drawable.ic_stat_name)
+            .setContentTitle(currentConfig?.remarks)
+            .setPriority(NotificationCompat.PRIORITY_MIN)
+            .setOngoing(true)
+            .setShowWhen(false)
+            .setOnlyAlertOnce(true)
+            .setContentIntent(contentPendingIntent)
+            .addAction(
+                R.drawable.ic_delete_24dp,
+                service.getString(R.string.notification_action_stop_v2ray),
+                stopV2RayPendingIntent
+            )
+            .addAction(
+                R.drawable.ic_delete_24dp,
+                service.getString(R.string.title_service_restart),
+                restartV2RayPendingIntent
+            )
+
+        //mBuilder?.setDefaults(NotificationCompat.FLAG_ONLY_ALERT_ONCE)
+
+        service.startForeground(NOTIFICATION_ID, mBuilder?.build())
+    }
+
+    /**
+     * Cancels the notification.
+     */
+    fun cancelNotification() {
+        val service = getService() ?: return
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
+            service.stopForeground(Service.STOP_FOREGROUND_REMOVE)
+        } else {
+            service.stopForeground(true)
+        }
+
+        mBuilder = null
+        speedNotificationJob?.cancel()
+        speedNotificationJob = null
+        mNotificationManager = null
+    }
+
+    /**
+     * Stops the speed notification.
+     * @param currentConfig The current profile configuration.
+     */
+    fun stopSpeedNotification(currentConfig: ProfileItem?) {
+        speedNotificationJob?.let {
+            it.cancel()
+            speedNotificationJob = null
+            updateNotification(currentConfig?.remarks, 0, 0)
+        }
+    }
+
+    /**
+     * Creates a notification channel for Android O and above.
+     * @return The channel ID.
+     */
+    @RequiresApi(Build.VERSION_CODES.O)
+    private fun createNotificationChannel(): String {
+        val channelId = AppConfig.RAY_NG_CHANNEL_ID
+        val channelName = AppConfig.RAY_NG_CHANNEL_NAME
+        val chan = NotificationChannel(
+            channelId,
+            channelName, NotificationManager.IMPORTANCE_HIGH
+        )
+        chan.lightColor = Color.DKGRAY
+        chan.importance = NotificationManager.IMPORTANCE_NONE
+        chan.lockscreenVisibility = Notification.VISIBILITY_PRIVATE
+        getNotificationManager()?.createNotificationChannel(chan)
+        return channelId
+    }
+
+    /**
+     * Updates the notification with the given content text and traffic data.
+     * @param contentText The content text.
+     * @param proxyTraffic The proxy traffic.
+     * @param directTraffic The direct traffic.
+     */
+    private fun updateNotification(contentText: String?, proxyTraffic: Long, directTraffic: Long) {
+        if (mBuilder != null) {
+            if (proxyTraffic < NOTIFICATION_ICON_THRESHOLD && directTraffic < NOTIFICATION_ICON_THRESHOLD) {
+                mBuilder?.setSmallIcon(R.drawable.ic_stat_name)
+            } else if (proxyTraffic > directTraffic) {
+                mBuilder?.setSmallIcon(R.drawable.ic_stat_proxy)
+            } else {
+                mBuilder?.setSmallIcon(R.drawable.ic_stat_direct)
+            }
+            mBuilder?.setStyle(NotificationCompat.BigTextStyle().bigText(contentText))
+            mBuilder?.setContentText(contentText)
+            getNotificationManager()?.notify(NOTIFICATION_ID, mBuilder?.build())
+        }
+    }
+
+    /**
+     * Gets the notification manager.
+     * @return The notification manager.
+     */
+    private fun getNotificationManager(): NotificationManager? {
+        if (mNotificationManager == null) {
+            val service = getService() ?: return null
+            mNotificationManager = service.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+        }
+        return mNotificationManager
+    }
+
+    /**
+     * Appends the speed string to the given text.
+     * @param text The text to append to.
+     * @param name The name of the tag.
+     * @param up The uplink speed.
+     * @param down The downlink speed.
+     */
+    private fun appendSpeedString(text: StringBuilder, name: String?, up: Double, down: Double) {
+        var n = name ?: "no tag"
+        n = n.substring(0, min(n.length, 6))
+        text.append(n)
+        for (i in n.length..6 step 2) {
+            text.append("\t")
+        }
+        text.append("•  ${up.toLong().toSpeedString()}↑  ${down.toLong().toSpeedString()}↓\n")
+    }
+
+    /**
+     * Gets the service instance.
+     * @return The service instance.
+     */
+    private fun getService(): Service? {
+        return V2RayServiceManager.serviceControl?.get()?.getService()
+    }
+}

+ 141 - 0
app/src/main/java/com/v2ray/ang/handler/PluginServiceManager.kt

@@ -0,0 +1,141 @@
+package com.v2ray.ang.handler
+
+import android.content.Context
+import android.os.SystemClock
+import android.util.Log
+import com.v2ray.ang.AppConfig
+import com.v2ray.ang.dto.EConfigType
+import com.v2ray.ang.dto.ProfileItem
+import com.v2ray.ang.fmt.Hysteria2Fmt
+import com.v2ray.ang.service.ProcessService
+import com.v2ray.ang.util.JsonUtil
+import com.v2ray.ang.util.Utils
+import java.io.File
+
+object PluginServiceManager {
+    private const val HYSTERIA2 = "libhysteria2.so"
+
+    private val procService: ProcessService by lazy {
+        ProcessService()
+    }
+
+    /**
+     * Run the plugin based on the provided configuration.
+     *
+     * @param context The context to use.
+     * @param config The profile configuration.
+     * @param socksPort The port information.
+     */
+    fun runPlugin(context: Context, config: ProfileItem?, socksPort: Int?) {
+        Log.i(AppConfig.TAG, "Starting plugin execution")
+
+        if (config == null) {
+            Log.w(AppConfig.TAG, "Cannot run plugin: config is null")
+            return
+        }
+
+        try {
+            if (config.configType == EConfigType.HYSTERIA2) {
+                if (socksPort == null) {
+                    Log.w(AppConfig.TAG, "Cannot run plugin: socksPort is null")
+                    return
+                }
+                Log.i(AppConfig.TAG, "Running Hysteria2 plugin")
+                val configFile = genConfigHy2(context, config, socksPort) ?: return
+                val cmd = genCmdHy2(context, configFile)
+
+                procService.runProcess(context, cmd)
+            }
+        } catch (e: Exception) {
+            Log.e(AppConfig.TAG, "Error running plugin", e)
+        }
+    }
+
+    /**
+     * Stop the running plugin.
+     */
+    fun stopPlugin() {
+        stopHy2()
+    }
+
+    /**
+     * Perform a real ping using Hysteria2.
+     *
+     * @param context The context to use.
+     * @param config The profile configuration.
+     * @return The ping delay in milliseconds, or -1 if it fails.
+     */
+    fun realPingHy2(context: Context, config: ProfileItem?): Long {
+        Log.i(AppConfig.TAG, "realPingHy2")
+        val retFailure = -1L
+
+        if (config?.configType?.equals(EConfigType.HYSTERIA2) == true) {
+            val socksPort = Utils.findFreePort(listOf(0))
+            val configFile = genConfigHy2(context, config, socksPort) ?: return retFailure
+            val cmd = genCmdHy2(context, configFile)
+
+            val proc = ProcessService()
+            proc.runProcess(context, cmd)
+            Thread.sleep(1000L)
+            val delay = SpeedtestManager.testConnection(context, socksPort)
+            proc.stopProcess()
+
+            return delay.first
+        }
+        return retFailure
+    }
+
+    /**
+     * Generate the configuration file for Hysteria2.
+     *
+     * @param context The context to use.
+     * @param config The profile configuration.
+     * @param socksPort The port information.
+     * @return The generated configuration file.
+     */
+    private fun genConfigHy2(context: Context, config: ProfileItem, socksPort: Int): File? {
+        Log.i(AppConfig.TAG, "runPlugin $HYSTERIA2")
+
+        val hy2Config = Hysteria2Fmt.toNativeConfig(config, socksPort) ?: return null
+
+        val configFile = File(context.noBackupFilesDir, "hy2_${SystemClock.elapsedRealtime()}.json")
+        Log.i(AppConfig.TAG, "runPlugin ${configFile.absolutePath}")
+
+        configFile.parentFile?.mkdirs()
+        configFile.writeText(JsonUtil.toJson(hy2Config))
+        Log.i(AppConfig.TAG, JsonUtil.toJson(hy2Config))
+
+        return configFile
+    }
+
+    /**
+     * Generate the command to run Hysteria2.
+     *
+     * @param context The context to use.
+     * @param configFile The configuration file.
+     * @return The command to run Hysteria2.
+     */
+    private fun genCmdHy2(context: Context, configFile: File): MutableList<String> {
+        return mutableListOf(
+            File(context.applicationInfo.nativeLibraryDir, HYSTERIA2).absolutePath,
+            "--disable-update-check",
+            "--config",
+            configFile.absolutePath,
+            "--log-level",
+            "warn",
+            "client"
+        )
+    }
+
+    /**
+     * Stop the Hysteria2 process.
+     */
+    private fun stopHy2() {
+        try {
+            Log.i(AppConfig.TAG, "$HYSTERIA2 destroy")
+            procService?.stopProcess()
+        } catch (e: Exception) {
+            Log.e(AppConfig.TAG, "Failed to stop Hysteria2 process", e)
+        }
+    }
+}

+ 380 - 0
app/src/main/java/com/v2ray/ang/handler/SettingsManager.kt

@@ -0,0 +1,380 @@
+package com.v2ray.ang.handler
+
+import android.content.Context
+import android.content.res.AssetManager
+import android.text.TextUtils
+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.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.dto.ProfileItem
+import com.v2ray.ang.dto.RoutingType
+import com.v2ray.ang.dto.RulesetItem
+import com.v2ray.ang.dto.V2rayConfig
+import com.v2ray.ang.dto.VpnInterfaceAddressConfig
+import com.v2ray.ang.handler.MmkvManager.decodeServerConfig
+import com.v2ray.ang.handler.MmkvManager.decodeServerList
+import com.v2ray.ang.util.JsonUtil
+import com.v2ray.ang.util.Utils
+import java.io.File
+import java.io.FileOutputStream
+import java.util.Collections
+import java.util.Locale
+
+object SettingsManager {
+
+    /**
+     * Initialize routing rulesets.
+     * @param context The application context.
+     */
+    fun initRoutingRulesets(context: Context) {
+        val exist = MmkvManager.decodeRoutingRulesets()
+        if (exist.isNullOrEmpty()) {
+            val rulesetList = getPresetRoutingRulesets(context)
+            MmkvManager.encodeRoutingRulesets(rulesetList)
+        }
+    }
+
+    /**
+     * Get preset routing rulesets.
+     * @param context The application context.
+     * @param index The index of the routing type.
+     * @return A mutable list of RulesetItem.
+     */
+    private fun getPresetRoutingRulesets(context: Context, index: Int = 0): MutableList<RulesetItem>? {
+        val fileName = RoutingType.fromIndex(index).fileName
+        val assets = Utils.readTextFromAssets(context, fileName)
+        if (TextUtils.isEmpty(assets)) {
+            return null
+        }
+
+        return JsonUtil.fromJson(assets, Array<RulesetItem>::class.java).toMutableList()
+    }
+
+    /**
+     * Reset routing rulesets from presets.
+     * @param context The application context.
+     * @param index The index of the routing type.
+     */
+    fun resetRoutingRulesetsFromPresets(context: Context, index: Int) {
+        val rulesetList = getPresetRoutingRulesets(context, index) ?: return
+        resetRoutingRulesetsCommon(rulesetList)
+    }
+
+    /**
+     * Reset routing rulesets.
+     * @param content The content of the rulesets.
+     * @return True if successful, false otherwise.
+     */
+    fun resetRoutingRulesets(content: String?): Boolean {
+        if (content.isNullOrEmpty()) {
+            return false
+        }
+
+        try {
+            val rulesetList = JsonUtil.fromJson(content, Array<RulesetItem>::class.java).toMutableList()
+            if (rulesetList.isNullOrEmpty()) {
+                return false
+            }
+
+            resetRoutingRulesetsCommon(rulesetList)
+            return true
+        } catch (e: Exception) {
+            Log.e(ANG_PACKAGE, "Failed to reset routing rulesets", e)
+            return false
+        }
+    }
+
+    /**
+     * Common method to reset routing rulesets.
+     * @param rulesetList The list of rulesets.
+     */
+    private fun resetRoutingRulesetsCommon(rulesetList: MutableList<RulesetItem>) {
+        val rulesetNew: MutableList<RulesetItem> = mutableListOf()
+        MmkvManager.decodeRoutingRulesets()?.forEach { key ->
+            if (key.locked == true) {
+                rulesetNew.add(key)
+            }
+        }
+
+        rulesetNew.addAll(rulesetList)
+        MmkvManager.encodeRoutingRulesets(rulesetNew)
+    }
+
+    /**
+     * Get a routing ruleset by index.
+     * @param index The index of the ruleset.
+     * @return The RulesetItem.
+     */
+    fun getRoutingRuleset(index: Int): RulesetItem? {
+        if (index < 0) return null
+
+        val rulesetList = MmkvManager.decodeRoutingRulesets()
+        if (rulesetList.isNullOrEmpty()) return null
+
+        return rulesetList[index]
+    }
+
+    /**
+     * Save a routing ruleset.
+     * @param index The index of the ruleset.
+     * @param ruleset The RulesetItem to save.
+     */
+    fun saveRoutingRuleset(index: Int, ruleset: RulesetItem?) {
+        if (ruleset == null) return
+
+        var rulesetList = MmkvManager.decodeRoutingRulesets()
+        if (rulesetList.isNullOrEmpty()) {
+            rulesetList = mutableListOf()
+        }
+
+        if (index < 0 || index >= rulesetList.count()) {
+            rulesetList.add(0, ruleset)
+        } else {
+            rulesetList[index] = ruleset
+        }
+        MmkvManager.encodeRoutingRulesets(rulesetList)
+    }
+
+    /**
+     * Remove a routing ruleset by index.
+     * @param index The index of the ruleset.
+     */
+    fun removeRoutingRuleset(index: Int) {
+        if (index < 0) return
+
+        val rulesetList = MmkvManager.decodeRoutingRulesets()
+        if (rulesetList.isNullOrEmpty()) return
+
+        rulesetList.removeAt(index)
+        MmkvManager.encodeRoutingRulesets(rulesetList)
+    }
+
+    /**
+     * Check if routing rulesets bypass LAN.
+     * @return True if bypassing LAN, false otherwise.
+     */
+    fun routingRulesetsBypassLan(): Boolean {
+        val vpnBypassLan = MmkvManager.decodeSettingsString(AppConfig.PREF_VPN_BYPASS_LAN) ?: "1"
+        if (vpnBypassLan == "1") {
+            return true
+        } else if (vpnBypassLan == "2") {
+            return false
+        }
+
+        val guid = MmkvManager.getSelectServer() ?: return false
+        val config = decodeServerConfig(guid) ?: return false
+        if (config.configType == EConfigType.CUSTOM) {
+            val raw = MmkvManager.decodeServerRaw(guid) ?: return false
+            val v2rayConfig = JsonUtil.fromJson(raw, V2rayConfig::class.java)
+            val exist = v2rayConfig.routing.rules.filter { it.outboundTag == TAG_DIRECT }.any {
+                it.domain?.contains(GEOSITE_PRIVATE) == true || it.ip?.contains(GEOIP_PRIVATE) == true
+            }
+            return exist == true
+        }
+
+        val rulesetItems = MmkvManager.decodeRoutingRulesets()
+        val exist = rulesetItems?.filter { it.enabled && it.outboundTag == TAG_DIRECT }?.any {
+            it.domain?.contains(GEOSITE_PRIVATE) == true || it.ip?.contains(GEOIP_PRIVATE) == true
+        }
+        return exist == true
+    }
+
+    /**
+     * Swap routing rulesets.
+     * @param fromPosition The position to swap from.
+     * @param toPosition The position to swap to.
+     */
+    fun swapRoutingRuleset(fromPosition: Int, toPosition: Int) {
+        val rulesetList = MmkvManager.decodeRoutingRulesets()
+        if (rulesetList.isNullOrEmpty()) return
+
+        Collections.swap(rulesetList, fromPosition, toPosition)
+        MmkvManager.encodeRoutingRulesets(rulesetList)
+    }
+
+    /**
+     * Swap subscriptions.
+     * @param fromPosition The position to swap from.
+     * @param toPosition The position to swap to.
+     */
+    fun swapSubscriptions(fromPosition: Int, toPosition: Int) {
+        val subsList = MmkvManager.decodeSubsList()
+        if (subsList.isNullOrEmpty()) return
+
+        Collections.swap(subsList, fromPosition, toPosition)
+        MmkvManager.encodeSubsList(subsList)
+    }
+
+    /**
+     * Get server via remarks.
+     * @param remarks The remarks of the server.
+     * @return The ProfileItem.
+     */
+    fun getServerViaRemarks(remarks: String?): ProfileItem? {
+        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
+    }
+
+    /**
+     * Get the SOCKS port.
+     * @return The SOCKS port.
+     */
+    fun getSocksPort(): Int {
+        return Utils.parseInt(MmkvManager.decodeSettingsString(AppConfig.PREF_SOCKS_PORT), AppConfig.PORT_SOCKS.toInt())
+    }
+
+    /**
+     * Get the HTTP port.
+     * @return The HTTP port.
+     */
+    fun getHttpPort(): Int {
+        return getSocksPort() + if (Utils.isXray()) 0 else 1
+    }
+
+    /**
+     * Initialize assets.
+     * @param context The application context.
+     * @param assets The AssetManager.
+     */
+    fun initAssets(context: Context, assets: AssetManager) {
+        val extFolder = Utils.userAssetPath(context)
+
+        try {
+            val geo = arrayOf("geosite.dat", "geoip.dat")
+            assets.list("")
+                ?.filter { geo.contains(it) }
+                ?.filter { !File(extFolder, it).exists() }
+                ?.forEach {
+                    val target = File(extFolder, it)
+                    assets.open(it).use { input ->
+                        FileOutputStream(target).use { output ->
+                            input.copyTo(output)
+                        }
+                    }
+                    Log.i(AppConfig.TAG, "Copied from apk assets folder to ${target.absolutePath}")
+                }
+        } catch (e: Exception) {
+            Log.e(ANG_PACKAGE, "asset copy failed", e)
+        }
+    }
+
+    /**
+     * Get domestic DNS servers from preference.
+     * @return A list of domestic DNS servers.
+     */
+    fun getDomesticDnsServers(): List<String> {
+        val domesticDns =
+            MmkvManager.decodeSettingsString(AppConfig.PREF_DOMESTIC_DNS) ?: AppConfig.DNS_DIRECT
+        val ret = domesticDns.split(",").filter { Utils.isPureIpAddress(it) || Utils.isCoreDNSAddress(it) }
+        if (ret.isEmpty()) {
+            return listOf(AppConfig.DNS_DIRECT)
+        }
+        return ret
+    }
+
+    /**
+     * Get remote DNS servers from preference.
+     * @return A list of remote DNS servers.
+     */
+    fun getRemoteDnsServers(): List<String> {
+        val remoteDns =
+            MmkvManager.decodeSettingsString(AppConfig.PREF_REMOTE_DNS) ?: AppConfig.DNS_PROXY
+        val ret = remoteDns.split(",").filter { Utils.isPureIpAddress(it) || Utils.isCoreDNSAddress(it) }
+        if (ret.isEmpty()) {
+            return listOf(AppConfig.DNS_PROXY)
+        }
+        return ret
+    }
+
+    /**
+     * Get VPN DNS servers from preference.
+     * @return A list of VPN DNS servers.
+     */
+    fun getVpnDnsServers(): List<String> {
+        val vpnDns = MmkvManager.decodeSettingsString(AppConfig.PREF_VPN_DNS) ?: AppConfig.DNS_VPN
+        return vpnDns.split(",").filter { Utils.isPureIpAddress(it) }
+    }
+
+    /**
+     * Get delay test URL.
+     * @param second Whether to use the second URL.
+     * @return The delay test URL.
+     */
+    fun getDelayTestUrl(second: Boolean = false): String {
+        return if (second) {
+            AppConfig.DELAY_TEST_URL2
+        } else {
+            MmkvManager.decodeSettingsString(AppConfig.PREF_DELAY_TEST_URL)
+                ?: AppConfig.DELAY_TEST_URL
+        }
+    }
+
+    /**
+     * Get the locale.
+     * @return The locale.
+     */
+    fun getLocale(): Locale {
+        val langCode =
+            MmkvManager.decodeSettingsString(AppConfig.PREF_LANGUAGE) ?: Language.AUTO.code
+        val language = Language.fromCode(langCode)
+
+        return when (language) {
+            Language.AUTO -> Utils.getSysLocale()
+            Language.ENGLISH -> Locale.ENGLISH
+            Language.CHINA -> Locale.CHINA
+            Language.TRADITIONAL_CHINESE -> Locale.TRADITIONAL_CHINESE
+            Language.VIETNAMESE -> Locale("vi")
+            Language.RUSSIAN -> Locale("ru")
+            Language.PERSIAN -> Locale("fa")
+            Language.ARABIC -> Locale("ar")
+            Language.BANGLA -> Locale("bn")
+            Language.BAKHTIARI -> Locale("bqi", "IR")
+        }
+    }
+
+    /**
+     * Set night mode.
+     */
+    fun setNightMode() {
+        when (MmkvManager.decodeSettingsString(AppConfig.PREF_UI_MODE_NIGHT, "0")) {
+            "0" -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM)
+            "1" -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO)
+            "2" -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES)
+        }
+    }
+
+    /**
+     * Retrieves the currently selected VPN interface address configuration.
+     * This method reads the user's preference for VPN interface addressing and returns
+     * the corresponding configuration containing IPv4 and IPv6 addresses.
+     *
+     * @return The selected VpnInterfaceAddressConfig instance, or the default configuration
+     *         if no valid selection is found or if the stored index is invalid.
+     */
+    fun getCurrentVpnInterfaceAddressConfig(): VpnInterfaceAddressConfig {
+        val selectedIndex = MmkvManager.decodeSettingsString(AppConfig.PREF_VPN_INTERFACE_ADDRESS_CONFIG_INDEX, "0")?.toInt()
+        return VpnInterfaceAddressConfig.getConfigByIndex(selectedIndex ?: 0)
+    }
+
+    /**
+     * Get the VPN MTU from settings, defaulting to AppConfig.VPN_MTU.
+     */
+    fun getVpnMtu(): Int {
+        return Utils.parseInt(MmkvManager.decodeSettingsString(AppConfig.PREF_VPN_MTU), AppConfig.VPN_MTU)
+    }
+}

+ 189 - 0
app/src/main/java/com/v2ray/ang/handler/SpeedtestManager.kt

@@ -0,0 +1,189 @@
+package com.v2ray.ang.handler
+
+import android.content.Context
+import android.os.SystemClock
+import android.text.TextUtils
+import android.util.Log
+import com.v2ray.ang.AppConfig
+import com.v2ray.ang.R
+import com.v2ray.ang.dto.IPAPIInfo
+import com.v2ray.ang.extension.responseLength
+import com.v2ray.ang.util.HttpUtil
+import com.v2ray.ang.util.JsonUtil
+import kotlinx.coroutines.isActive
+import libv2ray.Libv2ray
+import java.io.IOException
+import java.net.InetSocketAddress
+import java.net.Socket
+import java.net.UnknownHostException
+import kotlin.coroutines.coroutineContext
+
+object SpeedtestManager {
+
+    private val tcpTestingSockets = ArrayList<Socket?>()
+
+    /**
+     * Measures the TCP connection time to a given URL and port.
+     *
+     * @param url The URL to connect to.
+     * @param port The port to connect to.
+     * @return The connection time in milliseconds, or -1 if the connection failed.
+     */
+    suspend fun tcping(url: String, port: Int): Long {
+        var time = -1L
+        for (k in 0 until 2) {
+            val one = socketConnectTime(url, port)
+            if (!coroutineContext.isActive) {
+                break
+            }
+            if (one != -1L && (time == -1L || one < time)) {
+                time = one
+            }
+        }
+        return time
+    }
+
+    /**
+     * Measures the real ping time using the V2Ray library.
+     *
+     * @param config The configuration string for the V2Ray library.
+     * @return The ping time in milliseconds, or -1 if the ping failed.
+     */
+    fun realPing(config: String): Long {
+        return try {
+            Libv2ray.measureOutboundDelay(config, SettingsManager.getDelayTestUrl())
+        } catch (e: Exception) {
+            Log.e(AppConfig.TAG, "Failed to measure outbound delay", e)
+            -1L
+        }
+    }
+
+    /**
+     * Measures the ping time to a given URL using the system ping command.
+     *
+     * @param url The URL to ping.
+     * @return The ping time in milliseconds as a string, or "-1ms" if the ping failed.
+     */
+    fun ping(url: String): String {
+        try {
+            val command = "/system/bin/ping -c 3 $url"
+            val process = Runtime.getRuntime().exec(command)
+            val allText = process.inputStream.bufferedReader().use { it.readText() }
+            if (!TextUtils.isEmpty(allText)) {
+                val tempInfo = allText.substring(allText.indexOf("min/avg/max/mdev") + 19)
+                val temps =
+                    tempInfo.split("/".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()
+                if (temps.count() > 0 && temps[0].length < 10) {
+                    return temps[0].toFloat().toInt().toString() + "ms"
+                }
+            }
+        } catch (e: Exception) {
+            Log.e(AppConfig.TAG, "Failed to ping URL: $url", e)
+        }
+        return "-1ms"
+    }
+
+    /**
+     * Measures the time taken to establish a TCP connection to a given URL and port.
+     *
+     * @param url The URL to connect to.
+     * @param port The port to connect to.
+     * @return The connection time in milliseconds, or -1 if the connection failed.
+     */
+    fun socketConnectTime(url: String, port: Int): Long {
+        try {
+            val socket = Socket()
+            synchronized(this) {
+                tcpTestingSockets.add(socket)
+            }
+            val start = System.currentTimeMillis()
+            socket.connect(InetSocketAddress(url, port), 3000)
+            val time = System.currentTimeMillis() - start
+            synchronized(this) {
+                tcpTestingSockets.remove(socket)
+            }
+            socket.close()
+            return time
+        } catch (e: UnknownHostException) {
+            Log.e(AppConfig.TAG, "Unknown host: $url", e)
+        } catch (e: IOException) {
+            Log.e(AppConfig.TAG, "socketConnectTime IOException: $e")
+        } catch (e: Exception) {
+            Log.e(AppConfig.TAG, "Failed to establish socket connection to $url:$port", e)
+        }
+        return -1
+    }
+
+    /**
+     * Closes all TCP sockets that are currently being tested.
+     */
+    fun closeAllTcpSockets() {
+        synchronized(this) {
+            tcpTestingSockets.forEach {
+                it?.close()
+            }
+            tcpTestingSockets.clear()
+        }
+    }
+
+    /**
+     * Tests the connection to a given URL and port.
+     *
+     * @param context The Context in which the test is running.
+     * @param port The port to connect to.
+     * @return A pair containing the elapsed time in milliseconds and the result message.
+     */
+    fun testConnection(context: Context, port: Int): Pair<Long, String> {
+        var result: String
+        var elapsed = -1L
+
+        val conn = HttpUtil.createProxyConnection(SettingsManager.getDelayTestUrl(), port, 15000, 15000) ?: return Pair(elapsed, "")
+        try {
+            val start = SystemClock.elapsedRealtime()
+            val code = conn.responseCode
+            elapsed = SystemClock.elapsedRealtime() - start
+
+            if (code == 204 || code == 200 && conn.responseLength == 0L) {
+                result = context.getString(R.string.connection_test_available, elapsed)
+            } else {
+                throw IOException(
+                    context.getString(
+                        R.string.connection_test_error_status_code,
+                        code
+                    )
+                )
+            }
+        } catch (e: IOException) {
+            Log.e(AppConfig.TAG, "Connection test IOException", e)
+            result = context.getString(R.string.connection_test_error, e.message)
+        } catch (e: Exception) {
+            Log.e(AppConfig.TAG, "Connection test Exception", e)
+            result = context.getString(R.string.connection_test_error, e.message)
+        } finally {
+            conn.disconnect()
+        }
+
+        return Pair(elapsed, result)
+    }
+
+    fun getRemoteIPInfo(): String? {
+        val httpPort = SettingsManager.getHttpPort()
+        var content = HttpUtil.getUrlContent(AppConfig.IP_API_URL, 5000, httpPort) ?: return null
+
+        var ipInfo = JsonUtil.fromJson(content, IPAPIInfo::class.java) ?: return null
+        var ip = ipInfo.ip ?: ipInfo.clientIp ?: ipInfo.ip_addr ?: ipInfo.query
+        var country = ipInfo.country_code ?: ipInfo.country ?: ipInfo.countryCode
+
+        return "(${country ?: "unknown"}) $ip"
+    }
+
+    /**
+     * Gets the version of the V2Ray library.
+     *
+     * @return The version of the V2Ray library.
+     */
+    fun getLibVersion(): String {
+        return Libv2ray.checkVersionX()
+    }
+
+}

+ 63 - 0
app/src/main/java/com/v2ray/ang/handler/SubscriptionUpdater.kt

@@ -0,0 +1,63 @@
+package com.v2ray.ang.handler
+
+import android.annotation.SuppressLint
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.content.Context
+import android.os.Build
+import android.util.Log
+import androidx.core.app.NotificationCompat
+import androidx.core.app.NotificationManagerCompat
+import androidx.work.CoroutineWorker
+import androidx.work.WorkerParameters
+import com.v2ray.ang.AppConfig
+import com.v2ray.ang.R
+
+object SubscriptionUpdater {
+
+    class UpdateTask(context: Context, params: WorkerParameters) :
+        CoroutineWorker(context, params) {
+
+        private val notificationManager = NotificationManagerCompat.from(applicationContext)
+        private val notification =
+            NotificationCompat.Builder(applicationContext, AppConfig.SUBSCRIPTION_UPDATE_CHANNEL)
+                .setWhen(0)
+                .setTicker("Update")
+                .setContentTitle(context.getString(R.string.title_pref_auto_update_subscription))
+                .setSmallIcon(R.drawable.ic_stat_name)
+                .setCategory(NotificationCompat.CATEGORY_SERVICE)
+                .setPriority(NotificationCompat.PRIORITY_DEFAULT)
+
+        /**
+         * Performs the subscription update work.
+         * @return The result of the work.
+         */
+        @SuppressLint("MissingPermission")
+        override suspend fun doWork(): Result {
+            Log.i(AppConfig.TAG, "subscription automatic update starting")
+
+            val subs = MmkvManager.decodeSubscriptions().filter { it.second.autoUpdate }
+
+            for (sub in subs) {
+                val subItem = sub.second
+
+                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+                    notification.setChannelId(AppConfig.SUBSCRIPTION_UPDATE_CHANNEL)
+                    val channel =
+                        NotificationChannel(
+                            AppConfig.SUBSCRIPTION_UPDATE_CHANNEL,
+                            AppConfig.SUBSCRIPTION_UPDATE_CHANNEL_NAME,
+                            NotificationManager.IMPORTANCE_MIN
+                        )
+                    notificationManager.createNotificationChannel(channel)
+                }
+                notificationManager.notify(3, notification.build())
+                Log.i(AppConfig.TAG, "subscription automatic update: ---${subItem.remarks}")
+                AngConfigManager.updateConfigViaSub(Pair(sub.first, subItem))
+                notification.setContentText("Updating ${subItem.remarks}")
+            }
+            notificationManager.cancel(3)
+            return Result.success()
+        }
+    }
+}

+ 107 - 0
app/src/main/java/com/v2ray/ang/handler/UpdateCheckerManager.kt

@@ -0,0 +1,107 @@
+package com.v2ray.ang.handler
+
+import android.content.Context
+import android.os.Build
+import android.util.Log
+import com.v2ray.ang.AppConfig
+import com.v2ray.ang.BuildConfig
+import com.v2ray.ang.dto.CheckUpdateResult
+import com.v2ray.ang.dto.GitHubRelease
+import com.v2ray.ang.extension.concatUrl
+import com.v2ray.ang.util.HttpUtil
+import com.v2ray.ang.util.JsonUtil
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import java.io.File
+import java.io.FileOutputStream
+
+object UpdateCheckerManager {
+    suspend fun checkForUpdate(includePreRelease: Boolean = false): CheckUpdateResult = withContext(Dispatchers.IO) {
+            val url = if (includePreRelease) {
+                AppConfig.APP_API_URL
+            } else {
+                AppConfig.APP_API_URL.concatUrl("latest")
+            }
+
+            var response = HttpUtil.getUrlContent(url, 5000)
+            if (response.isNullOrEmpty()) {
+                val httpPort = SettingsManager.getHttpPort()
+                response = HttpUtil.getUrlContent(url, 5000, httpPort) ?: throw IllegalStateException("Failed to get response")
+            }
+
+            val latestRelease = if (includePreRelease) {
+                JsonUtil.fromJson(response, Array<GitHubRelease>::class.java)
+                    .firstOrNull()
+                    ?: throw IllegalStateException("No pre-release found")
+            } else {
+                JsonUtil.fromJson(response, GitHubRelease::class.java)
+            }
+
+            val latestVersion = latestRelease.tagName.removePrefix("v")
+            Log.i(AppConfig.TAG, "Found new version: $latestVersion (current: ${BuildConfig.VERSION_NAME})")
+
+            return@withContext if (compareVersions(latestVersion, BuildConfig.VERSION_NAME) > 0) {
+                val downloadUrl = getDownloadUrl(latestRelease, Build.SUPPORTED_ABIS[0])
+                CheckUpdateResult(
+                    hasUpdate = true,
+                    latestVersion = latestVersion,
+                    releaseNotes = latestRelease.body,
+                    downloadUrl = downloadUrl,
+                    isPreRelease = latestRelease.prerelease
+                )
+            } else {
+                CheckUpdateResult(hasUpdate = false)
+            }
+    }
+
+    suspend fun downloadApk(context: Context, downloadUrl: String): File? = withContext(Dispatchers.IO) {
+        try {
+            val httpPort = SettingsManager.getHttpPort()
+            val connection = HttpUtil.createProxyConnection(downloadUrl, httpPort, 10000, 10000, true)
+                ?: throw IllegalStateException("Failed to create connection")
+
+            try {
+                val apkFile = File(context.cacheDir, "update.apk")
+                Log.i(AppConfig.TAG, "Downloading APK to: ${apkFile.absolutePath}")
+
+                FileOutputStream(apkFile).use { outputStream ->
+                    connection.inputStream.use { inputStream ->
+                        inputStream.copyTo(outputStream)
+                    }
+                }
+                Log.i(AppConfig.TAG, "APK download completed")
+                return@withContext apkFile
+            } catch (e: Exception) {
+                Log.e(AppConfig.TAG, "Failed to download APK: ${e.message}")
+                return@withContext null
+            } finally {
+                try {
+                    connection.disconnect()
+                } catch (e: Exception) {
+                    Log.e(AppConfig.TAG, "Error closing connection: ${e.message}")
+                }
+            }
+        } catch (e: Exception) {
+            Log.e(AppConfig.TAG, "Failed to initiate download: ${e.message}")
+            return@withContext null
+        }
+    }
+
+    private fun compareVersions(version1: String, version2: String): Int {
+        val v1 = version1.split(".")
+        val v2 = version2.split(".")
+
+        for (i in 0 until maxOf(v1.size, v2.size)) {
+            val num1 = if (i < v1.size) v1[i].toInt() else 0
+            val num2 = if (i < v2.size) v2[i].toInt() else 0
+            if (num1 != num2) return num1 - num2
+        }
+        return 0
+    }
+
+    private fun getDownloadUrl(release: GitHubRelease, abi: String): String {
+        return release.assets.find { it.name.contains(abi) }?.browserDownloadUrl
+            ?: release.assets.firstOrNull()?.browserDownloadUrl
+            ?: throw IllegalStateException("No compatible APK found")
+    }
+}

+ 376 - 0
app/src/main/java/com/v2ray/ang/handler/V2RayServiceManager.kt

@@ -0,0 +1,376 @@
+package com.v2ray.ang.handler
+
+import android.app.Service
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.os.Build
+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.dto.ProfileItem
+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
+import com.v2ray.ang.handler.PluginServiceManager
+import com.v2ray.ang.util.Utils
+import go.Seq
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import libv2ray.CoreCallbackHandler
+import libv2ray.CoreController
+import libv2ray.Libv2ray
+import java.lang.ref.SoftReference
+
+object V2RayServiceManager {
+
+    private val coreController: CoreController = Libv2ray.newCoreController(CoreCallback())
+    private val mMsgReceive = ReceiveMessageHandler()
+    private var currentConfig: ProfileItem? = null
+
+    var serviceControl: SoftReference<ServiceControl>? = null
+        set(value) {
+            field = value
+            Seq.setContext(value?.get()?.getService()?.applicationContext)
+            Libv2ray.initCoreEnv(Utils.userAssetPath(value?.get()?.getService()), Utils.getDeviceIdForXUDPBaseKey())
+        }
+
+    /**
+     * Starts the V2Ray service from a toggle action.
+     * @param context The context from which the service is started.
+     * @return True if the service was started successfully, false otherwise.
+     */
+    fun startVServiceFromToggle(context: Context): Boolean {
+        if (MmkvManager.getSelectServer().isNullOrEmpty()) {
+            context.toast(R.string.app_tile_first_use)
+            return false
+        }
+        startContextService(context)
+        return true
+    }
+
+    /**
+     * Starts the V2Ray service.
+     * @param context The context from which the service is started.
+     * @param guid The GUID of the server configuration to use (optional).
+     */
+    fun startVService(context: Context, guid: String? = null) {
+        if (guid != null) {
+            MmkvManager.setSelectServer(guid)
+        }
+        startContextService(context)
+    }
+
+    /**
+     * Stops the V2Ray service.
+     * @param context The context from which the service is stopped.
+     */
+    fun stopVService(context: Context) {
+        context.toast(R.string.toast_services_stop)
+        MessageUtil.sendMsg2Service(context, AppConfig.MSG_STATE_STOP, "")
+    }
+
+    /**
+     * Checks if the V2Ray service is running.
+     * @return True if the service is running, false otherwise.
+     */
+    fun isRunning() = coreController.isRunning
+
+    /**
+     * Gets the name of the currently running server.
+     * @return The name of the running server.
+     */
+    fun getRunningServerName() = currentConfig?.remarks.orEmpty()
+
+    /**
+     * Starts the context service for V2Ray.
+     * Chooses between VPN service or Proxy-only service based on user settings.
+     * @param context The context from which the service is started.
+     */
+    private fun startContextService(context: Context) {
+        if (coreController.isRunning) {
+            return
+        }
+        val guid = MmkvManager.getSelectServer() ?: return
+        val config = MmkvManager.decodeServerConfig(guid) ?: return
+        if (config.configType != EConfigType.CUSTOM
+            && !Utils.isValidUrl(config.server)
+            && !Utils.isPureIpAddress(config.server.orEmpty())
+        ) return
+//        val result = V2rayConfigUtil.getV2rayConfig(context, guid)
+//        if (!result.status) return
+
+        if (MmkvManager.decodeSettingsBool(AppConfig.PREF_PROXY_SHARING) == true) {
+            context.toast(R.string.toast_warning_pref_proxysharing_short)
+        } else {
+            context.toast(R.string.toast_services_start)
+        }
+        val intent = if ((MmkvManager.decodeSettingsString(AppConfig.PREF_MODE) ?: AppConfig.VPN) == AppConfig.VPN) {
+            Intent(context.applicationContext, V2RayVpnService::class.java)
+        } else {
+            Intent(context.applicationContext, V2RayProxyOnlyService::class.java)
+        }
+        if (Build.VERSION.SDK_INT > Build.VERSION_CODES.N_MR1) {
+            context.startForegroundService(intent)
+        } else {
+            context.startService(intent)
+        }
+    }
+
+    /**
+     * Refer to the official documentation for [registerReceiver](https://developer.android.com/reference/androidx/core/content/ContextCompat#registerReceiver(android.content.Context,android.content.BroadcastReceiver,android.content.IntentFilter,int):
+     * `registerReceiver(Context, BroadcastReceiver, IntentFilter, int)`.
+     * Starts the V2Ray core service.
+     */
+    fun startCoreLoop(): Boolean {
+        if (coreController.isRunning) {
+            return false
+        }
+
+        val service = getService() ?: return false
+        val guid = MmkvManager.getSelectServer() ?: return false
+        val config = MmkvManager.decodeServerConfig(guid) ?: return false
+        val result = V2rayConfigManager.getV2rayConfig(service, guid)
+        if (!result.status)
+            return false
+
+        try {
+            val mFilter = IntentFilter(AppConfig.BROADCAST_ACTION_SERVICE)
+            mFilter.addAction(Intent.ACTION_SCREEN_ON)
+            mFilter.addAction(Intent.ACTION_SCREEN_OFF)
+            mFilter.addAction(Intent.ACTION_USER_PRESENT)
+            ContextCompat.registerReceiver(service, mMsgReceive, mFilter, Utils.receiverFlags())
+        } catch (e: Exception) {
+            Log.e(AppConfig.TAG, "Failed to register broadcast receiver", e)
+            return false
+        }
+
+        currentConfig = config
+
+        try {
+            coreController.startLoop(result.content)
+        } catch (e: Exception) {
+            Log.e(AppConfig.TAG, "Failed to start Core loop", e)
+            return false
+        }
+
+        if (coreController.isRunning == false) {
+            MessageUtil.sendMsg2UI(service, AppConfig.MSG_STATE_START_FAILURE, "")
+            NotificationManager.cancelNotification()
+            return false
+        }
+
+        try {
+            MessageUtil.sendMsg2UI(service, AppConfig.MSG_STATE_START_SUCCESS, "")
+            NotificationManager.showNotification(currentConfig)
+            NotificationManager.startSpeedNotification(currentConfig)
+
+            PluginServiceManager.runPlugin(service, config, result.socksPort)
+        } catch (e: Exception) {
+            Log.e(AppConfig.TAG, "Failed to startup service", e)
+            return false
+        }
+        return true
+    }
+
+    /**
+     * Stops the V2Ray core service.
+     * Unregisters broadcast receivers, stops notifications, and shuts down plugins.
+     * @return True if the core was stopped successfully, false otherwise.
+     */
+    fun stopCoreLoop(): Boolean {
+        val service = getService() ?: return false
+
+        if (coreController.isRunning) {
+            CoroutineScope(Dispatchers.IO).launch {
+                try {
+                    coreController.stopLoop()
+                } catch (e: Exception) {
+                    Log.e(AppConfig.TAG, "Failed to stop V2Ray loop", e)
+                }
+            }
+        }
+
+        MessageUtil.sendMsg2UI(service, AppConfig.MSG_STATE_STOP_SUCCESS, "")
+        NotificationManager.cancelNotification()
+
+        try {
+            service.unregisterReceiver(mMsgReceive)
+        } catch (e: Exception) {
+            Log.e(AppConfig.TAG, "Failed to unregister broadcast receiver", e)
+        }
+        PluginServiceManager.stopPlugin()
+
+        return true
+    }
+
+    /**
+     * Queries the statistics for a given tag and link.
+     * @param tag The tag to query.
+     * @param link The link to query.
+     * @return The statistics value.
+     */
+    fun queryStats(tag: String, link: String): Long {
+        return coreController.queryStats(tag, link)
+    }
+
+    /**
+     * Measures the connection delay for the current V2Ray configuration.
+     * Tests with primary URL first, then falls back to alternative URL if needed.
+     * Also fetches remote IP information if the delay test was successful.
+     */
+    private fun measureV2rayDelay() {
+        if (coreController.isRunning == false) {
+            return
+        }
+
+        CoroutineScope(Dispatchers.IO).launch {
+            val service = getService() ?: return@launch
+            var time = -1L
+            var errorStr = ""
+
+            try {
+                time = coreController.measureDelay(SettingsManager.getDelayTestUrl())
+            } catch (e: Exception) {
+                Log.e(AppConfig.TAG, "Failed to measure delay with primary URL", e)
+                errorStr = e.message?.substringAfter("\":") ?: "empty message"
+            }
+            if (time == -1L) {
+                try {
+                    time = coreController.measureDelay(SettingsManager.getDelayTestUrl(true))
+                } catch (e: Exception) {
+                    Log.e(AppConfig.TAG, "Failed to measure delay with alternative URL", e)
+                    errorStr = e.message?.substringAfter("\":") ?: "empty message"
+                }
+            }
+
+            val result = if (time >= 0) {
+                service.getString(R.string.connection_test_available, time)
+            } else {
+                service.getString(R.string.connection_test_error, errorStr)
+            }
+            MessageUtil.sendMsg2UI(service, AppConfig.MSG_MEASURE_DELAY_SUCCESS, result)
+
+            // Only fetch IP info if the delay test was successful
+            if (time >= 0) {
+                SpeedtestManager.getRemoteIPInfo()?.let { ip ->
+                    MessageUtil.sendMsg2UI(service, AppConfig.MSG_MEASURE_DELAY_SUCCESS, "$result\n$ip")
+                }
+            }
+        }
+    }
+
+    /**
+     * Gets the current service instance.
+     * @return The current service instance, or null if not available.
+     */
+    private fun getService(): Service? {
+        return serviceControl?.get()?.getService()
+    }
+
+    /**
+     * Core callback handler implementation for handling V2Ray core events.
+     * Handles startup, shutdown, socket protection, and status emission.
+     */
+    private class CoreCallback : CoreCallbackHandler {
+        /**
+         * Called when V2Ray core starts up.
+         * @return 0 for success, any other value for failure.
+         */
+        override fun startup(): Long {
+            return 0
+        }
+
+        /**
+         * Called when V2Ray core shuts down.
+         * @return 0 for success, any other value for failure.
+         */
+        override fun shutdown(): Long {
+            val serviceControl = serviceControl?.get() ?: return -1
+            return try {
+                serviceControl.stopService()
+                0
+            } catch (e: Exception) {
+                Log.e(AppConfig.TAG, "Failed to stop service in callback", e)
+                -1
+            }
+        }
+
+        /**
+         * Called when V2Ray core emits status information.
+         * @param l Status code.
+         * @param s Status message.
+         * @return Always returns 0.
+         */
+        override fun onEmitStatus(l: Long, s: String?): Long {
+            return 0
+        }
+    }
+
+    /**
+     * Broadcast receiver for handling messages sent to the service.
+     * Handles registration, service control, and screen events.
+     */
+    private class ReceiveMessageHandler : BroadcastReceiver() {
+        /**
+         * Handles received broadcast messages.
+         * Processes service control messages and screen state changes.
+         * @param ctx The context in which the receiver is running.
+         * @param intent The intent being received.
+         */
+        override fun onReceive(ctx: Context?, intent: Intent?) {
+            val serviceControl = serviceControl?.get() ?: return
+            when (intent?.getIntExtra("key", 0)) {
+                AppConfig.MSG_REGISTER_CLIENT -> {
+                    if (coreController.isRunning) {
+                        MessageUtil.sendMsg2UI(serviceControl.getService(), AppConfig.MSG_STATE_RUNNING, "")
+                    } else {
+                        MessageUtil.sendMsg2UI(serviceControl.getService(), AppConfig.MSG_STATE_NOT_RUNNING, "")
+                    }
+                }
+
+                AppConfig.MSG_UNREGISTER_CLIENT -> {
+                    // nothing to do
+                }
+
+                AppConfig.MSG_STATE_START -> {
+                    // nothing to do
+                }
+
+                AppConfig.MSG_STATE_STOP -> {
+                    Log.i(AppConfig.TAG, "Stop Service")
+                    serviceControl.stopService()
+                }
+
+                AppConfig.MSG_STATE_RESTART -> {
+                    Log.i(AppConfig.TAG, "Restart Service")
+                    serviceControl.stopService()
+                    Thread.sleep(500L)
+                    startVService(serviceControl.getService())
+                }
+
+                AppConfig.MSG_MEASURE_DELAY -> {
+                    measureV2rayDelay()
+                }
+            }
+
+            when (intent?.action) {
+                Intent.ACTION_SCREEN_OFF -> {
+                    Log.i(AppConfig.TAG, "SCREEN_OFF, stop querying stats")
+                    NotificationManager.stopSpeedNotification(currentConfig)
+                }
+
+                Intent.ACTION_SCREEN_ON -> {
+                    Log.i(AppConfig.TAG, "SCREEN_ON, start querying stats")
+                    NotificationManager.startSpeedNotification(currentConfig)
+                }
+            }
+        }
+    }
+}

+ 1278 - 0
app/src/main/java/com/v2ray/ang/handler/V2rayConfigManager.kt

@@ -0,0 +1,1278 @@
+package com.v2ray.ang.handler
+
+import android.content.Context
+import android.text.TextUtils
+import android.util.Log
+import com.v2ray.ang.AppConfig
+import com.v2ray.ang.R
+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
+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.extension.isNotNullEmpty
+import com.v2ray.ang.fmt.CustomFmt
+import com.v2ray.ang.fmt.HttpFmt
+import com.v2ray.ang.fmt.ShadowsocksFmt
+import com.v2ray.ang.fmt.SocksFmt
+import com.v2ray.ang.fmt.TrojanFmt
+import com.v2ray.ang.fmt.VlessFmt
+import com.v2ray.ang.fmt.VmessFmt
+import com.v2ray.ang.fmt.WireguardFmt
+import com.v2ray.ang.util.HttpUtil
+import com.v2ray.ang.util.JsonUtil
+import com.v2ray.ang.util.Utils
+
+object V2rayConfigManager {
+    private var initConfigCache: String? = null
+
+    //region get config function
+
+    /**
+     * Retrieves the V2ray configuration for the given GUID.
+     *
+     * @param context The context of the caller.
+     * @param guid The unique identifier for the V2ray configuration.
+     * @return A ConfigResult object containing the configuration details or indicating failure.
+     */
+    fun getV2rayConfig(context: Context, guid: String): ConfigResult {
+        try {
+            val config = MmkvManager.decodeServerConfig(guid) ?: return ConfigResult(false)
+            return if (config.configType == EConfigType.CUSTOM) {
+                getV2rayCustomConfig(guid, config)
+            } else {
+                getV2rayNormalConfig(context, guid, config)
+            }
+        } catch (e: Exception) {
+            Log.e(AppConfig.TAG, "Failed to get V2ray config", e)
+            return ConfigResult(false)
+        }
+    }
+
+    /**
+     * Generates a V2ray configuration from multiple server profiles.
+     *
+     * @param context The context of the caller.
+     * @param guidList A list of server GUIDs to be included in the generated configuration.
+     *                 Each GUID represents a unique server profile stored in the system.
+     * @return A V2rayConfig object containing the combined configuration of all specified servers,
+     *         or null if the operation fails (e.g., no valid configurations found, parsing errors)
+     */
+    fun genV2rayConfig(context: Context, guidList: List<String>): V2rayConfig? {
+        try {
+            val configList = guidList.mapNotNull { guid ->
+                MmkvManager.decodeServerConfig(guid)
+            }
+            return genV2rayMultipleConfig(context, configList)
+        } catch (e: Exception) {
+            Log.e(AppConfig.TAG, "Failed to generate V2ray config", e)
+            return null
+        }
+    }
+
+    /**
+     * Retrieves the speedtest V2ray configuration for the given GUID.
+     *
+     * @param context The context of the caller.
+     * @param guid The unique identifier for the V2ray configuration.
+     * @return A ConfigResult object containing the configuration details or indicating failure.
+     */
+    fun getV2rayConfig4Speedtest(context: Context, guid: String): ConfigResult {
+        try {
+            val config = MmkvManager.decodeServerConfig(guid) ?: return ConfigResult(false)
+            return if (config.configType == EConfigType.CUSTOM) {
+                getV2rayCustomConfig(guid, config)
+            } else {
+                getV2rayNormalConfig4Speedtest(context, guid, config)
+            }
+        } catch (e: Exception) {
+            Log.e(AppConfig.TAG, "Failed to get V2ray config for speedtest", e)
+            return ConfigResult(false)
+        }
+    }
+
+    /**
+     * Retrieves the custom V2ray configuration.
+     *
+     * @param guid The unique identifier for the V2ray configuration.
+     * @param config The profile item containing the configuration details.
+     * @return A ConfigResult object containing the result of the configuration retrieval.
+     */
+    private fun getV2rayCustomConfig(guid: String, config: ProfileItem): ConfigResult {
+        val raw = MmkvManager.decodeServerRaw(guid) ?: return ConfigResult(false)
+        return ConfigResult(true, guid, raw)
+    }
+
+    /**
+     * Retrieves the normal V2ray configuration.
+     *
+     * @param context The context in which the function is called.
+     * @param guid The unique identifier for the V2ray configuration.
+     * @param config The profile item containing the configuration details.
+     * @return A ConfigResult object containing the result of the configuration retrieval.
+     */
+    private fun getV2rayNormalConfig(context: Context, guid: String, config: ProfileItem): ConfigResult {
+        val result = ConfigResult(false)
+
+        val address = config.server ?: return result
+        if (!Utils.isPureIpAddress(address)) {
+            if (!Utils.isValidUrl(address)) {
+                Log.w(AppConfig.TAG, "$address is an invalid ip or domain")
+                return result
+            }
+        }
+
+        val v2rayConfig = initV2rayConfig(context) ?: return result
+        v2rayConfig.log.loglevel = MmkvManager.decodeSettingsString(AppConfig.PREF_LOGLEVEL) ?: "warning"
+        v2rayConfig.remarks = config.remarks
+
+        getInbounds(v2rayConfig)
+
+        if (config.configType == EConfigType.HYSTERIA2) {
+            result.socksPort = getPlusOutbounds(v2rayConfig, config) ?: return result
+        } else {
+            getOutbounds(v2rayConfig, config) ?: return result
+            getMoreOutbounds(v2rayConfig, config.subscriptionId)
+        }
+
+        getRouting(v2rayConfig)
+
+        getFakeDns(v2rayConfig)
+
+        getDns(v2rayConfig)
+
+        if (MmkvManager.decodeSettingsBool(AppConfig.PREF_LOCAL_DNS_ENABLED) == true) {
+            getCustomLocalDns(v2rayConfig)
+        }
+        if (MmkvManager.decodeSettingsBool(AppConfig.PREF_SPEED_ENABLED) != true) {
+            v2rayConfig.stats = null
+            v2rayConfig.policy = null
+        }
+
+        //Resolve and add to DNS Hosts
+        if (MmkvManager.decodeSettingsString(AppConfig.PREF_OUTBOUND_DOMAIN_RESOLVE_METHOD, "1") == "1") {
+            resolveOutboundDomainsToHosts(v2rayConfig)
+        }
+
+        result.status = true
+        result.content = JsonUtil.toJsonPretty(v2rayConfig) ?: ""
+        result.guid = guid
+        return result
+    }
+
+    private fun genV2rayMultipleConfig(context: Context, configList: List<ProfileItem>): V2rayConfig? {
+        val validConfigs = configList.asSequence().filter { it.server.isNotNullEmpty() }
+            .filter { !Utils.isPureIpAddress(it.server!!) || Utils.isValidUrl(it.server!!) }
+            .filter { it.configType != EConfigType.CUSTOM }
+            .filter { it.configType != EConfigType.HYSTERIA2 }
+            .filter { config ->
+                if (config.subscriptionId.isEmpty()) {
+                    return@filter true
+                }
+                val subItem = MmkvManager.decodeSubscription(config.subscriptionId)
+                if (subItem?.intelligentSelectionFilter.isNullOrEmpty() || config.remarks.isEmpty()) {
+                    return@filter true
+                }
+                Regex(pattern = subItem?.intelligentSelectionFilter!!).containsMatchIn(input = config.remarks)
+            }.toList()
+
+        if (validConfigs.isEmpty()) {
+            Log.w(AppConfig.TAG, "All configs are invalid")
+            return null
+        }
+
+        val v2rayConfig = initV2rayConfig(context) ?: return null
+        v2rayConfig.log.loglevel = MmkvManager.decodeSettingsString(AppConfig.PREF_LOGLEVEL) ?: "warning"
+
+        val subIds = configList.map { it.subscriptionId }.toHashSet()
+        val remarks = if (subIds.size == 1 && subIds.first().isNotEmpty()) {
+            val sub = MmkvManager.decodeSubscription(subIds.first())
+            (sub?.remarks ?: "") + context.getString(R.string.intelligent_selection)
+        } else {
+            context.getString(R.string.intelligent_selection)
+        }
+
+        v2rayConfig.remarks = remarks
+
+        getInbounds(v2rayConfig)
+
+        v2rayConfig.outbounds.removeAt(0)
+        val outboundsList = mutableListOf<V2rayConfig.OutboundBean>()
+        var index = 0
+        for (config in validConfigs) {
+            index++
+            val outbound = convertProfile2Outbound(config) ?: continue
+            val ret = updateOutboundWithGlobalSettings(outbound)
+            if (!ret) continue
+            outbound.tag = "proxy-$index"
+            outboundsList.add(outbound)
+        }
+        outboundsList.addAll(v2rayConfig.outbounds)
+        v2rayConfig.outbounds = ArrayList(outboundsList)
+
+        getRouting(v2rayConfig)
+
+        getFakeDns(v2rayConfig)
+
+        getDns(v2rayConfig)
+
+        getBalance(v2rayConfig)
+
+        if (MmkvManager.decodeSettingsBool(AppConfig.PREF_LOCAL_DNS_ENABLED) == true) {
+            getCustomLocalDns(v2rayConfig)
+        }
+        if (MmkvManager.decodeSettingsBool(AppConfig.PREF_SPEED_ENABLED) != true) {
+            v2rayConfig.stats = null
+            v2rayConfig.policy = null
+        }
+
+        //Resolve and add to DNS Hosts
+        if (MmkvManager.decodeSettingsString(AppConfig.PREF_OUTBOUND_DOMAIN_RESOLVE_METHOD, "1") == "1") {
+            resolveOutboundDomainsToHosts(v2rayConfig)
+        }
+
+        return v2rayConfig
+    }
+
+    /**
+     * Retrieves the normal V2ray configuration for speedtest.
+     *
+     * @param context The context in which the function is called.
+     * @param guid The unique identifier for the V2ray configuration.
+     * @param config The profile item containing the configuration details.
+     * @return A ConfigResult object containing the result of the configuration retrieval.
+     */
+    private fun getV2rayNormalConfig4Speedtest(context: Context, guid: String, config: ProfileItem): ConfigResult {
+        val result = ConfigResult(false)
+
+        val address = config.server ?: return result
+        if (!Utils.isPureIpAddress(address)) {
+            if (!Utils.isValidUrl(address)) {
+                Log.w(AppConfig.TAG, "$address is an invalid ip or domain")
+                return result
+            }
+        }
+
+        val v2rayConfig = initV2rayConfig(context) ?: return result
+
+        if (config.configType == EConfigType.HYSTERIA2) {
+            result.socksPort = getPlusOutbounds(v2rayConfig, config) ?: return result
+        } else {
+            getOutbounds(v2rayConfig, config) ?: return result
+            getMoreOutbounds(v2rayConfig, config.subscriptionId)
+        }
+
+        v2rayConfig.log.loglevel = MmkvManager.decodeSettingsString(AppConfig.PREF_LOGLEVEL) ?: "warning"
+        v2rayConfig.inbounds.clear()
+        v2rayConfig.routing.rules.clear()
+        v2rayConfig.dns = null
+        v2rayConfig.fakedns = null
+        v2rayConfig.stats = null
+        v2rayConfig.policy = null
+
+        v2rayConfig.outbounds.forEach { key ->
+            key.mux = null
+        }
+
+        result.status = true
+        result.content = JsonUtil.toJsonPretty(v2rayConfig) ?: ""
+        result.guid = guid
+        return result
+    }
+
+    /**
+     * Initializes V2ray configuration.
+     *
+     * This function loads the V2ray configuration from assets or from a cached value.
+     * It first attempts to use the cached configuration if available, otherwise reads
+     * the configuration from the "v2ray_config.json" asset file.
+     *
+     * @param context Android context used to access application assets
+     * @return V2rayConfig object parsed from the JSON configuration, or null if the configuration is empty
+     */
+    private fun initV2rayConfig(context: Context): V2rayConfig? {
+        val 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
+    }
+
+
+    //endregion
+
+
+    //region some sub function
+
+    /**
+     * Configures the inbound settings for V2ray.
+     *
+     * This function sets up the listening ports, sniffing options, and other inbound-related configurations.
+     *
+     * @param v2rayConfig The V2ray configuration object to be modified
+     * @return true if inbound configuration was successful, false otherwise
+     */
+    private fun getInbounds(v2rayConfig: V2rayConfig): Boolean {
+        try {
+            val socksPort = SettingsManager.getSocksPort()
+
+            v2rayConfig.inbounds.forEach { curInbound ->
+                if (MmkvManager.decodeSettingsBool(AppConfig.PREF_PROXY_SHARING) != true) {
+                    //bind all inbounds to localhost if the user requests
+                    curInbound.listen = AppConfig.LOOPBACK
+                }
+            }
+            v2rayConfig.inbounds[0].port = socksPort
+            val fakedns = MmkvManager.decodeSettingsBool(AppConfig.PREF_FAKE_DNS_ENABLED) == true
+            val sniffAllTlsAndHttp =
+                MmkvManager.decodeSettingsBool(AppConfig.PREF_SNIFFING_ENABLED, true) != false
+            v2rayConfig.inbounds[0].sniffing?.enabled = fakedns || sniffAllTlsAndHttp
+            v2rayConfig.inbounds[0].sniffing?.routeOnly =
+                MmkvManager.decodeSettingsBool(AppConfig.PREF_ROUTE_ONLY_ENABLED, false)
+            if (!sniffAllTlsAndHttp) {
+                v2rayConfig.inbounds[0].sniffing?.destOverride?.clear()
+            }
+            if (fakedns) {
+                v2rayConfig.inbounds[0].sniffing?.destOverride?.add("fakedns")
+            }
+
+            if (Utils.isXray()) {
+                v2rayConfig.inbounds.removeAt(1)
+            } else {
+                val httpPort = SettingsManager.getHttpPort()
+                v2rayConfig.inbounds[1].port = httpPort
+            }
+
+        } catch (e: Exception) {
+            Log.e(AppConfig.TAG, "Failed to configure inbounds", e)
+            return false
+        }
+        return true
+    }
+
+    /**
+     * Configures the fake DNS settings if enabled.
+     *
+     * Adds FakeDNS configuration to v2rayConfig if both local DNS and fake DNS are enabled.
+     *
+     * @param v2rayConfig The V2ray configuration object to be modified
+     */
+    private fun getFakeDns(v2rayConfig: V2rayConfig) {
+        if (MmkvManager.decodeSettingsBool(AppConfig.PREF_LOCAL_DNS_ENABLED) == true
+            && MmkvManager.decodeSettingsBool(AppConfig.PREF_FAKE_DNS_ENABLED) == true
+        ) {
+            v2rayConfig.fakedns = listOf(V2rayConfig.FakednsBean())
+        }
+    }
+
+    /**
+     * Configures routing settings for V2ray.
+     *
+     * Sets up the domain strategy and adds routing rules from saved rulesets.
+     *
+     * @param v2rayConfig The V2ray configuration object to be modified
+     * @return true if routing configuration was successful, false otherwise
+     */
+    private fun getRouting(v2rayConfig: V2rayConfig): Boolean {
+        try {
+
+            v2rayConfig.routing.domainStrategy =
+                MmkvManager.decodeSettingsString(AppConfig.PREF_ROUTING_DOMAIN_STRATEGY)
+                    ?: "AsIs"
+
+            val rulesetItems = MmkvManager.decodeRoutingRulesets()
+            rulesetItems?.forEach { key ->
+                getRoutingUserRule(key, v2rayConfig)
+            }
+        } catch (e: Exception) {
+            Log.e(AppConfig.TAG, "Failed to configure routing", e)
+            return false
+        }
+        return true
+    }
+
+    /**
+     * Adds a specific ruleset item to the routing configuration.
+     *
+     * @param item The ruleset item to add
+     * @param v2rayConfig The V2ray configuration object to be modified
+     */
+    private fun getRoutingUserRule(item: RulesetItem?, v2rayConfig: V2rayConfig) {
+        try {
+            if (item == null || !item.enabled) {
+                return
+            }
+
+            val rule = JsonUtil.fromJson(JsonUtil.toJson(item), RulesBean::class.java) ?: return
+
+            v2rayConfig.routing.rules.add(rule)
+
+        } catch (e: Exception) {
+            Log.e(AppConfig.TAG, "Failed to apply routing user rule", e)
+        }
+    }
+
+    /**
+     * Retrieves domain rules for a specific outbound tag.
+     *
+     * Searches through all rulesets to find domains targeting the specified tag.
+     *
+     * @param tag The outbound tag to search for
+     * @return ArrayList of domain rules matching the tag
+     */
+    private fun getUserRule2Domain(tag: String): ArrayList<String> {
+        val domain = ArrayList<String>()
+
+        val rulesetItems = MmkvManager.decodeRoutingRulesets()
+        rulesetItems?.forEach { key ->
+            if (key.enabled && key.outboundTag == tag && !key.domain.isNullOrEmpty()) {
+                key.domain?.forEach {
+                    if (it != AppConfig.GEOSITE_PRIVATE
+                        && (it.startsWith("geosite:") || it.startsWith("domain:"))
+                    ) {
+                        domain.add(it)
+                    }
+                }
+            }
+        }
+
+        return domain
+    }
+
+    /**
+     * Configures custom local DNS settings.
+     *
+     * Sets up DNS inbound, outbound, and routing rules for local DNS resolution.
+     *
+     * @param v2rayConfig The V2ray configuration object to be modified
+     * @return true if custom local DNS configuration was successful, false otherwise
+     */
+    private fun getCustomLocalDns(v2rayConfig: V2rayConfig): Boolean {
+        try {
+            if (MmkvManager.decodeSettingsBool(AppConfig.PREF_FAKE_DNS_ENABLED) == true) {
+                val geositeCn = arrayListOf(AppConfig.GEOSITE_CN)
+                val proxyDomain = getUserRule2Domain(AppConfig.TAG_PROXY)
+                val directDomain = getUserRule2Domain(AppConfig.TAG_DIRECT)
+                // fakedns with all domains to make it always top priority
+                v2rayConfig.dns?.servers?.add(
+                    0,
+                    V2rayConfig.DnsBean.ServersBean(
+                        address = "fakedns",
+                        domains = geositeCn.plus(proxyDomain).plus(directDomain)
+                    )
+                )
+            }
+
+            if (MmkvManager.decodeSettingsBool(AppConfig.PREF_USE_HEV_TUNNEL) == false) {
+
+                // DNS inbound
+                val remoteDns = SettingsManager.getRemoteDnsServers()
+                if (v2rayConfig.inbounds.none { e -> e.protocol == "dokodemo-door" && e.tag == "dns-in" }) {
+                    val dnsInboundSettings = V2rayConfig.InboundBean.InSettingsBean(
+                        address = if (Utils.isPureIpAddress(remoteDns.first())) remoteDns.first() else AppConfig.DNS_PROXY,
+                        port = 53,
+                        network = "tcp,udp"
+                    )
+
+                    val localDnsPort = Utils.parseInt(
+                        MmkvManager.decodeSettingsString(AppConfig.PREF_LOCAL_DNS_PORT),
+                        AppConfig.PORT_LOCAL_DNS.toInt()
+                    )
+                    v2rayConfig.inbounds.add(
+                        V2rayConfig.InboundBean(
+                            tag = "dns-in",
+                            port = localDnsPort,
+                            listen = AppConfig.LOOPBACK,
+                            protocol = "dokodemo-door",
+                            settings = dnsInboundSettings,
+                            sniffing = null
+                        )
+                    )
+                }
+
+                // DNS routing tag
+                v2rayConfig.routing.rules.add(
+                    0, RulesBean(
+                        inboundTag = arrayListOf("dns-in"),
+                        outboundTag = "dns-out",
+                        domain = null
+                    )
+                )
+            } else {
+                //hev-socks5-tunnel dns routing
+                v2rayConfig.routing.rules.add(
+                    0, RulesBean(
+                        inboundTag = arrayListOf("socks"),
+                        outboundTag = "dns-out",
+                        port = "53",
+                        type = "field"
+                    )
+                )
+            }
+
+            // DNS outbound
+            if (v2rayConfig.outbounds.none { e -> e.protocol == "dns" && e.tag == "dns-out" }) {
+                v2rayConfig.outbounds.add(
+                    V2rayConfig.OutboundBean(
+                        protocol = "dns",
+                        tag = "dns-out",
+                        settings = null,
+                        streamSettings = null,
+                        mux = null
+                    )
+                )
+            }
+        } catch (e: Exception) {
+            Log.e(AppConfig.TAG, "Failed to configure custom local DNS", e)
+            return false
+        }
+        return true
+    }
+
+    /**
+     * Configures the DNS settings for V2ray.
+     *
+     * Sets up DNS servers, hosts, and routing rules for DNS resolution.
+     *
+     * @param v2rayConfig The V2ray configuration object to be modified
+     * @return true if DNS configuration was successful, false otherwise
+     */
+    private fun getDns(v2rayConfig: V2rayConfig): Boolean {
+        try {
+            val hosts = mutableMapOf<String, Any>()
+            val servers = ArrayList<Any>()
+
+            //remote Dns
+            val remoteDns = SettingsManager.getRemoteDnsServers()
+            val proxyDomain = getUserRule2Domain(AppConfig.TAG_PROXY)
+            remoteDns.forEach {
+                servers.add(it)
+            }
+            if (proxyDomain.isNotEmpty()) {
+                servers.add(
+                    V2rayConfig.DnsBean.ServersBean(
+                        address = remoteDns.first(),
+                        domains = proxyDomain,
+                    )
+                )
+            }
+
+            // domestic DNS
+            val domesticDns = SettingsManager.getDomesticDnsServers()
+            val directDomain = getUserRule2Domain(AppConfig.TAG_DIRECT)
+            val isCnRoutingMode = directDomain.contains(AppConfig.GEOSITE_CN)
+            val geoipCn = arrayListOf(AppConfig.GEOIP_CN)
+            if (directDomain.isNotEmpty()) {
+                servers.add(
+                    V2rayConfig.DnsBean.ServersBean(
+                        address = domesticDns.first(),
+                        domains = directDomain,
+                        expectIPs = if (isCnRoutingMode) geoipCn else null,
+                        skipFallback = true,
+                        tag = AppConfig.TAG_DOMESTIC_DNS
+                    )
+                )
+            }
+
+            //block dns
+            val blkDomain = getUserRule2Domain(AppConfig.TAG_BLOCKED)
+            if (blkDomain.isNotEmpty()) {
+                hosts.putAll(blkDomain.map { it to AppConfig.LOOPBACK })
+            }
+
+            // hardcode googleapi rule to fix play store problems
+            hosts[AppConfig.GOOGLEAPIS_CN_DOMAIN] = AppConfig.GOOGLEAPIS_COM_DOMAIN
+
+            // hardcode popular Android Private DNS rule to fix localhost DNS problem
+            hosts[AppConfig.DNS_ALIDNS_DOMAIN] = AppConfig.DNS_ALIDNS_ADDRESSES
+            hosts[AppConfig.DNS_CLOUDFLARE_ONE_DOMAIN] = AppConfig.DNS_CLOUDFLARE_ONE_ADDRESSES
+            hosts[AppConfig.DNS_CLOUDFLARE_DNS_COM_DOMAIN] = AppConfig.DNS_CLOUDFLARE_DNS_COM_ADDRESSES
+            hosts[AppConfig.DNS_CLOUDFLARE_DNS_DOMAIN] = AppConfig.DNS_CLOUDFLARE_DNS_ADDRESSES
+            hosts[AppConfig.DNS_DNSPOD_DOMAIN] = AppConfig.DNS_DNSPOD_ADDRESSES
+            hosts[AppConfig.DNS_GOOGLE_DOMAIN] = AppConfig.DNS_GOOGLE_ADDRESSES
+            hosts[AppConfig.DNS_QUAD9_DOMAIN] = AppConfig.DNS_QUAD9_ADDRESSES
+            hosts[AppConfig.DNS_YANDEX_DOMAIN] = AppConfig.DNS_YANDEX_ADDRESSES
+
+            //User DNS hosts
+            try {
+                val userHosts = MmkvManager.decodeSettingsString(AppConfig.PREF_DNS_HOSTS)
+                if (userHosts.isNotNullEmpty()) {
+                    var userHostsMap = userHosts?.split(",")
+                        ?.filter { it.isNotEmpty() }
+                        ?.filter { it.contains(":") }
+                        ?.associate { it.split(":").let { (k, v) -> k to v } }
+                    if (userHostsMap != null) hosts.putAll(userHostsMap)
+                }
+            } catch (e: Exception) {
+                Log.e(AppConfig.TAG, "Failed to configure user DNS hosts", e)
+            }
+
+            // DNS dns
+            v2rayConfig.dns = V2rayConfig.DnsBean(
+                servers = servers,
+                hosts = hosts,
+                tag = AppConfig.TAG_DNS
+            )
+
+            // DNS routing
+            v2rayConfig.routing.rules.add(
+                RulesBean(
+                    outboundTag = AppConfig.TAG_DIRECT,
+                    inboundTag = arrayListOf(AppConfig.TAG_DOMESTIC_DNS),
+                    domain = null
+                )
+            )
+            v2rayConfig.routing.rules.add(
+                RulesBean(
+                    outboundTag = AppConfig.TAG_PROXY,
+                    inboundTag = arrayListOf(AppConfig.TAG_DNS),
+                    domain = null
+                )
+            )
+        } catch (e: Exception) {
+            Log.e(AppConfig.TAG, "Failed to configure DNS", e)
+            return false
+        }
+        return true
+    }
+
+
+    //endregion
+
+
+    //region outbound related functions
+
+    /**
+     * Configures the primary outbound connection.
+     *
+     * Converts the profile to an outbound configuration and applies global settings.
+     *
+     * @param v2rayConfig The V2ray configuration object to be modified
+     * @param config The profile item containing connection details
+     * @return true if outbound configuration was successful, null if there was an error
+     */
+    private fun getOutbounds(v2rayConfig: V2rayConfig, config: ProfileItem): Boolean? {
+        val outbound = convertProfile2Outbound(config) ?: return null
+        val ret = updateOutboundWithGlobalSettings(outbound)
+        if (!ret) return null
+
+        if (v2rayConfig.outbounds.isNotEmpty()) {
+            v2rayConfig.outbounds[0] = outbound
+        } else {
+            v2rayConfig.outbounds.add(outbound)
+        }
+
+        updateOutboundFragment(v2rayConfig)
+        return true
+    }
+
+    /**
+     * Configures special outbound settings for Hysteria2 protocol.
+     *
+     * Creates a SOCKS outbound connection on a free port for protocols requiring special handling.
+     *
+     * @param v2rayConfig The V2ray configuration object to be modified
+     * @param config The profile item containing connection details
+     * @return The port number for the SOCKS connection, or null if there was an error
+     */
+    private fun getPlusOutbounds(v2rayConfig: V2rayConfig, config: ProfileItem): Int? {
+        try {
+            val socksPort = Utils.findFreePort(listOf(100 + SettingsManager.getSocksPort(), 0))
+
+            val outboundNew = OutboundBean(
+                mux = null,
+                protocol = EConfigType.SOCKS.name.lowercase(),
+                settings = OutSettingsBean(
+                    servers = listOf(
+                        OutSettingsBean.ServersBean(
+                            address = AppConfig.LOOPBACK,
+                            port = socksPort
+                        )
+                    )
+                )
+            )
+            if (v2rayConfig.outbounds.isNotEmpty()) {
+                v2rayConfig.outbounds[0] = outboundNew
+            } else {
+                v2rayConfig.outbounds.add(outboundNew)
+            }
+
+            return socksPort
+        } catch (e: Exception) {
+            Log.e(AppConfig.TAG, "Failed to configure plusOutbound", e)
+            return null
+        }
+    }
+
+    /**
+     * Configures additional outbound connections for proxy chaining.
+     *
+     * Sets up previous and next proxies in a subscription for advanced routing capabilities.
+     *
+     * @param v2rayConfig The V2ray configuration object to be modified
+     * @param subscriptionId The subscription ID to look up related proxies
+     * @return true if additional outbounds were configured successfully, false otherwise
+     */
+    private fun getMoreOutbounds(v2rayConfig: V2rayConfig, subscriptionId: String): Boolean {
+        //fragment proxy
+        if (MmkvManager.decodeSettingsBool(AppConfig.PREF_FRAGMENT_ENABLED, false) == true) {
+            return false
+        }
+
+        if (subscriptionId.isEmpty()) {
+            return false
+        }
+        try {
+            val subItem = MmkvManager.decodeSubscription(subscriptionId) ?: return false
+
+            //current proxy
+            val outbound = v2rayConfig.outbounds[0]
+
+            //Previous proxy
+            val prevNode = SettingsManager.getServerViaRemarks(subItem.prevProfile)
+            if (prevNode != null) {
+                val prevOutbound = convertProfile2Outbound(prevNode)
+                if (prevOutbound != null) {
+                    updateOutboundWithGlobalSettings(prevOutbound)
+                    prevOutbound.tag = AppConfig.TAG_PROXY + "2"
+                    v2rayConfig.outbounds.add(prevOutbound)
+                    outbound.ensureSockopt().dialerProxy = prevOutbound.tag
+                }
+            }
+
+            //Next proxy
+            val nextNode = SettingsManager.getServerViaRemarks(subItem.nextProfile)
+            if (nextNode != null) {
+                val nextOutbound = convertProfile2Outbound(nextNode)
+                if (nextOutbound != null) {
+                    updateOutboundWithGlobalSettings(nextOutbound)
+                    nextOutbound.tag = AppConfig.TAG_PROXY
+                    v2rayConfig.outbounds.add(0, nextOutbound)
+                    outbound.tag = AppConfig.TAG_PROXY + "1"
+                    nextOutbound.ensureSockopt().dialerProxy = outbound.tag
+                }
+            }
+        } catch (e: Exception) {
+            Log.e(AppConfig.TAG, "Failed to configure more outbounds", e)
+            return false
+        }
+
+        return true
+    }
+
+    /**
+     * Updates outbound settings based on global preferences.
+     *
+     * Applies multiplexing and protocol-specific settings to an outbound connection.
+     *
+     * @param outbound The outbound connection to update
+     * @return true if the update was successful, false otherwise
+     */
+    private fun updateOutboundWithGlobalSettings(outbound: V2rayConfig.OutboundBean): Boolean {
+        try {
+            var muxEnabled = MmkvManager.decodeSettingsBool(AppConfig.PREF_MUX_ENABLED, false)
+            val protocol = outbound.protocol
+            if (protocol.equals(EConfigType.SHADOWSOCKS.name, true)
+                || protocol.equals(EConfigType.SOCKS.name, true)
+                || protocol.equals(EConfigType.HTTP.name, true)
+                || protocol.equals(EConfigType.TROJAN.name, true)
+                || protocol.equals(EConfigType.WIREGUARD.name, true)
+                || protocol.equals(EConfigType.HYSTERIA2.name, true)
+            ) {
+                muxEnabled = false
+            } else if (outbound.streamSettings?.network == NetworkType.XHTTP.type) {
+                muxEnabled = false
+            }
+
+            if (muxEnabled == true) {
+                outbound.mux?.enabled = true
+                outbound.mux?.concurrency = MmkvManager.decodeSettingsString(AppConfig.PREF_MUX_CONCURRENCY, "8").orEmpty().toInt()
+                outbound.mux?.xudpConcurrency = MmkvManager.decodeSettingsString(AppConfig.PREF_MUX_XUDP_CONCURRENCY, "16").orEmpty().toInt()
+                outbound.mux?.xudpProxyUDP443 = MmkvManager.decodeSettingsString(AppConfig.PREF_MUX_XUDP_QUIC, "reject")
+                if (protocol.equals(EConfigType.VLESS.name, true) && outbound.settings?.vnext?.first()?.users?.first()?.flow?.isNotEmpty() == true) {
+                    outbound.mux?.concurrency = -1
+                }
+            } else {
+                outbound.mux?.enabled = false
+                outbound.mux?.concurrency = -1
+            }
+
+            if (protocol.equals(EConfigType.WIREGUARD.name, true)) {
+                var localTunAddr = if (outbound.settings?.address == null) {
+                    listOf(AppConfig.WIREGUARD_LOCAL_ADDRESS_V4)
+                } else {
+                    outbound.settings?.address as List<*>
+                }
+                if (MmkvManager.decodeSettingsBool(AppConfig.PREF_PREFER_IPV6) != true) {
+                    localTunAddr = listOf(localTunAddr.first())
+                }
+                outbound.settings?.address = localTunAddr
+            }
+
+            if (outbound.streamSettings?.network == AppConfig.DEFAULT_NETWORK
+                && outbound.streamSettings?.tcpSettings?.header?.type == AppConfig.HEADER_TYPE_HTTP
+            ) {
+                val path = outbound.streamSettings?.tcpSettings?.header?.request?.path
+                val host = outbound.streamSettings?.tcpSettings?.header?.request?.headers?.Host
+
+                val requestString: String by lazy {
+                    """{"version":"1.1","method":"GET","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.122 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate"],"Connection":["keep-alive"],"Pragma":"no-cache"}}"""
+                }
+                outbound.streamSettings?.tcpSettings?.header?.request = JsonUtil.fromJson(
+                    requestString,
+                    StreamSettingsBean.TcpSettingsBean.HeaderBean.RequestBean::class.java
+                )
+                outbound.streamSettings?.tcpSettings?.header?.request?.path =
+                    if (path.isNullOrEmpty()) {
+                        listOf("/")
+                    } else {
+                        path
+                    }
+                outbound.streamSettings?.tcpSettings?.header?.request?.headers?.Host = host
+            }
+
+
+        } catch (e: Exception) {
+            Log.e(AppConfig.TAG, "Failed to update outbound with global settings", e)
+            return false
+        }
+        return true
+    }
+
+    /**
+     * Configures load balancing settings for the V2ray configuration.
+     *
+     * @param v2rayConfig The V2ray configuration object to be modified with balancing settings
+     */
+    private fun getBalance(v2rayConfig: V2rayConfig)
+    {
+        try {
+            v2rayConfig.routing.rules.forEach { rule ->
+                if (rule.outboundTag == "proxy") {
+                    rule.outboundTag = null
+                    rule.balancerTag = "proxy-round"
+                }
+            }
+
+            if (MmkvManager.decodeSettingsString(AppConfig.PREF_INTELLIGENT_SELECTION_METHOD, "0") == "0") {
+                val balancer = V2rayConfig.RoutingBean.BalancerBean(
+                    tag = "proxy-round",
+                    selector = listOf("proxy-"),
+                    strategy = V2rayConfig.RoutingBean.StrategyObject(
+                        type = "leastPing"
+                    )
+                )
+                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 = listOf("proxy-"),
+                    pingConfig = V2rayConfig.BurstObservatoryObject.PingConfigObject(
+                        destination = MmkvManager.decodeSettingsString(AppConfig.PREF_DELAY_TEST_URL) ?: AppConfig.DELAY_TEST_URL,
+                        interval = "5m",
+                        sampling = 2,
+                        timeout = "30s"
+                    )
+                )
+            }
+
+            if (v2rayConfig.routing.domainStrategy == "IPIfNonMatch") {
+                v2rayConfig.routing.rules.add(
+                    RulesBean(
+                        ip = arrayListOf("0.0.0.0/0", "::/0"),
+                        balancerTag = "proxy-round",
+                        type = "field"
+                    )
+                )
+            } else {
+                v2rayConfig.routing.rules.add(
+                    RulesBean(
+                        network = "tcp,udp",
+                        balancerTag = "proxy-round",
+                        type = "field"
+                    )
+                )
+            }
+        } catch (e: Exception) {
+            Log.e(AppConfig.TAG, "Failed to configure balance", e)
+        }
+    }
+
+    /**
+     * Updates the outbound with fragment settings for traffic optimization.
+     *
+     * Configures packet fragmentation for TLS and REALITY protocols if enabled.
+     *
+     * @param v2rayConfig The V2ray configuration object to be modified
+     * @return true if fragment configuration was successful, false otherwise
+     */
+    private fun updateOutboundFragment(v2rayConfig: V2rayConfig): Boolean {
+        try {
+            if (MmkvManager.decodeSettingsBool(AppConfig.PREF_FRAGMENT_ENABLED, false) == false) {
+                return true
+            }
+            if (v2rayConfig.outbounds[0].streamSettings?.security != AppConfig.TLS
+                && v2rayConfig.outbounds[0].streamSettings?.security != AppConfig.REALITY
+            ) {
+                return true
+            }
+
+            val fragmentOutbound =
+                V2rayConfig.OutboundBean(
+                    protocol = AppConfig.PROTOCOL_FREEDOM,
+                    tag = AppConfig.TAG_FRAGMENT,
+                    mux = null
+                )
+
+            var packets =
+                MmkvManager.decodeSettingsString(AppConfig.PREF_FRAGMENT_PACKETS) ?: "tlshello"
+            if (v2rayConfig.outbounds[0].streamSettings?.security == AppConfig.REALITY
+                && packets == "tlshello"
+            ) {
+                packets = "1-3"
+            } else if (v2rayConfig.outbounds[0].streamSettings?.security == AppConfig.TLS
+                && packets != "tlshello"
+            ) {
+                packets = "tlshello"
+            }
+
+            fragmentOutbound.settings = OutboundBean.OutSettingsBean(
+                fragment = OutboundBean.OutSettingsBean.FragmentBean(
+                    packets = packets,
+                    length = MmkvManager.decodeSettingsString(AppConfig.PREF_FRAGMENT_LENGTH)
+                        ?: "50-100",
+                    interval = MmkvManager.decodeSettingsString(AppConfig.PREF_FRAGMENT_INTERVAL)
+                        ?: "10-20"
+                ),
+                noises = listOf(
+                    OutboundBean.OutSettingsBean.NoiseBean(
+                        type = "rand",
+                        packet = "10-20",
+                        delay = "10-16",
+                    )
+                ),
+            )
+            fragmentOutbound.streamSettings = StreamSettingsBean(
+                sockopt = StreamSettingsBean.SockoptBean(
+                    TcpNoDelay = true,
+                    mark = 255
+                )
+            )
+            v2rayConfig.outbounds.add(fragmentOutbound)
+
+            //proxy chain
+            v2rayConfig.outbounds[0].streamSettings?.sockopt =
+                StreamSettingsBean.SockoptBean(
+                    dialerProxy = AppConfig.TAG_FRAGMENT
+                )
+        } catch (e: Exception) {
+            Log.e(AppConfig.TAG, "Failed to update outbound fragment", e)
+            return false
+        }
+        return true
+    }
+
+    /**
+     * Resolves domain names to IP addresses in outbound connections.
+     *
+     * Pre-resolves domains to improve connection speed and reliability.
+     *
+     * @param v2rayConfig The V2ray configuration object to be modified
+     */
+    private fun resolveOutboundDomainsToHosts(v2rayConfig: V2rayConfig) {
+        val proxyOutboundList = v2rayConfig.getAllProxyOutbound()
+        val dns = v2rayConfig.dns ?: return
+        val newHosts = dns.hosts?.toMutableMap() ?: mutableMapOf()
+        val preferIpv6 = MmkvManager.decodeSettingsBool(AppConfig.PREF_PREFER_IPV6) == true
+
+        for (item in proxyOutboundList) {
+            val domain = item.getServerAddress()
+            if (domain.isNullOrEmpty()) continue
+
+            if (newHosts.containsKey(domain)) {
+                item.ensureSockopt().domainStrategy = "UseIP"
+                item.ensureSockopt().happyEyeballs = StreamSettingsBean.happyEyeballsBean(
+                    prioritizeIPv6 = preferIpv6,
+                    interleave = 2
+                )
+                continue
+            }
+
+            val resolvedIps = HttpUtil.resolveHostToIP(domain, preferIpv6)
+            if (resolvedIps.isNullOrEmpty()) continue
+
+            item.ensureSockopt().domainStrategy = "UseIP"
+            item.ensureSockopt().happyEyeballs = StreamSettingsBean.happyEyeballsBean(
+                prioritizeIPv6 = preferIpv6,
+                interleave = 2
+            )
+            newHosts[domain] = if (resolvedIps.size == 1) {
+                resolvedIps[0]
+            } else {
+                resolvedIps
+            }
+        }
+
+        dns.hosts = newHosts
+    }
+
+    /**
+     * Converts a profile item to an outbound configuration.
+     *
+     * Creates appropriate outbound settings based on the protocol type.
+     *
+     * @param profileItem The profile item to convert
+     * @return OutboundBean configuration for the profile, or null if not supported
+     */
+    private fun convertProfile2Outbound(profileItem: ProfileItem): V2rayConfig.OutboundBean? {
+        return when (profileItem.configType) {
+            EConfigType.VMESS -> VmessFmt.toOutbound(profileItem)
+            EConfigType.CUSTOM -> null
+            EConfigType.SHADOWSOCKS -> ShadowsocksFmt.toOutbound(profileItem)
+            EConfigType.SOCKS -> SocksFmt.toOutbound(profileItem)
+            EConfigType.VLESS -> VlessFmt.toOutbound(profileItem)
+            EConfigType.TROJAN -> TrojanFmt.toOutbound(profileItem)
+            EConfigType.WIREGUARD -> WireguardFmt.toOutbound(profileItem)
+            EConfigType.HYSTERIA2 -> null
+            EConfigType.HTTP -> HttpFmt.toOutbound(profileItem)
+        }
+    }
+
+    /**
+     * Creates an initial outbound configuration for a specific protocol type.
+     *
+     * Provides a template configuration for different protocol types.
+     *
+     * @param configType The type of configuration to create
+     * @return An initial OutboundBean for the specified configuration type, or null for custom types
+     */
+    fun createInitOutbound(configType: EConfigType): OutboundBean? {
+        return when (configType) {
+            EConfigType.VMESS,
+            EConfigType.VLESS ->
+                return OutboundBean(
+                    protocol = configType.name.lowercase(),
+                    settings = OutSettingsBean(
+                        vnext = listOf(
+                            OutSettingsBean.VnextBean(
+                                users = listOf(OutSettingsBean.VnextBean.UsersBean())
+                            )
+                        )
+                    ),
+                    streamSettings = StreamSettingsBean()
+                )
+
+            EConfigType.SHADOWSOCKS,
+            EConfigType.SOCKS,
+            EConfigType.HTTP,
+            EConfigType.TROJAN,
+            EConfigType.HYSTERIA2 ->
+                return OutboundBean(
+                    protocol = configType.name.lowercase(),
+                    settings = OutSettingsBean(
+                        servers = listOf(OutSettingsBean.ServersBean())
+                    ),
+                    streamSettings = StreamSettingsBean()
+                )
+
+            EConfigType.WIREGUARD ->
+                return OutboundBean(
+                    protocol = configType.name.lowercase(),
+                    settings = OutSettingsBean(
+                        secretKey = "",
+                        peers = listOf(OutSettingsBean.WireGuardBean())
+                    )
+                )
+
+            EConfigType.CUSTOM -> null
+        }
+    }
+
+    /**
+     * Configures transport settings for an outbound connection.
+     *
+     * Sets up protocol-specific transport options based on the profile settings.
+     *
+     * @param streamSettings The stream settings to configure
+     * @param profileItem The profile containing transport configuration
+     * @return The Server Name Indication (SNI) value to use, or null if not applicable
+     */
+    fun populateTransportSettings(streamSettings: StreamSettingsBean, profileItem: ProfileItem): String? {
+        val transport = profileItem.network.orEmpty()
+        val headerType = profileItem.headerType
+        val host = profileItem.host
+        val path = profileItem.path
+        val seed = profileItem.seed
+//        val quicSecurity = profileItem.quicSecurity
+//        val key = profileItem.quicKey
+        val mode = profileItem.mode
+        val serviceName = profileItem.serviceName
+        val authority = profileItem.authority
+        val xhttpMode = profileItem.xhttpMode
+        val xhttpExtra = profileItem.xhttpExtra
+
+        var sni: String? = null
+        streamSettings.network = if (transport.isEmpty()) NetworkType.TCP.type else transport
+        when (streamSettings.network) {
+            NetworkType.TCP.type -> {
+                val tcpSetting = StreamSettingsBean.TcpSettingsBean()
+                if (headerType == AppConfig.HEADER_TYPE_HTTP) {
+                    tcpSetting.header.type = AppConfig.HEADER_TYPE_HTTP
+                    if (!TextUtils.isEmpty(host) || !TextUtils.isEmpty(path)) {
+                        val requestObj = StreamSettingsBean.TcpSettingsBean.HeaderBean.RequestBean()
+                        requestObj.headers.Host = host.orEmpty().split(",").map { it.trim() }.filter { it.isNotEmpty() }
+                        requestObj.path = path.orEmpty().split(",").map { it.trim() }.filter { it.isNotEmpty() }
+                        tcpSetting.header.request = requestObj
+                        sni = requestObj.headers.Host?.getOrNull(0)
+                    }
+                } else {
+                    tcpSetting.header.type = "none"
+                    sni = host
+                }
+                streamSettings.tcpSettings = tcpSetting
+            }
+
+            NetworkType.KCP.type -> {
+                val kcpsetting = StreamSettingsBean.KcpSettingsBean()
+                kcpsetting.header.type = headerType ?: "none"
+                if (seed.isNullOrEmpty()) {
+                    kcpsetting.seed = null
+                } else {
+                    kcpsetting.seed = seed
+                }
+                if (host.isNullOrEmpty()) {
+                    kcpsetting.header.domain = null
+                } else {
+                    kcpsetting.header.domain = host
+                }
+                streamSettings.kcpSettings = kcpsetting
+            }
+
+            NetworkType.WS.type -> {
+                val wssetting = StreamSettingsBean.WsSettingsBean()
+                wssetting.headers.Host = host.orEmpty()
+                sni = host
+                wssetting.path = path ?: "/"
+                streamSettings.wsSettings = wssetting
+            }
+
+            NetworkType.HTTP_UPGRADE.type -> {
+                val httpupgradeSetting = StreamSettingsBean.HttpupgradeSettingsBean()
+                httpupgradeSetting.host = host.orEmpty()
+                sni = host
+                httpupgradeSetting.path = path ?: "/"
+                streamSettings.httpupgradeSettings = httpupgradeSetting
+            }
+
+            NetworkType.XHTTP.type -> {
+                val xhttpSetting = StreamSettingsBean.XhttpSettingsBean()
+                xhttpSetting.host = host.orEmpty()
+                sni = host
+                xhttpSetting.path = path ?: "/"
+                xhttpSetting.mode = xhttpMode
+                xhttpSetting.extra = JsonUtil.parseString(xhttpExtra)
+                streamSettings.xhttpSettings = xhttpSetting
+            }
+
+            NetworkType.H2.type, NetworkType.HTTP.type -> {
+                streamSettings.network = NetworkType.H2.type
+                val h2Setting = StreamSettingsBean.HttpSettingsBean()
+                h2Setting.host = host.orEmpty().split(",").map { it.trim() }.filter { it.isNotEmpty() }
+                sni = h2Setting.host.getOrNull(0)
+                h2Setting.path = path ?: "/"
+                streamSettings.httpSettings = h2Setting
+            }
+
+//                    "quic" -> {
+//                        val quicsetting = QuicSettingBean()
+//                        quicsetting.security = quicSecurity ?: "none"
+//                        quicsetting.key = key.orEmpty()
+//                        quicsetting.header.type = headerType ?: "none"
+//                        quicSettings = quicsetting
+//                    }
+
+            NetworkType.GRPC.type -> {
+                val grpcSetting = StreamSettingsBean.GrpcSettingsBean()
+                grpcSetting.multiMode = mode == "multi"
+                grpcSetting.serviceName = serviceName.orEmpty()
+                grpcSetting.authority = authority.orEmpty()
+                grpcSetting.idle_timeout = 60
+                grpcSetting.health_check_timeout = 20
+                sni = authority
+                streamSettings.grpcSettings = grpcSetting
+            }
+        }
+        return sni
+    }
+
+    /**
+     * Configures TLS or REALITY security settings for an outbound connection.
+     *
+     * Sets up security-related parameters like certificates, fingerprints, and SNI.
+     *
+     * @param streamSettings The stream settings to configure
+     * @param profileItem The profile containing security configuration
+     * @param sniExt An external SNI value to use if the profile doesn't specify one
+     */
+    fun populateTlsSettings(streamSettings: StreamSettingsBean, profileItem: ProfileItem, sniExt: String?) {
+        val streamSecurity = profileItem.security.orEmpty()
+        val allowInsecure = profileItem.insecure == true
+        val sni = if (profileItem.sni.isNullOrEmpty()) {
+            when {
+                sniExt.isNotNullEmpty() && Utils.isDomainName(sniExt) -> sniExt
+                profileItem.server.isNotNullEmpty() && Utils.isDomainName(profileItem.server) -> profileItem.server
+                else -> sniExt
+            }
+        } else {
+            profileItem.sni
+        }
+        val fingerprint = profileItem.fingerPrint
+        val alpns = profileItem.alpn
+        val publicKey = profileItem.publicKey
+        val shortId = profileItem.shortId
+        val spiderX = profileItem.spiderX
+        val mldsa65Verify = profileItem.mldsa65Verify
+
+        streamSettings.security = if (streamSecurity.isEmpty()) null else streamSecurity
+        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() },
+            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,
+        )
+        if (streamSettings.security == AppConfig.TLS) {
+            streamSettings.tlsSettings = tlsSetting
+            streamSettings.realitySettings = null
+        } else if (streamSettings.security == AppConfig.REALITY) {
+            streamSettings.tlsSettings = null
+            streamSettings.realitySettings = tlsSetting
+        }
+    }
+
+    //endregion
+}

+ 68 - 0
app/src/main/java/com/v2ray/ang/helper/CustomDividerItemDecoration.kt

@@ -0,0 +1,68 @@
+package com.v2ray.ang.helper
+
+import android.graphics.Canvas
+import android.graphics.Rect
+import android.graphics.drawable.Drawable
+import android.view.View
+import androidx.recyclerview.widget.RecyclerView
+
+class CustomDividerItemDecoration(
+    private val divider: Drawable,
+    private val orientation: Int
+) : RecyclerView.ItemDecoration() {
+
+    override fun onDraw(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State) {
+        if (orientation == RecyclerView.VERTICAL) {
+            drawVerticalDividers(canvas, parent)
+        } else {
+            drawHorizontalDividers(canvas, parent)
+        }
+    }
+
+    private fun drawVerticalDividers(canvas: Canvas, parent: RecyclerView) {
+        val left = parent.paddingLeft
+        val right = parent.width - parent.paddingRight
+
+        val childCount = parent.childCount
+        for (i in 0 until childCount - 1) {
+            val child = parent.getChildAt(i)
+            val params = child.layoutParams as RecyclerView.LayoutParams
+
+            val top = child.bottom + params.bottomMargin
+            val bottom = top + divider.intrinsicHeight
+
+            divider.setBounds(left, top, right, bottom)
+            divider.draw(canvas)
+        }
+    }
+
+    private fun drawHorizontalDividers(canvas: Canvas, parent: RecyclerView) {
+        val top = parent.paddingTop
+        val bottom = parent.height - parent.paddingBottom
+
+        val childCount = parent.childCount
+        for (i in 0 until childCount - 1) {
+            val child = parent.getChildAt(i)
+            val params = child.layoutParams as RecyclerView.LayoutParams
+
+            val left = child.right + params.rightMargin
+            val right = left + divider.intrinsicWidth
+
+            divider.setBounds(left, top, right, bottom)
+            divider.draw(canvas)
+        }
+    }
+
+    override fun getItemOffsets(
+        outRect: Rect,
+        view: View,
+        parent: RecyclerView,
+        state: RecyclerView.State
+    ) {
+        if (orientation == RecyclerView.VERTICAL) {
+            outRect.set(0, 0, 0, divider.intrinsicHeight)
+        } else {
+            outRect.set(0, 0, divider.intrinsicWidth, 0)
+        }
+    }
+}

+ 53 - 0
app/src/main/java/com/v2ray/ang/helper/ItemTouchHelperAdapter.kt

@@ -0,0 +1,53 @@
+/*
+ * Copyright (C) 2015 Paul Burke
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.v2ray.ang.helper
+
+/**
+ * Interface to listen for a move or dismissal event from a [ItemTouchHelper.Callback].
+ *
+ * @author Paul Burke (ipaulpro)
+ */
+interface ItemTouchHelperAdapter {
+    /**
+     * Called when an item has been dragged far enough to trigger a move. This is called every time
+     * an item is shifted, and **not** at the end of a "drop" event.<br></br>
+     * <br></br>
+     * Implementations should call [RecyclerView.Adapter.notifyItemMoved] after
+     * adjusting the underlying data to reflect this move.
+     *
+     * @param fromPosition The start position of the moved item.
+     * @param toPosition   Then resolved position of the moved item.
+     * @return True if the item was moved to the new adapter position.
+     * @see RecyclerView.getAdapterPositionFor
+     * @see RecyclerView.ViewHolder.getAdapterPosition
+     */
+    fun onItemMove(fromPosition: Int, toPosition: Int): Boolean
+
+
+    fun onItemMoveCompleted()
+
+    /**
+     * Called when an item has been dismissed by a swipe.<br></br>
+     * <br></br>
+     * Implementations should call [RecyclerView.Adapter.notifyItemRemoved] after
+     * adjusting the underlying data to reflect this removal.
+     *
+     * @param position The position of the item dismissed.
+     * @see RecyclerView.getAdapterPositionFor
+     * @see RecyclerView.ViewHolder.getAdapterPosition
+     */
+    fun onItemDismiss(position: Int)
+}

+ 38 - 0
app/src/main/java/com/v2ray/ang/helper/ItemTouchHelperViewHolder.kt

@@ -0,0 +1,38 @@
+/*
+ * Copyright (C) 2015 Paul Burke
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.v2ray.ang.helper
+
+import androidx.recyclerview.widget.ItemTouchHelper
+
+/**
+ * Interface to notify an item ViewHolder of relevant callbacks from [ ].
+ *
+ * @author Paul Burke (ipaulpro)
+ */
+interface ItemTouchHelperViewHolder {
+    /**
+     * Called when the [ItemTouchHelper] first registers an item as being moved or swiped.
+     * Implementations should update the item view to indicate it's active state.
+     */
+    fun onItemSelected()
+
+
+    /**
+     * Called when the [ItemTouchHelper] has completed the move or swipe, and the active item
+     * state should be cleared.
+     */
+    fun onItemClear()
+}

+ 147 - 0
app/src/main/java/com/v2ray/ang/helper/SimpleItemTouchHelperCallback.kt

@@ -0,0 +1,147 @@
+/*
+ * Copyright (C) 2015 Paul Burke
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.v2ray.ang.helper
+
+import android.animation.ValueAnimator
+import android.graphics.Canvas
+import android.view.animation.DecelerateInterpolator
+import androidx.recyclerview.widget.GridLayoutManager
+import androidx.recyclerview.widget.ItemTouchHelper
+import androidx.recyclerview.widget.RecyclerView
+import kotlin.math.abs
+import kotlin.math.min
+import kotlin.math.sign
+
+/**
+ * An implementation of [ItemTouchHelper.Callback] that enables basic drag & drop and
+ * swipe-to-dismiss. Drag events are automatically started by an item long-press.<br></br>
+ *
+ * Expects the `RecyclerView.Adapter` to listen for [ ] callbacks and the `RecyclerView.ViewHolder` to implement
+ * [ItemTouchHelperViewHolder].
+ *
+ * @author Paul Burke (ipaulpro)
+ */
+class SimpleItemTouchHelperCallback(private val mAdapter: ItemTouchHelperAdapter) : ItemTouchHelper.Callback() {
+    private var mReturnAnimator: ValueAnimator? = null
+
+    override fun isLongPressDragEnabled(): Boolean = true
+
+    override fun isItemViewSwipeEnabled(): Boolean = true
+
+    override fun getMovementFlags(
+        recyclerView: RecyclerView,
+        viewHolder: RecyclerView.ViewHolder
+    ): Int {
+        val dragFlags: Int
+        val swipeFlags: Int
+        if (recyclerView.layoutManager is GridLayoutManager) {
+            dragFlags = ItemTouchHelper.UP or ItemTouchHelper.DOWN or ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT
+            swipeFlags = ItemTouchHelper.START or ItemTouchHelper.END
+        } else {
+            dragFlags = ItemTouchHelper.UP or ItemTouchHelper.DOWN
+            swipeFlags = ItemTouchHelper.START or ItemTouchHelper.END
+        }
+        return makeMovementFlags(dragFlags, swipeFlags)
+    }
+
+    override fun onMove(
+        recyclerView: RecyclerView,
+        source: RecyclerView.ViewHolder,
+        target: RecyclerView.ViewHolder
+    ): Boolean {
+        return if (source.itemViewType != target.itemViewType) {
+            false
+        } else {
+            mAdapter.onItemMove(source.bindingAdapterPosition, target.bindingAdapterPosition)
+            true
+        }
+    }
+
+    override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
+        // Do not delete; simply return item to original position
+        returnViewToOriginalPosition(viewHolder)
+    }
+
+    override fun onChildDraw(
+        c: Canvas, recyclerView: RecyclerView,
+        viewHolder: RecyclerView.ViewHolder,
+        dX: Float, dY: Float, actionState: Int, isCurrentlyActive: Boolean
+    ) {
+        if (actionState == ItemTouchHelper.ACTION_STATE_SWIPE) {
+            val maxSwipeDistance = viewHolder.itemView.width * SWIPE_THRESHOLD
+            val swipeAmount = abs(dX)
+            val direction = sign(dX)
+
+            // Limit maximum swipe distance
+            val translationX = min(swipeAmount, maxSwipeDistance) * direction
+            val alpha = ALPHA_FULL - min(swipeAmount, maxSwipeDistance) / maxSwipeDistance
+
+            viewHolder.itemView.translationX = translationX
+            viewHolder.itemView.alpha = alpha
+
+            if (swipeAmount >= maxSwipeDistance && isCurrentlyActive) {
+                returnViewToOriginalPosition(viewHolder)
+            }
+        } else {
+            super.onChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive)
+        }
+    }
+
+    private fun returnViewToOriginalPosition(viewHolder: RecyclerView.ViewHolder) {
+        mReturnAnimator?.takeIf { it.isRunning }?.cancel()
+
+        mReturnAnimator = ValueAnimator.ofFloat(viewHolder.itemView.translationX, 0f).apply {
+            addUpdateListener { animation ->
+                val value = animation.animatedValue as Float
+                viewHolder.itemView.translationX = value
+                viewHolder.itemView.alpha = 1f - abs(value) / (viewHolder.itemView.width * SWIPE_THRESHOLD)
+            }
+            interpolator = DecelerateInterpolator()
+            duration = ANIMATION_DURATION
+            start()
+        }
+    }
+
+    override fun onSelectedChanged(viewHolder: RecyclerView.ViewHolder?, actionState: Int) {
+        if (actionState != ItemTouchHelper.ACTION_STATE_IDLE && viewHolder is ItemTouchHelperViewHolder) {
+            viewHolder.onItemSelected()
+        }
+        super.onSelectedChanged(viewHolder, actionState)
+    }
+
+    override fun clearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) {
+        super.clearView(recyclerView, viewHolder)
+        viewHolder.itemView.alpha = ALPHA_FULL
+        if (viewHolder is ItemTouchHelperViewHolder) {
+            viewHolder.onItemClear()
+        }
+        mAdapter.onItemMoveCompleted()
+    }
+
+    override fun getSwipeThreshold(viewHolder: RecyclerView.ViewHolder): Float {
+        return 1.1f // Set a value greater than 1 to prevent default swipe delete
+    }
+
+    override fun getSwipeEscapeVelocity(defaultValue: Float): Float {
+        return defaultValue * 10 // Increase swipe escape velocity to make swipe harder to trigger
+    }
+
+    companion object {
+        private const val ALPHA_FULL = 1.0f
+        private const val SWIPE_THRESHOLD = 0.25f
+        private const val ANIMATION_DURATION: Long = 200
+    }
+}

+ 32 - 0
app/src/main/java/com/v2ray/ang/plugin/NativePlugin.kt

@@ -0,0 +1,32 @@
+/******************************************************************************
+ *                                                                            *
+ * Copyright (C) 2021 by nekohasekai <[email protected]>             *
+ * Copyright (C) 2021 by Max Lv <[email protected]>                          *
+ * Copyright (C) 2021 by Mygod Studio <[email protected]>  *
+ *                                                                            *
+ * This program is free software: you can redistribute it and/or modify       *
+ * it under the terms of the GNU General Public License as published by       *
+ * the Free Software Foundation, either version 3 of the License, or          *
+ *  (at your option) any later version.                                       *
+ *                                                                            *
+ * This program is distributed in the hope that it will be useful,            *
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of             *
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the              *
+ * GNU General Public License for more details.                               *
+ *                                                                            *
+ * You should have received a copy of the GNU General Public License          *
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.       *
+ *                                                                            *
+ ******************************************************************************/
+
+package com.v2ray.ang.plugin
+
+import android.content.pm.ResolveInfo
+
+class NativePlugin(resolveInfo: ResolveInfo) : ResolvedPlugin(resolveInfo) {
+    init {
+        check(resolveInfo.providerInfo != null)
+    }
+
+    override val componentInfo get() = resolveInfo.providerInfo!!
+}

+ 43 - 0
app/src/main/java/com/v2ray/ang/plugin/Plugin.kt

@@ -0,0 +1,43 @@
+/******************************************************************************
+ *                                                                            *
+ * Copyright (C) 2021 by nekohasekai <[email protected]>             *
+ * Copyright (C) 2021 by Max Lv <[email protected]>                          *
+ * Copyright (C) 2021 by Mygod Studio <[email protected]>  *
+ *                                                                            *
+ * This program is free software: you can redistribute it and/or modify       *
+ * it under the terms of the GNU General Public License as published by       *
+ * the Free Software Foundation, either version 3 of the License, or          *
+ *  (at your option) any later version.                                       *
+ *                                                                            *
+ * This program is distributed in the hope that it will be useful,            *
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of             *
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the              *
+ * GNU General Public License for more details.                               *
+ *                                                                            *
+ * You should have received a copy of the GNU General Public License          *
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.       *
+ *                                                                            *
+ ******************************************************************************/
+
+package com.v2ray.ang.plugin
+
+import android.graphics.drawable.Drawable
+
+abstract class Plugin {
+    abstract val id: String
+    abstract val label: CharSequence
+    abstract val version: Int
+    abstract val versionName: String
+    open val icon: Drawable? get() = null
+    open val defaultConfig: String? get() = null
+    open val packageName: String get() = ""
+    open val directBootAware: Boolean get() = true
+
+    override fun equals(other: Any?): Boolean {
+        if (this === other) return true
+        if (javaClass != other?.javaClass) return false
+        return id == (other as Plugin).id
+    }
+
+    override fun hashCode() = id.hashCode()
+}

+ 33 - 0
app/src/main/java/com/v2ray/ang/plugin/PluginContract.kt

@@ -0,0 +1,33 @@
+/******************************************************************************
+ *                                                                            *
+ * Copyright (C) 2021 by nekohasekai <[email protected]>             *
+ *                                                                            *
+ * This program is free software: you can redistribute it and/or modify       *
+ * it under the terms of the GNU General Public License as published by       *
+ * the Free Software Foundation, either version 3 of the License, or          *
+ *  (at your option) any later version.                                       *
+ *                                                                            *
+ * This program is distributed in the hope that it will be useful,            *
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of             *
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the              *
+ * GNU General Public License for more details.                               *
+ *                                                                            *
+ * You should have received a copy of the GNU General Public License          *
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.       *
+ *                                                                            *
+ ******************************************************************************/
+
+package com.v2ray.ang.plugin
+
+object PluginContract {
+
+    const val ACTION_NATIVE_PLUGIN = "io.nekohasekai.sagernet.plugin.ACTION_NATIVE_PLUGIN"
+    const val EXTRA_ENTRY = "io.nekohasekai.sagernet.plugin.EXTRA_ENTRY"
+    const val METADATA_KEY_ID = "io.nekohasekai.sagernet.plugin.id"
+    const val METADATA_KEY_EXECUTABLE_PATH = "io.nekohasekai.sagernet.plugin.executable_path"
+    const val METHOD_GET_EXECUTABLE = "sagernet:getExecutable"
+
+    const val COLUMN_PATH = "path"
+    const val COLUMN_MODE = "mode"
+    const val SCHEME = "plugin"
+}

+ 54 - 0
app/src/main/java/com/v2ray/ang/plugin/PluginList.kt

@@ -0,0 +1,54 @@
+/******************************************************************************
+ *                                                                            *
+ * Copyright (C) 2021 by nekohasekai <[email protected]>             *
+ * Copyright (C) 2021 by Max Lv <[email protected]>                          *
+ * Copyright (C) 2021 by Mygod Studio <[email protected]>  *
+ *                                                                            *
+ * This program is free software: you can redistribute it and/or modify       *
+ * it under the terms of the GNU General Public License as published by       *
+ * the Free Software Foundation, either version 3 of the License, or          *
+ *  (at your option) any later version.                                       *
+ *                                                                            *
+ * This program is distributed in the hope that it will be useful,            *
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of             *
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the              *
+ * GNU General Public License for more details.                               *
+ *                                                                            *
+ * You should have received a copy of the GNU General Public License          *
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.       *
+ *                                                                            *
+ ******************************************************************************/
+
+package com.v2ray.ang.plugin
+
+import android.content.Intent
+import android.content.pm.PackageManager
+import com.v2ray.ang.AngApplication
+
+class PluginList : ArrayList<Plugin>() {
+    init {
+        addAll(
+            AngApplication.application.packageManager.queryIntentContentProviders(
+                Intent(PluginContract.ACTION_NATIVE_PLUGIN), PackageManager.GET_META_DATA
+            )
+                .filter { it.providerInfo.exported }.map { NativePlugin(it) })
+    }
+
+    val lookup = mutableMapOf<String, Plugin>().apply {
+        for (plugin in [email protected]()) {
+            fun check(old: Plugin?) {
+                if (old != null && old != plugin) {
+                    [email protected](old)
+                }
+                /* if (old != null && old !== plugin) {
+                     val packages = [email protected] { it.id == plugin.id }
+                         .joinToString { it.packageName }
+                     val message = "Conflicting plugins found from: $packages"
+                     Toast.makeText(SagerNet.application, message, Toast.LENGTH_LONG).show()
+                     throw IllegalStateException(message)
+                 }*/
+            }
+            check(put(plugin.id, plugin))
+        }
+    }
+}

+ 233 - 0
app/src/main/java/com/v2ray/ang/plugin/PluginManager.kt

@@ -0,0 +1,233 @@
+/******************************************************************************
+ *                                                                            *
+ * Copyright (C) 2021 by nekohasekai <[email protected]>             *
+ * Copyright (C) 2021 by Max Lv <[email protected]>                          *
+ * Copyright (C) 2021 by Mygod Studio <[email protected]>  *
+ *                                                                            *
+ * This program is free software: you can redistribute it and/or modify       *
+ * it under the terms of the GNU General Public License as published by       *
+ * the Free Software Foundation, either version 3 of the License, or          *
+ *  (at your option) any later version.                                       *
+ *                                                                            *
+ * This program is distributed in the hope that it will be useful,            *
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of             *
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the              *
+ * GNU General Public License for more details.                               *
+ *                                                                            *
+ * You should have received a copy of the GNU General Public License          *
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.       *
+ *                                                                            *
+ ******************************************************************************/
+
+package com.v2ray.ang.plugin
+
+import android.annotation.SuppressLint
+import android.content.BroadcastReceiver
+import android.content.ContentResolver
+import android.content.Intent
+import android.content.pm.ComponentInfo
+import android.content.pm.PackageManager
+import android.content.pm.ProviderInfo
+import android.database.Cursor
+import android.net.Uri
+import android.os.Build
+import android.system.Os
+import androidx.core.os.bundleOf
+import com.v2ray.ang.AngApplication
+import com.v2ray.ang.extension.listenForPackageChanges
+import com.v2ray.ang.extension.toast
+import com.v2ray.ang.plugin.PluginContract.METADATA_KEY_ID
+import java.io.File
+import java.io.FileNotFoundException
+
+object PluginManager {
+
+    class PluginNotFoundException(val plugin: String) : FileNotFoundException(plugin)
+
+    private var receiver: BroadcastReceiver? = null
+    private var cachedPlugins: PluginList? = null
+    fun fetchPlugins() = synchronized(this) {
+        if (receiver == null) receiver = AngApplication.application.listenForPackageChanges {
+            synchronized(this) {
+                receiver = null
+                cachedPlugins = null
+            }
+        }
+        if (cachedPlugins == null) cachedPlugins = PluginList()
+        cachedPlugins!!
+    }
+
+    private fun buildUri(id: String, authority: String) = Uri.Builder()
+        .scheme(PluginContract.SCHEME)
+        .authority(authority)
+        .path("/$id")
+        .build()
+
+    data class InitResult(
+        val path: String,
+    )
+
+    @Throws(Throwable::class)
+    fun init(pluginId: String): InitResult? {
+        if (pluginId.isEmpty()) return null
+        var throwable: Throwable? = null
+
+        try {
+            val result = initNative(pluginId)
+            if (result != null) return result
+        } catch (t: Throwable) {
+            if (throwable == null) throwable = t  //Logs.w(t)
+        }
+
+        throw throwable ?: PluginNotFoundException(pluginId)
+    }
+
+    private fun initNative(pluginId: String): InitResult? {
+        var flags = PackageManager.GET_META_DATA
+        if (Build.VERSION.SDK_INT >= 24) {
+            flags =
+                flags or PackageManager.MATCH_DIRECT_BOOT_UNAWARE or PackageManager.MATCH_DIRECT_BOOT_AWARE
+        }
+        var providers = AngApplication.application.packageManager.queryIntentContentProviders(
+            Intent(PluginContract.ACTION_NATIVE_PLUGIN, buildUri(pluginId, "com.github.dyhkwong.AngApplication")), flags
+        )
+            .filter { it.providerInfo.exported }
+        if (providers.isEmpty()) {
+            providers = AngApplication.application.packageManager.queryIntentContentProviders(
+                Intent(PluginContract.ACTION_NATIVE_PLUGIN, buildUri(pluginId, "io.nekohasekai.AngApplication")), flags
+            )
+                .filter { it.providerInfo.exported }
+        }
+        if (providers.isEmpty()) {
+            providers = AngApplication.application.packageManager.queryIntentContentProviders(
+                Intent(PluginContract.ACTION_NATIVE_PLUGIN, buildUri(pluginId, "moe.matsuri.lite")), flags
+            )
+                .filter { it.providerInfo.exported }
+        }
+        if (providers.isEmpty()) {
+            providers = AngApplication.application.packageManager.queryIntentContentProviders(
+                Intent(PluginContract.ACTION_NATIVE_PLUGIN, buildUri(pluginId, "fr.husi")), flags
+            )
+                .filter { it.providerInfo.exported }
+        }
+        if (providers.isEmpty()) {
+            providers = AngApplication.application.packageManager.queryIntentContentProviders(
+                Intent(PluginContract.ACTION_NATIVE_PLUGIN), PackageManager.GET_META_DATA
+            ).filter {
+                it.providerInfo.exported &&
+                        it.providerInfo.metaData.containsKey(METADATA_KEY_ID) &&
+                        it.providerInfo.metaData.getString(METADATA_KEY_ID) == pluginId
+            }
+            if (providers.size > 1) {
+                providers = listOf(providers[0]) // What if there is more than one?
+            }
+        }
+        if (providers.isEmpty()) return null
+        if (providers.size > 1) {
+            val message =
+                "Conflicting plugins found from: ${providers.joinToString { it.providerInfo.packageName }}"
+            AngApplication.application.toast(message)
+            throw IllegalStateException(message)
+        }
+        val provider = providers.single().providerInfo
+        var failure: Throwable? = null
+        try {
+            initNativeFaster(provider)?.also { return InitResult(it) }
+        } catch (t: Throwable) {
+            //   Logs.w("Initializing native plugin faster mode failed")
+            failure = t
+        }
+
+        val uri = Uri.Builder().apply {
+            scheme(ContentResolver.SCHEME_CONTENT)
+            authority(provider.authority)
+        }.build()
+        try {
+            return initNativeFast(
+                AngApplication.application.contentResolver,
+                pluginId,
+                uri
+            )?.let { InitResult(it) }
+        } catch (t: Throwable) {
+            //  Logs.w("Initializing native plugin fast mode failed")
+            failure?.also { t.addSuppressed(it) }
+            failure = t
+        }
+
+        try {
+            return initNativeSlow(
+                AngApplication.application.contentResolver,
+                pluginId,
+                uri
+            )?.let { InitResult(it) }
+        } catch (t: Throwable) {
+            failure?.also { t.addSuppressed(it) }
+            throw t
+        }
+    }
+
+    private fun initNativeFaster(provider: ProviderInfo): String? {
+        return provider.loadString(PluginContract.METADATA_KEY_EXECUTABLE_PATH)
+            ?.let { relativePath ->
+                File(provider.applicationInfo.nativeLibraryDir).resolve(relativePath).apply {
+                    check(canExecute())
+                }.absolutePath
+            }
+    }
+
+    private fun initNativeFast(cr: ContentResolver, pluginId: String, uri: Uri): String? {
+        return cr.call(uri, PluginContract.METHOD_GET_EXECUTABLE, null, bundleOf())
+            ?.getString(PluginContract.EXTRA_ENTRY)?.also {
+                check(File(it).canExecute())
+            }
+    }
+
+    @SuppressLint("Recycle")
+    private fun initNativeSlow(cr: ContentResolver, pluginId: String, uri: Uri): String? {
+        var initialized = false
+        fun entryNotFound(): Nothing =
+            throw IndexOutOfBoundsException("Plugin entry binary not found")
+
+        val pluginDir = File(AngApplication.application.noBackupFilesDir, "plugin")
+        (cr.query(
+            uri,
+            arrayOf(PluginContract.COLUMN_PATH, PluginContract.COLUMN_MODE),
+            null,
+            null,
+            null
+        )
+            ?: return null).use { cursor ->
+            if (!cursor.moveToFirst()) entryNotFound()
+            pluginDir.deleteRecursively()
+            if (!pluginDir.mkdirs()) throw FileNotFoundException("Unable to create plugin directory")
+            val pluginDirPath = pluginDir.absolutePath + '/'
+            do {
+                val path = cursor.getString(0)
+                val file = File(pluginDir, path)
+                check(file.absolutePath.startsWith(pluginDirPath))
+                cr.openInputStream(uri.buildUpon().path(path).build())!!.use { inStream ->
+                    file.outputStream().use { outStream -> inStream.copyTo(outStream) }
+                }
+                Os.chmod(
+                    file.absolutePath, when (cursor.getType(1)) {
+                        Cursor.FIELD_TYPE_INTEGER -> cursor.getInt(1)
+                        Cursor.FIELD_TYPE_STRING -> cursor.getString(1).toInt(8)
+                        else -> throw IllegalArgumentException("File mode should be of type int")
+                    }
+                )
+                if (path == pluginId) initialized = true
+            } while (cursor.moveToNext())
+        }
+        if (!initialized) entryNotFound()
+        return File(pluginDir, pluginId).absolutePath
+    }
+
+    fun ComponentInfo.loadString(key: String) = when (val value = metaData.getString(key)) {
+        is String -> value
+//        is Int -> AngApplication.application.packageManager.getResourcesForApplication(applicationInfo)
+//            .getString(value)
+
+        null -> null
+        else -> error("meta-data $key has invalid type ${value.javaClass}")
+    }
+}

+ 51 - 0
app/src/main/java/com/v2ray/ang/plugin/ResolvedPlugin.kt

@@ -0,0 +1,51 @@
+/******************************************************************************
+ *                                                                            *
+ * Copyright (C) 2021 by nekohasekai <[email protected]>             *
+ * Copyright (C) 2021 by Max Lv <[email protected]>                          *
+ * Copyright (C) 2021 by Mygod Studio <[email protected]>  *
+ *                                                                            *
+ * This program is free software: you can redistribute it and/or modify       *
+ * it under the terms of the GNU General Public License as published by       *
+ * the Free Software Foundation, either version 3 of the License, or          *
+ *  (at your option) any later version.                                       *
+ *                                                                            *
+ * This program is distributed in the hope that it will be useful,            *
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of             *
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the              *
+ * GNU General Public License for more details.                               *
+ *                                                                            *
+ * You should have received a copy of the GNU General Public License          *
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.       *
+ *                                                                            *
+ ******************************************************************************/
+
+package com.v2ray.ang.plugin
+
+import android.content.pm.ComponentInfo
+import android.content.pm.PackageManager
+import android.content.pm.ResolveInfo
+import android.graphics.drawable.Drawable
+import android.os.Build
+import com.v2ray.ang.AngApplication
+import com.v2ray.ang.plugin.PluginManager.loadString
+
+abstract class ResolvedPlugin(protected val resolveInfo: ResolveInfo) : Plugin() {
+    protected abstract val componentInfo: ComponentInfo
+
+    override val id by lazy { componentInfo.loadString(PluginContract.METADATA_KEY_ID)!! }
+    override val version by lazy {
+        getPackageInfo(componentInfo.packageName).versionCode
+    }
+    override val versionName: String by lazy {
+        getPackageInfo(componentInfo.packageName).versionName!!
+    }
+    override val label: CharSequence get() = resolveInfo.loadLabel(AngApplication.application.packageManager)
+    override val icon: Drawable get() = resolveInfo.loadIcon(AngApplication.application.packageManager)
+    override val packageName: String get() = componentInfo.packageName
+    override val directBootAware get() = Build.VERSION.SDK_INT < 24 || componentInfo.directBootAware
+
+    fun getPackageInfo(packageName: String) = AngApplication.application.packageManager.getPackageInfo(
+        packageName, if (Build.VERSION.SDK_INT >= 28) PackageManager.GET_SIGNING_CERTIFICATES
+        else @Suppress("DEPRECATION") PackageManager.GET_SIGNATURES
+    )!!
+}

+ 23 - 0
app/src/main/java/com/v2ray/ang/receiver/BootReceiver.kt

@@ -0,0 +1,23 @@
+package com.v2ray.ang.receiver
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import com.v2ray.ang.handler.MmkvManager
+import com.v2ray.ang.handler.V2RayServiceManager
+
+class BootReceiver : BroadcastReceiver() {
+    /**
+     * This method is called when the BroadcastReceiver is receiving an Intent broadcast.
+     * It checks if the context is not null and the action is ACTION_BOOT_COMPLETED.
+     * If the conditions are met, it starts the V2Ray service.
+     *
+     * @param context The Context in which the receiver is running.
+     * @param intent The Intent being received.
+     */
+    override fun onReceive(context: Context?, intent: Intent?) {
+        if (context == null || intent?.action != Intent.ACTION_BOOT_COMPLETED) return
+        if (!MmkvManager.decodeStartOnBoot() || MmkvManager.getSelectServer().isNullOrEmpty()) return
+        V2RayServiceManager.startVService(context)
+    }
+}

+ 41 - 0
app/src/main/java/com/v2ray/ang/receiver/TaskerReceiver.kt

@@ -0,0 +1,41 @@
+package com.v2ray.ang.receiver
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.text.TextUtils
+import com.v2ray.ang.AppConfig
+import com.v2ray.ang.handler.V2RayServiceManager
+
+class TaskerReceiver : BroadcastReceiver() {
+
+    /**
+     * This method is called when the BroadcastReceiver is receiving an Intent broadcast.
+     * It retrieves the bundle from the intent and checks the switch and guid values.
+     * Depending on the switch value, it starts or stops the V2Ray service.
+     *
+     * @param context The Context in which the receiver is running.
+     * @param intent The Intent being received.
+     */
+    override fun onReceive(context: Context, intent: Intent?) {
+        try {
+            val bundle = intent?.getBundleExtra(AppConfig.TASKER_EXTRA_BUNDLE)
+            val switch = bundle?.getBoolean(AppConfig.TASKER_EXTRA_BUNDLE_SWITCH, false)
+            val guid = bundle?.getString(AppConfig.TASKER_EXTRA_BUNDLE_GUID).orEmpty()
+
+            if (switch == null || TextUtils.isEmpty(guid)) {
+                return
+            } else if (switch) {
+                if (guid == AppConfig.TASKER_DEFAULT_GUID) {
+                    V2RayServiceManager.startVServiceFromToggle(context)
+                } else {
+                    V2RayServiceManager.startVService(context, guid)
+                }
+            } else {
+                V2RayServiceManager.stopVService(context)
+            }
+        } catch (e: Exception) {
+            android.util.Log.e(AppConfig.TAG, "Error processing Tasker broadcast", e)
+        }
+    }
+}

+ 100 - 0
app/src/main/java/com/v2ray/ang/receiver/WidgetProvider.kt

@@ -0,0 +1,100 @@
+package com.v2ray.ang.receiver
+
+import android.app.PendingIntent
+import android.appwidget.AppWidgetManager
+import android.appwidget.AppWidgetProvider
+import android.content.ComponentName
+import android.content.Context
+import android.content.Intent
+import android.os.Build
+import android.widget.RemoteViews
+import com.v2ray.ang.AppConfig
+import com.v2ray.ang.R
+import com.v2ray.ang.handler.V2RayServiceManager
+
+class WidgetProvider : AppWidgetProvider() {
+    /**
+     * This method is called every time the widget is updated.
+     * It updates the widget background based on the V2Ray service running state.
+     *
+     * @param context The Context in which the receiver is running.
+     * @param appWidgetManager The AppWidgetManager instance.
+     * @param appWidgetIds The appWidgetIds for which an update is needed.
+     */
+    override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
+        super.onUpdate(context, appWidgetManager, appWidgetIds)
+        updateWidgetBackground(context, appWidgetManager, appWidgetIds, V2RayServiceManager.isRunning())
+    }
+
+    /**
+     * Updates the widget background based on whether the V2Ray service is running.
+     *
+     * @param context The Context in which the receiver is running.
+     * @param appWidgetManager The AppWidgetManager instance.
+     * @param appWidgetIds The appWidgetIds for which an update is needed.
+     * @param isRunning Boolean indicating if the V2Ray service is running.
+     */
+    private fun updateWidgetBackground(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray, isRunning: Boolean) {
+        val remoteViews = RemoteViews(context.packageName, R.layout.widget_switch)
+        val intent = Intent(context, WidgetProvider::class.java)
+        intent.action = AppConfig.BROADCAST_ACTION_WIDGET_CLICK
+        val pendingIntent = PendingIntent.getBroadcast(
+            context,
+            R.id.layout_switch,
+            intent,
+            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+                PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
+            } else {
+                PendingIntent.FLAG_UPDATE_CURRENT
+            }
+        )
+        remoteViews.setOnClickPendingIntent(R.id.layout_switch, pendingIntent)
+        if (isRunning) {
+            remoteViews.setInt(R.id.image_switch, "setImageResource", R.drawable.ic_stop_24dp)
+            remoteViews.setInt(R.id.layout_background, "setBackgroundResource", R.drawable.ic_rounded_corner_active)
+        } else {
+            remoteViews.setInt(R.id.image_switch, "setImageResource", R.drawable.ic_play_24dp)
+            remoteViews.setInt(R.id.layout_background, "setBackgroundResource", R.drawable.ic_rounded_corner_inactive)
+        }
+
+        for (appWidgetId in appWidgetIds) {
+            appWidgetManager.updateAppWidget(appWidgetId, remoteViews)
+        }
+    }
+
+    /**
+     * This method is called when the BroadcastReceiver is receiving an Intent broadcast.
+     * It handles widget click actions and updates the widget background based on the V2Ray service state.
+     *
+     * @param context The Context in which the receiver is running.
+     * @param intent The Intent being received.
+     */
+    override fun onReceive(context: Context, intent: Intent) {
+        super.onReceive(context, intent)
+        if (AppConfig.BROADCAST_ACTION_WIDGET_CLICK == intent.action) {
+            if (V2RayServiceManager.isRunning()) {
+                V2RayServiceManager.stopVService(context)
+            } else {
+                V2RayServiceManager.startVServiceFromToggle(context)
+            }
+        } else if (AppConfig.BROADCAST_ACTION_ACTIVITY == intent.action) {
+            AppWidgetManager.getInstance(context)?.let { manager ->
+                when (intent.getIntExtra("key", 0)) {
+                    AppConfig.MSG_STATE_RUNNING, AppConfig.MSG_STATE_START_SUCCESS -> {
+                        updateWidgetBackground(
+                            context, manager, manager.getAppWidgetIds(ComponentName(context, WidgetProvider::class.java)),
+                            true
+                        )
+                    }
+
+                    AppConfig.MSG_STATE_NOT_RUNNING, AppConfig.MSG_STATE_START_FAILURE, AppConfig.MSG_STATE_STOP_SUCCESS -> {
+                        updateWidgetBackground(
+                            context, manager, manager.getAppWidgetIds(ComponentName(context, WidgetProvider::class.java)),
+                            false
+                        )
+                    }
+                }
+            }
+        }
+    }
+}

+ 52 - 0
app/src/main/java/com/v2ray/ang/service/ProcessService.kt

@@ -0,0 +1,52 @@
+package com.v2ray.ang.service
+
+import android.content.Context
+import android.util.Log
+import com.v2ray.ang.AppConfig
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+
+class ProcessService {
+    private var process: Process? = null
+
+    /**
+     * Runs a process with the given command.
+     * @param context The context.
+     * @param cmd The command to run.
+     */
+    fun runProcess(context: Context, cmd: MutableList<String>) {
+        Log.i(AppConfig.TAG, cmd.toString())
+
+        try {
+            val proBuilder = ProcessBuilder(cmd)
+            proBuilder.redirectErrorStream(true)
+            process = proBuilder
+                .directory(context.filesDir)
+                .start()
+
+            CoroutineScope(Dispatchers.IO).launch {
+                Thread.sleep(50L)
+                Log.i(AppConfig.TAG, "runProcess check")
+                process?.waitFor()
+                Log.i(AppConfig.TAG, "runProcess exited")
+            }
+            Log.i(AppConfig.TAG, process.toString())
+
+        } catch (e: Exception) {
+            Log.e(AppConfig.TAG, e.toString(), e)
+        }
+    }
+
+    /**
+     * Stops the running process.
+     */
+    fun stopProcess() {
+        try {
+            Log.i(AppConfig.TAG, "runProcess destroy")
+            process?.destroy()
+        } catch (e: Exception) {
+            Log.e(AppConfig.TAG, "Failed to destroy process", e)
+        }
+    }
+}

+ 119 - 0
app/src/main/java/com/v2ray/ang/service/QSTileService.kt

@@ -0,0 +1,119 @@
+package com.v2ray.ang.service
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.graphics.drawable.Icon
+import android.os.Build
+import android.service.quicksettings.Tile
+import android.service.quicksettings.TileService
+import android.util.Log
+import androidx.annotation.RequiresApi
+import androidx.core.content.ContextCompat
+import com.v2ray.ang.AppConfig
+import com.v2ray.ang.R
+import com.v2ray.ang.handler.V2RayServiceManager
+import com.v2ray.ang.util.MessageUtil
+import com.v2ray.ang.util.Utils
+import java.lang.ref.SoftReference
+
+@RequiresApi(Build.VERSION_CODES.N)
+class QSTileService : TileService() {
+
+    /**
+     * Sets the state of the tile.
+     * @param state The state to set.
+     */
+    fun setState(state: Int) {
+        qsTile?.icon = Icon.createWithResource(applicationContext, R.drawable.ic_stat_name)
+        if (state == Tile.STATE_INACTIVE) {
+            qsTile?.state = Tile.STATE_INACTIVE
+            qsTile?.label = getString(R.string.app_name)
+        } else if (state == Tile.STATE_ACTIVE) {
+            qsTile?.state = Tile.STATE_ACTIVE
+            qsTile?.label = V2RayServiceManager.getRunningServerName()
+        }
+
+        qsTile?.updateTile()
+    }
+
+    /**
+     * Refer to the official documentation for [registerReceiver](https://developer.android.com/reference/androidx/core/content/ContextCompat#registerReceiver(android.content.Context,android.content.BroadcastReceiver,android.content.IntentFilter,int):
+     * `registerReceiver(Context, BroadcastReceiver, IntentFilter, int)`.
+     */
+    override fun onStartListening() {
+        super.onStartListening()
+
+        if (V2RayServiceManager.isRunning()) {
+            setState(Tile.STATE_ACTIVE)
+        } else {
+            setState(Tile.STATE_INACTIVE)
+        }
+        mMsgReceive = ReceiveMessageHandler(this)
+        val mFilter = IntentFilter(AppConfig.BROADCAST_ACTION_ACTIVITY)
+        ContextCompat.registerReceiver(applicationContext, mMsgReceive, mFilter, Utils.receiverFlags())
+        MessageUtil.sendMsg2Service(this, AppConfig.MSG_REGISTER_CLIENT, "")
+    }
+
+    /**
+     * Called when the tile stops listening.
+     */
+    override fun onStopListening() {
+        super.onStopListening()
+
+        try {
+            applicationContext.unregisterReceiver(mMsgReceive)
+            mMsgReceive = null
+        } catch (e: Exception) {
+            Log.e(AppConfig.TAG, "Failed to unregister receiver", e)
+        }
+
+    }
+
+    /**
+     * Called when the tile is clicked.
+     */
+    override fun onClick() {
+        super.onClick()
+        when (qsTile.state) {
+            Tile.STATE_INACTIVE -> {
+                V2RayServiceManager.startVServiceFromToggle(this)
+            }
+
+            Tile.STATE_ACTIVE -> {
+                V2RayServiceManager.stopVService(this)
+            }
+        }
+    }
+
+    private var mMsgReceive: BroadcastReceiver? = null
+
+    private class ReceiveMessageHandler(context: QSTileService) : BroadcastReceiver() {
+        var mReference: SoftReference<QSTileService> = SoftReference(context)
+        override fun onReceive(ctx: Context?, intent: Intent?) {
+            val context = mReference.get()
+            when (intent?.getIntExtra("key", 0)) {
+                AppConfig.MSG_STATE_RUNNING -> {
+                    context?.setState(Tile.STATE_ACTIVE)
+                }
+
+                AppConfig.MSG_STATE_NOT_RUNNING -> {
+                    context?.setState(Tile.STATE_INACTIVE)
+                }
+
+                AppConfig.MSG_STATE_START_SUCCESS -> {
+                    context?.setState(Tile.STATE_ACTIVE)
+                }
+
+                AppConfig.MSG_STATE_START_FAILURE -> {
+                    context?.setState(Tile.STATE_INACTIVE)
+                }
+
+                AppConfig.MSG_STATE_STOP_SUCCESS -> {
+                    context?.setState(Tile.STATE_INACTIVE)
+                }
+            }
+        }
+    }
+}

+ 28 - 0
app/src/main/java/com/v2ray/ang/service/ServiceControl.kt

@@ -0,0 +1,28 @@
+package com.v2ray.ang.service
+
+import android.app.Service
+
+interface ServiceControl {
+    /**
+     * Gets the service instance.
+     * @return The service instance.
+     */
+    fun getService(): Service
+
+    /**
+     * Starts the service.
+     */
+    fun startService()
+
+    /**
+     * Stops the service.
+     */
+    fun stopService()
+
+    /**
+     * Protects the VPN socket.
+     * @param socket The socket to protect.
+     * @return True if the socket is protected, false otherwise.
+     */
+    fun vpnProtect(socket: Int): Boolean
+}

+ 94 - 0
app/src/main/java/com/v2ray/ang/service/TProxyService.kt

@@ -0,0 +1,94 @@
+package com.v2ray.ang.service
+
+import android.content.Context
+import android.os.ParcelFileDescriptor
+import android.util.Log
+import com.v2ray.ang.AppConfig
+import com.v2ray.ang.handler.MmkvManager
+import com.v2ray.ang.handler.SettingsManager
+import java.io.File
+
+/**
+ * Manages the tun2socks process that handles VPN traffic
+ */
+class TProxyService(
+    private val context: Context,
+    private val vpnInterface: ParcelFileDescriptor,
+    private val isRunningProvider: () -> Boolean,
+    private val restartCallback: () -> Unit
+) : Tun2SocksControl {
+    companion object {
+        @JvmStatic
+        @Suppress("FunctionName")
+        private external fun TProxyStartService(configPath: String, fd: Int)
+        @JvmStatic
+        @Suppress("FunctionName")
+        private external fun TProxyStopService()
+        @JvmStatic
+        @Suppress("FunctionName")
+        private external fun TProxyGetStats(): LongArray?
+
+        init {
+            System.loadLibrary("hev-socks5-tunnel")
+        }
+    }
+
+    /**
+     * Starts the tun2socks process with the appropriate parameters.
+     */
+    override fun startTun2Socks() {
+        Log.i(AppConfig.TAG, "Starting HevSocks5Tunnel via JNI")
+
+        val configContent = buildConfig()
+        val configFile = File(context.filesDir, "hev-socks5-tunnel.yaml").apply {
+            writeText(configContent)
+        }
+        Log.i(AppConfig.TAG, "Config file created: ${configFile.absolutePath}")
+        Log.d(AppConfig.TAG, "Config content:\n$configContent")
+
+        try {
+            Log.i(AppConfig.TAG, "TProxyStartService...")
+            TProxyStartService(configFile.absolutePath, vpnInterface.fd)
+        } catch (e: Exception) {
+            Log.e(AppConfig.TAG, "HevSocks5Tunnel exception: ${e.message}")
+        }
+    }
+
+    private fun buildConfig(): String {
+        val socksPort = SettingsManager.getSocksPort()
+        val vpnConfig = SettingsManager.getCurrentVpnInterfaceAddressConfig()
+        return buildString {
+            appendLine("tunnel:")
+            appendLine("  mtu: ${SettingsManager.getVpnMtu()}")
+            appendLine("  ipv4: ${vpnConfig.ipv4Client}")
+
+            if (MmkvManager.decodeSettingsBool(AppConfig.PREF_PREFER_IPV6) == true) {
+                appendLine("  ipv6: '${vpnConfig.ipv6Client}'")
+            }
+
+            appendLine("socks5:")
+            appendLine("  port: ${socksPort}")
+            appendLine("  address: ${AppConfig.LOOPBACK}")
+            appendLine("  udp: 'udp'")
+
+            appendLine("misc:")
+            appendLine("  read-write-timeout: ${MmkvManager.decodeSettingsString(AppConfig.PREF_HEV_TUNNEL_RW_TIMEOUT) ?: AppConfig.HEVTUN_RW_TIMEOUT}")
+            val hevTunLogLevel = MmkvManager.decodeSettingsString(AppConfig.PREF_HEV_TUNNEL_LOGLEVEL) ?: "none"
+            if (hevTunLogLevel != "none") {
+                appendLine("  log-level: $hevTunLogLevel")
+            }
+        }
+    }
+
+    /**
+     * Stops the tun2socks process
+     */
+    override fun stopTun2Socks() {
+        try {
+            Log.i(AppConfig.TAG, "TProxyStopService...")
+            TProxyStopService()
+        } catch (e: Exception) {
+            Log.e(AppConfig.TAG, "Failed to stop hev-socks5-tunnel", e)
+        }
+    }
+}

+ 19 - 0
app/src/main/java/com/v2ray/ang/service/Tun2SocksControl.kt

@@ -0,0 +1,19 @@
+package com.v2ray.ang.service
+
+/**
+ * Interface that defines the control operations for tun2socks implementations.
+ * 
+ * This interface is implemented by different tunnel solutions like:
+ */
+interface Tun2SocksControl {
+    /**
+     * Starts the tun2socks process with the appropriate parameters.
+     * This initializes the VPN tunnel and connects it to the SOCKS proxy.
+     */
+    fun startTun2Socks()
+    
+    /**
+     * Stops the tun2socks process and cleans up resources.
+     */
+    fun stopTun2Socks()
+}

+ 128 - 0
app/src/main/java/com/v2ray/ang/service/Tun2SocksService.kt

@@ -0,0 +1,128 @@
+package com.v2ray.ang.service
+
+import android.content.Context
+import android.net.LocalSocket
+import android.net.LocalSocketAddress
+import android.os.ParcelFileDescriptor
+import android.util.Log
+import com.v2ray.ang.AppConfig
+import com.v2ray.ang.handler.MmkvManager
+import com.v2ray.ang.handler.SettingsManager
+import com.v2ray.ang.util.Utils
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import java.io.File
+
+/**
+ * Manages the tun2socks process that handles VPN traffic
+ */
+class Tun2SocksService(
+    private val context: Context,
+    private val vpnInterface: ParcelFileDescriptor,
+    private val isRunningProvider: () -> Boolean,
+    private val restartCallback: () -> Unit
+) : Tun2SocksControl {
+    companion object {
+        private const val TUN2SOCKS = "libtun2socks.so"
+    }
+
+    private lateinit var process: Process
+
+    /**
+     * Starts the tun2socks process with the appropriate parameters.
+     */
+    override fun startTun2Socks() {
+        Log.i(AppConfig.TAG, "Start run $TUN2SOCKS")
+        val socksPort = SettingsManager.getSocksPort()
+        val vpnConfig = SettingsManager.getCurrentVpnInterfaceAddressConfig()
+        val cmd = arrayListOf(
+            File(context.applicationInfo.nativeLibraryDir, TUN2SOCKS).absolutePath,
+            "--netif-ipaddr", vpnConfig.ipv4Router,
+            "--netif-netmask", "255.255.255.252",
+            "--socks-server-addr", "${AppConfig.LOOPBACK}:${socksPort}",
+            "--tunmtu", SettingsManager.getVpnMtu().toString(),
+            "--sock-path", "sock_path",
+            "--enable-udprelay",
+            "--loglevel", "notice"
+        )
+
+        if (MmkvManager.decodeSettingsBool(AppConfig.PREF_PREFER_IPV6)) {
+            cmd.add("--netif-ip6addr")
+            cmd.add(vpnConfig.ipv6Router)
+        }
+        if (MmkvManager.decodeSettingsBool(AppConfig.PREF_LOCAL_DNS_ENABLED)) {
+            val localDnsPort = Utils.parseInt(
+                MmkvManager.decodeSettingsString(AppConfig.PREF_LOCAL_DNS_PORT), 
+                AppConfig.PORT_LOCAL_DNS.toInt()
+            )
+            cmd.add("--dnsgw")
+            cmd.add("${AppConfig.LOOPBACK}:${localDnsPort}")
+        }
+        Log.i(AppConfig.TAG, cmd.toString())
+
+        try {
+            val proBuilder = ProcessBuilder(cmd)
+            proBuilder.redirectErrorStream(true)
+            process = proBuilder
+                .directory(context.filesDir)
+                .start()
+            Thread {
+                Log.i(AppConfig.TAG, "$TUN2SOCKS check")
+                process.waitFor()
+                Log.i(AppConfig.TAG, "$TUN2SOCKS exited")
+                if (isRunningProvider()) {
+                    Log.i(AppConfig.TAG, "$TUN2SOCKS restart")
+                    restartCallback()
+                }
+            }.start()
+            Log.i(AppConfig.TAG, "$TUN2SOCKS process info: $process")
+
+            sendFd()
+        } catch (e: Exception) {
+            Log.e(AppConfig.TAG, "Failed to start $TUN2SOCKS process", e)
+        }
+    }
+
+    /**
+     * Sends the file descriptor to the tun2socks process.
+     * Attempts to send the file descriptor multiple times if necessary.
+     */
+    private fun sendFd() {
+        val fd = vpnInterface.fileDescriptor
+        val path = File(context.filesDir, "sock_path").absolutePath
+        Log.i(AppConfig.TAG, "LocalSocket path: $path")
+
+        CoroutineScope(Dispatchers.IO).launch {
+            var tries = 0
+            while (true) try {
+                Thread.sleep(50L shl tries)
+                Log.i(AppConfig.TAG, "LocalSocket sendFd tries: $tries")
+                LocalSocket().use { localSocket ->
+                    localSocket.connect(LocalSocketAddress(path, LocalSocketAddress.Namespace.FILESYSTEM))
+                    localSocket.setFileDescriptorsForSend(arrayOf(fd))
+                    localSocket.outputStream.write(42)
+                }
+                break
+            } catch (e: Exception) {
+                Log.e(AppConfig.TAG, "Failed to send file descriptor, try: $tries", e)
+                if (tries > 5) break
+                tries += 1
+            }
+        }
+    }
+
+    /**
+     * Stops the tun2socks process
+     */
+    override fun stopTun2Socks() {
+        try {
+            Log.i(AppConfig.TAG, "$TUN2SOCKS destroy")
+            if (::process.isInitialized) {
+                process.destroy()
+            }
+        } catch (e: Exception) {
+            Log.e(AppConfig.TAG, "Failed to destroy $TUN2SOCKS process", e)
+        }
+    }
+}

+ 94 - 0
app/src/main/java/com/v2ray/ang/service/V2RayProxyOnlyService.kt

@@ -0,0 +1,94 @@
+package com.v2ray.ang.service
+
+import android.app.Service
+import android.content.Context
+import android.content.Intent
+import android.os.Build
+import android.os.IBinder
+import androidx.annotation.RequiresApi
+import com.v2ray.ang.handler.SettingsManager
+import com.v2ray.ang.handler.V2RayServiceManager
+import com.v2ray.ang.util.MyContextWrapper
+import java.lang.ref.SoftReference
+
+class V2RayProxyOnlyService : Service(), ServiceControl {
+    /**
+     * Initializes the service.
+     */
+    override fun onCreate() {
+        super.onCreate()
+        V2RayServiceManager.serviceControl = SoftReference(this)
+    }
+
+    /**
+     * Handles the start command for the service.
+     * @param intent The intent.
+     * @param flags The flags.
+     * @param startId The start ID.
+     * @return The start mode.
+     */
+    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+        V2RayServiceManager.startCoreLoop()
+        return START_STICKY
+    }
+
+    /**
+     * Destroys the service.
+     */
+    override fun onDestroy() {
+        super.onDestroy()
+        V2RayServiceManager.stopCoreLoop()
+    }
+
+    /**
+     * Gets the service instance.
+     * @return The service instance.
+     */
+    override fun getService(): Service {
+        return this
+    }
+
+    /**
+     * Starts the service.
+     */
+    override fun startService() {
+        // do nothing
+    }
+
+    /**
+     * Stops the service.
+     */
+    override fun stopService() {
+        stopSelf()
+    }
+
+    /**
+     * Protects the VPN socket.
+     * @param socket The socket to protect.
+     * @return True if the socket is protected, false otherwise.
+     */
+    override fun vpnProtect(socket: Int): Boolean {
+        return true
+    }
+
+    /**
+     * Binds the service.
+     * @param intent The intent.
+     * @return The binder.
+     */
+    override fun onBind(intent: Intent?): IBinder? {
+        return null
+    }
+
+    /**
+     * Attaches the base context to the service.
+     * @param newBase The new base context.
+     */
+    @RequiresApi(Build.VERSION_CODES.N)
+    override fun attachBaseContext(newBase: Context?) {
+        val context = newBase?.let {
+            MyContextWrapper.wrap(newBase, SettingsManager.getLocale())
+        }
+        super.attachBaseContext(context)
+    }
+}

+ 91 - 0
app/src/main/java/com/v2ray/ang/service/V2RayTestService.kt

@@ -0,0 +1,91 @@
+package com.v2ray.ang.service
+
+import android.app.Service
+import android.content.Intent
+import android.os.IBinder
+import com.v2ray.ang.AppConfig.MSG_MEASURE_CONFIG
+import com.v2ray.ang.AppConfig.MSG_MEASURE_CONFIG_CANCEL
+import com.v2ray.ang.AppConfig.MSG_MEASURE_CONFIG_SUCCESS
+import com.v2ray.ang.dto.EConfigType
+import com.v2ray.ang.extension.serializable
+import com.v2ray.ang.handler.MmkvManager
+import com.v2ray.ang.handler.PluginServiceManager
+import com.v2ray.ang.handler.SpeedtestManager
+import com.v2ray.ang.handler.V2rayConfigManager
+import com.v2ray.ang.util.MessageUtil
+import com.v2ray.ang.util.Utils
+import go.Seq
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.asCoroutineDispatcher
+import kotlinx.coroutines.cancelChildren
+import kotlinx.coroutines.launch
+import libv2ray.Libv2ray
+import java.util.concurrent.Executors
+
+class V2RayTestService : Service() {
+    private val realTestScope by lazy { CoroutineScope(Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors()).asCoroutineDispatcher()) }
+
+    /**
+     * Initializes the V2Ray environment.
+     */
+    override fun onCreate() {
+        super.onCreate()
+        Seq.setContext(this)
+        Libv2ray.initCoreEnv(Utils.userAssetPath(this), Utils.getDeviceIdForXUDPBaseKey())
+    }
+
+    /**
+     * Handles the start command for the service.
+     * @param intent The intent.
+     * @param flags The flags.
+     * @param startId The start ID.
+     * @return The start mode.
+     */
+    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+        when (intent?.getIntExtra("key", 0)) {
+            MSG_MEASURE_CONFIG -> {
+                val guid = intent.serializable<String>("content") ?: ""
+                realTestScope.launch {
+                    val result = startRealPing(guid)
+                    MessageUtil.sendMsg2UI(this@V2RayTestService, MSG_MEASURE_CONFIG_SUCCESS, Pair(guid, result))
+                }
+            }
+
+            MSG_MEASURE_CONFIG_CANCEL -> {
+                realTestScope.coroutineContext[Job]?.cancelChildren()
+            }
+        }
+        return super.onStartCommand(intent, flags, startId)
+    }
+
+    /**
+     * Binds the service.
+     * @param intent The intent.
+     * @return The binder.
+     */
+    override fun onBind(intent: Intent?): IBinder? {
+        return null
+    }
+
+    /**
+     * Starts the real ping test.
+     * @param guid The GUID of the configuration.
+     * @return The ping result.
+     */
+    private fun startRealPing(guid: String): Long {
+        val retFailure = -1L
+
+        val config = MmkvManager.decodeServerConfig(guid) ?: return retFailure
+        if (config.configType == EConfigType.HYSTERIA2) {
+            val delay = PluginServiceManager.realPingHy2(this, config)
+            return delay
+        } else {
+            val configResult = V2rayConfigManager.getV2rayConfig4Speedtest(this, guid)
+            if (!configResult.status) {
+                return retFailure
+            }
+            return SpeedtestManager.realPing(configResult.content)
+        }
+    }
+}

+ 356 - 0
app/src/main/java/com/v2ray/ang/service/V2RayVpnService.kt

@@ -0,0 +1,356 @@
+package com.v2ray.ang.service
+
+import android.app.Service
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.net.ConnectivityManager
+import android.net.Network
+import android.net.NetworkCapabilities
+import android.net.NetworkRequest
+import android.net.ProxyInfo
+import android.net.VpnService
+import android.os.Build
+import android.os.ParcelFileDescriptor
+import android.os.StrictMode
+import android.util.Log
+import androidx.annotation.RequiresApi
+import com.v2ray.ang.AppConfig
+import com.v2ray.ang.AppConfig.LOOPBACK
+import com.v2ray.ang.BuildConfig
+import com.v2ray.ang.handler.MmkvManager
+import com.v2ray.ang.handler.NotificationManager
+import com.v2ray.ang.handler.SettingsManager
+import com.v2ray.ang.handler.V2RayServiceManager
+import com.v2ray.ang.util.MyContextWrapper
+import com.v2ray.ang.util.Utils
+import java.lang.ref.SoftReference
+
+class V2RayVpnService : VpnService(), ServiceControl {
+    private lateinit var mInterface: ParcelFileDescriptor
+    private var isRunning = false
+    private var tun2SocksService: Tun2SocksControl? = null
+
+    /**destroy
+     * Unfortunately registerDefaultNetworkCallback is going to return our VPN interface: https://android.googlesource.com/platform/frameworks/base/+/dda156ab0c5d66ad82bdcf76cda07cbc0a9c8a2e
+     *
+     * This makes doing a requestNetwork with REQUEST necessary so that we don't get ALL possible networks that
+     * satisfies default network capabilities but only THE default network. Unfortunately we need to have
+     * android.permission.CHANGE_NETWORK_STATE to be able to call requestNetwork.
+     *
+     * Source: https://android.googlesource.com/platform/frameworks/base/+/2df4c7d/services/core/java/com/android/server/ConnectivityService.java#887
+     */
+    @delegate:RequiresApi(Build.VERSION_CODES.P)
+    private val defaultNetworkRequest by lazy {
+        NetworkRequest.Builder()
+            .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
+            .addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_RESTRICTED)
+            .build()
+    }
+
+    private val connectivity by lazy { getSystemService(CONNECTIVITY_SERVICE) as ConnectivityManager }
+
+    @delegate:RequiresApi(Build.VERSION_CODES.P)
+    private val defaultNetworkCallback by lazy {
+        object : ConnectivityManager.NetworkCallback() {
+            override fun onAvailable(network: Network) {
+                setUnderlyingNetworks(arrayOf(network))
+            }
+
+            override fun onCapabilitiesChanged(network: Network, networkCapabilities: NetworkCapabilities) {
+                // it's a good idea to refresh capabilities
+                setUnderlyingNetworks(arrayOf(network))
+            }
+
+            override fun onLost(network: Network) {
+                setUnderlyingNetworks(null)
+            }
+        }
+    }
+
+    override fun onCreate() {
+        super.onCreate()
+        val policy = StrictMode.ThreadPolicy.Builder().permitAll().build()
+        StrictMode.setThreadPolicy(policy)
+        V2RayServiceManager.serviceControl = SoftReference(this)
+    }
+
+    override fun onRevoke() {
+        stopV2Ray()
+    }
+
+//    override fun onLowMemory() {
+//        stopV2Ray()
+//        super.onLowMemory()
+//    }
+
+    override fun onDestroy() {
+        super.onDestroy()
+        NotificationManager.cancelNotification()
+    }
+
+    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+        if (V2RayServiceManager.startCoreLoop()) {
+            startService()
+        }
+        return START_STICKY
+        //return super.onStartCommand(intent, flags, startId)
+    }
+
+    override fun getService(): Service {
+        return this
+    }
+
+    override fun startService() {
+        setupService()
+    }
+
+    override fun stopService() {
+        stopV2Ray(true)
+    }
+
+    override fun vpnProtect(socket: Int): Boolean {
+        return protect(socket)
+    }
+
+    @RequiresApi(Build.VERSION_CODES.N)
+    override fun attachBaseContext(newBase: Context?) {
+        val context = newBase?.let {
+            MyContextWrapper.wrap(newBase, SettingsManager.getLocale())
+        }
+        super.attachBaseContext(context)
+    }
+
+    /**
+     * Sets up the VPN service.
+     * Prepares the VPN and configures it if preparation is successful.
+     */
+    private fun setupService() {
+        val prepare = prepare(this)
+        if (prepare != null) {
+            return
+        }
+
+        if (configureVpnService() != true) {
+            return
+        }
+
+        runTun2socks()
+    }
+
+    /**
+     * Configures the VPN service.
+     * @return True if the VPN service was configured successfully, false otherwise.
+     */
+    private fun configureVpnService(): Boolean {
+        val builder = Builder()
+
+        // Configure network settings (addresses, routing and DNS)
+        configureNetworkSettings(builder)
+
+        // Configure app-specific settings (session name and per-app proxy)
+        configurePerAppProxy(builder)
+
+        // Close the old interface since the parameters have been changed
+        try {
+            mInterface.close()
+        } catch (ignored: Exception) {
+            // ignored
+        }
+
+        // Configure platform-specific features
+        configurePlatformFeatures(builder)
+
+        // Create a new interface using the builder and save the parameters
+        try {
+            mInterface = builder.establish()!!
+            isRunning = true
+            return true
+        } catch (e: Exception) {
+            Log.e(AppConfig.TAG, "Failed to establish VPN interface", e)
+            stopV2Ray()
+        }
+        return false
+    }
+
+    /**
+     * Configures the basic network settings for the VPN.
+     * This includes IP addresses, routing rules, and DNS servers.
+     *
+     * @param builder The VPN Builder to configure
+     */
+    private fun configureNetworkSettings(builder: Builder) {
+        val vpnConfig = SettingsManager.getCurrentVpnInterfaceAddressConfig()
+        val bypassLan = SettingsManager.routingRulesetsBypassLan()
+
+        // Configure IPv4 settings
+        builder.setMtu(SettingsManager.getVpnMtu())
+        builder.addAddress(vpnConfig.ipv4Client, 30)
+
+        // Configure routing rules
+        if (bypassLan) {
+            AppConfig.ROUTED_IP_LIST.forEach {
+                val addr = it.split('/')
+                builder.addRoute(addr[0], addr[1].toInt())
+            }
+        } else {
+            builder.addRoute("0.0.0.0", 0)
+        }
+
+        // Configure IPv6 if enabled
+        if (MmkvManager.decodeSettingsBool(AppConfig.PREF_PREFER_IPV6) == true) {
+            builder.addAddress(vpnConfig.ipv6Client, 126)
+            if (bypassLan) {
+                builder.addRoute("2000::", 3) // Currently only 1/8 of total IPv6 is in use
+                builder.addRoute("fc00::", 18) // Xray-core default FakeIPv6 Pool
+            } else {
+                builder.addRoute("::", 0)
+            }
+        }
+
+        // Configure DNS servers
+        //if (MmkvManager.decodeSettingsBool(AppConfig.PREF_LOCAL_DNS_ENABLED) == true) {
+        //  builder.addDnsServer(PRIVATE_VLAN4_ROUTER)
+        //} else {
+        SettingsManager.getVpnDnsServers().forEach {
+            if (Utils.isPureIpAddress(it)) {
+                builder.addDnsServer(it)
+            }
+        }
+
+        builder.setSession(V2RayServiceManager.getRunningServerName())
+    }
+
+    /**
+     * Configures platform-specific VPN features for different Android versions.
+     *
+     * @param builder The VPN Builder to configure
+     */
+    private fun configurePlatformFeatures(builder: Builder) {
+        // Android P (API 28) and above: Configure network callbacks
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+            try {
+                connectivity.requestNetwork(defaultNetworkRequest, defaultNetworkCallback)
+            } catch (e: Exception) {
+                Log.e(AppConfig.TAG, "Failed to request default network", e)
+            }
+        }
+
+        // Android Q (API 29) and above: Configure metering and HTTP proxy
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+            builder.setMetered(false)
+            if (MmkvManager.decodeSettingsBool(AppConfig.PREF_APPEND_HTTP_PROXY)) {
+                builder.setHttpProxy(ProxyInfo.buildDirectProxy(LOOPBACK, SettingsManager.getHttpPort()))
+            }
+        }
+    }
+
+    /**
+     * Configures per-app proxy rules for the VPN builder.
+     *
+     * - If per-app proxy is not enabled, disallow the VPN service's own package.
+     * - If no apps are selected, disallow the VPN service's own package.
+     * - If bypass mode is enabled, disallow all selected apps (including self).
+     * - If proxy mode is enabled, only allow the selected apps (excluding self).
+     *
+     * @param builder The VPN Builder to configure.
+     */
+    private fun configurePerAppProxy(builder: Builder) {
+        val selfPackageName = BuildConfig.APPLICATION_ID
+
+        // If per-app proxy is not enabled, disallow the VPN service's own package and return
+        if (MmkvManager.decodeSettingsBool(AppConfig.PREF_PER_APP_PROXY) == false) {
+            builder.addDisallowedApplication(selfPackageName)
+            return
+        }
+
+        // If no apps are selected, disallow the VPN service's own package and return
+        val apps = MmkvManager.decodeSettingsStringSet(AppConfig.PREF_PER_APP_PROXY_SET)
+        if (apps.isNullOrEmpty()) {
+            builder.addDisallowedApplication(selfPackageName)
+            return
+        }
+
+        val bypassApps = MmkvManager.decodeSettingsBool(AppConfig.PREF_BYPASS_APPS)
+        // Handle the VPN service's own package according to the mode
+        if (bypassApps) apps.add(selfPackageName) else apps.remove(selfPackageName)
+
+        apps.forEach {
+            try {
+                if (bypassApps) {
+                    // In bypass mode, disallow the selected apps
+                    builder.addDisallowedApplication(it)
+                } else {
+                    // In proxy mode, only allow the selected apps
+                    builder.addAllowedApplication(it)
+                }
+            } catch (e: PackageManager.NameNotFoundException) {
+                Log.e(AppConfig.TAG, "Failed to configure app in VPN: ${e.localizedMessage}", e)
+            }
+        }
+    }
+
+    /**
+     * Runs the tun2socks process.
+     * Starts the tun2socks process with the appropriate parameters.
+     */
+    private fun runTun2socks() {
+        if (MmkvManager.decodeSettingsBool(AppConfig.PREF_USE_HEV_TUNNEL) == true) {
+            tun2SocksService = TProxyService(
+                context = applicationContext,
+                vpnInterface = mInterface,
+                isRunningProvider = { isRunning },
+                restartCallback = { runTun2socks() }
+            )
+        } else {
+            tun2SocksService = Tun2SocksService(
+                context = applicationContext,
+                vpnInterface = mInterface,
+                isRunningProvider = { isRunning },
+                restartCallback = { runTun2socks() }
+            )
+        }
+
+        tun2SocksService?.startTun2Socks()
+    }
+
+    /**
+     * Stops the V2Ray service.
+     * @param isForced Whether to force stop the service.
+     */
+    private fun stopV2Ray(isForced: Boolean = true) {
+//        val configName = defaultDPreference.getPrefString(PREF_CURR_CONFIG_GUID, "")
+//        val emptyInfo = VpnNetworkInfo()
+//        val info = loadVpnNetworkInfo(configName, emptyInfo)!! + (lastNetworkInfo ?: emptyInfo)
+//        saveVpnNetworkInfo(configName, info)
+        isRunning = false
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+            try {
+                connectivity.unregisterNetworkCallback(defaultNetworkCallback)
+            } catch (ignored: Exception) {
+                // ignored
+            }
+        }
+
+        tun2SocksService?.stopTun2Socks()
+        tun2SocksService = null
+
+        V2RayServiceManager.stopCoreLoop()
+
+        if (isForced) {
+            //stopSelf has to be called ahead of mInterface.close(). otherwise v2ray core cannot be stooped
+            //It's strage but true.
+            //This can be verified by putting stopself() behind and call stopLoop and startLoop
+            //in a row for several times. You will find that later created v2ray core report port in use
+            //which means the first v2ray core somehow failed to stop and release the port.
+            stopSelf()
+
+            try {
+                mInterface.close()
+            } catch (e: Exception) {
+                Log.e(AppConfig.TAG, "Failed to close VPN interface", e)
+            }
+        }
+    }
+}
+

+ 201 - 0
app/src/main/java/com/v2ray/ang/ui/AboutActivity.kt

@@ -0,0 +1,201 @@
+package com.v2ray.ang.ui
+
+import android.Manifest
+import android.content.Intent
+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 com.tencent.mmkv.MMKV
+import com.v2ray.ang.AppConfig
+import com.v2ray.ang.BuildConfig
+import com.v2ray.ang.R
+import com.v2ray.ang.databinding.ActivityAboutBinding
+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.SpeedtestManager
+import com.v2ray.ang.util.Utils
+import com.v2ray.ang.util.ZipUtil
+import java.io.File
+import java.text.SimpleDateFormat
+import java.util.Locale
+
+class AboutActivity : BaseActivity() {
+
+    private val binding by lazy { ActivityAboutBinding.inflate(layoutInflater) }
+    private val extDir by lazy { File(Utils.backupPath(this)) }
+
+    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)
+            }
+        }
+
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+        setContentView(binding.root)
+
+        title = getString(R.string.title_about)
+
+        binding.tvBackupSummary.text = this.getString(R.string.summary_configuration_backup, extDir)
+
+        binding.layoutBackup.setOnClickListener {
+            val ret = backupConfiguration(extDir.absolutePath)
+            if (ret.first) {
+                toastSuccess(R.string.toast_success)
+            } else {
+                toastError(R.string.toast_failure)
+            }
+        }
+
+        binding.layoutShare.setOnClickListener {
+            val ret = backupConfiguration(cacheDir.absolutePath)
+            if (ret.first) {
+                startActivity(
+                    Intent.createChooser(
+                        Intent(Intent.ACTION_SEND).setType("application/zip")
+                            .setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+                            .putExtra(
+                                Intent.EXTRA_STREAM,
+                                FileProvider.getUriForFile(
+                                    this, BuildConfig.APPLICATION_ID + ".cache", File(ret.second)
+                                )
+                            ), getString(R.string.title_configuration_share)
+                    )
+                )
+            } else {
+                toastError(R.string.toast_failure)
+            }
+        }
+
+        binding.layoutRestore.setOnClickListener {
+            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) == android.content.pm.PackageManager.PERMISSION_GRANTED) {
+                try {
+                    showFileChooser()
+                } catch (e: Exception) {
+                    Log.e(AppConfig.TAG, "Failed to show file chooser", e)
+                }
+            } else {
+                requestPermissionLauncher.launch(permission)
+            }
+        }
+
+        binding.layoutSoureCcode.setOnClickListener {
+            Utils.openUri(this, AppConfig.APP_URL)
+        }
+
+        binding.layoutFeedback.setOnClickListener {
+            Utils.openUri(this, AppConfig.APP_ISSUES_URL)
+        }
+
+        binding.layoutOssLicenses.setOnClickListener {
+            val webView = android.webkit.WebView(this)
+            webView.loadUrl("file:///android_asset/open_source_licenses.html")
+            android.app.AlertDialog.Builder(this)
+                .setTitle("Open source licenses")
+                .setView(webView)
+                .setPositiveButton("OK") { dialog, _ -> dialog.dismiss() }
+                .show()
+        }
+
+        binding.layoutTgChannel.setOnClickListener {
+            Utils.openUri(this, AppConfig.TG_CHANNEL_URL)
+        }
+
+        binding.layoutPrivacyPolicy.setOnClickListener {
+            Utils.openUri(this, AppConfig.APP_PRIVACY_POLICY)
+        }
+
+        "v${BuildConfig.VERSION_NAME} (${SpeedtestManager.getLibVersion()})".also {
+            binding.tvVersion.text = it
+        }
+    }
+
+    private fun backupConfiguration(outputZipFilePos: String): Pair<Boolean, String> {
+        val dateFormated = SimpleDateFormat(
+            "yyyy-MM-dd-HH-mm-ss",
+            Locale.getDefault()
+        ).format(System.currentTimeMillis())
+        val folderName = "${getString(R.string.app_name)}_${dateFormated}"
+        val backupDir = this.cacheDir.absolutePath + "/$folderName"
+        val outputZipFilePath = "$outputZipFilePos/$folderName.zip"
+
+        val count = MMKV.backupAllToDirectory(backupDir)
+        if (count <= 0) {
+            return Pair(false, "")
+        }
+
+        if (ZipUtil.zipFromFolder(backupDir, outputZipFilePath)) {
+            return Pair(true, outputZipFilePath)
+        } else {
+            return Pair(false, "")
+        }
+    }
+
+    private fun restoreConfiguration(zipFile: File): Boolean {
+        val backupDir = this.cacheDir.absolutePath + "/${System.currentTimeMillis()}"
+
+        if (!ZipUtil.unzipToFolder(zipFile, backupDir)) {
+            return false
+        }
+
+        val count = MMKV.restoreAllFromDirectory(backupDir)
+        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: android.content.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)
+                        }
+                    }
+                    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)
+                }
+            }
+        }
+}

+ 65 - 0
app/src/main/java/com/v2ray/ang/ui/BaseActivity.kt

@@ -0,0 +1,65 @@
+package com.v2ray.ang.ui
+
+import android.content.Context
+import android.os.Build
+import android.os.Bundle
+import android.view.MenuItem
+import androidx.annotation.RequiresApi
+import androidx.appcompat.app.AppCompatActivity
+import androidx.core.content.ContextCompat
+import androidx.core.view.WindowCompat
+import androidx.recyclerview.widget.DividerItemDecoration
+import androidx.recyclerview.widget.RecyclerView
+import com.v2ray.ang.handler.SettingsManager
+import com.v2ray.ang.helper.CustomDividerItemDecoration
+import com.v2ray.ang.util.MyContextWrapper
+import com.v2ray.ang.util.Utils
+
+
+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 {
+                isAppearanceLightStatusBars = true
+            }
+        }
+    }
+
+    override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
+        android.R.id.home -> {
+            // Handles the home button press by delegating to the onBackPressedDispatcher.
+            // This ensures consistent back navigation behavior.
+            onBackPressedDispatcher.onBackPressed()
+            true
+        }
+
+        else -> super.onOptionsItemSelected(item)
+    }
+
+    @RequiresApi(Build.VERSION_CODES.N)
+    override fun attachBaseContext(newBase: Context?) {
+        super.attachBaseContext(MyContextWrapper.wrap(newBase ?: return, SettingsManager.getLocale()))
+    }
+
+    /**
+     * Adds a custom divider to a RecyclerView.
+     *
+     * @param recyclerView  The target RecyclerView to which the divider will be added.
+     * @param context       The context used to access resources.
+     * @param drawableResId The resource ID of the drawable to be used as the divider.
+     * @param orientation   The orientation of the divider (DividerItemDecoration.VERTICAL or DividerItemDecoration.HORIZONTAL).
+     */
+    fun addCustomDividerToRecyclerView(recyclerView: RecyclerView, context: Context?, drawableResId: Int, orientation: Int = DividerItemDecoration.VERTICAL) {
+        // Get the drawable from resources
+        val drawable = ContextCompat.getDrawable(context!!, drawableResId)
+        requireNotNull(drawable) { "Drawable resource not found" }
+
+        // Create a DividerItemDecoration with the specified orientation
+        val dividerItemDecoration = CustomDividerItemDecoration(drawable, orientation)
+
+        // Add the divider to the RecyclerView
+        recyclerView.addItemDecoration(dividerItemDecoration)
+    }
+}

+ 77 - 0
app/src/main/java/com/v2ray/ang/ui/CheckUpdateActivity.kt

@@ -0,0 +1,77 @@
+package com.v2ray.ang.ui
+
+import android.os.Bundle
+import android.util.Log
+import androidx.appcompat.app.AlertDialog
+import androidx.lifecycle.lifecycleScope
+import com.v2ray.ang.AppConfig
+import com.v2ray.ang.BuildConfig
+import com.v2ray.ang.R
+import com.v2ray.ang.databinding.ActivityCheckUpdateBinding
+import com.v2ray.ang.dto.CheckUpdateResult
+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.SpeedtestManager
+import com.v2ray.ang.handler.UpdateCheckerManager
+import com.v2ray.ang.util.Utils
+import kotlinx.coroutines.launch
+
+class CheckUpdateActivity : BaseActivity() {
+
+    private val binding by lazy { ActivityCheckUpdateBinding.inflate(layoutInflater) }
+
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+        setContentView(binding.root)
+
+        title = getString(R.string.update_check_for_update)
+
+        binding.layoutCheckUpdate.setOnClickListener {
+            checkForUpdates(binding.checkPreRelease.isChecked)
+        }
+
+        binding.checkPreRelease.setOnCheckedChangeListener { _, isChecked ->
+            MmkvManager.encodeSettings(AppConfig.PREF_CHECK_UPDATE_PRE_RELEASE, isChecked)
+        }
+        binding.checkPreRelease.isChecked = MmkvManager.decodeSettingsBool(AppConfig.PREF_CHECK_UPDATE_PRE_RELEASE, false)
+
+        "v${BuildConfig.VERSION_NAME} (${SpeedtestManager.getLibVersion()})".also {
+            binding.tvVersion.text = it
+        }
+
+        checkForUpdates(binding.checkPreRelease.isChecked)
+    }
+
+    private fun checkForUpdates(includePreRelease: Boolean) {
+        toast(R.string.update_checking_for_update)
+
+        lifecycleScope.launch {
+            try {
+                val result = UpdateCheckerManager.checkForUpdate(includePreRelease)
+                if (result.hasUpdate) {
+                    showUpdateDialog(result)
+                } else {
+                    toastSuccess(R.string.update_already_latest_version)
+                }
+            } catch (e: Exception) {
+                Log.e(AppConfig.TAG, "Failed to check for updates: ${e.message}")
+                toastError(e.message ?: getString(R.string.toast_failure))
+            }
+        }
+    }
+
+    private fun showUpdateDialog(result: CheckUpdateResult) {
+        AlertDialog.Builder(this)
+            .setTitle(getString(R.string.update_new_version_found, result.latestVersion))
+            .setMessage(result.releaseNotes)
+            .setPositiveButton(R.string.update_now) { _, _ ->
+                result.downloadUrl?.let {
+                    Utils.openUri(this, it)
+                }
+            }
+            .setNegativeButton(android.R.string.cancel, null)
+            .show()
+    }
+}

+ 17 - 0
app/src/main/java/com/v2ray/ang/ui/FragmentAdapter.kt

@@ -0,0 +1,17 @@
+package com.v2ray.ang.ui
+
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.FragmentActivity
+import androidx.viewpager2.adapter.FragmentStateAdapter
+
+class FragmentAdapter(fragmentActivity: FragmentActivity, private val mFragments: List<Fragment>) :
+    FragmentStateAdapter(fragmentActivity) {
+
+    override fun createFragment(position: Int): Fragment {
+        return mFragments[position]
+    }
+
+    override fun getItemCount(): Int {
+        return mFragments.size
+    }
+}

+ 156 - 0
app/src/main/java/com/v2ray/ang/ui/LogcatActivity.kt

@@ -0,0 +1,156 @@
+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.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.toastSuccess
+import com.v2ray.ang.util.Utils
+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) }
+
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+        setContentView(binding.root)
+
+        title = getString(R.string.title_logcat)
+
+        binding.recyclerView.setHasFixedSize(true)
+        binding.recyclerView.layoutManager = LinearLayoutManager(this)
+        addCustomDividerToRecyclerView(binding.recyclerView, this, R.drawable.custom_divider)
+        binding.recyclerView.adapter = adapter
+
+        binding.refreshLayout.setOnRefreshListener(this)
+
+        logsets.add(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,tun2socks,${ANG_PACKAGE},AndroidRuntime,System.err")
+                val process = withContext(Dispatchers.IO) {
+                    Runtime.getRuntime().exec(lst.toTypedArray())
+                }
+
+                val allText = process.inputStream.bufferedReader().use { it.readLines() }.reversed()
+                launch(Dispatchers.Main) {
+                    logsetsAll = allText.toMutableList()
+                    logsets = allText.toMutableList()
+                    refreshData()
+                    binding.refreshLayout.isRefreshing = false
+                }
+            }
+        } catch (e: IOException) {
+            Log.e(AppConfig.TAG, "Failed to get logcat", e)
+        }
+    }
+
+    private fun clearLogcat() {
+        try {
+            lifecycleScope.launch(Dispatchers.Default) {
+                val lst = LinkedHashSet<String>()
+                lst.add("logcat")
+                lst.add("-c")
+                withContext(Dispatchers.IO) {
+                    val process = Runtime.getRuntime().exec(lst.toTypedArray())
+                    process.waitFor()
+                }
+                launch(Dispatchers.Main) {
+                    logsetsAll.clear()
+                    logsets.clear()
+                    refreshData()
+                }
+            }
+        } catch (e: IOException) {
+            Log.e(AppConfig.TAG, "Failed to clear logcat", e)
+        }
+    }
+
+    override fun onCreateOptionsMenu(menu: Menu): Boolean {
+        menuInflater.inflate(R.menu.menu_logcat, menu)
+
+        val searchItem = menu.findItem(R.id.search_view)
+        if (searchItem != null) {
+            val searchView = searchItem.actionView as SearchView
+            searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
+                override fun onQueryTextSubmit(query: String?): Boolean = false
+
+                override fun onQueryTextChange(newText: String?): Boolean {
+                    filterLogs(newText)
+                    return false
+                }
+            })
+            searchView.setOnCloseListener {
+                filterLogs("")
+                false
+            }
+        }
+
+        return super.onCreateOptionsMenu(menu)
+    }
+
+    override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
+        R.id.copy_all -> {
+            Utils.setClipboard(this, logsets.joinToString("\n"))
+            toastSuccess(R.string.toast_success)
+            true
+        }
+
+        R.id.clear_all -> {
+            clearLogcat()
+            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()
+    }
+
+    @SuppressLint("NotifyDataSetChanged")
+    fun refreshData() {
+        adapter.notifyDataSetChanged()
+    }
+}

+ 44 - 0
app/src/main/java/com/v2ray/ang/ui/LogcatRecyclerAdapter.kt

@@ -0,0 +1,44 @@
+package com.v2ray.ang.ui
+
+import android.util.Log
+import android.view.LayoutInflater
+import android.view.ViewGroup
+import androidx.recyclerview.widget.RecyclerView
+import com.v2ray.ang.AppConfig
+import com.v2ray.ang.databinding.ItemRecyclerLogcatBinding
+
+class LogcatRecyclerAdapter(val activity: LogcatActivity) : RecyclerView.Adapter<LogcatRecyclerAdapter.MainViewHolder>() {
+    private var mActivity: LogcatActivity = activity
+
+
+    override fun getItemCount() = mActivity.logsets.size
+
+    override fun onBindViewHolder(holder: MainViewHolder, position: Int) {
+        try {
+            val log = mActivity.logsets[position]
+            if (log.isEmpty()) {
+                holder.itemSubSettingBinding.logTag.text = ""
+                holder.itemSubSettingBinding.logContent.text = ""
+            } else {
+                val content = log.split("):", limit = 2)
+                holder.itemSubSettingBinding.logTag.text = content.first().split("(", limit = 2).first().trim()
+                holder.itemSubSettingBinding.logContent.text = if (content.count() > 1) content.last().trim() else ""
+            }
+        } catch (e: Exception) {
+            Log.e(AppConfig.TAG, "Error binding log view data", e)
+        }
+    }
+
+    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MainViewHolder {
+        return MainViewHolder(
+            ItemRecyclerLogcatBinding.inflate(
+                LayoutInflater.from(parent.context),
+                parent,
+                false
+            )
+        )
+    }
+
+    class MainViewHolder(val itemSubSettingBinding: ItemRecyclerLogcatBinding) : RecyclerView.ViewHolder(itemSubSettingBinding.root)
+
+}

+ 703 - 0
app/src/main/java/com/v2ray/ang/ui/MainActivity.kt

@@ -0,0 +1,703 @@
+package com.v2ray.ang.ui
+
+import android.Manifest
+import android.annotation.SuppressLint
+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
+import android.view.Menu
+import android.view.MenuItem
+import androidx.activity.OnBackPressedCallback
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.activity.viewModels
+import androidx.appcompat.app.ActionBarDrawerToggle
+import androidx.appcompat.app.AlertDialog
+import androidx.appcompat.widget.SearchView
+import androidx.core.content.ContextCompat
+import androidx.core.view.GravityCompat
+import androidx.core.view.isVisible
+import androidx.lifecycle.lifecycleScope
+import androidx.recyclerview.widget.GridLayoutManager
+import androidx.recyclerview.widget.ItemTouchHelper
+import com.google.android.material.navigation.NavigationView
+import com.google.android.material.tabs.TabLayout
+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.extension.toast
+import com.v2ray.ang.extension.toastError
+import com.v2ray.ang.handler.AngConfigManager
+import com.v2ray.ang.handler.MigrateManager
+import com.v2ray.ang.handler.MmkvManager
+import com.v2ray.ang.helper.SimpleItemTouchHelperCallback
+import com.v2ray.ang.handler.V2RayServiceManager
+import com.v2ray.ang.util.Utils
+import com.v2ray.ang.viewmodel.MainViewModel
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedListener {
+    private val binding by lazy {
+        ActivityMainBinding.inflate(layoutInflater)
+    }
+
+    private val adapter by lazy { MainRecyclerAdapter(this) }
+    private val requestVpnPermission = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
+        if (it.resultCode == RESULT_OK) {
+            startV2Ray()
+        }
+    }
+    private val requestSubSettingActivity = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
+        initGroupTab()
+    }
+    private val tabGroupListener = object : TabLayout.OnTabSelectedListener {
+        override fun onTabSelected(tab: TabLayout.Tab?) {
+            val selectId = tab?.tag.toString()
+            if (selectId != mainViewModel.subscriptionId) {
+                mainViewModel.subscriptionIdChanged(selectId)
+            }
+        }
+
+        override fun onTabUnselected(tab: TabLayout.Tab?) {
+        }
+
+        override fun onTabReselected(tab: TabLayout.Tab?) {
+        }
+    }
+    private var mItemTouchHelper: ItemTouchHelper? = null
+    val mainViewModel: MainViewModel by viewModels()
+
+    // 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)
+        setContentView(binding.root)
+        title = getString(R.string.title_server)
+        setSupportActionBar(binding.toolbar)
+
+        binding.fab.setOnClickListener {
+            if (mainViewModel.isRunning.value == true) {
+                V2RayServiceManager.stopVService(this)
+            } else if ((MmkvManager.decodeSettingsString(AppConfig.PREF_MODE) ?: VPN) == VPN) {
+                val intent = VpnService.prepare(this)
+                if (intent == null) {
+                    startV2Ray()
+                } else {
+                    requestVpnPermission.launch(intent)
+                }
+            } else {
+                startV2Ray()
+            }
+        }
+        binding.layoutTest.setOnClickListener {
+            if (mainViewModel.isRunning.value == true) {
+                setTestState(getString(R.string.connection_test_testing))
+                mainViewModel.testCurrentServerRealPing()
+            } else {
+//                tv_test_state.text = getString(R.string.connection_test_fail)
+            }
+        }
+
+        binding.recyclerView.setHasFixedSize(true)
+        if (MmkvManager.decodeSettingsBool(AppConfig.PREF_DOUBLE_COLUMN_DISPLAY, false)) {
+            binding.recyclerView.layoutManager = GridLayoutManager(this, 2)
+        } else {
+            binding.recyclerView.layoutManager = GridLayoutManager(this, 1)
+        }
+        addCustomDividerToRecyclerView(binding.recyclerView, this, R.drawable.custom_divider)
+        binding.recyclerView.adapter = adapter
+
+        mItemTouchHelper = ItemTouchHelper(SimpleItemTouchHelperCallback(adapter))
+        mItemTouchHelper?.attachToRecyclerView(binding.recyclerView)
+
+        val toggle = ActionBarDrawerToggle(
+            this, binding.drawerLayout, binding.toolbar, R.string.navigation_drawer_open, R.string.navigation_drawer_close
+        )
+        binding.drawerLayout.addDrawerListener(toggle)
+        toggle.syncState()
+        binding.navView.setNavigationItemSelectedListener(this)
+
+        initGroupTab()
+        setupViewModel()
+        migrateLegacy()
+
+        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)
+            }
+        }
+
+        onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
+            override fun handleOnBackPressed() {
+                if (binding.drawerLayout.isDrawerOpen(GravityCompat.START)) {
+                    binding.drawerLayout.closeDrawer(GravityCompat.START)
+                } else {
+                    isEnabled = false
+                    onBackPressedDispatcher.onBackPressed()
+                    isEnabled = true
+                }
+            }
+        })
+    }
+
+    @SuppressLint("NotifyDataSetChanged")
+    private fun setupViewModel() {
+        mainViewModel.updateListAction.observe(this) { index ->
+            if (index >= 0) {
+                adapter.notifyItemChanged(index)
+            } else {
+                adapter.notifyDataSetChanged()
+            }
+        }
+        mainViewModel.updateTestResultAction.observe(this) { setTestState(it) }
+        mainViewModel.isRunning.observe(this) { isRunning ->
+            adapter.isRunning = isRunning
+            if (isRunning) {
+                binding.fab.setImageResource(R.drawable.ic_stop_24dp)
+                binding.fab.backgroundTintList = ColorStateList.valueOf(ContextCompat.getColor(this, R.color.color_fab_active))
+                setTestState(getString(R.string.connection_connected))
+                binding.layoutTest.isFocusable = true
+            } else {
+                binding.fab.setImageResource(R.drawable.ic_play_24dp)
+                binding.fab.backgroundTintList = ColorStateList.valueOf(ContextCompat.getColor(this, R.color.color_fab_inactive))
+                setTestState(getString(R.string.connection_not_connected))
+                binding.layoutTest.isFocusable = false
+            }
+        }
+        mainViewModel.startListenBroadcast()
+        mainViewModel.initAssets(assets)
+    }
+
+    private fun migrateLegacy() {
+        lifecycleScope.launch(Dispatchers.IO) {
+            val result = MigrateManager.migrateServerConfig2Profile()
+            launch(Dispatchers.Main) {
+                if (result) {
+                    toast(getString(R.string.migration_success))
+                    mainViewModel.reloadServerList()
+                } else {
+                    //toast(getString(R.string.migration_fail))
+                }
+            }
+
+        }
+    }
+
+    private fun initGroupTab() {
+        binding.tabGroup.removeOnTabSelectedListener(tabGroupListener)
+        binding.tabGroup.removeAllTabs()
+        binding.tabGroup.isVisible = false
+
+        val (listId, listRemarks) = mainViewModel.getSubscriptions(this)
+        if (listId == null || listRemarks == null) {
+            return
+        }
+
+        for (it in listRemarks.indices) {
+            val tab = binding.tabGroup.newTab()
+            tab.text = listRemarks[it]
+            tab.tag = listId[it]
+            binding.tabGroup.addTab(tab)
+        }
+        val selectIndex =
+            listId.indexOf(mainViewModel.subscriptionId).takeIf { it >= 0 } ?: (listId.count() - 1)
+        binding.tabGroup.selectTab(binding.tabGroup.getTabAt(selectIndex))
+        binding.tabGroup.addOnTabSelectedListener(tabGroupListener)
+        binding.tabGroup.isVisible = true
+    }
+
+    private fun startV2Ray() {
+        if (MmkvManager.getSelectServer().isNullOrEmpty()) {
+            toast(R.string.title_file_chooser)
+            return
+        }
+        V2RayServiceManager.startVService(this)
+    }
+
+    private fun restartV2Ray() {
+        if (mainViewModel.isRunning.value == true) {
+            V2RayServiceManager.stopVService(this)
+        }
+        lifecycleScope.launch {
+            delay(500)
+            startV2Ray()
+        }
+    }
+
+    public override fun onResume() {
+        super.onResume()
+        mainViewModel.reloadServerList()
+    }
+
+    public override fun onPause() {
+        super.onPause()
+    }
+
+    override fun onCreateOptionsMenu(menu: Menu): Boolean {
+        menuInflater.inflate(R.menu.menu_main, menu)
+
+        val searchItem = menu.findItem(R.id.search_view)
+        if (searchItem != null) {
+            val searchView = searchItem.actionView as SearchView
+            searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
+                override fun onQueryTextSubmit(query: String?): Boolean = false
+
+                override fun onQueryTextChange(newText: String?): Boolean {
+                    mainViewModel.filterConfig(newText.orEmpty())
+                    return false
+                }
+            })
+
+            searchView.setOnCloseListener {
+                mainViewModel.filterConfig("")
+                false
+            }
+        }
+        return super.onCreateOptionsMenu(menu)
+    }
+
+    override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
+        R.id.import_qrcode -> {
+            importQRcode()
+            true
+        }
+
+        R.id.import_clipboard -> {
+            importClipboard()
+            true
+        }
+
+        R.id.import_local -> {
+            importConfigLocal()
+            true
+        }
+
+        R.id.import_manually_vmess -> {
+            importManually(EConfigType.VMESS.value)
+            true
+        }
+
+        R.id.import_manually_vless -> {
+            importManually(EConfigType.VLESS.value)
+            true
+        }
+
+        R.id.import_manually_ss -> {
+            importManually(EConfigType.SHADOWSOCKS.value)
+            true
+        }
+
+        R.id.import_manually_socks -> {
+            importManually(EConfigType.SOCKS.value)
+            true
+        }
+
+        R.id.import_manually_http -> {
+            importManually(EConfigType.HTTP.value)
+            true
+        }
+
+        R.id.import_manually_trojan -> {
+            importManually(EConfigType.TROJAN.value)
+            true
+        }
+
+        R.id.import_manually_wireguard -> {
+            importManually(EConfigType.WIREGUARD.value)
+            true
+        }
+
+        R.id.import_manually_hysteria2 -> {
+            importManually(EConfigType.HYSTERIA2.value)
+            true
+        }
+
+        R.id.export_all -> {
+            exportAll()
+            true
+        }
+
+        R.id.ping_all -> {
+            toast(getString(R.string.connection_test_testing_count, mainViewModel.serversCache.count()))
+            mainViewModel.testAllTcping()
+            true
+        }
+
+        R.id.real_ping_all -> {
+            toast(getString(R.string.connection_test_testing_count, mainViewModel.serversCache.count()))
+            mainViewModel.testAllRealPing()
+            true
+        }
+
+        R.id.intelligent_selection_all -> {
+            if (MmkvManager.decodeSettingsString(AppConfig.PREF_OUTBOUND_DOMAIN_RESOLVE_METHOD, "1") != "0") {
+                toast(getString(R.string.pre_resolving_domain))
+            }
+            mainViewModel.createIntelligentSelectionAll()
+            true
+        }
+
+        R.id.service_restart -> {
+            restartV2Ray()
+            true
+        }
+
+        R.id.del_all_config -> {
+            delAllConfig()
+            true
+        }
+
+        R.id.del_duplicate_config -> {
+            delDuplicateConfig()
+            true
+        }
+
+        R.id.del_invalid_config -> {
+            delInvalidConfig()
+            true
+        }
+
+        R.id.sort_by_test_results -> {
+            sortByTestResults()
+            true
+        }
+
+        R.id.sub_update -> {
+            importConfigViaSub()
+            true
+        }
+
+
+        else -> super.onOptionsItemSelected(item)
+    }
+
+    private fun importManually(createConfigType: Int) {
+        startActivity(
+            Intent()
+                .putExtra("createConfigType", createConfigType)
+                .putExtra("subscriptionId", mainViewModel.subscriptionId)
+                .setClass(this, ServerActivity::class.java)
+        )
+    }
+
+    /**
+     * 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)
+        }
+        return true
+    }
+
+    /**
+     * import config from clipboard
+     */
+    private fun importClipboard()
+            : Boolean {
+        try {
+            val clipboard = Utils.getClipboard(this)
+            importBatchConfig(clipboard)
+        } catch (e: Exception) {
+            Log.e(AppConfig.TAG, "Failed to import config from clipboard", e)
+            return false
+        }
+        return true
+    }
+
+    private fun importBatchConfig(server: String?) {
+        binding.pbWaiting.show()
+
+        lifecycleScope.launch(Dispatchers.IO) {
+            try {
+                val (count, countSub) = AngConfigManager.importBatchConfig(server, mainViewModel.subscriptionId, true)
+                delay(500L)
+                withContext(Dispatchers.Main) {
+                    when {
+                        count > 0 -> {
+                            toast(getString(R.string.title_import_config_count, count))
+                            mainViewModel.reloadServerList()
+                        }
+
+                        countSub > 0 -> initGroupTab()
+                        else -> toastError(R.string.toast_failure)
+                    }
+                    binding.pbWaiting.hide()
+                }
+            } catch (e: Exception) {
+                withContext(Dispatchers.Main) {
+                    toastError(R.string.toast_failure)
+                    binding.pbWaiting.hide()
+                }
+                Log.e(AppConfig.TAG, "Failed to import batch config", e)
+            }
+        }
+    }
+
+    /**
+     * import config from local config file
+     */
+    private fun importConfigLocal(): Boolean {
+        try {
+            showFileChooser()
+        } catch (e: Exception) {
+            Log.e(AppConfig.TAG, "Failed to import config from local file", e)
+            return false
+        }
+        return true
+    }
+
+
+    /**
+     * import config from sub
+     */
+    private fun importConfigViaSub(): Boolean {
+        binding.pbWaiting.show()
+
+        lifecycleScope.launch(Dispatchers.IO) {
+            val count = mainViewModel.updateConfigViaSubAll()
+            delay(500L)
+            launch(Dispatchers.Main) {
+                if (count > 0) {
+                    toast(getString(R.string.title_update_config_count, count))
+                    mainViewModel.reloadServerList()
+                } else {
+                    toastError(R.string.toast_failure)
+                }
+                binding.pbWaiting.hide()
+            }
+        }
+        return true
+    }
+
+    private fun exportAll() {
+        binding.pbWaiting.show()
+        lifecycleScope.launch(Dispatchers.IO) {
+            val ret = mainViewModel.exportAllServer()
+            launch(Dispatchers.Main) {
+                if (ret > 0)
+                    toast(getString(R.string.title_export_config_count, ret))
+                else
+                    toastError(R.string.toast_failure)
+                binding.pbWaiting.hide()
+            }
+        }
+    }
+
+    private fun delAllConfig() {
+        AlertDialog.Builder(this).setMessage(R.string.del_config_comfirm)
+            .setPositiveButton(android.R.string.ok) { _, _ ->
+                binding.pbWaiting.show()
+                lifecycleScope.launch(Dispatchers.IO) {
+                    val ret = mainViewModel.removeAllServer()
+                    launch(Dispatchers.Main) {
+                        mainViewModel.reloadServerList()
+                        toast(getString(R.string.title_del_config_count, ret))
+                        binding.pbWaiting.hide()
+                    }
+                }
+            }
+            .setNegativeButton(android.R.string.cancel) { _, _ ->
+                //do noting
+            }
+            .show()
+    }
+
+    private fun delDuplicateConfig() {
+        AlertDialog.Builder(this).setMessage(R.string.del_config_comfirm)
+            .setPositiveButton(android.R.string.ok) { _, _ ->
+                binding.pbWaiting.show()
+                lifecycleScope.launch(Dispatchers.IO) {
+                    val ret = mainViewModel.removeDuplicateServer()
+                    launch(Dispatchers.Main) {
+                        mainViewModel.reloadServerList()
+                        toast(getString(R.string.title_del_duplicate_config_count, ret))
+                        binding.pbWaiting.hide()
+                    }
+                }
+            }
+            .setNegativeButton(android.R.string.cancel) { _, _ ->
+                //do noting
+            }
+            .show()
+    }
+
+    private fun delInvalidConfig() {
+        AlertDialog.Builder(this).setMessage(R.string.del_invalid_config_comfirm)
+            .setPositiveButton(android.R.string.ok) { _, _ ->
+                binding.pbWaiting.show()
+                lifecycleScope.launch(Dispatchers.IO) {
+                    val ret = mainViewModel.removeInvalidServer()
+                    launch(Dispatchers.Main) {
+                        mainViewModel.reloadServerList()
+                        toast(getString(R.string.title_del_config_count, ret))
+                        binding.pbWaiting.hide()
+                    }
+                }
+            }
+            .setNegativeButton(android.R.string.cancel) { _, _ ->
+                //do noting
+            }
+            .show()
+    }
+
+    private fun sortByTestResults() {
+        binding.pbWaiting.show()
+        lifecycleScope.launch(Dispatchers.IO) {
+            mainViewModel.sortByTestResults()
+            launch(Dispatchers.Main) {
+                mainViewModel.reloadServerList()
+                binding.pbWaiting.hide()
+            }
+        }
+    }
+
+    /**
+     * show file chooser
+     */
+    private fun showFileChooser() {
+        val intent = Intent(Intent.ACTION_GET_CONTENT)
+        intent.type = "*/*"
+        intent.addCategory(Intent.CATEGORY_OPENABLE)
+
+        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)
+        }
+    }
+
+    /**
+     * 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)
+            }
+        } else {
+            requestPermissionLauncher.launch(permission)
+        }
+    }
+
+    private fun setTestState(content: String?) {
+        binding.tvTestState.text = content
+    }
+
+//    val mConnection = object : ServiceConnection {
+//        override fun onServiceDisconnected(name: ComponentName?) {
+//        }
+//
+//        override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
+//            sendMsg(AppConfig.MSG_REGISTER_CLIENT, "")
+//        }
+//    }
+
+    override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean {
+        if (keyCode == KeyEvent.KEYCODE_BACK || keyCode == KeyEvent.KEYCODE_BUTTON_B) {
+            moveTaskToBack(false)
+            return true
+        }
+        return super.onKeyDown(keyCode, event)
+    }
+
+
+    override fun onNavigationItemSelected(item: MenuItem): Boolean {
+        // Handle navigation view item clicks here.
+        when (item.itemId) {
+            R.id.sub_setting -> requestSubSettingActivity.launch(Intent(this, SubSettingActivity::class.java))
+            R.id.per_app_proxy_settings -> startActivity(Intent(this, PerAppProxyActivity::class.java))
+            R.id.routing_setting -> requestSubSettingActivity.launch(Intent(this, RoutingSettingActivity::class.java))
+            R.id.user_asset_setting -> startActivity(Intent(this, UserAssetActivity::class.java))
+            R.id.settings -> startActivity(
+                Intent(this, SettingsActivity::class.java)
+                    .putExtra("isRunning", mainViewModel.isRunning.value == true)
+            )
+
+            R.id.promotion -> Utils.openUri(this, "${Utils.decode(AppConfig.APP_PROMOTION_URL)}?t=${System.currentTimeMillis()}")
+            R.id.logcat -> startActivity(Intent(this, LogcatActivity::class.java))
+            R.id.check_for_update -> startActivity(Intent(this, CheckUpdateActivity::class.java))
+            R.id.about -> startActivity(Intent(this, AboutActivity::class.java))
+        }
+
+        binding.drawerLayout.closeDrawer(GravityCompat.START)
+        return true
+    }
+}

+ 362 - 0
app/src/main/java/com/v2ray/ang/ui/MainRecyclerAdapter.kt

@@ -0,0 +1,362 @@
+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.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.databinding.ItemRecyclerFooterBinding
+import com.v2ray.ang.databinding.ItemRecyclerMainBinding
+import com.v2ray.ang.dto.EConfigType
+import com.v2ray.ang.dto.ProfileItem
+import com.v2ray.ang.extension.toast
+import com.v2ray.ang.extension.toastError
+import com.v2ray.ang.extension.toastSuccess
+import com.v2ray.ang.handler.AngConfigManager
+import com.v2ray.ang.handler.MmkvManager
+import com.v2ray.ang.helper.ItemTouchHelperAdapter
+import com.v2ray.ang.helper.ItemTouchHelperViewHolder
+import com.v2ray.ang.handler.V2RayServiceManager
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+
+class MainRecyclerAdapter(val activity: MainActivity) : 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)
+
+    /**
+     * Gets the total number of items in the adapter (servers count + footer view)
+     * @return The total item count
+     */
+    override fun getItemCount() = mActivity.mainViewModel.serversCache.size + 1
+
+    override fun onBindViewHolder(holder: BaseViewHolder, position: Int) {
+        if (holder is MainViewHolder) {
+            val guid = mActivity.mainViewModel.serversCache[position].guid
+            val profile = mActivity.mainViewModel.serversCache[position].profile
+            val isCustom = profile.configType == EConfigType.CUSTOM
+
+            holder.itemView.setBackgroundColor(Color.TRANSPARENT)
+
+            //Name address
+            holder.itemMainBinding.tvName.text = profile.remarks
+            holder.itemMainBinding.tvStatistics.text = getAddress(profile)
+            holder.itemMainBinding.tvType.text = profile.configType.name
+
+            //TestResult
+            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))
+            } else {
+                holder.itemMainBinding.tvTestResult.setTextColor(ContextCompat.getColor(mActivity, R.color.colorPing))
+            }
+
+            //layoutIndicator
+            if (guid == MmkvManager.getSelectServer()) {
+                holder.itemMainBinding.layoutIndicator.setBackgroundResource(R.color.colorAccent)
+            } else {
+                holder.itemMainBinding.layoutIndicator.setBackgroundResource(0)
+            }
+
+            //subscription remarks
+            val subRemarks = getSubscriptionRemarks(profile)
+            holder.itemMainBinding.tvSubscription.text = subRemarks
+            holder.itemMainBinding.layoutSubscription.visibility = if (subRemarks.isEmpty()) View.GONE else View.VISIBLE
+
+            //layout
+            if (doubleColumnDisplay) {
+                holder.itemMainBinding.layoutShare.visibility = View.GONE
+                holder.itemMainBinding.layoutEdit.visibility = View.GONE
+                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)
+                }
+            } else {
+                holder.itemMainBinding.layoutShare.visibility = View.VISIBLE
+                holder.itemMainBinding.layoutEdit.visibility = View.VISIBLE
+                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)
+                }
+
+                holder.itemMainBinding.layoutEdit.setOnClickListener {
+                    editServer(guid, profile)
+                }
+                holder.itemMainBinding.layoutRemove.setOnClickListener {
+                    removeServer(guid, position)
+                }
+            }
+
+            holder.itemMainBinding.infoContainer.setOnClickListener {
+                setSelectServer(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()}")
+//                }
+//            }
+//        }
+    }
+
+    /**
+     * Gets the server address information
+     * Hides part of IP or domain information for privacy protection
+     * @param profile The server configuration
+     * @return Formatted address string
+     */
+    private fun getAddress(profile: ProfileItem): String {
+        // Hide xxx:xxx:***/xxx.xxx.xxx.***
+        return "${
+            profile.server?.let {
+                if (it.contains(":"))
+                    it.split(":").take(2).joinToString(":", postfix = ":***")
+                else
+                    it.split('.').dropLast(1).joinToString(".", postfix = ".***")
+            }
+        } : ${profile.serverPort}"
+    }
+
+    /**
+     * Gets the subscription remarks information
+     * @param profile The server configuration
+     * @return Subscription remarks string, or empty string if none
+     */
+    private fun getSubscriptionRemarks(profile: ProfileItem): String {
+        val subRemarks =
+            if (mActivity.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))
+        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 {
+            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()) {
+            if (MmkvManager.decodeSettingsBool(AppConfig.PREF_CONFIRM_REMOVE) == true) {
+                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)
+            }
+        } else {
+            application.toast(R.string.toast_action_not_allowed)
+        }
+    }
+
+    /**
+     * 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)
+        notifyItemRemoved(position)
+        notifyItemRangeChanged(position, mActivity.mainViewModel.serversCache.size)
+    }
+
+    /**
+     * 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) {
+                V2RayServiceManager.stopVService(mActivity)
+                mActivity.lifecycleScope.launch {
+                    try {
+                        delay(500)
+                        V2RayServiceManager.startVService(mActivity)
+                    } catch (e: Exception) {
+                        Log.e(AppConfig.TAG, "Failed to restart V2Ray service", e)
+                    }
+                }
+            }
+        }
+    }
+
+    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BaseViewHolder {
+        return when (viewType) {
+            VIEW_TYPE_ITEM ->
+                MainViewHolder(ItemRecyclerMainBinding.inflate(LayoutInflater.from(parent.context), parent, false))
+
+            else ->
+                FooterViewHolder(ItemRecyclerFooterBinding.inflate(LayoutInflater.from(parent.context), parent, false))
+        }
+    }
+
+    override fun getItemViewType(position: Int): Int {
+        return if (position == mActivity.mainViewModel.serversCache.size) {
+            VIEW_TYPE_FOOTER
+        } else {
+            VIEW_TYPE_ITEM
+        }
+    }
+
+    open class BaseViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
+        fun onItemSelected() {
+            itemView.setBackgroundColor(Color.LTGRAY)
+        }
+
+        fun onItemClear() {
+            itemView.setBackgroundColor(0)
+        }
+    }
+
+    class MainViewHolder(val itemMainBinding: ItemRecyclerMainBinding) :
+        BaseViewHolder(itemMainBinding.root), ItemTouchHelperViewHolder
+
+    class FooterViewHolder(val itemFooterBinding: ItemRecyclerFooterBinding) :
+        BaseViewHolder(itemFooterBinding.root)
+
+    override fun onItemMove(fromPosition: Int, toPosition: Int): Boolean {
+        mActivity.mainViewModel.swapServer(fromPosition, toPosition)
+        notifyItemMoved(fromPosition, toPosition)
+        return true
+    }
+
+    override fun onItemMoveCompleted() {
+        // do nothing
+    }
+
+    override fun onItemDismiss(position: Int) {
+    }
+}

+ 285 - 0
app/src/main/java/com/v2ray/ang/ui/PerAppProxyActivity.kt

@@ -0,0 +1,285 @@
+package com.v2ray.ang.ui
+
+import android.annotation.SuppressLint
+import android.os.Bundle
+import android.text.TextUtils
+import android.util.Log
+import android.view.Menu
+import android.view.MenuItem
+import android.widget.Toast
+import androidx.appcompat.widget.SearchView
+import androidx.lifecycle.lifecycleScope
+import com.v2ray.ang.AppConfig
+import com.v2ray.ang.AppConfig.ANG_PACKAGE
+import com.v2ray.ang.R
+import com.v2ray.ang.databinding.ActivityBypassListBinding
+import com.v2ray.ang.dto.AppInfo
+import com.v2ray.ang.extension.toast
+import com.v2ray.ang.extension.toastSuccess
+import com.v2ray.ang.extension.v2RayApplication
+import com.v2ray.ang.handler.MmkvManager
+import com.v2ray.ang.handler.SettingsManager
+import com.v2ray.ang.util.AppManagerUtil
+import com.v2ray.ang.util.HttpUtil
+import com.v2ray.ang.util.Utils
+import es.dmoral.toasty.Toasty
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import java.text.Collator
+
+class PerAppProxyActivity : BaseActivity() {
+    private val binding by lazy { ActivityBypassListBinding.inflate(layoutInflater) }
+
+    private var adapter: PerAppProxyAdapter? = null
+    private var appsAll: List<AppInfo>? = null
+
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+        setContentView(binding.root)
+
+        title = getString(R.string.per_app_proxy_settings)
+
+        addCustomDividerToRecyclerView(binding.recyclerView, this, R.drawable.custom_divider)
+
+        lifecycleScope.launch {
+            try {
+                binding.pbWaiting.show()
+                val blacklist = MmkvManager.decodeSettingsStringSet(AppConfig.PREF_PER_APP_PROXY_SET)
+                val apps = withContext(Dispatchers.IO) {
+                    val appsList = AppManagerUtil.loadNetworkAppList(this@PerAppProxyActivity)
+
+                    if (blacklist != null) {
+                        appsList.forEach { app ->
+                            app.isSelected = if (blacklist.contains(app.packageName)) 1 else 0
+                        }
+                        appsList.sortedWith { p1, p2 ->
+                            when {
+                                p1.isSelected > p2.isSelected -> -1
+                                p1.isSelected < p2.isSelected -> 1
+                                p1.isSystemApp > p2.isSystemApp -> 1
+                                p1.isSystemApp < p2.isSystemApp -> -1
+                                p1.appName.lowercase() > p2.appName.lowercase() -> 1
+                                p1.appName.lowercase() < p2.appName.lowercase() -> -1
+                                p1.packageName > p2.packageName -> 1
+                                p1.packageName < p2.packageName -> -1
+                                else -> 0
+                            }
+                        }
+                    } else {
+                        val collator = Collator.getInstance()
+                        appsList.sortedWith(compareBy(collator) { it.appName })
+                    }
+                }
+
+                appsAll = apps
+                adapter = PerAppProxyAdapter(this@PerAppProxyActivity, apps, blacklist)
+                binding.recyclerView.adapter = adapter
+                binding.pbWaiting.hide()
+            } catch (e: Exception) {
+                binding.pbWaiting.hide()
+                Log.e(ANG_PACKAGE, "Error loading apps", e)
+            }
+        }
+
+        binding.switchPerAppProxy.setOnCheckedChangeListener { _, isChecked ->
+            MmkvManager.encodeSettings(AppConfig.PREF_PER_APP_PROXY, isChecked)
+        }
+        binding.switchPerAppProxy.isChecked = MmkvManager.decodeSettingsBool(AppConfig.PREF_PER_APP_PROXY, false)
+
+        binding.switchBypassApps.setOnCheckedChangeListener { _, isChecked ->
+            MmkvManager.encodeSettings(AppConfig.PREF_BYPASS_APPS, isChecked)
+        }
+        binding.switchBypassApps.isChecked = MmkvManager.decodeSettingsBool(AppConfig.PREF_BYPASS_APPS, false)
+
+        binding.layoutSwitchBypassAppsTips.setOnClickListener {
+            Toasty.info(this, R.string.summary_pref_per_app_proxy, Toast.LENGTH_LONG, true).show()
+        }
+    }
+
+    override fun onPause() {
+        super.onPause()
+        adapter?.let {
+            MmkvManager.encodeSettings(AppConfig.PREF_PER_APP_PROXY_SET, it.blacklist)
+        }
+    }
+
+    override fun onCreateOptionsMenu(menu: Menu): Boolean {
+        menuInflater.inflate(R.menu.menu_bypass_list, menu)
+
+        val searchItem = menu.findItem(R.id.search_view)
+        if (searchItem != null) {
+            val searchView = searchItem.actionView as SearchView
+            searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
+                override fun onQueryTextSubmit(query: String?): Boolean = false
+
+                override fun onQueryTextChange(newText: String?): Boolean {
+                    filterProxyApp(newText.orEmpty())
+                    return false
+                }
+            })
+        }
+
+
+        return super.onCreateOptionsMenu(menu)
+    }
+
+
+    @SuppressLint("NotifyDataSetChanged")
+    override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
+        R.id.select_all -> adapter?.let { it ->
+            val pkgNames = it.apps.map { it.packageName }
+            if (it.blacklist.containsAll(pkgNames)) {
+                it.apps.forEach {
+                    val packageName = it.packageName
+                    adapter?.blacklist?.remove(packageName)
+                }
+            } else {
+                it.apps.forEach {
+                    val packageName = it.packageName
+                    adapter?.blacklist?.add(packageName)
+                }
+            }
+            it.notifyDataSetChanged()
+            true
+        } == true
+
+        R.id.select_proxy_app -> {
+            selectProxyApp()
+            true
+        }
+
+        R.id.import_proxy_app -> {
+            importProxyApp()
+            true
+        }
+
+        R.id.export_proxy_app -> {
+            exportProxyApp()
+            true
+        }
+
+        else -> super.onOptionsItemSelected(item)
+    }
+
+    private fun selectProxyApp() {
+        toast(R.string.msg_downloading_content)
+        binding.pbWaiting.show()
+
+        val url = AppConfig.ANDROID_PACKAGE_NAME_LIST_URL
+        lifecycleScope.launch(Dispatchers.IO) {
+            var content = HttpUtil.getUrlContent(url, 5000)
+            if (content.isNullOrEmpty()) {
+                val httpPort = SettingsManager.getHttpPort()
+                content = HttpUtil.getUrlContent(url, 5000, httpPort) ?: ""
+            }
+            launch(Dispatchers.Main) {
+                Log.i(AppConfig.TAG, content)
+                selectProxyApp(content, true)
+                toastSuccess(R.string.toast_success)
+                binding.pbWaiting.hide()
+            }
+        }
+    }
+
+    private fun importProxyApp() {
+        val content = Utils.getClipboard(applicationContext)
+        if (TextUtils.isEmpty(content)) return
+        selectProxyApp(content, false)
+        toastSuccess(R.string.toast_success)
+    }
+
+    private fun exportProxyApp() {
+        var lst = binding.switchBypassApps.isChecked.toString()
+
+        adapter?.blacklist?.forEach block@{
+            lst = lst + System.getProperty("line.separator") + it
+        }
+        Utils.setClipboard(applicationContext, lst)
+        toastSuccess(R.string.toast_success)
+    }
+
+    @SuppressLint("NotifyDataSetChanged")
+    private fun selectProxyApp(content: String, force: Boolean): Boolean {
+        try {
+            val proxyApps = if (TextUtils.isEmpty(content)) {
+                Utils.readTextFromAssets(v2RayApplication, "proxy_packagename.txt")
+            } else {
+                content
+            }
+            if (TextUtils.isEmpty(proxyApps)) return false
+
+            adapter?.blacklist?.clear()
+
+            if (binding.switchBypassApps.isChecked) {
+                adapter?.let { it ->
+                    it.apps.forEach block@{
+                        val packageName = it.packageName
+                        Log.i(AppConfig.TAG, packageName)
+                        if (!inProxyApps(proxyApps, packageName, force)) {
+                            adapter?.blacklist?.add(packageName)
+                            println(packageName)
+                            return@block
+                        }
+                    }
+                    it.notifyDataSetChanged()
+                }
+            } else {
+                adapter?.let { it ->
+                    it.apps.forEach block@{
+                        val packageName = it.packageName
+                        Log.i(AppConfig.TAG, packageName)
+                        if (inProxyApps(proxyApps, packageName, force)) {
+                            adapter?.blacklist?.add(packageName)
+                            println(packageName)
+                            return@block
+                        }
+                    }
+                    it.notifyDataSetChanged()
+                }
+            }
+        } catch (e: Exception) {
+            Log.e(AppConfig.TAG, "Error selecting proxy app", e)
+            return false
+        }
+        return true
+    }
+
+    private fun inProxyApps(proxyApps: String, packageName: String, force: Boolean): Boolean {
+        if (force) {
+            if (packageName == "com.google.android.webview") return false
+            if (packageName.startsWith("com.google")) return true
+        }
+
+        return proxyApps.indexOf(packageName) >= 0
+    }
+
+    private fun filterProxyApp(content: String): Boolean {
+        val apps = ArrayList<AppInfo>()
+
+        val key = content.uppercase()
+        if (key.isNotEmpty()) {
+            appsAll?.forEach {
+                if (it.appName.uppercase().indexOf(key) >= 0
+                    || it.packageName.uppercase().indexOf(key) >= 0
+                ) {
+                    apps.add(it)
+                }
+            }
+        } else {
+            appsAll?.forEach {
+                apps.add(it)
+            }
+        }
+
+        adapter = PerAppProxyAdapter(this, apps, adapter?.blacklist)
+        binding.recyclerView.adapter = adapter
+        refreshData()
+        return true
+    }
+
+    @SuppressLint("NotifyDataSetChanged")
+    fun refreshData() {
+        adapter?.notifyDataSetChanged()
+    }
+}

+ 88 - 0
app/src/main/java/com/v2ray/ang/ui/PerAppProxyAdapter.kt

@@ -0,0 +1,88 @@
+package com.v2ray.ang.ui
+
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.recyclerview.widget.RecyclerView
+import com.v2ray.ang.databinding.ItemRecyclerBypassListBinding
+import com.v2ray.ang.dto.AppInfo
+
+class PerAppProxyAdapter(val activity: BaseActivity, val apps: List<AppInfo>, blacklist: MutableSet<String>?) :
+    RecyclerView.Adapter<PerAppProxyAdapter.BaseViewHolder>() {
+
+    companion object {
+        private const val VIEW_TYPE_HEADER = 0
+        private const val VIEW_TYPE_ITEM = 1
+    }
+
+    val blacklist = if (blacklist == null) HashSet() else HashSet(blacklist)
+
+    override fun onBindViewHolder(holder: BaseViewHolder, position: Int) {
+        if (holder is AppViewHolder) {
+            val appInfo = apps[position - 1]
+            holder.bind(appInfo)
+        }
+    }
+
+    override fun getItemCount() = apps.size + 1
+
+    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BaseViewHolder {
+        val ctx = parent.context
+
+        return when (viewType) {
+            VIEW_TYPE_HEADER -> {
+                val view = View(ctx)
+                view.layoutParams = ViewGroup.LayoutParams(
+                    ViewGroup.LayoutParams.MATCH_PARENT,
+                    0
+                )
+                BaseViewHolder(view)
+            }
+//            VIEW_TYPE_ITEM -> AppViewHolder(ctx.layoutInflater
+//                    .inflate(R.layout.item_recycler_bypass_list, parent, false))
+
+            else -> AppViewHolder(ItemRecyclerBypassListBinding.inflate(LayoutInflater.from(ctx), parent, false))
+
+        }
+    }
+
+    override fun getItemViewType(position: Int) = if (position == 0) VIEW_TYPE_HEADER else VIEW_TYPE_ITEM
+
+    open class BaseViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView)
+
+    inner class AppViewHolder(private val itemBypassBinding: ItemRecyclerBypassListBinding) : BaseViewHolder(itemBypassBinding.root),
+        View.OnClickListener {
+        private val inBlacklist: Boolean get() = blacklist.contains(appInfo.packageName)
+        private lateinit var appInfo: AppInfo
+
+        fun bind(appInfo: AppInfo) {
+            this.appInfo = appInfo
+
+            // Set app icon and name
+            itemBypassBinding.icon.setImageDrawable(appInfo.appIcon)
+            itemBypassBinding.name.text = if (appInfo.isSystemApp) {
+                String.format("** %s", appInfo.appName)
+            } else {
+                appInfo.appName
+            }
+
+            // Set package name and checkbox state
+            itemBypassBinding.packageName.text = appInfo.packageName
+            itemBypassBinding.checkBox.isChecked = inBlacklist
+
+            // Handle item click to toggle blacklist status
+            itemView.setOnClickListener(this)
+        }
+
+
+        override fun onClick(v: View?) {
+            if (inBlacklist) {
+                blacklist.remove(appInfo.packageName)
+                itemBypassBinding.checkBox.isChecked = false
+            } else {
+                blacklist.add(appInfo.packageName)
+                itemBypassBinding.checkBox.isChecked = true
+            }
+        }
+    }
+}

+ 132 - 0
app/src/main/java/com/v2ray/ang/ui/RoutingEditActivity.kt

@@ -0,0 +1,132 @@
+package com.v2ray.ang.ui
+
+import android.os.Bundle
+import android.view.Menu
+import android.view.MenuItem
+import androidx.appcompat.app.AlertDialog
+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.toast
+import com.v2ray.ang.extension.toastSuccess
+import com.v2ray.ang.handler.SettingsManager
+import com.v2ray.ang.util.Utils
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+
+class RoutingEditActivity : BaseActivity() {
+    private val binding by lazy { ActivityRoutingEditBinding.inflate(layoutInflater) }
+    private val position by lazy { intent.getIntExtra("position", -1) }
+
+    private val outbound_tag: Array<out String> by lazy {
+        resources.getStringArray(R.array.outbound_tag)
+    }
+
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+        setContentView(binding.root)
+        title = getString(R.string.routing_settings_rule_title)
+
+        val rulesetItem = SettingsManager.getRoutingRuleset(position)
+        if (rulesetItem != null) {
+            bindingServer(rulesetItem)
+        } else {
+            clearServer()
+        }
+    }
+
+    private fun bindingServer(rulesetItem: RulesetItem): Boolean {
+        binding.etRemarks.text = Utils.getEditable(rulesetItem.remarks)
+        binding.chkLocked.isChecked = rulesetItem.locked == true
+        binding.etDomain.text = Utils.getEditable(rulesetItem.domain?.joinToString(","))
+        binding.etIp.text = Utils.getEditable(rulesetItem.ip?.joinToString(","))
+        binding.etPort.text = Utils.getEditable(rulesetItem.port)
+        binding.etProtocol.text = Utils.getEditable(rulesetItem.protocol?.joinToString(","))
+        binding.etNetwork.text = Utils.getEditable(rulesetItem.network)
+        val outbound = Utils.arrayFind(outbound_tag, rulesetItem.outboundTag)
+        binding.spOutboundTag.setSelection(outbound)
+
+        return true
+    }
+
+    private fun clearServer(): Boolean {
+        binding.etRemarks.text = null
+        binding.spOutboundTag.setSelection(0)
+        return true
+    }
+
+    private fun saveServer(): Boolean {
+        val rulesetItem = SettingsManager.getRoutingRuleset(position) ?: RulesetItem()
+
+        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() }
+            outboundTag = outbound_tag[binding.spOutboundTag.selectedItemPosition]
+        }
+
+        if (rulesetItem.remarks.isNullOrEmpty()) {
+            toast(R.string.sub_setting_remarks)
+            return false
+        }
+
+        SettingsManager.saveRoutingRuleset(position, rulesetItem)
+        toastSuccess(R.string.toast_success)
+        finish()
+        return true
+    }
+
+
+    private fun deleteServer(): Boolean {
+        if (position >= 0) {
+            AlertDialog.Builder(this).setMessage(R.string.del_config_comfirm)
+                .setPositiveButton(android.R.string.ok) { _, _ ->
+                    lifecycleScope.launch(Dispatchers.IO) {
+                        SettingsManager.removeRoutingRuleset(position)
+                        launch(Dispatchers.Main) {
+                            finish()
+                        }
+                    }
+                }
+                .setNegativeButton(android.R.string.cancel) { _, _ ->
+                    // do nothing
+                }
+                .show()
+        }
+        return true
+    }
+
+    override fun onCreateOptionsMenu(menu: Menu): Boolean {
+        menuInflater.inflate(R.menu.action_server, menu)
+        val del_config = menu.findItem(R.id.del_config)
+
+        if (position < 0) {
+            del_config?.isVisible = false
+        }
+
+        return super.onCreateOptionsMenu(menu)
+    }
+
+    override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
+        R.id.del_config -> {
+            deleteServer()
+            true
+        }
+
+        R.id.save_config -> {
+            saveServer()
+            true
+        }
+
+        else -> super.onOptionsItemSelected(item)
+    }
+
+}

+ 204 - 0
app/src/main/java/com/v2ray/ang/ui/RoutingSettingActivity.kt

@@ -0,0 +1,204 @@
+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.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.databinding.ActivityRoutingSettingBinding
+import com.v2ray.ang.dto.RulesetItem
+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.helper.SimpleItemTouchHelperCallback
+import com.v2ray.ang.util.JsonUtil
+import com.v2ray.ang.util.Utils
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+class RoutingSettingActivity : BaseActivity() {
+    private val binding by lazy { ActivityRoutingSettingBinding.inflate(layoutInflater) }
+
+    var rulesets: MutableList<RulesetItem> = mutableListOf()
+    private val adapter by lazy { RoutingSettingRecyclerAdapter(this) }
+    private var mItemTouchHelper: ItemTouchHelper? = null
+    private val routing_domain_strategy: Array<out String> by lazy {
+        resources.getStringArray(R.array.routing_domain_strategy)
+    }
+    private val preset_rulesets: Array<out String> by lazy {
+        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)
+
+        title = getString(R.string.routing_settings_title)
+
+        binding.recyclerView.setHasFixedSize(true)
+        binding.recyclerView.layoutManager = LinearLayoutManager(this)
+        addCustomDividerToRecyclerView(binding.recyclerView, this, R.drawable.custom_divider)
+        binding.recyclerView.adapter = adapter
+
+        mItemTouchHelper = ItemTouchHelper(SimpleItemTouchHelperCallback(adapter))
+        mItemTouchHelper?.attachToRecyclerView(binding.recyclerView)
+
+        binding.tvDomainStrategySummary.text = getDomainStrategy()
+        binding.layoutDomainStrategy.setOnClickListener {
+            setDomainStrategy()
+        }
+    }
+
+    override fun onResume() {
+        super.onResume()
+        refreshData()
+    }
+
+    override fun onCreateOptionsMenu(menu: Menu): Boolean {
+        menuInflater.inflate(R.menu.menu_routing_setting, menu)
+        return super.onCreateOptionsMenu(menu)
+    }
+
+    override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) {
+        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.export_rulesets_to_clipboard -> export2Clipboard().let { true }
+        else -> super.onOptionsItemSelected(item)
+    }
+
+    private fun getDomainStrategy(): String {
+        return MmkvManager.decodeSettingsString(AppConfig.PREF_ROUTING_DOMAIN_STRATEGY) ?: routing_domain_strategy.first()
+    }
+
+    private fun setDomainStrategy() {
+        android.app.AlertDialog.Builder(this).setItems(routing_domain_strategy.asList().toTypedArray()) { _, i ->
+            try {
+                val value = routing_domain_strategy[i]
+                MmkvManager.encodeSettings(AppConfig.PREF_ROUTING_DOMAIN_STRATEGY, value)
+                binding.tvDomainStrategySummary.text = value
+            } catch (e: Exception) {
+                Log.e(AppConfig.TAG, "Failed to set domain strategy", e)
+            }
+        }.show()
+    }
+
+    private fun importPredefined() {
+        AlertDialog.Builder(this).setItems(preset_rulesets.asList().toTypedArray()) { _, i ->
+            AlertDialog.Builder(this).setMessage(R.string.routing_settings_import_rulesets_tip)
+                .setPositiveButton(android.R.string.ok) { _, _ ->
+                    try {
+                        lifecycleScope.launch(Dispatchers.IO) {
+                            SettingsManager.resetRoutingRulesetsFromPresets(this@RoutingSettingActivity, i)
+                            launch(Dispatchers.Main) {
+                                refreshData()
+                                toastSuccess(R.string.toast_success)
+                            }
+                        }
+                    } catch (e: Exception) {
+                        Log.e(AppConfig.TAG, "Failed to import predefined ruleset", e)
+                    }
+                }
+                .setNegativeButton(android.R.string.cancel) { _, _ ->
+                    //do nothing
+                }
+                .show()
+        }.show()
+    }
+
+    private fun importFromClipboard() {
+        AlertDialog.Builder(this).setMessage(R.string.routing_settings_import_rulesets_tip)
+            .setPositiveButton(android.R.string.ok) { _, _ ->
+                val clipboard = try {
+                    Utils.getClipboard(this)
+                } catch (e: Exception) {
+                    Log.e(AppConfig.TAG, "Failed to get clipboard content", e)
+                    toastError(R.string.toast_failure)
+                    return@setPositiveButton
+                }
+                lifecycleScope.launch(Dispatchers.IO) {
+                    val result = SettingsManager.resetRoutingRulesets(clipboard)
+                    withContext(Dispatchers.Main) {
+                        if (result) {
+                            refreshData()
+                            toastSuccess(R.string.toast_success)
+                        } else {
+                            toastError(R.string.toast_failure)
+                        }
+                    }
+                }
+            }
+            .setNegativeButton(android.R.string.cancel) { _, _ ->
+                //do nothing
+            }
+            .show()
+    }
+
+    private fun export2Clipboard() {
+        val rulesetList = MmkvManager.decodeRoutingRulesets()
+        if (rulesetList.isNullOrEmpty()) {
+            toastError(R.string.toast_failure)
+        } else {
+            Utils.setClipboard(this, JsonUtil.toJson(rulesetList))
+            toastSuccess(R.string.toast_success)
+        }
+    }
+
+    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)
+            .setPositiveButton(android.R.string.ok) { _, _ ->
+                lifecycleScope.launch(Dispatchers.IO) {
+                    val result = SettingsManager.resetRoutingRulesets(qrcode)
+                    withContext(Dispatchers.Main) {
+                        if (result) {
+                            refreshData()
+                            toastSuccess(R.string.toast_success)
+                        } else {
+                            toastError(R.string.toast_failure)
+                        }
+                    }
+                }
+            }
+            .setNegativeButton(android.R.string.cancel) { _, _ ->
+                //do nothing
+            }
+            .show()
+        return true
+    }
+
+    @SuppressLint("NotifyDataSetChanged")
+    fun refreshData() {
+        rulesets.clear()
+        rulesets.addAll(MmkvManager.decodeRoutingRulesets() ?: mutableListOf())
+        adapter.notifyDataSetChanged()
+    }
+}

+ 80 - 0
app/src/main/java/com/v2ray/ang/ui/RoutingSettingRecyclerAdapter.kt

@@ -0,0 +1,80 @@
+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.databinding.ItemRecyclerRoutingSettingBinding
+import com.v2ray.ang.handler.SettingsManager
+import com.v2ray.ang.helper.ItemTouchHelperAdapter
+import com.v2ray.ang.helper.ItemTouchHelperViewHolder
+
+class RoutingSettingRecyclerAdapter(val activity: RoutingSettingActivity) : RecyclerView.Adapter<RoutingSettingRecyclerAdapter.MainViewHolder>(),
+    ItemTouchHelperAdapter {
+
+    private var mActivity: RoutingSettingActivity = activity
+    override fun getItemCount() = mActivity.rulesets.size
+
+    override fun onBindViewHolder(holder: MainViewHolder, position: Int) {
+        val ruleset = mActivity.rulesets[position]
+
+        holder.itemRoutingSettingBinding.remarks.text = ruleset.remarks
+        holder.itemRoutingSettingBinding.domainIp.text = (ruleset.domain ?: ruleset.ip ?: ruleset.port)?.toString()
+        holder.itemRoutingSettingBinding.outboundTag.text = ruleset.outboundTag
+        holder.itemRoutingSettingBinding.chkEnable.isChecked = ruleset.enabled
+        holder.itemRoutingSettingBinding.imgLocked.isVisible = ruleset.locked == true
+        holder.itemView.setBackgroundColor(Color.TRANSPARENT)
+
+        holder.itemRoutingSettingBinding.layoutEdit.setOnClickListener {
+            mActivity.startActivity(
+                Intent(mActivity, RoutingEditActivity::class.java)
+                    .putExtra("position", position)
+            )
+        }
+
+        holder.itemRoutingSettingBinding.chkEnable.setOnCheckedChangeListener { it, isChecked ->
+            if (!it.isPressed) return@setOnCheckedChangeListener
+            ruleset.enabled = isChecked
+            SettingsManager.saveRoutingRuleset(position, ruleset)
+        }
+    }
+
+    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MainViewHolder {
+        return MainViewHolder(
+            ItemRecyclerRoutingSettingBinding.inflate(
+                LayoutInflater.from(parent.context),
+                parent,
+                false
+            )
+        )
+    }
+
+    class MainViewHolder(val itemRoutingSettingBinding: ItemRecyclerRoutingSettingBinding) :
+        BaseViewHolder(itemRoutingSettingBinding.root), ItemTouchHelperViewHolder
+
+    open class BaseViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
+        fun onItemSelected() {
+            itemView.setBackgroundColor(Color.LTGRAY)
+        }
+
+        fun onItemClear() {
+            itemView.setBackgroundColor(0)
+        }
+    }
+
+    override fun onItemMove(fromPosition: Int, toPosition: Int): Boolean {
+        SettingsManager.swapRoutingRuleset(fromPosition, toPosition)
+        notifyItemMoved(fromPosition, toPosition)
+        return true
+    }
+
+    override fun onItemMoveCompleted() {
+        mActivity.refreshData()
+    }
+
+    override fun onItemDismiss(position: Int) {
+    }
+}

+ 52 - 0
app/src/main/java/com/v2ray/ang/ui/ScScannerActivity.kt

@@ -0,0 +1,52 @@
+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()
+        }
+    }
+
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+        setContentView(R.layout.activity_none)
+        importQRcode()
+    }
+
+    private fun importQRcode(): Boolean {
+        requestCameraPermissionLauncher.launch(Manifest.permission.CAMERA)
+        return true
+    }
+
+    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)
+            }
+
+            startActivity(Intent(this, MainActivity::class.java))
+        }
+        finish()
+    }
+}

+ 21 - 0
app/src/main/java/com/v2ray/ang/ui/ScSwitchActivity.kt

@@ -0,0 +1,21 @@
+package com.v2ray.ang.ui
+
+import android.os.Bundle
+import com.v2ray.ang.R
+import com.v2ray.ang.handler.V2RayServiceManager
+
+class ScSwitchActivity : BaseActivity() {
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+        moveTaskToBack(true)
+
+        setContentView(R.layout.activity_none)
+
+        if (V2RayServiceManager.isRunning()) {
+            V2RayServiceManager.stopVService(this)
+        } else {
+            V2RayServiceManager.startVServiceFromToggle(this)
+        }
+        finish()
+    }
+}

+ 134 - 0
app/src/main/java/com/v2ray/ang/ui/ScannerActivity.kt

@@ -0,0 +1,134 @@
+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.extension.toast
+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.ScannerConfig
+
+class ScannerActivity : BaseActivity() {
+
+
+    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)
+
+        if (MmkvManager.decodeSettingsBool(AppConfig.PREF_START_SCAN_IMMEDIATE) == true) {
+            launchScan()
+        }
+    }
+
+    private fun launchScan() {
+        scanQrCode.launch(
+            ScannerConfig.build {
+                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
+            }
+        )
+    }
+
+    private fun handleResult(result: QRResult) {
+        if (result is QRResult.QRSuccess) {
+            finished(result.content.rawValue.orEmpty())
+        } else {
+            finish()
+        }
+    }
+
+    private fun finished(text: String) {
+        val intent = Intent()
+        intent.putExtra("SCAN_RESULT", text)
+        setResult(RESULT_OK, intent)
+        finish()
+    }
+
+    override fun onCreateOptionsMenu(menu: Menu): Boolean {
+        menuInflater.inflate(R.menu.menu_scanner, menu)
+        return true
+    }
+
+    override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
+        R.id.scan_code -> {
+            launchScan()
+            true
+        }
+
+        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)
+            }
+            true
+        }
+
+
+        else -> super.onOptionsItemSelected(item)
+    }
+
+    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)
+
+        try {
+            chooseFile.launch(Intent.createChooser(intent, getString(R.string.title_file_chooser)))
+        } catch (ex: android.content.ActivityNotFoundException) {
+            toast(R.string.toast_require_file_manager)
+        }
+    }
+}

+ 671 - 0
app/src/main/java/com/v2ray/ang/ui/ServerActivity.kt

@@ -0,0 +1,671 @@
+package com.v2ray.ang.ui
+
+import android.os.Bundle
+import android.text.TextUtils
+import android.view.Menu
+import android.view.MenuItem
+import android.view.View
+import android.widget.AdapterView
+import android.widget.ArrayAdapter
+import android.widget.EditText
+import android.widget.LinearLayout
+import android.widget.Spinner
+import android.widget.TextView
+import androidx.appcompat.app.AlertDialog
+import com.v2ray.ang.AppConfig
+import com.v2ray.ang.AppConfig.DEFAULT_PORT
+import com.v2ray.ang.AppConfig.PREF_ALLOW_INSECURE
+import com.v2ray.ang.AppConfig.REALITY
+import com.v2ray.ang.AppConfig.TLS
+import com.v2ray.ang.AppConfig.WIREGUARD_LOCAL_ADDRESS_V4
+import com.v2ray.ang.AppConfig.WIREGUARD_LOCAL_MTU
+import com.v2ray.ang.R
+import com.v2ray.ang.dto.EConfigType
+import com.v2ray.ang.dto.NetworkType
+import com.v2ray.ang.dto.ProfileItem
+import com.v2ray.ang.extension.isNotNullEmpty
+import com.v2ray.ang.extension.toast
+import com.v2ray.ang.extension.toastSuccess
+import com.v2ray.ang.handler.MmkvManager
+import com.v2ray.ang.util.JsonUtil
+import com.v2ray.ang.util.Utils
+
+class ServerActivity : BaseActivity() {
+
+    private val editGuid by lazy { intent.getStringExtra("guid").orEmpty() }
+    private val isRunning by lazy {
+        intent.getBooleanExtra("isRunning", false)
+                && editGuid.isNotEmpty()
+                && editGuid == MmkvManager.getSelectServer()
+    }
+    private val createConfigType by lazy {
+        EConfigType.fromInt(intent.getIntExtra("createConfigType", EConfigType.VMESS.value))
+            ?: EConfigType.VMESS
+    }
+    private val subscriptionId by lazy {
+        intent.getStringExtra("subscriptionId")
+    }
+
+    private val securitys: Array<out String> by lazy {
+        resources.getStringArray(R.array.securitys)
+    }
+    private val shadowsocksSecuritys: Array<out String> by lazy {
+        resources.getStringArray(R.array.ss_securitys)
+    }
+    private val flows: Array<out String> by lazy {
+        resources.getStringArray(R.array.flows)
+    }
+    private val networks: Array<out String> by lazy {
+        resources.getStringArray(R.array.networks)
+    }
+    private val tcpTypes: Array<out String> by lazy {
+        resources.getStringArray(R.array.header_type_tcp)
+    }
+    private val kcpAndQuicTypes: Array<out String> by lazy {
+        resources.getStringArray(R.array.header_type_kcp_and_quic)
+    }
+    private val grpcModes: Array<out String> by lazy {
+        resources.getStringArray(R.array.mode_type_grpc)
+    }
+    private val streamSecuritys: Array<out String> by lazy {
+        resources.getStringArray(R.array.streamsecurityxs)
+    }
+    private val allowinsecures: Array<out String> by lazy {
+        resources.getStringArray(R.array.allowinsecures)
+    }
+    private val uTlsItems: Array<out String> by lazy {
+        resources.getStringArray(R.array.streamsecurity_utls)
+    }
+    private val alpns: Array<out String> by lazy {
+        resources.getStringArray(R.array.streamsecurity_alpn)
+    }
+    private val xhttpMode: Array<out String> by lazy {
+        resources.getStringArray(R.array.xhttp_mode)
+    }
+
+
+    // Kotlin synthetics was used, but since it is removed in 1.8. We switch to old manual approach.
+    // We don't use AndroidViewBinding because, it is better to share similar logics for different
+    // protocols. Use findViewById manually ensures the xml are de-coupled with the activity logic.
+    private val et_remarks: EditText by lazy { findViewById(R.id.et_remarks) }
+    private val et_address: EditText by lazy { findViewById(R.id.et_address) }
+    private val et_port: EditText by lazy { findViewById(R.id.et_port) }
+    private val et_id: EditText by lazy { findViewById(R.id.et_id) }
+    private val et_security: EditText? by lazy { findViewById(R.id.et_security) }
+    private val sp_flow: Spinner? by lazy { findViewById(R.id.sp_flow) }
+    private val sp_security: Spinner? by lazy { findViewById(R.id.sp_security) }
+    private val sp_stream_security: Spinner? by lazy { findViewById(R.id.sp_stream_security) }
+    private val sp_allow_insecure: Spinner? by lazy { findViewById(R.id.sp_allow_insecure) }
+    private val container_allow_insecure: LinearLayout? by lazy { findViewById(R.id.lay_allow_insecure) }
+    private val et_sni: EditText? by lazy { findViewById(R.id.et_sni) }
+    private val container_sni: LinearLayout? by lazy { findViewById(R.id.lay_sni) }
+    private val sp_stream_fingerprint: Spinner? by lazy { findViewById(R.id.sp_stream_fingerprint) } //uTLS
+    private val container_fingerprint: LinearLayout? by lazy { findViewById(R.id.lay_stream_fingerprint) }
+    private val sp_network: Spinner? by lazy { findViewById(R.id.sp_network) }
+    private val sp_header_type: Spinner? by lazy { findViewById(R.id.sp_header_type) }
+    private val sp_header_type_title: TextView? by lazy { findViewById(R.id.sp_header_type_title) }
+    private val tv_request_host: TextView? by lazy { findViewById(R.id.tv_request_host) }
+    private val et_request_host: EditText? by lazy { findViewById(R.id.et_request_host) }
+    private val tv_path: TextView? by lazy { findViewById(R.id.tv_path) }
+    private val et_path: EditText? by lazy { findViewById(R.id.et_path) }
+    private val sp_stream_alpn: Spinner? by lazy { findViewById(R.id.sp_stream_alpn) } //uTLS
+    private val container_alpn: LinearLayout? by lazy { findViewById(R.id.lay_stream_alpn) }
+    private val et_public_key: EditText? by lazy { findViewById(R.id.et_public_key) }
+    private val et_preshared_key: EditText? by lazy { findViewById(R.id.et_preshared_key) }
+    private val container_public_key: LinearLayout? by lazy { findViewById(R.id.lay_public_key) }
+    private val et_short_id: EditText? by lazy { findViewById(R.id.et_short_id) }
+    private val container_short_id: LinearLayout? by lazy { findViewById(R.id.lay_short_id) }
+    private val et_spider_x: EditText? by lazy { findViewById(R.id.et_spider_x) }
+    private val container_spider_x: LinearLayout? by lazy { findViewById(R.id.lay_spider_x) }
+    private val et_mldsa65_verify: EditText? by lazy { findViewById(R.id.et_mldsa65_verify) }
+    private val container_mldsa65_verify: LinearLayout? by lazy { findViewById(R.id.lay_mldsa65_verify) }
+    private val et_reserved1: EditText? by lazy { findViewById(R.id.et_reserved1) }
+    private val et_local_address: EditText? by lazy { findViewById(R.id.et_local_address) }
+    private val et_local_mtu: EditText? by lazy { findViewById(R.id.et_local_mtu) }
+    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) }
+    private val layout_extra: LinearLayout? by lazy { findViewById(R.id.layout_extra) }
+
+
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+        title = getString(R.string.title_server)
+
+        val config = MmkvManager.decodeServerConfig(editGuid)
+        when (config?.configType ?: createConfigType) {
+            EConfigType.VMESS -> setContentView(R.layout.activity_server_vmess)
+            EConfigType.CUSTOM -> return
+            EConfigType.SHADOWSOCKS -> setContentView(R.layout.activity_server_shadowsocks)
+            EConfigType.SOCKS -> setContentView(R.layout.activity_server_socks)
+            EConfigType.HTTP -> setContentView(R.layout.activity_server_socks)
+            EConfigType.VLESS -> setContentView(R.layout.activity_server_vless)
+            EConfigType.TROJAN -> setContentView(R.layout.activity_server_trojan)
+            EConfigType.WIREGUARD -> setContentView(R.layout.activity_server_wireguard)
+            EConfigType.HYSTERIA2 -> setContentView(R.layout.activity_server_hysteria2)
+        }
+        sp_network?.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
+            override fun onItemSelected(
+                parent: AdapterView<*>?,
+                view: View?,
+                position: Int,
+                id: Long,
+            ) {
+                val types = transportTypes(networks[position])
+                sp_header_type?.isEnabled = types.size > 1
+                val adapter =
+                    ArrayAdapter(this@ServerActivity, android.R.layout.simple_spinner_item, types)
+                adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
+                sp_header_type?.adapter = adapter
+                sp_header_type_title?.text =
+                    when (networks[position]) {
+                        NetworkType.GRPC.type -> getString(R.string.server_lab_mode_type)
+                        NetworkType.XHTTP.type -> getString(R.string.server_lab_xhttp_mode)
+                        else -> getString(R.string.server_lab_head_type)
+                    }.orEmpty()
+                sp_header_type?.setSelection(
+                    Utils.arrayFind(
+                        types,
+                        when (networks[position]) {
+                            NetworkType.GRPC.type -> config?.mode
+                            NetworkType.XHTTP.type -> config?.xhttpMode
+                            else -> config?.headerType
+                        }.orEmpty()
+                    )
+                )
+
+                et_request_host?.text = Utils.getEditable(
+                    when (networks[position]) {
+                        //"quic" -> config?.quicSecurity
+                        NetworkType.GRPC.type -> config?.authority
+                        else -> config?.host
+                    }.orEmpty()
+                )
+                et_path?.text = Utils.getEditable(
+                    when (networks[position]) {
+                        NetworkType.KCP.type -> config?.seed
+                        //"quic" -> config?.quicKey
+                        NetworkType.GRPC.type -> config?.serviceName
+                        else -> config?.path
+                    }.orEmpty()
+                )
+
+                tv_request_host?.text = Utils.getEditable(
+                    getString(
+                        when (networks[position]) {
+                            NetworkType.TCP.type -> R.string.server_lab_request_host_http
+                            NetworkType.WS.type -> R.string.server_lab_request_host_ws
+                            NetworkType.HTTP_UPGRADE.type -> R.string.server_lab_request_host_httpupgrade
+                            NetworkType.XHTTP.type -> R.string.server_lab_request_host_xhttp
+                            NetworkType.H2.type -> R.string.server_lab_request_host_h2
+                            //"quic" -> R.string.server_lab_request_host_quic
+                            NetworkType.GRPC.type -> R.string.server_lab_request_host_grpc
+                            else -> R.string.server_lab_request_host
+                        }
+                    )
+                )
+
+                tv_path?.text = Utils.getEditable(
+                    getString(
+                        when (networks[position]) {
+                            NetworkType.KCP.type -> R.string.server_lab_path_kcp
+                            NetworkType.WS.type -> R.string.server_lab_path_ws
+                            NetworkType.HTTP_UPGRADE.type -> R.string.server_lab_path_httpupgrade
+                            NetworkType.XHTTP.type -> R.string.server_lab_path_xhttp
+                            NetworkType.H2.type -> R.string.server_lab_path_h2
+                            //"quic" -> R.string.server_lab_path_quic
+                            NetworkType.GRPC.type -> R.string.server_lab_path_grpc
+                            else -> R.string.server_lab_path
+                        }
+                    )
+                )
+                et_extra?.text = Utils.getEditable(
+                    when (networks[position]) {
+                        NetworkType.XHTTP.type -> config?.xhttpExtra
+                        else -> null
+                    }.orEmpty()
+                )
+
+                layout_extra?.visibility =
+                    when (networks[position]) {
+                        NetworkType.XHTTP.type -> View.VISIBLE
+                        else -> View.GONE
+                    }
+            }
+
+            override fun onNothingSelected(parent: AdapterView<*>?) {
+                // do nothing
+            }
+        }
+        sp_stream_security?.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
+            override fun onItemSelected(
+                parent: AdapterView<*>?,
+                view: View?,
+                position: Int,
+                id: Long,
+            ) {
+                val isBlank = streamSecuritys[position].isBlank()
+                val isTLS = streamSecuritys[position] == TLS
+
+                when {
+                    // Case 1: Null or blank
+                    isBlank -> {
+                        listOf(
+                            container_sni,
+                            container_fingerprint,
+                            container_alpn,
+                            container_allow_insecure,
+                            container_public_key,
+                            container_short_id,
+                            container_spider_x,
+                            container_mldsa65_verify
+                        ).forEach { it?.visibility = View.GONE }
+                    }
+
+                    // Case 2: TLS value
+                    isTLS -> {
+                        listOf(
+                            container_sni,
+                            container_fingerprint,
+                            container_alpn
+                        ).forEach { it?.visibility = View.VISIBLE }
+                        container_allow_insecure?.visibility = View.VISIBLE
+                        listOf(
+                            container_public_key,
+                            container_short_id,
+                            container_spider_x,
+                            container_mldsa65_verify
+                        ).forEach { it?.visibility = View.GONE }
+                    }
+
+                    // Case 3: Other reality values
+                    else -> {
+                        listOf(container_sni, container_fingerprint).forEach {
+                            it?.visibility = View.VISIBLE
+                        }
+                        container_alpn?.visibility = View.GONE
+                        container_allow_insecure?.visibility = View.GONE
+                        listOf(
+                            container_public_key,
+                            container_short_id,
+                            container_spider_x,
+                            container_mldsa65_verify
+                        ).forEach { it?.visibility = View.VISIBLE }
+                    }
+                }
+            }
+
+            override fun onNothingSelected(p0: AdapterView<*>?) {
+                // do nothing
+            }
+        }
+        if (config != null) {
+            bindingServer(config)
+        } else {
+            clearServer()
+        }
+    }
+
+    /**
+     * binding selected server config
+     */
+    private fun bindingServer(config: ProfileItem): Boolean {
+
+        et_remarks.text = Utils.getEditable(config.remarks)
+        et_address.text = Utils.getEditable(config.server.orEmpty())
+        et_port.text = Utils.getEditable(config.serverPort ?: DEFAULT_PORT.toString())
+        et_id.text = Utils.getEditable(config.password.orEmpty())
+
+        if (config.configType == EConfigType.SOCKS || config.configType == EConfigType.HTTP) {
+            et_security?.text = Utils.getEditable(config.username.orEmpty())
+        } else if (config.configType == EConfigType.VLESS) {
+            et_security?.text = Utils.getEditable(config.method.orEmpty())
+            val flow = Utils.arrayFind(flows, config.flow.orEmpty())
+            if (flow >= 0) {
+                sp_flow?.setSelection(flow)
+            }
+        } else if (config.configType == EConfigType.WIREGUARD) {
+            et_id.text = Utils.getEditable(config.secretKey.orEmpty())
+            et_public_key?.text = Utils.getEditable(config.publicKey.orEmpty())
+            et_preshared_key?.visibility = View.VISIBLE
+            et_preshared_key?.text = Utils.getEditable(config.preSharedKey.orEmpty())
+            et_reserved1?.text = Utils.getEditable(config.reserved ?: "0,0,0")
+            et_local_address?.text = Utils.getEditable(
+                config.localAddress ?: WIREGUARD_LOCAL_ADDRESS_V4
+            )
+            et_local_mtu?.text = Utils.getEditable(config.mtu?.toString() ?: WIREGUARD_LOCAL_MTU)
+        } else if (config.configType == EConfigType.HYSTERIA2) {
+            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)
+        }
+        val securityEncryptions =
+            if (config.configType == EConfigType.SHADOWSOCKS) shadowsocksSecuritys else securitys
+        val security = Utils.arrayFind(securityEncryptions, config.method.orEmpty())
+        if (security >= 0) {
+            sp_security?.setSelection(security)
+        }
+
+        val streamSecurity = Utils.arrayFind(streamSecuritys, config.security.orEmpty())
+        if (streamSecurity >= 0) {
+            sp_stream_security?.setSelection(streamSecurity)
+            container_sni?.visibility = View.VISIBLE
+            container_fingerprint?.visibility = View.VISIBLE
+            container_alpn?.visibility = View.VISIBLE
+
+            et_sni?.text = Utils.getEditable(config.sni)
+            config.fingerPrint?.let { it ->
+                val utlsIndex = Utils.arrayFind(uTlsItems, it)
+                utlsIndex.let { sp_stream_fingerprint?.setSelection(if (it >= 0) it else 0) }
+            }
+            config.alpn?.let { it ->
+                val alpnIndex = Utils.arrayFind(alpns, it)
+                alpnIndex.let { sp_stream_alpn?.setSelection(if (it >= 0) it else 0) }
+            }
+            if (config.security == TLS) {
+                container_allow_insecure?.visibility = View.VISIBLE
+                val allowinsecure = Utils.arrayFind(allowinsecures, config.insecure.toString())
+                if (allowinsecure >= 0) {
+                    sp_allow_insecure?.setSelection(allowinsecure)
+                }
+                listOf(
+                    container_public_key,
+                    container_short_id,
+                    container_spider_x,
+                    container_mldsa65_verify
+                ).forEach { it?.visibility = View.GONE }
+            } else if (config.security == REALITY) {
+                container_public_key?.visibility = View.VISIBLE
+                et_public_key?.text = Utils.getEditable(config.publicKey.orEmpty())
+                container_short_id?.visibility = View.VISIBLE
+                et_short_id?.text = Utils.getEditable(config.shortId.orEmpty())
+                container_spider_x?.visibility = View.VISIBLE
+                et_spider_x?.text = Utils.getEditable(config.spiderX.orEmpty())
+                container_mldsa65_verify?.visibility = View.VISIBLE
+                et_mldsa65_verify?.text = Utils.getEditable(config.mldsa65Verify.orEmpty())
+                container_allow_insecure?.visibility = View.GONE
+            }
+        }
+
+        if (config.security.isNullOrEmpty()) {
+            listOf(
+                container_sni,
+                container_fingerprint,
+                container_alpn,
+                container_allow_insecure,
+                container_public_key,
+                container_short_id,
+                container_spider_x,
+                container_mldsa65_verify
+            ).forEach { it?.visibility = View.GONE }
+        }
+        val network = Utils.arrayFind(networks, config.network.orEmpty())
+        if (network >= 0) {
+            sp_network?.setSelection(network)
+        }
+        return true
+    }
+
+    /**
+     * clear or init server config
+     */
+    private fun clearServer(): Boolean {
+        et_remarks.text = null
+        et_address.text = null
+        et_port.text = Utils.getEditable(DEFAULT_PORT.toString())
+        et_id.text = null
+        sp_security?.setSelection(0)
+        sp_network?.setSelection(0)
+
+        sp_header_type?.setSelection(0)
+        et_request_host?.text = null
+        et_path?.text = null
+        sp_stream_security?.setSelection(0)
+        sp_allow_insecure?.setSelection(0)
+        et_sni?.text = null
+
+        //et_security.text = null
+        sp_flow?.setSelection(0)
+        et_public_key?.text = null
+        et_reserved1?.text = Utils.getEditable("0,0,0")
+        et_local_address?.text =
+            Utils.getEditable(WIREGUARD_LOCAL_ADDRESS_V4)
+        et_local_mtu?.text = Utils.getEditable(WIREGUARD_LOCAL_MTU)
+        return true
+    }
+
+    /**
+     * save server config
+     */
+    private fun saveServer(): Boolean {
+        if (TextUtils.isEmpty(et_remarks.text.toString())) {
+            toast(R.string.server_lab_remarks)
+            return false
+        }
+        if (TextUtils.isEmpty(et_address.text.toString())) {
+            toast(R.string.server_lab_address)
+            return false
+        }
+        if (createConfigType != EConfigType.HYSTERIA2) {
+            if (Utils.parseInt(et_port.text.toString()) <= 0) {
+                toast(R.string.server_lab_port)
+                return false
+            }
+        }
+        val config =
+            MmkvManager.decodeServerConfig(editGuid) ?: ProfileItem.create(createConfigType)
+        if (config.configType != EConfigType.SOCKS
+            && config.configType != EConfigType.HTTP
+            && TextUtils.isEmpty(et_id.text.toString())
+        ) {
+            if (config.configType == EConfigType.TROJAN
+                || config.configType == EConfigType.SHADOWSOCKS
+                || config.configType == EConfigType.HYSTERIA2
+            ) {
+                toast(R.string.server_lab_id3)
+            } else {
+                toast(R.string.server_lab_id)
+            }
+            return false
+        }
+        sp_stream_security?.let {
+            if (config.configType == EConfigType.TROJAN && TextUtils.isEmpty(streamSecuritys[it.selectedItemPosition])) {
+                toast(R.string.server_lab_stream_security)
+                return false
+            }
+        }
+        if (et_extra?.text?.toString().isNotNullEmpty()) {
+            if (JsonUtil.parseString(et_extra?.text?.toString()) == null) {
+                toast(R.string.server_lab_xhttp_extra)
+                return false
+            }
+        }
+
+        saveCommon(config)
+        saveStreamSettings(config)
+        saveTls(config)
+
+        if (config.subscriptionId.isEmpty() && !subscriptionId.isNullOrEmpty()) {
+            config.subscriptionId = subscriptionId.orEmpty()
+        }
+        //Log.i(AppConfig.TAG, JsonUtil.toJsonPretty(config) ?: "")
+        MmkvManager.encodeServerConfig(editGuid, config)
+        toastSuccess(R.string.toast_success)
+        finish()
+        return true
+    }
+
+    private fun saveCommon(config: ProfileItem) {
+        config.remarks = et_remarks.text.toString().trim()
+        config.server = et_address.text.toString().trim()
+        config.serverPort = et_port.text.toString().trim()
+        config.password = et_id.text.toString().trim()
+
+        if (config.configType == EConfigType.VMESS) {
+            config.method = securitys[sp_security?.selectedItemPosition ?: 0]
+        } else if (config.configType == EConfigType.VLESS) {
+            config.method = et_security?.text.toString().trim()
+            config.flow = flows[sp_flow?.selectedItemPosition ?: 0]
+        } else if (config.configType == EConfigType.SHADOWSOCKS) {
+            config.method = shadowsocksSecuritys[sp_security?.selectedItemPosition ?: 0]
+        } else if (config.configType == EConfigType.SOCKS || config.configType == EConfigType.HTTP) {
+            if (!TextUtils.isEmpty(et_security?.text) || !TextUtils.isEmpty(et_id.text)) {
+                config.username = et_security?.text.toString().trim()
+            }
+        } else if (config.configType == EConfigType.TROJAN) {
+        } else if (config.configType == EConfigType.WIREGUARD) {
+            config.secretKey = et_id.text.toString().trim()
+            config.publicKey = et_public_key?.text.toString().trim()
+            config.preSharedKey = et_preshared_key?.text.toString().trim()
+            config.reserved = et_reserved1?.text.toString().trim()
+            config.localAddress = et_local_address?.text.toString().trim()
+            config.mtu = Utils.parseInt(et_local_mtu?.text.toString())
+        } else if (config.configType == EConfigType.HYSTERIA2) {
+            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()
+        }
+    }
+
+
+    private fun saveStreamSettings(profileItem: ProfileItem) {
+        val network = sp_network?.selectedItemPosition ?: return
+        val type = sp_header_type?.selectedItemPosition ?: return
+        val requestHost = et_request_host?.text?.toString()?.trim() ?: return
+        val path = et_path?.text?.toString()?.trim() ?: return
+
+        profileItem.network = networks[network]
+        profileItem.headerType = transportTypes(networks[network])[type]
+        profileItem.host = requestHost
+        profileItem.path = path
+        profileItem.seed = path
+        profileItem.quicSecurity = requestHost
+        profileItem.quicKey = path
+        profileItem.mode = transportTypes(networks[network])[type]
+        profileItem.serviceName = path
+        profileItem.authority = requestHost
+        profileItem.xhttpMode = transportTypes(networks[network])[type]
+        profileItem.xhttpExtra = et_extra?.text?.toString()?.trim()
+    }
+
+    private fun saveTls(config: ProfileItem) {
+        val streamSecurity = sp_stream_security?.selectedItemPosition ?: return
+        val sniField = et_sni?.text?.toString()?.trim()
+        val allowInsecureField = sp_allow_insecure?.selectedItemPosition
+        val utlsIndex = sp_stream_fingerprint?.selectedItemPosition ?: 0
+        val alpnIndex = sp_stream_alpn?.selectedItemPosition ?: 0
+        val publicKey = et_public_key?.text?.toString()
+        val shortId = et_short_id?.text?.toString()
+        val spiderX = et_spider_x?.text?.toString()
+        val mldsa65Verify = et_mldsa65_verify?.text?.toString()
+
+        val allowInsecure =
+            if (allowInsecureField == null || allowinsecures[allowInsecureField].isBlank()) {
+                MmkvManager.decodeSettingsBool(PREF_ALLOW_INSECURE)
+            } else {
+                allowinsecures[allowInsecureField].toBoolean()
+            }
+
+        config.security = streamSecuritys[streamSecurity]
+        config.insecure = allowInsecure
+        config.sni = sniField
+        config.fingerPrint = uTlsItems[utlsIndex]
+        config.alpn = alpns[alpnIndex]
+        config.publicKey = publicKey
+        config.shortId = shortId
+        config.spiderX = spiderX
+        config.mldsa65Verify = mldsa65Verify
+    }
+
+    private fun transportTypes(network: String?): Array<out String> {
+        return when (network) {
+            NetworkType.TCP.type -> {
+                tcpTypes
+            }
+
+            NetworkType.KCP.type -> {
+                kcpAndQuicTypes
+            }
+
+            NetworkType.GRPC.type -> {
+                grpcModes
+            }
+
+            NetworkType.XHTTP.type -> {
+                xhttpMode
+            }
+
+            else -> {
+                arrayOf("---")
+            }
+        }
+    }
+
+    /**
+     * delete server config
+     */
+    private fun deleteServer(): Boolean {
+        if (editGuid.isNotEmpty()) {
+            if (editGuid != MmkvManager.getSelectServer()) {
+                if (MmkvManager.decodeSettingsBool(AppConfig.PREF_CONFIRM_REMOVE) == true) {
+                    AlertDialog.Builder(this).setMessage(R.string.del_config_comfirm)
+                        .setPositiveButton(android.R.string.ok) { _, _ ->
+                            MmkvManager.removeServer(editGuid)
+                            finish()
+                        }
+                        .setNegativeButton(android.R.string.cancel) { _, _ ->
+                            // do nothing
+                        }
+                        .show()
+                } else {
+                    MmkvManager.removeServer(editGuid)
+                    finish()
+                }
+            } else {
+                application.toast(R.string.toast_action_not_allowed)
+            }
+        }
+        return true
+    }
+
+    override fun onCreateOptionsMenu(menu: Menu): Boolean {
+        menuInflater.inflate(R.menu.action_server, menu)
+        val delButton = menu.findItem(R.id.del_config)
+        val saveButton = menu.findItem(R.id.save_config)
+
+        if (editGuid.isNotEmpty()) {
+            if (isRunning) {
+                delButton?.isVisible = false
+                saveButton?.isVisible = false
+            }
+        } else {
+            delButton?.isVisible = false
+        }
+
+        return super.onCreateOptionsMenu(menu)
+    }
+
+    override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
+        R.id.del_config -> {
+            deleteServer()
+            true
+        }
+
+        R.id.save_config -> {
+            saveServer()
+            true
+        }
+
+        else -> super.onOptionsItemSelected(item)
+    }
+}

+ 148 - 0
app/src/main/java/com/v2ray/ang/ui/ServerCustomConfigActivity.kt

@@ -0,0 +1,148 @@
+package com.v2ray.ang.ui
+
+import android.os.Bundle
+import android.text.TextUtils
+import android.util.Log
+import android.view.Menu
+import android.view.MenuItem
+import androidx.appcompat.app.AlertDialog
+import com.blacksquircle.ui.editorkit.utils.EditorTheme
+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.extension.toast
+import com.v2ray.ang.extension.toastSuccess
+import com.v2ray.ang.fmt.CustomFmt
+import com.v2ray.ang.handler.MmkvManager
+import com.v2ray.ang.util.Utils
+
+class ServerCustomConfigActivity : BaseActivity() {
+    private val binding by lazy { ActivityServerCustomConfigBinding.inflate(layoutInflater) }
+
+    private val editGuid by lazy { intent.getStringExtra("guid").orEmpty() }
+    private val isRunning by lazy {
+        intent.getBooleanExtra("isRunning", false)
+                && editGuid.isNotEmpty()
+                && editGuid == MmkvManager.getSelectServer()
+    }
+
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+        setContentView(binding.root)
+        title = getString(R.string.title_server)
+
+        if (!Utils.getDarkModeStatus(this)) {
+            binding.editor.colorScheme = EditorTheme.INTELLIJ_LIGHT
+        }
+        binding.editor.language = JsonLanguage()
+        val config = MmkvManager.decodeServerConfig(editGuid)
+        if (config != null) {
+            bindingServer(config)
+        } else {
+            clearServer()
+        }
+    }
+
+    /**
+     * Binding selected server config
+     */
+    private fun bindingServer(config: ProfileItem): Boolean {
+        binding.etRemarks.text = Utils.getEditable(config.remarks)
+        val raw = MmkvManager.decodeServerRaw(editGuid)
+        val configContent = raw.orEmpty()
+
+        binding.editor.setTextContent(Utils.getEditable(configContent))
+        return true
+    }
+
+    /**
+     * clear or init server config
+     */
+    private fun clearServer(): Boolean {
+        binding.etRemarks.text = null
+        return true
+    }
+
+    /**
+     * save server config
+     */
+    private fun saveServer(): Boolean {
+        if (TextUtils.isEmpty(binding.etRemarks.text.toString())) {
+            toast(R.string.server_lab_remarks)
+            return false
+        }
+
+        val profileItem = try {
+            CustomFmt.parse(binding.editor.text.toString())
+        } catch (e: Exception) {
+            Log.e(AppConfig.TAG, "Failed to parse custom configuration", e)
+            toast("${getString(R.string.toast_malformed_josn)} ${e.cause?.message}")
+            return false
+        }
+
+        val config = MmkvManager.decodeServerConfig(editGuid) ?: ProfileItem.create(EConfigType.CUSTOM)
+        binding.etRemarks.text.let {
+            config.remarks = if (it.isNullOrEmpty()) profileItem?.remarks.orEmpty() else it.toString()
+        }
+        config.server = profileItem?.server
+        config.serverPort = profileItem?.serverPort
+
+        MmkvManager.encodeServerConfig(editGuid, config)
+        MmkvManager.encodeServerRaw(editGuid, binding.editor.text.toString())
+        toastSuccess(R.string.toast_success)
+        finish()
+        return true
+    }
+
+    /**
+     * save server config
+     */
+    private fun deleteServer(): Boolean {
+        if (editGuid.isNotEmpty()) {
+            AlertDialog.Builder(this).setMessage(R.string.del_config_comfirm)
+                .setPositiveButton(android.R.string.ok) { _, _ ->
+                    MmkvManager.removeServer(editGuid)
+                    finish()
+                }
+                .setNegativeButton(android.R.string.cancel) { _, _ ->
+                    // do nothing
+                }
+                .show()
+        }
+        return true
+    }
+
+    override fun onCreateOptionsMenu(menu: Menu): Boolean {
+        menuInflater.inflate(R.menu.action_server, menu)
+        val delButton = menu.findItem(R.id.del_config)
+        val saveButton = menu.findItem(R.id.save_config)
+
+        if (editGuid.isNotEmpty()) {
+            if (isRunning) {
+                delButton?.isVisible = false
+                saveButton?.isVisible = false
+            }
+        } else {
+            delButton?.isVisible = false
+        }
+
+        return super.onCreateOptionsMenu(menu)
+    }
+
+    override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
+        R.id.del_config -> {
+            deleteServer()
+            true
+        }
+
+        R.id.save_config -> {
+            saveServer()
+            true
+        }
+
+        else -> super.onOptionsItemSelected(item)
+    }
+}

+ 408 - 0
app/src/main/java/com/v2ray/ang/ui/SettingsActivity.kt

@@ -0,0 +1,408 @@
+package com.v2ray.ang.ui
+
+import android.content.Intent
+import android.os.Bundle
+import android.text.TextUtils
+import android.view.View
+import androidx.activity.viewModels
+import androidx.preference.CheckBoxPreference
+import androidx.preference.EditTextPreference
+import androidx.preference.ListPreference
+import androidx.preference.PreferenceFragmentCompat
+import androidx.work.ExistingPeriodicWorkPolicy
+import androidx.work.PeriodicWorkRequest
+import androidx.work.multiprocess.RemoteWorkManager
+import com.v2ray.ang.AngApplication
+import com.v2ray.ang.AppConfig
+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.SubscriptionUpdater
+import com.v2ray.ang.util.Utils
+import com.v2ray.ang.viewmodel.SettingsViewModel
+import java.util.concurrent.TimeUnit
+
+class SettingsActivity : BaseActivity() {
+    private val settingsViewModel: SettingsViewModel by viewModels()
+
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+        setContentView(R.layout.activity_settings)
+
+        title = getString(R.string.title_settings)
+
+        settingsViewModel.startListenPreferenceChange()
+    }
+
+    class SettingsFragment : PreferenceFragmentCompat() {
+
+        private val perAppProxy by lazy { findPreference<CheckBoxPreference>(AppConfig.PREF_PER_APP_PROXY) }
+        private val localDns by lazy { findPreference<CheckBoxPreference>(AppConfig.PREF_LOCAL_DNS_ENABLED) }
+        private val fakeDns by lazy { findPreference<CheckBoxPreference>(AppConfig.PREF_FAKE_DNS_ENABLED) }
+        private val appendHttpProxy by lazy { findPreference<CheckBoxPreference>(AppConfig.PREF_APPEND_HTTP_PROXY) }
+        private val localDnsPort by lazy { findPreference<EditTextPreference>(AppConfig.PREF_LOCAL_DNS_PORT) }
+        private val vpnDns by lazy { findPreference<EditTextPreference>(AppConfig.PREF_VPN_DNS) }
+        private val vpnBypassLan by lazy { findPreference<ListPreference>(AppConfig.PREF_VPN_BYPASS_LAN) }
+        private val vpnInterfaceAddress by lazy { findPreference<ListPreference>(AppConfig.PREF_VPN_INTERFACE_ADDRESS_CONFIG_INDEX) }
+        private val vpnMtu by lazy { findPreference<EditTextPreference>(AppConfig.PREF_VPN_MTU) }
+
+        private val mux by lazy { findPreference<CheckBoxPreference>(AppConfig.PREF_MUX_ENABLED) }
+        private val muxConcurrency by lazy { findPreference<EditTextPreference>(AppConfig.PREF_MUX_CONCURRENCY) }
+        private val muxXudpConcurrency by lazy { findPreference<EditTextPreference>(AppConfig.PREF_MUX_XUDP_CONCURRENCY) }
+        private val muxXudpQuic by lazy { findPreference<ListPreference>(AppConfig.PREF_MUX_XUDP_QUIC) }
+
+        private val fragment by lazy { findPreference<CheckBoxPreference>(AppConfig.PREF_FRAGMENT_ENABLED) }
+        private val fragmentPackets by lazy { findPreference<ListPreference>(AppConfig.PREF_FRAGMENT_PACKETS) }
+        private val fragmentLength by lazy { findPreference<EditTextPreference>(AppConfig.PREF_FRAGMENT_LENGTH) }
+        private val fragmentInterval by lazy { findPreference<EditTextPreference>(AppConfig.PREF_FRAGMENT_INTERVAL) }
+
+        private val autoUpdateCheck by lazy { findPreference<CheckBoxPreference>(AppConfig.SUBSCRIPTION_AUTO_UPDATE) }
+        private val autoUpdateInterval by lazy { findPreference<EditTextPreference>(AppConfig.SUBSCRIPTION_AUTO_UPDATE_INTERVAL) }
+
+        private val socksPort by lazy { findPreference<EditTextPreference>(AppConfig.PREF_SOCKS_PORT) }
+        private val remoteDns by lazy { findPreference<EditTextPreference>(AppConfig.PREF_REMOTE_DNS) }
+        private val domesticDns by lazy { findPreference<EditTextPreference>(AppConfig.PREF_DOMESTIC_DNS) }
+        private val dnsHosts by lazy { findPreference<EditTextPreference>(AppConfig.PREF_DNS_HOSTS) }
+        private val delayTestUrl by lazy { findPreference<EditTextPreference>(AppConfig.PREF_DELAY_TEST_URL) }
+        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?) {
+            addPreferencesFromResource(R.xml.pref_settings)
+
+            perAppProxy?.setOnPreferenceClickListener {
+                startActivity(Intent(activity, PerAppProxyActivity::class.java))
+                perAppProxy?.isChecked = true
+                false
+            }
+            localDns?.setOnPreferenceChangeListener { _, any ->
+                updateLocalDns(any as Boolean)
+                true
+            }
+            localDnsPort?.setOnPreferenceChangeListener { _, any ->
+                val nval = any as String
+                localDnsPort?.summary =
+                    if (TextUtils.isEmpty(nval)) AppConfig.PORT_LOCAL_DNS else nval
+                true
+            }
+            vpnDns?.setOnPreferenceChangeListener { _, any ->
+                vpnDns?.summary = any as String
+                true
+            }
+
+            vpnMtu?.setOnPreferenceChangeListener { _, any ->
+                val nval = any as String
+                vpnMtu?.summary = if (TextUtils.isEmpty(nval)) AppConfig.VPN_MTU.toString() else nval
+                true
+            }
+
+            mux?.setOnPreferenceChangeListener { _, newValue ->
+                updateMux(newValue as Boolean)
+                true
+            }
+            muxConcurrency?.setOnPreferenceChangeListener { _, newValue ->
+                updateMuxConcurrency(newValue as String)
+                true
+            }
+            muxXudpConcurrency?.setOnPreferenceChangeListener { _, newValue ->
+                updateMuxXudpConcurrency(newValue as String)
+                true
+            }
+
+            fragment?.setOnPreferenceChangeListener { _, newValue ->
+                updateFragment(newValue as Boolean)
+                true
+            }
+            fragmentPackets?.setOnPreferenceChangeListener { _, newValue ->
+                updateFragmentPackets(newValue as String)
+                true
+            }
+            fragmentLength?.setOnPreferenceChangeListener { _, newValue ->
+                updateFragmentLength(newValue as String)
+                true
+            }
+            fragmentInterval?.setOnPreferenceChangeListener { _, newValue ->
+                updateFragmentInterval(newValue as String)
+                true
+            }
+
+            autoUpdateCheck?.setOnPreferenceChangeListener { _, newValue ->
+                val value = newValue as Boolean
+                autoUpdateCheck?.isChecked = value
+                autoUpdateInterval?.isEnabled = value
+                autoUpdateInterval?.text?.toLongEx()?.let {
+                    if (newValue) configureUpdateTask(it) else cancelUpdateTask()
+                }
+                true
+            }
+            autoUpdateInterval?.setOnPreferenceChangeListener { _, any ->
+                var nval = any as String
+
+                // It must be greater than 15 minutes because WorkManager couldn't run tasks under 15 minutes intervals
+                nval =
+                    if (TextUtils.isEmpty(nval) || nval.toLongEx() < 15) AppConfig.SUBSCRIPTION_DEFAULT_UPDATE_INTERVAL else nval
+                autoUpdateInterval?.summary = nval
+                configureUpdateTask(nval.toLongEx())
+                true
+            }
+
+            socksPort?.setOnPreferenceChangeListener { _, any ->
+                val nval = any as String
+                socksPort?.summary = if (TextUtils.isEmpty(nval)) AppConfig.PORT_SOCKS else nval
+                true
+            }
+
+            remoteDns?.setOnPreferenceChangeListener { _, any ->
+                val nval = any as String
+                remoteDns?.summary = if (nval == "") AppConfig.DNS_PROXY else nval
+                true
+            }
+            domesticDns?.setOnPreferenceChangeListener { _, any ->
+                val nval = any as String
+                domesticDns?.summary = if (nval == "") AppConfig.DNS_DIRECT else nval
+                true
+            }
+            dnsHosts?.setOnPreferenceChangeListener { _, any ->
+                val nval = any as String
+                dnsHosts?.summary = nval
+                true
+            }
+            delayTestUrl?.setOnPreferenceChangeListener { _, any ->
+                val nval = any as String
+                delayTestUrl?.summary = if (nval == "") AppConfig.DELAY_TEST_URL else nval
+                true
+            }
+            mode?.setOnPreferenceChangeListener { _, newValue ->
+                updateMode(newValue.toString())
+                true
+            }
+            mode?.dialogLayoutResource = R.layout.preference_with_help_link
+            //loglevel.summary = "LogLevel"
+
+            useHevTun?.setOnPreferenceChangeListener { _, newValue ->
+                updateHevTunSettings(newValue as Boolean)
+                true
+            }
+
+            hevTunRwTimeout?.setOnPreferenceChangeListener { _, any ->
+                val nval = any as String
+                hevTunRwTimeout?.summary = if (TextUtils.isEmpty(nval)) AppConfig.HEVTUN_RW_TIMEOUT else nval
+                true
+            }
+        }
+
+        override fun onStart() {
+            super.onStart()
+            updateMode(MmkvManager.decodeSettingsString(AppConfig.PREF_MODE, VPN))
+            localDns?.isChecked = MmkvManager.decodeSettingsBool(AppConfig.PREF_LOCAL_DNS_ENABLED, false)
+            fakeDns?.isChecked = MmkvManager.decodeSettingsBool(AppConfig.PREF_FAKE_DNS_ENABLED, false)
+            appendHttpProxy?.isChecked = MmkvManager.decodeSettingsBool(AppConfig.PREF_APPEND_HTTP_PROXY, false)
+            localDnsPort?.summary = MmkvManager.decodeSettingsString(AppConfig.PREF_LOCAL_DNS_PORT, AppConfig.PORT_LOCAL_DNS)
+            vpnDns?.summary = MmkvManager.decodeSettingsString(AppConfig.PREF_VPN_DNS, AppConfig.DNS_VPN)
+            vpnMtu?.summary = MmkvManager.decodeSettingsString(AppConfig.PREF_VPN_MTU, AppConfig.VPN_MTU.toString())
+
+            updateMux(MmkvManager.decodeSettingsBool(AppConfig.PREF_MUX_ENABLED, false))
+            mux?.isChecked = MmkvManager.decodeSettingsBool(AppConfig.PREF_MUX_ENABLED, false)
+            muxConcurrency?.summary = MmkvManager.decodeSettingsString(AppConfig.PREF_MUX_CONCURRENCY, "8")
+            muxXudpConcurrency?.summary = MmkvManager.decodeSettingsString(AppConfig.PREF_MUX_XUDP_CONCURRENCY, "8")
+
+            updateFragment(MmkvManager.decodeSettingsBool(AppConfig.PREF_FRAGMENT_ENABLED, false))
+            fragment?.isChecked = MmkvManager.decodeSettingsBool(AppConfig.PREF_FRAGMENT_ENABLED, false)
+            fragmentPackets?.summary = MmkvManager.decodeSettingsString(AppConfig.PREF_FRAGMENT_PACKETS, "tlshello")
+            fragmentLength?.summary = MmkvManager.decodeSettingsString(AppConfig.PREF_FRAGMENT_LENGTH, "50-100")
+            fragmentInterval?.summary = MmkvManager.decodeSettingsString(AppConfig.PREF_FRAGMENT_INTERVAL, "10-20")
+
+            autoUpdateCheck?.isChecked = MmkvManager.decodeSettingsBool(AppConfig.SUBSCRIPTION_AUTO_UPDATE, false)
+            autoUpdateInterval?.summary =
+                MmkvManager.decodeSettingsString(AppConfig.SUBSCRIPTION_AUTO_UPDATE_INTERVAL, AppConfig.SUBSCRIPTION_DEFAULT_UPDATE_INTERVAL)
+            autoUpdateInterval?.isEnabled = MmkvManager.decodeSettingsBool(AppConfig.SUBSCRIPTION_AUTO_UPDATE, false)
+
+            socksPort?.summary = MmkvManager.decodeSettingsString(AppConfig.PREF_SOCKS_PORT, AppConfig.PORT_SOCKS)
+            remoteDns?.summary = MmkvManager.decodeSettingsString(AppConfig.PREF_REMOTE_DNS, AppConfig.DNS_PROXY)
+            domesticDns?.summary = MmkvManager.decodeSettingsString(AppConfig.PREF_DOMESTIC_DNS, AppConfig.DNS_DIRECT)
+            dnsHosts?.summary = MmkvManager.decodeSettingsString(AppConfig.PREF_DNS_HOSTS)
+            delayTestUrl?.summary = MmkvManager.decodeSettingsString(AppConfig.PREF_DELAY_TEST_URL, AppConfig.DELAY_TEST_URL)
+
+            updateHevTunSettings(MmkvManager.decodeSettingsBool(AppConfig.PREF_USE_HEV_TUNNEL, false))
+            hevTunRwTimeout?.summary = MmkvManager.decodeSettingsString(AppConfig.PREF_HEV_TUNNEL_RW_TIMEOUT, AppConfig.HEVTUN_RW_TIMEOUT)
+
+            initSharedPreference()
+        }
+
+        private fun initSharedPreference() {
+            listOf(
+                localDnsPort,
+                vpnDns,
+                vpnMtu,
+                muxConcurrency,
+                muxXudpConcurrency,
+                fragmentLength,
+                fragmentInterval,
+                autoUpdateInterval,
+                socksPort,
+                remoteDns,
+                domesticDns,
+                delayTestUrl,
+                hevTunRwTimeout
+            ).forEach { key ->
+                key?.text = key?.summary.toString()
+            }
+
+            listOf(
+                AppConfig.PREF_SNIFFING_ENABLED,
+            ).forEach { key ->
+                findPreference<CheckBoxPreference>(key)?.isChecked =
+                    MmkvManager.decodeSettingsBool(key, true)
+            }
+
+            listOf(
+                AppConfig.PREF_ROUTE_ONLY_ENABLED,
+                AppConfig.PREF_IS_BOOTED,
+                AppConfig.PREF_BYPASS_APPS,
+                AppConfig.PREF_SPEED_ENABLED,
+                AppConfig.PREF_CONFIRM_REMOVE,
+                AppConfig.PREF_START_SCAN_IMMEDIATE,
+                AppConfig.PREF_DOUBLE_COLUMN_DISPLAY,
+                AppConfig.PREF_PREFER_IPV6,
+                AppConfig.PREF_PROXY_SHARING,
+                AppConfig.PREF_ALLOW_INSECURE,
+                AppConfig.PREF_USE_HEV_TUNNEL
+            ).forEach { key ->
+                findPreference<CheckBoxPreference>(key)?.isChecked =
+                    MmkvManager.decodeSettingsBool(key, false)
+            }
+
+            listOf(
+                AppConfig.PREF_VPN_BYPASS_LAN,
+                AppConfig.PREF_VPN_INTERFACE_ADDRESS_CONFIG_INDEX,
+                AppConfig.PREF_ROUTING_DOMAIN_STRATEGY,
+                AppConfig.PREF_MUX_XUDP_QUIC,
+                AppConfig.PREF_FRAGMENT_PACKETS,
+                AppConfig.PREF_LANGUAGE,
+                AppConfig.PREF_UI_MODE_NIGHT,
+                AppConfig.PREF_LOGLEVEL,
+                AppConfig.PREF_OUTBOUND_DOMAIN_RESOLVE_METHOD,
+                AppConfig.PREF_INTELLIGENT_SELECTION_METHOD,
+                AppConfig.PREF_MODE,
+                AppConfig.PREF_HEV_TUNNEL_LOGLEVEL
+            ).forEach { key ->
+                if (MmkvManager.decodeSettingsString(key) != null) {
+                    findPreference<ListPreference>(key)?.value = MmkvManager.decodeSettingsString(key)
+                }
+            }
+        }
+
+        private fun updateMode(mode: String?) {
+            val vpn = mode == VPN
+            perAppProxy?.isEnabled = vpn
+            perAppProxy?.isChecked = MmkvManager.decodeSettingsBool(AppConfig.PREF_PER_APP_PROXY, false)
+            localDns?.isEnabled = vpn
+            fakeDns?.isEnabled = vpn
+            appendHttpProxy?.isEnabled = vpn
+            localDnsPort?.isEnabled = vpn
+            vpnDns?.isEnabled = vpn
+            vpnBypassLan?.isEnabled = vpn
+            vpnInterfaceAddress?.isEnabled = vpn
+            vpnMtu?.isEnabled = vpn
+            if (vpn) {
+                updateLocalDns(
+                    MmkvManager.decodeSettingsBool(
+                        AppConfig.PREF_LOCAL_DNS_ENABLED,
+                        false
+                    )
+                )
+            }
+        }
+
+        private fun updateLocalDns(enabled: Boolean) {
+            fakeDns?.isEnabled = enabled
+            localDnsPort?.isEnabled = enabled
+            vpnDns?.isEnabled = !enabled
+        }
+
+        private fun configureUpdateTask(interval: Long) {
+            val rw = RemoteWorkManager.getInstance(AngApplication.application)
+            rw.cancelUniqueWork(AppConfig.SUBSCRIPTION_UPDATE_TASK_NAME)
+            rw.enqueueUniquePeriodicWork(
+                AppConfig.SUBSCRIPTION_UPDATE_TASK_NAME,
+                ExistingPeriodicWorkPolicy.UPDATE,
+                PeriodicWorkRequest.Builder(
+                    SubscriptionUpdater.UpdateTask::class.java,
+                    interval,
+                    TimeUnit.MINUTES
+                )
+                    .apply {
+                        setInitialDelay(interval, TimeUnit.MINUTES)
+                    }
+                    .build()
+            )
+        }
+
+        private fun cancelUpdateTask() {
+            val rw = RemoteWorkManager.getInstance(AngApplication.application)
+            rw.cancelUniqueWork(AppConfig.SUBSCRIPTION_UPDATE_TASK_NAME)
+        }
+
+        private fun updateMux(enabled: Boolean) {
+            muxConcurrency?.isEnabled = enabled
+            muxXudpConcurrency?.isEnabled = enabled
+            muxXudpQuic?.isEnabled = enabled
+            if (enabled) {
+                updateMuxConcurrency(MmkvManager.decodeSettingsString(AppConfig.PREF_MUX_CONCURRENCY, "8"))
+                updateMuxXudpConcurrency(MmkvManager.decodeSettingsString(AppConfig.PREF_MUX_XUDP_CONCURRENCY, "8"))
+            }
+        }
+
+        private fun updateMuxConcurrency(value: String?) {
+            val concurrency = value?.toIntOrNull() ?: 8
+            muxConcurrency?.summary = concurrency.toString()
+        }
+
+
+        private fun updateMuxXudpConcurrency(value: String?) {
+            if (value == null) {
+                muxXudpQuic?.isEnabled = true
+            } else {
+                val concurrency = value.toIntOrNull() ?: 8
+                muxXudpConcurrency?.summary = concurrency.toString()
+                muxXudpQuic?.isEnabled = concurrency >= 0
+            }
+        }
+
+        private fun updateFragment(enabled: Boolean) {
+            fragmentPackets?.isEnabled = enabled
+            fragmentLength?.isEnabled = enabled
+            fragmentInterval?.isEnabled = enabled
+            if (enabled) {
+                updateFragmentPackets(MmkvManager.decodeSettingsString(AppConfig.PREF_FRAGMENT_PACKETS, "tlshello"))
+                updateFragmentLength(MmkvManager.decodeSettingsString(AppConfig.PREF_FRAGMENT_LENGTH, "50-100"))
+                updateFragmentInterval(MmkvManager.decodeSettingsString(AppConfig.PREF_FRAGMENT_INTERVAL, "10-20"))
+            }
+        }
+
+        private fun updateFragmentPackets(value: String?) {
+            fragmentPackets?.summary = value.toString()
+        }
+
+        private fun updateFragmentLength(value: String?) {
+            fragmentLength?.summary = value.toString()
+        }
+
+        private fun updateFragmentInterval(value: String?) {
+            fragmentInterval?.summary = value.toString()
+        }
+
+        private fun updateHevTunSettings(enabled: Boolean) {
+            hevTunLogLevel?.isEnabled = enabled
+            hevTunRwTimeout?.isEnabled = enabled
+        }
+    }
+
+    fun onModeHelpClicked(view: View) {
+        Utils.openUri(this, AppConfig.APP_WIKI_MODE)
+    }
+}

Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio