diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 52ded340..8c5a92fe 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,13 +1,14 @@ name: Build APK on: - push: workflow_dispatch: inputs: - XRAY_CORE_VERSION: - description: 'Xray core version or commit hash' + release_tag: required: false - + type: string + push: + branches: + - master jobs: build: @@ -15,49 +16,144 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 - - - name: Setup Java - uses: actions/setup-java@v4 + uses: actions/checkout@v4.2.2 with: - distribution: 'temurin' - java-version: '17' + submodules: 'recursive' + fetch-depth: '0' + + - name: Setup Android SDK + uses: android-actions/setup-android@v3.2.0 + with: + log-accepted-android-sdk-licenses: false + cmdline-tools-version: '12266719' + packages: 'platforms;android-35 build-tools;35.0.0 platform-tools' + + - name: Install NDK + run: | + echo "y" | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager \ + --channel=3 \ + --install "ndk;29.0.13113456" + echo "NDK_HOME=$ANDROID_HOME/ndk/29.0.13113456" >> $GITHUB_ENV + sed -i '10i\ + \ + ndkVersion = "29.0.13113456"' ${{ github.workspace }}/V2rayNG/app/build.gradle.kts + + - name: Restore cached libtun2socks + id: cache-libtun2socks-restore + uses: actions/cache/restore@v4 + with: + path: ${{ github.workspace }}/libs + key: libtun2socks-${{ runner.os }}-${{ env.NDK_HOME }}-${{ hashFiles('.git/modules/badvpn/HEAD') }}-${{ hashFiles('.git/modules/libancillary/HEAD') }} + + - name: Build libtun2socks + if: steps.cache-libtun2socks-restore.outputs.cache-hit != 'true' + run: | + bash compile-tun2socks.sh + + - name: Save libtun2socks + if: steps.cache-libtun2socks-restore.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: ${{ github.workspace }}/libs + key: libtun2socks-${{ runner.os }}-${{ env.NDK_HOME }}-${{ hashFiles('.git/modules/badvpn/HEAD') }}-${{ hashFiles('.git/modules/libancillary/HEAD') }} + + - name: Copy libtun2socks + run: | + cp -r ${{ github.workspace }}/libs ${{ github.workspace }}/V2rayNG/app + + - name: Fetch AndroidLibXrayLite tag + run: | + pushd AndroidLibXrayLite + CURRENT_TAG=$(git describe --tags --abbrev=0) + echo "Current tag in this repo: $CURRENT_TAG" + echo "CURRENT_TAG=$CURRENT_TAG" >> $GITHUB_ENV + popd + + - name: Download libv2ray + uses: robinraju/release-downloader@v1.12 + with: + repository: '2dust/AndroidLibXrayLite' + tag: ${{ env.CURRENT_TAG }} + fileName: 'libv2ray.aar' + out-file-path: V2rayNG/app/libs/ + + - name: Restore cached libhysteria2 + id: cache-libhysteria2-restore + uses: actions/cache/restore@v4 + with: + path: ${{ github.workspace }}/hysteria/libs + key: libhysteria2-${{ runner.os }}-${{ env.NDK_HOME }}-${{ hashFiles('.git/modules/hysteria/HEAD') }}-${{ hashFiles('libhysteria2.sh') }} - name: Setup Golang - uses: actions/setup-go@v5 + if: steps.cache-libhysteria2-restore.outputs.cache-hit != 'true' + uses: actions/setup-go@v5.4.0 with: - go-version: '1.22.2' + go-version-file: 'AndroidLibXrayLite/go.mod' + cache: false - - name: Install gomobile + - name: Build libhysteria2 + if: steps.cache-libhysteria2-restore.outputs.cache-hit != 'true' run: | - go install golang.org/x/mobile/cmd/gomobile@latest - echo "$(go env GOPATH)/bin" >> $GITHUB_PATH + bash libhysteria2.sh + - name: Save libhysteria2 + if: steps.cache-libhysteria2-restore.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: ${{ github.workspace }}/hysteria/libs + key: libhysteria2-${{ runner.os }}-${{ env.NDK_HOME }}-${{ hashFiles('.git/modules/hysteria/HEAD') }}-${{ hashFiles('libhysteria2.sh') }} - - name: Setup Android environment - uses: android-actions/setup-android@v3 - - - - name: Build dependencies + - name: Copy libhysteria2 run: | - mkdir ${{ github.workspace }}/build - cd ${{ github.workspace }}/build - git clone --depth=1 -b main https://github.com/2dust/AndroidLibXrayLite.git - cd AndroidLibXrayLite - go get github.com/xtls/xray-core@${{ github.event.inputs.XRAY_CORE_VERSION }} || true - gomobile init - go mod tidy -v - gomobile bind -v -androidapi 21 -ldflags='-s -w' ./ - cp *.aar ${{ github.workspace }}/V2rayNG/app/libs/ + cp -r ${{ github.workspace }}/hysteria/libs ${{ github.workspace }}/V2rayNG/app + + - name: Setup Java + uses: actions/setup-java@v4.7.0 + with: + distribution: 'temurin' + java-version: '21' + + - name: Decode Keystore + uses: timheuer/base64-to-file@v1.2.4 + id: android_keystore + with: + fileName: "android_keystore.jks" + encodedString: ${{ secrets.APP_KEYSTORE_BASE64 }} - name: Build APK run: | cd ${{ github.workspace }}/V2rayNG + echo "sdk.dir=${ANDROID_HOME}" > local.properties chmod 755 gradlew - ./gradlew assembleDebug - - - name: Upload APK - uses: actions/upload-artifact@v4 + ./gradlew licenseFdroidReleaseReport + ./gradlew assembleRelease -Pandroid.injected.signing.store.file=${{ steps.android_keystore.outputs.filePath }} -Pandroid.injected.signing.store.password=${{ secrets.APP_KEYSTORE_PASSWORD }} -Pandroid.injected.signing.key.alias=${{ secrets.APP_KEYSTORE_ALIAS }} -Pandroid.injected.signing.key.password=${{ secrets.APP_KEY_PASSWORD }} + + - name: Upload arm64-v8a APK + uses: actions/upload-artifact@v4.6.2 + if: ${{ success() }} with: - name: apk - path: ${{ github.workspace }}/V2rayNG/app/build/outputs/apk/debug/ + name: arm64-v8a + path: ${{ github.workspace }}/V2rayNG/app/build/outputs/apk/*/release/*arm64-v8a*.apk + + - name: Upload armeabi-v7a APK + uses: actions/upload-artifact@v4.6.2 + if: ${{ success() }} + with: + name: armeabi-v7a + path: ${{ github.workspace }}/V2rayNG/app/build/outputs/apk/*/release/*armeabi-v7a*.apk + + - name: Upload x86 APK + uses: actions/upload-artifact@v4.6.2 + if: ${{ success() }} + with: + name: x86-apk + path: ${{ github.workspace }}/V2rayNG/app/build/outputs/apk/*/release/*x86*.apk + + - name: Upload to release + uses: svenstaro/upload-release-action@v2 + if: github.event.inputs.release_tag != '' + with: + file: ${{ github.workspace }}/V2rayNG/app/build/outputs/apk/*playstore*/release/*.apk + tag: ${{ github.event.inputs.release_tag }} + file_glob: true + prerelease: true diff --git a/.github/workflows/fastlane.yml b/.github/workflows/fastlane.yml new file mode 100644 index 00000000..4ed66e23 --- /dev/null +++ b/.github/workflows/fastlane.yml @@ -0,0 +1,16 @@ +name: Validate Fastlane metadata + +on: + workflow_dispatch: + push: + branches: [ "master" ] + pull_request: + branches: [ "master" ] + +jobs: + go: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Validate Fastlane Supply Metadata + uses: ashutoshgngwr/validate-fastlane-supply-metadata@v2.1.0 diff --git a/.gitignore b/.gitignore index 0963c509..9582b273 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,66 @@ +# Ignore data and key store files *.dat *.jks + +# Ignore output JSON file V2rayNG/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 +V2rayNG/app/build +V2rayNG/build +V2rayNG/local.properties + +# Ignore APK and AAR files +*.apk +*.aar + +# Ignore signing properties +signing.properties + +# Ignore shared object files +*.so + +# Ignore Google services JSON +V2rayNG/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 diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..2bdafca3 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,12 @@ +[submodule "hysteria"] + path = hysteria + url = https://github.com/apernet/hysteria +[submodule "AndroidLibXrayLite"] + path = AndroidLibXrayLite + url = https://github.com/2dust/AndroidLibXrayLite +[submodule "badvpn"] + path = badvpn + url = https://github.com/XTLS/badvpn +[submodule "libancillary"] + path = libancillary + url = https://github.com/shadowsocks/libancillary diff --git a/AndroidLibV2rayLite/README.md b/AndroidLibV2rayLite/README.md deleted file mode 100644 index 118eefcf..00000000 --- a/AndroidLibV2rayLite/README.md +++ /dev/null @@ -1,20 +0,0 @@ -# AndroidLibV2rayLite - -### Preparation -- latest Ubuntu environment -- At lease 30G free space -- Get Repo [AndroidLibV2rayLite](https://github.com/2dust/AndroidLibV2rayLite) or [AndroidLibXrayLite](https://github.com/2dust/AndroidLibXrayLite) -### Prepare Go -- Go to https://golang.org/doc/install and install latest go -- Make sure `go version` works as expected -### Prepare gomobile -- Go to https://pkg.go.dev/golang.org/x/mobile/cmd/gomobile and install gomobile -- export PATH=$PATH:~/go/bin -- Make sure `gomobile init` works as expected -### Prepare NDK -- Go to https://developer.android.com/ndk/downloads and install latest NDK -- export PATH=$PATH: -- Make sure `ndk-build -v` works as expected -### Make -- sudo apt install make -- Read and understand [build script](https://github.com/2dust/AndroidLibV2rayLite/blob/master/Makefile) diff --git a/AndroidLibXrayLite b/AndroidLibXrayLite new file mode 160000 index 00000000..8ad3e1dd --- /dev/null +++ b/AndroidLibXrayLite @@ -0,0 +1 @@ +Subproject commit 8ad3e1ddf165d8d67e488346b2faa9153d3e33a4 diff --git a/README.md b/README.md index 0299c673..4bd6f8ec 100644 --- a/README.md +++ b/README.md @@ -3,16 +3,12 @@ A V2Ray client for Android, support [Xray core](https://github.com/XTLS/Xray-core) and [v2fly core](https://github.com/v2fly/v2ray-core) [![API](https://img.shields.io/badge/API-21%2B-yellow.svg?style=flat)](https://developer.android.com/about/versions/lollipop) -[![Kotlin Version](https://img.shields.io/badge/Kotlin-1.9.23-blue.svg)](https://kotlinlang.org) +[![Kotlin Version](https://img.shields.io/badge/Kotlin-2.1.21-blue.svg)](https://kotlinlang.org) [![GitHub commit activity](https://img.shields.io/github/commit-activity/m/2dust/v2rayNG)](https://github.com/2dust/v2rayNG/commits/master) [![CodeFactor](https://www.codefactor.io/repository/github/2dust/v2rayng/badge)](https://www.codefactor.io/repository/github/2dust/v2rayng) [![GitHub Releases](https://img.shields.io/github/downloads/2dust/v2rayNG/latest/total?logo=github)](https://github.com/2dust/v2rayNG/releases) [![Chat on Telegram](https://img.shields.io/badge/Chat%20on-Telegram-brightgreen.svg)](https://t.me/v2rayn) - -Get it on Google Play - - ### Telegram Channel [github_2dust](https://t.me/github_2dust) @@ -21,7 +17,7 @@ A V2Ray client for Android, support [Xray core](https://github.com/XTLS/Xray-cor #### Geoip and Geosite - geoip.dat and geosite.dat files are in `Android/data/com.v2ray.ang/files/assets` (path may differ on some Android device) - download feature will get enhanced version in this [repo](https://github.com/Loyalsoldier/v2ray-rules-dat) (Note it need a working proxy) -- latest official [domain list](https://github.com/v2fly/domain-list-community) and [ip list](https://github.com/v2fly/geoip) can be imported manually +- latest official [domain list](https://github.com/Loyalsoldier/v2ray-rules-dat) and [ip list](https://github.com/Loyalsoldier/geoip) can be imported manually - possible to use third party dat file in the same folder, like [h2y](https://guide.v2fly.org/routing/sitedata.html#%E5%A4%96%E7%BD%AE%E7%9A%84%E5%9F%9F%E5%90%8D%E6%96%87%E4%BB%B6) ### More in our [wiki](https://github.com/2dust/v2rayNG/wiki) diff --git a/V2rayNG/.gitignore b/V2rayNG/.gitignore deleted file mode 100644 index 16eb6ec8..00000000 --- a/V2rayNG/.gitignore +++ /dev/null @@ -1,10 +0,0 @@ -*.iml -.gradle -/local.properties -/.idea -.DS_Store -/build -/captures -*.apk -signing.properties -*.aar diff --git a/V2rayNG/app/.gitignore b/V2rayNG/app/.gitignore deleted file mode 100644 index 2abde4aa..00000000 --- a/V2rayNG/app/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -/build -/google-services.json diff --git a/V2rayNG/app/build.gradle.kts b/V2rayNG/app/build.gradle.kts index 84fbd1a3..1624786c 100644 --- a/V2rayNG/app/build.gradle.kts +++ b/V2rayNG/app/build.gradle.kts @@ -1,46 +1,63 @@ plugins { - id("com.android.application") - id("org.jetbrains.kotlin.android") + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + id("com.jaredsburrows.license") } android { namespace = "com.v2ray.ang" - compileSdk = 34 + compileSdk = 35 defaultConfig { applicationId = "com.v2ray.ang" minSdk = 21 - targetSdk = 34 - versionCode = 601 - versionName = "1.9.7" + targetSdk = 35 + versionCode = 658 + versionName = "1.10.8" multiDexEnabled = true + + val abiFilterList = (properties["ABI_FILTERS"] as? String)?.split(';') splits { abi { isEnable = true - include( - "arm64-v8a", - "armeabi-v7a", - "x86_64", - "x86" - ) - isUniversalApk = 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" } - compileOptions { - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 - } buildTypes { release { isMinifyEnabled = false - + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) } - debug { - isMinifyEnabled = false + } + flavorDimensions.add("distribution") + productFlavors { + create("fdroid") { + dimension = "distribution" + applicationIdSuffix = ".fdroid" + buildConfigField("String", "DISTRIBUTION", "\"F-Droid\"") + } + create("playstore") { + dimension = "distribution" + buildConfigField("String", "DISTRIBUTION", "\"Play Store\"") } } @@ -50,30 +67,58 @@ android { } } + + compileOptions { + isCoreLibraryDesugaringEnabled = true + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } kotlinOptions { - jvmTarget = JavaVersion.VERSION_1_8.toString() + jvmTarget = JavaVersion.VERSION_17.toString() } applicationVariants.all { val variant = this - val versionCodes = - mapOf("armeabi-v7a" to 4, "arm64-v8a" to 4, "x86" to 4, "x86_64" to 4, "universal" to 4) + 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 = 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 + 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 { @@ -86,47 +131,61 @@ android { useLegacyPackaging = true } } + } dependencies { + // Core Libraries implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.aar", "*.jar")))) - testImplementation(libs.junit) - implementation(libs.flexbox) - // Androidx - implementation(libs.constraintlayout) - implementation(libs.legacy.support.v4) - implementation(libs.appcompat) - implementation(libs.material) - implementation(libs.cardview) + // 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.fragment.ktx) - implementation(libs.multidex) - implementation(libs.viewpager2) + implementation(libs.androidx.swiperefreshlayout) - // Androidx ktx - implementation(libs.activity.ktx) + // 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) - //kotlin - implementation(libs.kotlin.reflect) - implementation(libs.kotlinx.coroutines.core) - implementation(libs.kotlinx.coroutines.android) - - implementation(libs.mmkv.static) - implementation(libs.gson) - implementation(libs.rxjava) - implementation(libs.rxandroid) - implementation(libs.rxpermissions) - implementation(libs.toastcompat) - implementation(libs.editorkit) - implementation(libs.language.base) - implementation(libs.language.json) - implementation(libs.quickie.bundled) - implementation(libs.core) + // Background Task Libraries implementation(libs.work.runtime.ktx) implementation(libs.work.multiprocess) -} \ No newline at end of file + + // 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) +} diff --git a/V2rayNG/app/libs/arm64-v8a/libtun2socks.so b/V2rayNG/app/libs/arm64-v8a/libtun2socks.so deleted file mode 100644 index 3dbdde7a..00000000 Binary files a/V2rayNG/app/libs/arm64-v8a/libtun2socks.so and /dev/null differ diff --git a/V2rayNG/app/libs/armeabi-v7a/libtun2socks.so b/V2rayNG/app/libs/armeabi-v7a/libtun2socks.so deleted file mode 100644 index 79e3a96a..00000000 Binary files a/V2rayNG/app/libs/armeabi-v7a/libtun2socks.so and /dev/null differ diff --git a/V2rayNG/app/libs/x86/libtun2socks.so b/V2rayNG/app/libs/x86/libtun2socks.so deleted file mode 100644 index 30a48fc2..00000000 Binary files a/V2rayNG/app/libs/x86/libtun2socks.so and /dev/null differ diff --git a/V2rayNG/app/libs/x86_64/libtun2socks.so b/V2rayNG/app/libs/x86_64/libtun2socks.so deleted file mode 100644 index ce965f06..00000000 Binary files a/V2rayNG/app/libs/x86_64/libtun2socks.so and /dev/null differ diff --git a/V2rayNG/app/proguard-rules.pro b/V2rayNG/app/proguard-rules.pro index e69de29b..481bb434 100644 --- a/V2rayNG/app/proguard-rules.pro +++ b/V2rayNG/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 \ No newline at end of file diff --git a/V2rayNG/app/src/main/AndroidManifest.xml b/V2rayNG/app/src/main/AndroidManifest.xml index af02d4b6..00e4b747 100644 --- a/V2rayNG/app/src/main/AndroidManifest.xml +++ b/V2rayNG/app/src/main/AndroidManifest.xml @@ -35,7 +35,6 @@ - + @@ -212,7 +215,8 @@ android:icon="@drawable/ic_stat_name" android:label="@string/app_tile_name" android:permission="android.permission.BIND_QUICK_SETTINGS_TILE" - android:process=":RunSoLibV2RayDaemon"> + android:process=":RunSoLibV2RayDaemon" + tools:targetApi="24"> @@ -224,8 +228,7 @@ + android:icon="@mipmap/ic_launcher"> @@ -234,7 +237,8 @@ + android:process=":RunSoLibV2RayDaemon" + tools:ignore="ExportedReceiver"> diff --git a/V2rayNG/app/src/main/assets/custom_routing_black b/V2rayNG/app/src/main/assets/custom_routing_black index c10156a3..f15daacd 100644 --- a/V2rayNG/app/src/main/assets/custom_routing_black +++ b/V2rayNG/app/src/main/assets/custom_routing_black @@ -20,13 +20,6 @@ "port": "443", "network": "udp" }, - { - "remarks": "阻断广告", - "outboundTag": "block", - "domain": [ - "geosite:category-ads-all" - ] - }, { "remarks": "绕过局域网IP", "outboundTag": "direct", @@ -42,21 +35,89 @@ ] }, { - "remarks": "代理GFW", + "remarks": "代理海外公共DNSIP", "outboundTag": "proxy", - "domain": [ - "geosite:gfw", - "geosite:greatfire" + "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": "代理Google等", + "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": [ - "1.0.0.1", - "1.1.1.1", - "8.8.8.8", - "8.8.4.4", "geoip:facebook", "geoip:fastly", "geoip:google", @@ -65,6 +126,14 @@ "geoip:twitter" ] }, + { + "remarks": "代理GFW", + "outboundTag": "proxy", + "domain": [ + "geosite:gfw", + "geosite:greatfire" + ] + }, { "remarks": "最终直连", "port": "0-65535", diff --git a/V2rayNG/app/src/main/assets/custom_routing_global b/V2rayNG/app/src/main/assets/custom_routing_global index 99006d9b..21bfb24d 100644 --- a/V2rayNG/app/src/main/assets/custom_routing_global +++ b/V2rayNG/app/src/main/assets/custom_routing_global @@ -5,13 +5,6 @@ "port": "443", "network": "udp" }, - { - "remarks": "阻断广告", - "outboundTag": "block", - "domain": [ - "geosite:category-ads-all" - ] - }, { "remarks": "绕过局域网IP", "outboundTag": "direct", diff --git a/V2rayNG/app/src/main/assets/custom_routing_white b/V2rayNG/app/src/main/assets/custom_routing_white index 7918822c..e3c360c0 100644 --- a/V2rayNG/app/src/main/assets/custom_routing_white +++ b/V2rayNG/app/src/main/assets/custom_routing_white @@ -13,13 +13,6 @@ "port": "443", "network": "udp" }, - { - "remarks": "阻断广告", - "outboundTag": "block", - "domain": [ - "geosite:category-ads-all" - ] - }, { "remarks": "绕过局域网IP", "outboundTag": "direct", @@ -35,48 +28,69 @@ ] }, { - "remarks": "绕过中国域名", + "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:dns.alidns.com", - "domain:dns.pub", + "domain:alidns.com", "domain:doh.pub", "domain:dot.pub", - "domain:doh.360.cn", - "domain:dot.360.cn", - "geosite:cn", - "geosite:geolocation-cn" + "domain:360.cn", + "domain:onedns.net" ] }, { "remarks": "绕过中国IP", "outboundTag": "direct", "ip": [ - "223.5.5.5/32", - "223.6.6.6/32", - "2400:3200::1/128", - "2400:3200:baba::1/128", - "119.29.29.29/32", - "1.12.12.12/32", - "120.53.53.53/32", - "2402:4e00::/128", - "2402:4e00:1::/128", - "180.76.76.76/32", - "2400:da00::6666/128", - "114.114.114.114/32", - "114.114.115.115/32", - "180.184.1.1/32", - "180.184.2.2/32", - "101.226.4.6/32", - "218.30.118.6/32", - "123.125.81.6/32", - "140.207.198.6/32", "geoip:cn" ] }, { - "remarks": "最终代理", - "port": "0-65535", - "outboundTag": "proxy" + "remarks": "绕过中国域名", + "outboundTag": "direct", + "domain": [ + "geosite:cn" + ] } -] +] \ No newline at end of file diff --git a/V2rayNG/app/src/main/assets/custom_routing_white_iran b/V2rayNG/app/src/main/assets/custom_routing_white_iran new file mode 100644 index 00000000..97ff1227 --- /dev/null +++ b/V2rayNG/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" + ] + } +] diff --git a/V2rayNG/app/src/main/assets/open_source_licenses.html b/V2rayNG/app/src/main/assets/open_source_licenses.html new file mode 100644 index 00000000..0cfd1daa --- /dev/null +++ b/V2rayNG/app/src/main/assets/open_source_licenses.html @@ -0,0 +1,1285 @@ + + + + + Open source licenses + + +

Notice for packages:

+
    +
  • Camera Core +
    +
    Copyright © 2019 The Android Open Source Project
    +
    +
    +
  • +
+ +
                                 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.
+
+
+
BSD License
+https://chromium.googlesource.com/libyuv/libyuv/+/refs/heads/main/README.chromium
+
+
+ + +
                                 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.
+
+
+
+
    +
  • MMKV +
    +
    Copyright © 20xx Tencent Wechat, Inc.
    +
    +
    +
  • +
+ +
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.
+
+
+
+ + +
Android Software Development Kit License
+https://developer.android.com/studio/terms.html
+
+
+ + +
ML Kit Terms of Service
+https://developers.google.com/ml-kit/terms
+
+
+
    +
  • editorkit +
    +
    Copyright © 20xx Dmitrii Rubtsov
    +
    +
    +
  • +
  • language-base +
    +
    Copyright © 20xx Dmitrii Rubtsov
    +
    +
    +
  • +
  • language-json +
    +
    Copyright © 20xx Dmitrii Rubtsov
    +
    +
    +
  • +
+ +
Apache 2.0 License
+https://github.com/massivemadness/EditorKit/blob/master/LICENSE
+
+
+ + +
MIT-0
+https://spdx.org/licenses/MIT-0.html
+
+
+ + +
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.
+
+
+
+ + diff --git a/V2rayNG/app/src/main/assets/proxy_packagename.txt b/V2rayNG/app/src/main/assets/proxy_packagename.txt index ecac8251..12abf7db 100644 --- a/V2rayNG/app/src/main/assets/proxy_packagename.txt +++ b/V2rayNG/app/src/main/assets/proxy_packagename.txt @@ -4,6 +4,7 @@ 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 @@ -34,7 +35,9 @@ 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 @@ -108,6 +111,7 @@ 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 @@ -115,6 +119,7 @@ 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 @@ -159,6 +164,7 @@ com.slack com.snaptube.premium com.sololearn com.sonelli.juicessh +com.sparkslab.dcardreader com.spotify.music com.tencent.huatuo com.termux @@ -173,10 +179,13 @@ com.twitter.android com.u91porn com.u9porn com.ubisoft.dance.justdance2015companion +com.udn.news com.utopia.pxview -com.valvesoftware.android.steam.communimunity 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 @@ -186,10 +195,12 @@ 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 @@ -210,6 +221,7 @@ 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 @@ -226,6 +238,7 @@ 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 @@ -239,3 +252,162 @@ 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 \ No newline at end of file diff --git a/V2rayNG/app/src/main/assets/v2ray_config.json b/V2rayNG/app/src/main/assets/v2ray_config.json index 90abcee0..4f8c3d7e 100644 --- a/V2rayNG/app/src/main/assets/v2ray_config.json +++ b/V2rayNG/app/src/main/assets/v2ray_config.json @@ -97,7 +97,7 @@ } ], "routing": { - "domainStrategy": "IPIfNonMatch", + "domainStrategy": "AsIs", "rules": [] }, "dns": { diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/AngApplication.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/AngApplication.kt new file mode 100644 index 00000000..44f680b3 --- /dev/null +++ b/V2rayNG/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() + } +} diff --git a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/AppConfig.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/AppConfig.kt similarity index 56% rename from V2rayNG/app/src/main/kotlin/com/v2ray/ang/AppConfig.kt rename to V2rayNG/app/src/main/java/com/v2ray/ang/AppConfig.kt index 3d209651..09e3a9d5 100644 --- a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/AppConfig.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/AppConfig.kt @@ -5,6 +5,7 @@ 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" @@ -12,7 +13,6 @@ object AppConfig { /** Legacy configuration keys. */ const val ANG_CONFIG = "ang_config" - const val PREF_INAPP_BUY_IS_PREMIUM = "pref_inapp_buy_is_premium" /** Preferences mapped to MMKV storage. */ const val PREF_SNIFFING_ENABLED = "pref_sniffing_enabled" @@ -22,8 +22,11 @@ object AppConfig { 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_ROUTING_DOMAIN_STRATEGY = "pref_routing_domain_strategy" const val PREF_ROUTING_RULESET = "pref_routing_ruleset" const val PREF_MUX_ENABLED = "pref_mux_enabled" @@ -41,28 +44,30 @@ object AppConfig { 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_HTTP_PORT = "pref_http_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_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" /** Cache keys. */ const val CACHE_SUBSCRIPTION_ID = "cache_subscription_id" const val CACHE_KEYWORD_FILTER = "cache_keyword_filter" /** Protocol identifiers. */ - const val PROTOCOL_HTTP: String = "http://" - const val PROTOCOL_HTTPS: String = "https://" - const val PROTOCOL_FREEDOM: String = "freedom" + const val PROTOCOL_FREEDOM = "freedom" /** Broadcast actions. */ const val BROADCAST_ACTION_SERVICE = "com.v2ray.ang.action.service" @@ -87,19 +92,20 @@ object AppConfig { const val DOWNLINK = "downlink" /** URLs for various resources. */ - const val androidpackagenamelistUrl = - "https://raw.githubusercontent.com/2dust/androidpackagenamelist/master/proxy.txt" - const val v2rayCustomRoutingListUrl = - "https://raw.githubusercontent.com/2dust/v2rayCustomRoutingList/master/" - const val v2rayNGUrl = "https://github.com/2dust/v2rayNG" - const val v2rayNGIssues = "$v2rayNGUrl/issues" - const val v2rayNGWikiMode = "$v2rayNGUrl/wiki/Mode" - const val v2rayNGPrivacyPolicy = "https://raw.githubusercontent.com/2dust/v2rayNG/master/CR.md" - const val PromotionUrl = "aHR0cHM6Ly85LjIzNDQ1Ni54eXovYWJjLmh0bWw=" - const val GeoUrl = "https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/" - const val TgChannelUrl = "https://t.me/github_2dust" - const val DelayTestUrl = "https://www.gstatic.com/generate_204" - const val DelayTestUrl2 = "https://www.google.com/generate_204" + 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" @@ -113,7 +119,6 @@ object AppConfig { /** Ports and addresses for various services. */ const val PORT_LOCAL_DNS = "10853" const val PORT_SOCKS = "10808" - const val PORT_HTTP = "10809" 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" @@ -158,4 +163,86 @@ object AppConfig { /** Give a good name to this, IDK*/ const val VPN = "VPN" + // 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" + ) + } diff --git a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/dto/AppInfo.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/dto/AppInfo.kt similarity index 100% rename from V2rayNG/app/src/main/kotlin/com/v2ray/ang/dto/AppInfo.kt rename to V2rayNG/app/src/main/java/com/v2ray/ang/dto/AppInfo.kt diff --git a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/dto/AssetUrlItem.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/dto/AssetUrlItem.kt similarity index 70% rename from V2rayNG/app/src/main/kotlin/com/v2ray/ang/dto/AssetUrlItem.kt rename to V2rayNG/app/src/main/java/com/v2ray/ang/dto/AssetUrlItem.kt index d0703f09..5a8d1e60 100644 --- a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/dto/AssetUrlItem.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/dto/AssetUrlItem.kt @@ -4,5 +4,6 @@ data class AssetUrlItem( var remarks: String = "", var url: String = "", val addedTime: Long = System.currentTimeMillis(), - var lastUpdated: Long = -1 + var lastUpdated: Long = -1, + var locked: Boolean? = false, ) \ No newline at end of file diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/dto/CheckUpdateResult.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/dto/CheckUpdateResult.kt new file mode 100644 index 00000000..be4f62e5 --- /dev/null +++ b/V2rayNG/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 +) \ No newline at end of file diff --git a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/dto/ConfigResult.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/dto/ConfigResult.kt similarity index 64% rename from V2rayNG/app/src/main/kotlin/com/v2ray/ang/dto/ConfigResult.kt rename to V2rayNG/app/src/main/java/com/v2ray/ang/dto/ConfigResult.kt index 1c3d97ff..c8870248 100644 --- a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/dto/ConfigResult.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/dto/ConfigResult.kt @@ -1,9 +1,9 @@ package com.v2ray.ang.dto -data class ConfigResult ( +data class ConfigResult( var status: Boolean, var guid: String? = null, var content: String = "", - var domainPort: String? = null, + var socksPort: Int? = null, ) diff --git a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/dto/EConfigType.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/dto/EConfigType.kt similarity index 80% rename from V2rayNG/app/src/main/kotlin/com/v2ray/ang/dto/EConfigType.kt rename to V2rayNG/app/src/main/java/com/v2ray/ang/dto/EConfigType.kt index 1dca4941..ed7cad3b 100644 --- a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/dto/EConfigType.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/dto/EConfigType.kt @@ -11,11 +11,12 @@ enum class EConfigType(val value: Int, val protocolScheme: String) { VLESS(5, AppConfig.VLESS), TROJAN(6, AppConfig.TROJAN), WIREGUARD(7, AppConfig.WIREGUARD), -// TUIC(8, AppConfig.TUIC), + + // TUIC(8, AppConfig.TUIC), HYSTERIA2(9, AppConfig.HYSTERIA2), HTTP(10, AppConfig.HTTP); companion object { - fun fromInt(value: Int) = values().firstOrNull { it.value == value } + fun fromInt(value: Int) = entries.firstOrNull { it.value == value } } } diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/dto/GitHubRelease.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/dto/GitHubRelease.kt new file mode 100644 index 00000000..0a7dce56 --- /dev/null +++ b/V2rayNG/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, + @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 + ) +} \ No newline at end of file diff --git a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/dto/Hysteria2Bean.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/dto/Hysteria2Bean.kt similarity index 60% rename from V2rayNG/app/src/main/kotlin/com/v2ray/ang/dto/Hysteria2Bean.kt rename to V2rayNG/app/src/main/java/com/v2ray/ang/dto/Hysteria2Bean.kt index c671541d..c4fc6582 100644 --- a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/dto/Hysteria2Bean.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/dto/Hysteria2Bean.kt @@ -8,6 +8,8 @@ data class Hysteria2Bean( 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?, @@ -25,5 +27,20 @@ data class Hysteria2Bean( 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?, + ) +} \ No newline at end of file diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/dto/IPAPIInfo.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/dto/IPAPIInfo.kt new file mode 100644 index 00000000..97814fbb --- /dev/null +++ b/V2rayNG/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 +) \ No newline at end of file diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/dto/Language.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/dto/Language.kt new file mode 100644 index 00000000..46333d06 --- /dev/null +++ b/V2rayNG/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 + } + } +} diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/dto/NetworkType.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/dto/NetworkType.kt new file mode 100644 index 00000000..0e8091b9 --- /dev/null +++ b/V2rayNG/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 + } +} diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/dto/ProfileItem.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/dto/ProfileItem.kt new file mode 100644 index 00000000..7a1f7346 --- /dev/null +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/dto/ProfileItem.kt @@ -0,0 +1,120 @@ +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 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 { + 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 + ) + } +} \ No newline at end of file diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/dto/RoutingType.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/dto/RoutingType.kt new file mode 100644 index 00000000..a98ac167 --- /dev/null +++ b/V2rayNG/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 + } + } + } +} diff --git a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/dto/RulesetItem.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/dto/RulesetItem.kt similarity index 90% rename from V2rayNG/app/src/main/kotlin/com/v2ray/ang/dto/RulesetItem.kt rename to V2rayNG/app/src/main/java/com/v2ray/ang/dto/RulesetItem.kt index 06a41bfa..9005f559 100644 --- a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/dto/RulesetItem.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/dto/RulesetItem.kt @@ -9,5 +9,5 @@ data class RulesetItem( var network: String? = null, var protocol: List? = null, var enabled: Boolean = true, - var looked: Boolean? = false, + var locked: Boolean? = false, ) \ No newline at end of file diff --git a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/dto/ServerAffiliationInfo.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/dto/ServerAffiliationInfo.kt similarity index 100% rename from V2rayNG/app/src/main/kotlin/com/v2ray/ang/dto/ServerAffiliationInfo.kt rename to V2rayNG/app/src/main/java/com/v2ray/ang/dto/ServerAffiliationInfo.kt diff --git a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/dto/ServerConfig.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/dto/ServerConfig.kt similarity index 100% rename from V2rayNG/app/src/main/kotlin/com/v2ray/ang/dto/ServerConfig.kt rename to V2rayNG/app/src/main/java/com/v2ray/ang/dto/ServerConfig.kt diff --git a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/dto/ServersCache.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/dto/ServersCache.kt similarity index 100% rename from V2rayNG/app/src/main/kotlin/com/v2ray/ang/dto/ServersCache.kt rename to V2rayNG/app/src/main/java/com/v2ray/ang/dto/ServersCache.kt diff --git a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/dto/SubscriptionItem.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/dto/SubscriptionItem.kt similarity index 90% rename from V2rayNG/app/src/main/kotlin/com/v2ray/ang/dto/SubscriptionItem.kt rename to V2rayNG/app/src/main/java/com/v2ray/ang/dto/SubscriptionItem.kt index 8e8c66a4..8957df78 100644 --- a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/dto/SubscriptionItem.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/dto/SubscriptionItem.kt @@ -11,5 +11,6 @@ data class SubscriptionItem( var prevProfile: String? = null, var nextProfile: String? = null, var filter: String? = null, + var allowInsecureUrl: Boolean = false, ) diff --git a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/dto/V2rayConfig.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/dto/V2rayConfig.kt similarity index 64% rename from V2rayNG/app/src/main/kotlin/com/v2ray/ang/dto/V2rayConfig.kt rename to V2rayNG/app/src/main/java/com/v2ray/ang/dto/V2rayConfig.kt index 34e4402a..155be104 100644 --- a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/dto/V2rayConfig.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/dto/V2rayConfig.kt @@ -1,23 +1,17 @@ package com.v2ray.ang.dto -import android.text.TextUtils -import com.google.gson.GsonBuilder -import com.google.gson.JsonPrimitive -import com.google.gson.JsonSerializationContext -import com.google.gson.JsonSerializer import com.google.gson.annotations.SerializedName -import com.google.gson.reflect.TypeToken +import com.v2ray.ang.AppConfig import com.v2ray.ang.util.Utils -import java.lang.reflect.Type data class V2rayConfig( var remarks: String? = null, var stats: Any? = null, val log: LogBean, - var policy: PolicyBean?, + var policy: PolicyBean? = null, val inbounds: ArrayList, var outbounds: ArrayList, - var dns: DnsBean, + var dns: DnsBean? = null, val routing: RoutingBean, val api: Any? = null, val transport: Any? = null, @@ -27,21 +21,11 @@ data class V2rayConfig( var observatory: Any? = null, var burstObservatory: Any? = null ) { - companion object { - 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 HTTP = "http" - } data class LogBean( - val access: String, - val error: String, - var loglevel: String?, + val access: String? = null, + val error: String? = null, + var loglevel: String? = null, val dnsLog: Boolean? = null ) @@ -51,7 +35,7 @@ data class V2rayConfig( var protocol: String, var listen: String? = null, val settings: Any? = null, - val sniffing: SniffingBean?, + val sniffing: SniffingBean? = null, val streamSettings: Any? = null, val allocate: Any? = null ) { @@ -82,7 +66,6 @@ data class V2rayConfig( val sendThrough: String? = null, var mux: MuxBean? = MuxBean(false) ) { - data class OutSettingsBean( var vnext: List? = null, var fragment: FragmentBean? = null, @@ -110,17 +93,17 @@ data class V2rayConfig( data class VnextBean( var address: String = "", - var port: Int = DEFAULT_PORT, + var port: Int = AppConfig.DEFAULT_PORT, var users: List ) { data class UsersBean( var id: String = "", var alterId: Int? = null, - var security: String = DEFAULT_SECURITY, - var level: Int = DEFAULT_LEVEL, - var encryption: String = "", - var flow: String = "" + var security: String? = null, + var level: Int = AppConfig.DEFAULT_LEVEL, + var encryption: String? = null, + var flow: String? = null ) } @@ -141,8 +124,8 @@ data class V2rayConfig( var method: String? = null, var ota: Boolean = false, var password: String? = null, - var port: Int = DEFAULT_PORT, - var level: Int = DEFAULT_LEVEL, + var port: Int = AppConfig.DEFAULT_PORT, + var level: Int = AppConfig.DEFAULT_LEVEL, val email: String? = null, var flow: String? = null, val ivCheck: Boolean? = null, @@ -151,7 +134,7 @@ data class V2rayConfig( data class SocksUsersBean( var user: String = "", var pass: String = "", - var level: Int = DEFAULT_LEVEL + var level: Int = AppConfig.DEFAULT_LEVEL ) } @@ -159,18 +142,19 @@ data class V2rayConfig( data class WireGuardBean( var publicKey: String = "", + var preSharedKey: String? = null, var endpoint: String = "" ) } data class StreamSettingsBean( - var network: String = DEFAULT_NETWORK, - var security: String = "", + 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 splithttpSettings: SplithttpSettingsBean? = null, + var xhttpSettings: XhttpSettingsBean? = null, var httpSettings: HttpSettingsBean? = null, var tlsSettings: TlsSettingsBean? = null, var quicSettings: QuicSettingBean? = null, @@ -220,11 +204,14 @@ data class V2rayConfig( var header: HeaderBean = HeaderBean(), var seed: String? = null ) { - data class HeaderBean(var type: String = "none") + data class HeaderBean( + var type: String = "none", + var domain: String? = null + ) } data class WsSettingsBean( - var path: String = "", + var path: String? = null, var headers: HeadersBean = HeadersBean(), val maxEarlyData: Int? = null, val useBrowserForwarding: Boolean? = null, @@ -234,21 +221,21 @@ data class V2rayConfig( } data class HttpupgradeSettingsBean( - var path: String = "", - var host: String = "", + var path: String? = null, + var host: String? = null, val acceptProxyProtocol: Boolean? = null ) - data class SplithttpSettingsBean( - var path: String = "", - var host: String = "", - val maxUploadSize: Int? = null, - val maxConcurrentUploads: Int? = 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 = ArrayList(), - var path: String = "" + var path: String? = null ) data class SockoptBean( @@ -257,12 +244,13 @@ data class V2rayConfig( var tcpFastOpen: Boolean? = null, var tproxy: String? = null, var mark: Int? = null, - var dialerProxy: String? = null + var dialerProxy: String? = null, + var domainStrategy: String? = null ) data class TlsSettingsBean( var allowInsecure: Boolean = false, - var serverName: String = "", + var serverName: String? = null, val alpn: List? = null, val minVersion: String? = null, val maxVersion: String? = null, @@ -307,143 +295,29 @@ data class V2rayConfig( ) } - fun populateTransportSettings( - transport: String, headerType: String?, host: String?, path: String?, seed: String?, - quicSecurity: String?, key: String?, mode: String?, serviceName: String?, - authority: String? - ): String { - var sni = "" - network = transport - when (network) { - "tcp" -> { - val tcpSetting = TcpSettingsBean() - if (headerType == HTTP) { - tcpSetting.header.type = HTTP - if (!TextUtils.isEmpty(host) || !TextUtils.isEmpty(path)) { - val requestObj = 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) ?: sni - } - } else { - tcpSetting.header.type = "none" - sni = host.orEmpty() - } - tcpSettings = tcpSetting - } - - "kcp" -> { - val kcpsetting = KcpSettingsBean() - kcpsetting.header.type = headerType ?: "none" - if (seed.isNullOrEmpty()) { - kcpsetting.seed = null - } else { - kcpsetting.seed = seed - } - kcpSettings = kcpsetting - } - - "ws" -> { - val wssetting = WsSettingsBean() - wssetting.headers.Host = host.orEmpty() - sni = wssetting.headers.Host - wssetting.path = path ?: "/" - wsSettings = wssetting - } - - "httpupgrade" -> { - val httpupgradeSetting = HttpupgradeSettingsBean() - httpupgradeSetting.host = host.orEmpty() - sni = httpupgradeSetting.host - httpupgradeSetting.path = path ?: "/" - httpupgradeSettings = httpupgradeSetting - } - - "splithttp" -> { - val splithttpSetting = SplithttpSettingsBean() - splithttpSetting.host = host.orEmpty() - sni = splithttpSetting.host - splithttpSetting.path = path ?: "/" - splithttpSettings = splithttpSetting - } - - "h2", "http" -> { - network = "h2" - val h2Setting = HttpSettingsBean() - h2Setting.host = (host.orEmpty()).split(",").map { it.trim() }.filter { it.isNotEmpty() } - sni = h2Setting.host.getOrNull(0) ?: sni - h2Setting.path = path ?: "/" - httpSettings = h2Setting - } - - "quic" -> { - val quicsetting = QuicSettingBean() - quicsetting.security = quicSecurity ?: "none" - quicsetting.key = key.orEmpty() - quicsetting.header.type = headerType ?: "none" - quicSettings = quicsetting - } - - "grpc" -> { - val grpcSetting = GrpcSettingsBean() - grpcSetting.multiMode = mode == "multi" - grpcSetting.serviceName = serviceName.orEmpty() - grpcSetting.authority = authority.orEmpty() - grpcSetting.idle_timeout = 60 - grpcSetting.health_check_timeout = 20 - sni = authority.orEmpty() - grpcSettings = grpcSetting - } - } - return sni - } - - fun populateTlsSettings( - streamSecurity: String, allowInsecure: Boolean, sni: String, fingerprint: String?, alpns: String?, - publicKey: String?, shortId: String?, spiderX: String? - ) { - security = streamSecurity - val tlsSetting = TlsSettingsBean( - allowInsecure = allowInsecure, - serverName = sni, - fingerprint = fingerprint, - alpn = if (alpns.isNullOrEmpty()) null else alpns.split(",").map { it.trim() }.filter { it.isNotEmpty() }, - publicKey = publicKey, - shortId = shortId, - spiderX = spiderX - ) - if (security == TLS) { - tlsSettings = tlsSetting - realitySettings = null - } else if (security == REALITY) { - tlsSettings = null - realitySettings = tlsSetting - } - } } data class MuxBean( var enabled: Boolean, - var concurrency: Int = 8, - var xudpConcurrency: Int = 8, - var xudpProxyUDP443: String = "", + 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?.get(0)?.address + 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?.get(0)?.address + return settings?.servers?.first()?.address } else if (protocol.equals(EConfigType.WIREGUARD.name, true)) { - return settings?.peers?.get(0)?.endpoint?.substringBeforeLast(":") + return settings?.peers?.first()?.endpoint?.substringBeforeLast(":") } return null } @@ -452,16 +326,16 @@ data class V2rayConfig( if (protocol.equals(EConfigType.VMESS.name, true) || protocol.equals(EConfigType.VLESS.name, true) ) { - return settings?.vnext?.get(0)?.port + 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?.get(0)?.port + return settings?.servers?.first()?.port } else if (protocol.equals(EConfigType.WIREGUARD.name, true)) { - return settings?.peers?.get(0)?.endpoint?.substringAfterLast(":")?.toInt() + return settings?.peers?.first()?.endpoint?.substringAfterLast(":")?.toInt() } return null } @@ -476,16 +350,16 @@ data class V2rayConfig( if (protocol.equals(EConfigType.VMESS.name, true) || protocol.equals(EConfigType.VLESS.name, true) ) { - return settings?.vnext?.get(0)?.users?.get(0)?.id + 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?.get(0)?.password + return settings?.servers?.first()?.password } else if (protocol.equals(EConfigType.SOCKS.name, true) || protocol.equals(EConfigType.HTTP.name, true) ) { - return settings?.servers?.get(0)?.users?.get(0)?.pass + return settings?.servers?.first()?.users?.first()?.pass } else if (protocol.equals(EConfigType.WIREGUARD.name, true)) { return settings?.secretKey } @@ -494,14 +368,14 @@ data class V2rayConfig( fun getSecurityEncryption(): String? { return when { - protocol.equals(EConfigType.VMESS.name, true) -> settings?.vnext?.get(0)?.users?.get(0)?.security - protocol.equals(EConfigType.VLESS.name, true) -> settings?.vnext?.get(0)?.users?.get(0)?.encryption - protocol.equals(EConfigType.SHADOWSOCKS.name, true) -> settings?.servers?.get(0)?.method + 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? { + fun getTransportSettingDetails(): List? { if (protocol.equals(EConfigType.VMESS.name, true) || protocol.equals(EConfigType.VLESS.name, true) || protocol.equals(EConfigType.TROJAN.name, true) @@ -509,7 +383,7 @@ data class V2rayConfig( ) { val transport = streamSettings?.network ?: return null return when (transport) { - "tcp" -> { + NetworkType.TCP.type -> { val tcpSetting = streamSettings?.tcpSettings ?: return null listOf( tcpSetting.header.type, @@ -518,7 +392,7 @@ data class V2rayConfig( ) } - "kcp" -> { + NetworkType.KCP.type -> { val kcpSetting = streamSettings?.kcpSettings ?: return null listOf( kcpSetting.header.type, @@ -527,7 +401,7 @@ data class V2rayConfig( ) } - "ws" -> { + NetworkType.WS.type -> { val wsSetting = streamSettings?.wsSettings ?: return null listOf( "", @@ -536,7 +410,7 @@ data class V2rayConfig( ) } - "httpupgrade" -> { + NetworkType.HTTP_UPGRADE.type -> { val httpupgradeSetting = streamSettings?.httpupgradeSettings ?: return null listOf( "", @@ -545,16 +419,16 @@ data class V2rayConfig( ) } - "splithttp" -> { - val splithttpSetting = streamSettings?.splithttpSettings ?: return null + NetworkType.XHTTP.type -> { + val xhttpSettings = streamSettings?.xhttpSettings ?: return null listOf( "", - splithttpSetting.host, - splithttpSetting.path + xhttpSettings.host, + xhttpSettings.path ) } - "h2" -> { + NetworkType.H2.type -> { val h2Setting = streamSettings?.httpSettings ?: return null listOf( "", @@ -563,16 +437,16 @@ data class V2rayConfig( ) } - "quic" -> { - val quicSetting = streamSettings?.quicSettings ?: return null - listOf( - quicSetting.header.type, - quicSetting.security, - quicSetting.key - ) - } +// "quic" -> { +// val quicSetting = streamSettings?.quicSettings ?: return null +// listOf( +// quicSetting.header.type, +// quicSetting.security, +// quicSetting.key +// ) +// } - "grpc" -> { + NetworkType.GRPC.type -> { val grpcSetting = streamSettings?.grpcSettings ?: return null listOf( if (grpcSetting.multiMode == true) "multi" else "gun", @@ -586,6 +460,18 @@ data class V2rayConfig( } 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( @@ -601,7 +487,8 @@ data class V2rayConfig( var port: Int? = null, var domains: List? = null, var expectIPs: List? = null, - val clientIp: String? = null + val clientIp: String? = null, + val skipFallback: Boolean? = null, ) } @@ -661,15 +548,9 @@ data class V2rayConfig( return null } - fun toPrettyPrinting(): String { - return GsonBuilder() - .setPrettyPrinting() - .disableHtmlEscaping() - .registerTypeAdapter( // custom serialiser is needed here since JSON by default parse number as Double, core will fail to start - object : TypeToken() {}.type, - JsonSerializer { src: Double?, _: Type?, _: JsonSerializationContext? -> JsonPrimitive(src?.toInt()) } - ) - .create() - .toJson(this) + fun getAllProxyOutbound(): List { + return outbounds.filter { outbound -> + EConfigType.entries.any { it.name.equals(outbound.protocol, ignoreCase = true) } + } } -} \ No newline at end of file +} diff --git a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/dto/VmessQRCode.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/dto/VmessQRCode.kt similarity index 100% rename from V2rayNG/app/src/main/kotlin/com/v2ray/ang/dto/VmessQRCode.kt rename to V2rayNG/app/src/main/java/com/v2ray/ang/dto/VmessQRCode.kt diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/dto/VpnInterfaceAddressConfig.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/dto/VpnInterfaceAddressConfig.kt new file mode 100644 index 00000000..6b7bc379 --- /dev/null +++ b/V2rayNG/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 + } + } + } +} diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/extension/_Ext.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/extension/_Ext.kt new file mode 100644 index 00000000..6e5c6bb4 --- /dev/null +++ b/V2rayNG/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) { + 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) { + 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 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 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() +} \ No newline at end of file diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/fmt/CustomFmt.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/fmt/CustomFmt.kt new file mode 100644 index 00000000..3bc20927 --- /dev/null +++ b/V2rayNG/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 + } +} \ No newline at end of file diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/fmt/FmtBase.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/fmt/FmtBase.kt new file mode 100644 index 00000000..73cdf958 --- /dev/null +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/fmt/FmtBase.kt @@ -0,0 +1,170 @@ +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 { + 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 { + 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, 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.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 { + val dicQuery = HashMap() + 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.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() + } +} diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/fmt/HttpFmt.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/fmt/HttpFmt.kt new file mode 100644 index 00000000..8c641f24 --- /dev/null +++ b/V2rayNG/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 + } +} \ No newline at end of file diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/fmt/Hysteria2Fmt.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/fmt/Hysteria2Fmt.kt new file mode 100644 index 00000000..3b3dc88c --- /dev/null +++ b/V2rayNG/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() + + 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 + } +} \ No newline at end of file diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/fmt/ShadowsocksFmt.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/fmt/ShadowsocksFmt.kt new file mode 100644 index 00000000..87ba74f8 --- /dev/null +++ b/V2rayNG/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() + 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 + } +} \ No newline at end of file diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/fmt/SocksFmt.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/fmt/SocksFmt.kt new file mode 100644 index 00000000..30bc08e4 --- /dev/null +++ b/V2rayNG/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 + } +} \ No newline at end of file diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/fmt/TrojanFmt.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/fmt/TrojanFmt.kt new file mode 100644 index 00000000..446ef99c --- /dev/null +++ b/V2rayNG/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 + } +} \ No newline at end of file diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/fmt/VlessFmt.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/fmt/VlessFmt.kt new file mode 100644 index 00000000..9242f0ec --- /dev/null +++ b/V2rayNG/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 + } +} \ No newline at end of file diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/fmt/VmessFmt.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/fmt/VmessFmt.kt new file mode 100644 index 00000000..4201f4dc --- /dev/null +++ b/V2rayNG/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 + } + +} \ No newline at end of file diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/fmt/WireguardFmt.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/fmt/WireguardFmt.kt new file mode 100644 index 00000000..8f1cec84 --- /dev/null +++ b/V2rayNG/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 = mutableMapOf() + val peerParams: MutableMap = 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() + + 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) + } +} diff --git a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/util/AngConfigManager.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/handler/AngConfigManager.kt similarity index 62% rename from V2rayNG/app/src/main/kotlin/com/v2ray/ang/util/AngConfigManager.kt rename to V2rayNG/app/src/main/java/com/v2ray/ang/handler/AngConfigManager.kt index b4e1b7c4..d24ae0c2 100644 --- a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/util/AngConfigManager.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/handler/AngConfigManager.kt @@ -1,39 +1,337 @@ -package com.v2ray.ang.util +package com.v2ray.ang.handler import android.content.Context import android.graphics.Bitmap import android.text.TextUtils import android.util.Log - -import com.google.gson.GsonBuilder -import com.google.gson.JsonPrimitive -import com.google.gson.JsonSerializationContext -import com.google.gson.JsonSerializer -import com.google.gson.reflect.TypeToken import com.v2ray.ang.AppConfig import com.v2ray.ang.AppConfig.HY2 import com.v2ray.ang.R -import com.v2ray.ang.dto.* -import com.v2ray.ang.util.fmt.Hysteria2Fmt -import com.v2ray.ang.util.fmt.ShadowsocksFmt -import com.v2ray.ang.util.fmt.SocksFmt -import com.v2ray.ang.util.fmt.TrojanFmt -import com.v2ray.ang.util.fmt.VlessFmt -import com.v2ray.ang.util.fmt.VmessFmt -import com.v2ray.ang.util.fmt.WireguardFmt -import java.lang.reflect.Type +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 -import java.util.* object AngConfigManager { + + /** - * parse config form qrcode or... + * 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): 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() + } 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 { + 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 = + JsonUtil.fromJson(server, Array::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: ServerConfig? + removedSelectedServer: ProfileItem? ): Int { try { if (str == null || TextUtils.isEmpty(str)) { @@ -71,266 +369,22 @@ object AngConfigManager { config.subscriptionId = subid val guid = MmkvManager.encodeServerConfig("", config) if (removedSelectedServer != null && - config.getProxyOutbound() - ?.getServerAddress() == removedSelectedServer.getProxyOutbound() - ?.getServerAddress() && - config.getProxyOutbound() - ?.getServerPort() == removedSelectedServer.getProxyOutbound() - ?.getServerPort() + config.server == removedSelectedServer.server && config.serverPort == removedSelectedServer.serverPort ) { MmkvManager.setSelectServer(guid) } } catch (e: Exception) { - e.printStackTrace() + Log.e(AppConfig.TAG, "Failed to parse config", e) return -1 } return 0 } /** - * share config + * Updates the configuration via all subscriptions. + * + * @return The number of configurations updated. */ - 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) { - e.printStackTrace() - return "" - } - } - - /** - * share2Clipboard - */ - 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) { - e.printStackTrace() - return -1 - } - return 0 - } - - /** - * share2Clipboard - */ - fun shareNonCustomConfigsToClipboard(context: Context, serverList: List): 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()) - } - } catch (e: Exception) { - e.printStackTrace() - return -1 - } - return 0 - } - - /** - * share2QRCode - */ - fun share2QRCode(guid: String): Bitmap? { - try { - val conf = shareConfig(guid) - if (TextUtils.isEmpty(conf)) { - return null - } - return QRCodeDecoder.createQRCode(conf) - - } catch (e: Exception) { - e.printStackTrace() - return null - } - } - - /** - * shareFullContent2Clipboard - */ - fun shareFullContent2Clipboard(context: Context, guid: String?): Int { - try { - if (guid == null) return -1 - val result = V2rayConfigUtil.getV2rayConfig(context, guid) - if (result.status) { - Utils.setClipboard(context, result.content) - } else { - return -1 - } - } catch (e: Exception) { - e.printStackTrace() - return -1 - } - return 0 - } - - fun importBatchConfig(server: String?, subid: String, append: Boolean): Pair { - 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 - } - - fun parseBatchSubscription(servers: String?): Int { - try { - if (servers == null) { - return 0 - } - - var count = 0 - servers.lines() - .forEach { str -> - if (str.startsWith(AppConfig.PROTOCOL_HTTP) || str.startsWith(AppConfig.PROTOCOL_HTTPS)) { - count += importUrlAsSubscription(str) - } - } - return count - } catch (e: Exception) { - e.printStackTrace() - } - return 0 - } - - 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() - .reversed() - .forEach { - val resId = parseConfig(it, subid, subItem, removedSelectedServer) - if (resId == 0) { - count++ - } - } - return count - } catch (e: Exception) { - e.printStackTrace() - } - return 0 - } - - 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 = - JsonUtil.fromJson(server, Array::class.java) - - if (serverList.isNotEmpty()) { - var count = 0 - for (srv in serverList.reversed()) { - val config = ServerConfig.create(EConfigType.CUSTOM) - config.fullConfig = - JsonUtil.fromJson(JsonUtil.toJson(srv), V2rayConfig::class.java) - config.remarks = config.fullConfig?.remarks - ?: ("%04d-".format(count + 1) + System.currentTimeMillis() - .toString()) - config.subscriptionId = subid - val key = MmkvManager.encodeServerConfig("", config) - MmkvManager.encodeServerRaw(key, JsonUtil.toJsonPretty(srv)) - count += 1 - } - return count - } - } catch (e: Exception) { - e.printStackTrace() - } - - try { - // For compatibility - val config = ServerConfig.create(EConfigType.CUSTOM) - config.subscriptionId = subid - config.fullConfig = JsonUtil.fromJson(server, V2rayConfig::class.java) - config.remarks = config.fullConfig?.remarks ?: System.currentTimeMillis().toString() - val key = MmkvManager.encodeServerConfig("", config) - MmkvManager.encodeServerRaw(key, server) - return 1 - } catch (e: Exception) { - e.printStackTrace() - } - return 0 - } else if (server.startsWith("[Interface]") && server.contains("[Peer]")) { - try { - val config = WireguardFmt.parseWireguardConfFile(server) - ?: return R.string.toast_incorrect_protocol - config.fullConfig?.remarks ?: System.currentTimeMillis().toString() - val key = MmkvManager.encodeServerConfig("", config) - MmkvManager.encodeServerRaw(key, server) - return 1 - } catch (e: Exception) { - e.printStackTrace() - } - return 0 - } else { - return 0 - } - } - fun updateConfigViaSubAll(): Int { var count = 0 try { @@ -338,12 +392,18 @@ object AngConfigManager { count += updateConfigViaSub(it) } } catch (e: Exception) { - e.printStackTrace() + 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): Int { try { if (TextUtils.isEmpty(it.first) @@ -355,24 +415,29 @@ object AngConfigManager { if (!it.second.enabled) { return 0 } - val url = Utils.idnToASCII(it.second.url) + val url = HttpUtil.toIdnUrl(it.second.url) if (!Utils.isValidUrl(url)) { return 0 } - Log.d(AppConfig.ANG_PACKAGE, url) + if (!it.second.allowInsecureUrl) { + if (!Utils.isValidSubUrl(url)) { + return 0 + } + } + Log.i(AppConfig.TAG, url) var configText = try { val httpPort = SettingsManager.getHttpPort() - Utils.getUrlContentWithCustomUserAgent(url, 30000, httpPort) + HttpUtil.getUrlContentWithUserAgent(url, 15000, httpPort) } catch (e: Exception) { - e.printStackTrace() + Log.e(AppConfig.ANG_PACKAGE, "Update subscription: proxy not ready or other error", e) "" } if (configText.isEmpty()) { configText = try { - Utils.getUrlContentWithCustomUserAgent(url) + HttpUtil.getUrlContentWithUserAgent(url) } catch (e: Exception) { - e.printStackTrace() + Log.e(AppConfig.TAG, "Update subscription: Failed to get URL content with user agent", e) "" } } @@ -381,11 +446,19 @@ object AngConfigManager { } return parseConfigViaSub(configText, it.first, false) } catch (e: Exception) { - e.printStackTrace() + 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) { @@ -397,6 +470,12 @@ object AngConfigManager { 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 { diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/handler/MigrateManager.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/handler/MigrateManager.kt new file mode 100644 index 00000000..9dd90c76 --- /dev/null +++ b/V2rayNG/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) + } +} diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/handler/MmkvManager.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/handler/MmkvManager.kt new file mode 100644 index 00000000..9b589dd0 --- /dev/null +++ b/V2rayNG/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) { + mainStorage.encode(KEY_ANG_CONFIGS, JsonUtil.toJson(serverList)) + } + + /** + * Decodes the server list. + * + * @return The list of server GUIDs. + */ + fun decodeServerList(): MutableList { + val json = mainStorage.decodeString(KEY_ANG_CONFIGS) + return if (json.isNullOrBlank()) { + mutableListOf() + } else { + JsonUtil.fromJson(json, Array::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?) { + 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> { + initSubsList() + + val subscriptions = mutableListOf>() + 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) { + mainStorage.encode(KEY_SUB_IDS, JsonUtil.toJson(subsList)) + } + + /** + * Decodes the subscription list. + * + * @return The list of subscription IDs. + */ + fun decodeSubsList(): MutableList { + val json = mainStorage.decodeString(KEY_SUB_IDS) + return if (json.isNullOrBlank()) { + mutableListOf() + } else { + JsonUtil.fromJson(json, Array::class.java).toMutableList() + } + } + + //endregion + + //region Asset + + /** + * Decodes the asset URLs. + * + * @return The list of asset URLs. + */ + fun decodeAssetUrls(): List> { + val assetUrlItems = mutableListOf>() + 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? { + val ruleset = settingsStorage.decodeString(PREF_ROUTING_RULESET) + if (ruleset.isNullOrEmpty()) return null + return JsonUtil.fromJson(ruleset, Array::class.java).toMutableList() + } + + /** + * Encodes the routing rulesets. + * + * @param rulesetList The list of routing rulesets. + */ + fun encodeRoutingRulesets(rulesetList: MutableList?) { + 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): 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? { + 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 + +} diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/handler/SettingsManager.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/handler/SettingsManager.kt new file mode 100644 index 00000000..b2e23f7f --- /dev/null +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/handler/SettingsManager.kt @@ -0,0 +1,373 @@ +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? { + val fileName = RoutingType.fromIndex(index).fileName + val assets = Utils.readTextFromAssets(context, fileName) + if (TextUtils.isEmpty(assets)) { + return null + } + + return JsonUtil.fromJson(assets, Array::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::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) { + val rulesetNew: MutableList = 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 { + 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 { + 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 { + 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) + } +} diff --git a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/util/SpeedtestUtil.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/handler/SpeedtestManager.kt similarity index 56% rename from V2rayNG/app/src/main/kotlin/com/v2ray/ang/util/SpeedtestUtil.kt rename to V2rayNG/app/src/main/java/com/v2ray/ang/handler/SpeedtestManager.kt index 10e793be..e547c378 100644 --- a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/util/SpeedtestUtil.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/handler/SpeedtestManager.kt @@ -1,28 +1,34 @@ -package com.v2ray.ang.util +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.AppConfig.LOOPBACK 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.HttpURLConnection import java.net.InetSocketAddress -import java.net.Proxy import java.net.Socket -import java.net.URL import java.net.UnknownHostException import kotlin.coroutines.coroutineContext -object SpeedtestUtil { +object SpeedtestManager { private val tcpTestingSockets = ArrayList() + /** + * 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) { @@ -37,15 +43,27 @@ object SpeedtestUtil { 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, Utils.getDelayTestUrl()) + Libv2ray.measureOutboundDelay(config, SettingsManager.getDelayTestUrl()) } catch (e: Exception) { - Log.d(AppConfig.ANG_PACKAGE, "realPing: $e") + 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" @@ -60,11 +78,18 @@ object SpeedtestUtil { } } } catch (e: Exception) { - e.printStackTrace() + 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() @@ -80,15 +105,18 @@ object SpeedtestUtil { socket.close() return time } catch (e: UnknownHostException) { - e.printStackTrace() + Log.e(AppConfig.TAG, "Unknown host: $url", e) } catch (e: IOException) { - Log.d(AppConfig.ANG_PACKAGE, "socketConnectTime IOException: $e") + Log.e(AppConfig.TAG, "socketConnectTime IOException: $e") } catch (e: Exception) { - e.printStackTrace() + 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 { @@ -98,26 +126,19 @@ object SpeedtestUtil { } } + /** + * 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 { var result: String var elapsed = -1L - var conn: HttpURLConnection? = null + val conn = HttpUtil.createProxyConnection(SettingsManager.getDelayTestUrl(), port, 15000, 15000) ?: return Pair(elapsed, "") try { - val url = URL(Utils.getDelayTestUrl()) - - conn = url.openConnection( - Proxy( - Proxy.Type.HTTP, - InetSocketAddress(LOOPBACK, port) - ) - ) as HttpURLConnection - conn.connectTimeout = 30000 - conn.readTimeout = 30000 - conn.setRequestProperty("Connection", "close") - conn.instanceFollowRedirects = false - conn.useCaches = false - val start = SystemClock.elapsedRealtime() val code = conn.responseCode elapsed = SystemClock.elapsedRealtime() - start @@ -133,20 +154,34 @@ object SpeedtestUtil { ) } } catch (e: IOException) { - // network exception - Log.d(AppConfig.ANG_PACKAGE, "testConnection IOException: " + Log.getStackTraceString(e)) + Log.e(AppConfig.TAG, "Connection test IOException", e) result = context.getString(R.string.connection_test_error, e.message) } catch (e: Exception) { - // library exception, eg sumsung - Log.d(AppConfig.ANG_PACKAGE, "testConnection Exception: " + Log.getStackTraceString(e)) + Log.e(AppConfig.TAG, "Connection test Exception", e) result = context.getString(R.string.connection_test_error, e.message) } finally { - conn?.disconnect() + 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() } diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/handler/UpdateCheckerManager.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/handler/UpdateCheckerManager.kt new file mode 100644 index 00000000..37b55c2e --- /dev/null +++ b/V2rayNG/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::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") + } +} diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/handler/V2rayConfigManager.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/handler/V2rayConfigManager.kt new file mode 100644 index 00000000..f53697bb --- /dev/null +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/handler/V2rayConfigManager.kt @@ -0,0 +1,1091 @@ +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.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.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) + } + } + + /** + * 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 + } + + /** + * 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 { + val domain = ArrayList() + + 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) + ) + ) + } + + // 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 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 + ) + ) + } + + // DNS routing tag + v2rayConfig.routing.rules.add( + 0, RulesBean( + inboundTag = arrayListOf("dns-in"), + outboundTag = "dns-out", + domain = 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() + val servers = ArrayList() + + //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 + ) + ) + } + + if (Utils.isPureIpAddress(domesticDns.first())) { + v2rayConfig.routing.rules.add( + 0, RulesBean( + outboundTag = AppConfig.TAG_DIRECT, + port = "53", + ip = arrayListOf(domesticDns.first()), + domain = null + ) + ) + } + + //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 + ) + + // DNS routing + if (Utils.isPureIpAddress(remoteDns.first())) { + v2rayConfig.routing.rules.add( + 0, RulesBean( + outboundTag = AppConfig.TAG_PROXY, + port = "53", + ip = arrayListOf(remoteDns.first()), + 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 + } + + /** + * 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 = if (preferIpv6) "UseIPv6v4" else "UseIPv4v6" + continue + } + + val resolvedIps = HttpUtil.resolveHostToIP(domain, preferIpv6) + if (resolvedIps.isNullOrEmpty()) continue + + item.ensureSockopt().domainStrategy = if (preferIpv6) "UseIPv6v4" else "UseIPv4v6" + 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 + + 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, + ) + if (streamSettings.security == AppConfig.TLS) { + streamSettings.tlsSettings = tlsSetting + streamSettings.realitySettings = null + } else if (streamSettings.security == AppConfig.REALITY) { + streamSettings.tlsSettings = null + streamSettings.realitySettings = tlsSetting + } + } + + //endregion +} diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/helper/CustomDividerItemDecoration.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/helper/CustomDividerItemDecoration.kt new file mode 100644 index 00000000..0f6d37c0 --- /dev/null +++ b/V2rayNG/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) + } + } +} \ No newline at end of file diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/helper/ItemTouchHelperAdapter.java b/V2rayNG/app/src/main/java/com/v2ray/ang/helper/ItemTouchHelperAdapter.kt similarity index 53% rename from V2rayNG/app/src/main/java/com/v2ray/ang/helper/ItemTouchHelperAdapter.java rename to V2rayNG/app/src/main/java/com/v2ray/ang/helper/ItemTouchHelperAdapter.kt index 566feffc..8707d16f 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/helper/ItemTouchHelperAdapter.java +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/helper/ItemTouchHelperAdapter.kt @@ -13,46 +13,41 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - -package com.v2ray.ang.helper; - -import androidx.recyclerview.widget.ItemTouchHelper; -import androidx.recyclerview.widget.RecyclerView; +package com.v2ray.ang.helper /** - * Interface to listen for a move or dismissal event from a {@link ItemTouchHelper.Callback}. + * Interface to listen for a move or dismissal event from a [ItemTouchHelper.Callback]. * * @author Paul Burke (ipaulpro) */ -public interface ItemTouchHelperAdapter { - +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.
- *
- * Implementations should call {@link RecyclerView.Adapter#notifyItemMoved(int, int)} after + * an item is shifted, and **not** at the end of a "drop" event.

+ *

+ * 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(RecyclerView.ViewHolder) - * @see RecyclerView.ViewHolder#getAdapterPosition() + * @see RecyclerView.getAdapterPositionFor + * @see RecyclerView.ViewHolder.getAdapterPosition */ - boolean onItemMove(int fromPosition, int toPosition); + fun onItemMove(fromPosition: Int, toPosition: Int): Boolean - void onItemMoveCompleted(); + fun onItemMoveCompleted() /** - * Called when an item has been dismissed by a swipe.
- *
- * Implementations should call {@link RecyclerView.Adapter#notifyItemRemoved(int)} after + * Called when an item has been dismissed by a swipe.

+ *

+ * 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(RecyclerView.ViewHolder) - * @see RecyclerView.ViewHolder#getAdapterPosition() + * @see RecyclerView.getAdapterPositionFor + * @see RecyclerView.ViewHolder.getAdapterPosition */ - void onItemDismiss(int position); + fun onItemDismiss(position: Int) } diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/helper/ItemTouchHelperViewHolder.java b/V2rayNG/app/src/main/java/com/v2ray/ang/helper/ItemTouchHelperViewHolder.kt similarity index 67% rename from V2rayNG/app/src/main/java/com/v2ray/ang/helper/ItemTouchHelperViewHolder.java rename to V2rayNG/app/src/main/java/com/v2ray/ang/helper/ItemTouchHelperViewHolder.kt index 149768fc..75655b0c 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/helper/ItemTouchHelperViewHolder.java +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/helper/ItemTouchHelperViewHolder.kt @@ -13,29 +13,26 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +package com.v2ray.ang.helper -package com.v2ray.ang.helper; - -import androidx.recyclerview.widget.ItemTouchHelper; +import androidx.recyclerview.widget.ItemTouchHelper /** - * Interface to notify an item ViewHolder of relevant callbacks from {@link - * ItemTouchHelper.Callback}. + * Interface to notify an item ViewHolder of relevant callbacks from [ ]. * * @author Paul Burke (ipaulpro) */ -public interface ItemTouchHelperViewHolder { - +interface ItemTouchHelperViewHolder { /** - * Called when the {@link ItemTouchHelper} first registers an item as being moved or swiped. + * 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. */ - void onItemSelected(); + fun onItemSelected() /** - * Called when the {@link ItemTouchHelper} has completed the move or swipe, and the active item + * Called when the [ItemTouchHelper] has completed the move or swipe, and the active item * state should be cleared. */ - void onItemClear(); + fun onItemClear() } diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/helper/SimpleItemTouchHelperCallback.java b/V2rayNG/app/src/main/java/com/v2ray/ang/helper/SimpleItemTouchHelperCallback.java deleted file mode 100644 index e3686921..00000000 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/helper/SimpleItemTouchHelperCallback.java +++ /dev/null @@ -1,129 +0,0 @@ -/* - * 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.graphics.Canvas; - -import androidx.recyclerview.widget.GridLayoutManager; -import androidx.recyclerview.widget.ItemTouchHelper; -import androidx.recyclerview.widget.RecyclerView; - -import org.jetbrains.annotations.NotNull; - -/** - * An implementation of {@link ItemTouchHelper.Callback} that enables basic drag & drop and - * swipe-to-dismiss. Drag events are automatically started by an item long-press.
- *
- * Expects the RecyclerView.Adapter to listen for {@link - * ItemTouchHelperAdapter} callbacks and the RecyclerView.ViewHolder to implement - * {@link ItemTouchHelperViewHolder}. - * - * @author Paul Burke (ipaulpro) - */ -public class SimpleItemTouchHelperCallback extends ItemTouchHelper.Callback { - - public static final float ALPHA_FULL = 1.0f; - - private final ItemTouchHelperAdapter mAdapter; - - public SimpleItemTouchHelperCallback(ItemTouchHelperAdapter adapter) { - mAdapter = adapter; - } - - @Override - public boolean isLongPressDragEnabled() { - return true; - } - - @Override - public boolean isItemViewSwipeEnabled() { - return false; - } - - @Override - public int getMovementFlags(RecyclerView recyclerView, @NotNull RecyclerView.ViewHolder viewHolder) { - // Set movement flags based on the layout manager - if (recyclerView.getLayoutManager() instanceof GridLayoutManager) { - final int dragFlags = ItemTouchHelper.UP | ItemTouchHelper.DOWN | ItemTouchHelper.LEFT | ItemTouchHelper.RIGHT; - final int swipeFlags = 0; - return makeMovementFlags(dragFlags, swipeFlags); - } else { - final int dragFlags = ItemTouchHelper.UP | ItemTouchHelper.DOWN; - final int swipeFlags = ItemTouchHelper.START | ItemTouchHelper.END; - return makeMovementFlags(dragFlags, swipeFlags); - } - } - - @Override - public boolean onMove(@NotNull RecyclerView recyclerView, RecyclerView.ViewHolder source, RecyclerView.ViewHolder target) { - if (source.getItemViewType() != target.getItemViewType()) { - return false; - } - - // Notify the adapter of the move - mAdapter.onItemMove(source.getBindingAdapterPosition(), target.getBindingAdapterPosition()); - return true; - } - - @Override - public void onSwiped(RecyclerView.ViewHolder viewHolder, int i) { - // Notify the adapter of the dismissal - mAdapter.onItemDismiss(viewHolder.getBindingAdapterPosition()); - } - - @Override - public void onChildDraw(@NotNull Canvas c, @NotNull RecyclerView recyclerView, @NotNull RecyclerView.ViewHolder viewHolder, float dX, - float dY, int actionState, boolean isCurrentlyActive) { - if (actionState == ItemTouchHelper.ACTION_STATE_SWIPE) { - // Fade out the view as it is swiped out of the parent's bounds - final float alpha = ALPHA_FULL - Math.abs(dX) / (float) viewHolder.itemView.getWidth(); - viewHolder.itemView.setAlpha(alpha); - viewHolder.itemView.setTranslationX(dX); - } else { - super.onChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive); - } - } - - @Override - public void onSelectedChanged(RecyclerView.ViewHolder viewHolder, int actionState) { - // We only want the active item to change - if (actionState != ItemTouchHelper.ACTION_STATE_IDLE) { - if (viewHolder instanceof ItemTouchHelperViewHolder) { - // Let the view holder know that this item is being moved or dragged - ItemTouchHelperViewHolder itemViewHolder = (ItemTouchHelperViewHolder) viewHolder; - itemViewHolder.onItemSelected(); - } - } - - super.onSelectedChanged(viewHolder, actionState); - } - - @Override - public void clearView(@NotNull RecyclerView recyclerView, @NotNull RecyclerView.ViewHolder viewHolder) { - super.clearView(recyclerView, viewHolder); - - mAdapter.onItemMoveCompleted(); - - viewHolder.itemView.setAlpha(ALPHA_FULL); - - if (viewHolder instanceof ItemTouchHelperViewHolder) { - // Tell the view holder it's time to restore the idle state - ItemTouchHelperViewHolder itemViewHolder = (ItemTouchHelperViewHolder) viewHolder; - itemViewHolder.onItemClear(); - } - } -} diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/helper/SimpleItemTouchHelperCallback.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/helper/SimpleItemTouchHelperCallback.kt new file mode 100644 index 00000000..b98129a7 --- /dev/null +++ b/V2rayNG/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.

+ * + * 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 + } +} diff --git a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/plugin/NativePlugin.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/plugin/NativePlugin.kt similarity index 100% rename from V2rayNG/app/src/main/kotlin/com/v2ray/ang/plugin/NativePlugin.kt rename to V2rayNG/app/src/main/java/com/v2ray/ang/plugin/NativePlugin.kt diff --git a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/plugin/Plugin.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/plugin/Plugin.kt similarity index 100% rename from V2rayNG/app/src/main/kotlin/com/v2ray/ang/plugin/Plugin.kt rename to V2rayNG/app/src/main/java/com/v2ray/ang/plugin/Plugin.kt diff --git a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/plugin/PluginContract.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/plugin/PluginContract.kt similarity index 100% rename from V2rayNG/app/src/main/kotlin/com/v2ray/ang/plugin/PluginContract.kt rename to V2rayNG/app/src/main/java/com/v2ray/ang/plugin/PluginContract.kt diff --git a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/plugin/PluginList.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/plugin/PluginList.kt similarity index 78% rename from V2rayNG/app/src/main/kotlin/com/v2ray/ang/plugin/PluginList.kt rename to V2rayNG/app/src/main/java/com/v2ray/ang/plugin/PluginList.kt index 9c247286..2495eb45 100644 --- a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/plugin/PluginList.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/plugin/PluginList.kt @@ -29,8 +29,9 @@ class PluginList : ArrayList() { init { addAll( AngApplication.application.packageManager.queryIntentContentProviders( - Intent(PluginContract.ACTION_NATIVE_PLUGIN), PackageManager.GET_META_DATA) - .filter { it.providerInfo.exported }.map { NativePlugin(it) }) + Intent(PluginContract.ACTION_NATIVE_PLUGIN), PackageManager.GET_META_DATA + ) + .filter { it.providerInfo.exported }.map { NativePlugin(it) }) } val lookup = mutableMapOf().apply { @@ -39,13 +40,13 @@ class PluginList : ArrayList() { if (old != null && old != plugin) { this@PluginList.remove(old) } - /* if (old != null && old !== plugin) { - val packages = this@PluginList.filter { 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) - }*/ + /* if (old != null && old !== plugin) { + val packages = this@PluginList.filter { 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)) } diff --git a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/plugin/PluginManager.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/plugin/PluginManager.kt similarity index 80% rename from V2rayNG/app/src/main/kotlin/com/v2ray/ang/plugin/PluginManager.kt rename to V2rayNG/app/src/main/java/com/v2ray/ang/plugin/PluginManager.kt index 19fb6f2e..e7e8457a 100644 --- a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/plugin/PluginManager.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/plugin/PluginManager.kt @@ -32,10 +32,10 @@ import android.database.Cursor import android.net.Uri import android.os.Build import android.system.Os -import android.widget.Toast 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 @@ -43,6 +43,7 @@ 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) { @@ -88,30 +89,34 @@ object PluginManager { 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) + 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 } + 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 } + 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 } + 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 + 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? @@ -121,7 +126,7 @@ object PluginManager { if (providers.size > 1) { val message = "Conflicting plugins found from: ${providers.joinToString { it.providerInfo.packageName }}" - Toast.makeText(AngApplication.application, message, Toast.LENGTH_LONG).show() + AngApplication.application.toast(message) throw IllegalStateException(message) } val provider = providers.single().providerInfo @@ -129,7 +134,7 @@ object PluginManager { try { initNativeFaster(provider)?.also { return InitResult(it) } } catch (t: Throwable) { - // Logs.w("Initializing native plugin faster mode failed") + // Logs.w("Initializing native plugin faster mode failed") failure = t } @@ -138,19 +143,23 @@ object PluginManager { authority(provider.authority) }.build() try { - return initNativeFast(AngApplication.application.contentResolver, + return initNativeFast( + AngApplication.application.contentResolver, pluginId, - uri)?.let { InitResult(it) } + uri + )?.let { InitResult(it) } } catch (t: Throwable) { - // Logs.w("Initializing native plugin fast mode failed") + // Logs.w("Initializing native plugin fast mode failed") failure?.also { t.addSuppressed(it) } failure = t } try { - return initNativeSlow(AngApplication.application.contentResolver, + return initNativeSlow( + AngApplication.application.contentResolver, pluginId, - uri)?.let { InitResult(it) } + uri + )?.let { InitResult(it) } } catch (t: Throwable) { failure?.also { t.addSuppressed(it) } throw t @@ -180,11 +189,13 @@ object PluginManager { throw IndexOutOfBoundsException("Plugin entry binary not found") val pluginDir = File(AngApplication.application.noBackupFilesDir, "plugin") - (cr.query(uri, + (cr.query( + uri, arrayOf(PluginContract.COLUMN_PATH, PluginContract.COLUMN_MODE), null, null, - null) + null + ) ?: return null).use { cursor -> if (!cursor.moveToFirst()) entryNotFound() pluginDir.deleteRecursively() @@ -197,11 +208,13 @@ object PluginManager { 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") - }) + 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()) } @@ -209,10 +222,11 @@ object PluginManager { return File(pluginDir, pluginId).absolutePath } - fun ComponentInfo.loadString(key: String) = when (val value = metaData.get(key)) { + fun ComponentInfo.loadString(key: String) = when (val value = metaData.getString(key)) { is String -> value - is Int -> AngApplication.application.packageManager.getResourcesForApplication(applicationInfo) - .getString(value) +// is Int -> AngApplication.application.packageManager.getResourcesForApplication(applicationInfo) +// .getString(value) + null -> null else -> error("meta-data $key has invalid type ${value.javaClass}") } diff --git a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/plugin/ResolvedPlugin.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/plugin/ResolvedPlugin.kt similarity index 85% rename from V2rayNG/app/src/main/kotlin/com/v2ray/ang/plugin/ResolvedPlugin.kt rename to V2rayNG/app/src/main/java/com/v2ray/ang/plugin/ResolvedPlugin.kt index 3b2f2003..6e6861cf 100644 --- a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/plugin/ResolvedPlugin.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/plugin/ResolvedPlugin.kt @@ -22,6 +22,7 @@ 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 @@ -33,13 +34,18 @@ abstract class ResolvedPlugin(protected val resolveInfo: ResolveInfo) : Plugin() override val id by lazy { componentInfo.loadString(PluginContract.METADATA_KEY_ID)!! } override val version by lazy { - AngApplication.application.getPackageInfo(componentInfo.packageName).versionCode + getPackageInfo(componentInfo.packageName).versionCode } override val versionName: String by lazy { - AngApplication.application.getPackageInfo(componentInfo.packageName).versionName!! + 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 + )!! } diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/receiver/BootReceiver.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/receiver/BootReceiver.kt new file mode 100644 index 00000000..ae15e76c --- /dev/null +++ b/V2rayNG/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.service.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) + } +} diff --git a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/receiver/TaskerReceiver.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/receiver/TaskerReceiver.kt similarity index 52% rename from V2rayNG/app/src/main/kotlin/com/v2ray/ang/receiver/TaskerReceiver.kt rename to V2rayNG/app/src/main/java/com/v2ray/ang/receiver/TaskerReceiver.kt index 9af91c9b..bdb7bf9a 100644 --- a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/receiver/TaskerReceiver.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/receiver/TaskerReceiver.kt @@ -6,32 +6,36 @@ import android.content.Intent import android.text.TextUtils import com.v2ray.ang.AppConfig import com.v2ray.ang.service.V2RayServiceManager -import com.v2ray.ang.util.MmkvManager -import com.v2ray.ang.util.Utils 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, "") + val guid = bundle?.getString(AppConfig.TASKER_EXTRA_BUNDLE_GUID).orEmpty() - if (switch == null || guid == null || TextUtils.isEmpty(guid)) { + if (switch == null || TextUtils.isEmpty(guid)) { return } else if (switch) { if (guid == AppConfig.TASKER_DEFAULT_GUID) { - Utils.startVServiceFromToggle(context) + V2RayServiceManager.startVServiceFromToggle(context) } else { - MmkvManager.setSelectServer(guid) - V2RayServiceManager.startV2Ray(context) + V2RayServiceManager.startVService(context, guid) } } else { - Utils.stopVService(context) + V2RayServiceManager.stopVService(context) } } catch (e: Exception) { - e.printStackTrace() + android.util.Log.e(AppConfig.TAG, "Error processing Tasker broadcast", e) } } } diff --git a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/receiver/WidgetProvider.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/receiver/WidgetProvider.kt similarity index 72% rename from V2rayNG/app/src/main/kotlin/com/v2ray/ang/receiver/WidgetProvider.kt rename to V2rayNG/app/src/main/java/com/v2ray/ang/receiver/WidgetProvider.kt index 5aa6c2de..99d9bda2 100644 --- a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/receiver/WidgetProvider.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/receiver/WidgetProvider.kt @@ -11,18 +11,29 @@ import android.widget.RemoteViews import com.v2ray.ang.AppConfig import com.v2ray.ang.R import com.v2ray.ang.service.V2RayServiceManager -import com.v2ray.ang.util.Utils 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.v2rayPoint.isRunning) + 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) @@ -52,15 +63,19 @@ class WidgetProvider : AppWidgetProvider() { } /** - * 接收窗口小部件发送的广播 + * 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.v2rayPoint.isRunning) { - Utils.stopVService(context) + if (V2RayServiceManager.isRunning()) { + V2RayServiceManager.stopVService(context) } else { - Utils.startVServiceFromToggle(context) + V2RayServiceManager.startVServiceFromToggle(context) } } else if (AppConfig.BROADCAST_ACTION_ACTIVITY == intent.action) { AppWidgetManager.getInstance(context)?.let { manager -> diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/service/NotificationService.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/service/NotificationService.kt new file mode 100644 index 00000000..92c551a6 --- /dev/null +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/service/NotificationService.kt @@ -0,0 +1,252 @@ +package com.v2ray.ang.service + +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.AppConfig.ANG_PACKAGE +import com.v2ray.ang.AppConfig.TAG_DIRECT +import com.v2ray.ang.R +import com.v2ray.ang.dto.ProfileItem +import com.v2ray.ang.extension.toSpeedString +import com.v2ray.ang.handler.MmkvManager +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 NotificationService { + 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(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(TAG_DIRECT, AppConfig.UPLINK) + val directDownlink = V2RayServiceManager.queryStats(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, 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` = 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` = 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() + } +} \ No newline at end of file diff --git a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/service/ProcessService.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/service/ProcessService.kt similarity index 53% rename from V2rayNG/app/src/main/kotlin/com/v2ray/ang/service/ProcessService.kt rename to V2rayNG/app/src/main/java/com/v2ray/ang/service/ProcessService.kt index e88f7a29..e81e3615 100644 --- a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/service/ProcessService.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/service/ProcessService.kt @@ -2,17 +2,21 @@ package com.v2ray.ang.service import android.content.Context import android.util.Log -import com.v2ray.ang.AppConfig.ANG_PACKAGE +import com.v2ray.ang.AppConfig import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch class ProcessService { - private val TAG = ANG_PACKAGE - private lateinit var process: Process + 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) { - Log.d(TAG, cmd.toString()) + Log.i(AppConfig.TAG, cmd.toString()) try { val proBuilder = ProcessBuilder(cmd) @@ -23,23 +27,26 @@ class ProcessService { CoroutineScope(Dispatchers.IO).launch { Thread.sleep(50L) - Log.d(TAG, "runProcess check") - process.waitFor() - Log.d(TAG, "runProcess exited") + Log.i(AppConfig.TAG, "runProcess check") + process?.waitFor() + Log.i(AppConfig.TAG, "runProcess exited") } - Log.d(TAG, process.toString()) + Log.i(AppConfig.TAG, process.toString()) } catch (e: Exception) { - Log.d(TAG, e.toString()) + Log.e(AppConfig.TAG, e.toString(), e) } } + /** + * Stops the running process. + */ fun stopProcess() { try { - Log.d(TAG, "runProcess destroy") + Log.i(AppConfig.TAG, "runProcess destroy") process?.destroy() } catch (e: Exception) { - Log.d(TAG, e.toString()) + Log.e(AppConfig.TAG, "Failed to destroy process", e) } } -} \ No newline at end of file +} diff --git a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/service/QSTileService.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/service/QSTileService.kt similarity index 61% rename from V2rayNG/app/src/main/kotlin/com/v2ray/ang/service/QSTileService.kt rename to V2rayNG/app/src/main/java/com/v2ray/ang/service/QSTileService.kt index 1c9afe6c..7aecf634 100644 --- a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/service/QSTileService.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/service/QSTileService.kt @@ -1,6 +1,5 @@ package com.v2ray.ang.service -import android.annotation.TargetApi import android.content.BroadcastReceiver import android.content.Context import android.content.Intent @@ -9,58 +8,80 @@ 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.util.MessageUtil import com.v2ray.ang.util.Utils import java.lang.ref.SoftReference -@TargetApi(Build.VERSION_CODES.N) +@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) - qsTile?.icon = Icon.createWithResource(applicationContext, R.drawable.ic_stat_name) } else if (state == Tile.STATE_ACTIVE) { qsTile?.state = Tile.STATE_ACTIVE - qsTile?.label = V2RayServiceManager.currentConfig?.remarks - qsTile?.icon = Icon.createWithResource(applicationContext, R.drawable.ic_stat_name) + 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() - setState(Tile.STATE_INACTIVE) - mMsgReceive = ReceiveMessageHandler(this) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - registerReceiver(mMsgReceive, IntentFilter(AppConfig.BROADCAST_ACTION_ACTIVITY), Context.RECEIVER_EXPORTED) - } else { - registerReceiver(mMsgReceive, IntentFilter(AppConfig.BROADCAST_ACTION_ACTIVITY)) - } + 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() - unregisterReceiver(mMsgReceive) - mMsgReceive = null + 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 -> { - Utils.startVServiceFromToggle(this) + V2RayServiceManager.startVServiceFromToggle(this) } Tile.STATE_ACTIVE -> { - Utils.stopVService(this) + V2RayServiceManager.stopVService(this) } } } diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/service/ServiceControl.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/service/ServiceControl.kt new file mode 100644 index 00000000..085c72eb --- /dev/null +++ b/V2rayNG/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 +} diff --git a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/service/SubscriptionUpdater.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/service/SubscriptionUpdater.kt similarity index 85% rename from V2rayNG/app/src/main/kotlin/com/v2ray/ang/service/SubscriptionUpdater.kt rename to V2rayNG/app/src/main/java/com/v2ray/ang/service/SubscriptionUpdater.kt index 587fb5ef..5f3f8172 100644 --- a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/service/SubscriptionUpdater.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/service/SubscriptionUpdater.kt @@ -14,14 +14,11 @@ import com.v2ray.ang.AppConfig import com.v2ray.ang.AppConfig.SUBSCRIPTION_UPDATE_CHANNEL import com.v2ray.ang.AppConfig.SUBSCRIPTION_UPDATE_CHANNEL_NAME import com.v2ray.ang.R -import com.v2ray.ang.util.AngConfigManager -import com.v2ray.ang.util.AngConfigManager.updateConfigViaSub -import com.v2ray.ang.util.MmkvManager -import com.v2ray.ang.util.Utils +import com.v2ray.ang.handler.AngConfigManager.updateConfigViaSub +import com.v2ray.ang.handler.MmkvManager object SubscriptionUpdater { - class UpdateTask(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) { @@ -35,9 +32,13 @@ object SubscriptionUpdater { .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.d(AppConfig.ANG_PACKAGE, "subscription automatic update starting") + Log.i(AppConfig.TAG, "subscription automatic update starting") val subs = MmkvManager.decodeSubscriptions().filter { it.second.autoUpdate } @@ -55,10 +56,7 @@ object SubscriptionUpdater { notificationManager.createNotificationChannel(channel) } notificationManager.notify(3, notification.build()) - Log.d( - AppConfig.ANG_PACKAGE, - "subscription automatic update: ---${subItem.remarks}" - ) + Log.i(AppConfig.TAG, "subscription automatic update: ---${subItem.remarks}") updateConfigViaSub(Pair(sub.first, subItem)) notification.setContentText("Updating ${subItem.remarks}") } diff --git a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/service/V2RayProxyOnlyService.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/service/V2RayProxyOnlyService.kt similarity index 53% rename from V2rayNG/app/src/main/kotlin/com/v2ray/ang/service/V2RayProxyOnlyService.kt rename to V2rayNG/app/src/main/java/com/v2ray/ang/service/V2RayProxyOnlyService.kt index 4d387a45..25fcd1a6 100644 --- a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/service/V2RayProxyOnlyService.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/service/V2RayProxyOnlyService.kt @@ -6,50 +6,87 @@ 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.util.MyContextWrapper -import com.v2ray.ang.util.Utils 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.startV2rayPoint() + V2RayServiceManager.startCoreLoop() return START_STICKY } + /** + * Destroys the service. + */ override fun onDestroy() { super.onDestroy() - V2RayServiceManager.stopV2rayPoint() + 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, Utils.getLocale()) + MyContextWrapper.wrap(newBase, SettingsManager.getLocale()) } super.attachBaseContext(context) } diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/service/V2RayServiceManager.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/service/V2RayServiceManager.kt new file mode 100644 index 00000000..4f42ca23 --- /dev/null +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/service/V2RayServiceManager.kt @@ -0,0 +1,377 @@ +package com.v2ray.ang.service + +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.handler.MmkvManager +import com.v2ray.ang.handler.SettingsManager +import com.v2ray.ang.handler.SpeedtestManager +import com.v2ray.ang.handler.V2rayConfigManager +import com.v2ray.ang.util.MessageUtil +import com.v2ray.ang.util.PluginUtil +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? = 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, "") + NotificationService.cancelNotification() + return false + } + + try { + MessageUtil.sendMsg2UI(service, AppConfig.MSG_STATE_START_SUCCESS, "") + NotificationService.showNotification(currentConfig) + NotificationService.startSpeedNotification(currentConfig) + + PluginUtil.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, "") + NotificationService.cancelNotification() + + try { + service.unregisterReceiver(mMsgReceive) + } catch (e: Exception) { + Log.e(AppConfig.TAG, "Failed to unregister broadcast receiver", e) + } + PluginUtil.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") + NotificationService.stopSpeedNotification(currentConfig) + } + + Intent.ACTION_SCREEN_ON -> { + Log.i(AppConfig.TAG, "SCREEN_ON, start querying stats") + NotificationService.startSpeedNotification(currentConfig) + } + } + } + } +} diff --git a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/service/V2RayTestService.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/service/V2RayTestService.kt similarity index 61% rename from V2rayNG/app/src/main/kotlin/com/v2ray/ang/service/V2RayTestService.kt rename to V2rayNG/app/src/main/java/com/v2ray/ang/service/V2RayTestService.kt index 3e12f8b0..3fef1ae1 100644 --- a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/service/V2RayTestService.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/service/V2RayTestService.kt @@ -8,12 +8,12 @@ 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.SpeedtestManager +import com.v2ray.ang.handler.V2rayConfigManager import com.v2ray.ang.util.MessageUtil -import com.v2ray.ang.util.MmkvManager import com.v2ray.ang.util.PluginUtil -import com.v2ray.ang.util.SpeedtestUtil import com.v2ray.ang.util.Utils -import com.v2ray.ang.util.V2rayConfigUtil import go.Seq import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job @@ -24,14 +24,24 @@ import libv2ray.Libv2ray import java.util.concurrent.Executors class V2RayTestService : Service() { - private val realTestScope by lazy { CoroutineScope(Executors.newFixedThreadPool(10).asCoroutineDispatcher()) } + 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.initV2Env(Utils.userAssetPath(this), Utils.getDeviceIdForXUDPBaseKey()) + 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 -> { @@ -49,23 +59,33 @@ class V2RayTestService : Service() { 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 server = MmkvManager.decodeServerConfig(guid) ?: return retFailure - if (server.getProxyOutbound()?.protocol?.equals(EConfigType.HYSTERIA2.name, true) == true) { - val delay = PluginUtil.realPingHy2(this, server) + val config = MmkvManager.decodeServerConfig(guid) ?: return retFailure + if (config.configType == EConfigType.HYSTERIA2) { + val delay = PluginUtil.realPingHy2(this, config) return delay } else { - val config = V2rayConfigUtil.getV2rayConfig(this, guid) - if (!config.status) { + val configResult = V2rayConfigManager.getV2rayConfig4Speedtest(this, guid) + if (!configResult.status) { return retFailure } - return SpeedtestUtil.realPing(config.content) + return SpeedtestManager.realPing(configResult.content) } } } diff --git a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/service/V2RayVpnService.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/service/V2RayVpnService.kt similarity index 70% rename from V2rayNG/app/src/main/kotlin/com/v2ray/ang/service/V2RayVpnService.kt rename to V2rayNG/app/src/main/java/com/v2ray/ang/service/V2RayVpnService.kt index fc33653d..d734c299 100644 --- a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/service/V2RayVpnService.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/service/V2RayVpnService.kt @@ -10,6 +10,7 @@ import android.net.LocalSocketAddress 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 @@ -19,10 +20,9 @@ import androidx.annotation.RequiresApi import com.v2ray.ang.AppConfig import com.v2ray.ang.AppConfig.LOOPBACK import com.v2ray.ang.BuildConfig -import com.v2ray.ang.R -import com.v2ray.ang.util.MmkvManager.settingsStorage +import com.v2ray.ang.handler.MmkvManager +import com.v2ray.ang.handler.SettingsManager import com.v2ray.ang.util.MyContextWrapper -import com.v2ray.ang.util.SettingsManager import com.v2ray.ang.util.Utils import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -33,18 +33,11 @@ import java.lang.ref.SoftReference class V2RayVpnService : VpnService(), ServiceControl { companion object { private const val VPN_MTU = 1500 - private const val PRIVATE_VLAN4_CLIENT = "26.26.26.1" - private const val PRIVATE_VLAN4_ROUTER = "26.26.26.2" - private const val PRIVATE_VLAN6_CLIENT = "da26:2626::1" - private const val PRIVATE_VLAN6_ROUTER = "da26:2626::2" private const val TUN2SOCKS = "libtun2socks.so" } - private lateinit var mInterface: ParcelFileDescriptor private var isRunning = false - - //val fd: Int get() = mInterface.fd private lateinit var process: Process /**destroy @@ -64,7 +57,7 @@ class V2RayVpnService : VpnService(), ServiceControl { .build() } - private val connectivity by lazy { getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager } + private val connectivity by lazy { getSystemService(CONNECTIVITY_SERVICE) as ConnectivityManager } @delegate:RequiresApi(Build.VERSION_CODES.P) private val defaultNetworkCallback by lazy { @@ -86,7 +79,6 @@ class V2RayVpnService : VpnService(), ServiceControl { override fun onCreate() { super.onCreate() - val policy = StrictMode.ThreadPolicy.Builder().permitAll().build() StrictMode.setThreadPolicy(policy) V2RayServiceManager.serviceControl = SoftReference(this) @@ -103,221 +95,17 @@ class V2RayVpnService : VpnService(), ServiceControl { override fun onDestroy() { super.onDestroy() - V2RayServiceManager.cancelNotification() - } - - private fun setup() { - val prepare = prepare(this) - if (prepare != null) { - return - } - - // If the old interface has exactly the same parameters, use it! - // Configure a builder while parsing the parameters. - val builder = Builder() - //val enableLocalDns = defaultDPreference.getPrefBoolean(AppConfig.PREF_LOCAL_DNS_ENABLED, false) - - builder.setMtu(VPN_MTU) - builder.addAddress(PRIVATE_VLAN4_CLIENT, 30) - //builder.addDnsServer(PRIVATE_VLAN4_ROUTER) - val bypassLan = SettingsManager.routingRulesetsBypassLan() - if (bypassLan) { - resources.getStringArray(R.array.bypass_private_ip_address).forEach { - val addr = it.split('/') - builder.addRoute(addr[0], addr[1].toInt()) - } - } else { - builder.addRoute("0.0.0.0", 0) - } - - if (settingsStorage?.decodeBool(AppConfig.PREF_PREFER_IPV6) == true) { - builder.addAddress(PRIVATE_VLAN6_CLIENT, 126) - if (bypassLan) { - builder.addRoute("2000::", 3) //currently only 1/8 of total ipV6 is in use - } else { - builder.addRoute("::", 0) - } - } - -// if (settingsStorage?.decodeBool(AppConfig.PREF_LOCAL_DNS_ENABLED) == true) { -// builder.addDnsServer(PRIVATE_VLAN4_ROUTER) -// } else { - Utils.getVpnDnsServers() - .forEach { - if (Utils.isPureIpAddress(it)) { - builder.addDnsServer(it) - } - } -// } - - builder.setSession(V2RayServiceManager.currentConfig?.remarks.orEmpty()) - - val selfPackageName = BuildConfig.APPLICATION_ID - if (settingsStorage?.decodeBool(AppConfig.PREF_PER_APP_PROXY) == true) { - val apps = settingsStorage?.decodeStringSet(AppConfig.PREF_PER_APP_PROXY_SET) - val bypassApps = settingsStorage?.decodeBool(AppConfig.PREF_BYPASS_APPS) ?: false - //process self package - if (bypassApps) apps?.add(selfPackageName) else apps?.remove(selfPackageName) - apps?.forEach { - try { - if (bypassApps) - builder.addDisallowedApplication(it) - else - builder.addAllowedApplication(it) - } catch (e: PackageManager.NameNotFoundException) { - } - } - } else { - builder.addDisallowedApplication(selfPackageName) - } - - // Close the old interface since the parameters have been changed. - try { - mInterface.close() - } catch (ignored: Exception) { - // ignored - } - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - try { - connectivity.requestNetwork(defaultNetworkRequest, defaultNetworkCallback) - } catch (e: Exception) { - e.printStackTrace() - } - } - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - builder.setMetered(false) - } - - // Create a new interface using the builder and save the parameters. - try { - mInterface = builder.establish()!! - isRunning = true - runTun2socks() - } catch (e: Exception) { - // non-nullable lateinit var - e.printStackTrace() - stopV2Ray() - } - } - - private fun runTun2socks() { - val socksPort = SettingsManager.getSocksPort() - val cmd = arrayListOf( - File(applicationContext.applicationInfo.nativeLibraryDir, TUN2SOCKS).absolutePath, - "--netif-ipaddr", PRIVATE_VLAN4_ROUTER, - "--netif-netmask", "255.255.255.252", - "--socks-server-addr", "$LOOPBACK:${socksPort}", - "--tunmtu", VPN_MTU.toString(), - "--sock-path", "sock_path",//File(applicationContext.filesDir, "sock_path").absolutePath, - "--enable-udprelay", - "--loglevel", "notice" - ) - - if (settingsStorage?.decodeBool(AppConfig.PREF_PREFER_IPV6) == true) { - cmd.add("--netif-ip6addr") - cmd.add(PRIVATE_VLAN6_ROUTER) - } - if (settingsStorage?.decodeBool(AppConfig.PREF_LOCAL_DNS_ENABLED) == true) { - val localDnsPort = Utils.parseInt(settingsStorage?.decodeString(AppConfig.PREF_LOCAL_DNS_PORT), AppConfig.PORT_LOCAL_DNS.toInt()) - cmd.add("--dnsgw") - cmd.add("$LOOPBACK:${localDnsPort}") - } - Log.d(packageName, cmd.toString()) - - try { - val proBuilder = ProcessBuilder(cmd) - proBuilder.redirectErrorStream(true) - process = proBuilder - .directory(applicationContext.filesDir) - .start() - Thread(Runnable { - Log.d(packageName, "$TUN2SOCKS check") - process.waitFor() - Log.d(packageName, "$TUN2SOCKS exited") - if (isRunning) { - Log.d(packageName, "$TUN2SOCKS restart") - runTun2socks() - } - }).start() - Log.d(packageName, process.toString()) - - sendFd() - } catch (e: Exception) { - Log.d(packageName, e.toString()) - } - } - - private fun sendFd() { - val fd = mInterface.fileDescriptor - val path = File(applicationContext.filesDir, "sock_path").absolutePath - Log.d(packageName, path) - - CoroutineScope(Dispatchers.IO).launch { - var tries = 0 - while (true) try { - Thread.sleep(50L shl tries) - Log.d(packageName, "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.d(packageName, e.toString()) - if (tries > 5) break - tries += 1 - } - } + NotificationService.cancelNotification() } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - V2RayServiceManager.startV2rayPoint() + if (V2RayServiceManager.startCoreLoop()) { + startService() + } return START_STICKY //return super.onStartCommand(intent, flags, startId) } - 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 - } - } - - try { - Log.d(packageName, "tun2socks destroy") - process.destroy() - } catch (e: Exception) { - Log.d(packageName, e.toString()) - } - - V2RayServiceManager.stopV2rayPoint() - - 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 (ignored: Exception) { - // ignored - } - } - } - override fun getService(): Service { return this } @@ -337,8 +125,251 @@ class V2RayVpnService : VpnService(), ServiceControl { @RequiresApi(Build.VERSION_CODES.N) override fun attachBaseContext(newBase: Context?) { val context = newBase?.let { - MyContextWrapper.wrap(newBase, Utils.getLocale()) + 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 setup() { + val prepare = prepare(this) + if (prepare != null) { + return + } + + if (setupVpnService() != true) { + return + } + + runTun2socks() + } + + /** + * Configures the VPN service. + * @return True if the VPN service was configured successfully, false otherwise. + */ + private fun setupVpnService(): Boolean { + // If the old interface has exactly the same parameters, use it! + // Configure a builder while parsing the parameters. + val builder = Builder() + val vpnConfig = SettingsManager.getCurrentVpnInterfaceAddressConfig() + //val enableLocalDns = defaultDPreference.getPrefBoolean(AppConfig.PREF_LOCAL_DNS_ENABLED, false) + + builder.setMtu(VPN_MTU) + builder.addAddress(vpnConfig.ipv4Client, 30) + //builder.addDnsServer(PRIVATE_VLAN4_ROUTER) + val bypassLan = SettingsManager.routingRulesetsBypassLan() + 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) + } + + 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) + } + } + +// 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()) + + val selfPackageName = BuildConfig.APPLICATION_ID + if (MmkvManager.decodeSettingsBool(AppConfig.PREF_PER_APP_PROXY)) { + val apps = MmkvManager.decodeSettingsStringSet(AppConfig.PREF_PER_APP_PROXY_SET) + val bypassApps = MmkvManager.decodeSettingsBool(AppConfig.PREF_BYPASS_APPS) + //process self package + if (bypassApps) apps?.add(selfPackageName) else apps?.remove(selfPackageName) + apps?.forEach { + try { + if (bypassApps) + builder.addDisallowedApplication(it) + else + builder.addAllowedApplication(it) + } catch (e: PackageManager.NameNotFoundException) { + Log.e(AppConfig.TAG, "Failed to configure app in VPN: ${e.localizedMessage}", e) + } + } + } else { + builder.addDisallowedApplication(selfPackageName) + } + + // Close the old interface since the parameters have been changed. + try { + mInterface.close() + } catch (ignored: Exception) { + // ignored + } + + 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) + } + } + + 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())) + } + } + + // Create a new interface using the builder and save the parameters. + try { + mInterface = builder.establish()!! + isRunning = true + return true + } catch (e: Exception) { + // non-nullable lateinit var + Log.e(AppConfig.TAG, "Failed to establish VPN interface", e) + stopV2Ray() + } + return false + } + + /** + * Runs the tun2socks process. + * Starts the tun2socks process with the appropriate parameters. + */ + private fun runTun2socks() { + Log.i(AppConfig.TAG, "Start run $TUN2SOCKS") + val socksPort = SettingsManager.getSocksPort() + val vpnConfig = SettingsManager.getCurrentVpnInterfaceAddressConfig() + val cmd = arrayListOf( + File(applicationContext.applicationInfo.nativeLibraryDir, TUN2SOCKS).absolutePath, + "--netif-ipaddr", vpnConfig.ipv4Router, + "--netif-netmask", "255.255.255.252", + "--socks-server-addr", "$LOOPBACK:${socksPort}", + "--tunmtu", VPN_MTU.toString(), + "--sock-path", "sock_path",//File(applicationContext.filesDir, "sock_path").absolutePath, + "--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("$LOOPBACK:${localDnsPort}") + } + Log.i(AppConfig.TAG, cmd.toString()) + + try { + val proBuilder = ProcessBuilder(cmd) + proBuilder.redirectErrorStream(true) + process = proBuilder + .directory(applicationContext.filesDir) + .start() + Thread { + Log.i(AppConfig.TAG, "$TUN2SOCKS check") + process.waitFor() + Log.i(AppConfig.TAG, "$TUN2SOCKS exited") + if (isRunning) { + Log.i(AppConfig.TAG, "$TUN2SOCKS restart") + runTun2socks() + } + }.start() + Log.i(AppConfig.TAG, "$TUN2SOCKS process info : ${process.toString()}") + + 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 = mInterface.fileDescriptor + val path = File(applicationContext.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 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 + } + } + + try { + Log.i(AppConfig.TAG, "$TUN2SOCKS destroy") + process.destroy() + } catch (e: Exception) { + Log.e(AppConfig.TAG, "Failed to destroy $TUN2SOCKS process", e) + } + + 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) + } + } + } } diff --git a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/ui/AboutActivity.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/AboutActivity.kt similarity index 52% rename from V2rayNG/app/src/main/kotlin/com/v2ray/ang/ui/AboutActivity.kt rename to V2rayNG/app/src/main/java/com/v2ray/ang/ui/AboutActivity.kt index 8f0f1bf0..1931cb45 100644 --- a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/ui/AboutActivity.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/AboutActivity.kt @@ -4,16 +4,20 @@ 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.tbruyelle.rxpermissions3.RxPermissions 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.util.SpeedtestUtil +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 @@ -21,9 +25,23 @@ 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) @@ -31,12 +49,13 @@ class AboutActivity : BaseActivity() { 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) { - toast(R.string.toast_success) + toastSuccess(R.string.toast_success) } else { - toast(R.string.toast_failure) + toastError(R.string.toast_failure) } } @@ -48,59 +67,69 @@ class AboutActivity : BaseActivity() { Intent(Intent.ACTION_SEND).setType("application/zip") .setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) .putExtra( - Intent.EXTRA_STREAM, FileProvider.getUriForFile( + Intent.EXTRA_STREAM, + FileProvider.getUriForFile( this, BuildConfig.APPLICATION_ID + ".cache", File(ret.second) ) ), getString(R.string.title_configuration_share) ) ) } else { - toast(R.string.toast_failure) + 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 - } - RxPermissions(this) - .request(permission) - .subscribe { - if (it) { - try { - showFileChooser() - } catch (e: Exception) { - e.printStackTrace() - } - } else - toast(R.string.toast_permission_denied) + 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.v2rayNGUrl) + Utils.openUri(this, AppConfig.APP_URL) } binding.layoutFeedback.setOnClickListener { - Utils.openUri(this, AppConfig.v2rayNGIssues) + 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.TgChannelUrl) + Utils.openUri(this, AppConfig.TG_CHANNEL_URL) } binding.layoutPrivacyPolicy.setOnClickListener { - Utils.openUri(this, AppConfig.v2rayNGPrivacyPolicy) + Utils.openUri(this, AppConfig.APP_PRIVACY_POLICY) } - "v${BuildConfig.VERSION_NAME} (${SpeedtestUtil.getLibVersion()})".also { + "v${BuildConfig.VERSION_NAME} (${SpeedtestManager.getLibVersion()})".also { binding.tvVersion.text = it } } - fun backupConfiguration(outputZipFilePos: String): Pair { + private fun backupConfiguration(outputZipFilePos: String): Pair { val dateFormated = SimpleDateFormat( "yyyy-MM-dd-HH-mm-ss", Locale.getDefault() @@ -121,7 +150,7 @@ class AboutActivity : BaseActivity() { } } - fun restoreConfiguration(zipFile: File): Boolean { + private fun restoreConfiguration(zipFile: File): Boolean { val backupDir = this.cacheDir.absolutePath + "/${System.currentTimeMillis()}" if (!ZipUtil.unzipToFolder(zipFile, backupDir)) { @@ -133,42 +162,39 @@ class AboutActivity : BaseActivity() { } private fun showFileChooser() { - val intent = Intent(Intent.ACTION_GET_CONTENT) - intent.type = "*/*" - intent.addCategory(Intent.CATEGORY_OPENABLE) + 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()) { - val uri = it.data?.data - if (it.resultCode == RESULT_OK && uri != null) { + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + val uri = result.data?.data + if (result.resultCode == RESULT_OK && uri != null) { try { - try { - val targetFile = - File(this.cacheDir.absolutePath, "${System.currentTimeMillis()}.zip") - contentResolver.openInputStream(uri).use { input -> - targetFile.outputStream().use { fileOut -> - input?.copyTo(fileOut) - } + val targetFile = + File(this.cacheDir.absolutePath, "${System.currentTimeMillis()}.zip") + contentResolver.openInputStream(uri).use { input -> + targetFile.outputStream().use { fileOut -> + input?.copyTo(fileOut) } - if (restoreConfiguration(targetFile)) { - toast(R.string.toast_success) - } else { - toast(R.string.toast_failure) - } - } catch (e: Exception) { - e.printStackTrace() } - + if (restoreConfiguration(targetFile)) { + toastSuccess(R.string.toast_success) + } else { + toastError(R.string.toast_failure) + } } catch (e: Exception) { - e.printStackTrace() - toast(e.message.toString()) + Log.e(AppConfig.TAG, "Error during file restore", e) + toastError(R.string.toast_failure) } } } diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/BaseActivity.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/BaseActivity.kt new file mode 100644 index 00000000..0cd7f647 --- /dev/null +++ b/V2rayNG/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) + } +} diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/CheckUpdateActivity.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/CheckUpdateActivity.kt new file mode 100644 index 00000000..a9b698c5 --- /dev/null +++ b/V2rayNG/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() + } +} \ No newline at end of file diff --git a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/ui/FragmentAdapter.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/FragmentAdapter.kt similarity index 100% rename from V2rayNG/app/src/main/kotlin/com/v2ray/ang/ui/FragmentAdapter.kt rename to V2rayNG/app/src/main/java/com/v2ray/ang/ui/FragmentAdapter.kt diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/LogcatActivity.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/LogcatActivity.kt new file mode 100644 index 00000000..436f5913 --- /dev/null +++ b/V2rayNG/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 = mutableListOf() + var logsets: MutableList = 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() + 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() + 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() + } +} \ No newline at end of file diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/LogcatRecyclerAdapter.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/LogcatRecyclerAdapter.kt new file mode 100644 index 00000000..e83714d5 --- /dev/null +++ b/V2rayNG/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() { + 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) + +} diff --git a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/ui/MainActivity.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/MainActivity.kt similarity index 59% rename from V2rayNG/app/src/main/kotlin/com/v2ray/ang/ui/MainActivity.kt rename to V2rayNG/app/src/main/java/com/v2ray/ang/ui/MainActivity.kt index 880a3822..0c7584d8 100644 --- a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/ui/MainActivity.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/MainActivity.kt @@ -1,18 +1,18 @@ package com.v2ray.ang.ui import android.Manifest -import android.content.ActivityNotFoundException +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.text.TextUtils +import android.util.Log import android.view.KeyEvent import android.view.Menu import android.view.MenuItem -import android.widget.Toast import androidx.activity.OnBackPressedCallback import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels @@ -23,31 +23,28 @@ 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 androidx.recyclerview.widget.LinearLayoutManager import com.google.android.material.navigation.NavigationView import com.google.android.material.tabs.TabLayout -import com.tbruyelle.rxpermissions3.RxPermissions 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.service.V2RayServiceManager -import com.v2ray.ang.util.AngConfigManager -import com.v2ray.ang.util.MmkvManager -import com.v2ray.ang.util.MmkvManager.settingsStorage import com.v2ray.ang.util.Utils import com.v2ray.ang.viewmodel.MainViewModel -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers -import io.reactivex.rxjava3.core.Observable import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import me.drakeet.support.toast.ToastCompat -import java.util.concurrent.TimeUnit +import kotlinx.coroutines.withContext class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedListener { private val binding by lazy { @@ -80,6 +77,53 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList 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) @@ -88,8 +132,8 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList binding.fab.setOnClickListener { if (mainViewModel.isRunning.value == true) { - Utils.stopVService(this) - } else if ((settingsStorage?.decodeString(AppConfig.PREF_MODE) ?: VPN) == VPN) { + V2RayServiceManager.stopVService(this) + } else if ((MmkvManager.decodeSettingsString(AppConfig.PREF_MODE) ?: VPN) == VPN) { val intent = VpnService.prepare(this) if (intent == null) { startV2Ray() @@ -110,7 +154,12 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList } binding.recyclerView.setHasFixedSize(true) - binding.recyclerView.layoutManager = LinearLayoutManager(this) + 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)) @@ -125,14 +174,13 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList initGroupTab() setupViewModel() + migrateLegacy() if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - RxPermissions(this) - .request(Manifest.permission.POST_NOTIFICATIONS) - .subscribe { - if (!it) - toast(R.string.toast_permission_denied) - } + 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) { @@ -140,13 +188,15 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList if (binding.drawerLayout.isDrawerOpen(GravityCompat.START)) { binding.drawerLayout.closeDrawer(GravityCompat.START) } else { - //super.onBackPressed() + isEnabled = false onBackPressedDispatcher.onBackPressed() + isEnabled = true } } }) } + @SuppressLint("NotifyDataSetChanged") private fun setupViewModel() { mainViewModel.updateListAction.observe(this) { index -> if (index >= 0) { @@ -171,7 +221,22 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList } } mainViewModel.startListenBroadcast() - mainViewModel.copyAssets(assets) + 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() { @@ -197,23 +262,22 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList binding.tabGroup.isVisible = true } - fun startV2Ray() { + private fun startV2Ray() { if (MmkvManager.getSelectServer().isNullOrEmpty()) { toast(R.string.title_file_chooser) return } - V2RayServiceManager.startV2Ray(this) + V2RayServiceManager.startVService(this) } - fun restartV2Ray() { + private fun restartV2Ray() { if (mainViewModel.isRunning.value == true) { - Utils.stopVService(this) + V2RayServiceManager.stopVService(this) + } + lifecycleScope.launch { + delay(500) + startV2Ray() } - Observable.timer(500, TimeUnit.MILLISECONDS) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe { - startV2Ray() - } } public override fun onResume() { @@ -250,7 +314,7 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) { R.id.import_qrcode -> { - importQRcode(true) + importQRcode() true } @@ -259,6 +323,11 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList true } + R.id.import_local -> { + importConfigLocal() + true + } + R.id.import_manually_vmess -> { importManually(EConfigType.VMESS.value) true @@ -299,53 +368,19 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList true } - R.id.import_config_custom_clipboard -> { - importConfigCustomClipboard() - true - } - - R.id.import_config_custom_local -> { - importConfigCustomLocal() - true - } - - R.id.import_config_custom_url -> { - importConfigCustomUrlClipboard() - true - } - - R.id.import_config_custom_url_scan -> { - importQRcode(false) - true - } - - R.id.sub_update -> { - importConfigViaSub() - true - } - R.id.export_all -> { - binding.pbWaiting.show() - lifecycleScope.launch(Dispatchers.IO) { - val ret = mainViewModel.exportAllServer() - launch(Dispatchers.Main) { - if (ret == 0) - toast(R.string.toast_success) - else - toast(R.string.toast_failure) - binding.pbWaiting.hide() - } - } - + 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 } @@ -356,75 +391,31 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList } R.id.del_all_config -> { - AlertDialog.Builder(this).setMessage(R.string.del_config_comfirm) - .setPositiveButton(android.R.string.ok) { _, _ -> - binding.pbWaiting.show() - lifecycleScope.launch(Dispatchers.IO) { - mainViewModel.removeAllServer() - launch(Dispatchers.Main) { - mainViewModel.reloadServerList() - binding.pbWaiting.hide() - } - } - } - .setNegativeButton(android.R.string.no) { _, _ -> - //do noting - } - .show() + delAllConfig() true } R.id.del_duplicate_config -> { - 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.no) { _, _ -> - //do noting - } - .show() + delDuplicateConfig() true } R.id.del_invalid_config -> { - AlertDialog.Builder(this).setMessage(R.string.del_invalid_config_comfirm) - .setPositiveButton(android.R.string.ok) { _, _ -> - binding.pbWaiting.show() - lifecycleScope.launch(Dispatchers.IO) { - mainViewModel.removeInvalidServer() - launch(Dispatchers.Main) { - mainViewModel.reloadServerList() - binding.pbWaiting.hide() - } - } - } - .setNegativeButton(android.R.string.no) { _, _ -> - //do noting - } - .show() + delInvalidConfig() true } R.id.sort_by_test_results -> { - binding.pbWaiting.show() - lifecycleScope.launch(Dispatchers.IO) { - mainViewModel.sortByTestResults() - launch(Dispatchers.Main) { - mainViewModel.reloadServerList() - binding.pbWaiting.hide() - } - } + sortByTestResults() true } + R.id.sub_update -> { + importConfigViaSub() + true + } + + else -> super.onOptionsItemSelected(item) } @@ -440,39 +431,17 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList /** * import config from qrcode */ - private fun importQRcode(forConfig: Boolean): Boolean { -// try { -// startActivityForResult(Intent("com.google.zxing.client.android.SCAN") -// .addCategory(Intent.CATEGORY_DEFAULT) -// .addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP), requestCode) -// } catch (e: Exception) { - RxPermissions(this) - .request(Manifest.permission.CAMERA) - .subscribe { - if (it) - if (forConfig) - scanQRCodeForConfig.launch(Intent(this, ScannerActivity::class.java)) - else - scanQRCodeForUrlToCustomConfig.launch(Intent(this, ScannerActivity::class.java)) - else - toast(R.string.toast_permission_denied) - } -// } + 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 } - private val scanQRCodeForConfig = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { - if (it.resultCode == RESULT_OK) { - importBatchConfig(it.data?.getStringExtra("SCAN_RESULT")) - } - } - - private val scanQRCodeForUrlToCustomConfig = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { - if (it.resultCode == RESULT_OK) { - importConfigCustomUrl(it.data?.getStringExtra("SCAN_RESULT")) - } - } - /** * import config from clipboard */ @@ -482,116 +451,59 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList val clipboard = Utils.getClipboard(this) importBatchConfig(clipboard) } catch (e: Exception) { - e.printStackTrace() + Log.e(AppConfig.TAG, "Failed to import config from clipboard", e) return false } return true } private fun importBatchConfig(server: String?) { -// val dialog = AlertDialog.Builder(this) -// .setView(LayoutProgressBinding.inflate(layoutInflater).root) -// .setCancelable(false) -// .show() binding.pbWaiting.show() lifecycleScope.launch(Dispatchers.IO) { - val (count, countSub) = AngConfigManager.importBatchConfig(server, mainViewModel.subscriptionId, true) - delay(500L) - launch(Dispatchers.Main) { - if (count > 0) { - toast(R.string.toast_success) - mainViewModel.reloadServerList() - } else if (countSub > 0) { - initGroupTab() - } else { - toast(R.string.toast_failure) - } - //dialog.dismiss() - binding.pbWaiting.hide() - } - } - } + 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() + } - private fun importConfigCustomClipboard() - : Boolean { - try { - val configText = Utils.getClipboard(this) - if (TextUtils.isEmpty(configText)) { - toast(R.string.toast_none_data_clipboard) - return false + 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) } - importCustomizeConfig(configText) - return true - } catch (e: Exception) { - e.printStackTrace() - return false } } /** * import config from local config file */ - private fun importConfigCustomLocal(): Boolean { + private fun importConfigLocal(): Boolean { try { showFileChooser() } catch (e: Exception) { - e.printStackTrace() + Log.e(AppConfig.TAG, "Failed to import config from local file", e) return false } return true } - private fun importConfigCustomUrlClipboard() - : Boolean { - try { - val url = Utils.getClipboard(this) - if (TextUtils.isEmpty(url)) { - toast(R.string.toast_none_data_clipboard) - return false - } - return importConfigCustomUrl(url) - } catch (e: Exception) { - e.printStackTrace() - return false - } - } - - /** - * import config from url - */ - private fun importConfigCustomUrl(url: String?): Boolean { - try { - if (!Utils.isValidUrl(url)) { - toast(R.string.toast_invalid_url) - return false - } - lifecycleScope.launch(Dispatchers.IO) { - val configText = try { - Utils.getUrlContentWithCustomUserAgent(url) - } catch (e: Exception) { - e.printStackTrace() - "" - } - launch(Dispatchers.Main) { - importCustomizeConfig(configText) - } - } - } catch (e: Exception) { - e.printStackTrace() - return false - } - return true - } /** * import config from sub */ private fun importConfigViaSub(): Boolean { -// val dialog = AlertDialog.Builder(this) -// .setView(LayoutProgressBinding.inflate(layoutInflater).root) -// .setCancelable(false) -// .show() binding.pbWaiting.show() lifecycleScope.launch(Dispatchers.IO) { @@ -599,18 +511,99 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList delay(500L) launch(Dispatchers.Main) { if (count > 0) { - toast(R.string.toast_success) + toast(getString(R.string.title_update_config_count, count)) mainViewModel.reloadServerList() } else { - toast(R.string.toast_failure) + toastError(R.string.toast_failure) } - //dialog.dismiss() 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 */ @@ -619,17 +612,17 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList intent.type = "*/*" intent.addCategory(Intent.CATEGORY_OPENABLE) - try { - chooseFileForCustomConfig.launch(Intent.createChooser(intent, getString(R.string.title_file_chooser))) - } catch (ex: ActivityNotFoundException) { - toast(R.string.toast_require_file_manager) + val permission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + Manifest.permission.READ_MEDIA_IMAGES + } else { + Manifest.permission.READ_EXTERNAL_STORAGE } - } - private val chooseFileForCustomConfig = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { - val uri = it.data?.data - if (it.resultCode == RESULT_OK && uri != null) { - readContentFromUri(uri) + 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) } } @@ -642,42 +635,17 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList } else { Manifest.permission.READ_EXTERNAL_STORAGE } - RxPermissions(this) - .request(permission) - .subscribe { - if (it) { - try { - contentResolver.openInputStream(uri).use { input -> - importCustomizeConfig(input?.bufferedReader()?.readText()) - } - } catch (e: Exception) { - e.printStackTrace() - } - } else - toast(R.string.toast_permission_denied) - } - } - /** - * import customize config - */ - private fun importCustomizeConfig(server: String?) { - try { - if (server == null || TextUtils.isEmpty(server)) { - toast(R.string.toast_none_data) - return + 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) } - if (mainViewModel.appendCustomConfigServer(server)) { - mainViewModel.reloadServerList() - toast(R.string.toast_success) - } else { - toast(R.string.toast_failure) - } - //adapter.notifyItemInserted(mainViewModel.serverList.lastIndex) - } catch (e: Exception) { - ToastCompat.makeText(this, "${getString(R.string.toast_malformed_josn)} ${e.cause?.message}", Toast.LENGTH_LONG).show() - e.printStackTrace() - return + } else { + requestPermissionLauncher.launch(permission) } } @@ -706,35 +674,22 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList 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.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.settings -> { - startActivity( - Intent(this, SettingsActivity::class.java) - .putExtra("isRunning", mainViewModel.isRunning.value == true) - ) - } - - R.id.routing_setting -> { - requestSubSettingActivity.launch(Intent(this, RoutingSettingActivity::class.java)) - } - - - R.id.promotion -> { - Utils.openUri(this, "${Utils.decode(AppConfig.PromotionUrl)}?t=${System.currentTimeMillis()}") - } - - R.id.logcat -> { - startActivity(Intent(this, LogcatActivity::class.java)) - } - - R.id.about -> { - startActivity(Intent(this, AboutActivity::class.java)) - } + 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 } -} +} \ No newline at end of file diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/MainRecyclerAdapter.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/MainRecyclerAdapter.kt new file mode 100644 index 00000000..e7ea6211 --- /dev/null +++ b/V2rayNG/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.service.V2RayServiceManager +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +class MainRecyclerAdapter(val activity: MainActivity) : RecyclerView.Adapter(), 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 by lazy { + mActivity.resources.getStringArray(R.array.share_method) + } + private val share_method_more: Array 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, 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) { + } +} \ No newline at end of file diff --git a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/ui/PerAppProxyActivity.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/PerAppProxyActivity.kt similarity index 53% rename from V2rayNG/app/src/main/kotlin/com/v2ray/ang/ui/PerAppProxyActivity.kt rename to V2rayNG/app/src/main/java/com/v2ray/ang/ui/PerAppProxyActivity.kt index ecca716c..b0df22e5 100644 --- a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/ui/PerAppProxyActivity.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/PerAppProxyActivity.kt @@ -1,35 +1,35 @@ 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.view.View +import android.widget.Toast import androidx.appcompat.widget.SearchView import androidx.lifecycle.lifecycleScope -import androidx.recyclerview.widget.DividerItemDecoration -import androidx.recyclerview.widget.LinearLayoutManager 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.MmkvManager.settingsStorage +import com.v2ray.ang.util.HttpUtil import com.v2ray.ang.util.Utils -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers -import io.reactivex.rxjava3.schedulers.Schedulers +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 val binding by lazy { ActivityBypassListBinding.inflate(layoutInflater) } private var adapter: PerAppProxyAdapter? = null private var appsAll: List? = null @@ -38,144 +38,63 @@ class PerAppProxyActivity : BaseActivity() { super.onCreate(savedInstanceState) setContentView(binding.root) - val dividerItemDecoration = DividerItemDecoration(this, LinearLayoutManager.VERTICAL) - binding.recyclerView.addItemDecoration(dividerItemDecoration) + title = getString(R.string.per_app_proxy_settings) - val blacklist = settingsStorage?.decodeStringSet(AppConfig.PREF_PER_APP_PROXY_SET) + addCustomDividerToRecyclerView(binding.recyclerView, this, R.drawable.custom_divider) - AppManagerUtil.rxLoadNetworkAppList(this) - .subscribeOn(Schedulers.io()) - .map { - if (blacklist != null) { - it.forEach { one -> - if (blacklist.contains(one.packageName)) { - one.isSelected = 1 - } else { - one.isSelected = 0 + 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 } - } - val comparator = Comparator { p1, p2 -> - when { - p1.isSelected > p2.isSelected -> -1 - p1.isSelected == p2.isSelected -> 0 - else -> 1 + appsList.sortedWith { p1, p2 -> + when { + p1.isSelected > p2.isSelected -> -1 + p1.isSelected == p2.isSelected -> 0 + else -> 1 + } } - } - it.sortedWith(comparator) - } else { - val comparator = object : Comparator { + } else { val collator = Collator.getInstance() - override fun compare(o1: AppInfo, o2: AppInfo) = collator.compare(o1.appName, o2.appName) + appsList.sortedWith(compareBy(collator) { it.appName }) } - it.sortedWith(comparator) } - } -// .map { -// val comparator = object : Comparator { -// val collator = Collator.getInstance() -// override fun compare(o1: AppInfo, o2: AppInfo) = collator.compare(o1.appName, o2.appName) -// } -// it.sortedWith(comparator) -// } - .observeOn(AndroidSchedulers.mainThread()) - .subscribe { - appsAll = it - adapter = PerAppProxyAdapter(this, it, blacklist) + + appsAll = apps + adapter = PerAppProxyAdapter(this@PerAppProxyActivity, apps, blacklist) binding.recyclerView.adapter = adapter - binding.pbWaiting.visibility = View.GONE + binding.pbWaiting.hide() + } catch (e: Exception) { + binding.pbWaiting.hide() + Log.e(ANG_PACKAGE, "Error loading apps", e) } - /*** - recycler_view.addOnScrollListener(object : RecyclerView.OnScrollListener() { - var dst = 0 - val threshold = resources.getDimensionPixelSize(R.dimen.bypass_list_header_height) * 2 - override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { - dst += dy - if (dst > threshold) { - header_view.hide() - dst = 0 - } else if (dst < -20) { - header_view.show() - dst = 0 } - } - - var hiding = false - fun View.hide() { - val target = -height.toFloat() - if (hiding || translationY == target) return - animate() - .translationY(target) - .setInterpolator(AccelerateInterpolator(2F)) - .setListener(object : AnimatorListenerAdapter() { - override fun onAnimationEnd(animation: Animator?) { - hiding = false - } - }) - hiding = true - } - - var showing = false - fun View.show() { - val target = 0f - if (showing || translationY == target) return - animate() - .translationY(target) - .setInterpolator(DecelerateInterpolator(2F)) - .setListener(object : AnimatorListenerAdapter() { - override fun onAnimationEnd(animation: Animator?) { - showing = false - } - }) - showing = true - } - }) - ***/ binding.switchPerAppProxy.setOnCheckedChangeListener { _, isChecked -> - settingsStorage.encode(AppConfig.PREF_PER_APP_PROXY, isChecked) + MmkvManager.encodeSettings(AppConfig.PREF_PER_APP_PROXY, isChecked) } - binding.switchPerAppProxy.isChecked = settingsStorage.getBoolean(AppConfig.PREF_PER_APP_PROXY, false) + binding.switchPerAppProxy.isChecked = MmkvManager.decodeSettingsBool(AppConfig.PREF_PER_APP_PROXY, false) binding.switchBypassApps.setOnCheckedChangeListener { _, isChecked -> - settingsStorage.encode(AppConfig.PREF_BYPASS_APPS, isChecked) + MmkvManager.encodeSettings(AppConfig.PREF_BYPASS_APPS, isChecked) } - binding.switchBypassApps.isChecked = settingsStorage.getBoolean(AppConfig.PREF_BYPASS_APPS, false) + binding.switchBypassApps.isChecked = MmkvManager.decodeSettingsBool(AppConfig.PREF_BYPASS_APPS, false) - /*** - et_search.setOnEditorActionListener { v, actionId, event -> - if (actionId == EditorInfo.IME_ACTION_SEARCH) { - //hide - var imm: InputMethodManager = v.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager - imm.toggleSoftInput(0, InputMethodManager.HIDE_NOT_ALWAYS) - - val key = v.text.toString().toUpperCase() - val apps = ArrayList() - if (TextUtils.isEmpty(key)) { - appsAll?.forEach { - apps.add(it) + binding.layoutSwitchBypassAppsTips.setOnClickListener { + Toasty.info(this, R.string.summary_pref_per_app_proxy, Toast.LENGTH_LONG, true).show() } - } else { - appsAll?.forEach { - if (it.appName.toUpperCase().indexOf(key) >= 0) { - apps.add(it) - } - } - } - adapter = PerAppProxyAdapter(this, apps, adapter?.blacklist) - recycler_view.adapter = adapter - adapter?.notifyDataSetChanged() - true - } else { - false - } - } - ***/ } override fun onPause() { super.onPause() adapter?.let { - settingsStorage.encode(AppConfig.PREF_PER_APP_PROXY_SET, it.blacklist) + MmkvManager.encodeSettings(AppConfig.PREF_PER_APP_PROXY_SET, it.blacklist) } } @@ -199,8 +118,10 @@ class PerAppProxyActivity : BaseActivity() { return super.onCreateOptionsMenu(menu) } + + @SuppressLint("NotifyDataSetChanged") override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) { - R.id.select_all -> adapter?.let { + R.id.select_all -> adapter?.let { it -> val pkgNames = it.apps.map { it.packageName } if (it.blacklist.containsAll(pkgNames)) { it.apps.forEach { @@ -215,7 +136,7 @@ class PerAppProxyActivity : BaseActivity() { } it.notifyDataSetChanged() true - } ?: false + } == true R.id.select_proxy_app -> { selectProxyApp() @@ -237,13 +158,20 @@ class PerAppProxyActivity : BaseActivity() { private fun selectProxyApp() { toast(R.string.msg_downloading_content) - val url = AppConfig.androidpackagenamelistUrl + binding.pbWaiting.show() + + val url = AppConfig.ANDROID_PACKAGE_NAME_LIST_URL lifecycleScope.launch(Dispatchers.IO) { - val content = Utils.getUrlContext(url, 5000) + var content = HttpUtil.getUrlContent(url, 5000) + if (content.isNullOrEmpty()) { + val httpPort = SettingsManager.getHttpPort() + content = HttpUtil.getUrlContent(url, 5000, httpPort) ?: "" + } launch(Dispatchers.Main) { - Log.d(ANG_PACKAGE, content) + Log.i(AppConfig.TAG, content) selectProxyApp(content, true) - toast(R.string.toast_success) + toastSuccess(R.string.toast_success) + binding.pbWaiting.hide() } } } @@ -252,7 +180,7 @@ class PerAppProxyActivity : BaseActivity() { val content = Utils.getClipboard(applicationContext) if (TextUtils.isEmpty(content)) return selectProxyApp(content, false) - toast(R.string.toast_success) + toastSuccess(R.string.toast_success) } private fun exportProxyApp() { @@ -262,9 +190,10 @@ class PerAppProxyActivity : BaseActivity() { lst = lst + System.getProperty("line.separator") + it } Utils.setClipboard(applicationContext, lst) - toast(R.string.toast_success) + toastSuccess(R.string.toast_success) } + @SuppressLint("NotifyDataSetChanged") private fun selectProxyApp(content: String, force: Boolean): Boolean { try { val proxyApps = if (TextUtils.isEmpty(content)) { @@ -277,10 +206,10 @@ class PerAppProxyActivity : BaseActivity() { adapter?.blacklist?.clear() if (binding.switchBypassApps.isChecked) { - adapter?.let { + adapter?.let { it -> it.apps.forEach block@{ val packageName = it.packageName - Log.d(ANG_PACKAGE, packageName) + Log.i(AppConfig.TAG, packageName) if (!inProxyApps(proxyApps, packageName, force)) { adapter?.blacklist?.add(packageName) println(packageName) @@ -290,10 +219,10 @@ class PerAppProxyActivity : BaseActivity() { it.notifyDataSetChanged() } } else { - adapter?.let { + adapter?.let { it -> it.apps.forEach block@{ val packageName = it.packageName - Log.d(ANG_PACKAGE, packageName) + Log.i(AppConfig.TAG, packageName) if (inProxyApps(proxyApps, packageName, force)) { adapter?.blacklist?.add(packageName) println(packageName) @@ -304,7 +233,7 @@ class PerAppProxyActivity : BaseActivity() { } } } catch (e: Exception) { - e.printStackTrace() + Log.e(AppConfig.TAG, "Error selecting proxy app", e) return false } return true @@ -339,7 +268,12 @@ class PerAppProxyActivity : BaseActivity() { adapter = PerAppProxyAdapter(this, apps, adapter?.blacklist) binding.recyclerView.adapter = adapter - adapter?.notifyDataSetChanged() + refreshData() return true } + + @SuppressLint("NotifyDataSetChanged") + fun refreshData() { + adapter?.notifyDataSetChanged() + } } diff --git a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/ui/PerAppProxyAdapter.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/PerAppProxyAdapter.kt similarity index 86% rename from V2rayNG/app/src/main/kotlin/com/v2ray/ang/ui/PerAppProxyAdapter.kt rename to V2rayNG/app/src/main/java/com/v2ray/ang/ui/PerAppProxyAdapter.kt index 7e5dcddd..1d02b3b1 100644 --- a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/ui/PerAppProxyAdapter.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/PerAppProxyAdapter.kt @@ -4,7 +4,6 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.recyclerview.widget.RecyclerView -import com.v2ray.ang.R import com.v2ray.ang.databinding.ItemRecyclerBypassListBinding import com.v2ray.ang.dto.AppInfo @@ -35,7 +34,7 @@ class PerAppProxyAdapter(val activity: BaseActivity, val apps: List, bl val view = View(ctx) view.layoutParams = ViewGroup.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, - ctx.resources.getDimensionPixelSize(R.dimen.bypass_list_header_height) * 0 + 0 ) BaseViewHolder(view) } @@ -59,22 +58,23 @@ class PerAppProxyAdapter(val activity: BaseActivity, val apps: List, bl fun bind(appInfo: AppInfo) { this.appInfo = appInfo + // Set app icon and name itemBypassBinding.icon.setImageDrawable(appInfo.appIcon) -// name.text = appInfo.appName - - itemBypassBinding.checkBox.isChecked = inBlacklist - itemBypassBinding.packageName.text = appInfo.packageName - if (appInfo.isSystemApp) { - itemBypassBinding.name.text = String.format("** %1s", appInfo.appName) - //name.textColor = Color.RED + itemBypassBinding.name.text = if (appInfo.isSystemApp) { + String.format("** %s", appInfo.appName) } else { - itemBypassBinding.name.text = appInfo.appName - //name.textColor = Color.DKGRAY + 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) diff --git a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/ui/RoutingEditActivity.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/RoutingEditActivity.kt similarity index 74% rename from V2rayNG/app/src/main/kotlin/com/v2ray/ang/ui/RoutingEditActivity.kt rename to V2rayNG/app/src/main/java/com/v2ray/ang/ui/RoutingEditActivity.kt index f8a64e17..bb0ee572 100644 --- a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/ui/RoutingEditActivity.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/RoutingEditActivity.kt @@ -1,7 +1,6 @@ package com.v2ray.ang.ui import android.os.Bundle -import android.text.TextUtils import android.view.Menu import android.view.MenuItem import androidx.appcompat.app.AlertDialog @@ -10,7 +9,8 @@ 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.util.SettingsManager +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 @@ -38,7 +38,7 @@ class RoutingEditActivity : BaseActivity() { private fun bindingServer(rulesetItem: RulesetItem): Boolean { binding.etRemarks.text = Utils.getEditable(rulesetItem.remarks) - binding.chkLocked.isChecked = rulesetItem.looked ?: false + 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) @@ -59,26 +59,32 @@ class RoutingEditActivity : BaseActivity() { private fun saveServer(): Boolean { val rulesetItem = SettingsManager.getRoutingRuleset(position) ?: RulesetItem() - rulesetItem.remarks = binding.etRemarks.text.toString() - rulesetItem.looked = binding.chkLocked.isChecked - binding.etDomain.text.toString().let { rulesetItem.domain = if (it.isEmpty()) null else it.split(",").map { itt -> itt.trim() }.filter { itt -> itt.isNotEmpty() } } - binding.etIp.text.toString().let { rulesetItem.ip = if (it.isEmpty()) null else it.split(",").map { itt -> itt.trim() }.filter { itt -> itt.isNotEmpty() } } - binding.etProtocol.text.toString().let { rulesetItem.protocol = if (it.isEmpty()) null else it.split(",").map { itt -> itt.trim() }.filter { itt -> itt.isNotEmpty() } } - binding.etPort.text.toString().let { rulesetItem.port = it.ifEmpty { null } } - binding.etNetwork.text.toString().let { rulesetItem.network = it.ifEmpty { null } } - rulesetItem.outboundTag = outbound_tag[binding.spOutboundTag.selectedItemPosition] + 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 (TextUtils.isEmpty(rulesetItem.remarks)) { + if (rulesetItem.remarks.isNullOrEmpty()) { toast(R.string.sub_setting_remarks) return false } SettingsManager.saveRoutingRuleset(position, rulesetItem) - toast(R.string.toast_success) + toastSuccess(R.string.toast_success) finish() return true } + private fun deleteServer(): Boolean { if (position >= 0) { AlertDialog.Builder(this).setMessage(R.string.del_config_comfirm) @@ -90,7 +96,7 @@ class RoutingEditActivity : BaseActivity() { } } } - .setNegativeButton(android.R.string.no) { _, _ -> + .setNegativeButton(android.R.string.cancel) { _, _ -> // do nothing } .show() diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/RoutingSettingActivity.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/RoutingSettingActivity.kt new file mode 100644 index 00000000..e585cff8 --- /dev/null +++ b/V2rayNG/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 = mutableListOf() + private val adapter by lazy { RoutingSettingRecyclerAdapter(this) } + private var mItemTouchHelper: ItemTouchHelper? = null + private val routing_domain_strategy: Array by lazy { + resources.getStringArray(R.array.routing_domain_strategy) + } + private val preset_rulesets: Array 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() + } +} \ No newline at end of file diff --git a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/ui/RoutingSettingRecyclerAdapter.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/RoutingSettingRecyclerAdapter.kt similarity index 93% rename from V2rayNG/app/src/main/kotlin/com/v2ray/ang/ui/RoutingSettingRecyclerAdapter.kt rename to V2rayNG/app/src/main/java/com/v2ray/ang/ui/RoutingSettingRecyclerAdapter.kt index 41b1c50a..bd48ea59 100644 --- a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/ui/RoutingSettingRecyclerAdapter.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/RoutingSettingRecyclerAdapter.kt @@ -8,10 +8,9 @@ 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 -import com.v2ray.ang.ui.MainRecyclerAdapter.BaseViewHolder -import com.v2ray.ang.util.SettingsManager class RoutingSettingRecyclerAdapter(val activity: RoutingSettingActivity) : RecyclerView.Adapter(), ItemTouchHelperAdapter { @@ -26,7 +25,7 @@ class RoutingSettingRecyclerAdapter(val activity: RoutingSettingActivity) : Recy 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.looked ?: false + holder.itemRoutingSettingBinding.imgLocked.isVisible = ruleset.locked == true holder.itemView.setBackgroundColor(Color.TRANSPARENT) holder.itemRoutingSettingBinding.layoutEdit.setOnClickListener { @@ -37,7 +36,7 @@ class RoutingSettingRecyclerAdapter(val activity: RoutingSettingActivity) : Recy } holder.itemRoutingSettingBinding.chkEnable.setOnCheckedChangeListener { it, isChecked -> - if( !it.isPressed) return@setOnCheckedChangeListener + if (!it.isPressed) return@setOnCheckedChangeListener ruleset.enabled = isChecked SettingsManager.saveRoutingRuleset(position, ruleset) } diff --git a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/ui/ScScannerActivity.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/ScScannerActivity.kt similarity index 51% rename from V2rayNG/app/src/main/kotlin/com/v2ray/ang/ui/ScScannerActivity.kt rename to V2rayNG/app/src/main/java/com/v2ray/ang/ui/ScScannerActivity.kt index 17090b75..9a972f93 100644 --- a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/ui/ScScannerActivity.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/ScScannerActivity.kt @@ -4,42 +4,49 @@ import android.Manifest import android.content.Intent import android.os.Bundle import androidx.activity.result.contract.ActivityResultContracts -import com.tbruyelle.rxpermissions3.RxPermissions import com.v2ray.ang.R import com.v2ray.ang.extension.toast -import com.v2ray.ang.util.AngConfigManager +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() } - fun importQRcode(): Boolean { - RxPermissions(this) - .request(Manifest.permission.CAMERA) - .subscribe { - if (it) - scanQRCode.launch(Intent(this, ScannerActivity::class.java)) - else - toast(R.string.toast_permission_denied) - } - + private fun importQRcode(): Boolean { + requestCameraPermissionLauncher.launch(Manifest.permission.CAMERA) return true } private val scanQRCode = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { if (it.resultCode == RESULT_OK) { - val (count, countSub) = AngConfigManager.importBatchConfig(it.data?.getStringExtra("SCAN_RESULT"), "", false) + val scanResult = it.data?.getStringExtra("SCAN_RESULT").orEmpty() + val (count, countSub) = AngConfigManager.importBatchConfig(scanResult, "", false) + if (count + countSub > 0) { - toast(R.string.toast_success) + toastSuccess(R.string.toast_success) } else { - toast(R.string.toast_failure) + toastError(R.string.toast_failure) } + startActivity(Intent(this, MainActivity::class.java)) } finish() } -} +} \ No newline at end of file diff --git a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/ui/ScSwitchActivity.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/ScSwitchActivity.kt similarity index 69% rename from V2rayNG/app/src/main/kotlin/com/v2ray/ang/ui/ScSwitchActivity.kt rename to V2rayNG/app/src/main/java/com/v2ray/ang/ui/ScSwitchActivity.kt index 7f536971..0495318a 100644 --- a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/ui/ScSwitchActivity.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/ScSwitchActivity.kt @@ -3,7 +3,6 @@ package com.v2ray.ang.ui import android.os.Bundle import com.v2ray.ang.R import com.v2ray.ang.service.V2RayServiceManager -import com.v2ray.ang.util.Utils class ScSwitchActivity : BaseActivity() { override fun onCreate(savedInstanceState: Bundle?) { @@ -12,10 +11,10 @@ class ScSwitchActivity : BaseActivity() { setContentView(R.layout.activity_none) - if (V2RayServiceManager.v2rayPoint.isRunning) { - Utils.stopVService(this) + if (V2RayServiceManager.isRunning()) { + V2RayServiceManager.stopVService(this) } else { - Utils.startVServiceFromToggle(this) + V2RayServiceManager.startVServiceFromToggle(this) } finish() } diff --git a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/ui/ScannerActivity.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/ScannerActivity.kt similarity index 71% rename from V2rayNG/app/src/main/kotlin/com/v2ray/ang/ui/ScannerActivity.kt rename to V2rayNG/app/src/main/java/com/v2ray/ang/ui/ScannerActivity.kt index e0053b4b..2a971ac0 100644 --- a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/ui/ScannerActivity.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/ScannerActivity.kt @@ -2,17 +2,19 @@ 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 com.tbruyelle.rxpermissions3.RxPermissions +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.util.MmkvManager.settingsStorage +import com.v2ray.ang.handler.MmkvManager import com.v2ray.ang.util.QRCodeDecoder import io.github.g00fy2.quickie.QRResult import io.github.g00fy2.quickie.ScanCustomCode @@ -20,12 +22,44 @@ 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 (settingsStorage?.decodeBool(AppConfig.PREF_START_SCAN_IMMEDIATE) == true) { + if (MmkvManager.decodeSettingsBool(AppConfig.PREF_START_SCAN_IMMEDIATE) == true) { launchScan() } } @@ -72,21 +106,16 @@ class ScannerActivity : BaseActivity() { } else { Manifest.permission.READ_EXTERNAL_STORAGE } - RxPermissions(this) - .request(permission) - .subscribe { - if (it) { - try { - showFileChooser() - } catch (e: Exception) { - e.printStackTrace() - } - } else - toast(R.string.toast_permission_denied) - } + + if (ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED) { + showFileChooser() + } else { + requestPermissionLauncher.launch(permission) + } true } + else -> super.onOptionsItemSelected(item) } @@ -102,18 +131,4 @@ class ScannerActivity : BaseActivity() { toast(R.string.toast_require_file_manager) } } - - private val chooseFile = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { - val uri = it.data?.data - if (it.resultCode == RESULT_OK && uri != null) { - try { - val bitmap = BitmapFactory.decodeStream(contentResolver.openInputStream(uri)) - val text = QRCodeDecoder.syncDecodeQRCode(bitmap) - finished(text.orEmpty()) - } catch (e: Exception) { - e.printStackTrace() - toast(e.message.toString()) - } - } - } -} +} \ No newline at end of file diff --git a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/ui/ServerActivity.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/ServerActivity.kt similarity index 51% rename from V2rayNG/app/src/main/kotlin/com/v2ray/ang/ui/ServerActivity.kt rename to V2rayNG/app/src/main/java/com/v2ray/ang/ui/ServerActivity.kt index bc1ea3af..e9bdad66 100644 --- a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/ui/ServerActivity.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/ServerActivity.kt @@ -13,22 +13,22 @@ 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_ADDRESS_V6 import com.v2ray.ang.AppConfig.WIREGUARD_LOCAL_MTU import com.v2ray.ang.R import com.v2ray.ang.dto.EConfigType -import com.v2ray.ang.dto.ServerConfig -import com.v2ray.ang.dto.V2rayConfig -import com.v2ray.ang.dto.V2rayConfig.Companion.DEFAULT_PORT -import com.v2ray.ang.dto.V2rayConfig.Companion.TLS -import com.v2ray.ang.extension.removeWhiteSpace +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.util.MmkvManager -import com.v2ray.ang.util.MmkvManager.settingsStorage +import com.v2ray.ang.extension.toastSuccess +import com.v2ray.ang.handler.MmkvManager +import com.v2ray.ang.util.JsonUtil import com.v2ray.ang.util.Utils -import com.v2ray.ang.util.Utils.getIpv6Address class ServerActivity : BaseActivity() { @@ -79,6 +79,10 @@ class ServerActivity : BaseActivity() { private val alpns: Array by lazy { resources.getStringArray(R.array.streamsecurity_alpn) } + private val xhttpMode: Array 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 @@ -87,7 +91,6 @@ class ServerActivity : BaseActivity() { 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_alterId: EditText? by lazy { findViewById(R.id.et_alterId) } 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) } @@ -108,17 +111,23 @@ class ServerActivity : BaseActivity() { 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_reserved1: EditText? by lazy { findViewById(R.id.et_reserved1) } - private val et_reserved2: EditText? by lazy { findViewById(R.id.et_reserved2) } - private val et_reserved3: EditText? by lazy { findViewById(R.id.et_reserved3) } 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?) { @@ -142,7 +151,7 @@ class ServerActivity : BaseActivity() { parent: AdapterView<*>?, view: View?, position: Int, - id: Long + id: Long, ) { val types = transportTypes(networks[position]) sp_header_type?.isEnabled = types.size > 1 @@ -150,25 +159,49 @@ class ServerActivity : BaseActivity() { 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 = if (networks[position] == "grpc") - getString(R.string.server_lab_mode_type) else - getString(R.string.server_lab_head_type) - config?.getProxyOutbound()?.getTransportSettingDetails()?.let { transportDetails -> - sp_header_type?.setSelection(Utils.arrayFind(types, transportDetails[0])) - et_request_host?.text = Utils.getEditable(transportDetails[1]) - et_path?.text = Utils.getEditable(transportDetails[2]) - } + 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]) { - "tcp" -> R.string.server_lab_request_host_http - "ws" -> R.string.server_lab_request_host_ws - "httpupgrade" -> R.string.server_lab_request_host_httpupgrade - "splithttp" -> R.string.server_lab_request_host_splithttp - "h2" -> R.string.server_lab_request_host_h2 - "quic" -> R.string.server_lab_request_host_quic - "grpc" -> R.string.server_lab_request_host_grpc + 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 } ) @@ -177,17 +210,29 @@ class ServerActivity : BaseActivity() { tv_path?.text = Utils.getEditable( getString( when (networks[position]) { - "kcp" -> R.string.server_lab_path_kcp - "ws" -> R.string.server_lab_path_ws - "httpupgrade" -> R.string.server_lab_path_httpupgrade - "splithttp" -> R.string.server_lab_path_splithttp - "h2" -> R.string.server_lab_path_h2 - "quic" -> R.string.server_lab_path_quic - "grpc" -> R.string.server_lab_path_grpc + 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<*>?) { @@ -199,31 +244,48 @@ class ServerActivity : BaseActivity() { parent: AdapterView<*>?, view: View?, position: Int, - id: Long + id: Long, ) { - if (streamSecuritys[position].isBlank()) { - container_sni?.visibility = View.GONE - container_fingerprint?.visibility = View.GONE - container_alpn?.visibility = View.GONE - container_allow_insecure?.visibility = View.GONE - container_public_key?.visibility = View.GONE - container_short_id?.visibility = View.GONE - container_spider_x?.visibility = View.GONE - } else { - container_sni?.visibility = View.VISIBLE - container_fingerprint?.visibility = View.VISIBLE - container_alpn?.visibility = View.VISIBLE - if (streamSecuritys[position] == TLS) { + 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 + ).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 - container_public_key?.visibility = View.GONE - container_short_id?.visibility = View.GONE - container_spider_x?.visibility = View.GONE - } else { - container_allow_insecure?.visibility = View.GONE + listOf( + container_public_key, + container_short_id, + container_spider_x + ).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_public_key?.visibility = View.VISIBLE - container_short_id?.visibility = View.VISIBLE - container_spider_x?.visibility = View.VISIBLE + container_allow_insecure?.visibility = View.GONE + listOf( + container_public_key, + container_short_id, + container_spider_x + ).forEach { it?.visibility = View.VISIBLE } } } } @@ -242,120 +304,92 @@ class ServerActivity : BaseActivity() { /** * binding selected server config */ - private fun bindingServer(config: ServerConfig): Boolean { - val outbound = config.getProxyOutbound() ?: return false + private fun bindingServer(config: ProfileItem): Boolean { et_remarks.text = Utils.getEditable(config.remarks) - et_address.text = Utils.getEditable(outbound.getServerAddress().orEmpty()) - et_port.text = - Utils.getEditable(outbound.getServerPort()?.toString() ?: DEFAULT_PORT.toString()) - et_id.text = Utils.getEditable(outbound.getPassword().orEmpty()) - et_alterId?.text = - Utils.getEditable(outbound.settings?.vnext?.get(0)?.users?.get(0)?.alterId.toString()) - if (config.configType == EConfigType.SOCKS - || config.configType == EConfigType.HTTP - ) { - et_security?.text = - Utils.getEditable(outbound.settings?.servers?.get(0)?.users?.get(0)?.user.orEmpty()) + 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(outbound.getSecurityEncryption().orEmpty()) - val flow = Utils.arrayFind( - flows, - outbound.settings?.vnext?.get(0)?.users?.get(0)?.flow.orEmpty() - ) + 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_public_key?.text = - Utils.getEditable(outbound.settings?.peers?.get(0)?.publicKey.orEmpty()) - if (outbound.settings?.reserved == null) { - et_reserved1?.text = Utils.getEditable("0") - et_reserved2?.text = Utils.getEditable("0") - et_reserved3?.text = Utils.getEditable("0") - } else { - et_reserved1?.text = - Utils.getEditable(outbound.settings?.reserved?.get(0).toString()) - et_reserved2?.text = - Utils.getEditable(outbound.settings?.reserved?.get(1).toString()) - et_reserved3?.text = - Utils.getEditable(outbound.settings?.reserved?.get(2).toString()) - } - if (outbound.settings?.address == null) { - et_local_address?.text = - Utils.getEditable("${WIREGUARD_LOCAL_ADDRESS_V4},${WIREGUARD_LOCAL_ADDRESS_V6}") - } else { - val list = outbound.settings?.address as List<*> - et_local_address?.text = Utils.getEditable(list.joinToString(",")) - } - if (outbound.settings?.mtu == null) { - et_local_mtu?.text = Utils.getEditable(WIREGUARD_LOCAL_MTU) - } else { - et_local_mtu?.text = Utils.getEditable(outbound.settings?.mtu.toString()) - } + 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(outbound.settings?.obfsPassword) + 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, outbound.getSecurityEncryption().orEmpty()) + val security = Utils.arrayFind(securityEncryptions, config.method.orEmpty()) if (security >= 0) { sp_security?.setSelection(security) } - val streamSetting = config.outboundBean?.streamSettings ?: return true - val streamSecurity = Utils.arrayFind(streamSecuritys, streamSetting.security) + val streamSecurity = Utils.arrayFind(streamSecuritys, config.security.orEmpty()) if (streamSecurity >= 0) { sp_stream_security?.setSelection(streamSecurity) - (streamSetting.tlsSettings ?: streamSetting.realitySettings)?.let { tlsSetting -> - container_sni?.visibility = View.VISIBLE - container_fingerprint?.visibility = View.VISIBLE - container_alpn?.visibility = View.VISIBLE - et_sni?.text = Utils.getEditable(tlsSetting.serverName) - tlsSetting.fingerprint?.let { - val utlsIndex = Utils.arrayFind(uTlsItems, tlsSetting.fingerprint) - sp_stream_fingerprint?.setSelection(utlsIndex) - } - tlsSetting.alpn?.let { - val alpnIndex = Utils.arrayFind( - alpns, - Utils.removeWhiteSpace(tlsSetting.alpn.joinToString(",")).orEmpty() - ) - sp_stream_alpn?.setSelection(alpnIndex) - } - if (streamSetting.tlsSettings != null) { - container_allow_insecure?.visibility = View.VISIBLE - val allowinsecure = - Utils.arrayFind(allowinsecures, tlsSetting.allowInsecure.toString()) - if (allowinsecure >= 0) { - sp_allow_insecure?.setSelection(allowinsecure) - } - container_public_key?.visibility = View.GONE - container_short_id?.visibility = View.GONE - container_spider_x?.visibility = View.GONE - } else { // reality settings - container_public_key?.visibility = View.VISIBLE - et_public_key?.text = Utils.getEditable(tlsSetting.publicKey.orEmpty()) - container_short_id?.visibility = View.VISIBLE - et_short_id?.text = Utils.getEditable(tlsSetting.shortId.orEmpty()) - container_spider_x?.visibility = View.VISIBLE - et_spider_x?.text = Utils.getEditable(tlsSetting.spiderX.orEmpty()) - container_allow_insecure?.visibility = View.GONE - } + 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) } } - if (streamSetting.tlsSettings == null && streamSetting.realitySettings == null) { - container_sni?.visibility = View.GONE - container_fingerprint?.visibility = View.GONE - container_alpn?.visibility = View.GONE - container_allow_insecure?.visibility = View.GONE + 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) + } container_public_key?.visibility = View.GONE container_short_id?.visibility = View.GONE container_spider_x?.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_allow_insecure?.visibility = View.GONE } } - val network = Utils.arrayFind(networks, streamSetting.network) + + if (config.security.isNullOrEmpty()) { + container_sni?.visibility = View.GONE + container_fingerprint?.visibility = View.GONE + container_alpn?.visibility = View.GONE + container_allow_insecure?.visibility = View.GONE + container_public_key?.visibility = View.GONE + container_short_id?.visibility = View.GONE + container_spider_x?.visibility = View.GONE + } + val network = Utils.arrayFind(networks, config.network.orEmpty()) if (network >= 0) { sp_network?.setSelection(network) } @@ -370,7 +404,6 @@ class ServerActivity : BaseActivity() { et_address.text = null et_port.text = Utils.getEditable(DEFAULT_PORT.toString()) et_id.text = null - et_alterId?.text = Utils.getEditable("0") sp_security?.setSelection(0) sp_network?.setSelection(0) @@ -384,11 +417,9 @@ class ServerActivity : BaseActivity() { //et_security.text = null sp_flow?.setSelection(0) et_public_key?.text = null - et_reserved1?.text = Utils.getEditable("0") - et_reserved2?.text = Utils.getEditable("0") - et_reserved3?.text = Utils.getEditable("0") + et_reserved1?.text = Utils.getEditable("0,0,0") et_local_address?.text = - Utils.getEditable("${WIREGUARD_LOCAL_ADDRESS_V4},${WIREGUARD_LOCAL_ADDRESS_V6}") + Utils.getEditable(WIREGUARD_LOCAL_ADDRESS_V4) et_local_mtu?.text = Utils.getEditable(WIREGUARD_LOCAL_MTU) return true } @@ -405,13 +436,14 @@ class ServerActivity : BaseActivity() { toast(R.string.server_lab_address) return false } - val port = Utils.parseInt(et_port.text.toString()) - if (port <= 0) { - toast(R.string.server_lab_port) - 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) ?: ServerConfig.create(createConfigType) + MmkvManager.decodeServerConfig(editGuid) ?: ProfileItem.create(createConfigType) if (config.configType != EConfigType.SOCKS && config.configType != EConfigType.HTTP && TextUtils.isEmpty(et_id.text.toString()) @@ -432,125 +464,84 @@ class ServerActivity : BaseActivity() { return false } } - et_alterId?.let { - val alterId = Utils.parseInt(it.text.toString()) - if (alterId < 0) { - toast(R.string.server_lab_alterid) + if (et_extra?.text?.toString().isNotNullEmpty()) { + if (JsonUtil.parseString(et_extra?.text?.toString()) == null) { + toast(R.string.server_lab_xhttp_extra) return false } } - config.remarks = et_remarks.text.toString().trim() - config.outboundBean?.settings?.vnext?.get(0)?.let { vnext -> - saveVnext(vnext, port, config) - } - config.outboundBean?.settings?.servers?.get(0)?.let { server -> - saveServers(server, port, config) - } - val wireguard = config.outboundBean?.settings - wireguard?.peers?.get(0)?.let { _ -> - savePeer(wireguard, port) - } + saveCommon(config) + saveStreamSettings(config) + saveTls(config) - config.outboundBean?.streamSettings?.let { - val sni = saveStreamSettings(it) - saveTls(it, sni) - } if (config.subscriptionId.isEmpty() && !subscriptionId.isNullOrEmpty()) { config.subscriptionId = subscriptionId.orEmpty() } - if (config.configType == EConfigType.HYSTERIA2) { - config.outboundBean?.settings?.obfsPassword = et_obfs_password?.text?.toString() - } - + //Log.i(AppConfig.TAG, JsonUtil.toJsonPretty(config) ?: "") MmkvManager.encodeServerConfig(editGuid, config) - toast(R.string.toast_success) + toastSuccess(R.string.toast_success) finish() return true } - private fun saveVnext( - vnext: V2rayConfig.OutboundBean.OutSettingsBean.VnextBean, - port: Int, - config: ServerConfig - ) { - vnext.address = et_address.text.toString().trim() - vnext.port = port - vnext.users[0].id = et_id.text.toString().trim() + 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) { - vnext.users[0].alterId = Utils.parseInt(et_alterId?.text.toString()) - vnext.users[0].security = securitys[sp_security?.selectedItemPosition ?: 0] + config.method = securitys[sp_security?.selectedItemPosition ?: 0] } else if (config.configType == EConfigType.VLESS) { - vnext.users[0].encryption = et_security?.text.toString().trim() - vnext.users[0].flow = flows[sp_flow?.selectedItemPosition ?: 0] - } - } - - private fun saveServers( - server: V2rayConfig.OutboundBean.OutSettingsBean.ServersBean, - port: Int, - config: ServerConfig - ) { - server.address = et_address.text.toString().trim() - server.port = port - if (config.configType == EConfigType.SHADOWSOCKS) { - server.password = et_id.text.toString().trim() - server.method = shadowsocksSecuritys[sp_security?.selectedItemPosition ?: 0] + 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)) { - server.users = null - } else { - val socksUsersBean = - V2rayConfig.OutboundBean.OutSettingsBean.ServersBean.SocksUsersBean() - socksUsersBean.user = et_security?.text.toString().trim() - socksUsersBean.pass = et_id.text.toString().trim() - server.users = listOf(socksUsersBean) + if (!TextUtils.isEmpty(et_security?.text) || !TextUtils.isEmpty(et_id.text)) { + config.username = et_security?.text.toString().trim() } - } else if (config.configType == EConfigType.TROJAN || config.configType == EConfigType.HYSTERIA2) { - server.password = et_id.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 savePeer(wireguard: V2rayConfig.OutboundBean.OutSettingsBean, port: Int) { - wireguard.secretKey = et_id.text.toString().trim() - wireguard.peers?.get(0)?.publicKey = et_public_key?.text.toString().trim() - wireguard.peers?.get(0)?.endpoint = - getIpv6Address(et_address.text.toString().trim()) + ":" + port - val reserved1 = Utils.parseInt(et_reserved1?.text.toString()) - val reserved2 = Utils.parseInt(et_reserved2?.text.toString()) - val reserved3 = Utils.parseInt(et_reserved3?.text.toString()) - if (reserved1 > 0 || reserved2 > 0 || reserved3 > 0) { - wireguard.reserved = listOf(reserved1, reserved2, reserved3) - } else { - wireguard.reserved = null - } - wireguard.address = et_local_address?.text.toString().removeWhiteSpace().split(",") - wireguard.mtu = Utils.parseInt(et_local_mtu?.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 saveStreamSettings(streamSetting: V2rayConfig.OutboundBean.StreamSettingsBean): String? { - val network = sp_network?.selectedItemPosition ?: return null - val type = sp_header_type?.selectedItemPosition ?: return null - val requestHost = et_request_host?.text?.toString()?.trim() ?: return null - val path = et_path?.text?.toString()?.trim() ?: return null - - val sni = streamSetting.populateTransportSettings( - transport = networks[network], - headerType = transportTypes(networks[network])[type], - host = requestHost, - path = path, - seed = path, - quicSecurity = requestHost, - key = path, - mode = transportTypes(networks[network])[type], - serviceName = path, - authority = requestHost, - ) - - return sni - } - - private fun saveTls(streamSetting: V2rayConfig.OutboundBean.StreamSettingsBean, sni: String?) { + 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 @@ -560,38 +551,41 @@ class ServerActivity : BaseActivity() { val shortId = et_short_id?.text?.toString() val spiderX = et_spider_x?.text?.toString() - val allowInsecure = if (allowInsecureField == null || allowinsecures[allowInsecureField].isBlank()) { - settingsStorage?.decodeBool(PREF_ALLOW_INSECURE) ?: false - } else { - allowinsecures[allowInsecureField].toBoolean() - } + val allowInsecure = + if (allowInsecureField == null || allowinsecures[allowInsecureField].isBlank()) { + MmkvManager.decodeSettingsBool(PREF_ALLOW_INSECURE) + } else { + allowinsecures[allowInsecureField].toBoolean() + } - streamSetting.populateTlsSettings( - streamSecurity = streamSecuritys[streamSecurity], - allowInsecure = allowInsecure, - sni = sniField ?: sni ?: "", - fingerprint = uTlsItems[utlsIndex], - alpns = alpns[alpnIndex], - publicKey = publicKey, - shortId = shortId, - spiderX = spiderX - ) + 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 } private fun transportTypes(network: String?): Array { return when (network) { - "tcp" -> { + NetworkType.TCP.type -> { tcpTypes } - "kcp", "quic" -> { + NetworkType.KCP.type -> { kcpAndQuicTypes } - "grpc" -> { + NetworkType.GRPC.type -> { grpcModes } + NetworkType.XHTTP.type -> { + xhttpMode + } + else -> { arrayOf("---") } @@ -599,18 +593,18 @@ class ServerActivity : BaseActivity() { } /** - * save server config + * delete server config */ private fun deleteServer(): Boolean { if (editGuid.isNotEmpty()) { if (editGuid != MmkvManager.getSelectServer()) { - if (settingsStorage?.decodeBool(AppConfig.PREF_CONFIRM_REMOVE) == true) { + 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.no) { _, _ -> + .setNegativeButton(android.R.string.cancel) { _, _ -> // do nothing } .show() diff --git a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/ui/ServerCustomConfigActivity.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/ServerCustomConfigActivity.kt similarity index 75% rename from V2rayNG/app/src/main/kotlin/com/v2ray/ang/ui/ServerCustomConfigActivity.kt rename to V2rayNG/app/src/main/java/com/v2ray/ang/ui/ServerCustomConfigActivity.kt index d6891270..44e0b32b 100644 --- a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/ui/ServerCustomConfigActivity.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/ServerCustomConfigActivity.kt @@ -2,23 +2,22 @@ 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 android.widget.Toast 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.ServerConfig -import com.v2ray.ang.dto.V2rayConfig +import com.v2ray.ang.dto.ProfileItem import com.v2ray.ang.extension.toast -import com.v2ray.ang.util.JsonUtil -import com.v2ray.ang.util.MmkvManager +import com.v2ray.ang.extension.toastSuccess +import com.v2ray.ang.fmt.CustomFmt +import com.v2ray.ang.handler.MmkvManager import com.v2ray.ang.util.Utils -import me.drakeet.support.toast.ToastCompat class ServerCustomConfigActivity : BaseActivity() { private val binding by lazy { ActivityServerCustomConfigBinding.inflate(layoutInflater) } @@ -48,16 +47,14 @@ class ServerCustomConfigActivity : BaseActivity() { } /** - * bingding seleced server config + * Binding selected server config */ - private fun bindingServer(config: ServerConfig): Boolean { + private fun bindingServer(config: ProfileItem): Boolean { binding.etRemarks.text = Utils.getEditable(config.remarks) val raw = MmkvManager.decodeServerRaw(editGuid) - if (raw.isNullOrBlank()) { - binding.editor.setTextContent(Utils.getEditable(config.fullConfig?.toPrettyPrinting().orEmpty())) - } else { - binding.editor.setTextContent(Utils.getEditable(raw)) - } + val configContent = raw.orEmpty() + + binding.editor.setTextContent(Utils.getEditable(configContent)) return true } @@ -78,21 +75,24 @@ class ServerCustomConfigActivity : BaseActivity() { return false } - val v2rayConfig = try { - JsonUtil.fromJson(binding.editor.text.toString(), V2rayConfig::class.java) + val profileItem = try { + CustomFmt.parse(binding.editor.text.toString()) } catch (e: Exception) { - e.printStackTrace() - ToastCompat.makeText(this, "${getString(R.string.toast_malformed_josn)} ${e.cause?.message}", Toast.LENGTH_LONG).show() + 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) ?: ServerConfig.create(EConfigType.CUSTOM) - config.remarks = if (binding.etRemarks.text.isNullOrEmpty()) v2rayConfig.remarks.orEmpty() else binding.etRemarks.text.toString() - config.fullConfig = v2rayConfig + 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()) - toast(R.string.toast_success) + toastSuccess(R.string.toast_success) finish() return true } @@ -107,7 +107,7 @@ class ServerCustomConfigActivity : BaseActivity() { MmkvManager.removeServer(editGuid) finish() } - .setNegativeButton(android.R.string.no) { _, _ -> + .setNegativeButton(android.R.string.cancel) { _, _ -> // do nothing } .show() diff --git a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/ui/SettingsActivity.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/SettingsActivity.kt similarity index 72% rename from V2rayNG/app/src/main/kotlin/com/v2ray/ang/ui/SettingsActivity.kt rename to V2rayNG/app/src/main/java/com/v2ray/ang/ui/SettingsActivity.kt index 803b4130..6af64e3a 100644 --- a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/ui/SettingsActivity.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/SettingsActivity.kt @@ -17,8 +17,8 @@ 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.service.SubscriptionUpdater -import com.v2ray.ang.util.MmkvManager.settingsStorage import com.v2ray.ang.util.Utils import com.v2ray.ang.viewmodel.SettingsViewModel import java.util.concurrent.TimeUnit @@ -40,8 +40,11 @@ class SettingsActivity : BaseActivity() { private val perAppProxy by lazy { findPreference(AppConfig.PREF_PER_APP_PROXY) } private val localDns by lazy { findPreference(AppConfig.PREF_LOCAL_DNS_ENABLED) } private val fakeDns by lazy { findPreference(AppConfig.PREF_FAKE_DNS_ENABLED) } + private val appendHttpProxy by lazy { findPreference(AppConfig.PREF_APPEND_HTTP_PROXY) } private val localDnsPort by lazy { findPreference(AppConfig.PREF_LOCAL_DNS_PORT) } private val vpnDns by lazy { findPreference(AppConfig.PREF_VPN_DNS) } + private val vpnBypassLan by lazy { findPreference(AppConfig.PREF_VPN_BYPASS_LAN) } + private val vpnInterfaceAddress by lazy { findPreference(AppConfig.PREF_VPN_INTERFACE_ADDRESS_CONFIG_INDEX) } private val mux by lazy { findPreference(AppConfig.PREF_MUX_ENABLED) } private val muxConcurrency by lazy { findPreference(AppConfig.PREF_MUX_CONCURRENCY) } @@ -57,9 +60,9 @@ class SettingsActivity : BaseActivity() { private val autoUpdateInterval by lazy { findPreference(AppConfig.SUBSCRIPTION_AUTO_UPDATE_INTERVAL) } private val socksPort by lazy { findPreference(AppConfig.PREF_SOCKS_PORT) } - private val httpPort by lazy { findPreference(AppConfig.PREF_HTTP_PORT) } private val remoteDns by lazy { findPreference(AppConfig.PREF_REMOTE_DNS) } private val domesticDns by lazy { findPreference(AppConfig.PREF_DOMESTIC_DNS) } + private val dnsHosts by lazy { findPreference(AppConfig.PREF_DNS_HOSTS) } private val delayTestUrl by lazy { findPreference(AppConfig.PREF_DELAY_TEST_URL) } private val mode by lazy { findPreference(AppConfig.PREF_MODE) } @@ -141,11 +144,7 @@ class SettingsActivity : BaseActivity() { socksPort?.summary = if (TextUtils.isEmpty(nval)) AppConfig.PORT_SOCKS else nval true } - httpPort?.setOnPreferenceChangeListener { _, any -> - val nval = any as String - httpPort?.summary = if (TextUtils.isEmpty(nval)) AppConfig.PORT_HTTP else nval - true - } + remoteDns?.setOnPreferenceChangeListener { _, any -> val nval = any as String remoteDns?.summary = if (nval == "") AppConfig.DNS_PROXY else nval @@ -156,9 +155,14 @@ class SettingsActivity : BaseActivity() { 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.DelayTestUrl else nval + delayTestUrl?.summary = if (nval == "") AppConfig.DELAY_TEST_URL else nval true } mode?.setOnPreferenceChangeListener { _, newValue -> @@ -172,33 +176,34 @@ class SettingsActivity : BaseActivity() { override fun onStart() { super.onStart() - updateMode(settingsStorage.decodeString(AppConfig.PREF_MODE, VPN)) - localDns?.isChecked = settingsStorage.getBoolean(AppConfig.PREF_LOCAL_DNS_ENABLED, false) - fakeDns?.isChecked = settingsStorage.getBoolean(AppConfig.PREF_FAKE_DNS_ENABLED, false) - localDnsPort?.summary = settingsStorage.decodeString(AppConfig.PREF_LOCAL_DNS_PORT, AppConfig.PORT_LOCAL_DNS) - vpnDns?.summary = settingsStorage.decodeString(AppConfig.PREF_VPN_DNS, AppConfig.DNS_VPN) + 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) - updateMux(settingsStorage.getBoolean(AppConfig.PREF_MUX_ENABLED, false)) - mux?.isChecked = settingsStorage.getBoolean(AppConfig.PREF_MUX_ENABLED, false) - muxConcurrency?.summary = settingsStorage.decodeString(AppConfig.PREF_MUX_CONCURRENCY, "8") - muxXudpConcurrency?.summary = settingsStorage.decodeString(AppConfig.PREF_MUX_XUDP_CONCURRENCY, "8") + 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(settingsStorage.getBoolean(AppConfig.PREF_FRAGMENT_ENABLED, false)) - fragment?.isChecked = settingsStorage.getBoolean(AppConfig.PREF_FRAGMENT_ENABLED, false) - fragmentPackets?.summary = settingsStorage.decodeString(AppConfig.PREF_FRAGMENT_PACKETS, "tlshello") - fragmentLength?.summary = settingsStorage.decodeString(AppConfig.PREF_FRAGMENT_LENGTH, "50-100") - fragmentInterval?.summary = settingsStorage.decodeString(AppConfig.PREF_FRAGMENT_INTERVAL, "10-20") + 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 = settingsStorage.getBoolean(AppConfig.SUBSCRIPTION_AUTO_UPDATE, false) + autoUpdateCheck?.isChecked = MmkvManager.decodeSettingsBool(AppConfig.SUBSCRIPTION_AUTO_UPDATE, false) autoUpdateInterval?.summary = - settingsStorage.decodeString(AppConfig.SUBSCRIPTION_AUTO_UPDATE_INTERVAL, AppConfig.SUBSCRIPTION_DEFAULT_UPDATE_INTERVAL) - autoUpdateInterval?.isEnabled = settingsStorage.getBoolean(AppConfig.SUBSCRIPTION_AUTO_UPDATE, false) + MmkvManager.decodeSettingsString(AppConfig.SUBSCRIPTION_AUTO_UPDATE_INTERVAL, AppConfig.SUBSCRIPTION_DEFAULT_UPDATE_INTERVAL) + autoUpdateInterval?.isEnabled = MmkvManager.decodeSettingsBool(AppConfig.SUBSCRIPTION_AUTO_UPDATE, false) - socksPort?.summary = settingsStorage.decodeString(AppConfig.PREF_SOCKS_PORT, AppConfig.PORT_SOCKS) - httpPort?.summary = settingsStorage.decodeString(AppConfig.PREF_HTTP_PORT, AppConfig.PORT_HTTP) - remoteDns?.summary = settingsStorage.decodeString(AppConfig.PREF_REMOTE_DNS, AppConfig.DNS_PROXY) - domesticDns?.summary = settingsStorage.decodeString(AppConfig.PREF_DOMESTIC_DNS, AppConfig.DNS_DIRECT) - delayTestUrl?.summary = settingsStorage.decodeString(AppConfig.PREF_DELAY_TEST_URL, AppConfig.DelayTestUrl) + 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) initSharedPreference() } @@ -213,7 +218,6 @@ class SettingsActivity : BaseActivity() { fragmentInterval, autoUpdateInterval, socksPort, - httpPort, remoteDns, domesticDns, delayTestUrl @@ -225,7 +229,7 @@ class SettingsActivity : BaseActivity() { AppConfig.PREF_SNIFFING_ENABLED, ).forEach { key -> findPreference(key)?.isChecked = - settingsStorage.decodeBool(key, true) + MmkvManager.decodeSettingsBool(key, true) } listOf( @@ -235,25 +239,29 @@ class SettingsActivity : BaseActivity() { 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 ).forEach { key -> findPreference(key)?.isChecked = - settingsStorage.decodeBool(key, false) + 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_MODE ).forEach { key -> - if (settingsStorage.decodeString(key) != null) { - findPreference(key)?.value = settingsStorage.decodeString(key) + if (MmkvManager.decodeSettingsString(key) != null) { + findPreference(key)?.value = MmkvManager.decodeSettingsString(key) } } } @@ -261,14 +269,17 @@ class SettingsActivity : BaseActivity() { private fun updateMode(mode: String?) { val vpn = mode == VPN perAppProxy?.isEnabled = vpn - perAppProxy?.isChecked = settingsStorage.getBoolean(AppConfig.PREF_PER_APP_PROXY, false) + 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 if (vpn) { updateLocalDns( - settingsStorage.getBoolean( + MmkvManager.decodeSettingsBool( AppConfig.PREF_LOCAL_DNS_ENABLED, false ) @@ -310,19 +321,17 @@ class SettingsActivity : BaseActivity() { muxXudpConcurrency?.isEnabled = enabled muxXudpQuic?.isEnabled = enabled if (enabled) { - updateMuxConcurrency(settingsStorage.decodeString(AppConfig.PREF_MUX_CONCURRENCY, "8")) - updateMuxXudpConcurrency(settingsStorage.decodeString(AppConfig.PREF_MUX_XUDP_CONCURRENCY, "8")) + updateMuxConcurrency(MmkvManager.decodeSettingsString(AppConfig.PREF_MUX_CONCURRENCY, "8")) + updateMuxXudpConcurrency(MmkvManager.decodeSettingsString(AppConfig.PREF_MUX_XUDP_CONCURRENCY, "8")) } } private fun updateMuxConcurrency(value: String?) { - if (value == null) { - } else { - val concurrency = value.toIntOrNull() ?: 8 - muxConcurrency?.summary = concurrency.toString() - } + val concurrency = value?.toIntOrNull() ?: 8 + muxConcurrency?.summary = concurrency.toString() } + private fun updateMuxXudpConcurrency(value: String?) { if (value == null) { muxXudpQuic?.isEnabled = true @@ -338,9 +347,9 @@ class SettingsActivity : BaseActivity() { fragmentLength?.isEnabled = enabled fragmentInterval?.isEnabled = enabled if (enabled) { - updateFragmentPackets(settingsStorage.decodeString(AppConfig.PREF_FRAGMENT_PACKETS, "tlshello")) - updateFragmentLength(settingsStorage.decodeString(AppConfig.PREF_FRAGMENT_LENGTH, "50-100")) - updateFragmentInterval(settingsStorage.decodeString(AppConfig.PREF_FRAGMENT_INTERVAL, "10-20")) + 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")) } } @@ -358,6 +367,6 @@ class SettingsActivity : BaseActivity() { } fun onModeHelpClicked(view: View) { - Utils.openUri(this, AppConfig.v2rayNGWikiMode) + Utils.openUri(this, AppConfig.APP_WIKI_MODE) } } diff --git a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/ui/SubEditActivity.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/SubEditActivity.kt similarity index 68% rename from V2rayNG/app/src/main/kotlin/com/v2ray/ang/ui/SubEditActivity.kt rename to V2rayNG/app/src/main/java/com/v2ray/ang/ui/SubEditActivity.kt index 86b27da5..f85382f1 100644 --- a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/ui/SubEditActivity.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/SubEditActivity.kt @@ -6,11 +6,13 @@ import android.view.Menu import android.view.MenuItem import androidx.appcompat.app.AlertDialog import androidx.lifecycle.lifecycleScope +import com.v2ray.ang.AppConfig import com.v2ray.ang.R import com.v2ray.ang.databinding.ActivitySubEditBinding import com.v2ray.ang.dto.SubscriptionItem import com.v2ray.ang.extension.toast -import com.v2ray.ang.util.MmkvManager +import com.v2ray.ang.extension.toastSuccess +import com.v2ray.ang.handler.MmkvManager import com.v2ray.ang.util.Utils import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -18,8 +20,8 @@ import kotlinx.coroutines.launch class SubEditActivity : BaseActivity() { private val binding by lazy { ActivitySubEditBinding.inflate(layoutInflater) } - var del_config: MenuItem? = null - var save_config: MenuItem? = null + private var del_config: MenuItem? = null + private var save_config: MenuItem? = null private val editSubId by lazy { intent.getStringExtra("subId").orEmpty() } @@ -37,7 +39,7 @@ class SubEditActivity : BaseActivity() { } /** - * bingding seleced server config + * binding selected server config */ private fun bindingServer(subItem: SubscriptionItem): Boolean { binding.etRemarks.text = Utils.getEditable(subItem.remarks) @@ -45,6 +47,7 @@ class SubEditActivity : BaseActivity() { binding.etFilter.text = Utils.getEditable(subItem.filter) binding.chkEnable.isChecked = subItem.enabled binding.autoUpdateCheck.isChecked = subItem.autoUpdate + binding.allowInsecureUrl.isChecked = subItem.allowInsecureUrl binding.etPreProfile.text = Utils.getEditable(subItem.prevProfile) binding.etNextProfile.text = Utils.getEditable(subItem.nextProfile) return true @@ -76,18 +79,28 @@ class SubEditActivity : BaseActivity() { subItem.autoUpdate = binding.autoUpdateCheck.isChecked subItem.prevProfile = binding.etPreProfile.text.toString() subItem.nextProfile = binding.etNextProfile.text.toString() + subItem.allowInsecureUrl = binding.allowInsecureUrl.isChecked if (TextUtils.isEmpty(subItem.remarks)) { toast(R.string.sub_setting_remarks) return false } -// if (TextUtils.isEmpty(subItem.url)) { -// toast(R.string.sub_setting_url) -// return false -// } + if (subItem.url.isNotEmpty()) { + if (!Utils.isValidUrl(subItem.url)) { + toast(R.string.toast_invalid_url) + return false + } + + if (!Utils.isValidSubUrl(subItem.url)) { + toast(R.string.toast_insecure_url_protocol) + if (!subItem.allowInsecureUrl) { + return false + } + } + } MmkvManager.encodeSubscription(editSubId, subItem) - toast(R.string.toast_success) + toastSuccess(R.string.toast_success) finish() return true } @@ -97,19 +110,28 @@ class SubEditActivity : BaseActivity() { */ private fun deleteServer(): Boolean { if (editSubId.isNotEmpty()) { - AlertDialog.Builder(this).setMessage(R.string.del_config_comfirm) - .setPositiveButton(android.R.string.ok) { _, _ -> - lifecycleScope.launch(Dispatchers.IO) { - MmkvManager.removeSubscription(editSubId) - launch(Dispatchers.Main) { - finish() + if (MmkvManager.decodeSettingsBool(AppConfig.PREF_CONFIRM_REMOVE) == true) { + AlertDialog.Builder(this).setMessage(R.string.del_config_comfirm) + .setPositiveButton(android.R.string.ok) { _, _ -> + lifecycleScope.launch(Dispatchers.IO) { + MmkvManager.removeSubscription(editSubId) + launch(Dispatchers.Main) { + finish() + } } } + .setNegativeButton(android.R.string.cancel) { _, _ -> + // do nothing + } + .show() + } else { + lifecycleScope.launch(Dispatchers.IO) { + MmkvManager.removeSubscription(editSubId) + launch(Dispatchers.Main) { + finish() + } } - .setNegativeButton(android.R.string.no) { _, _ -> - // do nothing - } - .show() + } } return true } diff --git a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/ui/SubSettingActivity.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/SubSettingActivity.kt similarity index 81% rename from V2rayNG/app/src/main/kotlin/com/v2ray/ang/ui/SubSettingActivity.kt rename to V2rayNG/app/src/main/java/com/v2ray/ang/ui/SubSettingActivity.kt index be637eb9..ef27fcff 100644 --- a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/ui/SubSettingActivity.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/SubSettingActivity.kt @@ -1,21 +1,21 @@ package com.v2ray.ang.ui +import android.annotation.SuppressLint import android.content.Intent import android.os.Bundle import android.view.Menu import android.view.MenuItem -import androidx.appcompat.app.AlertDialog import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.LinearLayoutManager import com.v2ray.ang.R import com.v2ray.ang.databinding.ActivitySubSettingBinding -import com.v2ray.ang.databinding.LayoutProgressBinding import com.v2ray.ang.dto.SubscriptionItem -import com.v2ray.ang.extension.toast +import com.v2ray.ang.extension.toastError +import com.v2ray.ang.extension.toastSuccess +import com.v2ray.ang.handler.AngConfigManager +import com.v2ray.ang.handler.MmkvManager import com.v2ray.ang.helper.SimpleItemTouchHelperCallback -import com.v2ray.ang.util.AngConfigManager -import com.v2ray.ang.util.MmkvManager import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -35,6 +35,7 @@ class SubSettingActivity : BaseActivity() { 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)) @@ -58,21 +59,18 @@ class SubSettingActivity : BaseActivity() { } R.id.sub_update -> { - val dialog = AlertDialog.Builder(this) - .setView(LayoutProgressBinding.inflate(layoutInflater).root) - .setCancelable(false) - .show() + binding.pbWaiting.show() lifecycleScope.launch(Dispatchers.IO) { val count = AngConfigManager.updateConfigViaSubAll() delay(500L) launch(Dispatchers.Main) { if (count > 0) { - toast(R.string.toast_success) + toastSuccess(R.string.toast_success) } else { - toast(R.string.toast_failure) + toastError(R.string.toast_failure) } - dialog.dismiss() + binding.pbWaiting.hide() } } @@ -83,6 +81,7 @@ class SubSettingActivity : BaseActivity() { } + @SuppressLint("NotifyDataSetChanged") fun refreshData() { subscriptions = MmkvManager.decodeSubscriptions() adapter.notifyDataSetChanged() diff --git a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/ui/SubSettingRecyclerAdapter.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/SubSettingRecyclerAdapter.kt similarity index 69% rename from V2rayNG/app/src/main/kotlin/com/v2ray/ang/ui/SubSettingRecyclerAdapter.kt rename to V2rayNG/app/src/main/java/com/v2ray/ang/ui/SubSettingRecyclerAdapter.kt index 8186c71d..cc2d5404 100644 --- a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/ui/SubSettingRecyclerAdapter.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/SubSettingRecyclerAdapter.kt @@ -3,21 +3,26 @@ 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.lifecycle.lifecycleScope import androidx.recyclerview.widget.RecyclerView +import com.v2ray.ang.AppConfig import com.v2ray.ang.R import com.v2ray.ang.databinding.ItemQrcodeBinding import com.v2ray.ang.databinding.ItemRecyclerSubSettingBinding import com.v2ray.ang.extension.toast +import com.v2ray.ang.handler.MmkvManager +import com.v2ray.ang.handler.SettingsManager import com.v2ray.ang.helper.ItemTouchHelperAdapter import com.v2ray.ang.helper.ItemTouchHelperViewHolder -import com.v2ray.ang.util.MmkvManager import com.v2ray.ang.util.QRCodeDecoder -import com.v2ray.ang.util.SettingsManager import com.v2ray.ang.util.Utils +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch class SubSettingRecyclerAdapter(val activity: SubSettingActivity) : RecyclerView.Adapter(), ItemTouchHelperAdapter { @@ -44,16 +49,25 @@ class SubSettingRecyclerAdapter(val activity: SubSettingActivity) : RecyclerView ) } + holder.itemSubSettingBinding.layoutRemove.setOnClickListener { + removeSubscription(subId, position) + } + holder.itemSubSettingBinding.chkEnable.setOnCheckedChangeListener { it, isChecked -> - if( !it.isPressed) return@setOnCheckedChangeListener + if (!it.isPressed) return@setOnCheckedChangeListener subItem.enabled = isChecked MmkvManager.encodeSubscription(subId, subItem) } if (TextUtils.isEmpty(subItem.url)) { + holder.itemSubSettingBinding.layoutUrl.visibility = View.GONE holder.itemSubSettingBinding.layoutShare.visibility = View.INVISIBLE + holder.itemSubSettingBinding.chkEnable.visibility = View.INVISIBLE } else { + holder.itemSubSettingBinding.layoutUrl.visibility = View.VISIBLE + holder.itemSubSettingBinding.layoutShare.visibility = View.VISIBLE + holder.itemSubSettingBinding.chkEnable.visibility = View.VISIBLE holder.itemSubSettingBinding.layoutShare.setOnClickListener { AlertDialog.Builder(mActivity) .setItems(share_method.asList().toTypedArray()) { _, i -> @@ -78,13 +92,39 @@ class SubSettingRecyclerAdapter(val activity: SubSettingActivity) : RecyclerView else -> mActivity.toast("else") } } catch (e: Exception) { - e.printStackTrace() + Log.e(AppConfig.TAG, "Share subscription failed", e) } }.show() } } } + private fun removeSubscription(subId: String, position: Int) { + if (MmkvManager.decodeSettingsBool(AppConfig.PREF_CONFIRM_REMOVE) == true) { + AlertDialog.Builder(mActivity).setMessage(R.string.del_config_comfirm) + .setPositiveButton(android.R.string.ok) { _, _ -> + removeSubscriptionSub(subId, position) + } + .setNegativeButton(android.R.string.cancel) { _, _ -> + //do noting + } + .show() + } else { + removeSubscriptionSub(subId, position) + } + } + + private fun removeSubscriptionSub(subId: String, position: Int) { + mActivity.lifecycleScope.launch(Dispatchers.IO) { + MmkvManager.removeSubscription(subId) + launch(Dispatchers.Main) { + notifyItemRemoved(position) + notifyItemRangeChanged(position, mActivity.subscriptions.size) + mActivity.refreshData() + } + } + } + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MainViewHolder { return MainViewHolder( ItemRecyclerSubSettingBinding.inflate( diff --git a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/ui/TaskerActivity.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/TaskerActivity.kt similarity index 94% rename from V2rayNG/app/src/main/kotlin/com/v2ray/ang/ui/TaskerActivity.kt rename to V2rayNG/app/src/main/java/com/v2ray/ang/ui/TaskerActivity.kt index 9e660ff8..8600a4ee 100644 --- a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/ui/TaskerActivity.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/TaskerActivity.kt @@ -3,6 +3,7 @@ package com.v2ray.ang.ui import android.content.Intent import android.os.Bundle import android.text.TextUtils +import android.util.Log import android.view.Menu import android.view.MenuItem import android.view.View @@ -11,7 +12,7 @@ import android.widget.ListView import com.v2ray.ang.AppConfig import com.v2ray.ang.R import com.v2ray.ang.databinding.ActivityTaskerBinding -import com.v2ray.ang.util.MmkvManager +import com.v2ray.ang.handler.MmkvManager class TaskerActivity : BaseActivity() { private val binding by lazy { ActivityTaskerBinding.inflate(layoutInflater) } @@ -28,7 +29,7 @@ class TaskerActivity : BaseActivity() { lstData.add("Default") lstGuid.add(AppConfig.TASKER_DEFAULT_GUID) - MmkvManager.decodeServerList()?.forEach { key -> + MmkvManager.decodeServerList().forEach { key -> MmkvManager.decodeServerConfig(key)?.let { config -> lstData.add(config.remarks) lstGuid.add(key) @@ -60,7 +61,7 @@ class TaskerActivity : BaseActivity() { } } } catch (e: Exception) { - e.printStackTrace() + Log.e(AppConfig.TAG, "Failed to initialize Tasker settings", e) } } diff --git a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/ui/UrlSchemeActivity.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/UrlSchemeActivity.kt similarity index 69% rename from V2rayNG/app/src/main/kotlin/com/v2ray/ang/ui/UrlSchemeActivity.kt rename to V2rayNG/app/src/main/java/com/v2ray/ang/ui/UrlSchemeActivity.kt index 976ce6fb..364f442a 100644 --- a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/ui/UrlSchemeActivity.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/UrlSchemeActivity.kt @@ -4,10 +4,16 @@ import android.content.Intent import android.net.Uri import android.os.Bundle import android.util.Log +import androidx.lifecycle.lifecycleScope +import com.v2ray.ang.AppConfig import com.v2ray.ang.R import com.v2ray.ang.databinding.ActivityLogcatBinding import com.v2ray.ang.extension.toast -import com.v2ray.ang.util.AngConfigManager +import com.v2ray.ang.extension.toastError +import com.v2ray.ang.handler.AngConfigManager +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import java.net.URLDecoder class UrlSchemeActivity : BaseActivity() { @@ -40,7 +46,7 @@ class UrlSchemeActivity : BaseActivity() { } else -> { - toast(R.string.toast_failure) + toastError(R.string.toast_failure) } } } @@ -49,7 +55,7 @@ class UrlSchemeActivity : BaseActivity() { startActivity(Intent(this, MainActivity::class.java)) finish() } catch (e: Exception) { - e.printStackTrace() + Log.e(AppConfig.TAG, "Error processing URL scheme", e) } } @@ -57,7 +63,7 @@ class UrlSchemeActivity : BaseActivity() { if (uriString.isNullOrEmpty()) { return } - Log.d("UrlScheme", uriString) + Log.i(AppConfig.TAG, uriString) var decodedUrl = URLDecoder.decode(uriString, "UTF-8") val uri = Uri.parse(decodedUrl) @@ -65,12 +71,16 @@ class UrlSchemeActivity : BaseActivity() { if (uri.fragment.isNullOrEmpty() && !fragment.isNullOrEmpty()) { decodedUrl += "#${fragment}" } - Log.d("UrlScheme-decodedUrl", decodedUrl) - val (count, countSub) = AngConfigManager.importBatchConfig(decodedUrl, "", false) - if (count + countSub > 0) { - toast(R.string.import_subscription_success) - } else { - toast(R.string.import_subscription_failure) + Log.i(AppConfig.TAG, decodedUrl) + lifecycleScope.launch(Dispatchers.IO) { + val (count, countSub) = AngConfigManager.importBatchConfig(decodedUrl, "", false) + withContext(Dispatchers.Main) { + if (count + countSub > 0) { + toast(R.string.import_subscription_success) + } else { + toast(R.string.import_subscription_failure) + } + } } } } diff --git a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/ui/UserAssetActivity.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/UserAssetActivity.kt similarity index 50% rename from V2rayNG/app/src/main/kotlin/com/v2ray/ang/ui/UserAssetActivity.kt rename to V2rayNG/app/src/main/java/com/v2ray/ang/ui/UserAssetActivity.kt index 82ab0d88..efc0a39c 100644 --- a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/ui/UserAssetActivity.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/UserAssetActivity.kt @@ -19,37 +19,67 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView -import com.tbruyelle.rxpermissions3.RxPermissions import com.v2ray.ang.AppConfig -import com.v2ray.ang.AppConfig.LOOPBACK import com.v2ray.ang.R -import com.v2ray.ang.databinding.ActivitySubSettingBinding +import com.v2ray.ang.databinding.ActivityUserAssetBinding import com.v2ray.ang.databinding.ItemRecyclerUserAssetBinding -import com.v2ray.ang.databinding.LayoutProgressBinding import com.v2ray.ang.dto.AssetUrlItem +import com.v2ray.ang.extension.concatUrl import com.v2ray.ang.extension.toTrafficString import com.v2ray.ang.extension.toast -import com.v2ray.ang.util.MmkvManager -import com.v2ray.ang.util.MmkvManager.settingsStorage -import com.v2ray.ang.util.SettingsManager +import com.v2ray.ang.extension.toastError +import com.v2ray.ang.extension.toastSuccess +import com.v2ray.ang.handler.MmkvManager +import com.v2ray.ang.handler.SettingsManager +import com.v2ray.ang.util.HttpUtil import com.v2ray.ang.util.Utils import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import java.io.File import java.io.FileOutputStream import java.net.HttpURLConnection -import java.net.InetSocketAddress -import java.net.Proxy -import java.net.URL import java.text.DateFormat import java.util.Date class UserAssetActivity : BaseActivity() { - private val binding by lazy { ActivitySubSettingBinding.inflate(layoutInflater) } + private val binding by lazy { ActivityUserAssetBinding.inflate(layoutInflater) } val extDir by lazy { File(Utils.userAssetPath(this)) } val builtInGeoFiles = arrayOf("geosite.dat", "geoip.dat") + private val requestStoragePermissionLauncher = registerForActivityResult( + ActivityResultContracts.RequestPermission() + ) { isGranted: Boolean -> + if (isGranted) { + val intent = Intent(Intent.ACTION_GET_CONTENT) + intent.type = "*/*" + intent.addCategory(Intent.CATEGORY_OPENABLE) + + try { + chooseFile.launch( + Intent.createChooser( + intent, + getString(R.string.title_file_chooser) + ) + ) + } catch (ex: android.content.ActivityNotFoundException) { + toast(R.string.toast_require_file_manager) + } + } else { + toast(R.string.toast_permission_denied) + } + } + + private val requestCameraPermissionLauncher = registerForActivityResult( + ActivityResultContracts.RequestPermission() + ) { isGranted: Boolean -> + if (isGranted) { + scanQRCodeForAssetURL.launch(Intent(this, ScannerActivity::class.java)) + } else { + toast(R.string.toast_permission_denied) + } + } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -58,12 +88,18 @@ class UserAssetActivity : BaseActivity() { binding.recyclerView.setHasFixedSize(true) binding.recyclerView.layoutManager = LinearLayoutManager(this) + addCustomDividerToRecyclerView(binding.recyclerView, this, R.drawable.custom_divider) binding.recyclerView.adapter = UserAssetAdapter() + + binding.tvGeoFilesSourcesSummary.text = getGeoFilesSources() + binding.layoutGeoFilesSources.setOnClickListener { + setGeoFilesSources() + } } override fun onResume() { super.onResume() - binding.recyclerView.adapter?.notifyDataSetChanged() + refreshData() } override fun onCreateOptionsMenu(menu: Menu): Boolean { @@ -71,88 +107,71 @@ class UserAssetActivity : BaseActivity() { return super.onCreateOptionsMenu(menu) } - override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) { - R.id.add_file -> { - showFileChooser() - true - } - - R.id.add_url -> { - val intent = Intent(this, UserAssetUrlActivity::class.java) - startActivity(intent) - true - } - - R.id.download_file -> { - downloadGeoFiles() - true - } - + // Use when to streamline the option selection + override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) { + R.id.add_file -> showFileChooser().let { true } + R.id.add_url -> startActivity(Intent(this, UserAssetUrlActivity::class.java)).let { true } + R.id.add_qrcode -> importAssetFromQRcode().let { true } + R.id.download_file -> downloadGeoFiles().let { true } else -> super.onOptionsItemSelected(item) } + private fun getGeoFilesSources(): String { + return MmkvManager.decodeSettingsString(AppConfig.PREF_GEO_FILES_SOURCES) ?: AppConfig.GEO_FILES_SOURCES.first() + } + + private fun setGeoFilesSources() { + AlertDialog.Builder(this).setItems(AppConfig.GEO_FILES_SOURCES.toTypedArray()) { _, i -> + try { + val value = AppConfig.GEO_FILES_SOURCES[i] + MmkvManager.encodeSettings(AppConfig.PREF_GEO_FILES_SOURCES, value) + binding.tvGeoFilesSourcesSummary.text = value + } catch (e: Exception) { + Log.e(AppConfig.TAG, "Failed to set geo files sources", e) + } + }.show() + } + private fun showFileChooser() { val permission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { Manifest.permission.READ_MEDIA_IMAGES } else { Manifest.permission.READ_EXTERNAL_STORAGE } - RxPermissions(this) - .request(permission) - .subscribe { - if (it) { - val intent = Intent(Intent.ACTION_GET_CONTENT) - intent.type = "*/*" - intent.addCategory(Intent.CATEGORY_OPENABLE) - - try { - chooseFile.launch( - Intent.createChooser( - intent, - getString(R.string.title_file_chooser) - ) - ) - } catch (ex: android.content.ActivityNotFoundException) { - toast(R.string.toast_require_file_manager) - } - } else - toast(R.string.toast_permission_denied) - } + requestStoragePermissionLauncher.launch(permission) } - private val chooseFile = - registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { it -> - val uri = it.data?.data - if (it.resultCode == RESULT_OK && uri != null) { - val assetId = Utils.getUuid() - try { - val assetItem = AssetUrlItem( - getCursorName(uri) ?: uri.toString(), - "file" - ) + private val chooseFile = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + val uri = result.data?.data + if (result.resultCode == RESULT_OK && uri != null) { + val assetId = Utils.getUuid() + runCatching { + val assetItem = AssetUrlItem( + getCursorName(uri) ?: uri.toString(), + "file" + ) - // check remarks unique - val assetList = MmkvManager.decodeAssetUrls() - if (assetList.any { it.second.remarks == assetItem.remarks && it.first != assetId }) { - toast(R.string.msg_remark_is_duplicate) - return@registerForActivityResult - } + val assetList = MmkvManager.decodeAssetUrls() + if (assetList.any { it.second.remarks == assetItem.remarks && it.first != assetId }) { + toast(R.string.msg_remark_is_duplicate) + } else { MmkvManager.encodeAsset(assetId, assetItem) copyFile(uri) - } catch (e: Exception) { - toast(R.string.toast_asset_copy_failed) - MmkvManager.removeAssetUrl(assetId) } + }.onFailure { + toastError(R.string.toast_asset_copy_failed) + MmkvManager.removeAssetUrl(assetId) } } + } private fun copyFile(uri: Uri): String { val targetFile = File(extDir, getCursorName(uri) ?: uri.toString()) contentResolver.openInputStream(uri).use { inputStream -> targetFile.outputStream().use { fileOut -> inputStream?.copyTo(fileOut) - toast(R.string.toast_success) - binding.recyclerView.adapter?.notifyDataSetChanged() + toastSuccess(R.string.toast_success) + refreshData() } } return targetFile.path @@ -166,60 +185,80 @@ class UserAssetActivity : BaseActivity() { }.also { cursor.close() } } } catch (e: Exception) { - e.printStackTrace() + Log.e(AppConfig.TAG, "Failed to get cursor name", e) null } + private fun importAssetFromQRcode(): Boolean { + requestCameraPermissionLauncher.launch(Manifest.permission.CAMERA) + return true + } + + private val scanQRCodeForAssetURL = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { + if (it.resultCode == RESULT_OK) { + importAsset(it.data?.getStringExtra("SCAN_RESULT")) + } + } + + private fun importAsset(url: String?): Boolean { + try { + if (!Utils.isValidUrl(url)) { + toast(R.string.toast_invalid_url) + return false + } + // Send URL to UserAssetUrlActivity for Processing + startActivity( + Intent(this, UserAssetUrlActivity::class.java) + .putExtra(UserAssetUrlActivity.ASSET_URL_QRCODE, url) + ) + } catch (e: Exception) { + Log.e(AppConfig.TAG, "Failed to import asset from URL", e) + return false + } + return true + } + private fun downloadGeoFiles() { - val dialog = AlertDialog.Builder(this) - .setView(LayoutProgressBinding.inflate(layoutInflater).root) - .setCancelable(false) - .show() + binding.pbWaiting.show() toast(R.string.msg_downloading_content) val httpPort = SettingsManager.getHttpPort() var assets = MmkvManager.decodeAssetUrls() assets = addBuiltInGeoItems(assets) - assets.forEach { - //toast(getString(R.string.msg_downloading_content) + it) - lifecycleScope.launch(Dispatchers.IO) { - var result = downloadGeo(it.second, 60000, httpPort) - if (!result) { - result = downloadGeo(it.second, 60000, 0) - } - launch(Dispatchers.Main) { - if (result) { - toast(getString(R.string.toast_success) + " " + it.second.remarks) - binding.recyclerView.adapter?.notifyDataSetChanged() - } else { - toast(getString(R.string.toast_failure) + " " + it.second.remarks) + var resultCount = 0 + lifecycleScope.launch(Dispatchers.IO) { + assets.forEach { + try { + var result = downloadGeo(it.second, 15000, httpPort) + if (!result) { + result = downloadGeo(it.second, 15000, 0) } - dialog.dismiss() + if (result) + resultCount++ + } catch (e: Exception) { + Log.e(AppConfig.TAG, "Failed to download geo file: ${it.second.remarks}", e) } } + withContext(Dispatchers.Main) { + if (resultCount > 0) { + toast(getString(R.string.title_update_config_count, resultCount)) + refreshData() + } else { + toast(getString(R.string.toast_failure)) + } + binding.pbWaiting.hide() + } } } private fun downloadGeo(item: AssetUrlItem, timeout: Int, httpPort: Int): Boolean { val targetTemp = File(extDir, item.remarks + "_temp") val target = File(extDir, item.remarks) - var conn: HttpURLConnection? = null - //Log.d(AppConfig.ANG_PACKAGE, url) + Log.i(AppConfig.TAG, "Downloading geo file: ${item.remarks} from ${item.url}") + val conn = HttpUtil.createProxyConnection(item.url, httpPort, timeout, timeout, needStream = true) ?: return false try { - conn = if (httpPort == 0) { - URL(item.url).openConnection() as HttpURLConnection - } else { - URL(item.url).openConnection( - Proxy( - Proxy.Type.HTTP, - InetSocketAddress(LOOPBACK, httpPort) - ) - ) as HttpURLConnection - } - conn.connectTimeout = timeout - conn.readTimeout = timeout val inputStream = conn.inputStream val responseCode = conn.responseCode if (responseCode == HttpURLConnection.HTTP_OK) { @@ -231,10 +270,10 @@ class UserAssetActivity : BaseActivity() { } return true } catch (e: Exception) { - Log.e(AppConfig.ANG_PACKAGE, Log.getStackTraceString(e)) + Log.e(AppConfig.TAG, "Failed to download geo file: ${item.remarks}", e) return false } finally { - conn?.disconnect() + conn.disconnect() } } @@ -246,7 +285,8 @@ class UserAssetActivity : BaseActivity() { list.add( Utils.getUuid() to AssetUrlItem( it, - AppConfig.GeoUrl + it + String.format(AppConfig.GITHUB_DOWNLOAD_URL, getGeoFilesSources()).concatUrl(it), + locked = true ) ) } @@ -254,6 +294,20 @@ class UserAssetActivity : BaseActivity() { return list + assets } + fun initAssets() { + lifecycleScope.launch(Dispatchers.Default) { + SettingsManager.initAssets(this@UserAssetActivity, assets) + withContext(Dispatchers.Main) { + refreshData() + } + } + } + + @SuppressLint("NotifyDataSetChanged") + fun refreshData() { + binding.recyclerView.adapter?.notifyDataSetChanged() + } + inner class UserAssetAdapter : RecyclerView.Adapter() { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): UserAssetViewHolder { return UserAssetViewHolder( @@ -283,12 +337,12 @@ class UserAssetActivity : BaseActivity() { holder.itemUserAssetBinding.assetProperties.text = getString(R.string.msg_file_not_found) } - if (item.second.remarks in builtInGeoFiles && item.second.url == AppConfig.GeoUrl + item.second.remarks) { + if (item.second.locked == true) { holder.itemUserAssetBinding.layoutEdit.visibility = GONE - holder.itemUserAssetBinding.layoutRemove.visibility = GONE + //holder.itemUserAssetBinding.layoutRemove.visibility = GONE } else { holder.itemUserAssetBinding.layoutEdit.visibility = item.second.url.let { if (it == "file") GONE else VISIBLE } - holder.itemUserAssetBinding.layoutRemove.visibility = VISIBLE + //holder.itemUserAssetBinding.layoutRemove.visibility = VISIBLE } holder.itemUserAssetBinding.layoutEdit.setOnClickListener { @@ -297,9 +351,16 @@ class UserAssetActivity : BaseActivity() { startActivity(intent) } holder.itemUserAssetBinding.layoutRemove.setOnClickListener { - file?.delete() - MmkvManager.removeAssetUrl(item.first) - binding.recyclerView.adapter?.notifyItemRemoved(position) + AlertDialog.Builder(this@UserAssetActivity).setMessage(R.string.del_config_comfirm) + .setPositiveButton(android.R.string.ok) { _, _ -> + file?.delete() + MmkvManager.removeAssetUrl(item.first) + initAssets() + } + .setNegativeButton(android.R.string.cancel) { _, _ -> + //do noting + } + .show() } } @@ -312,4 +373,4 @@ class UserAssetActivity : BaseActivity() { class UserAssetViewHolder(val itemUserAssetBinding: ItemRecyclerUserAssetBinding) : RecyclerView.ViewHolder(itemUserAssetBinding.root) -} +} \ No newline at end of file diff --git a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/ui/UserAssetUrlActivity.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/UserAssetUrlActivity.kt similarity index 76% rename from V2rayNG/app/src/main/kotlin/com/v2ray/ang/ui/UserAssetUrlActivity.kt rename to V2rayNG/app/src/main/java/com/v2ray/ang/ui/UserAssetUrlActivity.kt index 5db6b1df..3d390967 100644 --- a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/ui/UserAssetUrlActivity.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/UserAssetUrlActivity.kt @@ -2,24 +2,32 @@ 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.v2ray.ang.AppConfig import com.v2ray.ang.R import com.v2ray.ang.databinding.ActivityUserAssetUrlBinding import com.v2ray.ang.dto.AssetUrlItem import com.v2ray.ang.extension.toast -import com.v2ray.ang.util.MmkvManager +import com.v2ray.ang.extension.toastSuccess +import com.v2ray.ang.handler.MmkvManager import com.v2ray.ang.util.Utils import java.io.File class UserAssetUrlActivity : BaseActivity() { + // Receive QRcode URL from UserAssetActivity + companion object { + const val ASSET_URL_QRCODE = "ASSET_URL_QRCODE" + } + private val binding by lazy { ActivityUserAssetUrlBinding.inflate(layoutInflater) } - var del_config: MenuItem? = null - var save_config: MenuItem? = null + private var del_config: MenuItem? = null + private var save_config: MenuItem? = null - val extDir by lazy { File(Utils.userAssetPath(this)) } + private val extDir by lazy { File(Utils.userAssetPath(this)) } private val editAssetId by lazy { intent.getStringExtra("assetId").orEmpty() } override fun onCreate(savedInstanceState: Bundle?) { @@ -28,10 +36,16 @@ class UserAssetUrlActivity : BaseActivity() { title = getString(R.string.title_user_asset_add_url) val assetItem = MmkvManager.decodeAsset(editAssetId) - if (assetItem != null) { - bindingAsset(assetItem) - } else { - clearAsset() + val assetUrlQrcode = intent.getStringExtra(ASSET_URL_QRCODE) + val assetNameQrcode = File(assetUrlQrcode.toString()).name + when { + assetItem != null -> bindingAsset(assetItem) + assetUrlQrcode != null -> { + binding.etRemarks.setText(assetNameQrcode) + binding.etUrl.setText(assetUrlQrcode) + } + + else -> clearAsset() } } @@ -63,7 +77,11 @@ class UserAssetUrlActivity : BaseActivity() { // remove file associated with the asset val file = extDir.resolve(assetItem.remarks) if (file.exists()) { - file.delete() + try { + file.delete() + } catch (e: Exception) { + Log.e(AppConfig.TAG, "Failed to delete asset file: ${file.path}", e) + } } } else { assetId = Utils.getUuid() @@ -91,7 +109,7 @@ class UserAssetUrlActivity : BaseActivity() { } MmkvManager.encodeAsset(assetId, assetItem) - toast(R.string.toast_success) + toastSuccess(R.string.toast_success) finish() return true } @@ -106,7 +124,7 @@ class UserAssetUrlActivity : BaseActivity() { MmkvManager.removeAssetUrl(editAssetId) finish() } - .setNegativeButton(android.R.string.no) { _, _ -> + .setNegativeButton(android.R.string.cancel) { _, _ -> // do nothing } .show() diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/util/AppManagerUtil.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/util/AppManagerUtil.kt new file mode 100644 index 00000000..577698ea --- /dev/null +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/util/AppManagerUtil.kt @@ -0,0 +1,40 @@ +package com.v2ray.ang.util + +import android.content.Context +import android.content.pm.ApplicationInfo +import android.content.pm.PackageManager +import com.v2ray.ang.dto.AppInfo +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +object AppManagerUtil { + /** + * Load the list of network applications. + * + * @param context The context to use. + * @return A list of AppInfo objects representing the network applications. + */ + suspend fun loadNetworkAppList(context: Context): ArrayList = + withContext(Dispatchers.IO) { + val packageManager = context.packageManager + val packages = packageManager.getInstalledPackages(PackageManager.GET_PERMISSIONS) + val apps = ArrayList() + + for (pkg in packages) { + val applicationInfo = pkg.applicationInfo ?: continue + + val appName = applicationInfo.loadLabel(packageManager).toString() + val appIcon = applicationInfo.loadIcon(packageManager) ?: continue + val isSystemApp = applicationInfo.flags and ApplicationInfo.FLAG_SYSTEM > 0 + + val appInfo = AppInfo(appName, pkg.packageName, appIcon, isSystemApp, 0) + apps.add(appInfo) + } + + return@withContext apps + } + + fun getLastUpdateTime(context: Context): Long = + context.packageManager.getPackageInfo(context.packageName, 0).lastUpdateTime + +} diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/util/HttpUtil.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/util/HttpUtil.kt new file mode 100644 index 00000000..7172728e --- /dev/null +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/util/HttpUtil.kt @@ -0,0 +1,223 @@ +package com.v2ray.ang.util + +import android.util.Log +import com.v2ray.ang.AppConfig +import com.v2ray.ang.AppConfig.LOOPBACK +import com.v2ray.ang.BuildConfig +import com.v2ray.ang.util.Utils.encode +import com.v2ray.ang.util.Utils.urlDecode +import java.io.IOException +import java.net.HttpURLConnection +import java.net.IDN +import java.net.Inet6Address +import java.net.InetAddress +import java.net.InetSocketAddress +import java.net.Proxy +import java.net.URL + +object HttpUtil { + + /** + * Converts the domain part of a URL string to its IDN (Punycode, ASCII Compatible Encoding) format. + * + * For example, a URL like "https://例子.中国/path" will be converted to "https://xn--fsqu00a.xn--fiqs8s/path". + * + * @param str The URL string to convert (can contain non-ASCII characters in the domain). + * @return The URL string with the domain part converted to ASCII-compatible (Punycode) format. + */ + fun toIdnUrl(str: String): String { + val url = URL(str) + val host = url.host + val asciiHost = IDN.toASCII(url.host, IDN.ALLOW_UNASSIGNED) + if (host != asciiHost) { + return str.replace(host, asciiHost) + } else { + return str + } + } + + /** + * Converts a Unicode domain name to its IDN (Punycode, ASCII Compatible Encoding) format. + * If the input is an IP address or already an ASCII domain, returns the original string. + * + * @param domain The domain string to convert (can include non-ASCII internationalized characters). + * @return The domain in ASCII-compatible (Punycode) format, or the original string if input is an IP or already ASCII. + */ + fun toIdnDomain(domain: String): String { + // Return as is if it's a pure IP address (IPv4 or IPv6) + if (Utils.isPureIpAddress(domain)) { + return domain + } + + // Return as is if already ASCII (English domain or already punycode) + if (domain.all { it.code < 128 }) { + return domain + } + + // Otherwise, convert to ASCII using IDN + return IDN.toASCII(domain, IDN.ALLOW_UNASSIGNED) + } + + /** + * Resolves a hostname to an IP address, returns original input if it's already an IP + * + * @param host The hostname or IP address to resolve + * @param ipv6Preferred Whether to prefer IPv6 addresses, defaults to false + * @return The resolved IP address or the original input (if it's already an IP or resolution fails) + */ + fun resolveHostToIP(host: String, ipv6Preferred: Boolean = false): List? { + try { + // If it's already an IP address, return it as a list + if (Utils.isPureIpAddress(host)) { + return null + } + + // Get all IP addresses + val addresses = InetAddress.getAllByName(host) + if (addresses.isEmpty()) { + return null + } + + // Sort addresses based on preference + val sortedAddresses = if (ipv6Preferred) { + addresses.sortedWith(compareByDescending { it is Inet6Address }) + } else { + addresses.sortedWith(compareBy { it is Inet6Address }) + } + + val ipList = sortedAddresses.mapNotNull { it.hostAddress } + + Log.i(AppConfig.TAG, "Resolved IPs for $host: ${ipList.joinToString()}") + + return ipList + } catch (e: Exception) { + Log.e(AppConfig.TAG, "Failed to resolve host to IP", e) + return null + } + } + + + /** + * Retrieves the content of a URL as a string. + * + * @param url The URL to fetch content from. + * @param timeout The timeout value in milliseconds. + * @param httpPort The HTTP port to use. + * @return The content of the URL as a string. + */ + fun getUrlContent(url: String, timeout: Int, httpPort: Int = 0): String? { + val conn = createProxyConnection(url, httpPort, timeout, timeout) ?: return null + try { + return conn.inputStream.bufferedReader().readText() + } catch (_: Exception) { + } finally { + conn.disconnect() + } + return null + } + + /** + * Retrieves the content of a URL as a string with a custom User-Agent header. + * + * @param url The URL to fetch content from. + * @param timeout The timeout value in milliseconds. + * @param httpPort The HTTP port to use. + * @return The content of the URL as a string. + * @throws IOException If an I/O error occurs. + */ + @Throws(IOException::class) + fun getUrlContentWithUserAgent(url: String?, timeout: Int = 15000, httpPort: Int = 0): String { + var currentUrl = url + var redirects = 0 + val maxRedirects = 3 + + while (redirects++ < maxRedirects) { + if (currentUrl == null) continue + val conn = createProxyConnection(currentUrl, httpPort, timeout, timeout) ?: continue + conn.setRequestProperty("User-agent", "v2rayNG/${BuildConfig.VERSION_NAME}") + conn.connect() + + val responseCode = conn.responseCode + when (responseCode) { + in 300..399 -> { + val location = conn.getHeaderField("Location") + conn.disconnect() + if (location.isNullOrEmpty()) { + throw IOException("Redirect location not found") + } + currentUrl = location + continue + } + + else -> try { + return conn.inputStream.use { it.bufferedReader().readText() } + } finally { + conn.disconnect() + } + } + } + throw IOException("Too many redirects") + } + + /** + * Creates an HttpURLConnection object connected through a proxy. + * + * @param urlStr The target URL address. + * @param port The port of the proxy server. + * @param connectTimeout The connection timeout in milliseconds (default is 15000 ms). + * @param readTimeout The read timeout in milliseconds (default is 15000 ms). + * @param needStream Whether the connection needs to support streaming. + * @return Returns a configured HttpURLConnection object, or null if it fails. + */ + fun createProxyConnection( + urlStr: String, + port: Int, + connectTimeout: Int = 15000, + readTimeout: Int = 15000, + needStream: Boolean = false + ): HttpURLConnection? { + + var conn: HttpURLConnection? = null + try { + val url = URL(urlStr) + // Create a connection + conn = if (port == 0) { + url.openConnection() + } else { + url.openConnection( + Proxy( + Proxy.Type.HTTP, + InetSocketAddress(LOOPBACK, port) + ) + ) + } as HttpURLConnection + + // Set connection and read timeouts + conn.connectTimeout = connectTimeout + conn.readTimeout = readTimeout + if (!needStream) { + // Set request headers + conn.setRequestProperty("Connection", "close") + // Disable automatic redirects + conn.instanceFollowRedirects = false + // Disable caching + conn.useCaches = false + } + + //Add Basic Authorization + url.userInfo?.let { + conn.setRequestProperty( + "Authorization", + "Basic ${encode(urlDecode(it))}" + ) + } + } catch (e: Exception) { + Log.e(AppConfig.TAG, "Failed to create proxy connection", e) + // If an exception occurs, close the connection and return null + conn?.disconnect() + return null + } + return conn + } +} + diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/util/JsonUtil.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/util/JsonUtil.kt new file mode 100644 index 00000000..80a40fbf --- /dev/null +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/util/JsonUtil.kt @@ -0,0 +1,79 @@ +package com.v2ray.ang.util + +import android.util.Log +import com.google.gson.Gson +import com.google.gson.GsonBuilder +import com.google.gson.JsonObject +import com.google.gson.JsonParser +import com.google.gson.JsonPrimitive +import com.google.gson.JsonSerializationContext +import com.google.gson.JsonSerializer +import com.google.gson.reflect.TypeToken +import com.v2ray.ang.AppConfig +import java.lang.reflect.Type + +object JsonUtil { + private var gson = Gson() + + /** + * Converts an object to its JSON representation. + * + * @param src The object to convert. + * @return The JSON representation of the object. + */ + fun toJson(src: Any?): String { + return gson.toJson(src) + } + + /** + * Parses a JSON string into an object of the specified class. + * + * @param src The JSON string to parse. + * @param cls The class of the object to parse into. + * @return The parsed object. + */ + fun fromJson(src: String, cls: Class): T { + return gson.fromJson(src, cls) + } + + /** + * Converts an object to its pretty-printed JSON representation. + * + * @param src The object to convert. + * @return The pretty-printed JSON representation of the object, or null if the object is null. + */ + fun toJsonPretty(src: Any?): String? { + if (src == null) + return null + val gsonPre = GsonBuilder() + .setPrettyPrinting() + .disableHtmlEscaping() + .registerTypeAdapter( // custom serializer is needed here since JSON by default parse number as Double, core will fail to start + object : TypeToken() {}.type, + JsonSerializer { src: Double?, _: Type?, _: JsonSerializationContext? -> + JsonPrimitive( + src?.toInt() + ) + } + ) + .create() + return gsonPre.toJson(src) + } + + /** + * Parses a JSON string into a JsonObject. + * + * @param src The JSON string to parse. + * @return The parsed JsonObject, or null if parsing fails. + */ + fun parseString(src: String?): JsonObject? { + if (src == null) + return null + try { + return JsonParser.parseString(src).getAsJsonObject() + } catch (e: Exception) { + Log.e(AppConfig.TAG, "Failed to parse JSON string", e) + return null + } + } +} \ No newline at end of file diff --git a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/util/MessageUtil.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/util/MessageUtil.kt similarity index 58% rename from V2rayNG/app/src/main/kotlin/com/v2ray/ang/util/MessageUtil.kt rename to V2rayNG/app/src/main/java/com/v2ray/ang/util/MessageUtil.kt index 0f521f57..c84443c7 100644 --- a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/util/MessageUtil.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/util/MessageUtil.kt @@ -3,21 +3,43 @@ package com.v2ray.ang.util import android.content.ComponentName import android.content.Context import android.content.Intent +import android.util.Log import com.v2ray.ang.AppConfig import com.v2ray.ang.service.V2RayTestService import java.io.Serializable - object MessageUtil { + + /** + * Sends a message to the service. + * + * @param ctx The context. + * @param what The message identifier. + * @param content The message content. + */ fun sendMsg2Service(ctx: Context, what: Int, content: Serializable) { sendMsg(ctx, AppConfig.BROADCAST_ACTION_SERVICE, what, content) } + /** + * Sends a message to the UI. + * + * @param ctx The context. + * @param what The message identifier. + * @param content The message content. + */ fun sendMsg2UI(ctx: Context, what: Int, content: Serializable) { sendMsg(ctx, AppConfig.BROADCAST_ACTION_ACTIVITY, what, content) } + /** + * Sends a message to the test service. + * + * @param ctx The context. + * @param what The message identifier. + * @param content The message content. + */ fun sendMsg2TestService(ctx: Context, what: Int, content: Serializable) { try { val intent = Intent() @@ -26,10 +48,18 @@ object MessageUtil { intent.putExtra("content", content) ctx.startService(intent) } catch (e: Exception) { - e.printStackTrace() + Log.e(AppConfig.TAG, "Failed to send message to test service", e) } } + /** + * Sends a message with the specified action. + * + * @param ctx The context. + * @param action The action string. + * @param what The message identifier. + * @param content The message content. + */ private fun sendMsg(ctx: Context, action: String, what: Int, content: Serializable) { try { val intent = Intent() @@ -39,7 +69,7 @@ object MessageUtil { intent.putExtra("content", content) ctx.sendBroadcast(intent) } catch (e: Exception) { - e.printStackTrace() + Log.e(AppConfig.TAG, "Failed to send message with action: $action", e) } } } diff --git a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/util/MyContextWrapper.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/util/MyContextWrapper.kt similarity index 83% rename from V2rayNG/app/src/main/kotlin/com/v2ray/ang/util/MyContextWrapper.kt rename to V2rayNG/app/src/main/java/com/v2ray/ang/util/MyContextWrapper.kt index 8c0a4c63..a769368f 100644 --- a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/util/MyContextWrapper.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/util/MyContextWrapper.kt @@ -11,12 +11,18 @@ import java.util.Locale open class MyContextWrapper(base: Context?) : ContextWrapper(base) { companion object { + /** + * Wraps the context with a new locale. + * + * @param context The original context. + * @param newLocale The new locale to set. + * @return A ContextWrapper with the new locale. + */ @RequiresApi(Build.VERSION_CODES.N) fun wrap(context: Context, newLocale: Locale?): ContextWrapper { var mContext = context val res: Resources = mContext.resources val configuration: Configuration = res.configuration - //注意 Android 7.0 前后的不同处理方法 mContext = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { configuration.setLocale(newLocale) val localeList = LocaleList(newLocale) diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/util/PluginUtil.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/util/PluginUtil.kt new file mode 100644 index 00000000..2b9f71aa --- /dev/null +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/util/PluginUtil.kt @@ -0,0 +1,140 @@ +package com.v2ray.ang.util + +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.handler.SpeedtestManager +import com.v2ray.ang.service.ProcessService +import java.io.File + +object PluginUtil { + 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 { + 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) + } + } +} diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/util/QRCodeDecoder.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/util/QRCodeDecoder.kt new file mode 100644 index 00000000..446739b6 --- /dev/null +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/util/QRCodeDecoder.kt @@ -0,0 +1,123 @@ +package com.v2ray.ang.util + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import com.google.zxing.BarcodeFormat +import com.google.zxing.BinaryBitmap +import com.google.zxing.DecodeHintType +import com.google.zxing.EncodeHintType +import com.google.zxing.NotFoundException +import com.google.zxing.RGBLuminanceSource +import com.google.zxing.common.GlobalHistogramBinarizer +import com.google.zxing.qrcode.QRCodeReader +import com.google.zxing.qrcode.QRCodeWriter +import java.util.EnumMap + +/** + * QR code decoder utility. + */ +object QRCodeDecoder { + val HINTS: MutableMap = EnumMap(DecodeHintType::class.java) + + /** + * Creates a QR code bitmap from the given text. + * + * @param text The text to encode in the QR code. + * @param size The size of the QR code bitmap. + * @return The generated QR code bitmap, or null if an error occurs. + */ + fun createQRCode(text: String, size: Int = 800): Bitmap? { + return runCatching { + val hints = mapOf(EncodeHintType.CHARACTER_SET to Charsets.UTF_8) + val bitMatrix = QRCodeWriter().encode(text, BarcodeFormat.QR_CODE, size, size, hints) + val pixels = IntArray(size * size) { i -> + if (bitMatrix.get(i % size, i / size)) 0xff000000.toInt() else 0xffffffff.toInt() + } + Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888).apply { + setPixels(pixels, 0, size, 0, 0, size, size) + } + }.getOrNull() + } + + /** + * Decodes a QR code from a local image file. This method is time-consuming and should be called in a background thread. + * + * @param picturePath The local path of the image file to decode. + * @return The content of the QR code, or null if decoding fails. + */ + fun syncDecodeQRCode(picturePath: String): String? { + return syncDecodeQRCode(getDecodeAbleBitmap(picturePath)) + } + + /** + * Decodes a QR code from a bitmap. This method is time-consuming and should be called in a background thread. + * + * @param bitmap The bitmap to decode. + * @return The content of the QR code, or null if decoding fails. + */ + fun syncDecodeQRCode(bitmap: Bitmap?): String? { + return bitmap?.let { + runCatching { + val pixels = IntArray(it.width * it.height).also { array -> + it.getPixels(array, 0, it.width, 0, 0, it.width, it.height) + } + val source = RGBLuminanceSource(it.width, it.height, pixels) + val qrReader = QRCodeReader() + + try { + qrReader.decode(BinaryBitmap(GlobalHistogramBinarizer(source)), mapOf(DecodeHintType.TRY_HARDER to true)).text + } catch (e: NotFoundException) { + qrReader.decode(BinaryBitmap(GlobalHistogramBinarizer(source.invert())), mapOf(DecodeHintType.TRY_HARDER to true)).text + } + }.getOrNull() + } + } + + /** + * Converts a local image file to a bitmap that can be decoded as a QR code. The image is compressed to avoid being too large. + * + * @param picturePath The local path of the image file. + * @return The decoded bitmap, or null if an error occurs. + */ + private fun getDecodeAbleBitmap(picturePath: String): Bitmap? { + return try { + val options = BitmapFactory.Options() + options.inJustDecodeBounds = true + BitmapFactory.decodeFile(picturePath, options) + var sampleSize = options.outHeight / 400 + if (sampleSize <= 0) { + sampleSize = 1 + } + options.inSampleSize = sampleSize + options.inJustDecodeBounds = false + BitmapFactory.decodeFile(picturePath, options) + } catch (e: Exception) { + null + } + } + + init { + val allFormats: List = arrayListOf( + BarcodeFormat.AZTEC, + BarcodeFormat.CODABAR, + BarcodeFormat.CODE_39, + BarcodeFormat.CODE_93, + BarcodeFormat.CODE_128, + BarcodeFormat.DATA_MATRIX, + BarcodeFormat.EAN_8, + BarcodeFormat.EAN_13, + BarcodeFormat.ITF, + BarcodeFormat.MAXICODE, + BarcodeFormat.PDF_417, + BarcodeFormat.QR_CODE, + BarcodeFormat.RSS_14, + BarcodeFormat.RSS_EXPANDED, + BarcodeFormat.UPC_A, + BarcodeFormat.UPC_E, + BarcodeFormat.UPC_EAN_EXTENSION + ) + HINTS[DecodeHintType.TRY_HARDER] = BarcodeFormat.QR_CODE + HINTS[DecodeHintType.POSSIBLE_FORMATS] = allFormats + HINTS[DecodeHintType.CHARACTER_SET] = Charsets.UTF_8 + } +} diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/util/Utils.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/util/Utils.kt new file mode 100644 index 00000000..148ce4ec --- /dev/null +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/util/Utils.kt @@ -0,0 +1,570 @@ +package com.v2ray.ang.util + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent +import android.content.res.Configuration.UI_MODE_NIGHT_MASK +import android.content.res.Configuration.UI_MODE_NIGHT_NO +import android.os.Build +import android.os.LocaleList +import android.provider.Settings +import android.text.Editable +import android.util.Base64 +import android.util.Log +import android.util.Patterns +import android.webkit.URLUtil +import androidx.core.content.ContextCompat +import androidx.core.net.toUri +import com.v2ray.ang.AppConfig +import com.v2ray.ang.AppConfig.LOOPBACK +import com.v2ray.ang.BuildConfig +import java.io.IOException +import java.net.InetAddress +import java.net.ServerSocket +import java.net.URI +import java.net.URLDecoder +import java.net.URLEncoder +import java.util.Locale +import java.util.UUID + +object Utils { + + private val IPV4_REGEX = + Regex("^([01]?[0-9]?[0-9]|2[0-4][0-9]|25[0-5])\\.([01]?[0-9]?[0-9]|2[0-4][0-9]|25[0-5])\\.([01]?[0-9]?[0-9]|2[0-4][0-9]|25[0-5])\\.([01]?[0-9]?[0-9]|2[0-4][0-9]|25[0-5])$") + private val IPV6_REGEX = Regex("^((?:[0-9A-Fa-f]{1,4}))?((?::[0-9A-Fa-f]{1,4}))*::((?:[0-9A-Fa-f]{1,4}))?((?::[0-9A-Fa-f]{1,4}))*|((?:[0-9A-Fa-f]{1,4}))((?::[0-9A-Fa-f]{1,4})){7}$") + + /** + * Convert string to editable for Kotlin. + * + * @param text The string to convert. + * @return An Editable instance containing the text. + */ + fun getEditable(text: String?): Editable { + return Editable.Factory.getInstance().newEditable(text.orEmpty()) + } + + /** + * Find the position of a value in an array. + * + * @param array The array to search. + * @param value The value to find. + * @return The index of the value in the array, or -1 if not found. + */ + fun arrayFind(array: Array, value: String): Int { + return array.indexOf(value) + } + + /** + * Parse a string to an integer with a default value. + * + * @param str The string to parse. + * @param default The default value if parsing fails. + * @return The parsed integer, or the default value if parsing fails. + */ + fun parseInt(str: String?, default: Int = 0): Int { + return str?.toIntOrNull() ?: default + } + + /** + * Get text from the clipboard. + * + * @param context The context to use. + * @return The text from the clipboard, or an empty string if an error occurs. + */ + fun getClipboard(context: Context): String { + return try { + val cmb = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + cmb.primaryClip?.getItemAt(0)?.text.toString() + } catch (e: Exception) { + Log.e(AppConfig.TAG, "Failed to get clipboard content", e) + "" + } + } + + /** + * Set text to the clipboard. + * + * @param context The context to use. + * @param content The text to set to the clipboard. + */ + fun setClipboard(context: Context, content: String) { + try { + val cmb = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clipData = ClipData.newPlainText(null, content) + cmb.setPrimaryClip(clipData) + } catch (e: Exception) { + Log.e(AppConfig.TAG, "Failed to set clipboard content", e) + } + } + + /** + * Decode a base64 encoded string. + * + * @param text The base64 encoded string. + * @return The decoded string, or an empty string if decoding fails. + */ + fun decode(text: String?): String { + return tryDecodeBase64(text) ?: text?.trimEnd('=')?.let { tryDecodeBase64(it) }.orEmpty() + } + + /** + * Try to decode a base64 encoded string. + * + * @param text The base64 encoded string. + * @return The decoded string, or null if decoding fails. + */ + fun tryDecodeBase64(text: String?): String? { + if (text.isNullOrEmpty()) return null + + try { + return Base64.decode(text, Base64.NO_WRAP).toString(Charsets.UTF_8) + } catch (e: Exception) { + Log.e(AppConfig.TAG, "Failed to decode standard base64", e) + } + try { + return Base64.decode(text, Base64.NO_WRAP.or(Base64.URL_SAFE)).toString(Charsets.UTF_8) + } catch (e: Exception) { + Log.e(AppConfig.TAG, "Failed to decode URL-safe base64", e) + } + return null + } + + /** + * Encode a string to base64. + * + * @param text The string to encode. + * @return The base64 encoded string, or an empty string if encoding fails. + */ + fun encode(text: String): String { + return try { + Base64.encodeToString(text.toByteArray(Charsets.UTF_8), Base64.NO_WRAP) + } catch (e: Exception) { + Log.e(AppConfig.TAG, "Failed to encode text to base64", e) + "" + } + } + + /** + * Check if a string is a valid IP address. + * + * @param value The string to check. + * @return True if the string is a valid IP address, false otherwise. + */ + fun isIpAddress(value: String?): Boolean { + if (value.isNullOrEmpty()) return false + + try { + var addr = value.trim() + if (addr.isEmpty()) return false + + //CIDR + if (addr.contains("/")) { + val arr = addr.split("/") + if (arr.size == 2 && arr[1].toIntOrNull() != null && arr[1].toInt() > -1) { + addr = arr[0] + } + } + + // Handle IPv4-mapped IPv6 addresses + if (addr.startsWith("::ffff:") && '.' in addr) { + addr = addr.drop(7) + } else if (addr.startsWith("[::ffff:") && '.' in addr) { + addr = addr.drop(8).replace("]", "") + } + + val octets = addr.split('.') + if (octets.size == 4) { + if (octets[3].contains(":")) { + addr = addr.substring(0, addr.indexOf(":")) + } + return isIpv4Address(addr) + } + + return isIpv6Address(addr) + } catch (e: Exception) { + Log.e(AppConfig.TAG, "Failed to validate IP address", e) + return false + } + } + + /** + * Check if a string is a pure IP address (IPv4 or IPv6). + * + * @param value The string to check. + * @return True if the string is a pure IP address, false otherwise. + */ + fun isPureIpAddress(value: String): Boolean { + return isIpv4Address(value) || isIpv6Address(value) + } + + /** + * Check if a string is a valid domain name. + * + * A valid domain name must not be an IP address and must be a valid URL format. + * + * @param input The string to check. + * @return True if the string is a valid domain name, false otherwise. + */ + fun isDomainName(input: String?): Boolean { + if (input.isNullOrEmpty()) return false + + // Must not be an IP address and must be a valid URL format + return !isPureIpAddress(input) && isValidUrl(input) + } + + /** + * Check if a string is a valid IPv4 address. + * + * @param value The string to check. + * @return True if the string is a valid IPv4 address, false otherwise. + */ + private fun isIpv4Address(value: String): Boolean { + return IPV4_REGEX.matches(value) + } + + /** + * Check if a string is a valid IPv6 address. + * + * @param value The string to check. + * @return True if the string is a valid IPv6 address, false otherwise. + */ + private fun isIpv6Address(value: String): Boolean { + var addr = value + if (addr.startsWith("[") && addr.endsWith("]")) { + addr = addr.drop(1).dropLast(1) + } + return IPV6_REGEX.matches(addr) + } + + /** + * Check if a string is a CoreDNS address. + * + * @param s The string to check. + * @return True if the string is a CoreDNS address, false otherwise. + */ + fun isCoreDNSAddress(s: String): Boolean { + return s.startsWith("https") || + s.startsWith("tcp") || + s.startsWith("quic") || + s == "localhost" + } + + /** + * Check if a string is a valid URL. + * + * @param value The string to check. + * @return True if the string is a valid URL, false otherwise. + */ + fun isValidUrl(value: String?): Boolean { + if (value.isNullOrEmpty()) return false + + return try { + Patterns.WEB_URL.matcher(value).matches() || + Patterns.DOMAIN_NAME.matcher(value).matches() || + URLUtil.isValidUrl(value) + } catch (e: Exception) { + Log.e(AppConfig.TAG, "Failed to validate URL", e) + false + } + } + + /** + * Open a URI in a browser. + * + * @param context The context to use. + * @param uriString The URI string to open. + */ + fun openUri(context: Context, uriString: String) { + try { + val uri = uriString.toUri() + context.startActivity(Intent(Intent.ACTION_VIEW, uri)) + } catch (e: Exception) { + Log.e(AppConfig.TAG, "Failed to open URI", e) + } + } + + /** + * Generate a UUID. + * + * @return A UUID string without dashes. + */ + fun getUuid(): String { + return try { + UUID.randomUUID().toString().replace("-", "") + } catch (e: Exception) { + Log.e(AppConfig.TAG, "Failed to generate UUID", e) + "" + } + } + + /** + * Decode a URL-encoded string. + * + * @param url The URL-encoded string. + * @return The decoded string, or the original string if decoding fails. + */ + fun urlDecode(url: String): String { + return try { + URLDecoder.decode(url, Charsets.UTF_8.toString()) + } catch (e: Exception) { + Log.e(AppConfig.TAG, "Failed to decode URL", e) + url + } + } + + /** + * Encode a string to URL-encoded format. + * + * @param url The string to encode. + * @return The URL-encoded string, or the original string if encoding fails. + */ + fun urlEncode(url: String): String { + return try { + URLEncoder.encode(url, Charsets.UTF_8.toString()).replace("+", "%20") + } catch (e: Exception) { + Log.e(AppConfig.TAG, "Failed to encode URL", e) + url + } + } + + /** + * Read text from an asset file. + * + * @param context The context to use. + * @param fileName The name of the asset file. + * @return The content of the asset file as a string. + */ + fun readTextFromAssets(context: Context?, fileName: String): String { + if (context == null) return "" + + return try { + context.assets.open(fileName).use { inputStream -> + inputStream.bufferedReader().use { reader -> + reader.readText() + } + } + } catch (e: Exception) { + Log.e(AppConfig.TAG, "Failed to read asset file: $fileName", e) + "" + } + } + + /** + * Get the path to the user asset directory. + * + * @param context The context to use. + * @return The path to the user asset directory. + */ + fun userAssetPath(context: Context?): String { + if (context == null) return "" + + return try { + context.getExternalFilesDir(AppConfig.DIR_ASSETS)?.absolutePath + ?: context.getDir(AppConfig.DIR_ASSETS, 0).absolutePath + } catch (e: Exception) { + Log.e(AppConfig.TAG, "Failed to get user asset path", e) + "" + } + } + + /** + * Get the path to the backup directory. + * + * @param context The context to use. + * @return The path to the backup directory. + */ + fun backupPath(context: Context?): String { + if (context == null) return "" + + return try { + context.getExternalFilesDir(AppConfig.DIR_BACKUPS)?.absolutePath + ?: context.getDir(AppConfig.DIR_BACKUPS, 0).absolutePath + } catch (e: Exception) { + Log.e(AppConfig.TAG, "Failed to get backup path", e) + "" + } + } + + /** + * Get the device ID for XUDP base key. + * + * @return The device ID for XUDP base key. + */ + fun getDeviceIdForXUDPBaseKey(): String { + return try { + val androidId = Settings.Secure.ANDROID_ID.toByteArray(Charsets.UTF_8) + Base64.encodeToString(androidId.copyOf(32), Base64.NO_PADDING.or(Base64.URL_SAFE)) + } catch (e: Exception) { + Log.e(AppConfig.TAG, "Failed to generate device ID", e) + "" + } + } + + /** + * Get the dark mode status. + * + * @param context The context to use. + * @return True if dark mode is enabled, false otherwise. + */ + fun getDarkModeStatus(context: Context): Boolean { + return context.resources.configuration.uiMode and UI_MODE_NIGHT_MASK != UI_MODE_NIGHT_NO + } + + /** + * Get the IPv6 address in a formatted string. + * + * @param address The IPv6 address. + * @return The formatted IPv6 address, or the original address if not valid. + */ + fun getIpv6Address(address: String?): String { + if (address.isNullOrEmpty()) return "" + + return if (isIpv6Address(address) && !address.contains('[') && !address.contains(']')) { + "[$address]" + } else { + address + } + } + + /** + * Get the system locale. + * + * @return The system locale. + */ + fun getSysLocale(): Locale = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + LocaleList.getDefault()[0] + } else { + Locale.getDefault() + } + + /** + * Fix illegal characters in a URL. + * + * @param str The URL string. + * @return The URL string with illegal characters replaced. + */ + fun fixIllegalUrl(str: String): String { + return str.replace(" ", "%20") + .replace("|", "%7C") + } + + /** + * Find a free port from a list of ports. + * + * @param ports The list of ports to check. + * @return The first free port found. + * @throws IOException If no free port is found. + */ + fun findFreePort(ports: List): Int { + for (port in ports) { + try { + return ServerSocket(port).use { it.localPort } + } catch (ex: IOException) { + continue // try next port + } + } + + // if the program gets here, no port in the range was found + throw IOException("no free port found") + } + + /** + * Check if a string is a valid subscription URL. + * + * @param value The string to check. + * @return True if the string is a valid subscription URL, false otherwise. + */ + fun isValidSubUrl(value: String?): Boolean { + if (value.isNullOrEmpty()) return false + + try { + if (URLUtil.isHttpsUrl(value)) return true + if (URLUtil.isHttpUrl(value)) { + if (value.contains(LOOPBACK)) return true + + //Check private ip address + val uri = URI(fixIllegalUrl(value)) + if (isIpAddress(uri.host)) { + AppConfig.PRIVATE_IP_LIST.forEach { + if (isIpInCidr(uri.host, it)) return true + } + } + } + } catch (e: Exception) { + Log.e(AppConfig.TAG, "Failed to validate subscription URL", e) + } + return false + } + + /** + * Get the receiver flags based on the Android version. + * + * @return The receiver flags. + */ + fun receiverFlags(): Int = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + ContextCompat.RECEIVER_EXPORTED + } else { + ContextCompat.RECEIVER_NOT_EXPORTED + } + + /** + * Check if the package is Xray. + * + * @return True if the package is Xray, false otherwise. + */ + fun isXray(): Boolean = BuildConfig.APPLICATION_ID.startsWith("com.v2ray.ang") + + /** + * Check if it is the Google Play version. + * + * @return True if the package is Google Play, false otherwise. + */ + fun isGoogleFlavor(): Boolean = BuildConfig.FLAVOR == "playstore" + + /** + * Converts an InetAddress to its long representation + * + * @param ip The InetAddress to convert + * @return The long representation of the IP address + */ + private fun inetAddressToLong(ip: InetAddress): Long { + val bytes = ip.address + var result: Long = 0 + for (i in bytes.indices) { + result = result shl 8 or (bytes[i].toInt() and 0xff).toLong() + } + return result + } + + /** + * Check if an IP address is within a CIDR range + * + * @param ip The IP address to check + * @param cidr The CIDR notation range (e.g., "192.168.1.0/24") + * @return True if the IP is within the CIDR range, false otherwise + */ + fun isIpInCidr(ip: String, cidr: String): Boolean { + try { + if (!isIpAddress(ip)) return false + + // Parse CIDR (e.g., "192.168.1.0/24") + val (cidrIp, prefixLen) = cidr.split("/") + val prefixLength = prefixLen.toInt() + + // Convert IP and CIDR's IP portion to Long + val ipLong = inetAddressToLong(InetAddress.getByName(ip)) + val cidrIpLong = inetAddressToLong(InetAddress.getByName(cidrIp)) + + // Calculate subnet mask (e.g., /24 → 0xFFFFFF00) + val mask = if (prefixLength == 0) 0L else (-1L shl (32 - prefixLength)) + + // Check if they're in the same subnet + return (ipLong and mask) == (cidrIpLong and mask) + } catch (e: Exception) { + Log.e(AppConfig.TAG, "Failed to check if IP is in CIDR", e) + return false + } + } +} + diff --git a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/util/ZipUtil.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/util/ZipUtil.kt similarity index 75% rename from V2rayNG/app/src/main/kotlin/com/v2ray/ang/util/ZipUtil.kt rename to V2rayNG/app/src/main/java/com/v2ray/ang/util/ZipUtil.kt index 5343f6d8..9d9dce62 100644 --- a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/util/ZipUtil.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/util/ZipUtil.kt @@ -1,5 +1,7 @@ package com.v2ray.ang.util +import android.util.Log +import com.v2ray.ang.AppConfig import java.io.BufferedOutputStream import java.io.File import java.io.FileInputStream @@ -13,6 +15,14 @@ import java.util.zip.ZipOutputStream object ZipUtil { private const val BUFFER_SIZE = 4096 + /** + * Zip the contents of a folder. + * + * @param folderPath The path to the folder to zip. + * @param outputZipFilePath The path to the output zip file. + * @return True if the operation is successful, false otherwise. + * @throws IOException If an I/O error occurs. + */ @Throws(IOException::class) fun zipFromFolder(folderPath: String, outputZipFilePath: String): Boolean { val buffer = ByteArray(BUFFER_SIZE) @@ -53,12 +63,20 @@ object ZipUtil { zos.closeEntry() zos.close() } catch (e: Exception) { - e.printStackTrace() + Log.e(AppConfig.TAG, "Failed to zip folder", e) return false } return true } + /** + * Unzip the contents of a zip file to a folder. + * + * @param zipFile The zip file to unzip. + * @param destDirectory The destination directory. + * @return True if the operation is successful, false otherwise. + * @throws IOException If an I/O error occurs. + */ @Throws(IOException::class) fun unzipToFolder(zipFile: File, destDirectory: String): Boolean { File(destDirectory).run { @@ -72,10 +90,8 @@ object ZipUtil { zip.getInputStream(entry).use { input -> val filePath = destDirectory + File.separator + entry.name if (!entry.isDirectory) { - // if the entry is a file, extracts it extractFile(input, filePath) } else { - // if the entry is a directory, make the directory val dir = File(filePath) dir.mkdir() } @@ -83,12 +99,19 @@ object ZipUtil { } } } catch (e: Exception) { - e.printStackTrace() + Log.e(AppConfig.TAG, "Failed to unzip file", e) return false } return true } + /** + * Extract a file from an input stream. + * + * @param inputStream The input stream to read from. + * @param destFilePath The destination file path. + * @throws IOException If an I/O error occurs. + */ @Throws(IOException::class) private fun extractFile(inputStream: InputStream, destFilePath: String) { val bos = BufferedOutputStream(FileOutputStream(destFilePath)) diff --git a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/viewmodel/MainViewModel.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/viewmodel/MainViewModel.kt similarity index 53% rename from V2rayNG/app/src/main/kotlin/com/v2ray/ang/viewmodel/MainViewModel.kt rename to V2rayNG/app/src/main/java/com/v2ray/ang/viewmodel/MainViewModel.kt index f807970e..ec5cb7ee 100644 --- a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/viewmodel/MainViewModel.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/viewmodel/MainViewModel.kt @@ -6,43 +6,37 @@ import android.content.Context import android.content.Intent import android.content.IntentFilter import android.content.res.AssetManager -import android.os.Build import android.util.Log +import androidx.core.content.ContextCompat import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope import com.v2ray.ang.AngApplication import com.v2ray.ang.AppConfig -import com.v2ray.ang.AppConfig.ANG_PACKAGE import com.v2ray.ang.R -import com.v2ray.ang.dto.EConfigType import com.v2ray.ang.dto.ProfileItem -import com.v2ray.ang.dto.ServerConfig import com.v2ray.ang.dto.ServersCache -import com.v2ray.ang.dto.V2rayConfig import com.v2ray.ang.extension.serializable -import com.v2ray.ang.extension.toast -import com.v2ray.ang.util.AngConfigManager -import com.v2ray.ang.util.AngConfigManager.updateConfigViaSub -import com.v2ray.ang.util.JsonUtil +import com.v2ray.ang.extension.toastError +import com.v2ray.ang.extension.toastSuccess +import com.v2ray.ang.handler.AngConfigManager +import com.v2ray.ang.handler.MmkvManager +import com.v2ray.ang.handler.SettingsManager +import com.v2ray.ang.handler.SpeedtestManager import com.v2ray.ang.util.MessageUtil -import com.v2ray.ang.util.MmkvManager -import com.v2ray.ang.util.SpeedtestUtil import com.v2ray.ang.util.Utils import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.cancelChildren import kotlinx.coroutines.launch -import java.io.File -import java.io.FileOutputStream import java.util.Collections class MainViewModel(application: Application) : AndroidViewModel(application) { private var serverList = MmkvManager.decodeServerList() - var subscriptionId: String = MmkvManager.settingsStorage.decodeString(AppConfig.CACHE_SUBSCRIPTION_ID, "").orEmpty() + var subscriptionId: String = MmkvManager.decodeSettingsString(AppConfig.CACHE_SUBSCRIPTION_ID, "").orEmpty() - //var keywordFilter: String = MmkvManager.settingsStorage.decodeString(AppConfig.CACHE_KEYWORD_FILTER, "")?:"" + //var keywordFilter: String = MmkvManager.MmkvManager.decodeSettingsString(AppConfig.CACHE_KEYWORD_FILTER, "")?:"" var keywordFilter = "" val serversCache = mutableListOf() val isRunning by lazy { MutableLiveData() } @@ -50,37 +44,41 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { val updateTestResultAction by lazy { MutableLiveData() } private val tcpingTestScope by lazy { CoroutineScope(Dispatchers.IO) } + /** + * 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)`. + */ fun startListenBroadcast() { isRunning.value = false - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - getApplication().registerReceiver( - mMsgReceiver, - IntentFilter(AppConfig.BROADCAST_ACTION_ACTIVITY), - Context.RECEIVER_EXPORTED - ) - } else { - getApplication().registerReceiver( - mMsgReceiver, - IntentFilter(AppConfig.BROADCAST_ACTION_ACTIVITY) - ) - } + val mFilter = IntentFilter(AppConfig.BROADCAST_ACTION_ACTIVITY) + ContextCompat.registerReceiver(getApplication(), mMsgReceiver, mFilter, Utils.receiverFlags()) MessageUtil.sendMsg2Service(getApplication(), AppConfig.MSG_REGISTER_CLIENT, "") } + /** + * Called when the ViewModel is cleared. + */ override fun onCleared() { getApplication().unregisterReceiver(mMsgReceiver) tcpingTestScope.coroutineContext[Job]?.cancelChildren() - SpeedtestUtil.closeAllTcpSockets() - Log.i(ANG_PACKAGE, "Main ViewModel is cleared") + SpeedtestManager.closeAllTcpSockets() + Log.i(AppConfig.TAG, "Main ViewModel is cleared") super.onCleared() } + /** + * Reloads the server list. + */ fun reloadServerList() { serverList = MmkvManager.decodeServerList() updateCache() updateListAction.value = -1 } + /** + * Removes a server by its GUID. + * @param guid The GUID of the server to remove. + */ fun removeServer(guid: String) { serverList.remove(guid) MmkvManager.removeServer(guid) @@ -90,80 +88,106 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { } } - fun appendCustomConfigServer(server: String): Boolean { - if (server.contains("inbounds") - && server.contains("outbounds") - && server.contains("routing") - ) { - try { - val config = ServerConfig.create(EConfigType.CUSTOM) - config.subscriptionId = subscriptionId - config.fullConfig = JsonUtil.fromJson(server, V2rayConfig::class.java) - config.remarks = config.fullConfig?.remarks ?: System.currentTimeMillis().toString() - val key = MmkvManager.encodeServerConfig("", config) - MmkvManager.encodeServerRaw(key, server) - serverList.add(0, key) - val profile = ProfileItem( - configType = config.configType, - subscriptionId = config.subscriptionId, - remarks = config.remarks, - server = config.getProxyOutbound()?.getServerAddress(), - serverPort = config.getProxyOutbound()?.getServerPort(), - ) - serversCache.add(0, ServersCache(key, profile)) - return true - } catch (e: Exception) { - e.printStackTrace() - } - } - return false - } +// /** +// * Appends a custom configuration server. +// * @param server The server configuration to append. +// * @return True if the server was successfully appended, false otherwise. +// */ +// fun appendCustomConfigServer(server: String): Boolean { +// if (server.contains("inbounds") +// && server.contains("outbounds") +// && server.contains("routing") +// ) { +// try { +// val config = CustomFmt.parse(server) ?: return false +// config.subscriptionId = subscriptionId +// val key = MmkvManager.encodeServerConfig("", config) +// MmkvManager.encodeServerRaw(key, server) +// serverList.add(0, key) +//// val profile = ProfileLiteItem( +//// configType = config.configType, +//// subscriptionId = config.subscriptionId, +//// remarks = config.remarks, +//// server = config.getProxyOutbound()?.getServerAddress(), +//// serverPort = config.getProxyOutbound()?.getServerPort(), +//// ) +// serversCache.add(0, ServersCache(key, config)) +// return true +// } catch (e: Exception) { +// e.printStackTrace() +// } +// } +// return false +// } + /** + * Swaps the positions of two servers. + * @param fromPosition The initial position of the server. + * @param toPosition The target position of the server. + */ fun swapServer(fromPosition: Int, toPosition: Int) { - Collections.swap(serverList, fromPosition, toPosition) + if (subscriptionId.isEmpty()) { + Collections.swap(serverList, fromPosition, toPosition) + } else { + val fromPosition2 = serverList.indexOf(serversCache[fromPosition].guid) + val toPosition2 = serverList.indexOf(serversCache[toPosition].guid) + Collections.swap(serverList, fromPosition2, toPosition2) + } Collections.swap(serversCache, fromPosition, toPosition) MmkvManager.encodeServerList(serverList) } + /** + * Updates the cache of servers. + */ @Synchronized fun updateCache() { serversCache.clear() for (guid in serverList) { - var profile = MmkvManager.decodeProfileConfig(guid) - if (profile == null) { - val config = MmkvManager.decodeServerConfig(guid) ?: continue - profile = ProfileItem( - configType = config.configType, - subscriptionId = config.subscriptionId, - remarks = config.remarks, - server = config.getProxyOutbound()?.getServerAddress(), - serverPort = config.getProxyOutbound()?.getServerPort(), - ) - MmkvManager.encodeServerConfig(guid, config) - } + var profile = MmkvManager.decodeServerConfig(guid) ?: continue +// var profile = MmkvManager.decodeProfileConfig(guid) +// if (profile == null) { +// val config = MmkvManager.decodeServerConfig(guid) ?: continue +// profile = ProfileLiteItem( +// configType = config.configType, +// subscriptionId = config.subscriptionId, +// remarks = config.remarks, +// server = config.getProxyOutbound()?.getServerAddress(), +// serverPort = config.getProxyOutbound()?.getServerPort(), +// ) +// MmkvManager.encodeServerConfig(guid, config) +// } if (subscriptionId.isNotEmpty() && subscriptionId != profile.subscriptionId) { continue } - if (keywordFilter.isEmpty() || profile.remarks.contains(keywordFilter)) { + if (keywordFilter.isEmpty() || profile.remarks.lowercase().contains(keywordFilter.lowercase())) { serversCache.add(ServersCache(guid, profile)) } } } + /** + * Updates the configuration via subscription for all servers. + * @return The number of updated configurations. + */ fun updateConfigViaSubAll(): Int { - if (subscriptionId.isNullOrEmpty()) { + if (subscriptionId.isEmpty()) { return AngConfigManager.updateConfigViaSubAll() } else { val subItem = MmkvManager.decodeSubscription(subscriptionId) ?: return 0 - return updateConfigViaSub(Pair(subscriptionId, subItem)) + return AngConfigManager.updateConfigViaSub(Pair(subscriptionId, subItem)) } } + /** + * Exports all servers. + * @return The number of exported servers. + */ fun exportAllServer(): Int { val serverListCopy = - if (subscriptionId.isNullOrEmpty() && keywordFilter.isNullOrEmpty()) { + if (subscriptionId.isEmpty() && keywordFilter.isEmpty()) { serverList } else { serversCache.map { it.guid }.toList() @@ -176,21 +200,22 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { return ret } - + /** + * Tests the TCP ping for all servers. + */ fun testAllTcping() { tcpingTestScope.coroutineContext[Job]?.cancelChildren() - SpeedtestUtil.closeAllTcpSockets() + SpeedtestManager.closeAllTcpSockets() MmkvManager.clearAllTestDelayResults(serversCache.map { it.guid }.toList()) - updateListAction.value = -1 // update all - getApplication().toast(R.string.connection_test_testing) - for (item in serversCache) { + val serversCopy = serversCache.toList() + for (item in serversCopy) { item.profile.let { outbound -> val serverAddress = outbound.server val serverPort = outbound.serverPort if (serverAddress != null && serverPort != null) { tcpingTestScope.launch { - val testResult = SpeedtestUtil.tcping(serverAddress, serverPort) + val testResult = SpeedtestManager.tcping(serverAddress, serverPort.toInt()) launch(Dispatchers.Main) { MmkvManager.encodeServerTestDelayMillis(item.guid, testResult) updateListAction.value = getPosition(item.guid) @@ -201,33 +226,46 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { } } + /** + * Tests the real ping for all servers. + */ fun testAllRealPing() { MessageUtil.sendMsg2TestService(getApplication(), AppConfig.MSG_MEASURE_CONFIG_CANCEL, "") MmkvManager.clearAllTestDelayResults(serversCache.map { it.guid }.toList()) - updateListAction.value = -1 // update all + updateListAction.value = -1 - val serversCopy = serversCache.toList() // Create a copy of the list - - getApplication().toast(R.string.connection_test_testing) - viewModelScope.launch(Dispatchers.Default) { // without Dispatchers.Default viewModelScope will launch in main thread + val serversCopy = serversCache.toList() + viewModelScope.launch(Dispatchers.Default) { for (item in serversCopy) { MessageUtil.sendMsg2TestService(getApplication(), AppConfig.MSG_MEASURE_CONFIG, item.guid) } } } + /** + * Tests the real ping for the current server. + */ fun testCurrentServerRealPing() { MessageUtil.sendMsg2Service(getApplication(), AppConfig.MSG_MEASURE_DELAY, "") } + /** + * Changes the subscription ID. + * @param id The new subscription ID. + */ fun subscriptionIdChanged(id: String) { if (subscriptionId != id) { subscriptionId = id - MmkvManager.settingsStorage.encode(AppConfig.CACHE_SUBSCRIPTION_ID, subscriptionId) + MmkvManager.encodeSettings(AppConfig.CACHE_SUBSCRIPTION_ID, subscriptionId) reloadServerList() } } + /** + * Gets the subscriptions. + * @param context The context. + * @return A pair of lists containing the subscription IDs and remarks. + */ fun getSubscriptions(context: Context): Pair?, MutableList?> { val subscriptions = MmkvManager.decodeSubscriptions() if (subscriptionId.isNotEmpty() @@ -246,6 +284,11 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { return listId to listRemarks } + /** + * Gets the position of a server by its GUID. + * @param guid The GUID of the server. + * @return The position of the server. + */ fun getPosition(guid: String): Int { serversCache.forEachIndexed { index, it -> if (it.guid == guid) @@ -254,8 +297,12 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { return -1 } + /** + * Removes duplicate servers. + * @return The number of removed servers. + */ fun removeDuplicateServer(): Int { - val serversCacheCopy = mutableListOf>() + val serversCacheCopy = mutableListOf>() for (it in serversCache) { val config = MmkvManager.decodeServerConfig(it.guid) ?: continue serversCacheCopy.add(Pair(it.guid, config)) @@ -263,11 +310,11 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { val deleteServer = mutableListOf() serversCacheCopy.forEachIndexed { index, it -> - val outbound = it.second.getProxyOutbound() + val outbound = it.second serversCacheCopy.forEachIndexed { index2, it2 -> if (index2 > index) { - val outbound2 = it2.second.getProxyOutbound() - if (outbound == outbound2 && !deleteServer.contains(it2.first)) { + val outbound2 = it2.second + if (outbound.equals(outbound2) && !deleteServer.contains(it2.first)) { deleteServer.add(it2.first) } } @@ -280,28 +327,44 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { return deleteServer.count() } - fun removeAllServer() { - if (subscriptionId.isNullOrEmpty() && keywordFilter.isNullOrEmpty()) { - MmkvManager.removeAllServer() + /** + * Removes all servers. + * @return The number of removed servers. + */ + fun removeAllServer(): Int { + val count = + if (subscriptionId.isEmpty() && keywordFilter.isEmpty()) { + MmkvManager.removeAllServer() + } else { + val serversCopy = serversCache.toList() + for (item in serversCopy) { + MmkvManager.removeServer(item.guid) + } + serversCache.toList().count() + } + return count + } + + /** + * Removes invalid servers. + * @return The number of removed servers. + */ + fun removeInvalidServer(): Int { + var count = 0 + if (subscriptionId.isEmpty() && keywordFilter.isEmpty()) { + count += MmkvManager.removeInvalidServer("") } else { val serversCopy = serversCache.toList() for (item in serversCopy) { - MmkvManager.removeServer(item.guid) - } - } - } - - fun removeInvalidServer() { - if (subscriptionId.isNullOrEmpty() && keywordFilter.isNullOrEmpty()) { - MmkvManager.removeInvalidServer("") - } else { - val serversCopy = serversCache.toList() - for (item in serversCopy) { - MmkvManager.removeInvalidServer(item.guid) + count += MmkvManager.removeInvalidServer(item.guid) } } + return count } + /** + * Sorts servers by their test results. + */ fun sortByTestResults() { data class ServerDelay(var guid: String, var testDelayMillis: Long) @@ -321,39 +384,26 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { MmkvManager.encodeServerList(serverList) } - - fun copyAssets(assets: AssetManager) { - val extFolder = Utils.userAssetPath(getApplication()) + /** + * Initializes assets. + * @param assets The asset manager. + */ + fun initAssets(assets: AssetManager) { viewModelScope.launch(Dispatchers.Default) { - 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( - ANG_PACKAGE, - "Copied from apk assets folder to ${target.absolutePath}" - ) - } - } catch (e: Exception) { - Log.e(ANG_PACKAGE, "asset copy failed", e) - } + SettingsManager.initAssets(getApplication(), assets) } } + /** + * Filters the configuration by a keyword. + * @param keyword The keyword to filter by. + */ fun filterConfig(keyword: String) { if (keyword == keywordFilter) { return } keywordFilter = keyword - MmkvManager.settingsStorage.encode(AppConfig.CACHE_KEYWORD_FILTER, keywordFilter) + MmkvManager.encodeSettings(AppConfig.CACHE_KEYWORD_FILTER, keywordFilter) reloadServerList() } @@ -369,12 +419,12 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { } AppConfig.MSG_STATE_START_SUCCESS -> { - getApplication().toast(R.string.toast_services_success) + getApplication().toastSuccess(R.string.toast_services_success) isRunning.value = true } AppConfig.MSG_STATE_START_FAILURE -> { - getApplication().toast(R.string.toast_services_failure) + getApplication().toastError(R.string.toast_services_failure) isRunning.value = false } @@ -394,4 +444,4 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { } } } -} \ No newline at end of file +} diff --git a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/viewmodel/SettingsViewModel.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/viewmodel/SettingsViewModel.kt similarity index 66% rename from V2rayNG/app/src/main/kotlin/com/v2ray/ang/viewmodel/SettingsViewModel.kt rename to V2rayNG/app/src/main/java/com/v2ray/ang/viewmodel/SettingsViewModel.kt index cbeb2dc5..7ac5d60f 100644 --- a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/viewmodel/SettingsViewModel.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/viewmodel/SettingsViewModel.kt @@ -6,36 +6,50 @@ import android.util.Log import androidx.lifecycle.AndroidViewModel import androidx.preference.PreferenceManager import com.v2ray.ang.AppConfig -import com.v2ray.ang.util.MmkvManager.settingsStorage -import com.v2ray.ang.util.Utils +import com.v2ray.ang.handler.MmkvManager +import com.v2ray.ang.handler.SettingsManager class SettingsViewModel(application: Application) : AndroidViewModel(application), SharedPreferences.OnSharedPreferenceChangeListener { + /** + * Starts listening for preference changes. + */ fun startListenPreferenceChange() { PreferenceManager.getDefaultSharedPreferences(getApplication()) .registerOnSharedPreferenceChangeListener(this) } + /** + * Called when the ViewModel is cleared. + */ override fun onCleared() { PreferenceManager.getDefaultSharedPreferences(getApplication()) .unregisterOnSharedPreferenceChangeListener(this) - Log.i(AppConfig.ANG_PACKAGE, "Settings ViewModel is cleared") + Log.i(AppConfig.TAG, "Settings ViewModel is cleared") super.onCleared() } + /** + * Called when a shared preference is changed. + * @param sharedPreferences The shared preferences. + * @param key The key of the changed preference. + */ override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String?) { - Log.d(AppConfig.ANG_PACKAGE, "Observe settings changed: $key") + Log.i(AppConfig.TAG, "Observe settings changed: $key") when (key) { AppConfig.PREF_MODE, AppConfig.PREF_VPN_DNS, + AppConfig.PREF_VPN_BYPASS_LAN, + AppConfig.PREF_VPN_INTERFACE_ADDRESS_CONFIG_INDEX, AppConfig.PREF_REMOTE_DNS, AppConfig.PREF_DOMESTIC_DNS, + AppConfig.PREF_DNS_HOSTS, AppConfig.PREF_DELAY_TEST_URL, AppConfig.PREF_LOCAL_DNS_PORT, AppConfig.PREF_SOCKS_PORT, - AppConfig.PREF_HTTP_PORT, AppConfig.PREF_LOGLEVEL, + AppConfig.PREF_OUTBOUND_DOMAIN_RESOLVE_METHOD, AppConfig.PREF_LANGUAGE, AppConfig.PREF_UI_MODE_NIGHT, AppConfig.PREF_ROUTING_DOMAIN_STRATEGY, @@ -44,8 +58,8 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application AppConfig.PREF_FRAGMENT_LENGTH, AppConfig.PREF_FRAGMENT_INTERVAL, AppConfig.PREF_MUX_XUDP_QUIC, - -> { - settingsStorage?.encode(key, sharedPreferences.getString(key, "")) + -> { + MmkvManager.encodeSettings(key, sharedPreferences.getString(key, "")) } AppConfig.PREF_ROUTE_ONLY_ENABLED, @@ -54,34 +68,32 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application AppConfig.PREF_PROXY_SHARING, AppConfig.PREF_LOCAL_DNS_ENABLED, AppConfig.PREF_FAKE_DNS_ENABLED, + AppConfig.PREF_APPEND_HTTP_PROXY, AppConfig.PREF_ALLOW_INSECURE, AppConfig.PREF_PREFER_IPV6, AppConfig.PREF_PER_APP_PROXY, AppConfig.PREF_BYPASS_APPS, AppConfig.PREF_CONFIRM_REMOVE, AppConfig.PREF_START_SCAN_IMMEDIATE, + AppConfig.PREF_DOUBLE_COLUMN_DISPLAY, AppConfig.SUBSCRIPTION_AUTO_UPDATE, AppConfig.PREF_FRAGMENT_ENABLED, AppConfig.PREF_MUX_ENABLED, - -> { - settingsStorage?.encode(key, sharedPreferences.getBoolean(key, false)) + -> { + MmkvManager.encodeSettings(key, sharedPreferences.getBoolean(key, false)) } AppConfig.PREF_SNIFFING_ENABLED -> { - settingsStorage?.encode(key, sharedPreferences.getBoolean(key, true)) + MmkvManager.encodeSettings(key, sharedPreferences.getBoolean(key, true)) } AppConfig.PREF_MUX_CONCURRENCY, AppConfig.PREF_MUX_XUDP_CONCURRENCY -> { - settingsStorage?.encode(key, sharedPreferences.getString(key, "8")) + MmkvManager.encodeSettings(key, sharedPreferences.getString(key, "8")) } - -// AppConfig.PREF_PER_APP_PROXY_SET -> { -// settingsStorage?.encode(key, sharedPreferences.getStringSet(key, setOf())) -// } } if (key == AppConfig.PREF_UI_MODE_NIGHT) { - Utils.setNightMode(getApplication()) + SettingsManager.setNightMode() } } } diff --git a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/AngApplication.kt b/V2rayNG/app/src/main/kotlin/com/v2ray/ang/AngApplication.kt deleted file mode 100644 index 23ca7b8f..00000000 --- a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/AngApplication.kt +++ /dev/null @@ -1,52 +0,0 @@ -package com.v2ray.ang - -import android.content.Context -import android.content.pm.PackageManager -import android.os.Build -import androidx.multidex.MultiDexApplication -import androidx.work.Configuration -import androidx.work.WorkManager -import com.tencent.mmkv.MMKV -import com.v2ray.ang.util.SettingsManager -import com.v2ray.ang.util.Utils - -class AngApplication : MultiDexApplication() { - companion object { - //const val PREF_LAST_VERSION = "pref_last_version" - lateinit var application: AngApplication - } - - override fun attachBaseContext(base: Context?) { - super.attachBaseContext(base) - application = this - } - - private val workManagerConfiguration: Configuration = Configuration.Builder() - .setDefaultProcessName("${BuildConfig.APPLICATION_ID}:bg") - .build() - - override fun onCreate() { - super.onCreate() - -// LeakCanary.install(this) - -// val defaultSharedPreferences = PreferenceManager.getDefaultSharedPreferences(this) -// firstRun = defaultSharedPreferences.getInt(PREF_LAST_VERSION, 0) != BuildConfig.VERSION_CODE -// if (firstRun) -// defaultSharedPreferences.edit().putInt(PREF_LAST_VERSION, BuildConfig.VERSION_CODE).apply() - - MMKV.initialize(this) - - Utils.setNightMode(application) - // Initialize WorkManager with the custom configuration - WorkManager.initialize(this, workManagerConfiguration) - - SettingsManager.initRoutingRulesets(this) - } - - fun getPackageInfo(packageName: String) = packageManager.getPackageInfo( - packageName, if (Build.VERSION.SDK_INT >= 28) PackageManager.GET_SIGNING_CERTIFICATES - else @Suppress("DEPRECATION") PackageManager.GET_SIGNATURES - )!! - -} diff --git a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/dto/ProfileItem.kt b/V2rayNG/app/src/main/kotlin/com/v2ray/ang/dto/ProfileItem.kt deleted file mode 100644 index a906289c..00000000 --- a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/dto/ProfileItem.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.v2ray.ang.dto - -data class ProfileItem( - val configType: EConfigType, - var subscriptionId: String = "", - var remarks: String = "", - var server: String?, - var serverPort: Int?, -) \ No newline at end of file diff --git a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/extension/_Ext.kt b/V2rayNG/app/src/main/kotlin/com/v2ray/ang/extension/_Ext.kt deleted file mode 100644 index ab78ed8d..00000000 --- a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/extension/_Ext.kt +++ /dev/null @@ -1,96 +0,0 @@ -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 me.drakeet.support.toast.ToastCompat -import org.json.JSONObject -import java.io.Serializable -import java.net.URI -import java.net.URLConnection - -val Context.v2RayApplication: AngApplication? - get() = applicationContext as? AngApplication - -fun Context.toast(message: Int) { - ToastCompat.makeText(this, message, Toast.LENGTH_SHORT).show() -} - -fun Context.toast(message: CharSequence) { - ToastCompat.makeText(this, message, Toast.LENGTH_SHORT).show() -} - -fun JSONObject.putOpt(pair: Pair) { - put(pair.first, pair.second) -} - -fun JSONObject.putOpt(pairs: Map) { - pairs.forEach { put(it.key, it.value) } -} - -const val THRESHOLD = 1000L -const val DIVISOR = 1024.0 - -fun Long.toSpeedString(): String = this.toTrafficString() + "/s" - -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() - -fun String.removeWhiteSpace(): String = replace("\\s+".toRegex(), "") - -fun String.toLongEx(): Long = toLongOrNull() ?: 0 - -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") - }) - } - } - -inline fun 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 -} - -inline fun 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 -} \ No newline at end of file diff --git a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/receiver/BootReceiver.kt b/V2rayNG/app/src/main/kotlin/com/v2ray/ang/receiver/BootReceiver.kt deleted file mode 100644 index 6d92d2f0..00000000 --- a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/receiver/BootReceiver.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.v2ray.ang.receiver - -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import com.v2ray.ang.service.V2RayServiceManager -import com.v2ray.ang.util.MmkvManager - -class BootReceiver : BroadcastReceiver() { - override fun onReceive(context: Context?, intent: Intent?) { - if (Intent.ACTION_BOOT_COMPLETED == intent?.action && MmkvManager.decodeStartOnBoot()) { - if (MmkvManager.getSelectServer().isNullOrEmpty()) { - return - } - V2RayServiceManager.startV2Ray(context!!) - } - } -} \ No newline at end of file diff --git a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/service/ServiceControl.kt b/V2rayNG/app/src/main/kotlin/com/v2ray/ang/service/ServiceControl.kt deleted file mode 100644 index 36a26b88..00000000 --- a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/service/ServiceControl.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.v2ray.ang.service - -import android.app.Service - -interface ServiceControl { - fun getService(): Service - - fun startService() - - fun stopService() - - fun vpnProtect(socket: Int): Boolean -} diff --git a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/service/V2RayServiceManager.kt b/V2rayNG/app/src/main/kotlin/com/v2ray/ang/service/V2RayServiceManager.kt deleted file mode 100644 index 3c3b2116..00000000 --- a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/service/V2RayServiceManager.kt +++ /dev/null @@ -1,437 +0,0 @@ -package com.v2ray.ang.service - -import android.app.Notification -import android.app.NotificationChannel -import android.app.NotificationManager -import android.app.PendingIntent -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import android.content.IntentFilter -import android.graphics.Color -import android.os.Build -import android.util.Log -import androidx.annotation.RequiresApi -import androidx.core.app.NotificationCompat -import com.v2ray.ang.AppConfig -import com.v2ray.ang.AppConfig.ANG_PACKAGE -import com.v2ray.ang.AppConfig.TAG_DIRECT -import com.v2ray.ang.AppConfig.VPN -import com.v2ray.ang.R -import com.v2ray.ang.dto.ServerConfig -import com.v2ray.ang.extension.toSpeedString -import com.v2ray.ang.extension.toast -import com.v2ray.ang.ui.MainActivity -import com.v2ray.ang.util.MessageUtil -import com.v2ray.ang.util.MmkvManager -import com.v2ray.ang.util.MmkvManager.settingsStorage -import com.v2ray.ang.util.PluginUtil -import com.v2ray.ang.util.Utils -import com.v2ray.ang.util.V2rayConfigUtil -import go.Seq -import io.reactivex.rxjava3.core.Observable -import io.reactivex.rxjava3.disposables.Disposable -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import libv2ray.Libv2ray -import libv2ray.V2RayPoint -import libv2ray.V2RayVPNServiceSupportsSet -import java.lang.ref.SoftReference -import kotlin.math.min - -object V2RayServiceManager { - 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_ICON_THRESHOLD = 3000 - - val v2rayPoint: V2RayPoint = Libv2ray.newV2RayPoint(V2RayCallback(), Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) - private val mMsgReceive = ReceiveMessageHandler() - - var serviceControl: SoftReference? = null - set(value) { - field = value - Seq.setContext(value?.get()?.getService()?.applicationContext) - Libv2ray.initV2Env(Utils.userAssetPath(value?.get()?.getService()), Utils.getDeviceIdForXUDPBaseKey()) - } - var currentConfig: ServerConfig? = null - - private var lastQueryTime = 0L - private var mBuilder: NotificationCompat.Builder? = null - private var mDisposable: Disposable? = null - private var mNotificationManager: NotificationManager? = null - - fun startV2Ray(context: Context) { - if (v2rayPoint.isRunning) return - val guid = MmkvManager.getSelectServer() ?: return - val result = V2rayConfigUtil.getV2rayConfig(context, guid) - if (!result.status) return - - if (settingsStorage?.decodeBool(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 ((settingsStorage?.decodeString(AppConfig.PREF_MODE) ?: VPN) == 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) - } - } - - private class V2RayCallback : V2RayVPNServiceSupportsSet { - override fun shutdown(): Long { - val serviceControl = serviceControl?.get() ?: return -1 - // called by go - return try { - serviceControl.stopService() - 0 - } catch (e: Exception) { - Log.d(ANG_PACKAGE, e.toString()) - -1 - } - } - - override fun prepare(): Long { - return 0 - } - - override fun protect(l: Long): Boolean { - val serviceControl = serviceControl?.get() ?: return true - return serviceControl.vpnProtect(l.toInt()) - } - - override fun onEmitStatus(l: Long, s: String?): Long { - return 0 - } - - override fun setup(s: String): Long { - val serviceControl = serviceControl?.get() ?: return -1 - return try { - serviceControl.startService() - lastQueryTime = System.currentTimeMillis() - startSpeedNotification() - 0 - } catch (e: Exception) { - Log.d(ANG_PACKAGE, e.toString()) - -1 - } - } - } - - fun startV2rayPoint() { - val service = serviceControl?.get()?.getService() ?: return - val guid = MmkvManager.getSelectServer() ?: return - val config = MmkvManager.decodeServerConfig(guid) ?: return - if (v2rayPoint.isRunning) { - return - } - val result = V2rayConfigUtil.getV2rayConfig(service, guid) - if (!result.status) - return - - 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) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - service.registerReceiver(mMsgReceive, mFilter, Context.RECEIVER_EXPORTED) - } else { - service.registerReceiver(mMsgReceive, mFilter) - } - } catch (e: Exception) { - Log.d(ANG_PACKAGE, e.toString()) - } - - v2rayPoint.configureFileContent = result.content - v2rayPoint.domainName = result.domainPort - currentConfig = config - - try { - v2rayPoint.runLoop(settingsStorage?.decodeBool(AppConfig.PREF_PREFER_IPV6) ?: false) - } catch (e: Exception) { - Log.d(ANG_PACKAGE, e.toString()) - } - - if (v2rayPoint.isRunning) { - MessageUtil.sendMsg2UI(service, AppConfig.MSG_STATE_START_SUCCESS, "") - showNotification() - - PluginUtil.runPlugin(service, config, result.domainPort) - } else { - MessageUtil.sendMsg2UI(service, AppConfig.MSG_STATE_START_FAILURE, "") - cancelNotification() - } - } - - fun stopV2rayPoint() { - val service = serviceControl?.get()?.getService() ?: return - - if (v2rayPoint.isRunning) { - CoroutineScope(Dispatchers.IO).launch { - try { - v2rayPoint.stopLoop() - } catch (e: Exception) { - Log.d(ANG_PACKAGE, e.toString()) - } - } - } - - MessageUtil.sendMsg2UI(service, AppConfig.MSG_STATE_STOP_SUCCESS, "") - cancelNotification() - - try { - service.unregisterReceiver(mMsgReceive) - } catch (e: Exception) { - Log.d(ANG_PACKAGE, e.toString()) - } - PluginUtil.stopPlugin() - } - - private class ReceiveMessageHandler : BroadcastReceiver() { - override fun onReceive(ctx: Context?, intent: Intent?) { - val serviceControl = serviceControl?.get() ?: return - when (intent?.getIntExtra("key", 0)) { - AppConfig.MSG_REGISTER_CLIENT -> { - if (v2rayPoint.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 -> { - serviceControl.stopService() - } - - AppConfig.MSG_STATE_RESTART -> { - startV2rayPoint() - } - - AppConfig.MSG_MEASURE_DELAY -> { - measureV2rayDelay() - } - } - - when (intent?.action) { - Intent.ACTION_SCREEN_OFF -> { - Log.d(ANG_PACKAGE, "SCREEN_OFF, stop querying stats") - stopSpeedNotification() - } - - Intent.ACTION_SCREEN_ON -> { - Log.d(ANG_PACKAGE, "SCREEN_ON, start querying stats") - startSpeedNotification() - } - } - } - } - - private fun measureV2rayDelay() { - CoroutineScope(Dispatchers.IO).launch { - val service = serviceControl?.get()?.getService() ?: return@launch - var time = -1L - var errstr = "" - if (v2rayPoint.isRunning) { - try { - time = v2rayPoint.measureDelay(Utils.getDelayTestUrl()) - } catch (e: Exception) { - Log.d(ANG_PACKAGE, "measureV2rayDelay: $e") - errstr = e.message?.substringAfter("\":") ?: "empty message" - } - if (time == -1L) { - try { - time = v2rayPoint.measureDelay(Utils.getDelayTestUrl(true)) - } catch (e: Exception) { - Log.d(ANG_PACKAGE, "measureV2rayDelay: $e") - errstr = e.message?.substringAfter("\":") ?: "empty message" - } - } - } - val result = if (time == -1L) { - service.getString(R.string.connection_test_error, errstr) - } else { - service.getString(R.string.connection_test_available, time) - } - - MessageUtil.sendMsg2UI(service, AppConfig.MSG_MEASURE_DELAY_SUCCESS, result) - } - } - - private fun showNotification() { - val service = serviceControl?.get()?.getService() ?: return - val startMainIntent = Intent(service, MainActivity::class.java) - val contentPendingIntent = PendingIntent.getActivity( - service, - NOTIFICATION_PENDING_INTENT_CONTENT, startMainIntent, - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT - } else { - PendingIntent.FLAG_UPDATE_CURRENT - } - ) - - val stopV2RayIntent = Intent(AppConfig.BROADCAST_ACTION_SERVICE) - stopV2RayIntent.`package` = ANG_PACKAGE - stopV2RayIntent.putExtra("key", AppConfig.MSG_STATE_STOP) - - val stopV2RayPendingIntent = PendingIntent.getBroadcast( - service, - NOTIFICATION_PENDING_INTENT_STOP_V2RAY, stopV2RayIntent, - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT - } else { - PendingIntent.FLAG_UPDATE_CURRENT - } - ) - - 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 - ) - //.build() - - //mBuilder?.setDefaults(NotificationCompat.FLAG_ONLY_ALERT_ONCE) //取消震动,铃声其他都不好使 - - service.startForeground(NOTIFICATION_ID, mBuilder?.build()) - } - - @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 - } - - fun cancelNotification() { - val service = serviceControl?.get()?.getService() ?: return - service.stopForeground(true) - mBuilder = null - mDisposable?.dispose() - mDisposable = null - } - - 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) // Emui4.1 need content text even if style is set as BigTextStyle - getNotificationManager()?.notify(NOTIFICATION_ID, mBuilder?.build()) - } - } - - private fun getNotificationManager(): NotificationManager? { - if (mNotificationManager == null) { - val service = serviceControl?.get()?.getService() ?: return null - mNotificationManager = service.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - } - return mNotificationManager - } - - private fun startSpeedNotification() { - if (mDisposable == null && - v2rayPoint.isRunning && - settingsStorage?.decodeBool(AppConfig.PREF_SPEED_ENABLED) == true - ) { - var lastZeroSpeed = false - val outboundTags = currentConfig?.getAllOutboundTags() - outboundTags?.remove(TAG_DIRECT) - - mDisposable = Observable.interval(3, java.util.concurrent.TimeUnit.SECONDS) - .subscribe { - val queryTime = System.currentTimeMillis() - val sinceLastQueryInSeconds = (queryTime - lastQueryTime) / 1000.0 - var proxyTotal = 0L - val text = StringBuilder() - outboundTags?.forEach { - val up = v2rayPoint.queryStats(it, AppConfig.UPLINK) - val down = v2rayPoint.queryStats(it, AppConfig.DOWNLINK) - if (up + down > 0) { - appendSpeedString(text, it, up / sinceLastQueryInSeconds, down / sinceLastQueryInSeconds) - proxyTotal += up + down - } - } - val directUplink = v2rayPoint.queryStats(TAG_DIRECT, AppConfig.UPLINK) - val directDownlink = v2rayPoint.queryStats(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, TAG_DIRECT, directUplink / sinceLastQueryInSeconds, - directDownlink / sinceLastQueryInSeconds - ) - updateNotification(text.toString(), proxyTotal, directDownlink + directUplink) - } - lastZeroSpeed = zeroSpeed - lastQueryTime = queryTime - } - } - } - - 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") - } - - private fun stopSpeedNotification() { - if (mDisposable != null) { - mDisposable?.dispose() //stop queryStats - mDisposable = null - updateNotification(currentConfig?.remarks, 0, 0) - } - } -} diff --git a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/ui/BaseActivity.kt b/V2rayNG/app/src/main/kotlin/com/v2ray/ang/ui/BaseActivity.kt deleted file mode 100644 index 8a2c7fda..00000000 --- a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/ui/BaseActivity.kt +++ /dev/null @@ -1,42 +0,0 @@ -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.view.WindowCompat -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?) { - val context = newBase?.let { - MyContextWrapper.wrap(newBase, Utils.getLocale()) - } - super.attachBaseContext(context) - } -} diff --git a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/ui/LogcatActivity.kt b/V2rayNG/app/src/main/kotlin/com/v2ray/ang/ui/LogcatActivity.kt deleted file mode 100644 index c71294eb..00000000 --- a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/ui/LogcatActivity.kt +++ /dev/null @@ -1,95 +0,0 @@ -package com.v2ray.ang.ui - -import android.os.Bundle -import android.os.Handler -import android.os.Looper -import android.text.method.ScrollingMovementMethod -import android.view.Menu -import android.view.MenuItem -import android.view.View -import androidx.lifecycle.lifecycleScope -import com.v2ray.ang.AppConfig.ANG_PACKAGE -import com.v2ray.ang.R -import com.v2ray.ang.databinding.ActivityLogcatBinding -import com.v2ray.ang.extension.toast -import com.v2ray.ang.util.Utils -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import java.io.IOException - -class LogcatActivity : BaseActivity() { - private val binding by lazy { - ActivityLogcatBinding.inflate(layoutInflater) - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(binding.root) - - title = getString(R.string.title_logcat) - - logcat(false) - } - - private fun logcat(shouldFlushLog: Boolean) { - - try { - binding.pbWaiting.visibility = View.VISIBLE - - lifecycleScope.launch(Dispatchers.Default) { - if (shouldFlushLog) { - val lst = LinkedHashSet() - lst.add("logcat") - lst.add("-c") - withContext(Dispatchers.IO) { - val process = Runtime.getRuntime().exec(lst.toTypedArray()) - process.waitFor() - } - } - val lst = LinkedHashSet() - 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 bufferedReader = BufferedReader( -// InputStreamReader(process.inputStream)) -// val allText = bufferedReader.use(BufferedReader::readText) - val allText = process.inputStream.bufferedReader().use { it.readText() } - launch(Dispatchers.Main) { - binding.tvLogcat.text = allText - binding.tvLogcat.movementMethod = ScrollingMovementMethod() - binding.pbWaiting.visibility = View.GONE - Handler(Looper.getMainLooper()).post { binding.svLogcat.fullScroll(View.FOCUS_DOWN) } - } - } - } catch (e: IOException) { - e.printStackTrace() - } - } - - override fun onCreateOptionsMenu(menu: Menu): Boolean { - menuInflater.inflate(R.menu.menu_logcat, menu) - return super.onCreateOptionsMenu(menu) - } - - override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) { - R.id.copy_all -> { - Utils.setClipboard(this, binding.tvLogcat.text.toString()) - toast(R.string.toast_success) - true - } - - R.id.clear_all -> { - logcat(true) - true - } - - else -> super.onOptionsItemSelected(item) - } -} diff --git a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/ui/MainRecyclerAdapter.kt b/V2rayNG/app/src/main/kotlin/com/v2ray/ang/ui/MainRecyclerAdapter.kt deleted file mode 100644 index e9fb42f7..00000000 --- a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/ui/MainRecyclerAdapter.kt +++ /dev/null @@ -1,249 +0,0 @@ -package com.v2ray.ang.ui - -import android.content.Intent -import android.graphics.Color -import android.text.TextUtils -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.appcompat.app.AlertDialog -import androidx.core.content.ContextCompat -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.extension.toast -import com.v2ray.ang.helper.ItemTouchHelperAdapter -import com.v2ray.ang.helper.ItemTouchHelperViewHolder -import com.v2ray.ang.service.V2RayServiceManager -import com.v2ray.ang.util.AngConfigManager -import com.v2ray.ang.util.MmkvManager -import com.v2ray.ang.util.MmkvManager.settingsStorage -import com.v2ray.ang.util.Utils -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers -import io.reactivex.rxjava3.core.Observable -import java.util.concurrent.TimeUnit - -class MainRecyclerAdapter(val activity: MainActivity) : RecyclerView.Adapter(), 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 by lazy { - mActivity.resources.getStringArray(R.array.share_method) - } - var isRunning = false - - 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 -// //filter -// if (mActivity.mainViewModel.subscriptionId.isNotEmpty() -// && mActivity.mainViewModel.subscriptionId != config.subscriptionId -// ) { -// holder.itemMainBinding.cardView.visibility = View.GONE -// } else { -// holder.itemMainBinding.cardView.visibility = View.VISIBLE -// } - - val aff = MmkvManager.decodeServerAffiliationInfo(guid) - - holder.itemMainBinding.tvName.text = profile.remarks - holder.itemView.setBackgroundColor(Color.TRANSPARENT) - 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)) - } - if (guid == MmkvManager.getSelectServer()) { - holder.itemMainBinding.layoutIndicator.setBackgroundResource(R.color.colorAccent) - } else { - holder.itemMainBinding.layoutIndicator.setBackgroundResource(0) - } - holder.itemMainBinding.tvSubscription.text = MmkvManager.decodeSubscription(profile.subscriptionId)?.remarks ?: "" - - var shareOptions = share_method.asList() - when (profile.configType) { - EConfigType.CUSTOM -> { - holder.itemMainBinding.tvType.text = mActivity.getString(R.string.server_customize_config) - shareOptions = shareOptions.takeLast(1) - } - - else -> { - holder.itemMainBinding.tvType.text = profile.configType.name - } - } - - // 隐藏主页服务器地址为xxx:xxx:***/xxx.xxx.xxx.*** - val strState = "${ - profile.server?.let { - if (it.contains(":")) - it.split(":").take(2).joinToString(":", postfix = ":***") - else - it.split('.').dropLast(1).joinToString(".", postfix = ".***") - } - } : ${profile.serverPort}" - - holder.itemMainBinding.tvStatistics.text = strState - - holder.itemMainBinding.layoutShare.setOnClickListener { - AlertDialog.Builder(mActivity).setItems(shareOptions.toTypedArray()) { _, i -> - try { - when (i) { - 0 -> { - if (profile.configType == EConfigType.CUSTOM) { - shareFullContent(guid) - } else { - val ivBinding = ItemQrcodeBinding.inflate(LayoutInflater.from(mActivity)) - ivBinding.ivQcode.setImageBitmap(AngConfigManager.share2QRCode(guid)) - AlertDialog.Builder(mActivity).setView(ivBinding.root).show() - } - } - - 1 -> { - if (AngConfigManager.share2Clipboard(mActivity, guid) == 0) { - mActivity.toast(R.string.toast_success) - } else { - mActivity.toast(R.string.toast_failure) - } - } - - 2 -> shareFullContent(guid) - else -> mActivity.toast("else") - } - } catch (e: Exception) { - e.printStackTrace() - } - }.show() - } - - holder.itemMainBinding.layoutEdit.setOnClickListener { - val intent = Intent().putExtra("guid", guid) - .putExtra("isRunning", isRunning) - if (profile.configType == EConfigType.CUSTOM) { - mActivity.startActivity(intent.setClass(mActivity, ServerCustomConfigActivity::class.java)) - } else { - mActivity.startActivity(intent.setClass(mActivity, ServerActivity::class.java)) - } - } - holder.itemMainBinding.layoutRemove.setOnClickListener { - if (guid != MmkvManager.getSelectServer()) { - if (settingsStorage?.decodeBool(AppConfig.PREF_CONFIRM_REMOVE) == true) { - AlertDialog.Builder(mActivity).setMessage(R.string.del_config_comfirm) - .setPositiveButton(android.R.string.ok) { _, _ -> - removeServer(guid, position) - } - .setNegativeButton(android.R.string.no) { _, _ -> - //do noting - } - .show() - } else { - removeServer(guid, position) - } - } else { - application.toast(R.string.toast_action_not_allowed) - } - } - - holder.itemMainBinding.infoContainer.setOnClickListener { - 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) { - Utils.stopVService(mActivity) - Observable.timer(500, TimeUnit.MILLISECONDS) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe { - V2RayServiceManager.startV2Ray(mActivity) - } - } - } - } - } - if (holder is FooterViewHolder) { - //if (activity?.defaultDPreference?.getPrefBoolean(AppConfig.PREF_INAPP_BUY_IS_PREMIUM, false)) { - if (true) { - holder.itemFooterBinding.layoutEdit.visibility = View.INVISIBLE - } else { - holder.itemFooterBinding.layoutEdit.setOnClickListener { - Utils.openUri(mActivity, "${Utils.decode(AppConfig.PromotionUrl)}?t=${System.currentTimeMillis()}") - } - } - } - } - - private fun shareFullContent(guid: String) { - if (AngConfigManager.shareFullContent2Clipboard(mActivity, guid) == 0) { - mActivity.toast(R.string.toast_success) - } else { - mActivity.toast(R.string.toast_failure) - } - } - - private fun removeServer(guid: String, position: Int) { - mActivity.mainViewModel.removeServer(guid) - notifyItemRemoved(position) - notifyItemRangeChanged(position, mActivity.mainViewModel.serversCache.size) - } - - 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) { - } -} diff --git a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/ui/RoutingSettingActivity.kt b/V2rayNG/app/src/main/kotlin/com/v2ray/ang/ui/RoutingSettingActivity.kt deleted file mode 100644 index f6c12a96..00000000 --- a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/ui/RoutingSettingActivity.kt +++ /dev/null @@ -1,158 +0,0 @@ -package com.v2ray.ang.ui - -import android.content.Intent -import android.os.Bundle -import android.view.Menu -import android.view.MenuItem -import android.view.View -import android.widget.AdapterView -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.helper.SimpleItemTouchHelperCallback -import com.v2ray.ang.util.JsonUtil -import com.v2ray.ang.util.MmkvManager -import com.v2ray.ang.util.MmkvManager.settingsStorage -import com.v2ray.ang.util.SettingsManager -import com.v2ray.ang.util.Utils -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch - -class RoutingSettingActivity : BaseActivity() { - private val binding by lazy { ActivityRoutingSettingBinding.inflate(layoutInflater) } - - var rulesets: MutableList = mutableListOf() - private val adapter by lazy { RoutingSettingRecyclerAdapter(this) } - private var mItemTouchHelper: ItemTouchHelper? = null - private val routing_domain_strategy: Array by lazy { - resources.getStringArray(R.array.routing_domain_strategy) - } - private val preset_rulesets: Array by lazy { - resources.getStringArray(R.array.preset_rulesets) - } - - 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) - binding.recyclerView.adapter = adapter - - mItemTouchHelper = ItemTouchHelper(SimpleItemTouchHelperCallback(adapter)) - mItemTouchHelper?.attachToRecyclerView(binding.recyclerView) - - val found = Utils.arrayFind(routing_domain_strategy, settingsStorage?.decodeString(AppConfig.PREF_ROUTING_DOMAIN_STRATEGY) ?: "") - found.let { binding.spDomainStrategy.setSelection(if (it >= 0) it else 0) } - binding.spDomainStrategy.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { - override fun onNothingSelected(parent: AdapterView<*>?) { - } - - override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) { - settingsStorage.encode(AppConfig.PREF_ROUTING_DOMAIN_STRATEGY, routing_domain_strategy[position]) - } - } - } - - 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) = when (item.itemId) { - R.id.add_rule -> { - startActivity(Intent(this, RoutingEditActivity::class.java)) - true - } - - R.id.user_asset_setting -> { - startActivity(Intent(this, UserAssetActivity::class.java)) - true - } - - R.id.import_rulesets -> { - AlertDialog.Builder(this).setMessage(R.string.routing_settings_import_rulesets_tip) - .setPositiveButton(android.R.string.ok) { _, _ -> - AlertDialog.Builder(this).setItems(preset_rulesets.asList().toTypedArray()) { _, i -> - try { - lifecycleScope.launch(Dispatchers.IO) { - SettingsManager.resetRoutingRulesets(this@RoutingSettingActivity, i) - launch(Dispatchers.Main) { - refreshData() - toast(R.string.toast_success) - } - } - } catch (e: Exception) { - e.printStackTrace() - } - }.show() - - - } - .setNegativeButton(android.R.string.no) { _, _ -> - //do noting - } - .show() - true - } - - R.id.import_rulesets_from_clipboard -> { - AlertDialog.Builder(this).setMessage(R.string.routing_settings_import_rulesets_tip) - .setPositiveButton(android.R.string.ok) { _, _ -> - try { - val clipboard = Utils.getClipboard(this) - lifecycleScope.launch(Dispatchers.IO) { - val ret = SettingsManager.resetRoutingRulesetsFromClipboard(clipboard) - launch(Dispatchers.Main) { - if (ret) { - refreshData() - toast(R.string.toast_success) - } else { - toast(R.string.toast_failure) - } - } - } - } catch (e: Exception) { - e.printStackTrace() - } - } - .setNegativeButton(android.R.string.no) { _, _ -> - //do noting - } - .show() - true - } - - R.id.export_rulesets_to_clipboard -> { - val rulesetList = MmkvManager.decodeRoutingRulesets() - if (rulesetList.isNullOrEmpty()) { - toast(R.string.toast_failure) - } else { - Utils.setClipboard(this, JsonUtil.toJson(rulesetList)) - toast(R.string.toast_success) - } - true - } - - else -> super.onOptionsItemSelected(item) - } - - fun refreshData() { - rulesets = MmkvManager.decodeRoutingRulesets() ?: mutableListOf() - adapter.notifyDataSetChanged() - } -} diff --git a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/util/AppManagerUtil.kt b/V2rayNG/app/src/main/kotlin/com/v2ray/ang/util/AppManagerUtil.kt deleted file mode 100644 index 28107108..00000000 --- a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/util/AppManagerUtil.kt +++ /dev/null @@ -1,41 +0,0 @@ -package com.v2ray.ang.util - -import android.content.Context -import android.content.pm.ApplicationInfo -import android.content.pm.PackageManager -import com.v2ray.ang.dto.AppInfo -import io.reactivex.rxjava3.core.Observable - -object AppManagerUtil { - private fun loadNetworkAppList(ctx: Context): ArrayList { - val packageManager = ctx.packageManager - val packages = packageManager.getInstalledPackages(PackageManager.GET_PERMISSIONS) - val apps = ArrayList() - - for (pkg in packages) { - //if (!pkg.hasInternetPermission && pkg.packageName != "android") continue - - val applicationInfo = pkg.applicationInfo - - val appName = applicationInfo.loadLabel(packageManager).toString() - val appIcon = applicationInfo.loadIcon(packageManager) - val isSystemApp = applicationInfo.flags and ApplicationInfo.FLAG_SYSTEM > 0 - - val appInfo = AppInfo(appName, pkg.packageName, appIcon, isSystemApp, 0) - apps.add(appInfo) - } - - return apps - } - - fun rxLoadNetworkAppList(ctx: Context): Observable> = - Observable.unsafeCreate { - it.onNext(loadNetworkAppList(ctx)) - } - -// val PackageInfo.hasInternetPermission: Boolean -// get() { -// val permissions = requestedPermissions -// return permissions?.any { it == Manifest.permission.INTERNET } ?: false -// } -} diff --git a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/util/JsonUtil.kt b/V2rayNG/app/src/main/kotlin/com/v2ray/ang/util/JsonUtil.kt deleted file mode 100644 index 0c1c950a..00000000 --- a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/util/JsonUtil.kt +++ /dev/null @@ -1,37 +0,0 @@ -package com.v2ray.ang.util - -import com.google.gson.Gson -import com.google.gson.GsonBuilder -import com.google.gson.JsonPrimitive -import com.google.gson.JsonSerializationContext -import com.google.gson.JsonSerializer -import com.google.gson.reflect.TypeToken -import java.lang.reflect.Type - -object JsonUtil { - private var gson = Gson() - - fun toJson(src: Any?): String { - return gson.toJson(src) - } - - fun fromJson(json: String, cls: Class): T { - return gson.fromJson(json, cls) - } - - fun toJsonPretty(src: Any?): String { - val gsonPre = GsonBuilder() - .setPrettyPrinting() - .disableHtmlEscaping() - .registerTypeAdapter( // custom serialiser is needed here since JSON by default parse number as Double, core will fail to start - object : TypeToken() {}.type, - JsonSerializer { src: Double?, _: Type?, _: JsonSerializationContext? -> - JsonPrimitive( - src?.toInt() - ) - } - ) - .create() - return gsonPre.toJson(src) - } -} \ No newline at end of file diff --git a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/util/MmkvManager.kt b/V2rayNG/app/src/main/kotlin/com/v2ray/ang/util/MmkvManager.kt deleted file mode 100644 index 4787ffee..00000000 --- a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/util/MmkvManager.kt +++ /dev/null @@ -1,324 +0,0 @@ -package com.v2ray.ang.util - - -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.ServerConfig -import com.v2ray.ang.dto.SubscriptionItem - -object MmkvManager { - - //region private - - private const val ID_MAIN = "MAIN" - private const val ID_SERVER_CONFIG = "SERVER_CONFIG" - private const val ID_PROFILE_CONFIG = "PROFILE_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 mainStorage by lazy { MMKV.mmkvWithID(ID_MAIN, MMKV.MULTI_PROCESS_MODE) } - val settingsStorage by lazy { MMKV.mmkvWithID(ID_SETTING, MMKV.MULTI_PROCESS_MODE) } - private val serverStorage by lazy { MMKV.mmkvWithID(ID_SERVER_CONFIG, MMKV.MULTI_PROCESS_MODE) } - private val profileStorage by lazy { MMKV.mmkvWithID(ID_PROFILE_CONFIG, 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 serverRawStorage by lazy { MMKV.mmkvWithID(ID_SERVER_RAW, MMKV.MULTI_PROCESS_MODE) } - - //endregion - - //region Server - - fun getSelectServer(): String? { - return mainStorage.decodeString(KEY_SELECTED_SERVER) - } - - fun setSelectServer(guid: String) { - mainStorage.encode(KEY_SELECTED_SERVER, guid) - } - - fun encodeServerList(serverList: MutableList) { - mainStorage.encode(KEY_ANG_CONFIGS, JsonUtil.toJson(serverList)) - } - - fun decodeServerList(): MutableList { - val json = mainStorage.decodeString(KEY_ANG_CONFIGS) - return if (json.isNullOrBlank()) { - mutableListOf() - } else { - JsonUtil.fromJson(json, Array::class.java).toMutableList() - } - } - - fun decodeServerConfig(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) - } - - fun decodeProfileConfig(guid: String): ProfileItem? { - if (guid.isBlank()) { - return null - } - val json = profileStorage.decodeString(guid) - if (json.isNullOrBlank()) { - return null - } - return JsonUtil.fromJson(json, ProfileItem::class.java) - } - - fun encodeServerConfig(guid: String, config: ServerConfig): String { - val key = guid.ifBlank { Utils.getUuid() } - serverStorage.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 = ProfileItem( - 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 - } - - fun removeServer(guid: String) { - if (guid.isBlank()) { - return - } - if (getSelectServer() == guid) { - mainStorage.remove(KEY_SELECTED_SERVER) - } - val serverList = decodeServerList() - serverList.remove(guid) - encodeServerList(serverList) - serverStorage.remove(guid) - profileStorage.remove(guid) - serverAffStorage.remove(guid) - } - - fun removeServerViaSubid(subid: String) { - if (subid.isBlank()) { - return - } - serverStorage.allKeys()?.forEach { key -> - decodeServerConfig(key)?.let { config -> - if (config.subscriptionId == subid) { - removeServer(key) - } - } - } - } - - 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) - } - - fun encodeServerTestDelayMillis(guid: String, testResult: Long) { - if (guid.isBlank()) { - return - } - val aff = decodeServerAffiliationInfo(guid) ?: ServerAffiliationInfo() - aff.testDelayMillis = testResult - serverAffStorage.encode(guid, JsonUtil.toJson(aff)) - } - - fun clearAllTestDelayResults(keys: List?) { - keys?.forEach { key -> - decodeServerAffiliationInfo(key)?.let { aff -> - aff.testDelayMillis = 0 - serverAffStorage.encode(key, JsonUtil.toJson(aff)) - } - } - } - - fun removeAllServer() { - mainStorage.clearAll() - serverStorage.clearAll() - profileStorage.clearAll() - serverAffStorage.clearAll() - } - - fun removeInvalidServer(guid: String) { - if (guid.isNotEmpty()) { - decodeServerAffiliationInfo(guid)?.let { aff -> - if (aff.testDelayMillis < 0L) { - removeServer(guid) - } - } - } else { - serverAffStorage.allKeys()?.forEach { key -> - decodeServerAffiliationInfo(key)?.let { aff -> - if (aff.testDelayMillis < 0L) { - removeServer(key) - } - } - } - } - } - - fun encodeServerRaw(guid: String, config: String) { - serverRawStorage.encode(guid, config) - } - - fun decodeServerRaw(guid: String): String? { - return serverRawStorage.decodeString(guid) ?: return null - } - - //endregion - - //region Subscriptions - - private fun initSubsList() { - val subsList = decodeSubsList() - if (subsList.isNotEmpty()) { - return - } - subStorage.allKeys()?.forEach { key -> - subsList.add(key) - } - encodeSubsList(subsList) - } - - fun decodeSubscriptions(): List> { - initSubsList() - - val subscriptions = mutableListOf>() - decodeSubsList().forEach { key -> - val json = subStorage.decodeString(key) - if (!json.isNullOrBlank()) { - subscriptions.add(Pair(key, JsonUtil.fromJson(json, SubscriptionItem::class.java))) - } - } - return subscriptions - } - - fun removeSubscription(subid: String) { - subStorage.remove(subid) - val subsList = decodeSubsList() - subsList.remove(subid) - encodeSubsList(subsList) - - removeServerViaSubid(subid) - } - - 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) - } - } - - fun decodeSubscription(subscriptionId: String): SubscriptionItem? { - val json = subStorage.decodeString(subscriptionId) ?: return null - return JsonUtil.fromJson(json, SubscriptionItem::class.java) - } - - fun encodeSubsList(subsList: MutableList) { - mainStorage.encode(KEY_SUB_IDS, JsonUtil.toJson(subsList)) - } - - fun decodeSubsList(): MutableList { - val json = mainStorage.decodeString(KEY_SUB_IDS) - return if (json.isNullOrBlank()) { - mutableListOf() - } else { - JsonUtil.fromJson(json, Array::class.java).toMutableList() - } - } - - //endregion - - //region Asset - - fun decodeAssetUrls(): List> { - val assetUrlItems = mutableListOf>() - 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 } - } - - fun removeAssetUrl(assetid: String) { - assetStorage.remove(assetid) - } - - fun encodeAsset(assetid: String, assetItem: AssetUrlItem) { - val key = assetid.ifBlank { Utils.getUuid() } - assetStorage.encode(key, JsonUtil.toJson(assetItem)) - } - - fun decodeAsset(assetid: String): AssetUrlItem? { - val json = assetStorage.decodeString(assetid) ?: return null - return JsonUtil.fromJson(json, AssetUrlItem::class.java) - } - - //endregion - - //region Routing - - fun decodeRoutingRulesets(): MutableList? { - val ruleset = settingsStorage.decodeString(PREF_ROUTING_RULESET) - if (ruleset.isNullOrEmpty()) return null - return JsonUtil.fromJson(ruleset, Array::class.java).toMutableList() - } - - fun encodeRoutingRulesets(rulesetList: MutableList?) { - if (rulesetList.isNullOrEmpty()) - settingsStorage.encode(PREF_ROUTING_RULESET, "") - else - settingsStorage.encode(PREF_ROUTING_RULESET, JsonUtil.toJson(rulesetList)) - } - - //endregion - - //region Others - - fun encodeStartOnBoot(startOnBoot: Boolean) { - settingsStorage.encode(PREF_IS_BOOTED, startOnBoot) - } - - fun decodeStartOnBoot(): Boolean { - return settingsStorage.decodeBool(PREF_IS_BOOTED, false) - } - - //endregion - -} diff --git a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/util/PluginUtil.kt b/V2rayNG/app/src/main/kotlin/com/v2ray/ang/util/PluginUtil.kt deleted file mode 100644 index 08616856..00000000 --- a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/util/PluginUtil.kt +++ /dev/null @@ -1,99 +0,0 @@ -package com.v2ray.ang.util - -import android.content.Context -import android.os.SystemClock -import android.util.Log -import com.v2ray.ang.AppConfig.ANG_PACKAGE -import com.v2ray.ang.dto.EConfigType -import com.v2ray.ang.dto.ServerConfig -import com.v2ray.ang.service.ProcessService -import com.v2ray.ang.util.fmt.Hysteria2Fmt -import java.io.File - -object PluginUtil { - //private const val HYSTERIA2 = "hysteria2-plugin" - private const val HYSTERIA2 = "libhysteria2.so" - private const val TAG = ANG_PACKAGE - private lateinit var procService: ProcessService - -// fun initPlugin(name: String): PluginManager.InitResult { -// return PluginManager.init(name)!! -// } - - fun runPlugin(context: Context, config: ServerConfig?, domainPort: String?) { - Log.d(TAG, "runPlugin") - - val outbound = config?.getProxyOutbound() ?: return - if (outbound.protocol.equals(EConfigType.HYSTERIA2.name, true)) { - val configFile = genConfigHy2(context, config, domainPort) ?: return - val cmd = genCmdHy2(context, configFile) - - procService = ProcessService() - procService.runProcess(context, cmd) - } - } - - fun stopPlugin() { - stopHy2() - } - - fun realPingHy2(context: Context, config: ServerConfig?): Long { - Log.d(TAG, "realPingHy2") - val retFailure = -1L - - val outbound = config?.getProxyOutbound() ?: return retFailure - if (outbound.protocol.equals(EConfigType.HYSTERIA2.name, true)) { - val socksPort = Utils.findFreePort(listOf(0)) - val configFile = genConfigHy2(context, config, "0:${socksPort}") ?: return retFailure - val cmd = genCmdHy2(context, configFile) - - val proc = ProcessService() - proc.runProcess(context, cmd) - Thread.sleep(1000L) - val delay = SpeedtestUtil.testConnection(context, socksPort) - proc.stopProcess() - - return delay.first - } - return retFailure - } - - private fun genConfigHy2(context: Context, config: ServerConfig, domainPort: String?): File? { - Log.d(TAG, "runPlugin $HYSTERIA2") - - val socksPort = domainPort?.split(":")?.last() - .let { if (it.isNullOrEmpty()) return null else it.toInt() } - val hy2Config = Hysteria2Fmt.toNativeConfig(config, socksPort) ?: return null - - val configFile = File(context.noBackupFilesDir, "hy2_${SystemClock.elapsedRealtime()}.json") - Log.d(TAG, "runPlugin ${configFile.absolutePath}") - - configFile.parentFile?.mkdirs() - configFile.writeText(JsonUtil.toJson(hy2Config)) - Log.d(TAG, JsonUtil.toJson(hy2Config)) - - return configFile - } - - private fun genCmdHy2(context: Context, configFile: File): MutableList { - return mutableListOf( - File(context.applicationInfo.nativeLibraryDir, HYSTERIA2).absolutePath, - //initPlugin(HYSTERIA2).path, - "--disable-update-check", - "--config", - configFile.absolutePath, - "--log-level", - "warn", - "client" - ) - } - - private fun stopHy2() { - try { - Log.d(TAG, "$HYSTERIA2 destroy") - procService?.stopProcess() - } catch (e: Exception) { - Log.d(TAG, e.toString()) - } - } -} \ No newline at end of file diff --git a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/util/QRCodeDecoder.kt b/V2rayNG/app/src/main/kotlin/com/v2ray/ang/util/QRCodeDecoder.kt deleted file mode 100644 index fb0643da..00000000 --- a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/util/QRCodeDecoder.kt +++ /dev/null @@ -1,154 +0,0 @@ -package com.v2ray.ang.util - -import android.graphics.Bitmap -import android.graphics.BitmapFactory -import com.google.zxing.BarcodeFormat -import com.google.zxing.BinaryBitmap -import com.google.zxing.DecodeHintType -import com.google.zxing.EncodeHintType -import com.google.zxing.NotFoundException -import com.google.zxing.RGBLuminanceSource -import com.google.zxing.common.GlobalHistogramBinarizer -import com.google.zxing.qrcode.QRCodeReader -import com.google.zxing.qrcode.QRCodeWriter -import java.util.EnumMap - -/** - * 描述:解析二维码图片 - */ -object QRCodeDecoder { - val HINTS: MutableMap = EnumMap(DecodeHintType::class.java) - - /** - * create qrcode using zxing - */ - fun createQRCode(text: String, size: Int = 800): Bitmap? { - try { - val hints = HashMap() - hints[EncodeHintType.CHARACTER_SET] = "utf-8" - val bitMatrix = QRCodeWriter().encode( - text, - BarcodeFormat.QR_CODE, size, size, hints - ) - val pixels = IntArray(size * size) - for (y in 0 until size) { - for (x in 0 until size) { - if (bitMatrix.get(x, y)) { - pixels[y * size + x] = 0xff000000.toInt() - } else { - pixels[y * size + x] = 0xffffffff.toInt() - } - - } - } - val bitmap = Bitmap.createBitmap( - size, size, - Bitmap.Config.ARGB_8888 - ) - bitmap.setPixels(pixels, 0, size, 0, 0, size, size) - return bitmap - } catch (e: Exception) { - e.printStackTrace() - return null - } - } - - /** - * 同步解析本地图片二维码。该方法是耗时操作,请在子线程中调用。 - * - * @param picturePath 要解析的二维码图片本地路径 - * @return 返回二维码图片里的内容 或 null - */ - fun syncDecodeQRCode(picturePath: String): String? { - return syncDecodeQRCode(getDecodeAbleBitmap(picturePath)) - } - - /** - * 同步解析bitmap二维码。该方法是耗时操作,请在子线程中调用。 - * - * @param bitmap 要解析的二维码图片 - * @return 返回二维码图片里的内容 或 null - */ - fun syncDecodeQRCode(bitmap: Bitmap?): String? { - if (bitmap == null) { - return null - } - var source: RGBLuminanceSource? = null - try { - val width = bitmap.width - val height = bitmap.height - val pixels = IntArray(width * height) - bitmap.getPixels(pixels, 0, width, 0, 0, width, height) - source = RGBLuminanceSource(width, height, pixels) - val qrReader = QRCodeReader() - try { - val result = try { - qrReader.decode( - BinaryBitmap(GlobalHistogramBinarizer(source)), - mapOf(DecodeHintType.TRY_HARDER to true) - ) - } catch (e: NotFoundException) { - qrReader.decode( - BinaryBitmap(GlobalHistogramBinarizer(source.invert())), - mapOf(DecodeHintType.TRY_HARDER to true) - ) - } - return result.text - } catch (e: Exception) { - e.printStackTrace() - } - } catch (e: Exception) { - e.printStackTrace() - } - - return null - } - - /** - * 将本地图片文件转换成可解码二维码的 Bitmap。为了避免图片太大,这里对图片进行了压缩。感谢 https://github.com/devilsen 提的 PR - * - * @param picturePath 本地图片文件路径 - * @return - */ - private fun getDecodeAbleBitmap(picturePath: String): Bitmap? { - return try { - val options = BitmapFactory.Options() - options.inJustDecodeBounds = true - BitmapFactory.decodeFile(picturePath, options) - var sampleSize = options.outHeight / 400 - if (sampleSize <= 0) { - sampleSize = 1 - } - options.inSampleSize = sampleSize - options.inJustDecodeBounds = false - BitmapFactory.decodeFile(picturePath, options) - } catch (e: Exception) { - null - } - } - - init { - val allFormats: List = arrayListOf( - BarcodeFormat.AZTEC, - BarcodeFormat.CODABAR, - BarcodeFormat.CODE_39, - BarcodeFormat.CODE_93, - BarcodeFormat.CODE_128, - BarcodeFormat.DATA_MATRIX, - BarcodeFormat.EAN_8, - BarcodeFormat.EAN_13, - BarcodeFormat.ITF, - BarcodeFormat.MAXICODE, - BarcodeFormat.PDF_417, - BarcodeFormat.QR_CODE, - BarcodeFormat.RSS_14, - BarcodeFormat.RSS_EXPANDED, - BarcodeFormat.UPC_A, - BarcodeFormat.UPC_E, - BarcodeFormat.UPC_EAN_EXTENSION - ) - HINTS[DecodeHintType.TRY_HARDER] = BarcodeFormat.QR_CODE - HINTS[DecodeHintType.POSSIBLE_FORMATS] = allFormats - HINTS[DecodeHintType.CHARACTER_SET] = "utf-8" - } -} diff --git a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/util/SettingsManager.kt b/V2rayNG/app/src/main/kotlin/com/v2ray/ang/util/SettingsManager.kt deleted file mode 100644 index 80aecf18..00000000 --- a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/util/SettingsManager.kt +++ /dev/null @@ -1,159 +0,0 @@ -package com.v2ray.ang.util - -import android.content.Context -import android.text.TextUtils - -import com.v2ray.ang.AppConfig -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.RulesetItem -import com.v2ray.ang.dto.ServerConfig -import com.v2ray.ang.util.MmkvManager.decodeProfileConfig -import com.v2ray.ang.util.MmkvManager.decodeServerConfig -import com.v2ray.ang.util.MmkvManager.decodeServerList -import com.v2ray.ang.util.MmkvManager.settingsStorage -import com.v2ray.ang.util.Utils.parseInt -import java.util.Collections - -object SettingsManager { - - fun initRoutingRulesets(context: Context) { - val exist = MmkvManager.decodeRoutingRulesets() - if (exist.isNullOrEmpty()) { - val rulesetList = getPresetRoutingRulesets(context) - MmkvManager.encodeRoutingRulesets(rulesetList) - } - } - - private fun getPresetRoutingRulesets(context: Context, index: Int = 0): MutableList? { - val fileName = when (index) { - 0 -> "custom_routing_white" - 1 -> "custom_routing_black" - 2 -> "custom_routing_global" - else -> "custom_routing_white" - } - val assets = Utils.readTextFromAssets(context, fileName) - if (TextUtils.isEmpty(assets)) { - return null - } - - return JsonUtil.fromJson(assets, Array::class.java).toMutableList() - } - - fun resetRoutingRulesets(context: Context, index: Int) { - val rulesetList = getPresetRoutingRulesets(context, index) ?: return - resetRoutingRulesetsCommon(rulesetList) - } - - fun resetRoutingRulesetsFromClipboard(content: String?): Boolean { - if (content.isNullOrEmpty()) { - return false - } - - try { - val rulesetList = JsonUtil.fromJson(content, Array::class.java).toMutableList() - if (rulesetList.isNullOrEmpty()) { - return false - } - - resetRoutingRulesetsCommon(rulesetList) - return true - } catch (e: Exception) { - e.printStackTrace() - return false - } - } - - private fun resetRoutingRulesetsCommon(rulesetList: MutableList) { - val rulesetNew: MutableList = mutableListOf() - MmkvManager.decodeRoutingRulesets()?.forEach { key -> - if (key.looked == true) { - rulesetNew.add(key) - } - } - - rulesetNew.addAll(rulesetList) - MmkvManager.encodeRoutingRulesets(rulesetNew) - } - - fun getRoutingRuleset(index: Int): RulesetItem? { - if (index < 0) return null - - val rulesetList = MmkvManager.decodeRoutingRulesets() - if (rulesetList.isNullOrEmpty()) return null - - return rulesetList[index] - } - - fun saveRoutingRuleset(index: Int, ruleset: RulesetItem?) { - if (ruleset == null) return - - val rulesetList = MmkvManager.decodeRoutingRulesets() - if (rulesetList.isNullOrEmpty()) return - - if (index < 0 || index >= rulesetList.count()) { - rulesetList.add(ruleset) - } else { - rulesetList[index] = ruleset - } - MmkvManager.encodeRoutingRulesets(rulesetList) - } - - fun removeRoutingRuleset(index: Int) { - if (index < 0) return - - val rulesetList = MmkvManager.decodeRoutingRulesets() - if (rulesetList.isNullOrEmpty()) return - - rulesetList.removeAt(index) - MmkvManager.encodeRoutingRulesets(rulesetList) - } - - fun routingRulesetsBypassLan(): Boolean { - 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 - } - - fun swapRoutingRuleset(fromPosition: Int, toPosition: Int) { - val rulesetList = MmkvManager.decodeRoutingRulesets() - if (rulesetList.isNullOrEmpty()) return - - Collections.swap(rulesetList, fromPosition, toPosition) - MmkvManager.encodeRoutingRulesets(rulesetList) - } - - fun swapSubscriptions(fromPosition: Int, toPosition: Int) { - val subsList = MmkvManager.decodeSubsList() - if (subsList.isNullOrEmpty()) return - - Collections.swap(subsList, fromPosition, toPosition) - MmkvManager.encodeSubsList(subsList) - } - - fun getServerViaRemarks(remarks: String?): ServerConfig? { - if (remarks == null) { - return null - } - val serverList = decodeServerList() - for (guid in serverList) { - val profile = decodeProfileConfig(guid) - if (profile != null && profile.remarks == remarks) { - return decodeServerConfig(guid) - } - } - return null - } - - fun getSocksPort(): Int { - return parseInt(settingsStorage?.decodeString(AppConfig.PREF_SOCKS_PORT), AppConfig.PORT_SOCKS.toInt()) - } - - fun getHttpPort(): Int { - return parseInt(settingsStorage?.decodeString(AppConfig.PREF_HTTP_PORT), AppConfig.PORT_HTTP.toInt()) - } - -} diff --git a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/util/Utils.kt b/V2rayNG/app/src/main/kotlin/com/v2ray/ang/util/Utils.kt deleted file mode 100644 index 687fac9b..00000000 --- a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/util/Utils.kt +++ /dev/null @@ -1,469 +0,0 @@ -package com.v2ray.ang.util - -import android.content.ClipData -import android.content.ClipboardManager -import android.content.Context -import android.content.Intent -import android.content.pm.PackageManager -import android.content.res.Configuration.UI_MODE_NIGHT_MASK -import android.content.res.Configuration.UI_MODE_NIGHT_NO -import android.net.Uri -import android.os.Build -import android.os.LocaleList -import android.provider.Settings -import android.text.Editable -import android.util.Base64 -import android.util.Log -import android.util.Patterns -import android.webkit.URLUtil -import androidx.appcompat.app.AppCompatDelegate -import com.v2ray.ang.AppConfig -import com.v2ray.ang.AppConfig.ANG_PACKAGE -import com.v2ray.ang.AppConfig.LOOPBACK -import com.v2ray.ang.BuildConfig -import com.v2ray.ang.R -import com.v2ray.ang.extension.toast -import com.v2ray.ang.service.V2RayServiceManager -import com.v2ray.ang.util.MmkvManager.settingsStorage -import java.io.IOException -import java.net.* -import java.util.* - -object Utils { - - /** - * convert string to editalbe for kotlin - * - * @param text - * @return - */ - fun getEditable(text: String?): Editable { - return Editable.Factory.getInstance().newEditable(text.orEmpty()) - } - - /** - * find value in array position - */ - fun arrayFind(array: Array, value: String): Int { - for (i in array.indices) { - if (array[i] == value) { - return i - } - } - return -1 - } - - /** - * parseInt - */ - fun parseInt(str: String): Int { - return parseInt(str, 0) - } - - fun parseInt(str: String?, default: Int): Int { - return str?.toIntOrNull() ?: default - } - - - /** - * get text from clipboard - */ - fun getClipboard(context: Context): String { - return try { - val cmb = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - cmb.primaryClip?.getItemAt(0)?.text.toString() - } catch (e: Exception) { - e.printStackTrace() - "" - } - } - - /** - * set text to clipboard - */ - fun setClipboard(context: Context, content: String) { - try { - val cmb = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - val clipData = ClipData.newPlainText(null, content) - cmb.setPrimaryClip(clipData) - } catch (e: Exception) { - e.printStackTrace() - } - } - - /** - * base64 decode - */ - fun decode(text: String?): String { - return tryDecodeBase64(text) ?: text?.trimEnd('=')?.let { tryDecodeBase64(it) }.orEmpty() - } - - - fun tryDecodeBase64(text: String?): String? { - try { - return Base64.decode(text, Base64.NO_WRAP).toString(Charsets.UTF_8) - } catch (e: Exception) { - Log.i(ANG_PACKAGE, "Parse base64 standard failed $e") - } - try { - return Base64.decode(text, Base64.NO_WRAP.or(Base64.URL_SAFE)).toString(Charsets.UTF_8) - } catch (e: Exception) { - Log.i(ANG_PACKAGE, "Parse base64 url safe failed $e") - } - return null - } - - /** - * base64 encode - */ - fun encode(text: String): String { - return try { - Base64.encodeToString(text.toByteArray(Charsets.UTF_8), Base64.NO_WRAP) - } catch (e: Exception) { - e.printStackTrace() - "" - } - } - - /** - * get remote dns servers from preference - */ - fun getRemoteDnsServers(): List { - val remoteDns = settingsStorage?.decodeString(AppConfig.PREF_REMOTE_DNS) ?: AppConfig.DNS_PROXY - val ret = remoteDns.split(",").filter { isPureIpAddress(it) || isCoreDNSAddress(it) } - if (ret.isEmpty()) { - return listOf(AppConfig.DNS_PROXY) - } - return ret - } - - fun getVpnDnsServers(): List { - val vpnDns = settingsStorage?.decodeString(AppConfig.PREF_VPN_DNS) ?: AppConfig.DNS_VPN - return vpnDns.split(",").filter { isPureIpAddress(it) } - // allow empty, in that case dns will use system default - } - - /** - * get remote dns servers from preference - */ - fun getDomesticDnsServers(): List { - val domesticDns = settingsStorage?.decodeString(AppConfig.PREF_DOMESTIC_DNS) ?: AppConfig.DNS_DIRECT - val ret = domesticDns.split(",").filter { isPureIpAddress(it) || isCoreDNSAddress(it) } - if (ret.isEmpty()) { - return listOf(AppConfig.DNS_DIRECT) - } - return ret - } - - /** - * is ip address - */ - fun isIpAddress(value: String): Boolean { - try { - var addr = value - if (addr.isEmpty() || addr.isBlank()) { - return false - } - //CIDR - if (addr.indexOf("/") > 0) { - val arr = addr.split("/") - if (arr.count() == 2 && Integer.parseInt(arr[1]) > -1) { - addr = arr[0] - } - } - - // "::ffff:192.168.173.22" - // "[::ffff:192.168.173.22]:80" - if (addr.startsWith("::ffff:") && '.' in addr) { - addr = addr.drop(7) - } else if (addr.startsWith("[::ffff:") && '.' in addr) { - addr = addr.drop(8).replace("]", "") - } - - // addr = addr.toLowerCase() - val octets = addr.split('.').toTypedArray() - if (octets.size == 4) { - if (octets[3].indexOf(":") > 0) { - addr = addr.substring(0, addr.indexOf(":")) - } - return isIpv4Address(addr) - } - - // Ipv6addr [2001:abc::123]:8080 - return isIpv6Address(addr) - } catch (e: Exception) { - e.printStackTrace() - return false - } - } - - fun isPureIpAddress(value: String): Boolean { - return isIpv4Address(value) || isIpv6Address(value) - } - - fun isIpv4Address(value: String): Boolean { - val regV4 = - Regex("^([01]?[0-9]?[0-9]|2[0-4][0-9]|25[0-5])\\.([01]?[0-9]?[0-9]|2[0-4][0-9]|25[0-5])\\.([01]?[0-9]?[0-9]|2[0-4][0-9]|25[0-5])\\.([01]?[0-9]?[0-9]|2[0-4][0-9]|25[0-5])$") - return regV4.matches(value) - } - - fun isIpv6Address(value: String): Boolean { - var addr = value - if (addr.indexOf("[") == 0 && addr.lastIndexOf("]") > 0) { - addr = addr.drop(1) - addr = addr.dropLast(addr.count() - addr.lastIndexOf("]")) - } - val regV6 = - Regex("^((?:[0-9A-Fa-f]{1,4}))?((?::[0-9A-Fa-f]{1,4}))*::((?:[0-9A-Fa-f]{1,4}))?((?::[0-9A-Fa-f]{1,4}))*|((?:[0-9A-Fa-f]{1,4}))((?::[0-9A-Fa-f]{1,4})){7}$") - return regV6.matches(addr) - } - - private fun isCoreDNSAddress(s: String): Boolean { - return s.startsWith("https") || s.startsWith("tcp") || s.startsWith("quic") || s == "localhost" - } - - /** - * is valid url - */ - fun isValidUrl(value: String?): Boolean { - try { - if (value.isNullOrEmpty()) { - return false - } - if (Patterns.WEB_URL.matcher(value).matches() - || Patterns.DOMAIN_NAME.matcher(value).matches() - || URLUtil.isValidUrl(value) - ) { - return true - } - } catch (e: Exception) { - e.printStackTrace() - return false - } - return false - } - - fun startVServiceFromToggle(context: Context): Boolean { - if (MmkvManager.getSelectServer().isNullOrEmpty()) { - context.toast(R.string.app_tile_first_use) - return false - } - V2RayServiceManager.startV2Ray(context) - return true - } - - /** - * stopVService - */ - fun stopVService(context: Context) { - context.toast(R.string.toast_services_stop) - MessageUtil.sendMsg2Service(context, AppConfig.MSG_STATE_STOP, "") - } - - fun openUri(context: Context, uriString: String) { - val uri = Uri.parse(uriString) - context.startActivity(Intent(Intent.ACTION_VIEW, uri)) - } - - /** - * uuid - */ - fun getUuid(): String { - return try { - UUID.randomUUID().toString().replace("-", "") - } catch (e: Exception) { - e.printStackTrace() - "" - } - } - - fun urlDecode(url: String): String { - return try { - URLDecoder.decode(url, Charsets.UTF_8.toString()) - } catch (e: Exception) { - e.printStackTrace() - url - } - } - - fun urlEncode(url: String): String { - return try { - URLEncoder.encode(url, Charsets.UTF_8.toString()) - } catch (e: Exception) { - e.printStackTrace() - url - } - } - - - /** - * readTextFromAssets - */ - fun readTextFromAssets(context: Context?, fileName: String): String { - if (context == null) { - return "" - } - val content = context.assets.open(fileName).bufferedReader().use { - it.readText() - } - return content - } - - fun userAssetPath(context: Context?): String { - if (context == null) - return "" - val extDir = context.getExternalFilesDir(AppConfig.DIR_ASSETS) - ?: return context.getDir(AppConfig.DIR_ASSETS, 0).absolutePath - return extDir.absolutePath - } - - fun backupPath(context: Context?): String { - if (context == null) - return "" - val extDir = context.getExternalFilesDir(AppConfig.DIR_BACKUPS) - ?: return context.getDir(AppConfig.DIR_BACKUPS, 0).absolutePath - return extDir.absolutePath - } - - fun getDeviceIdForXUDPBaseKey(): String { - val androidId = Settings.Secure.ANDROID_ID.toByteArray(Charsets.UTF_8) - return Base64.encodeToString(androidId.copyOf(32), Base64.NO_PADDING.or(Base64.URL_SAFE)) - } - - fun getUrlContext(url: String, timeout: Int): String { - var result: String - var conn: HttpURLConnection? = null - - try { - conn = URL(url).openConnection() as HttpURLConnection - conn.connectTimeout = timeout - conn.readTimeout = timeout - conn.setRequestProperty("Connection", "close") - conn.instanceFollowRedirects = false - conn.useCaches = false - //val code = conn.responseCode - result = conn.inputStream.bufferedReader().readText() - } catch (e: Exception) { - result = "" - } finally { - conn?.disconnect() - } - return result - } - - @Throws(IOException::class) - fun getUrlContentWithCustomUserAgent(urlStr: String?, timeout: Int = 30000, httpPort: Int = 0): String { - val url = URL(urlStr) - val conn = if (httpPort == 0) { - url.openConnection() - } else { - url.openConnection( - Proxy( - Proxy.Type.HTTP, - InetSocketAddress(LOOPBACK, httpPort) - ) - ) - } - conn.connectTimeout = timeout - conn.readTimeout = timeout - conn.setRequestProperty("Connection", "close") - conn.setRequestProperty("User-agent", "v2rayNG/${BuildConfig.VERSION_NAME}") - url.userInfo?.let { - conn.setRequestProperty( - "Authorization", - "Basic ${encode(urlDecode(it))}" - ) - } - conn.useCaches = false - return conn.inputStream.use { - it.bufferedReader().readText() - } - } - - fun getDarkModeStatus(context: Context): Boolean { - return context.resources.configuration.uiMode and UI_MODE_NIGHT_MASK != UI_MODE_NIGHT_NO - } - - - fun setNightMode(context: Context) { - when (settingsStorage?.decodeString(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) - } - } - - fun getIpv6Address(address: String?): String { - if (address == null) { - return "" - } - return if (isIpv6Address(address) && !address.contains('[') && !address.contains(']')) { - String.format("[%s]", address) - } else { - address - } - } - - fun getLocale(): Locale { - val lang = settingsStorage?.decodeString(AppConfig.PREF_LANGUAGE) ?: "auto" - return when (lang) { - "auto" -> getSysLocale() - "en" -> Locale.ENGLISH - "zh-rCN" -> Locale.CHINA - "zh-rTW" -> Locale.TRADITIONAL_CHINESE - "vi" -> Locale("vi") - "ru" -> Locale("ru") - "fa" -> Locale("fa") - "bn" -> Locale("bn") - else -> getSysLocale() - } - } - - - private fun getSysLocale(): Locale = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - LocaleList.getDefault()[0] - } else { - Locale.getDefault() - } - - fun fixIllegalUrl(str: String): String { - return str - .replace(" ", "%20") - .replace("|", "%7C") - } - - fun removeWhiteSpace(str: String?): String? { - return str?.replace(" ", "") - } - - fun idnToASCII(str: String): String { - val url = URL(str) - return URL(url.protocol, IDN.toASCII(url.host, IDN.ALLOW_UNASSIGNED), url.port, url.file) - .toExternalForm() - } - - fun isTv(context: Context): Boolean = - context.packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK) - - fun getDelayTestUrl(second: Boolean = false): String { - return if (second) { - AppConfig.DelayTestUrl2 - } else { - settingsStorage.decodeString(AppConfig.PREF_DELAY_TEST_URL) ?: AppConfig.DelayTestUrl - } - } - - fun findFreePort(ports: List): Int { - for (port in ports) { - try { - return ServerSocket(port).use { it.localPort } - } catch (ex: IOException) { - continue // try next port - } - } - - // if the program gets here, no port in the range was found - throw IOException("no free port found") - } -} - diff --git a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/util/V2rayConfigUtil.kt b/V2rayNG/app/src/main/kotlin/com/v2ray/ang/util/V2rayConfigUtil.kt deleted file mode 100644 index 1e1b4504..00000000 --- a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/util/V2rayConfigUtil.kt +++ /dev/null @@ -1,597 +0,0 @@ -package com.v2ray.ang.util - -import android.content.Context -import android.text.TextUtils -import android.util.Log - -import com.v2ray.ang.AppConfig -import com.v2ray.ang.AppConfig.ANG_PACKAGE -import com.v2ray.ang.AppConfig.GEOIP_CN -import com.v2ray.ang.AppConfig.GEOSITE_CN -import com.v2ray.ang.AppConfig.LOOPBACK -import com.v2ray.ang.AppConfig.GEOSITE_PRIVATE -import com.v2ray.ang.AppConfig.PROTOCOL_FREEDOM -import com.v2ray.ang.AppConfig.TAG_BLOCKED -import com.v2ray.ang.AppConfig.TAG_DIRECT -import com.v2ray.ang.AppConfig.TAG_FRAGMENT -import com.v2ray.ang.AppConfig.TAG_PROXY -import com.v2ray.ang.AppConfig.WIREGUARD_LOCAL_ADDRESS_V4 -import com.v2ray.ang.AppConfig.WIREGUARD_LOCAL_ADDRESS_V6 -import com.v2ray.ang.dto.ConfigResult -import com.v2ray.ang.dto.EConfigType -import com.v2ray.ang.dto.RulesetItem -import com.v2ray.ang.dto.ServerConfig -import com.v2ray.ang.dto.V2rayConfig -import com.v2ray.ang.dto.V2rayConfig.Companion.DEFAULT_NETWORK -import com.v2ray.ang.dto.V2rayConfig.Companion.HTTP -import com.v2ray.ang.dto.V2rayConfig.RoutingBean.RulesBean -import com.v2ray.ang.util.MmkvManager.settingsStorage - -object V2rayConfigUtil { - - fun getV2rayConfig(context: Context, guid: String): ConfigResult { - try { - val config = MmkvManager.decodeServerConfig(guid) ?: return ConfigResult(false) - if (config.configType == EConfigType.CUSTOM) { - val raw = MmkvManager.decodeServerRaw(guid) - val customConfig = if (raw.isNullOrBlank()) { - config.fullConfig?.toPrettyPrinting() ?: return ConfigResult(false) - } else { - raw - } - val domainPort = config.getProxyOutbound()?.getServerAddressAndPort() - return ConfigResult(true, guid, customConfig, domainPort) - } - - val result = getV2rayNonCustomConfig(context, config) - //Log.d(ANG_PACKAGE, result.content) - result.guid = guid - return result - } catch (e: Exception) { - e.printStackTrace() - return ConfigResult(false) - } - } - - private fun getV2rayNonCustomConfig(context: Context, config: ServerConfig): ConfigResult { - val result = ConfigResult(false) - - val outbound = config.getProxyOutbound() ?: return result - val address = outbound.getServerAddress() ?: return result - if (!Utils.isIpAddress(address)) { - if (!Utils.isValidUrl(address)) { - Log.d(ANG_PACKAGE, "$address is an invalid ip or domain") - return result - } - } - - //取得默认配置 - val assets = Utils.readTextFromAssets(context, "v2ray_config.json") - if (TextUtils.isEmpty(assets)) { - return result - } - val v2rayConfig = JsonUtil.fromJson(assets, V2rayConfig::class.java) ?: return result - v2rayConfig.log.loglevel = settingsStorage?.decodeString(AppConfig.PREF_LOGLEVEL) ?: "warning" - v2rayConfig.remarks = config.remarks - - inbounds(v2rayConfig) - - val isPlugin = outbound.protocol.equals(EConfigType.HYSTERIA2.name, true) - val retOut = outbounds(v2rayConfig, outbound, isPlugin) - - val retMore = moreOutbounds(v2rayConfig, config.subscriptionId, isPlugin) - - routing(v2rayConfig) - - fakedns(v2rayConfig) - - dns(v2rayConfig) - - if (settingsStorage?.decodeBool(AppConfig.PREF_LOCAL_DNS_ENABLED) == true) { - customLocalDns(v2rayConfig) - } - if (settingsStorage?.decodeBool(AppConfig.PREF_SPEED_ENABLED) != true) { - v2rayConfig.stats = null - v2rayConfig.policy = null - } - - result.status = true - result.content = v2rayConfig.toPrettyPrinting() - result.domainPort = if (retMore.first) retMore.second else retOut.second - return result - } - - private fun inbounds(v2rayConfig: V2rayConfig): Boolean { - try { - val socksPort = SettingsManager.getSocksPort() - val httpPort = SettingsManager.getHttpPort() - - v2rayConfig.inbounds.forEach { curInbound -> - if (settingsStorage?.decodeBool(AppConfig.PREF_PROXY_SHARING) != true) { - //bind all inbounds to localhost if the user requests - curInbound.listen = LOOPBACK - } - } - v2rayConfig.inbounds[0].port = socksPort - val fakedns = settingsStorage?.decodeBool(AppConfig.PREF_FAKE_DNS_ENABLED) - ?: false - val sniffAllTlsAndHttp = - settingsStorage?.decodeBool(AppConfig.PREF_SNIFFING_ENABLED, true) - ?: true - v2rayConfig.inbounds[0].sniffing?.enabled = fakedns || sniffAllTlsAndHttp - v2rayConfig.inbounds[0].sniffing?.routeOnly = - settingsStorage?.decodeBool(AppConfig.PREF_ROUTE_ONLY_ENABLED, false) - if (!sniffAllTlsAndHttp) { - v2rayConfig.inbounds[0].sniffing?.destOverride?.clear() - } - if (fakedns) { - v2rayConfig.inbounds[0].sniffing?.destOverride?.add("fakedns") - } - - v2rayConfig.inbounds[1].port = httpPort - -// if (httpPort > 0) { -// val httpCopy = v2rayConfig.inbounds[0].copy() -// httpCopy.port = httpPort -// httpCopy.protocol = "http" -// v2rayConfig.inbounds.add(httpCopy) -// } - } catch (e: Exception) { - e.printStackTrace() - return false - } - return true - } - - private fun outbounds(v2rayConfig: V2rayConfig, outbound: V2rayConfig.OutboundBean, isPlugin: Boolean): Pair { - if (isPlugin) { - val socksPort = Utils.findFreePort(listOf(100 + SettingsManager.getSocksPort(), 0)) - val outboundNew = V2rayConfig.OutboundBean( - mux = null, - protocol = EConfigType.SOCKS.name.lowercase(), - settings = V2rayConfig.OutboundBean.OutSettingsBean( - servers = listOf( - V2rayConfig.OutboundBean.OutSettingsBean.ServersBean( - address = LOOPBACK, - port = socksPort - ) - ) - ) - ) - if (v2rayConfig.outbounds.isNotEmpty()) { - v2rayConfig.outbounds[0] = outboundNew - } else { - v2rayConfig.outbounds.add(outboundNew) - } - return Pair(true, outboundNew.getServerAddressAndPort()) - } - - val ret = updateOutboundWithGlobalSettings(outbound) - if (!ret) return Pair(false, "") - - if (v2rayConfig.outbounds.isNotEmpty()) { - v2rayConfig.outbounds[0] = outbound - } else { - v2rayConfig.outbounds.add(outbound) - } - - updateOutboundFragment(v2rayConfig) - return Pair(true, outbound.getServerAddressAndPort()) - } - - private fun fakedns(v2rayConfig: V2rayConfig) { - if (settingsStorage?.decodeBool(AppConfig.PREF_LOCAL_DNS_ENABLED) == true - && settingsStorage?.decodeBool(AppConfig.PREF_FAKE_DNS_ENABLED) == true - ) { - v2rayConfig.fakedns = listOf(V2rayConfig.FakednsBean()) - } - } - - private fun routing(v2rayConfig: V2rayConfig): Boolean { - try { - - v2rayConfig.routing.domainStrategy = settingsStorage?.decodeString(AppConfig.PREF_ROUTING_DOMAIN_STRATEGY) ?: "IPIfNonMatch" - - val rulesetItems = MmkvManager.decodeRoutingRulesets() - rulesetItems?.forEach { key -> - routingUserRule(key, v2rayConfig) - } - } catch (e: Exception) { - e.printStackTrace() - return false - } - return true - } - - private fun routingUserRule(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) { - e.printStackTrace() - } - } - - private fun userRule2Domain(tag: String): ArrayList { - val domain = ArrayList() - - val rulesetItems = MmkvManager.decodeRoutingRulesets() - rulesetItems?.forEach { key -> - if (key != null && key.enabled && key.outboundTag == tag && !key.domain.isNullOrEmpty()) { - key.domain?.forEach { - if (it != GEOSITE_PRIVATE - && (it.startsWith("geosite:") || it.startsWith("domain:")) - ) { - domain.add(it) - } - } - } - } - - return domain - } - - private fun customLocalDns(v2rayConfig: V2rayConfig): Boolean { - try { - if (settingsStorage?.decodeBool(AppConfig.PREF_FAKE_DNS_ENABLED) == true) { - val geositeCn = arrayListOf(GEOSITE_CN) - val proxyDomain = userRule2Domain(TAG_PROXY) - val directDomain = userRule2Domain(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) - ) - ) - } - - // DNS inbound对象 - val remoteDns = Utils.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( - settingsStorage?.decodeString(AppConfig.PREF_LOCAL_DNS_PORT), - AppConfig.PORT_LOCAL_DNS.toInt() - ) - v2rayConfig.inbounds.add( - V2rayConfig.InboundBean( - tag = "dns-in", - port = localDnsPort, - listen = LOOPBACK, - protocol = "dokodemo-door", - settings = dnsInboundSettings, - sniffing = null - ) - ) - } - - // 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 - ) - ) - } - - // DNS routing tag - v2rayConfig.routing.rules.add( - 0, V2rayConfig.RoutingBean.RulesBean( - inboundTag = arrayListOf("dns-in"), - outboundTag = "dns-out", - domain = null - ) - ) - } catch (e: Exception) { - e.printStackTrace() - return false - } - return true - } - - private fun dns(v2rayConfig: V2rayConfig): Boolean { - try { - val hosts = mutableMapOf() - val servers = ArrayList() - - //remote Dns - val remoteDns = Utils.getRemoteDnsServers() - val proxyDomain = userRule2Domain(TAG_PROXY) - remoteDns.forEach { - servers.add(it) - } - if (proxyDomain.size > 0) { - servers.add( - V2rayConfig.DnsBean.ServersBean( - remoteDns.first(), - 53, - proxyDomain, - null - ) - ) - } - - // domestic DNS - val domesticDns = Utils.getDomesticDnsServers() - val directDomain = userRule2Domain(TAG_DIRECT) - val isCnRoutingMode = directDomain.contains(GEOSITE_CN) - val geoipCn = arrayListOf(GEOIP_CN) - if (directDomain.size > 0) { - servers.add( - V2rayConfig.DnsBean.ServersBean( - domesticDns.first(), - 53, - directDomain, - if (isCnRoutingMode) geoipCn else null - ) - ) - } - - if (Utils.isPureIpAddress(domesticDns.first())) { - v2rayConfig.routing.rules.add( - 0, V2rayConfig.RoutingBean.RulesBean( - outboundTag = TAG_DIRECT, - port = "53", - ip = arrayListOf(domesticDns.first()), - domain = null - ) - ) - } - - //block dns - val blkDomain = userRule2Domain(TAG_BLOCKED) - if (blkDomain.size > 0) { - hosts.putAll(blkDomain.map { it to LOOPBACK }) - } - - // hardcode googleapi rule to fix play store problems - hosts["domain:googleapis.cn"] = "googleapis.com" - - // hardcode popular Android Private DNS rule to fix localhost DNS problem - hosts["dns.pub"] = arrayListOf("1.12.12.12", "120.53.53.53") - hosts["dns.alidns.com"] = arrayListOf("223.5.5.5", "223.6.6.6", "2400:3200::1", "2400:3200:baba::1") - hosts["one.one.one.one"] = arrayListOf("1.1.1.1", "1.0.0.1", "2606:4700:4700::1111", "2606:4700:4700::1001") - hosts["dns.google"] = arrayListOf("8.8.8.8", "8.8.4.4", "2001:4860:4860::8888", "2001:4860:4860::8844") - - // DNS dns对象 - v2rayConfig.dns = V2rayConfig.DnsBean( - servers = servers, - hosts = hosts - ) - - // DNS routing - if (Utils.isPureIpAddress(remoteDns.first())) { - v2rayConfig.routing.rules.add( - 0, V2rayConfig.RoutingBean.RulesBean( - outboundTag = TAG_PROXY, - port = "53", - ip = arrayListOf(remoteDns.first()), - domain = null - ) - ) - } - } catch (e: Exception) { - e.printStackTrace() - return false - } - return true - } - - private fun updateOutboundWithGlobalSettings(outbound: V2rayConfig.OutboundBean): Boolean { - try { - var muxEnabled = settingsStorage?.decodeBool(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 (protocol.equals(EConfigType.VLESS.name, true) - && outbound.settings?.vnext?.get(0)?.users?.get(0)?.flow?.isNotEmpty() == true - ) { - muxEnabled = false - } - if (muxEnabled == true) { - outbound.mux?.enabled = true - outbound.mux?.concurrency = - settingsStorage?.decodeInt(AppConfig.PREF_MUX_CONCURRENCY) ?: 8 - outbound.mux?.xudpConcurrency = - settingsStorage?.decodeInt(AppConfig.PREF_MUX_XUDP_CONCURRENCY) ?: 8 - outbound.mux?.xudpProxyUDP443 = - settingsStorage?.decodeString(AppConfig.PREF_MUX_XUDP_QUIC) ?: "reject" - } else { - outbound.mux?.enabled = false - outbound.mux?.concurrency = -1 - } - - if (protocol.equals(EConfigType.WIREGUARD.name, true)) { - var localTunAddr = if (outbound.settings?.address == null) { - listOf(WIREGUARD_LOCAL_ADDRESS_V4, WIREGUARD_LOCAL_ADDRESS_V6) - } else { - outbound.settings?.address as List<*> - } - if (settingsStorage?.decodeBool(AppConfig.PREF_PREFER_IPV6) != true) { - localTunAddr = listOf(localTunAddr.first()) - } - outbound.settings?.address = localTunAddr - } - - if (outbound.streamSettings?.network == DEFAULT_NETWORK - && outbound.streamSettings?.tcpSettings?.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 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/53.0.2785.143 Safari/537.36","Mozilla/5.0 (iPhone; CPU iPhone OS 10_0_2 like Mac OS X) AppleWebKit/601.1 (KHTML, like Gecko) CriOS/53.0.2785.109 Mobile/14A456 Safari/601.1.46"],"Accept-Encoding":["gzip, deflate"],"Connection":["keep-alive"],"Pragma":"no-cache"}}""" - } - outbound.streamSettings?.tcpSettings?.header?.request = JsonUtil.fromJson( - requestString, - V2rayConfig.OutboundBean.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) { - e.printStackTrace() - return false - } - return true - } - - private fun updateOutboundFragment(v2rayConfig: V2rayConfig): Boolean { - try { - if (settingsStorage?.decodeBool(AppConfig.PREF_FRAGMENT_ENABLED, false) == false) { - return true - } - if (v2rayConfig.outbounds[0].streamSettings?.security != V2rayConfig.TLS - && v2rayConfig.outbounds[0].streamSettings?.security != V2rayConfig.REALITY - ) { - return true - } - - val fragmentOutbound = - V2rayConfig.OutboundBean( - protocol = PROTOCOL_FREEDOM, - tag = TAG_FRAGMENT, - mux = null - ) - - var packets = - settingsStorage?.decodeString(AppConfig.PREF_FRAGMENT_PACKETS) ?: "tlshello" - if (v2rayConfig.outbounds[0].streamSettings?.security == V2rayConfig.REALITY - && packets == "tlshello" - ) { - packets = "1-3" - } else if (v2rayConfig.outbounds[0].streamSettings?.security == V2rayConfig.TLS - && packets != "tlshello" - ) { - packets = "tlshello" - } - - fragmentOutbound.settings = V2rayConfig.OutboundBean.OutSettingsBean( - fragment = V2rayConfig.OutboundBean.OutSettingsBean.FragmentBean( - packets = packets, - length = settingsStorage?.decodeString(AppConfig.PREF_FRAGMENT_LENGTH) - ?: "50-100", - interval = settingsStorage?.decodeString(AppConfig.PREF_FRAGMENT_INTERVAL) - ?: "10-20" - ), - noises = listOf( - V2rayConfig.OutboundBean.OutSettingsBean.NoiseBean( - type = "rand", - packet = "100-200", - delay = "10-20", - ) - ), - ) - fragmentOutbound.streamSettings = V2rayConfig.OutboundBean.StreamSettingsBean( - sockopt = V2rayConfig.OutboundBean.StreamSettingsBean.SockoptBean( - TcpNoDelay = true, - mark = 255 - ) - ) - v2rayConfig.outbounds.add(fragmentOutbound) - - //proxy chain - v2rayConfig.outbounds[0].streamSettings?.sockopt = - V2rayConfig.OutboundBean.StreamSettingsBean.SockoptBean( - dialerProxy = TAG_FRAGMENT - ) - } catch (e: Exception) { - e.printStackTrace() - return false - } - return true - } - - private fun moreOutbounds(v2rayConfig: V2rayConfig, subscriptionId: String, isPlugin: Boolean): Pair { - val returnPair = Pair(false, "") - var domainPort: String = "" - - if (isPlugin) { - return returnPair - } - //fragment proxy - if (settingsStorage?.decodeBool(AppConfig.PREF_FRAGMENT_ENABLED, false) == true) { - return returnPair - } - - if (subscriptionId.isNullOrEmpty()) { - return returnPair - } - try { - val subItem = MmkvManager.decodeSubscription(subscriptionId) ?: return returnPair - - //current proxy - val outbound = v2rayConfig.outbounds[0] - - //Previous proxy - val prevNode = SettingsManager.getServerViaRemarks(subItem.prevProfile) - if (prevNode != null) { - val prevOutbound = prevNode.getProxyOutbound() - if (prevOutbound != null) { - updateOutboundWithGlobalSettings(prevOutbound) - prevOutbound.tag = TAG_PROXY + "2" - v2rayConfig.outbounds.add(prevOutbound) - outbound.streamSettings?.sockopt = - V2rayConfig.OutboundBean.StreamSettingsBean.SockoptBean( - dialerProxy = prevOutbound.tag - ) - domainPort = prevOutbound.getServerAddressAndPort() - } - } - - //Next proxy - val nextNode = SettingsManager.getServerViaRemarks(subItem.nextProfile) - if (nextNode != null) { - val nextOutbound = nextNode.getProxyOutbound() - if (nextOutbound != null) { - updateOutboundWithGlobalSettings(nextOutbound) - nextOutbound.tag = TAG_PROXY - v2rayConfig.outbounds.add(0, nextOutbound) - outbound.tag = TAG_PROXY + "1" - nextOutbound.streamSettings?.sockopt = - V2rayConfig.OutboundBean.StreamSettingsBean.SockoptBean( - dialerProxy = outbound.tag - ) - } - } - } catch (e: Exception) { - e.printStackTrace() - return returnPair - } - - if (domainPort.isNotEmpty()) { - return Pair(true, domainPort) - } - return returnPair - } -} \ No newline at end of file diff --git a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/util/fmt/Hysteria2Fmt.kt b/V2rayNG/app/src/main/kotlin/com/v2ray/ang/util/fmt/Hysteria2Fmt.kt deleted file mode 100644 index d0d7bae7..00000000 --- a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/util/fmt/Hysteria2Fmt.kt +++ /dev/null @@ -1,112 +0,0 @@ -package com.v2ray.ang.util.fmt - -import android.text.TextUtils -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.ServerConfig -import com.v2ray.ang.dto.V2rayConfig -import com.v2ray.ang.extension.idnHost -import com.v2ray.ang.util.MmkvManager.settingsStorage -import com.v2ray.ang.util.Utils -import java.net.URI - -object Hysteria2Fmt { - - fun parse(str: String): ServerConfig { - var allowInsecure = settingsStorage?.decodeBool(AppConfig.PREF_ALLOW_INSECURE) ?: false - val config = ServerConfig.create(EConfigType.HYSTERIA2) - - val uri = URI(Utils.fixIllegalUrl(str)) - config.remarks = Utils.urlDecode(uri.fragment.orEmpty()) - - val queryParam = uri.rawQuery.split("&") - .associate { it.split("=").let { (k, v) -> k to Utils.urlDecode(v) } } - - config.outboundBean?.streamSettings?.populateTlsSettings( - V2rayConfig.TLS, - if ((queryParam["insecure"].orEmpty()) == "1") true else allowInsecure, - queryParam["sni"] ?: uri.idnHost, - null, - queryParam["alpn"], - null, - null, - null - ) - - config.outboundBean?.settings?.servers?.get(0)?.let { server -> - server.address = uri.idnHost - server.port = uri.port - server.password = uri.userInfo - } - if (!queryParam["obfs-password"].isNullOrEmpty()) { - config.outboundBean?.settings?.obfsPassword = queryParam["obfs-password"] - } - - return config - } - - fun toUri(config: ServerConfig): String { - val outbound = config.getProxyOutbound() ?: return "" - val streamSetting = outbound.streamSettings ?: V2rayConfig.OutboundBean.StreamSettingsBean() - - val remark = "#" + Utils.urlEncode(config.remarks) - val dicQuery = HashMap() - dicQuery["security"] = streamSetting.security.ifEmpty { "none" } - streamSetting.tlsSettings?.let { tlsSetting -> - dicQuery["insecure"] = if (tlsSetting.allowInsecure) "1" else "0" - if (!TextUtils.isEmpty(tlsSetting.serverName)) { - dicQuery["sni"] = tlsSetting.serverName - } - if (!tlsSetting.alpn.isNullOrEmpty() && tlsSetting.alpn.isNotEmpty()) { - dicQuery["alpn"] = Utils.removeWhiteSpace(tlsSetting.alpn.joinToString(",")).orEmpty() - } - } - if (!outbound.settings?.obfsPassword.isNullOrEmpty()) { - dicQuery["obfs"] = "salamander" - dicQuery["obfs-password"] = outbound.settings?.obfsPassword ?: "" - } - - val query = "?" + dicQuery.toList().joinToString( - separator = "&", - transform = { it.first + "=" + it.second }) - - val url = String.format( - "%s@%s:%s", - outbound.getPassword(), - Utils.getIpv6Address(outbound.getServerAddress()), - outbound.getServerPort() - ) - return url + query + remark - } - - fun toNativeConfig(config: ServerConfig, socksPort: Int): Hysteria2Bean? { - val outbound = config.getProxyOutbound() ?: return null - val tls = outbound.streamSettings?.tlsSettings - val obfs = if (outbound.settings?.obfsPassword.isNullOrEmpty()) null else - Hysteria2Bean.ObfsBean( - type = "salamander", - salamander = Hysteria2Bean.ObfsBean.SalamanderBean( - password = outbound.settings?.obfsPassword - ) - ) - - val bean = Hysteria2Bean( - server = outbound.getServerAddressAndPort(), - auth = outbound.getPassword(), - obfs = obfs, - socks5 = Hysteria2Bean.Socks5Bean( - listen = "$LOOPBACK:${socksPort}", - ), - http = Hysteria2Bean.Socks5Bean( - listen = "$LOOPBACK:${socksPort}", - ), - tls = Hysteria2Bean.TlsBean( - sni = tls?.serverName ?: outbound.getServerAddress(), - insecure = tls?.allowInsecure - ) - ) - return bean - } -} \ No newline at end of file diff --git a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/util/fmt/ShadowsocksFmt.kt b/V2rayNG/app/src/main/kotlin/com/v2ray/ang/util/fmt/ShadowsocksFmt.kt deleted file mode 100644 index afd2d350..00000000 --- a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/util/fmt/ShadowsocksFmt.kt +++ /dev/null @@ -1,159 +0,0 @@ -package com.v2ray.ang.util.fmt - -import android.util.Log -import com.v2ray.ang.AppConfig -import com.v2ray.ang.dto.EConfigType -import com.v2ray.ang.dto.ServerConfig -import com.v2ray.ang.extension.idnHost -import com.v2ray.ang.util.Utils -import java.net.URI - -object ShadowsocksFmt { - fun parse(str: String): ServerConfig? { - val config = ServerConfig.create(EConfigType.SHADOWSOCKS) - if (!tryResolveResolveSip002(str, config)) { - 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) { - e.printStackTrace() - } - - 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.outboundBean?.settings?.servers?.get(0)?.let { server -> - server.address = match.groupValues[3].removeSurrounding("[", "]") - server.port = match.groupValues[4].toInt() - server.password = match.groupValues[2] - server.method = match.groupValues[1].lowercase() - } - } - return config - } - - fun toUri(config: ServerConfig): String { - val outbound = config.getProxyOutbound() ?: return "" - val remark = "#" + Utils.urlEncode(config.remarks) - val pw = - Utils.encode("${outbound.getSecurityEncryption()}:${outbound.getPassword()}") - val url = String.format( - "%s@%s:%s", - pw, - Utils.getIpv6Address(outbound.getServerAddress()), - outbound.getServerPort() - ) - return url + remark - } - - private fun tryResolveResolveSip002(str: String, config: ServerConfig): Boolean { - try { - val uri = URI(Utils.fixIllegalUrl(str)) - config.remarks = Utils.urlDecode(uri.fragment.orEmpty()) - - val method: String - val password: String - if (uri.userInfo.contains(":")) { - val arrUserInfo = uri.userInfo.split(":").map { it.trim() } - if (arrUserInfo.count() != 2) { - return false - } - method = arrUserInfo[0] - password = Utils.urlDecode(arrUserInfo[1]) - } else { - val base64Decode = Utils.decode(uri.userInfo) - val arrUserInfo = base64Decode.split(":").map { it.trim() } - if (arrUserInfo.count() < 2) { - return false - } - method = arrUserInfo[0] - password = base64Decode.substringAfter(":") - } - - val query = Utils.urlDecode(uri.query.orEmpty()) - if (query != "") { - val queryPairs = HashMap() - val pairs = query.split(";") - Log.d(AppConfig.ANG_PACKAGE, pairs.toString()) - for (pair in pairs) { - val idx = pair.indexOf("=") - if (idx == -1) { - queryPairs[Utils.urlDecode(pair)] = "" - } else { - queryPairs[Utils.urlDecode(pair.substring(0, idx))] = - Utils.urlDecode(pair.substring(idx + 1)) - } - } - Log.d(AppConfig.ANG_PACKAGE, queryPairs.toString()) - var sni: String? = "" - if (queryPairs["plugin"] == "obfs-local" && queryPairs["obfs"] == "http") { - sni = config.outboundBean?.streamSettings?.populateTransportSettings( - "tcp", - "http", - queryPairs["obfs-host"], - queryPairs["path"], - null, - null, - null, - null, - null, - null - ) - } else if (queryPairs["plugin"] == "v2ray-plugin") { - var network = "ws" - if (queryPairs["mode"] == "quic") { - network = "quic" - } - sni = config.outboundBean?.streamSettings?.populateTransportSettings( - network, - null, - queryPairs["host"], - queryPairs["path"], - null, - null, - null, - null, - null, - null - ) - } - if ("tls" in queryPairs) { - config.outboundBean?.streamSettings?.populateTlsSettings( - "tls", false, sni.orEmpty(), null, null, null, null, null - ) - } - - } - - config.outboundBean?.settings?.servers?.get(0)?.let { server -> - server.address = uri.idnHost - server.port = uri.port - server.password = password - server.method = method - } - return true - } catch (e: Exception) { - Log.d(AppConfig.ANG_PACKAGE, e.toString()) - return false - } - } - -} \ No newline at end of file diff --git a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/util/fmt/SocksFmt.kt b/V2rayNG/app/src/main/kotlin/com/v2ray/ang/util/fmt/SocksFmt.kt deleted file mode 100644 index 106541f8..00000000 --- a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/util/fmt/SocksFmt.kt +++ /dev/null @@ -1,69 +0,0 @@ -package com.v2ray.ang.util.fmt - -import com.v2ray.ang.dto.EConfigType -import com.v2ray.ang.dto.ServerConfig -import com.v2ray.ang.dto.V2rayConfig -import com.v2ray.ang.util.Utils - -object SocksFmt { - fun parse(str: String): ServerConfig? { - val config = ServerConfig.create(EConfigType.SOCKS) - var result = str.replace(EConfigType.SOCKS.protocolScheme, "") - val indexSplit = result.indexOf("#") - - if (indexSplit > 0) { - try { - config.remarks = - Utils.urlDecode(result.substring(indexSplit + 1, result.length)) - } catch (e: Exception) { - e.printStackTrace() - } - - result = result.substring(0, indexSplit) - } - - //part decode - val indexS = result.indexOf("@") - if (indexS > 0) { - result = Utils.decode(result.substring(0, indexS)) + result.substring( - indexS, - result.length - ) - } else { - result = Utils.decode(result) - } - - val legacyPattern = "^(.*):(.*)@(.+?):(\\d+?)$".toRegex() - val match = - legacyPattern.matchEntire(result) ?: return null - - config.outboundBean?.settings?.servers?.get(0)?.let { server -> - server.address = match.groupValues[3].removeSurrounding("[", "]") - server.port = match.groupValues[4].toInt() - val socksUsersBean = - V2rayConfig.OutboundBean.OutSettingsBean.ServersBean.SocksUsersBean() - socksUsersBean.user = match.groupValues[1] - socksUsersBean.pass = match.groupValues[2] - server.users = listOf(socksUsersBean) - } - - return config - } - - fun toUri(config: ServerConfig): String { - val outbound = config.getProxyOutbound() ?: return "" - val remark = "#" + Utils.urlEncode(config.remarks) - val pw = - if (outbound.settings?.servers?.get(0)?.users?.get(0)?.user != null) - "${outbound.settings?.servers?.get(0)?.users?.get(0)?.user}:${outbound.getPassword()}" - else - ":" - val url = String.format( - "%s@%s:%s", - Utils.encode(pw), - Utils.getIpv6Address(outbound.getServerAddress()), - outbound.getServerPort() - ) - return url + remark - } -} \ No newline at end of file diff --git a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/util/fmt/TrojanFmt.kt b/V2rayNG/app/src/main/kotlin/com/v2ray/ang/util/fmt/TrojanFmt.kt deleted file mode 100644 index db3a2a34..00000000 --- a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/util/fmt/TrojanFmt.kt +++ /dev/null @@ -1,173 +0,0 @@ -package com.v2ray.ang.util.fmt - -import android.text.TextUtils -import com.v2ray.ang.AppConfig -import com.v2ray.ang.dto.EConfigType -import com.v2ray.ang.dto.ServerConfig -import com.v2ray.ang.dto.V2rayConfig -import com.v2ray.ang.extension.idnHost -import com.v2ray.ang.util.MmkvManager.settingsStorage -import com.v2ray.ang.util.Utils -import java.net.URI - -object TrojanFmt { - - fun parse(str: String): ServerConfig { - var allowInsecure = settingsStorage?.decodeBool(AppConfig.PREF_ALLOW_INSECURE) ?: false - val config = ServerConfig.create(EConfigType.TROJAN) - - val uri = URI(Utils.fixIllegalUrl(str)) - config.remarks = Utils.urlDecode(uri.fragment.orEmpty()) - - var flow = "" - var fingerprint = config.outboundBean?.streamSettings?.tlsSettings?.fingerprint - if (uri.rawQuery.isNullOrEmpty()) { - config.outboundBean?.streamSettings?.populateTlsSettings( - V2rayConfig.TLS, - allowInsecure, - "", - fingerprint, - null, - null, - null, - null - ) - } else { - val queryParam = uri.rawQuery.split("&") - .associate { it.split("=").let { (k, v) -> k to Utils.urlDecode(v) } } - - val sni = config.outboundBean?.streamSettings?.populateTransportSettings( - queryParam["type"] ?: "tcp", - queryParam["headerType"], - queryParam["host"], - queryParam["path"], - queryParam["seed"], - queryParam["quicSecurity"], - queryParam["key"], - queryParam["mode"], - queryParam["serviceName"], - queryParam["authority"] - ) - fingerprint = queryParam["fp"].orEmpty() - allowInsecure = if ((queryParam["allowInsecure"].orEmpty()) == "1") true else allowInsecure - config.outboundBean?.streamSettings?.populateTlsSettings( - queryParam["security"] ?: V2rayConfig.TLS, - allowInsecure, - queryParam["sni"] ?: sni.orEmpty(), - fingerprint, - queryParam["alpn"], - null, - null, - null - ) - flow = queryParam["flow"].orEmpty() - } - config.outboundBean?.settings?.servers?.get(0)?.let { server -> - server.address = uri.idnHost - server.port = uri.port - server.password = uri.userInfo - server.flow = flow - } - - return config - } - - fun toUri(config: ServerConfig): String { - val outbound = config.getProxyOutbound() ?: return "" - val streamSetting = outbound.streamSettings ?: V2rayConfig.OutboundBean.StreamSettingsBean() - - val remark = "#" + Utils.urlEncode(config.remarks) - val dicQuery = HashMap() - config.outboundBean?.settings?.servers?.get(0)?.flow?.let { - if (!TextUtils.isEmpty(it)) { - dicQuery["flow"] = it - } - } - - dicQuery["security"] = streamSetting.security.ifEmpty { "none" } - (streamSetting.tlsSettings - ?: streamSetting.realitySettings)?.let { tlsSetting -> - if (!TextUtils.isEmpty(tlsSetting.serverName)) { - dicQuery["sni"] = tlsSetting.serverName - } - if (!tlsSetting.alpn.isNullOrEmpty() && tlsSetting.alpn.isNotEmpty()) { - dicQuery["alpn"] = - Utils.removeWhiteSpace(tlsSetting.alpn.joinToString(",")).orEmpty() - } - if (!TextUtils.isEmpty(tlsSetting.fingerprint)) { - dicQuery["fp"] = tlsSetting.fingerprint.orEmpty() - } - if (!TextUtils.isEmpty(tlsSetting.publicKey)) { - dicQuery["pbk"] = tlsSetting.publicKey.orEmpty() - } - if (!TextUtils.isEmpty(tlsSetting.shortId)) { - dicQuery["sid"] = tlsSetting.shortId.orEmpty() - } - if (!TextUtils.isEmpty(tlsSetting.spiderX)) { - dicQuery["spx"] = Utils.urlEncode(tlsSetting.spiderX.orEmpty()) - } - } - dicQuery["type"] = - streamSetting.network.ifEmpty { V2rayConfig.DEFAULT_NETWORK } - - outbound.getTransportSettingDetails()?.let { transportDetails -> - when (streamSetting.network) { - "tcp" -> { - dicQuery["headerType"] = transportDetails[0].ifEmpty { "none" } - if (!TextUtils.isEmpty(transportDetails[1])) { - dicQuery["host"] = Utils.urlEncode(transportDetails[1]) - } - } - - "kcp" -> { - dicQuery["headerType"] = transportDetails[0].ifEmpty { "none" } - if (!TextUtils.isEmpty(transportDetails[2])) { - dicQuery["seed"] = Utils.urlEncode(transportDetails[2]) - } - } - - "ws", "httpupgrade", "splithttp" -> { - if (!TextUtils.isEmpty(transportDetails[1])) { - dicQuery["host"] = Utils.urlEncode(transportDetails[1]) - } - if (!TextUtils.isEmpty(transportDetails[2])) { - dicQuery["path"] = Utils.urlEncode(transportDetails[2]) - } - } - - "http", "h2" -> { - dicQuery["type"] = "http" - if (!TextUtils.isEmpty(transportDetails[1])) { - dicQuery["host"] = Utils.urlEncode(transportDetails[1]) - } - if (!TextUtils.isEmpty(transportDetails[2])) { - dicQuery["path"] = Utils.urlEncode(transportDetails[2]) - } - } - - "quic" -> { - dicQuery["headerType"] = transportDetails[0].ifEmpty { "none" } - dicQuery["quicSecurity"] = Utils.urlEncode(transportDetails[1]) - dicQuery["key"] = Utils.urlEncode(transportDetails[2]) - } - - "grpc" -> { - dicQuery["mode"] = transportDetails[0] - dicQuery["authority"] = Utils.urlEncode(transportDetails[1]) - dicQuery["serviceName"] = Utils.urlEncode(transportDetails[2]) - } - } - } - val query = "?" + dicQuery.toList().joinToString( - separator = "&", - transform = { it.first + "=" + it.second }) - - val url = String.format( - "%s@%s:%s", - outbound.getPassword(), - Utils.getIpv6Address(outbound.getServerAddress()), - outbound.getServerPort() - ) - return url + query + remark - } -} \ No newline at end of file diff --git a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/util/fmt/VlessFmt.kt b/V2rayNG/app/src/main/kotlin/com/v2ray/ang/util/fmt/VlessFmt.kt deleted file mode 100644 index ea37d310..00000000 --- a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/util/fmt/VlessFmt.kt +++ /dev/null @@ -1,164 +0,0 @@ -package com.v2ray.ang.util.fmt - -import android.text.TextUtils -import com.v2ray.ang.AppConfig -import com.v2ray.ang.dto.EConfigType -import com.v2ray.ang.dto.ServerConfig -import com.v2ray.ang.dto.V2rayConfig -import com.v2ray.ang.extension.idnHost -import com.v2ray.ang.util.MmkvManager.settingsStorage -import com.v2ray.ang.util.Utils -import java.net.URI - -object VlessFmt { - - fun parse(str: String): ServerConfig? { - var allowInsecure = settingsStorage?.decodeBool(AppConfig.PREF_ALLOW_INSECURE) ?: false - val config = ServerConfig.create(EConfigType.VLESS) - - val uri = URI(Utils.fixIllegalUrl(str)) - if (uri.rawQuery.isNullOrEmpty()) return null - val queryParam = uri.rawQuery.split("&") - .associate { it.split("=").let { (k, v) -> k to Utils.urlDecode(v) } } - - val streamSetting = config.outboundBean?.streamSettings ?: return null - - config.remarks = Utils.urlDecode(uri.fragment.orEmpty()) - config.outboundBean.settings?.vnext?.get(0)?.let { vnext -> - vnext.address = uri.idnHost - vnext.port = uri.port - vnext.users[0].id = uri.userInfo - vnext.users[0].encryption = queryParam["encryption"] ?: "none" - vnext.users[0].flow = queryParam["flow"].orEmpty() - } - - val sni = streamSetting.populateTransportSettings( - queryParam["type"] ?: "tcp", - queryParam["headerType"], - queryParam["host"], - queryParam["path"], - queryParam["seed"], - queryParam["quicSecurity"], - queryParam["key"], - queryParam["mode"], - queryParam["serviceName"], - queryParam["authority"] - ) - allowInsecure = if ((queryParam["allowInsecure"].orEmpty()) == "1") true else allowInsecure - streamSetting.populateTlsSettings( - queryParam["security"].orEmpty(), - allowInsecure, - queryParam["sni"] ?: sni, - queryParam["fp"].orEmpty(), - queryParam["alpn"], - queryParam["pbk"].orEmpty(), - queryParam["sid"].orEmpty(), - queryParam["spx"].orEmpty() - ) - - return config - } - - fun toUri(config: ServerConfig): String { - val outbound = config.getProxyOutbound() ?: return "" - val streamSetting = outbound.streamSettings ?: V2rayConfig.OutboundBean.StreamSettingsBean() - - val remark = "#" + Utils.urlEncode(config.remarks) - val dicQuery = HashMap() - outbound.settings?.vnext?.get(0)?.users?.get(0)?.flow?.let { - if (!TextUtils.isEmpty(it)) { - dicQuery["flow"] = it - } - } - dicQuery["encryption"] = - if (outbound.getSecurityEncryption().isNullOrEmpty()) "none" - else outbound.getSecurityEncryption().orEmpty() - - - dicQuery["security"] = streamSetting.security.ifEmpty { "none" } - (streamSetting.tlsSettings - ?: streamSetting.realitySettings)?.let { tlsSetting -> - if (!TextUtils.isEmpty(tlsSetting.serverName)) { - dicQuery["sni"] = tlsSetting.serverName - } - if (!tlsSetting.alpn.isNullOrEmpty() && tlsSetting.alpn.isNotEmpty()) { - dicQuery["alpn"] = - Utils.removeWhiteSpace(tlsSetting.alpn.joinToString(",")).orEmpty() - } - if (!TextUtils.isEmpty(tlsSetting.fingerprint)) { - dicQuery["fp"] = tlsSetting.fingerprint.orEmpty() - } - if (!TextUtils.isEmpty(tlsSetting.publicKey)) { - dicQuery["pbk"] = tlsSetting.publicKey.orEmpty() - } - if (!TextUtils.isEmpty(tlsSetting.shortId)) { - dicQuery["sid"] = tlsSetting.shortId.orEmpty() - } - if (!TextUtils.isEmpty(tlsSetting.spiderX)) { - dicQuery["spx"] = Utils.urlEncode(tlsSetting.spiderX.orEmpty()) - } - } - dicQuery["type"] = - streamSetting.network.ifEmpty { V2rayConfig.DEFAULT_NETWORK } - - outbound.getTransportSettingDetails()?.let { transportDetails -> - when (streamSetting.network) { - "tcp" -> { - dicQuery["headerType"] = transportDetails[0].ifEmpty { "none" } - if (!TextUtils.isEmpty(transportDetails[1])) { - dicQuery["host"] = Utils.urlEncode(transportDetails[1]) - } - } - - "kcp" -> { - dicQuery["headerType"] = transportDetails[0].ifEmpty { "none" } - if (!TextUtils.isEmpty(transportDetails[2])) { - dicQuery["seed"] = Utils.urlEncode(transportDetails[2]) - } - } - - "ws", "httpupgrade", "splithttp" -> { - if (!TextUtils.isEmpty(transportDetails[1])) { - dicQuery["host"] = Utils.urlEncode(transportDetails[1]) - } - if (!TextUtils.isEmpty(transportDetails[2])) { - dicQuery["path"] = Utils.urlEncode(transportDetails[2]) - } - } - - "http", "h2" -> { - dicQuery["type"] = "http" - if (!TextUtils.isEmpty(transportDetails[1])) { - dicQuery["host"] = Utils.urlEncode(transportDetails[1]) - } - if (!TextUtils.isEmpty(transportDetails[2])) { - dicQuery["path"] = Utils.urlEncode(transportDetails[2]) - } - } - - "quic" -> { - dicQuery["headerType"] = transportDetails[0].ifEmpty { "none" } - dicQuery["quicSecurity"] = Utils.urlEncode(transportDetails[1]) - dicQuery["key"] = Utils.urlEncode(transportDetails[2]) - } - - "grpc" -> { - dicQuery["mode"] = transportDetails[0] - dicQuery["authority"] = Utils.urlEncode(transportDetails[1]) - dicQuery["serviceName"] = Utils.urlEncode(transportDetails[2]) - } - } - } - val query = "?" + dicQuery.toList().joinToString( - separator = "&", - transform = { it.first + "=" + it.second }) - - val url = String.format( - "%s@%s:%s", - outbound.getPassword(), - Utils.getIpv6Address(outbound.getServerAddress()), - outbound.getServerPort() - ) - return url + query + remark - } -} \ No newline at end of file diff --git a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/util/fmt/VmessFmt.kt b/V2rayNG/app/src/main/kotlin/com/v2ray/ang/util/fmt/VmessFmt.kt deleted file mode 100644 index 1efad50b..00000000 --- a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/util/fmt/VmessFmt.kt +++ /dev/null @@ -1,155 +0,0 @@ -package com.v2ray.ang.util.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.ServerConfig -import com.v2ray.ang.dto.V2rayConfig -import com.v2ray.ang.dto.VmessQRCode -import com.v2ray.ang.extension.idnHost -import com.v2ray.ang.util.JsonUtil -import com.v2ray.ang.util.MmkvManager.settingsStorage -import com.v2ray.ang.util.Utils -import java.net.URI - -object VmessFmt { - - fun parse(str: String): ServerConfig? { - if (str.indexOf('?') > 0 && str.indexOf('&') > 0) { - return parseVmessStd(str) - } - - val allowInsecure = settingsStorage?.decodeBool(AppConfig.PREF_ALLOW_INSECURE) ?: false - val config = ServerConfig.create(EConfigType.VMESS) - val streamSetting = config.outboundBean?.streamSettings ?: return null - var result = str.replace(EConfigType.VMESS.protocolScheme, "") - result = Utils.decode(result) - if (TextUtils.isEmpty(result)) { - Log.d(AppConfig.ANG_PACKAGE, "R.string.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.d(AppConfig.ANG_PACKAGE, "R.string.toast_incorrect_protocol") - return null - } - - config.remarks = vmessQRCode.ps - config.outboundBean.settings?.vnext?.get(0)?.let { vnext -> - vnext.address = vmessQRCode.add - vnext.port = Utils.parseInt(vmessQRCode.port) - vnext.users[0].id = vmessQRCode.id - vnext.users[0].security = - if (TextUtils.isEmpty(vmessQRCode.scy)) V2rayConfig.DEFAULT_SECURITY else vmessQRCode.scy - vnext.users[0].alterId = Utils.parseInt(vmessQRCode.aid) - } - val sni = streamSetting.populateTransportSettings( - vmessQRCode.net, - vmessQRCode.type, - vmessQRCode.host, - vmessQRCode.path, - vmessQRCode.path, - vmessQRCode.host, - vmessQRCode.path, - vmessQRCode.type, - vmessQRCode.path, - vmessQRCode.host - ) - - val fingerprint = vmessQRCode.fp - streamSetting.populateTlsSettings( - vmessQRCode.tls, - allowInsecure, - if (TextUtils.isEmpty(vmessQRCode.sni)) sni else vmessQRCode.sni, - fingerprint, - vmessQRCode.alpn, - null, - null, - null - ) - - return config - } - - fun toUri(config: ServerConfig): String { - val outbound = config.getProxyOutbound() ?: return "" - val streamSetting = outbound.streamSettings ?: V2rayConfig.OutboundBean.StreamSettingsBean() - - val vmessQRCode = VmessQRCode() - vmessQRCode.v = "2" - vmessQRCode.ps = config.remarks - vmessQRCode.add = outbound.getServerAddress().orEmpty() - vmessQRCode.port = outbound.getServerPort().toString() - vmessQRCode.id = outbound.getPassword().orEmpty() - vmessQRCode.aid = outbound.settings?.vnext?.get(0)?.users?.get(0)?.alterId.toString() - vmessQRCode.scy = outbound.settings?.vnext?.get(0)?.users?.get(0)?.security.toString() - vmessQRCode.net = streamSetting.network - vmessQRCode.tls = streamSetting.security - vmessQRCode.sni = streamSetting.tlsSettings?.serverName.orEmpty() - vmessQRCode.alpn = - Utils.removeWhiteSpace(streamSetting.tlsSettings?.alpn?.joinToString(",")).orEmpty() - vmessQRCode.fp = streamSetting.tlsSettings?.fingerprint.orEmpty() - outbound.getTransportSettingDetails()?.let { transportDetails -> - vmessQRCode.type = transportDetails[0] - vmessQRCode.host = transportDetails[1] - vmessQRCode.path = transportDetails[2] - } - val json = JsonUtil.toJson(vmessQRCode) - return Utils.encode(json) - } - - fun parseVmessStd(str: String): ServerConfig? { - var allowInsecure = settingsStorage?.decodeBool(AppConfig.PREF_ALLOW_INSECURE) ?: false - val config = ServerConfig.create(EConfigType.VMESS) - - val uri = URI(Utils.fixIllegalUrl(str)) - if (uri.rawQuery.isNullOrEmpty()) return null - val queryParam = uri.rawQuery.split("&") - .associate { it.split("=").let { (k, v) -> k to Utils.urlDecode(v) } } - - val streamSetting = config.outboundBean?.streamSettings ?: return null - - config.remarks = Utils.urlDecode(uri.fragment.orEmpty()) - config.outboundBean.settings?.vnext?.get(0)?.let { vnext -> - vnext.address = uri.idnHost - vnext.port = uri.port - vnext.users[0].id = uri.userInfo - vnext.users[0].security = V2rayConfig.DEFAULT_SECURITY - vnext.users[0].alterId = 0 - } - - val sni = streamSetting.populateTransportSettings( - queryParam["type"] ?: "tcp", - queryParam["headerType"], - queryParam["host"], - queryParam["path"], - queryParam["seed"], - queryParam["quicSecurity"], - queryParam["key"], - queryParam["mode"], - queryParam["serviceName"], - queryParam["authority"] - ) - - allowInsecure = if ((queryParam["allowInsecure"].orEmpty()) == "1") true else allowInsecure - streamSetting.populateTlsSettings( - queryParam["security"].orEmpty(), - allowInsecure, - queryParam["sni"] ?: sni, - queryParam["fp"].orEmpty(), - queryParam["alpn"], - null, - null, - null - ) - - return config - } -} \ No newline at end of file diff --git a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/util/fmt/WireguardFmt.kt b/V2rayNG/app/src/main/kotlin/com/v2ray/ang/util/fmt/WireguardFmt.kt deleted file mode 100644 index a198d3c8..00000000 --- a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/util/fmt/WireguardFmt.kt +++ /dev/null @@ -1,107 +0,0 @@ -package com.v2ray.ang.util.fmt - -import com.v2ray.ang.AppConfig -import com.v2ray.ang.dto.EConfigType -import com.v2ray.ang.dto.ServerConfig -import com.v2ray.ang.extension.idnHost -import com.v2ray.ang.extension.removeWhiteSpace -import com.v2ray.ang.util.Utils -import java.net.URI - -object WireguardFmt { - fun parse(str: String): ServerConfig? { - val uri = URI(Utils.fixIllegalUrl(str)) - if (uri.rawQuery != null) { - val config = ServerConfig.create(EConfigType.WIREGUARD) - config.remarks = Utils.urlDecode(uri.fragment.orEmpty()) - - val queryParam = uri.rawQuery.split("&") - .associate { it.split("=").let { (k, v) -> k to Utils.urlDecode(v) } } - - config.outboundBean?.settings?.let { wireguard -> - wireguard.secretKey = uri.userInfo - wireguard.address = - (queryParam["address"] - ?: AppConfig.WIREGUARD_LOCAL_ADDRESS_V4).removeWhiteSpace() - .split(",") - wireguard.peers?.get(0)?.publicKey = queryParam["publickey"].orEmpty() - wireguard.peers?.get(0)?.endpoint = - Utils.getIpv6Address(uri.idnHost) + ":${uri.port}" - wireguard.mtu = Utils.parseInt(queryParam["mtu"] ?: AppConfig.WIREGUARD_LOCAL_MTU) - wireguard.reserved = - (queryParam["reserved"] ?: "0,0,0").removeWhiteSpace().split(",") - .map { it.toInt() } - } - return config - } else { - return null - } - } - - fun parseWireguardConfFile(str: String): ServerConfig? { - val config = ServerConfig.create(EConfigType.WIREGUARD) - val queryParam: MutableMap = mutableMapOf() - - var currentSection: String? = null - - str.lines().forEach { line -> - val trimmedLine = line.trim() - - when { - trimmedLine.startsWith("[Interface]", ignoreCase = true) -> currentSection = "Interface" - trimmedLine.startsWith("[Peer]", ignoreCase = true) -> currentSection = "Peer" - trimmedLine.isBlank() || trimmedLine.startsWith("#") -> Unit // Skip blank lines or comments - currentSection != null -> { - val (key, value) = trimmedLine.split("=").map { it.trim() } - queryParam[key.lowercase()] = value // Store the key in lowercase for case-insensitivity - } - } - } - - config.outboundBean?.settings?.let { wireguard -> - wireguard.secretKey = queryParam["privatekey"].orEmpty() - wireguard.address = (queryParam["address"] ?: AppConfig.WIREGUARD_LOCAL_ADDRESS_V4).removeWhiteSpace().split(",") - wireguard.peers?.getOrNull(0)?.publicKey = queryParam["publickey"].orEmpty() - wireguard.peers?.getOrNull(0)?.endpoint = queryParam["endpoint"].orEmpty() - wireguard.mtu = Utils.parseInt(queryParam["mtu"] ?: AppConfig.WIREGUARD_LOCAL_MTU) - wireguard.reserved = (queryParam["reserved"] ?: "0,0,0").removeWhiteSpace().split(",").map { it.toInt() } - } - - return config - } - - - - fun toUri(config: ServerConfig): String { - val outbound = config.getProxyOutbound() ?: return "" - - val remark = "#" + Utils.urlEncode(config.remarks) - val dicQuery = HashMap() - dicQuery["publickey"] = - Utils.urlEncode(outbound.settings?.peers?.get(0)?.publicKey.toString()) - if (outbound.settings?.reserved != null) { - dicQuery["reserved"] = Utils.urlEncode( - Utils.removeWhiteSpace(outbound.settings?.reserved?.joinToString(",")) - .toString() - ) - } - dicQuery["address"] = Utils.urlEncode( - Utils.removeWhiteSpace((outbound.settings?.address as List<*>).joinToString(",")) - .toString() - ) - if (outbound.settings?.mtu != null) { - dicQuery["mtu"] = outbound.settings?.mtu.toString() - } - val query = "?" + dicQuery.toList().joinToString( - separator = "&", - transform = { it.first + "=" + it.second }) - - val url = String.format( - "%s@%s:%s", - Utils.urlEncode(outbound.getPassword().toString()), - Utils.getIpv6Address(outbound.getServerAddress()), - outbound.getServerPort() - ) - return url + query + remark - } -} \ No newline at end of file diff --git a/V2rayNG/app/src/main/res/drawable-night/ic_check_update_24dp.xml b/V2rayNG/app/src/main/res/drawable-night/ic_check_update_24dp.xml new file mode 100644 index 00000000..5a7c2fd0 --- /dev/null +++ b/V2rayNG/app/src/main/res/drawable-night/ic_check_update_24dp.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/V2rayNG/app/src/main/res/drawable-night/ic_file_24dp.xml b/V2rayNG/app/src/main/res/drawable-night/ic_file_24dp.xml index 30595842..73387126 100644 --- a/V2rayNG/app/src/main/res/drawable-night/ic_file_24dp.xml +++ b/V2rayNG/app/src/main/res/drawable-night/ic_file_24dp.xml @@ -4,6 +4,6 @@ android:viewportWidth="1024" android:viewportHeight="1024"> + android:fillColor="#FFFFFFFF" + android:pathData="M537,85.3A85.3,85.3 0,0 1,597.3 110.3L828.3,341.3A85.3,85.3 0,0 1,853.3 401.7L853.3,810.7a128,128 0,0 1,-128 128L298.7,938.7a128,128 0,0 1,-128 -128L170.7,213.3a128,128 0,0 1,128 -128zM537,170.7L298.7,170.7a42.7,42.7 0,0 0,-42.7 42.7v597.3a42.7,42.7 0,0 0,42.7 42.7h426.7a42.7,42.7 0,0 0,42.7 -42.7L768,401.7L537,170.7zM512,384a42.7,42.7 0,0 1,42.4 37.7L554.7,426.7v85.3h85.3a42.7,42.7 0,0 1,5 85L640,597.3h-85.3v85.3a42.7,42.7 0,0 1,-85 5L469.3,682.7v-85.3L384,597.3a42.7,42.7 0,0 1,-5 -85L384,512h85.3v-85.3a42.7,42.7 0,0 1,42.7 -42.7z" /> diff --git a/V2rayNG/app/src/main/res/drawable-night/ic_logcat_24dp.xml b/V2rayNG/app/src/main/res/drawable-night/ic_logcat_24dp.xml index 1c1a9c94..77e41a46 100644 --- a/V2rayNG/app/src/main/res/drawable-night/ic_logcat_24dp.xml +++ b/V2rayNG/app/src/main/res/drawable-night/ic_logcat_24dp.xml @@ -4,12 +4,12 @@ android:viewportWidth="1024" android:viewportHeight="1024"> + android:fillColor="#FFFFFFFF" + android:pathData="M273.2,212.8h583.7L856.9,906.2h-583.7L273.2,212.8zM344.9,284.5L344.9,834.6h440.3L785.2,284.5h-440.3z" /> + android:fillColor="#FFFFFFFF" + android:pathData="M167.1,117.8h554.4v123.6h-71.7V189.4H238.8v498.8h73.9v71.7H167.1V117.8z" /> + android:fillColor="#FFFFFFFF" + android:pathData="M674.8,504H455.3v-71.7h219.4v71.7zM674.8,650.2H455.3v-71.7h219.4v71.7z" /> diff --git a/V2rayNG/app/src/main/res/drawable-night/ic_more_vert_24dp.xml b/V2rayNG/app/src/main/res/drawable-night/ic_more_vert_24dp.xml new file mode 100644 index 00000000..2a252346 --- /dev/null +++ b/V2rayNG/app/src/main/res/drawable-night/ic_more_vert_24dp.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/V2rayNG/app/src/main/res/drawable-night/ic_outline_filter_alt_24.xml b/V2rayNG/app/src/main/res/drawable-night/ic_outline_filter_alt_24.xml index 9d0d5910..07553f4e 100644 --- a/V2rayNG/app/src/main/res/drawable-night/ic_outline_filter_alt_24.xml +++ b/V2rayNG/app/src/main/res/drawable-night/ic_outline_filter_alt_24.xml @@ -1,9 +1,9 @@ - + android:height="24dp" + android:tint="#FFFFFF" + android:viewportWidth="24" + android:viewportHeight="24"> diff --git a/V2rayNG/app/src/main/res/drawable-night/ic_per_apps_24dp.xml b/V2rayNG/app/src/main/res/drawable-night/ic_per_apps_24dp.xml new file mode 100644 index 00000000..99d8212e --- /dev/null +++ b/V2rayNG/app/src/main/res/drawable-night/ic_per_apps_24dp.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/V2rayNG/app/src/main/res/drawable-night/ic_promotion_24dp.xml b/V2rayNG/app/src/main/res/drawable-night/ic_promotion_24dp.xml index 014a5021..e6142817 100644 --- a/V2rayNG/app/src/main/res/drawable-night/ic_promotion_24dp.xml +++ b/V2rayNG/app/src/main/res/drawable-night/ic_promotion_24dp.xml @@ -4,9 +4,9 @@ android:viewportWidth="1024" android:viewportHeight="1024"> + android:fillColor="#FFFFFFFF" + android:pathData="M723.9,312c-13.2,-13.2 -34.7,-13.2 -47.9,0 -6.6,6.6 -9.9,15.3 -9.9,23.9 0,8.7 3.3,17.3 9.9,23.9 65.9,65.9 80.9,165.5 37.3,247.8 -4.9,9.2 -10.5,18.2 -16.9,26.8 -6.4,8.7 -12.9,16.4 -20,23.3 -13.4,13.1 -13.7,34.5 -0.6,47.9 13.1,13.4 34.5,13.7 47.9,0.6 9.7,-9.5 18.9,-20.2 27.3,-31.6 8.3,-11.2 15.7,-23 22.2,-35.2 57.5,-108.7 37.7,-240.3 -49.3,-327.4zM507.6,470c-0.2,-0.2 -0.4,-0.3 -0.7,-0.5 -0.8,-0.3 -1.7,-0.1 -2.3,0.5 -0.2,0.2 -0.3,0.4 -0.5,0.7 -0.1,0.3 -0.2,0.5 -0.2,0.8 0,0.6 0.3,1.1 0.6,1.5 0.2,0.2 0.4,0.3 0.7,0.5 0.3,0.1 0.5,0.2 0.8,0.2 0.6,0 1.1,-0.3 1.5,-0.6 0.4,-0.4 0.6,-1 0.6,-1.5 0,-0.3 -0.1,-0.5 -0.2,-0.8 0,-0.4 -0.2,-0.6 -0.3,-0.8z" /> + android:fillColor="#FFFFFFFF" + android:pathData="M833.7,209.6c-13.2,-13.2 -34.5,-13.2 -47.6,0 -13.2,13.2 -13.2,34.5 0,47.6 122.3,122.3 140,314.4 42.1,456.6 -12.5,18.1 -26.5,35 -42.1,50.5 -6.6,6.6 -9.9,15.2 -9.9,23.8 0,8.6 3.3,17.2 9.9,23.8 13.2,13.2 34.5,13.2 47.6,0 18.4,-18.4 35.1,-38.5 49.9,-60C1000,583.1 979,355 833.7,209.6zM515.1,166.8c-48,-22.3 -102.9,-15.1 -143.4,19L176.6,349.9h-50.2c-31,0 -63.3,25.2 -63.3,56.3v209.4c0,31 32.2,56.3 63.3,56.3h50.2L371.7,836c24.9,21 55.4,31.8 86.2,31.8 19.3,0 38.7,-4.2 57.2,-12.8 48,-22.3 77.8,-69.1 77.8,-122L592.9,288.8c0,-52.9 -29.8,-99.6 -77.8,-122zM531.1,732.9c0,31.8 -17.2,52.9 -46.1,66.3 -28.9,13.4 -56.7,6.2 -81,-14.3L206.2,623.5c-4.9,-4.2 -11.2,-6.4 -17.6,-6.4h-60.2c-0.8,0 -1.5,-0.7 -1.5,-1.5L126.9,406.2c0,-0.8 0.7,-1.5 1.5,-1.5h60.2c6.5,0 12.7,-2.3 17.6,-6.4L412,237.7c24.4,-20.5 51.2,-24.7 80,-11.3 28.9,13.4 39.1,30.5 39.1,62.3v444.2z" /> diff --git a/V2rayNG/app/src/main/res/drawable-night/ic_select_all_24dp.xml b/V2rayNG/app/src/main/res/drawable-night/ic_select_all_24dp.xml index a24c01bf..3d37d197 100644 --- a/V2rayNG/app/src/main/res/drawable-night/ic_select_all_24dp.xml +++ b/V2rayNG/app/src/main/res/drawable-night/ic_select_all_24dp.xml @@ -1,9 +1,9 @@ + android:viewportHeight="24.0"> diff --git a/V2rayNG/app/src/main/res/drawable/custom_divider.xml b/V2rayNG/app/src/main/res/drawable/custom_divider.xml new file mode 100644 index 00000000..94f03147 --- /dev/null +++ b/V2rayNG/app/src/main/res/drawable/custom_divider.xml @@ -0,0 +1,13 @@ + + + + + + + + + \ No newline at end of file diff --git a/V2rayNG/app/src/main/res/drawable/ic_check_update_24dp.xml b/V2rayNG/app/src/main/res/drawable/ic_check_update_24dp.xml new file mode 100644 index 00000000..b0f163c6 --- /dev/null +++ b/V2rayNG/app/src/main/res/drawable/ic_check_update_24dp.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/V2rayNG/app/src/main/res/drawable/ic_circle.xml b/V2rayNG/app/src/main/res/drawable/ic_circle.xml new file mode 100644 index 00000000..13bb8e65 --- /dev/null +++ b/V2rayNG/app/src/main/res/drawable/ic_circle.xml @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/V2rayNG/app/src/main/res/drawable/ic_file_24dp.xml b/V2rayNG/app/src/main/res/drawable/ic_file_24dp.xml index c14b248f..50ca7039 100644 --- a/V2rayNG/app/src/main/res/drawable/ic_file_24dp.xml +++ b/V2rayNG/app/src/main/res/drawable/ic_file_24dp.xml @@ -4,6 +4,6 @@ android:viewportWidth="1024" android:viewportHeight="1024"> + android:fillColor="#FF000000" + android:pathData="M537,85.3A85.3,85.3 0,0 1,597.3 110.3L828.3,341.3A85.3,85.3 0,0 1,853.3 401.7L853.3,810.7a128,128 0,0 1,-128 128L298.7,938.7a128,128 0,0 1,-128 -128L170.7,213.3a128,128 0,0 1,128 -128zM537,170.7L298.7,170.7a42.7,42.7 0,0 0,-42.7 42.7v597.3a42.7,42.7 0,0 0,42.7 42.7h426.7a42.7,42.7 0,0 0,42.7 -42.7L768,401.7L537,170.7zM512,384a42.7,42.7 0,0 1,42.4 37.7L554.7,426.7v85.3h85.3a42.7,42.7 0,0 1,5 85L640,597.3h-85.3v85.3a42.7,42.7 0,0 1,-85 5L469.3,682.7v-85.3L384,597.3a42.7,42.7 0,0 1,-5 -85L384,512h85.3v-85.3a42.7,42.7 0,0 1,42.7 -42.7z" /> diff --git a/V2rayNG/app/src/main/res/drawable/ic_logcat_24dp.xml b/V2rayNG/app/src/main/res/drawable/ic_logcat_24dp.xml index b8a544ef..95fe140c 100644 --- a/V2rayNG/app/src/main/res/drawable/ic_logcat_24dp.xml +++ b/V2rayNG/app/src/main/res/drawable/ic_logcat_24dp.xml @@ -4,12 +4,12 @@ android:viewportWidth="1024" android:viewportHeight="1024"> + android:fillColor="#FF000000" + android:pathData="M273.2,212.8h583.7L856.9,906.2h-583.7L273.2,212.8zM344.9,284.5L344.9,834.6h440.3L785.2,284.5h-440.3z" /> + android:fillColor="#FF000000" + android:pathData="M167.1,117.8h554.4v123.6h-71.7V189.4H238.8v498.8h73.9v71.7H167.1V117.8z" /> + android:fillColor="#FF000000" + android:pathData="M674.8,504H455.3v-71.7h219.4v71.7zM674.8,650.2H455.3v-71.7h219.4v71.7z" /> diff --git a/V2rayNG/app/src/main/res/drawable/ic_more_vert_24dp.xml b/V2rayNG/app/src/main/res/drawable/ic_more_vert_24dp.xml new file mode 100644 index 00000000..32d39196 --- /dev/null +++ b/V2rayNG/app/src/main/res/drawable/ic_more_vert_24dp.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/V2rayNG/app/src/main/res/drawable/ic_outline_filter_alt_24.xml b/V2rayNG/app/src/main/res/drawable/ic_outline_filter_alt_24.xml index 3a12237a..77112182 100644 --- a/V2rayNG/app/src/main/res/drawable/ic_outline_filter_alt_24.xml +++ b/V2rayNG/app/src/main/res/drawable/ic_outline_filter_alt_24.xml @@ -1,9 +1,9 @@ - + android:height="24dp" + android:tint="#000000" + android:viewportWidth="24" + android:viewportHeight="24"> diff --git a/V2rayNG/app/src/main/res/drawable/ic_per_apps_24dp.xml b/V2rayNG/app/src/main/res/drawable/ic_per_apps_24dp.xml new file mode 100644 index 00000000..c45cd8c5 --- /dev/null +++ b/V2rayNG/app/src/main/res/drawable/ic_per_apps_24dp.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/V2rayNG/app/src/main/res/drawable/ic_promotion_24dp.xml b/V2rayNG/app/src/main/res/drawable/ic_promotion_24dp.xml index 079a2197..f4283b58 100644 --- a/V2rayNG/app/src/main/res/drawable/ic_promotion_24dp.xml +++ b/V2rayNG/app/src/main/res/drawable/ic_promotion_24dp.xml @@ -4,9 +4,9 @@ android:viewportWidth="1024" android:viewportHeight="1024"> + android:fillColor="#FF000000" + android:pathData="M723.9,312c-13.2,-13.2 -34.7,-13.2 -47.9,0 -6.6,6.6 -9.9,15.3 -9.9,23.9 0,8.7 3.3,17.3 9.9,23.9 65.9,65.9 80.9,165.5 37.3,247.8 -4.9,9.2 -10.5,18.2 -16.9,26.8 -6.4,8.7 -12.9,16.4 -20,23.3 -13.4,13.1 -13.7,34.5 -0.6,47.9 13.1,13.4 34.5,13.7 47.9,0.6 9.7,-9.5 18.9,-20.2 27.3,-31.6 8.3,-11.2 15.7,-23 22.2,-35.2 57.5,-108.7 37.7,-240.3 -49.3,-327.4zM507.6,470c-0.2,-0.2 -0.4,-0.3 -0.7,-0.5 -0.8,-0.3 -1.7,-0.1 -2.3,0.5 -0.2,0.2 -0.3,0.4 -0.5,0.7 -0.1,0.3 -0.2,0.5 -0.2,0.8 0,0.6 0.3,1.1 0.6,1.5 0.2,0.2 0.4,0.3 0.7,0.5 0.3,0.1 0.5,0.2 0.8,0.2 0.6,0 1.1,-0.3 1.5,-0.6 0.4,-0.4 0.6,-1 0.6,-1.5 0,-0.3 -0.1,-0.5 -0.2,-0.8 0,-0.4 -0.2,-0.6 -0.3,-0.8z" /> + android:fillColor="#FF000000" + android:pathData="M833.7,209.6c-13.2,-13.2 -34.5,-13.2 -47.6,0 -13.2,13.2 -13.2,34.5 0,47.6 122.3,122.3 140,314.4 42.1,456.6 -12.5,18.1 -26.5,35 -42.1,50.5 -6.6,6.6 -9.9,15.2 -9.9,23.8 0,8.6 3.3,17.2 9.9,23.8 13.2,13.2 34.5,13.2 47.6,0 18.4,-18.4 35.1,-38.5 49.9,-60C1000,583.1 979,355 833.7,209.6zM515.1,166.8c-48,-22.3 -102.9,-15.1 -143.4,19L176.6,349.9h-50.2c-31,0 -63.3,25.2 -63.3,56.3v209.4c0,31 32.2,56.3 63.3,56.3h50.2L371.7,836c24.9,21 55.4,31.8 86.2,31.8 19.3,0 38.7,-4.2 57.2,-12.8 48,-22.3 77.8,-69.1 77.8,-122L592.9,288.8c0,-52.9 -29.8,-99.6 -77.8,-122zM531.1,732.9c0,31.8 -17.2,52.9 -46.1,66.3 -28.9,13.4 -56.7,6.2 -81,-14.3L206.2,623.5c-4.9,-4.2 -11.2,-6.4 -17.6,-6.4h-60.2c-0.8,0 -1.5,-0.7 -1.5,-1.5L126.9,406.2c0,-0.8 0.7,-1.5 1.5,-1.5h60.2c6.5,0 12.7,-2.3 17.6,-6.4L412,237.7c24.4,-20.5 51.2,-24.7 80,-11.3 28.9,13.4 39.1,30.5 39.1,62.3v444.2z" /> diff --git a/V2rayNG/app/src/main/res/drawable/ic_qu_switch_24dp.xml b/V2rayNG/app/src/main/res/drawable/ic_qu_switch_24dp.xml index 0021d339..79be6f81 100644 --- a/V2rayNG/app/src/main/res/drawable/ic_qu_switch_24dp.xml +++ b/V2rayNG/app/src/main/res/drawable/ic_qu_switch_24dp.xml @@ -1,8 +1,8 @@ + android:viewportWidth="1024" + android:viewportHeight="1024"> diff --git a/V2rayNG/app/src/main/res/drawable/ic_source_code_24dp.xml b/V2rayNG/app/src/main/res/drawable/ic_source_code_24dp.xml index dc13d1c8..800f7b5f 100644 --- a/V2rayNG/app/src/main/res/drawable/ic_source_code_24dp.xml +++ b/V2rayNG/app/src/main/res/drawable/ic_source_code_24dp.xml @@ -1,8 +1,8 @@ - + android:height="24dp" + android:viewportWidth="1024" + android:viewportHeight="1024"> diff --git a/V2rayNG/app/src/main/res/drawable/license_24px.xml b/V2rayNG/app/src/main/res/drawable/license_24px.xml new file mode 100644 index 00000000..5b63fd29 --- /dev/null +++ b/V2rayNG/app/src/main/res/drawable/license_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/V2rayNG/app/src/main/res/layout/activity_about.xml b/V2rayNG/app/src/main/res/layout/activity_about.xml index 1118b07a..62053559 100644 --- a/V2rayNG/app/src/main/res/layout/activity_about.xml +++ b/V2rayNG/app/src/main/res/layout/activity_about.xml @@ -1,7 +1,8 @@ + android:layout_height="match_parent" + android:fitsSystemWindows="true"> + android:padding="@dimen/padding_spacing_dp16"> + android:paddingStart="@dimen/padding_spacing_dp16"> @@ -57,23 +58,23 @@ + android:padding="@dimen/padding_spacing_dp16"> @@ -81,23 +82,23 @@ + android:padding="@dimen/padding_spacing_dp16"> @@ -108,52 +109,76 @@ android:layout_height="match_parent" android:gravity="top" android:orientation="vertical" - android:paddingTop="@dimen/padding_start"> + android:paddingTop="@dimen/padding_spacing_dp16"> + android:padding="@dimen/padding_spacing_dp16"> + + + + + + + + android:padding="@dimen/padding_spacing_dp16"> @@ -162,23 +187,23 @@ + android:padding="@dimen/padding_spacing_dp16"> @@ -186,33 +211,33 @@ + android:padding="@dimen/padding_spacing_dp16"> + android:padding="@dimen/padding_spacing_dp16"> + android:fitsSystemWindows="true" + android:orientation="vertical" + tools:context=".ui.PerAppProxyActivity"> + + + android:padding="@dimen/padding_spacing_dp16"> - - + android:maxLines="2" + android:text="@string/per_app_proxy_settings_enable" + android:textAppearance="@style/TextAppearance.AppCompat.Small" + android:textColor="@color/colorAccent" + app:theme="@style/BrandedSwitch" /> @@ -56,18 +63,31 @@ android:gravity="center" android:orientation="horizontal"> - - + android:text="@string/switch_bypass_apps_mode" + android:textAppearance="@style/TextAppearance.AppCompat.Small" + android:textColor="@color/colorAccent" + app:theme="@style/BrandedSwitch" /> + + + + + + @@ -76,17 +96,11 @@ - - + android:scrollbars="vertical" + app:layoutManager="androidx.recyclerview.widget.GridLayoutManager" /> \ No newline at end of file diff --git a/V2rayNG/app/src/main/res/layout/activity_check_update.xml b/V2rayNG/app/src/main/res/layout/activity_check_update.xml new file mode 100644 index 00000000..29345075 --- /dev/null +++ b/V2rayNG/app/src/main/res/layout/activity_check_update.xml @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/V2rayNG/app/src/main/res/layout/activity_logcat.xml b/V2rayNG/app/src/main/res/layout/activity_logcat.xml index 1e56fdee..05db70a7 100644 --- a/V2rayNG/app/src/main/res/layout/activity_logcat.xml +++ b/V2rayNG/app/src/main/res/layout/activity_logcat.xml @@ -1,39 +1,26 @@ - + android:layout_height="match_parent"> - - - - + android:layout_height="match_parent" + android:scrollbars="vertical" + app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" /> + + + diff --git a/V2rayNG/app/src/main/res/layout/activity_main.xml b/V2rayNG/app/src/main/res/layout/activity_main.xml index 06c29322..e5f6964c 100644 --- a/V2rayNG/app/src/main/res/layout/activity_main.xml +++ b/V2rayNG/app/src/main/res/layout/activity_main.xml @@ -3,12 +3,12 @@ xmlns:app="http://schemas.android.com/apk/res-auto" android:id="@+id/drawer_layout" android:layout_width="match_parent" - android:layout_height="match_parent" - android:fitsSystemWindows="true"> + android:layout_height="match_parent"> + android:layout_height="match_parent"> + android:visibility="invisible" + app:indicatorColor="@color/color_fab_active" /> + android:padding="@dimen/padding_spacing_dp8" + app:tabIndicatorFullWidth="false" + app:tabMode="scrollable" + app:tabTextAppearance="@style/TabLayoutTextStyle" /> + android:nextFocusRight="@+id/fab" + android:scrollbars="vertical" /> + android:nextFocusLeft="@+id/recycler_view" + android:nextFocusRight="@+id/fab" + android:orientation="vertical"> + + @@ -85,27 +91,21 @@ + android:layout_marginEnd="@dimen/padding_spacing_dp16"> diff --git a/V2rayNG/app/src/main/res/layout/activity_routing_edit.xml b/V2rayNG/app/src/main/res/layout/activity_routing_edit.xml index 63959628..53a49fec 100644 --- a/V2rayNG/app/src/main/res/layout/activity_routing_edit.xml +++ b/V2rayNG/app/src/main/res/layout/activity_routing_edit.xml @@ -1,9 +1,11 @@ + android:fitsSystemWindows="true" + tools:context=".ui.RoutingEditActivity"> + android:padding="@dimen/padding_spacing_dp16"> + android:layout_height="wrap_content" + android:text="@string/routing_settings_locked" + android:textColor="@color/colorAccent" + app:theme="@style/BrandedSwitch" /> + android:hint="@string/routing_settings_tips" + android:inputType="textMultiLine" + android:maxLines="10" /> + android:hint="@string/routing_settings_tips" + android:inputType="textMultiLine" + android:maxLines="10" /> @@ -179,8 +185,8 @@ diff --git a/V2rayNG/app/src/main/res/layout/activity_routing_setting.xml b/V2rayNG/app/src/main/res/layout/activity_routing_setting.xml index 825c1c50..d79b4c84 100644 --- a/V2rayNG/app/src/main/res/layout/activity_routing_setting.xml +++ b/V2rayNG/app/src/main/res/layout/activity_routing_setting.xml @@ -7,7 +7,7 @@ android:fitsSystemWindows="true" tools:context=".ui.RoutingSettingActivity"> - @@ -17,40 +17,36 @@ android:layout_height="wrap_content" android:orientation="vertical"> - + android:background="?attr/selectableItemBackground" + android:clickable="true" + android:focusable="true" + android:gravity="center|start" + android:orientation="vertical" + android:padding="@dimen/padding_spacing_dp16"> - + android:text="@string/routing_settings_domain_strategy" + android:textAppearance="@style/TextAppearance.AppCompat.Subhead" /> - - - - - + + - + \ No newline at end of file diff --git a/V2rayNG/app/src/main/res/layout/activity_server_custom_config.xml b/V2rayNG/app/src/main/res/layout/activity_server_custom_config.xml index e1e140e7..d6d587b8 100644 --- a/V2rayNG/app/src/main/res/layout/activity_server_custom_config.xml +++ b/V2rayNG/app/src/main/res/layout/activity_server_custom_config.xml @@ -3,6 +3,7 @@ xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" + android:fitsSystemWindows="true" tools:context=".ui.ServerCustomConfigActivity"> + android:layout_margin="@dimen/padding_spacing_dp8" + android:orientation="vertical"> + android:layout_margin="@dimen/padding_spacing_dp8" + android:orientation="vertical"> @@ -64,16 +65,16 @@ + android:layout_marginTop="@dimen/padding_spacing_dp16" + android:gravity="top|start" /> diff --git a/V2rayNG/app/src/main/res/layout/activity_server_hysteria2.xml b/V2rayNG/app/src/main/res/layout/activity_server_hysteria2.xml index 16b8feec..86df50ef 100644 --- a/V2rayNG/app/src/main/res/layout/activity_server_hysteria2.xml +++ b/V2rayNG/app/src/main/res/layout/activity_server_hysteria2.xml @@ -3,39 +3,21 @@ xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" + android:fitsSystemWindows="true" tools:context=".ui.ServerActivity"> + android:padding="@dimen/padding_spacing_dp16"> - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -56,8 +133,8 @@ diff --git a/V2rayNG/app/src/main/res/layout/activity_server_shadowsocks.xml b/V2rayNG/app/src/main/res/layout/activity_server_shadowsocks.xml index 0e3193d2..0d9e958f 100644 --- a/V2rayNG/app/src/main/res/layout/activity_server_shadowsocks.xml +++ b/V2rayNG/app/src/main/res/layout/activity_server_shadowsocks.xml @@ -3,20 +3,21 @@ xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" + android:fitsSystemWindows="true" tools:context=".ui.ServerActivity"> + android:padding="@dimen/padding_spacing_dp16"> @@ -36,7 +37,7 @@ @@ -58,8 +61,8 @@ diff --git a/V2rayNG/app/src/main/res/layout/activity_server_socks.xml b/V2rayNG/app/src/main/res/layout/activity_server_socks.xml index 5d955322..90c1b883 100644 --- a/V2rayNG/app/src/main/res/layout/activity_server_socks.xml +++ b/V2rayNG/app/src/main/res/layout/activity_server_socks.xml @@ -3,20 +3,21 @@ xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" + android:fitsSystemWindows="true" tools:context=".ui.ServerActivity"> + android:padding="@dimen/padding_spacing_dp16"> @@ -36,7 +37,7 @@ @@ -55,8 +56,8 @@ diff --git a/V2rayNG/app/src/main/res/layout/activity_server_trojan.xml b/V2rayNG/app/src/main/res/layout/activity_server_trojan.xml index d5ef6278..2159ec43 100644 --- a/V2rayNG/app/src/main/res/layout/activity_server_trojan.xml +++ b/V2rayNG/app/src/main/res/layout/activity_server_trojan.xml @@ -3,20 +3,21 @@ xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" + android:fitsSystemWindows="true" tools:context=".ui.ServerActivity"> + android:padding="@dimen/padding_spacing_dp16"> @@ -39,8 +40,8 @@ diff --git a/V2rayNG/app/src/main/res/layout/activity_server_vless.xml b/V2rayNG/app/src/main/res/layout/activity_server_vless.xml index 2457e7b2..7d29efd2 100644 --- a/V2rayNG/app/src/main/res/layout/activity_server_vless.xml +++ b/V2rayNG/app/src/main/res/layout/activity_server_vless.xml @@ -3,20 +3,21 @@ xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" + android:fitsSystemWindows="true" tools:context=".ui.ServerActivity"> + android:padding="@dimen/padding_spacing_dp16"> @@ -35,7 +36,7 @@ @@ -77,8 +80,8 @@ diff --git a/V2rayNG/app/src/main/res/layout/activity_server_vmess.xml b/V2rayNG/app/src/main/res/layout/activity_server_vmess.xml index e5ecf1c2..4e77cbdf 100644 --- a/V2rayNG/app/src/main/res/layout/activity_server_vmess.xml +++ b/V2rayNG/app/src/main/res/layout/activity_server_vmess.xml @@ -3,20 +3,21 @@ xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" + android:fitsSystemWindows="true" tools:context=".ui.ServerActivity"> + android:padding="@dimen/padding_spacing_dp16"> @@ -35,27 +36,7 @@ - - - - - - - - @@ -77,8 +60,8 @@ diff --git a/V2rayNG/app/src/main/res/layout/activity_server_wireguard.xml b/V2rayNG/app/src/main/res/layout/activity_server_wireguard.xml index 53adca6b..7ecd7ce1 100644 --- a/V2rayNG/app/src/main/res/layout/activity_server_wireguard.xml +++ b/V2rayNG/app/src/main/res/layout/activity_server_wireguard.xml @@ -3,20 +3,21 @@ xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" + android:fitsSystemWindows="true" tools:context=".ui.ServerActivity"> + android:padding="@dimen/padding_spacing_dp16"> @@ -35,7 +36,7 @@ @@ -55,7 +56,27 @@ + + + + + + + + + + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:inputType="text" /> - - - @@ -128,8 +138,8 @@ diff --git a/V2rayNG/app/src/main/res/layout/activity_settings.xml b/V2rayNG/app/src/main/res/layout/activity_settings.xml index f674f2ae..618bc8c0 100644 --- a/V2rayNG/app/src/main/res/layout/activity_settings.xml +++ b/V2rayNG/app/src/main/res/layout/activity_settings.xml @@ -1,8 +1,16 @@ - \ No newline at end of file + android:fitsSystemWindows="true" + android:orientation="vertical" + tools:context=".ui.SettingsActivity"> + + + + \ No newline at end of file diff --git a/V2rayNG/app/src/main/res/layout/activity_sub_edit.xml b/V2rayNG/app/src/main/res/layout/activity_sub_edit.xml index 00b8fcbb..af5d70cd 100644 --- a/V2rayNG/app/src/main/res/layout/activity_sub_edit.xml +++ b/V2rayNG/app/src/main/res/layout/activity_sub_edit.xml @@ -1,9 +1,11 @@ + android:fitsSystemWindows="true" + tools:context=".ui.SubEditActivity"> + android:padding="@dimen/padding_spacing_dp16"> @@ -50,7 +52,7 @@ @@ -94,7 +96,7 @@ + android:paddingStart="@dimen/padding_spacing_dp16" + android:paddingEnd="@dimen/padding_spacing_dp16" + app:theme="@style/BrandedSwitch" /> + android:paddingStart="@dimen/padding_spacing_dp16" + android:paddingEnd="@dimen/padding_spacing_dp16" + app:theme="@style/BrandedSwitch" /> + + + + + + + + + android:layout_height="wrap_content" + android:hint="@string/sub_setting_pre_profile_tip" + android:inputType="text" /> + android:layout_height="wrap_content" + android:hint="@string/sub_setting_pre_profile_tip" + android:inputType="text" /> @@ -178,8 +204,8 @@ diff --git a/V2rayNG/app/src/main/res/layout/activity_sub_setting.xml b/V2rayNG/app/src/main/res/layout/activity_sub_setting.xml index ed34fc22..1654701d 100644 --- a/V2rayNG/app/src/main/res/layout/activity_sub_setting.xml +++ b/V2rayNG/app/src/main/res/layout/activity_sub_setting.xml @@ -1,22 +1,25 @@ - - + android:layout_height="wrap_content" + android:indeterminate="true" + android:visibility="invisible" + app:indicatorColor="@color/color_fab_active" /> + + - - \ No newline at end of file diff --git a/V2rayNG/app/src/main/res/layout/activity_tasker.xml b/V2rayNG/app/src/main/res/layout/activity_tasker.xml index 57202e33..56a1276d 100644 --- a/V2rayNG/app/src/main/res/layout/activity_tasker.xml +++ b/V2rayNG/app/src/main/res/layout/activity_tasker.xml @@ -1,31 +1,32 @@ + android:layout_height="wrap_content" + android:padding="@dimen/padding_spacing_dp8"> + android:checked="true" + app:theme="@style/BrandedSwitch" /> diff --git a/V2rayNG/app/src/main/res/layout/activity_user_asset.xml b/V2rayNG/app/src/main/res/layout/activity_user_asset.xml new file mode 100644 index 00000000..08d7583f --- /dev/null +++ b/V2rayNG/app/src/main/res/layout/activity_user_asset.xml @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/V2rayNG/app/src/main/res/layout/activity_user_asset_url.xml b/V2rayNG/app/src/main/res/layout/activity_user_asset_url.xml index 8aeb4d50..e1b55173 100644 --- a/V2rayNG/app/src/main/res/layout/activity_user_asset_url.xml +++ b/V2rayNG/app/src/main/res/layout/activity_user_asset_url.xml @@ -3,6 +3,7 @@ xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" + android:fitsSystemWindows="true" tools:context=".ui.UserAssetUrlActivity"> + android:padding="@dimen/padding_spacing_dp16"> @@ -50,7 +51,7 @@ diff --git a/V2rayNG/app/src/main/res/layout/dialog_config_filter.xml b/V2rayNG/app/src/main/res/layout/dialog_config_filter.xml index 1c043f4f..0bbc9003 100644 --- a/V2rayNG/app/src/main/res/layout/dialog_config_filter.xml +++ b/V2rayNG/app/src/main/res/layout/dialog_config_filter.xml @@ -7,25 +7,27 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" - android:padding="@dimen/layout_margin_top_height"> + android:padding="@dimen/padding_spacing_dp16"> + android:layout_height="wrap_content" + android:layout_marginTop="@dimen/padding_spacing_dp8" + android:layout_marginBottom="@dimen/padding_spacing_dp8" /> diff --git a/V2rayNG/app/src/main/res/layout/item_qrcode.xml b/V2rayNG/app/src/main/res/layout/item_qrcode.xml index d5997b3f..d3ea9075 100644 --- a/V2rayNG/app/src/main/res/layout/item_qrcode.xml +++ b/V2rayNG/app/src/main/res/layout/item_qrcode.xml @@ -9,8 +9,8 @@ diff --git a/V2rayNG/app/src/main/res/layout/item_recycler_bypass_list.xml b/V2rayNG/app/src/main/res/layout/item_recycler_bypass_list.xml index 5a1540be..889cb3c6 100644 --- a/V2rayNG/app/src/main/res/layout/item_recycler_bypass_list.xml +++ b/V2rayNG/app/src/main/res/layout/item_recycler_bypass_list.xml @@ -2,46 +2,47 @@ + android:focusable="true" + android:gravity="center_vertical" + android:padding="@dimen/padding_spacing_dp8"> + android:layout_width="@dimen/view_height_dp48" + android:layout_height="@dimen/view_height_dp48" + android:padding="@dimen/padding_spacing_dp8" /> + android:orientation="vertical"> + android:layout_height="wrap_content" + android:maxLines="1" + android:textAppearance="@style/TextAppearance.AppCompat.Subhead" /> - + android:padding="@dimen/padding_spacing_dp8" /> \ No newline at end of file diff --git a/V2rayNG/app/src/main/res/layout/item_recycler_footer.xml b/V2rayNG/app/src/main/res/layout/item_recycler_footer.xml index 6c4df973..01db7a96 100644 --- a/V2rayNG/app/src/main/res/layout/item_recycler_footer.xml +++ b/V2rayNG/app/src/main/res/layout/item_recycler_footer.xml @@ -8,18 +8,18 @@ + android:padding="@dimen/padding_spacing_dp16" + android:visibility="invisible"> + android:orientation="vertical"> + + + + + + + + + + + \ No newline at end of file diff --git a/V2rayNG/app/src/main/res/layout/item_recycler_main.xml b/V2rayNG/app/src/main/res/layout/item_recycler_main.xml index ac27a40e..313492d4 100644 --- a/V2rayNG/app/src/main/res/layout/item_recycler_main.xml +++ b/V2rayNG/app/src/main/res/layout/item_recycler_main.xml @@ -7,125 +7,119 @@ android:layout_height="wrap_content" android:gravity="center_vertical"> - + android:layout_gravity="center" + android:background="?attr/selectableItemBackground" + android:clickable="true" + android:focusable="true" + android:gravity="center" + android:nextFocusRight="@+id/layout_share" + android:orientation="horizontal" + android:paddingStart="@dimen/padding_spacing_dp4" + android:paddingTop="@dimen/padding_spacing_dp8" + android:paddingEnd="@dimen/padding_spacing_dp4" + android:paddingBottom="@dimen/padding_spacing_dp8"> + android:orientation="vertical" /> + + - - + android:orientation="horizontal"> - + android:orientation="vertical" + android:paddingStart="@dimen/padding_spacing_dp8"> - + - + - + - + + - + - + - + - + android:padding="@dimen/padding_spacing_dp8"> @@ -133,17 +127,17 @@ + android:padding="@dimen/padding_spacing_dp8"> @@ -151,41 +145,75 @@ + android:padding="@dimen/padding_spacing_dp8"> + + + + + + - + + + + + + - - - + android:lines="1" + android:textAppearance="@style/TextAppearance.AppCompat.Small" + android:textColor="@color/colorPing" + android:textSize="11sp" + tools:text="214ms" /> + - + + diff --git a/V2rayNG/app/src/main/res/layout/item_recycler_routing_setting.xml b/V2rayNG/app/src/main/res/layout/item_recycler_routing_setting.xml index fcfbc4b2..e104ef97 100644 --- a/V2rayNG/app/src/main/res/layout/item_recycler_routing_setting.xml +++ b/V2rayNG/app/src/main/res/layout/item_recycler_routing_setting.xml @@ -6,113 +6,105 @@ android:layout_height="wrap_content" android:gravity="center_vertical"> - + android:padding="@dimen/padding_spacing_dp8"> + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_weight="1" + android:orientation="vertical" + android:padding="@dimen/padding_spacing_dp8"> + + + + + + + + + + + + + + + + - - - - - - - - - - - - + android:padding="@dimen/padding_spacing_dp8"> + + android:layout_height="wrap_content" + android:layout_marginTop="@dimen/padding_spacing_dp8" + android:orientation="horizontal"> - - - - - - - - - + app:theme="@style/BrandedSwitch" /> - - + + + diff --git a/V2rayNG/app/src/main/res/layout/item_recycler_sub_setting.xml b/V2rayNG/app/src/main/res/layout/item_recycler_sub_setting.xml index 8bab7380..47c2b4c5 100644 --- a/V2rayNG/app/src/main/res/layout/item_recycler_sub_setting.xml +++ b/V2rayNG/app/src/main/res/layout/item_recycler_sub_setting.xml @@ -6,109 +6,153 @@ android:layout_height="wrap_content" android:gravity="center_vertical"> - + android:padding="@dimen/padding_spacing_dp8"> + android:layout_height="wrap_content" + android:orientation="vertical"> + android:orientation="horizontal"> - + android:layout_weight="1" + android:orientation="vertical" + android:paddingStart="@dimen/padding_spacing_dp8"> - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + android:layout_height="wrap_content" + app:theme="@style/BrandedSwitch" /> - - - - - - - - - - - - - - - - - - + + + \ No newline at end of file diff --git a/V2rayNG/app/src/main/res/layout/item_recycler_user_asset.xml b/V2rayNG/app/src/main/res/layout/item_recycler_user_asset.xml index cf1bf597..61cd2a67 100644 --- a/V2rayNG/app/src/main/res/layout/item_recycler_user_asset.xml +++ b/V2rayNG/app/src/main/res/layout/item_recycler_user_asset.xml @@ -5,90 +5,82 @@ android:layout_width="match_parent" android:layout_height="wrap_content"> - + android:background="?attr/selectableItemBackground" + android:gravity="center" + android:orientation="horizontal" + android:padding="@dimen/padding_spacing_dp8"> + android:layout_weight="1" + android:orientation="vertical" + android:padding="@dimen/padding_spacing_dp8"> + android:orientation="vertical"> - + android:maxLines="2" + android:minLines="1" + android:textAppearance="@style/TextAppearance.AppCompat.Subhead" + tools:text="Placeholder.dat" /> - + + - - - - - - - - - - - - - - - - + + + + + + + + + + + + diff --git a/V2rayNG/app/src/main/res/layout/layout_address_port.xml b/V2rayNG/app/src/main/res/layout/layout_address_port.xml index 66c46609..1c0111ca 100644 --- a/V2rayNG/app/src/main/res/layout/layout_address_port.xml +++ b/V2rayNG/app/src/main/res/layout/layout_address_port.xml @@ -19,7 +19,7 @@ @@ -38,7 +38,7 @@ @@ -57,7 +57,7 @@ diff --git a/V2rayNG/app/src/main/res/layout/layout_progress.xml b/V2rayNG/app/src/main/res/layout/layout_progress.xml deleted file mode 100644 index 8e431cad..00000000 --- a/V2rayNG/app/src/main/res/layout/layout_progress.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/V2rayNG/app/src/main/res/layout/layout_tls.xml b/V2rayNG/app/src/main/res/layout/layout_tls.xml index 1c0c5589..b7c34a12 100644 --- a/V2rayNG/app/src/main/res/layout/layout_tls.xml +++ b/V2rayNG/app/src/main/res/layout/layout_tls.xml @@ -2,14 +2,14 @@ @@ -21,7 +21,9 @@ @@ -31,7 +33,7 @@ android:id="@+id/lay_sni" android:layout_width="match_parent" android:layout_height="wrap_content" - android:layout_marginBottom="@dimen/activity_horizontal_margin" + android:layout_marginBottom="@dimen/padding_spacing_dp16" android:orientation="vertical"> @@ -52,8 +54,8 @@ android:id="@+id/lay_stream_fingerprint" android:layout_width="match_parent" android:layout_height="wrap_content" - android:orientation="vertical" - android:layout_marginBottom="@dimen/activity_horizontal_margin"> + android:layout_marginBottom="@dimen/padding_spacing_dp16" + android:orientation="vertical"> @@ -73,8 +77,8 @@ android:id="@+id/lay_stream_alpn" android:layout_width="match_parent" android:layout_height="wrap_content" - android:orientation="vertical" - android:layout_marginBottom="@dimen/activity_horizontal_margin"> + android:layout_marginBottom="@dimen/padding_spacing_dp16" + android:orientation="vertical"> @@ -104,7 +110,9 @@ @@ -112,7 +120,7 @@ android:id="@+id/lay_public_key" android:layout_width="match_parent" android:layout_height="wrap_content" - android:layout_marginBottom="@dimen/activity_horizontal_margin" + android:layout_marginBottom="@dimen/padding_spacing_dp16" android:orientation="vertical"> @@ -133,7 +141,7 @@ android:id="@+id/lay_short_id" android:layout_width="match_parent" android:layout_height="wrap_content" - android:layout_marginBottom="@dimen/activity_horizontal_margin" + android:layout_marginBottom="@dimen/padding_spacing_dp16" android:orientation="vertical"> @@ -154,7 +162,7 @@ android:id="@+id/lay_spider_x" android:layout_width="match_parent" android:layout_height="wrap_content" - android:layout_marginBottom="@dimen/activity_horizontal_margin" + android:layout_marginBottom="@dimen/padding_spacing_dp16" android:orientation="vertical"> diff --git a/V2rayNG/app/src/main/res/layout/layout_tls_hysteria2.xml b/V2rayNG/app/src/main/res/layout/layout_tls_hysteria2.xml index 1ed99925..fc279bcb 100644 --- a/V2rayNG/app/src/main/res/layout/layout_tls_hysteria2.xml +++ b/V2rayNG/app/src/main/res/layout/layout_tls_hysteria2.xml @@ -2,14 +2,14 @@ @@ -21,7 +21,9 @@ @@ -31,7 +33,7 @@ android:id="@+id/lay_sni" android:layout_width="match_parent" android:layout_height="wrap_content" - android:layout_marginBottom="@dimen/activity_horizontal_margin" + android:layout_marginBottom="@dimen/padding_spacing_dp16" android:orientation="vertical"> @@ -63,8 +65,31 @@ + + + + + + + + + + \ No newline at end of file diff --git a/V2rayNG/app/src/main/res/layout/layout_transport.xml b/V2rayNG/app/src/main/res/layout/layout_transport.xml index 103a36d3..85712ff8 100644 --- a/V2rayNG/app/src/main/res/layout/layout_transport.xml +++ b/V2rayNG/app/src/main/res/layout/layout_transport.xml @@ -7,7 +7,7 @@ + android:layout_height="wrap_content" + android:layout_marginTop="@dimen/padding_spacing_dp8" + android:layout_marginBottom="@dimen/padding_spacing_dp8" /> @@ -76,7 +80,7 @@ + + + + + + + \ No newline at end of file diff --git a/V2rayNG/app/src/main/res/layout/nav_header.xml b/V2rayNG/app/src/main/res/layout/nav_header.xml index a4c672aa..44a175d2 100644 --- a/V2rayNG/app/src/main/res/layout/nav_header.xml +++ b/V2rayNG/app/src/main/res/layout/nav_header.xml @@ -1,11 +1,11 @@ + android:padding="@dimen/padding_spacing_dp16"> diff --git a/V2rayNG/app/src/main/res/layout/widget_switch.xml b/V2rayNG/app/src/main/res/layout/widget_switch.xml index bf0bfc71..99abc068 100644 --- a/V2rayNG/app/src/main/res/layout/widget_switch.xml +++ b/V2rayNG/app/src/main/res/layout/widget_switch.xml @@ -18,7 +18,7 @@ android:id="@+id/image_switch" android:layout_width="45dp" android:layout_height="45dp" - android:padding="@dimen/padding" + android:padding="@dimen/padding_spacing_dp16" app:srcCompat="@drawable/ic_stat_name" /> @@ -26,6 +26,6 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@string/app_name" - android:textColor="@android:color/white" - android:textSize="10sp" /> + android:textAppearance="@style/TextAppearance.AppCompat.Small" + android:textColor="@android:color/white" /> diff --git a/V2rayNG/app/src/main/res/menu/action_server.xml b/V2rayNG/app/src/main/res/menu/action_server.xml index e0b34e39..62785bca 100644 --- a/V2rayNG/app/src/main/res/menu/action_server.xml +++ b/V2rayNG/app/src/main/res/menu/action_server.xml @@ -5,10 +5,10 @@ android:id="@+id/del_config" android:icon="@drawable/ic_delete_24dp" android:title="@string/menu_item_del_config" - app:showAsAction="always" /> + app:showAsAction="ifRoom" /> + app:showAsAction="ifRoom" /> \ No newline at end of file diff --git a/V2rayNG/app/src/main/res/menu/action_sub_setting.xml b/V2rayNG/app/src/main/res/menu/action_sub_setting.xml index 3179cb1a..fa77aa07 100644 --- a/V2rayNG/app/src/main/res/menu/action_sub_setting.xml +++ b/V2rayNG/app/src/main/res/menu/action_sub_setting.xml @@ -5,10 +5,10 @@ android:id="@+id/add_config" android:icon="@drawable/ic_add_24dp" android:title="@string/menu_item_add_config" - app:showAsAction="always" /> + app:showAsAction="ifRoom" /> + app:showAsAction="ifRoom" /> \ No newline at end of file diff --git a/V2rayNG/app/src/main/res/menu/menu_asset.xml b/V2rayNG/app/src/main/res/menu/menu_asset.xml index 72fa7c20..fc351ece 100644 --- a/V2rayNG/app/src/main/res/menu/menu_asset.xml +++ b/V2rayNG/app/src/main/res/menu/menu_asset.xml @@ -14,11 +14,15 @@ android:id="@+id/add_url" android:title="@string/menu_item_add_url" app:showAsAction="never" /> + + app:showAsAction="ifRoom" /> \ No newline at end of file diff --git a/V2rayNG/app/src/main/res/menu/menu_bypass_list.xml b/V2rayNG/app/src/main/res/menu/menu_bypass_list.xml index 63fed36e..8d950208 100644 --- a/V2rayNG/app/src/main/res/menu/menu_bypass_list.xml +++ b/V2rayNG/app/src/main/res/menu/menu_bypass_list.xml @@ -6,7 +6,7 @@ android:icon="@drawable/ic_description_24dp" android:title="@string/menu_item_search" app:actionViewClass="androidx.appcompat.widget.SearchView" - app:showAsAction="always" /> + app:showAsAction="ifRoom" /> + android:id="@+id/per_app_proxy_settings" + android:icon="@drawable/ic_per_apps_24dp" + android:title="@string/per_app_proxy_settings" /> + + @@ -27,6 +35,10 @@ android:id="@+id/logcat" android:icon="@drawable/ic_logcat_24dp" android:title="@string/title_logcat" /> + + + app:showAsAction="ifRoom" /> + app:showAsAction="ifRoom" /> \ No newline at end of file diff --git a/V2rayNG/app/src/main/res/menu/menu_main.xml b/V2rayNG/app/src/main/res/menu/menu_main.xml index 1144010e..ce231ae7 100644 --- a/V2rayNG/app/src/main/res/menu/menu_main.xml +++ b/V2rayNG/app/src/main/res/menu/menu_main.xml @@ -20,6 +20,10 @@ android:id="@+id/import_clipboard" android:title="@string/menu_item_import_config_clipboard" app:showAsAction="never" /> + - - - - - - - - - - + + app:showAsAction="ifRoom" /> + app:showAsAction="ifRoom" /> \ No newline at end of file diff --git a/V2rayNG/app/src/main/res/values-ar/strings.xml b/V2rayNG/app/src/main/res/values-ar/strings.xml index 029afdf0..d74011e9 100644 --- a/V2rayNG/app/src/main/res/values-ar/strings.xml +++ b/V2rayNG/app/src/main/res/values-ar/strings.xml @@ -1,6 +1,5 @@ - v2rayNG التبديل التبديل أول استخدام لهذه الميزة، يرجى استخدام التطبيق لإضافة خادم @@ -8,10 +7,12 @@ إغلاق درج التنقل نجحت عملية ترحيل البيانات! فشلت عملية ترحيل البيانات! + Please pull down to refresh! إيقاف تعذر الحصول على الإذن + Unable to obtain the notification permission انقر للمزيد بدء الخدمات إيقاف الخدمات @@ -25,6 +26,7 @@ حذف التكوين استيراد التكوين من رمز الاستجابة السريعة (QRcode) استيراد التكوين من الحافظة + Import config from locally الكتابة يدويًا [VMess] الكتابة يدويًا [VLESS] الكتابة يدويًا [Shadowsocks] @@ -33,11 +35,6 @@ الكتابة يدويًا [Trojan] الكتابة يدويًا [Wireguard] Type manually[Hysteria2] - تكوين مخصص - استيراد تكوين مخصص من الحافظة - استيراد تكوين مخصص من الجهاز - استيراد تكوين مخصص من عنوان URL - استيراد تكوين مخصص مسح عنوان URL تأكيد الحذف؟ Please test before deleting! Confirm delete ? ملاحظات @@ -55,14 +52,14 @@ http host ws host httpupgrade host - splithttp host + xhttp host h2 host QUIC security gRPC Authority path ws path httpupgrade path - splithttp path + xhttp path h2 path QUIC key kcp seed @@ -81,6 +78,7 @@ التشفير التدفق المفتاح العام + PreSharedKey(optional) المعرّف القصير SpiderX المفتاح السري @@ -99,20 +97,32 @@ المحتوى لا توجد بيانات في الحافظة رابط URL غير صالح + Please do not use the insecure HTTP protocol subscription address تأكد من أن منفذ الاتصالات الواردة يتوافق مع الإعدادات تكوين مشوه مضيف (SNI) (اختياري) + الإجراء غير مسموح به + Obfs password + Port Hopping + Port Hopping Interval + pinSHA256 + Bandwidth down (units) + Bandwidth up (units) + XHTTP Mode + XHTTP Extra raw JSON, format: { XHTTPObject } + + فشل نسخ الملف، يرجى استخدام مدير الملفات إضافة أصل إضافة ملفات إضافة URL + مسح رمز الاستجابة السريعة (QRcode) URL تنزيل الملفات إضافة عنوان URL للأصل الملف غير موجود الملاحظات موجودة بالفعل - الإجراء غير مسموح به - Obfs password + Geo files source (optional) جار التحميل @@ -124,11 +134,14 @@ جارٍ تنزيل المحتوى تصدير إلى الحافظة استيراد من الحافظة + Per-app settings + Enable per-app الإعدادات إعدادات متقدمة + إعدادات النواة إعدادات VPN الوكيل لكل تطبيق عام: التطبيق المحدد هو وكيل، غير المحدد اتصال مباشر؛ \nوضع التجاوز: التطبيق المحدد متصل مباشرة، غير المحدد وكيل. \nخيار تحديد تطبيق الوكيل تلقائيًا في القائمة @@ -167,10 +180,16 @@ DNS VPN DNS (IPv4/v6 فقط) + Does VPN bypass LAN + + VPN Interface Address DNS المحلي (اختياري) DNS + DNS hosts (Format: domain:address,…) + domain:address,… + True delay test url (http/https) Url @@ -181,11 +200,8 @@ السماح غير الآمن عند TLS، الافتراضي هو السماح غير الآمن - منفذ بروكسي SOCKS5 - منفذ بروكسي SOCKS5 - - منفذ بروكسي HTTP - منفذ بروكسي HTTP + منفذ بروكسي Local + منفذ بروكسي Local منفذ DNS المحلي منفذ DNS المحلي @@ -196,6 +212,13 @@ بدء المسح الضوئي على الفور افتح الكاميرا لمسح الرمز ضوئيًا على الفور عند بدء التشغيل، وإلا يمكنك اختيار مسح الرمز ضوئيًا أو تحديد صورة في شريط الأدوات + Append HTTP Proxy to VPN + HTTP proxy will be used directly from (browser/ some supported apps), without going through the virtual NIC device (Android 10+) + + Enable double column display + The profile list is displayed in double columns, allowing more content to be displayed on the screen. You need to restart the application to take effect. + + ملاحظات ملاحظات التحسينات أو الأخطاء إلى GitHub الانضمام إلى مجموعة Telegram @@ -203,6 +226,7 @@ سياسة الخصوصية حول\nترجمة م. ابراهيم قاسم الكود المصدري + Open Source licenses قناة Telegram نسخ التكوين احتياطيًا موقع التخزين: [%s]، سيتم مسح النسخة الاحتياطية بعد إلغاء تثبيت التطبيق أو مسح التخزين @@ -217,6 +241,7 @@ فاصل التحديث التلقائي (بالدقائق، الحد الأدنى للقيمة 15) مستوى السجل + Outbound domain pre-resolve method الوضع انقر هنا للحصول على مزيد من المساعدة اللغة @@ -237,21 +262,27 @@ Remarks regular filter تفعيل التحديث تفعيل التحديث التلقائي - Previous proxy remarks - Next proxy remarks - The remarks exists and is unique + Allow insecure HTTP address + Previous proxy configuration remarks + Next proxy configuration remarks + The configuration remarks exists and is unique تحديث الاشتراك (أول خطوة) Tcping لجميع الإعدادات اختبر جميع الإعدادات (3) - ملفات أصول جغرافية + Asset files الفرز حسب نتائج الاختبار (5) تصفية ملف التكوين جميع مجموعات الاشتراك حذف %d من الإعدادات المكررة + Delete %d configurations + Import %d configurations + Export %d configurations + Update %d configurations بدء الخدمة تأكيد + استراتيجية النطاق إعدادات التوجيه مفصولة بفواصل (،)، تذكر الحفظ @@ -259,14 +290,16 @@ مسح Routing Rule Settings Add rule - Import ruleset + استيراد مجموعات قواعد محددة مسبقاً Existing rulesets will be deleted, are you sure to continue? Import ruleset from clipboard + استيراد مجموعة قواعد من رمز الاستجابة السريعة Export ruleset to clipboard Locked, keep this rule when import presets التحقق من الاتصال يجري الاختبار… + Testing %d configurations… نجاح: استغرق اتصال HTTP %dms فشل اكتشاف اتصال الإنترنت: %s الإنترنت غير متاح @@ -282,12 +315,27 @@ فاصل الجزء (الحد الأدنى - الحد الأقصى) تفعيل الجزء + Check for update + Already on the latest version + New version found: %s + Update now + Check Pre-release + Checking for update… + رمز استجابة سريعة (QRcode) تصدير إلى الحافظة تصدير التكوين الكامل إلى الحافظة + + QRcode + Export to clipboard + Export full configuration to clipboard + Edit + Delete + + رمز استجابة سريعة (QRcode) تصدير إلى الحافظة @@ -304,4 +352,16 @@ داكن + + Follow config + Bypass + Not Bypass + + + + Do not resolve + Resolve and add to DNS Hosts + Resolve and replace domain + + diff --git a/V2rayNG/app/src/main/res/values-bn/strings.xml b/V2rayNG/app/src/main/res/values-bn/strings.xml index f4231d5d..f36c9d3a 100644 --- a/V2rayNG/app/src/main/res/values-bn/strings.xml +++ b/V2rayNG/app/src/main/res/values-bn/strings.xml @@ -1,6 +1,5 @@ - v2rayNG সুইচ সুইচ এই ফিচারটি প্রথম ব্যবহার করা হচ্ছে, সার্ভার যোগ করতে অ্যাপটি ব্যবহার করুন @@ -8,10 +7,12 @@ নেভিগেশন ড্রয়ার বন্ধ করুন ডেটা স্থানান্তর সফল! ডেটা স্থানান্তর ব্যর্থ! + Please pull down to refresh! বন্ধ করুন অনুমতি পাওয়া যাচ্ছে না + Unable to obtain the notification permission আরও দেখতে ক্লিক করুন সার্ভিস শুরু করুন সার্ভিস বন্ধ করুন @@ -25,6 +26,7 @@ কনফিগারেশন মুছুন QR কোড থেকে কনফিগারেশন আমদানি করুন ক্লিপবোর্ড থেকে কনফিগারেশন আমদানি করুন + Import config from locally ম্যানুয়ালি টাইপ করুন [VMess] ম্যানুয়ালি টাইপ করুন [VLESS] ম্যানুয়ালি টাইপ করুন [Shadowsocks] @@ -33,11 +35,6 @@ ম্যানুয়ালি টাইপ করুন [Trojan] ম্যানুয়ালি টাইপ করুন [Wireguard] Type manually[Hysteria2] - কাস্টম কনফিগারেশন - ক্লিপবোর্ড থেকে কাস্টম কনফিগারেশন আমদানি করুন - স্থানীয়ভাবে কাস্টম কনফিগারেশন আমদানি করুন - URL থেকে কাস্টম কনফিগারেশন আমদানি করুন - কাস্টম কনফিগারেশন স্ক্যান URL আমদানি করুন মুছে ফেলুন নিশ্চিত করুন? Please test before deleting! Confirm delete ? মন্তব্য @@ -54,14 +51,14 @@ http হোস্ট ws হোস্ট httpupgrade হোস্ট - splithttp হোস্ট + xhttp হোস্ট h2 হোস্ট QUIC নিরাপত্তা gRPC কর্তৃপক্ষ পথ ws পথ httpupgrade পথ - splithttp পথ + xhttp পথ h2 পথ QUIC কী kcp বীজ @@ -80,6 +77,7 @@ এনক্রিপশন ফ্লো পাবলিক কী + PreSharedKey(optional) শর্ট আইডি SpiderX সিক্রেট কী @@ -98,20 +96,32 @@ কনটেন্ট ক্লিপবোর্ডে কোনও তথ্য নেই অবৈধ URL + Please do not use the insecure HTTP protocol subscription address ইনবাউন্ড পোর্ট নিশ্চিত করুন সেটিংসের সাথে সামঞ্জস্যপূর্ণ কনফিগারেশন বিকৃত হোস্ট (SNI) (ঐচ্ছিক) + অ্যাকশন অনুমোদিত নয় + Obfs password + Port Hopping + Port Hopping Interval + pinSHA256 + Bandwidth down (units) + Bandwidth up (units) + XHTTP Mode + XHTTP Extra raw JSON, format: { XHTTPObject } + + ফাইল কপি ব্যর্থ, অনুগ্রহ করে ফাইল ম্যানেজার ব্যবহার করুন অ্যাসেট যোগ করুন ফাইল যোগ করুন URL যোগ করুন + QR কোড স্ক্যান করুন URL ফাইল ডাউনলোড করুন অ্যাসেট URL যোগ করুন ফাইল খুঁজে পাওয়া যায়নি মন্তব্য ইতিমধ্যে বিদ্যমান - অ্যাকশন অনুমোদিত নয় - Obfs password + Geo files source (optional) লোড হচ্ছে @@ -123,10 +133,13 @@ বিষয়বস্তু ডাউনলোড হচ্ছে ক্লিপবোর্ডে রপ্তানি করুন ক্লিপবোর্ড থেকে আমদানি করুন + Per-app settings + Enable per-app সেটিংস এডভান্সড সেটিংস + কোর সেটিংস VPN সেটিংস প্রতি-অ্যাপ প্রক্সি সাধারণ: চেকড অ্যাপ প্রক্সি, আনচেকড সরাসরি সংযোগ; \nবাইপাস মোড: চেকড অ্যাপ সরাসরি সংযুক্ত, আনচেকড প্রক্সি। \nমেনুতে প্রক্সি অ্যাপ্লিকেশন স্বয়ংক্রিয়ভাবে নির্বাচন করার বিকল্প @@ -167,10 +180,16 @@ DNS VPN DNS (শুধুমাত্র IPv4/v6) + Does VPN bypass LAN + + VPN Interface Address ঘরোয়া DNS (ঐচ্ছিক) DNS + DNS hosts (Format: domain:address,…) + domain:address,… + সঠিক বিলম্ব পরীক্ষা ইউআরএল (http/https) ইউআরএল @@ -181,11 +200,8 @@ allowInsecure যখন TLS, ডিফল্টভাবে allowInsecure - SOCKS5 প্রক্সি পোর্ট - SOCKS5 প্রক্সি পোর্ট - - HTTP প্রক্সি পোর্ট - HTTP প্রক্সি পোর্ট + Local প্রক্সি পোর্ট + Local প্রক্সি পোর্ট স্থানীয় DNS পোর্ট স্থানীয় DNS পোর্ট @@ -195,6 +211,14 @@ তাত্ক্ষণিক স্ক্যান শুরু করুন শুরুতে তাত্ক্ষণিকভাবে স্ক্যান করতে ক্যামেরা খুলুন, অন্যথায় আপনি কোড স্ক্যান বা টুলবারে একটি ছবি নির্বাচন করতে পারেন + + Append HTTP Proxy to VPN + HTTP proxy will be used directly from (browser/ some supported apps), without going through the virtual NIC device (Android 10+) + + Enable double column display + The profile list is displayed in double columns, allowing more content to be displayed on the screen. You need to restart the application to take effect. + + মতামত মতামত উন্নয়ন বা বাগগুলি GitHub-এ পাঠান টেলিগ্রাম গ্রুপে যোগদান করুন @@ -202,6 +226,7 @@ গোপনীয়তা নীতি সম্পর্কিত সোর্স কোড + Open Source licenses টেলিগ্রাম চ্যানেল কনফিগারেশন ব্যাকআপ স্টোরেজ অবস্থান: [%s], অ্যাপ আনইনস্টল বা স্টোরেজ ক্লিয়ার করার পরে ব্যাকআপ মুছে যাবে @@ -216,6 +241,7 @@ অটো আপডেট ইন্টারভ্যাল (মিনিট, সর্বনিম্ন মান ১৫) লগ স্তর + Outbound domain pre-resolve method মোড আরো সাহায্যের জন্য ক্লিক করুন ভাষা @@ -236,20 +262,26 @@ Remarks regular filter আপডেট সক্রিয় করুন স্বয়ংক্রিয় আপডেট সক্রিয় করুন - Previous proxy remarks - Next proxy remarks - The remarks exists and is unique + Allow insecure HTTP address + Previous proxy configuration remarks + Next proxy configuration remarks + The configuration remarks exists and is unique সাবস্ক্রিপশন আপডেট সব কনফিগারেশন TCPing সব কনফিগারেশন প্রকৃত বিলম্ব - জিও অ্যাসেট ফাইলগুলি + Asset files টেস্ট ফলাফল দ্বারা সাজানো কনফিগারেশন ফাইল ফিল্টার করুন সব সাবস্ক্রিপশন গ্রুপ %d ডুপ্লিকেট কনফিগারেশন মুছে ফেলুন + Delete %d configurations + Import %d configurations + Export %d configurations + Update %d configurations সার্ভিস শুরু করুন নিশ্চিত করুন + ডোমেইন কৌশল রাউটিং সেটিংস কমা (,) দ্বারা আলাদা করুন, মনে রাখবেন সেভ করতে @@ -257,14 +289,16 @@ মুছে ফেলুন Routing Rule Settings Add rule - Import ruleset + পূর্বনির্ধারিত নিয়মাবলী আমদানি করুন Existing rulesets will be deleted, are you sure to continue? Import ruleset from clipboard + QRcode থেকে রুলসেট আমদানি করুন Export ruleset to clipboard Locked, keep this rule when import presets সংযোগ পরীক্ষা করুন পরীক্ষা চলছে… + Testing %d configurations… সফল: HTTP সংযোগ নিয়েছে %dms ইন্টারনেট সংযোগ সনাক্ত করতে ব্যর্থ: %s ইন্টারনেট উপলব্ধ নয় @@ -279,12 +313,28 @@ ফ্র্যাগমেন্ট দৈর্ঘ্য (ন্যূনতম-সর্বাধিক) ফ্র্যাগমেন্ট ইন্টারভ্যাল (ন্যূনতম-সর্বাধিক) ফ্র্যাগমেন্ট সক্রিয় করুন + + Check for update + Already on the latest version + New version found: %s + Update now + Check Pre-release + Checking for update… + QR কোড ক্লিপবোর্ডে রপ্তানি করুন পূর্ণ কনফিগারেশন ক্লিপবোর্ডে রপ্তানি করুন + + QRcode + Export to clipboard + Export full configuration to clipboard + Edit + Delete + + QR কোড ক্লিপবোর্ডে রপ্তানি করুন @@ -300,4 +350,23 @@ লাইট ডার্ক + + চায়না হোয়াইটলিস্ট + চায়না ব্ল্যাকলিস্ট + গ্লোবাল + ইরান হোয়াইটলিস্ট + + + + Follow config + Bypass + Not Bypass + + + + Do not resolve + Resolve and add to DNS Hosts + Resolve and replace domain + + \ No newline at end of file diff --git a/V2rayNG/app/src/main/res/values-bqi-rIR/strings.xml b/V2rayNG/app/src/main/res/values-bqi-rIR/strings.xml new file mode 100644 index 00000000..90e5cc26 --- /dev/null +++ b/V2rayNG/app/src/main/res/values-bqi-rIR/strings.xml @@ -0,0 +1,382 @@ + + + آلشت + آلشت + سی کرت ٱولی و کار بوردن ای ویژیی، ز برنومه سی ازاف کردن سرور استفاڌه کۊنین + گۊشیڌن نومگه کشاری + بستن نومگه کشاری + مووفقیت من جاگورویی داده + جاگورویی داده ٱنجوم نگرؽڌ + سی وانۊ کردن، بکشینس بلم! + + ↓ + واڌاشتن + گرؽڌن موجوز مومکن نؽڌ + گرؽڌن موجوز وارسۊوی مومکن نؽڌ + سی گرؽڌن دۉسمندیا بیشتر کیلیک کوݩ + ر وستن خدمات + واڌاشتن خدمات + ر وستن خدمات وا مووفقیت ٱنجوم وابی + ر وستن خدمات وا مووفقیت ٱنجوم نوابی + + + فایل کانفیگ + ٱووردن کانفیگ + زفت کردن کانفیگ + پاک کردن کانفیگ + و من ٱووردن کانفیگ ز QRcode + و من ٱووردن کانفیگ ز کلیپ بورد + و من ٱووردن کانفیگ ز مهلی + هؽل دستی[VMess] + هؽل دستی[VLESS] + هؽل دستی[Shadowsocks] + هؽل دستی[SOCKS] + هؽل دستی[HTTP] + هؽل دستی[Trojan] + هؽل دستی[Wireguard] + هؽل دستی[Hysteria2] + پاک بۊ؟ + پؽش ز پاک کردن کانفیگ نا موئتبر واجۊری کوݩ! پاک کردن کانفیگن قوۊل اکۊنی؟ + نیشتنا + نشۊوی + پورت + نوم منتوری + شناسه جایگۊزین + ٱمنیت + شبکه + جاگورو + نوء سر بلگ + هالت gRPC + هاست + هاست http + هاست ws + هاست httpupgrade + هاست xhttp + هاست h2 + ٱمنیت QUIC + Authority gRPC + تور + تور WS + تور HTTPUpgrade + تور XHTTP + تور H2 + تور QUIC + KCP seed + نوم خدمات gRPC + TLS + Fingerprint + Alpn + اجازه نا ٱمن + SNI + نشۊوی + پورت + رزم + ٱمنیت + رزم (اختیاری) + نوم منتوری (اختیاری) + رزم نگاری + جریان + کیلیت پوی وولاتی + کیلیت رزم ناهاڌن ازاف (اختیاری) + ShortID + SpiderX + کیلیت سیخومی + Reserved(اختیاری، وا کاما ز یک جوڌا ابۊن) + نشۊوی مهلی (اختیاری IPv4/IPv6، وا کاما ز یک جوڌا ابۊن) + Mtu(اختیاری، پؽش فرز 1420) + وا مووفقیت ٱنجوم وابی + شکست خرد + هیچ داده ای وۊجۊڌ نڌاره + پوروتوکول نادوروست + رزم گوشایی ٱنجوم نوابی + پسند فایل کانفیگ + ی برنومه دؽوۉداری فایل بپۊرنین. + کانفیگ سفارشی + کانفیگ نا موئتبر هڌ + موئتوا + هیچ داده ای من کلیپ بورد وۊجۊڌ نڌاره + نشۊوی اینترنتی نا موئتبر هڌ + نشۊوی اشتراک پوروتوکول نا ٱمن HTTP ن و کار مبرین + موتمعن بۊین ک پورت وۊرۊڌی وا سامووا ی جۊر هڌ + کانفیگ زبال نؽڌ + هاست(SNI)(اختیاری) + ای کار ممنۊع هڌ + رزم obfs + پورت گوم (درگا سرورن ز نۊ هؽل اکونه) + فاسله پورت گوم (سانیه) + pinSHA256 + ب لم ٱووڌن پئنا باند (واهڌ) + وا روء رئڌن پئنا باند (واهڌ) + هالت XHTTP + XHTTP Extra خام JSON، قالوو: { XHTTPObject } + + + لف گیری فایل ٱنجوم نوابی، ز ی برنومه دؽوۉداری فایل هیاری بگرین + ازاف کردن دارایی + ازاف کردن فایل + ازاف کردن لینگ + اسکن QRcode + نشۊوی اینترنتی + دانلود فایلا + نشۊوی اینترنتی دارایین ازاف کۊنین + فایلن نجوست + نوم ز زیتر بیڌس + بونچک فایلا جوقرافیایی (اختیاری) + + + هونی بار ونی بۊ + پیتینیڌن + پسند پوی + رزما ن بزنین + هالت Bypass + پسند خوتکار پروکسی برنومه + موئتوا هونی دانلود ابۊن + و در کشیڌن من کلیپ بورد + و من ٱووردن ز کلیپ بورد + سامووا ب تفکیک برنومه + ر وندن ب تفکیک برنومه + + + + سامووا + سامووا پؽش رئڌه + سامووا هسته + سامووا VPN + پروکسی و ری برنومه + پوی وولاتی: برنومه واجۊری بیڌه پروکسی هڌ، منپیز موستقیم بؽ نشووه هڌ. هالت دور زیڌن: برنومه نشووک ناڌه موستقیمن منپیز هڌ، پروکسی نشووک زیڌه نؽڌ. گۊزینه پسند خوتکار برنومه پروکسی من نومگه + منپیز خوتکار مجال ر ونی + مجال ر وندن، خوساخوس و سرور پسند بیڌه منپیز ابۊ که گاشڌ نا مووفق بۊ. + + سامووا Mux + ر وندن Mux + زل تر، ٱما گاشڌ منپیز زی قت بۊ\nمخزن ترافیک TCP وا 8 منپیز پؽش فرز، بارت دؽوۉداری UDP وو QUIC ن ای لم سفارشی کۊنین. + منپیزا TCP (تلایه منجا 1-1024) + منپیزا XUDP (تلایه منجا 1-1024) + دؽوۉداری QUIC من تۊنل mux + + رڌ کردن + موجاز + گوم زیڌن + + + ر وندن نشۉݩ داڌن سورعت + نشۉݩ داڌن سورعت هیم سکویی من وارسۊویا. نماڌ وارسۊوی و ری و کار گرؽڌن آلشت ابۊ. + + ر وندن Sniffing + دامنه sniff ن ز کتن امتهۉݩ کۊنین (پؽش فرز رۊشن) + ر وندن routeOnly + ز نوم دامنه sniffed تینا سی تور جوستن استفاڌه کۊنین وو نشۊوی مۉرد نزرن و عونوان نشۊوی IP ووردارین. + + ر وندن DNS مهلی + درخاستا DNS و هسته و من ایان وو و دست ماژول DNS پردازشت ابۊن (پؽشنهاڌ ابۊ ٱر لنگ تور جوستن سی دور زیڌن نشۊویا LAN وو وولات ٱسلی هڌین فعال بۊ) + + ر وندن DNS جئلی + DNS مهلی نشۊویا IP جئلی ن وورگنه (زل تر، ٱما گاشڌ من یقرد ز برنومه یل کار نکونه) + + ترجی IPv6 + تورا IPv6 ن فعال کۊنین وو نشۊویا IPv6 ن ترجی بڌین + + ز ر دیر (اختیاری) DNS (udp/tcp/https/quic) (اختیاری) + DNS + + VPN DNS (تینا IPv4/v6) + ز شبکه مهلی اگوڌرته؟ VPN + + نشۊوی رابت VPN + + منی (اختیاری) DNS + DNS + + هاست موستقیم (قالوو: دامنه: نشۊوی،...) DNS + دامنه:نشۊوی،... + + نشۊوی اینترنتی آزمایش تئخیر واقعی (http/https) + نشۊوی اینترنتی + + هشتن منپیزا ز شبکه مهلی + پوی دسگایل ترن وا نشۊوی IP ایسا، ز ر socks/http و پروکسی منپیز بۊن، تینا من شبکه قابل اعتماد فعال بۊ تا ز منپیز غیر موجاز جلو گری بۊ. + منپیزا ز شبکه مهلی ن موجار کۊنین، موتمعن بۊین ک من ی شبکه قابل ائتماڌ هڌین. + + اجازه نا ٱمن + مجال و کار بوردن TLS ب تۉر پؽش فرز، موجوز نا ٱمن فعال هڌ. + + پورت پروکسی مهلی + پورت پروکسی مهلی + + پورت DNS مهلی + پورت DNS مهلی + + قوۊل کردن پاک کردن کانفیگ + سی پاک وابیڌن فایل کانفیگ نیاز به قوۊل کردن دووارته ز سمت منتور هڌ. + + زی اسکنن ر ون + شؽواتگرن سی اسکن، زی مجال ر وندن بۊگۊشین، ٱندی ترین کودن اسکن کۊنین یا شؽواتی ن منه نوار ٱوزار پسند کۊنین. + + پروکسی HTTP ن و VPN ازاف کۊنین + پروکسی HTTP ن موسقیمن ز (مۊرۊرگر/ی قرد ز برنومه یل لادراری بیڌه)، بؽ استفاڌه ز دسگا NIC مجازی (Android 10+) استفاڌه ابۊ. + + ر وندن نشۉݩ داڌن دو سۊتۊنی + نومگه نمایه یل من دو سۊتۊن نشۉݩ داڌه ابۊن وو چینۉ ترین موئتوا بیشتری ن سیل کۊنین. سی ر وستن، وا برنومه ن ز نۊ ر ونین. + + + فشناڌن منشڌ + فشناڌن منشڌ یا داسووݩ موشکلا من Github + ٱووڌن من جرگه تلگرام + برنومه تلگرامن نجوست + هریم سیخومی + زبار + کود بونچک + موجوزا کود بونچک + تورگه تلگرام + لادراری گرؽڌن ز کانفیگ + جاگه زفت کردن: [%s]، بعڌ پاک کردن برنومه یا پاک کردن جاگه زفت کردن، نوسخه لادرار هم پاک ابۊ. + وورگندن کانفیگ + یک رسۊوی کانفیگ + + تبلیقات + تبلیقات، سی نیشتن جوزیات بیشتر کیلیک کۊنین. (هیاری مالی کۊنین تا پاک بۊ) + + ورۊ کردن خوتکار اشتراکا + اشتراکا خوتۉ ن و تۉر خوتکار وا فاسله زمۊوی من پس زمینه ورۊ کۊنین. ای ویژیی من پوی دسگایل گاشڌ همیشه کار نکونه + فاسله ورۊ کردن خوتکار (اقلن وا 15 دؽقه بۊ) + + سئت داسووا + بارت پؽش هل دامنه دری + هالت + سی دووسمندیا وو هیاری بیشتر، ری ای هؽل بزݩ + زۉݩ + سامووا رابت منتوری + سامووا هالت رابت منتوری + + داسووا + لف گیری + روفتن + ر وندن دووارته خدمات + پاک کردن پوی کانفیگا جرگه سکویی + پاک کردن کانفیگا تکراری جرگه سکویی + پاک کردن کانفیگا نا موئتبر جرگه سکویی + و در کشیڌن کانفیگا غیر سفارشی جرگه سکویی من کلیپ بورد + سامووا جرگه اشتراک + نیشتنا + نشۊوی اینترنتی اختیاری + نوم موستعار فیلتر + فعال بیڌن ورۊ کردن + فعال بیڌن ورۊ کردن خوتکار + موجاز کردن نشۊوی HTTP نا ٱمن + نوم موستعار پروکسی دیندایی + نوم موستعار پروکسی نیایی + موتمعن بۊ ک نوم موستعار هڌس وو جۊرس نی + ورۊ کردن اشتراک جرگه سکویی + Tcping کانفیگا جرگه سکویی + تئخیر واقعی کانفیگا جرگه سکویی + فایلا بونچک جوقرافیایی + ترتیب و ری نتیجه یل آزمایش + فیلتر کردن کانفیگا + پوی جرگه یل کانفیگ + پاک کردن %d کانفیگ تکراری + + پاک کردن %d کانفیگ + و من ٱووردن %d کانفیگ + و در کشیڌن %d کانفیگ + ورۊ کردن %d کانفیگ + ر وندن خدمات + قوۊل + + + نشقه دامنه + سامووا تور جوستن + وا کاما ز یک جوڌا ابۊن (،) پسند دامنه یا آی پی + زفت کردن + روفتن + سامووا قانۉݩ تور جوستن + ازاف کردن قانۉݩ + و من ٱووردن قانووا + قانووایی ک هیم سکو هڌسۉݩ پاک ابۊن، هنی هم اخۊی پاکسۉݩ کۊنی؟ + و من ٱووردن قانووا ز کلیپ بورد + و من ٱووردن قانووا ز QRcode + و در کشیڌن قانووا وو زفت من کلیپ بورد + چفت هڌ، ای قانؤنن مجال و من ٱووردن ز پؽش سامووا زفت کۊنین + domain + ip + port + protocol + [http,tls,bittorrent] + network"[udp|tcp]" + [udp|tcp] + outboundTag + + منپیزن واجۊری کوݩ + هونی آزمایش ابۊ… + %d کانفیگ هونی آزمایش ابۊ... + مووفق بی: منپیز %dms تۊل کشی + منپیز و اینترنتن نجوست: %s + اینترنت من دسرس نؽ + کود ختا: #%d + منپیز هڌ، سی واجۊری کیلیک کوݩ + منپیز نؽڌ + + اشتراک وا مووفقیت زفت زابی + اشتراک زفت نوابی + سامووا فرگمنت + کتنا فرگمنت + تۊل کتنا فرگمنت (هدقل-هدکسر) + فاسله منجا کتنا فرگمنت (هدقل-هدکسر) + ر وندن فرگمنت + + واجۊری سی ورۊ رسۊوی + سکو نوسخه دیندایی پۊرنیڌه هڌ + نوسخه نۊ ن جوست: %s + سکو ورۊ رسۊوی کۊنین + واجۊری نوسخه یل پؽش ز تیجنیڌن + ورۊ رسۊوی ن هونی واجۊری اکونه... + + + QRcode + و در کشیڌن من کلیپ بورد + و در کشیڌن پوی کانفیگ من کلیپ بورد + + + + QRcode + و در کشیڌن من کلیپ بورد + و در کشیڌن پوی کانفیگ من کلیپ بورد + آلشت + پاک کردن + + + + QRcode + و در کشیڌن من کلیپ بورد + + + + VPN + تینا پروکسی + + + + و دین کردن سیستوم + رۊشنا + تاریک + + + + نومگه اسبؽڌ چین + نومگه شه چین + جهۊوی (Global) + نومگه اسبؽڌ ایران + + + + پؽش فرز کانفیگ + دور زیڌه بۊ + دور زیڌه نبۊ + + + + هل وو فسل مکۊنین + هل وو ٱووردن و میزبووݩ یل دامنه DNS + هل وو جایونی دامنه + + + diff --git a/V2rayNG/app/src/main/res/values-fa/strings.xml b/V2rayNG/app/src/main/res/values-fa/strings.xml index 4e2bf464..081571a3 100644 --- a/V2rayNG/app/src/main/res/values-fa/strings.xml +++ b/V2rayNG/app/src/main/res/values-fa/strings.xml @@ -7,15 +7,17 @@ بستن منو کشویی موفقیت در انتقال داده انتقال داده انجام نشد! + لطفاً برای تازه کردن، پایین بکشید! توقف - قادر به دریافت مجوز نیست - برای اطلاعات بیشتر کلیک کنید + دریافت مجوز امکان پذیر نیست + دریافت مجوز اعلان امکان پذیر نیست + برای کسب اطلاعات بیشتر کلیک کنید شروع خدمات توقف خدمات - خدمات با موفقیت شروع شد - شروع خدمات انجام نشد! + شروع خدمات با موفقیت انجام شد + شروع خدمات با موفقیت انجام نشد! فایل کانفیگ @@ -23,71 +25,68 @@ ذخیره کانفیگ حذف کانفیگ کانفیگ را از QRcode وارد کنید - کانفیگ را از کلیپ‌بورد وارد کنید - تایپ دستی[VMess] + کانفیگ را از کلیپ ‌بورد وارد کنید + کانفیگ را از محلی وارد کنید + تایپ دستی[VMESS] تایپ دستی[VLESS] - تایپ دستی[Shadowsocks] + تایپ دستی[SHADOWSOCKS] تایپ دستی[SOCKS] - Type manually[HTTP] - تایپ دستی[Trojan] - [Wireguard]تایپ دستی - Type manually[Hysteria2] - کانفیگ سفارشی - کانفیگ سفارشی را از کلیپ‌بورد وارد کنید - کانفیگ سفارشی را به صورت محلی وارد کنید - کانفیگ سفارشی را از طریق نشانی اینترنتی وارد کنید - نشانی اینترنتی اسکن کانفیگ سفارشی را وارد کنید + تایپ دستی[HTTP] + تایپ دستی[TROJAN] + ‌تایپ دستی[WIREGUARD] + تایپ دستی[HYSTERIA2] حذف شود؟ - Please test before deleting! Confirm delete ? + لطفا قبل از حذف کانفیگ نامعتبر بررسی کنید! حذف کانفیگ را تایید می کنید؟ ملاحظات نشانی پورت شناسه - alterId + شناسه جایگزین امنیت شبکه انتقال - نوع head + نوع سربرگ حالت gRPC - host - http host - ws host - httpupgrade host - splithttp host - h2 host + هاست + هاست HTTP + هاست WS + هاست HTTPUpgrade + هاست XHTTP + هاست H2 QUIC security gRPC Authority - path - ws path - httpupgrade path - splithttp path - h2 path - QUIC key - kcp seed - gRPC serviceName + مسیر + مسیر WS + مسیر HTTPUpgrade + مسیر XHTTP + مسیر H2 + مسیر QUIC + KCP seed + gRPC ServiceName TLS اثرانگشت Alpn - مجوز ناامن + اعطای مجوز ناامن SNI نشانی پورت رمز عبور امنیت رمز عبور (اختیاری) - نام‌کاربری (اختیاری) + نام‌ کاربری (اختیاری) رمزنگاری جریان - PublicKey - ShortId + کلید عمومی + کلید رمزگذاری اضافی (اختیاری) + ShortID SpiderX - SecretKey - Reserved (اختیاری) - آدرس محلی IPv4(اختیاری) - Mtu(optional, default 1420) + کلید خصوصی + Reserved (اختیاری، جدا شده با کاما) + آدرس محلی (IPv4/IPv6 اختیاری، جدا شده با کاما) + MTU (اختیاری، پیش‌فرض 1420) با موفقیت انجام شد شکست - چیزی نیست + هیچ داده ای وجود ندارد پروتکل نادرست رمزگشایی انجام نشد انتخاب فایل کانفیگ @@ -95,47 +94,62 @@ کانفیگ سفارشی کانفیگ معتبر نیست محتوا - هیچ داده‌ای در کلیپ‌بورد وجود ندارد + هیچ داده‌ ای در کلیپ ‌بورد وجود ندارد نشانی اینترنتی معتبر نیست + لطفاً از آدرس های اشتراک پروتکل HTTP ناامن استفاده نکنید اطمینان حاصل کنید که پورت ورودی با تنظیمات مطابقت دارد کانفیگ درست نیست - میزبان (SNI) (اختیاری) - کپی فایل انجام نشد، لطفا از برنامه مدیریت فایل استفاده کنید - افزودن فایل‌ها - URL - دانلود فایل‌ها + هاست (SNI) (اختیاری) این عمل ممنوع است - Obfs password + رمز عبور obfs + پورت پرش (درگاه سرور را بازنویسی می کند) + فاصله پورت پرش (ثانیه) + pinSHA256 + کاهش پهنای باند (واحد) + افزایش پهنای باند (واحد) + حالت XHTTP + خام JSON XHTTP Extra، قالب: { XHTTPObject } - - URL را اضافه کنید + + کپی فایل انجام نشد، لطفا از برنامه مدیریت فایل استفاده کنید + افزودن فایل ‌ها + اسکن QRcode + URL + دانلود فایل‌ ها + آدرس اینترنتی را اضافه کنید فایل پیدا نشد نام قبلاً وجود دارد + منبع فایل های جغرافیایی (اختیاری) + + بارگذاری جستجو انتخاب همه - کلیدواژه‌ها را وارد کنید - حالت Bypass + کلیدواژه‌ ها را وارد کنید + حالت دور زدن انتخاب خودکار پروکسی برنامه در حال دانلود محتوا - خروجی گرفتن در کلیپ‌بورد - وارد کردن از کلیپ‌بورد + خروجی گرفتن در کلیپ‌ بورد + وارد کردن از کلیپ‌ بورد + تنظیمات به تفکیک برنامه + فعال کردن به تفکیک برنامه تنظیمات تنظیمات پیشرفته + تنظیمات هسته تنظیمات VPN پروکسی به تفکیک برنامه - عمومی: برنامه بررسی شده پروکسی است، اتصال مستقیم بدون بررسی است. \nحالت bypass: برنامه بررسی شده مستقیما متصل است، پراکسی بررسی نشده است. \nگزینه‌ای برای انتخاب خودکار پروکسی برنامه در منو است + عمومی: برنامه انتخاب شده از طریق یک پروکسی متصل می شود، برنامه انتخاب نشده مستقیماً متصل می شود. \nحالت دور زدن: برنامه انتخاب شده مستقیماً متصل می شود، برنامه انتخاب نشده از طریق یک پروکسی متصل می شود. \nانتخاب خودکار برنامه های پراکسی در منو امکان پذیر است. اتصال خودکار هنگام راه اندازی - هنگام راه اندازی به طور خودکار به سرور انتخابی متصل می شود که ممکن است ناموفق باشد + هنگام راه اندازی به طور خودکار به سرور انتخابی متصل می شود که ممکن است ناموفق باشد. - تنظیمات Mux - فعال کردن Mux - سریعتر است، اما ممکن است باعث اتصال ناپایدار شود\nمخزن ترافیک TCP با 8 اتصال پیش‌فرض، نحوه مدیریت UDP و QUIC را در زیر سفارشی کنید + تنظیمات MUX + فعال کردن MUX + سریعتر است، اما ممکن است باعث اتصال ناپایدار شود\nمخزن ترافیک TCP با 8 اتصال پیش‌فرض، نحوه مدیریت UDP و QUIC را در زیر سفارشی کنید. اتصالات TCP (محدوده -1 تا 1024) اتصالات XUDP (محدوده -1 تا 1024) - مدیریت QUIC در تونل mux + مدیریت QUIC در تونل MUX رد کردن مجاز @@ -143,46 +157,49 @@ فعال کردن نمایش سرعت - نمایش سرعت فعلی در قسمت آگاه‌سازی. \nآیکون آگاه‌سازی بر اساس استفاده تغییر می‌کند. + نمایش سرعت فعلی در قسمت اعلان. \nآیکون اعلان بر اساس استفاده تغییر می‌کند. - فعال کردن Sniffing - دامنه sniff را از بسته امتحان کنید (پیش‌فرض روشن) - فعال کردن routeOnly - از نام دامنه sniffed فقط برای مسیریابی استفاده کنید و آدرس مورد نظر را به عنوان آدرس IP نگه دارید. + فعال کردن تجزیه و تحلیل بسته ها (Sniffing) + استفاده از تشخیص نام دامنه (Sniff) در بسته ها (به طور پیش فرض فعال است) + فعال کردن دامنه فقط مسیر یابی (RouteOnly) + از نام دامنه (Snnifed) فقط برای مسیریابی استفاده کنید و آدرس مقصد را به عنوان IP ذخیره کنید. فعال کردن DNS محلی - DNS پردازش شده توسط ماژول DNS هسته (توصیه می‌شود، در صورت نیاز به دور زدن LAN و نشانی mainland) + درخواست های DNS به هسته وارد شده و توسط ماژول DNS پردازش می شوند (توصیه می شود در صورت نیاز به مسیریابی برای دور زدن آدرس های LAN و سرزمین اصلی فعال شود) فعال کردن DNS جعلی - DNS محلی آدرس IP جعلی را برمی‌گرداند (سریع‌تر می‌باشد، اما ممکن است برای برخی از برنامه‌ها کار نکند) + دی ان اس محلی آدرس های آیپی جعلی را بر می گرداند (سریع تر می باشد و تاخیر را کاهش می دهد اما ممکن است برای برخی از برنامه ها کار نکند) - ترجیح دادن IPv6 - ترجیح دادن نشانی و مسیر های IPv6 + ترجیح دادن IPV6 + مسیرهای IPv6 را فعال کنید و آدرس‌های IPv6 را ترجیح دهید - DNS از راه دور (اختیاری) + DNS از راه دور (اختیاری) (udp/tcp/https/quic) DNS VPN DNS (فقط IPv4/v6) + آیا VPN از شبکه محلی عبور می کند؟ + + VPN Interface Address DNS داخلی (اختیاری) DNS - True delay test url (http/https) - Url + DNS مستقیم هاست (فرمت: دامنه:آدرس،…) + دامنه:آدرس،… - اجازه اتصالات از طریق LAN - دستگاه‌های دیگر می‌توانند از طریق socks/http به پراکسی توسط نشانی آی‌پی شما متصل شوند، فقط در شبکه مورد اعتماد فعال می‌شوند تا از اتصال غیرمجاز جلوگیری کنند - اتصالات از طریق LAN را مجاز کنید، مطمئن شوید که در یک شبکه قابل اعتماد هستید + آدرس اینترنتی آزمایش تاخیر واقعی کانفیگ ها (HTTP/HTTPS) + URL - موز ناامن - هنگام استفاده از TLS، به طور پیش‌فرض مجوز ناامن فعال است + اجازه اتصالات از طریق شبکه محلی + سایر دستگاه ها می توانند با استفاده از آدرس آیپی شما برای استفاده از یک پروکسی محلی متصل شوند. فقط در یک شبکه قابل اعتماد برای جلوگیری از اتصالات غیرمجاز استفاده کنید. + اتصالات از طریق شبکه محلی را مجاز کنید، مطمئن شوید که در یک شبکه قابل اعتماد هستید. - پورت پروکسی SOCKS5 - پورت پروکسی SOCKS5 + اعطای مجوز ناامن + هنگام استفاده از TLS، به طور پیش‌ فرض مجوز ناامن فعال است. - پورت پروکسی HTTP - پورت پروکسی HTTP + پورت پروکسی محلی + پورت پروکسی محلی پورت DNS محلی پورت DNS محلی @@ -193,77 +210,102 @@ فورا اسکن را شروع کن دوربین را برای اسکن بلافاصله در هنگام راه اندازی باز کنید، در غیر این صورت می توانید کد را اسکن کنید یا عکسی را در نوار ابزار انتخاب کنید. + پروکسی HTTP را به VPN اضافه کنید + پروکسی HTTP مستقیماً از (مرورگر/برخی برنامه‌های پشتیبانی‌شده)، بدون استفاده از دستگاه NIC مجازی (Android 10+) استفاده می‌شود. + + فعال کردن نمایش دو ستون + لیست نمایه در دو ستون نمایش داده می شود و امکان نمایش محتوای بیشتری را بر روی صفحه نمایش می دهد. برای اجرا باید برنامه را مجددا راه اندازی کنید. + + بازخورد - بازخورد یا گزارش اشکالات در گیت‌هاب + بازخورد یا گزارش اشکالات در گیت‌ هاب عضویت در گروه تلگرام برنامه تلگرام پیدا نشد حریم خصوصی درباره کد منبع + مجوز های منبع باز کانال تلگرام - پشتیبانگیری از پیکربندی + پشتیبان گیری از پیکربندی محل ذخیره سازی: [%s], پس از حذف نصب برنامه یا پاک کردن فضای ذخیره سازی، نسخه پشتیبان پاک می شود بازیابی پیکربندی - اشتراکگذاری پیکربندی + اشتراک گذاری پیکربندی تبلیغات تبلیغات، برای جزئیات بیشتر کلیک کنید (کمک مالی کنید تا حذف شود) - به‌روزرسانی خودکار اشتراک ها - اشتراک های خود را به طور خودکار با فاصله زمانی در پس زمینه به روز کنید. بسته به دستگاه، این ویژگی ممکن است همیشه کار نکند - فاصله به‌روزرسانی خودکار (دقیقه، حداقل مقدار 15) + به‌ روزرسانی خودکار اشتراک ها + اشتراک های خود را به طور خودکار با فاصله زمانی در پس زمینه به روز کنید. بسته به دستگاه، این ویژگی ممکن است همیشه کار نکند. + فاصله به‌ روزرسانی خودکار ( حداقل مقدار ، 15 دقیقه ) سطح گزارشات + Outbound domain pre-resolve method حالت - برای راهنمایی بیشتر روی این متن، کلیک کنید + برای اطلاعات و راهنمایی بیشتر، روی این متن کلیک کنید زبان تنظیمات رابط کاربری - UI mode settings + تنظیمات حالت رابط کاربری گزارشات کپی پاک کردن راه‌اندازی مجدد خدمات - حذف تمام کانفیگ - حذف کانفیگ های تکراری - حذف کانفیگ‌های نامعتبر (ابتدا آزمایش کنید) - خروجی گرفتن کانفیگ‌های غیرسفارشی در کلیپ‌بورد - تنظیمات گروه‌ی اشتراک + حذف تمام کانفیگ های گروه فعلی + حذف کانفیگ های تکراری گروه فعلی + حذف کانفیگ های نامعتبر گروه فعلی (ابتدا آزمایش کنید) + خروجی گرفتن کانفیگ های غیرسفارشی گروه فعلی در کلیپ ‌بورد + تنظیمات گروه‌ اشتراک ملاحظات نشانی اینترنتی اختیاری - Remarks regular filter + نام مستعار فیلتر فعال کردن به‌روزرسانی فعال سازی به‌روزرسانی خودکار - Previous proxy remarks - Next proxy remarks - The remarks exists and is unique - به‌روزرسانی اشتراک - Tcping همه کانفیگ - تاخیر واقعی همه کانفیگ - فایل‌های دارایی جغرافیا - مرتب‌سازی بر اساس نتایج آزمایش + مجاز کردن آدرس HTTP ناامن + نام مستعار پروکسی قبلی + نام مستعار پروکسی بعدی + لطفاً مطمئن شوید که نام مستعار وجود دارد و منحصر به فرد است + به‌روزرسانی گروه فعلی اشتراک + TCPING کانفیگ های گروه فعلی + تاخیر واقعی کانفیگ های گروه فعلی + فایل های منبع جغرافیایی + مرتب‌ سازی بر اساس نتایج آزمایش فیلتر کردن کانفیگ‌ها همه گروه‌های اشتراک حذف %d کانفیگ تکراری + حذف %d کانفیگ + وارد کردن %d کانفیگ + صادر کردن %d کانفیگ + آپدیت کردن %d کانفیگ شروع خدمات تایید + استراتژی دامنه تنظیمات مسیریابی - با کاما (,) از هم جدا شوند، ذخیره کردن فراموش نشود + با کاما (،) از هم جدا شوند، ذخیره کردن فراموش نشود ذخیره حذف تنظیمات قانون مسیریابی اضافه کردن قانون - وارد کردن مجموعه قوانین + وارد کردن مجموعه قوانین از پیش تعریف شده مجموعه قوانین موجود حذف خواهند شد، آیا مطمئن هستید که ادامه می دهید؟ وارد کردن مجموعه قوانین از کلیپ بورد + وارد کردن مجموعه قوانین از QRcode صادر کردن مجموعه قوانین به کلیپ بورد - قفل است، این قانون را هنگام وارد کردن از پیش تنظیم‌ها حفظ کنید + قفل است، این قانون را هنگام وارد کردن از پیش تنظیم‌ ها حفظ کنید + دامنه + آیپی + پورت + پورتکل + [http,tls,bittorrent] + شبکه + [udp|tcp] + برچسب خروجی اتصال را بررسی کنید در حال آزمایش... - موفقیت: اتصال HTTP %dms طول کشید + تست کردن %d کانفیگ… + موفقیت: اتصال %dms طول کشید اتصال به اینترنت شناسایی نشد: %s اینترنت در دسترس نیست کد خطا: #%d @@ -272,33 +314,68 @@ اشتراک با موفقیت ذخیره شد ذخیره اشتراک ناموفق بود - تنظیمات Fragment - Fragment Packets - Fragment Length (min-max) - Fragment Interval (min-max) - فعال کردن Fragment + تنظیمات فرگمنت + بسته های فرگمنت + طول بسته های فرگمنت (حداقل-حداکثر) + فاصله بین بسته های فرگمنت (حداقل-حداکثر) + فعال کردن فرگمنت + + بررسی به روز رسانی + در حال حاضر آخرین نسخه نصب شده است + نسخه جدید پیدا شد: %s + اکنون به روز رسانی کنید + بررسی نسخه پیش از انتشار + در حال بررسی برای به‌روزرسانی… + QRcode - خروجی گرفتن در کلیپ‌بورد - خروجی گرفتن کانفیگ کامل در کلیپ‌بورد + خروجی گرفتن در کلیپ‌ بورد + خروجی گرفتن کانفیگ کامل در کلیپ بورد + + + + QRcode + صادر کردن به کلیپ بورد + صادر کردن پیکربندی کامل به کلیپ بورد + ویرایش کردن + حذف کردن QRcode - خروجی گرفتن در کلیپ‌بورد + خروجی گرفتن در کلیپ‌ بورد VPN فقط پروکسی - افزودن + افزودن منبع افزودن لینک - Follow system - Light - Dark + پیش فرض سیستم + روشن + تاریک + + + + لیست سفید چین ( دور زدن سرزمین اصلی ) + لیست سیاه چین + جهانی(GLOBAL) + ایران + + + + پیش فرض کانفیگ + دور زده شود + دور زده نشود + + + + Do not resolve + Resolve and add to DNS Hosts + Resolve and replace domain diff --git a/V2rayNG/app/src/main/res/values-night/colors.xml b/V2rayNG/app/src/main/res/values-night/colors.xml index 35b55f82..ff1c835f 100644 --- a/V2rayNG/app/src/main/res/values-night/colors.xml +++ b/V2rayNG/app/src/main/res/values-night/colors.xml @@ -3,6 +3,7 @@ #f97910 #646464 #BDBDBD + #424242 #212121 #FFFFFF diff --git a/V2rayNG/app/src/main/res/values-night/styles.xml b/V2rayNG/app/src/main/res/values-night/themes.xml similarity index 71% rename from V2rayNG/app/src/main/res/values-night/styles.xml rename to V2rayNG/app/src/main/res/values-night/themes.xml index 828ffe7c..d24634ef 100644 --- a/V2rayNG/app/src/main/res/values-night/styles.xml +++ b/V2rayNG/app/src/main/res/values-night/themes.xml @@ -3,9 +3,10 @@ diff --git a/V2rayNG/app/src/main/res/values-ru/strings.xml b/V2rayNG/app/src/main/res/values-ru/strings.xml index 1713d4e4..c71054a0 100644 --- a/V2rayNG/app/src/main/res/values-ru/strings.xml +++ b/V2rayNG/app/src/main/res/values-ru/strings.xml @@ -7,10 +7,12 @@ Закрыть панель навигации Успешный перенос данных! Перенос данных не выполнен! + Потяните вниз для обновления! Остановить Разрешение не получено + Разрешение на отображение уведомлений не получено Ещё… Запуск служб Остановка служб @@ -24,6 +26,7 @@ Удалить профиль Импорт из QR-кода Импорт из буфера обмена + Импорт из файла Ручной ввод VMess Ручной ввод VLESS Ручной ввод Shadowsocks @@ -32,11 +35,6 @@ Ручной ввод Trojan Ручной ввод WireGuard Ручной ввод Hysteria2 - Другой профиль - Импорт из буфера обмена - Импорт из файла - Импорт из URL - Импорт сканированием URL Подтверждаете удаление? Выполните проверку перед удалением! Подтверждаете удаление? Название @@ -53,14 +51,14 @@ Узел HTTP Узел WS Узел HTTPUpgrade - Узел SplitHTTP + Узел XHTTP Узел H2 Шифрование QUIC Полномочия gRPC Путь Путь WS Путь HTTPUpgrade - Путь SplitHTTP + Путь XHTTP Путь H2 Ключ QUIC Сид KCP @@ -79,10 +77,11 @@ Шифрование Поток Открытый ключ + Дополнительный ключ шифрования (необязательно) ShortID SpiderX Закрытый ключ - Reserved (необязательно) + Reserved (необязательно, через запятую) Локальный адрес (необязательно, IPv4/IPv6 через запятую) MTU (необязательно, по умолчанию 1420) Успешно @@ -97,20 +96,32 @@ Данные В буфере обмена нет данных Неправильный URL + Не используйте небезопасный HTTP-протокол в адресе подписки Убедитесь, что входящий порт соответствует настройкам Профиль повреждён Узел (SNI) (необязательно) + Это действие запрещено + Пароль obfs + Смена портов (переопределяет порт) + Интервал смены портов + pinSHA256 + Входящая пропускная способность (единицы) + Исходящая пропускная способность (единицы) + Режим XHTTP + Необработанный JSON XHTTP Extra, формат: { XHTTPObject } + + Невозможно скопировать файл, используйте файловый менеджер Добавить ресурс Добавить файлы Добавить URL + Сканировать QR-код URL Загрузить файлы Добавить URL ресурса Файл не найден Название уже существует - Это действие запрещено - Пароль obfs + Источник геофайлов (необязательно) Загрузка… @@ -118,18 +129,20 @@ Выбрать все Введите ключевые слова Режим обхода - Автовыбор проксируемых приложений + Автовыбор приложений Загрузка данных Экспорт в буфер обмена Импорт из буфера обмена - + Выбор приложений + Использовать выбор приложений Настройки Расширенные настройки + Настройки ядра Настройки VPN Прокси для выбранных приложений - Основной: выделенное приложение соединяется через прокси, не выделенное — напрямую;\nРежим обхода: выделенное приложение соединяется напрямую, не выделенное — через прокси.\nЕсть возможность автоматического выбора проксируемых приложений в меню. + Основной: выбранное приложение соединяется через прокси, не выбранное — напрямую;\nРежим обхода: выбранное приложение соединяется напрямую, не выбранное — через прокси.\nЕсть возможность автоматического выбора проксируемых приложений в меню. Автоподключение при запуске Автоматически подключаться к выбранному серверу при запуске приложения (может оказаться неудачным) @@ -157,37 +170,40 @@ Обслуживание выполняется DNS-модулем ядра (в настройках маршрутизации рекомендуется выбрать режим «Все, кроме LAN и Китая») Использовать поддельную DNS - Локальная DNS возвращает поддельный IP-адрес (быстрее, но может не работать с некоторыми приложениями) + Локальная DNS возвращает поддельные IP-адреса (быстрее, но может не работать с некоторыми приложениями) Предпочитать IPv6 - Предпочитать IPv6-адреса и маршрутизацию + Использовать маршрутизацию IPv6 предпочитать IPv6-адреса Удалённая DNS (UDP/TCP/HTTPS/QUIC) (необязательно) DNS VPN DNS (только IPv4/v6) + VPN пропускает LAN + + VPN частный IP Внутренняя DNS (необязательно) DNS + Узлы DNS (формат: домен:адрес,…) + домен:адрес,… + Сервис проверки времени отклика (HTTP/HTTPS) URL Разрешать подключения из LAN - Другие устройства могут подключаться, используя ваш IP-адрес, чтобы использовать прокси по протоколам SOCKS/HTTP. Используйте только в доверенной сети, чтобы избежать несанкционированного подключения. + Другие устройства могут подключаться, используя ваш IP-адрес, чтобы использовать локальный прокси. Используйте только в доверенной сети, чтобы избежать несанкционированного подключения. Доступ из LAN разрешён, убедитесь, что вы находитесь в доверенной сети Разрешать небезопасные Для TLS по умолчанию разрешены небезопасные соединения - Порт SOCKS5-прокси - Порт SOCKS5-прокси + Порт локального прокси + Порт локального прокси - Порт HTTP-прокси - Порт HTTP-прокси - - Локальный порт DNS - Локальный порт DNS + Порт локальной DNS + Порт локальной DNS Подтверждение удаления профиля Требовать двойное подтверждение удаления профиля @@ -195,6 +211,13 @@ Сканирование при запуске Начинать сканирование сразу при запуске приложения или запускать функцию сканирования камерой или из изображения через панель инструментов + Дополнительный HTTP-прокси + HTTP-прокси будет использоваться напрямую (из браузера и других поддерживающих приложений), минуя виртуальный сетевой адаптер (Android 10+) + + Отображение в два столбца + Список профилей выводится в виде двух столбцов, что позволяет показать больше информации на экране. Требуется перезапуск приложения. + + Обратная связь Предложить улучшение или сообщить об ошибке на GitHub Присоединиться к группе в Telegram @@ -202,6 +225,7 @@ Политика конфиденциальности О приложении Исходный код + Лицензии открытого исходного кода Telegram-канал Резервирование конфигурации Путь: [%s]. Резервная копия будет стёрта при удалении приложения или очистке хранилища. @@ -216,6 +240,7 @@ Интервал автообновления (минут, не менее 15) Подробность ведения журнала + Outbound domain pre-resolve method Режим Нажмите для получения дополнительной информации Язык @@ -236,21 +261,27 @@ Название фильтра Использовать обновление Использовать автообновление - Название предыдущего прокси - Название следующего прокси - Название должно существовать и быть уникальным + Разрешать незащищённые HTTP-адреса + Предыдущая конфигурация прокси + Следующая конфигурация прокси + Конфигурация должна быть уникальной Обновить подписку группы Проверка профилей группы Время отклика профилей группы - Файлы георесурсов + Файлы ресурсов Сортировка по результатам теста Фильтр групп Все группы Удалено дубликатов профилей: %d + Удалено профилей: %d + Импортировано профилей: %d + Экспортировано профилей: %d + Обновлено профилей: %d Запуск службы Подтвердить + Доменная стратегия Маршрутизация Введите требуемые домены/IP через запятую @@ -258,9 +289,10 @@ Очистить Настройка правил маршрутизации Добавить правило - Импорт правил + Импорт набора правил Существующие правила будут удалены. Продолжить? Импорт правил из буфера обмена + Импорт правил из QR-кода Экспорт правил в буфер обмена Постоянное (сохранится при импорте правил) Домен @@ -268,13 +300,14 @@ Порт Протокол [http,tls,bittorrent] - Сеть" + Сеть [udp|tcp] Исходящее подключение Проверить подключение Проверка… - Успешно: HTTP-соединение заняло %d мс + Проверка профилей (%d) + Успешно: соединение заняло %d мс Сбой проверки интернет-соединения: %s Интернет недоступен Код ошибки: #%d @@ -289,12 +322,27 @@ Интервал фрагментов (от - до) Использовать фрагментирование + Проверить обновление + Установлена последняя версия + Найдена новая версия: %s + Обновить + Искать предварительный выпуск + Проверка обновления… + QR-код Экспорт в буфер обмена Экспорт всей конфигурации в буфер обмена + + QR-код + Экспорт в буфер обмена + Экспорт всей конфигурации в буфер обмена + Изменить + Удалить + + QR-код Экспорт в буфер обмена @@ -315,6 +363,19 @@ Белый список Китая Чёрный список Китая Общие + Белый список Ирана + + + + Как в профиле + Пропускает + Не пропускает + + + + Do not resolve + Resolve and add to DNS Hosts + Resolve and replace domain diff --git a/V2rayNG/app/src/main/res/values-vi/strings.xml b/V2rayNG/app/src/main/res/values-vi/strings.xml index 6fd61e59..86238b79 100644 --- a/V2rayNG/app/src/main/res/values-vi/strings.xml +++ b/V2rayNG/app/src/main/res/values-vi/strings.xml @@ -7,10 +7,12 @@ Đóng Menu ứng dụng Đã chuyển dữ liệu! Không thể chuyển dữ liệu! + Please pull down to refresh! Ngắt kết nối v2rayNG Vui lòng cấp quyền cần thiết cho v2rayNG! Bạn đã từ chối các quyền cần thiết như Camera hay Bộ nhớ? + Unable to obtain the notification permission Nhấn để biết thêm... Đang khởi động v2rayNG... Đã dừng v2rayNG! @@ -24,6 +26,7 @@ Xoá cấu hình Nhập cấu hình từ mã QR Nhập cấu hình từ Clipboard + Import config from locally Nhập thủ công [VMess] Nhập thủ công [VLESS] Nhập thủ công [ShadowSocks] @@ -32,11 +35,6 @@ Nhập thủ công [Trojan] Nhập thủ công [WireGuard] Type manually[Hysteria2] - Nâng cao / Cấu hình tùy chỉnh - Nhập cấu hình tùy chỉnh từ Clipboard - Nhập cấu hình tùy chỉnh từ Tệp - Nhập cấu hình tùy chỉnh từ URL - Nhập cấu hình tùy chỉnh quét URL Xác nhận xóa? Please test before deleting! Confirm delete ? Tên cấu hình @@ -53,14 +51,14 @@ http host ws host httpupgrade host - splithttp host + xhttp host h2 host QUIC security gRPC Authority path ws path httpupgrade path - splithttp path + xhttp path h2 path QUIC key kcp seed @@ -79,6 +77,7 @@ Mã hóa Kiểm soát lưu lượng (Flow) PublicKey + PreSharedKey(optional) ShortId SpiderX SecretKey @@ -97,20 +96,32 @@ Nội dung Không có dữ liệu nào trong Clipboard! URL không hợp lệ hoặc trống! + Please do not use the insecure HTTP protocol subscription address Vui lòng đảm bảo cấu hình tùy chỉnh này không bị lỗi trước khi sử dụng! Cấu hình không hợp lệ! Host (SNI) (Không bắt buộc) - Không thể sao chép tệp tin, hãy dùng trình quản lý tệp! - Thêm tệp - URL - Tải xuống tệp tin Hành động này bị cấm! Obfs password + Port Hopping + Port Hopping Interval + pinSHA256 + Bandwidth down (units) + Bandwidth up (units) + XHTTP Mode + XHTTP Extra raw JSON, format: { XHTTPObject } - + + Không thể sao chép tệp tin, hãy dùng trình quản lý tệp! + Thêm tệp + Quét mã QR + URL + Tải xuống tệp tin Thêm URL nội dung Không tìm thấy tập tin! Nhận xét đã tồn tại! + Geo files source (optional) + + Đang tải... Tìm kiếm Chọn tất cả @@ -120,11 +131,14 @@ Đang tải xuống nội dung... Xuất và Sao chép Nhập từ Clipboard + Per-app settings + Enable per-app Cài đặt Cài đặt nâng cao + Cài đặt lõi Cài đặt VPN Proxy theo Ứng dụng - Bình thường: Ứng dụng đã chọn sẽ kết nối thông qua Proxy, chưa chọn sẽ kết nối trực tiếp. \n- Chế độ Bypass: Ứng dụng đã chọn sẽ kết nối trực tiếp, chưa chọn sẽ kết nối qua Proxy. \n- Nếu bạn đang ở Trung Quốc thì vào Menu, chọn Tự động chọn ứng dụng Proxy. @@ -166,10 +180,16 @@ DNS VPN DNS (Chỉ IPv4 / IPv6) + Does VPN bypass LAN + + VPN Interface Address DNS nội địa (Không bắt buộc) DNS + DNS hosts (Format: domain:address,…) + domain:address,… + URL kiểm tra độ trễ thực (HTTP / HTTPS) URL @@ -180,11 +200,8 @@ Bỏ qua xác minh chứng chỉ Khi nhập những cấu hình có bảo mật TLS, mặc định sẽ không xác minh chứng chỉ. - Cổng Proxy SOCKS5 - Cổng Proxy SOCKS5 - - Cổng Proxy HTTP - Cổng Proxy HTTP + Cổng Proxy Local + Cổng Proxy Local Cổng Local DNS Cổng Local DNS @@ -195,6 +212,13 @@ Quét mã QR ngay lập tức Mở camera để quét mã QR ngay khi khởi động, nếu không, bạn cũng có thể chọn quét mã hoặc chọn ảnh từ thanh công cụ. + Append HTTP Proxy to VPN + HTTP proxy will be used directly from (browser/ some supported apps), without going through the virtual NIC device (Android 10+) + + Enable double column display + The profile list is displayed in double columns, allowing more content to be displayed on the screen. You need to restart the application to take effect. + + Phản hồi lỗi Phản hồi cải tiến hoặc lỗi lên GitHub Tham gia nhóm Telegram @@ -203,6 +227,7 @@ Chính sách bảo mật Giới thiệu Mã nguồn + Open Source licenses Kênh Telegram Sao lưu cấu hình Nơi lưu trữ: [%s], bản backup sẽ được dọn dẹp sau khi xóa ứng dụng hoặc xóa bộ nhớ. @@ -216,6 +241,7 @@ Thời gian cập nhật tự động (Phút, giá trị tối thiểu là 15) Cấp độ nhật ký + Outbound domain pre-resolve method Chế độ kết nối Nhấn vào đây nếu bạn cần trợ giúp! Ngôn ngữ @@ -236,21 +262,27 @@ Remarks regular filter Sử dụng gói đăng ký này Bật tự động cập nhật - Previous proxy remarks - Next proxy remarks - The remarks exists and is unique + Allow insecure HTTP address + Previous proxy configuration remarks + Next proxy configuration remarks + The configuration remarks exists and is unique Cập nhật các gói đăng ký Ping tất cả máy chủ Kiểm tra HTTP tất cả máy chủ - Tệp Geo Asset + Asset files Sắp xếp lại theo lần kiểm tra cuối cùng Lọc cấu hình theo các gói đăng ký Hiển thị tất cả các gói đăng ký Xoá %d cấu hình trùng lặp + Delete %d configurations + Import %d configurations + Export %d configurations + Update %d configurations Khởi động v2rayNG Xác nhận + Chiến lược tên miền (DomainStrategy) Cài đặt định tuyến Phân cách bằng dấu phẩy (,). Có thể tải xuống Rules mặc định để tham khảo ở menu ba chấm. @@ -258,14 +290,16 @@ Xoá Routing Rule Settings Add rule - Import ruleset + Nhập các bộ quy tắc được xác định trước Existing rulesets will be deleted, are you sure to continue? Import ruleset from clipboard + Nhập bộ quy tắc từ QRcode Export ruleset to clipboard Locked, keep this rule when import presets Kiểm tra kết nối Đang kiểm tra kết nối mạng... + Testing %d configurations… Kiểm tra thành công: thời gian truy cập Google là %d ms Lỗi kết nối mạng, hãy thử đổi cấu hình hoặc kiểm tra lại! Mã lỗi: %s Không có kết nối mạng! @@ -276,12 +310,33 @@ Nhập gói đăng ký thành công! Nhập gói đăng ký không thành công! + Fragment Settings + Fragment Packets + Fragment Length (min-max) + Fragment Interval (min-max) + Enable Fragment + + Check for update + Already on the latest version + New version found: %s + Update now + Check Pre-release + Checking for update… + Xuất ra mã QR (Chụp màn hình để lưu) Sao chép vào Clipboard Sao chép thành cấu hình tùy chỉnh + + QRcode + Export to clipboard + Export full configuration to clipboard + Edit + Delete + + Xuất gói ra mã QR (Chụp màn hình để lưu) Xuất gói vào Clipboard @@ -299,10 +354,16 @@ Sáng Tối - Fragment Settings - Fragment Packets - Fragment Length (min-max) - Fragment Interval (min-max) - Enable Fragment + + Follow config + Bypass + Not Bypass + + + + Do not resolve + Resolve and add to DNS Hosts + Resolve and replace domain + diff --git a/V2rayNG/app/src/main/res/values-zh-rCN/strings.xml b/V2rayNG/app/src/main/res/values-zh-rCN/strings.xml index 8ff73af0..a8eec856 100644 --- a/V2rayNG/app/src/main/res/values-zh-rCN/strings.xml +++ b/V2rayNG/app/src/main/res/values-zh-rCN/strings.xml @@ -2,15 +2,17 @@ 开关 开关 - 初次使用此功能请先用APP添加配置 + 初次使用此功能请先用 APP 添加配置 Open navigation drawer Close navigation drawer 数据迁移成功! 数据迁移失败啦! + 请下拉刷新! 停止 无法取得权限 + 无法取得通知权限 点击了解更多 启动服务中 关闭中 @@ -24,67 +26,64 @@ 删除配置 扫描二维码 从剪贴板导入 - 手动输入[VMess] - 手动输入[VLESS] - 手动输入[Shadowsocks] - 手动输入[SOCKS] - 手动输入[HTTP] - 手动输入[Trojan] - 手动输入[Wireguard] - 手动输入[Hysteria2] - 自定义配置 - 从剪贴板导入自定义配置 - 从本地导入自定义配置 - 剪贴板URL导入自定义配置 - 扫描URL导入自定义配置 + 从本地导入 + 手动输入 [VMess] + 手动输入 [VLESS] + 手动输入 [Shadowsocks] + 手动输入 [SOCKS] + 手动输入 [HTTP] + 手动输入 [Trojan] + 手动输入 [Wireguard] + 手动输入 [Hysteria2] 确认删除? 删除前请先测试!确认删除? - 别名(remarks) - 地址(address) - 端口(port) - 用户ID(id) - 额外ID(alterId) - 加密方式(security) - 传输协议(network) - 底层传输方式(transport) - 伪装类型(type) - gRPC 传输模式(mode) - 伪装域名(host) + 别名 (remarks) + 地址 (address) + 端口 (port) + 用户 ID (id) + 额外 ID (alterId) + 加密方式 (security) + 传输协议 (network) + 底层传输方式 (transport) + 伪装类型 (type) + gRPC 传输模式 (mode) + 伪装域名 (host) http host ws host httpupgrade host - splithttp host + xhttp host h2 host QUIC 加密方式 gRPC Authority path ws path httpupgrade path - splithttp path + xhttp path h2 path QUIC 加密密钥 kcp seed gRPC serviceName - 传输层安全(TLS) + 传输层安全 (TLS) Fingerprint Alpn - 跳过证书验证(allowInsecure) + 跳过证书验证 (allowInsecure) SNI 服务器地址 服务器端口 密码 加密方式 - 密码(可选) - 用户名(可选) - 加密方式(encryption) - 流控(flow) + 密码 (可选) + 用户名 (可选) + 加密方式 (encryption) + 流控 (flow) PublicKey + PreSharedKey (optional) ShortId SpiderX SecretKey - Reserved(可选) - 本地地址(可选IPv4/IPv6,逗号隔开) - Mtu(可选, 默认1420) + Reserved (可选,逗号隔开) + 本地地址 (可选 IPv4/IPv6,逗号隔开) + Mtu (可选, 默认 1420) 成功 失败 没有数据 @@ -97,20 +96,32 @@ 内容 剪贴板中没有数据 无效的网址 - 确保inbounds port和设置中的一致 + 请不要使用不安全的 HTTP 协议订阅地址 + 确保 inbounds port 和设置中的一致 配置格式错误 - Host(SNI)(可选) - 失败, 请使用文件管理器 - 添加文件 - URL - 下载文件 + Host (SNI) (可选) 禁止此项操作 混淆密码 + 跳跃端口 (会覆盖服务器端口) + 端口跳跃间隔 (秒) + SHA256 证书指纹 + 带宽下行 (单位) + 带宽上行 (单位) + XHTTP 模式 + XHTTP Extra 原始 JSON,格式: { XHTTPObject } - + + 失败, 请使用文件管理器 + 添加文件 + 扫描 QRcode + URL + 下载文件 添加资产网址 文件未找到 备注已经存在 + Geo 文件来源 (可选) + + 正在加载 搜索 全选 @@ -120,13 +131,16 @@ 正在下载内容 导出至剪贴板 从剪贴板导入 + 分应用设置 + 启用分应用 设置 进阶设置 + 核心设置 VPN 设置 - 分应用代理 - 常规:勾选的App被代理,未勾选的直连;\n绕行模式:勾选的App直连,未勾选的被代理.\n不明白者在菜单中选择自动选中需代理应用 + 分应用 + 常规: 勾选的 App 被代理, 未勾选的直连;\n绕行模式: 勾选的 App 直连, 未勾选的被代理.\n不明白者在菜单中选择自动选中需代理应用 开机时自动连接 开机时自动连接选择的服务器,可能会不成功 @@ -150,41 +164,44 @@ 启用 routeOnly 将嗅探得到的域名仅用于路由,代理目标地址仍为 IP - 启用本地DNS + 启用本地 DNS DNS 请求导入 core 由 DNS 模块处理(推荐启用 如果需要路由绕过局域网及大陆地址) - 启用虚拟DNS + 启用虚拟 DNS 本地返回虚构解析结果 (减低延时 但个别应用可能无法使用) - IPv6优先 - App优先使用IPv6地址连接服务器,同时开启VPN的IPv6路由 + IPv6 优先 + App 优先使用 IPv6 地址连接服务器, 同时开启 VPN 的 IPv6 路由 - 远程DNS (udp/tcp/https/quic)(可选) + 远程 DNS (udp/tcp/https/quic)(可选) DNS VPN DNS (仅支持 IPv4/v6) + VPN 是否绕过局域网 - 境内DNS (可选) + VPN 接口地址 + + 境内 DNS (可选) DNS + DNS hosts (格式: 域名: 地址,…) + domain: address,… + 真连接延迟测试网址 (http/https) Url 允许来自局域网的连接 - 其他设备可以使用socks/http协议通过您的IP地址连接到代理,仅在受信任的网络中启用以避免未经授权的连接 + 其他设备可以使用 socks/http 协议通过您的 IP 地址连接到代理, 仅在受信任的网络中启用以避免未经授权的连接 允许来自局域网的连接,请确保处于受信网络 - 跳过证书验证(allowInsecure) - 传输层安全选tls时,默认跳过证书验证(allowInsecure) + 跳过证书验证 (allowInsecure) + 传输层安全选 tls 时,默认跳过证书验证 (allowInsecure) - SOCKS5代理端口 - SOCKS5代理端口 + 本地代理端口 + 本地代理端口 - HTTP代理端口 - HTTP代理端口 - - 本地DNS端口 - 本地DNS端口 + 本地 DNS 端口 + 本地 DNS 端口 删除配置文件确认 删除配置文件是否需要用户二次确认 @@ -192,27 +209,36 @@ 立即启动扫码 启动时立即打开相机扫描,否则可在工具栏选择扫码或选照片 + 追加 HTTP 代理至 VPN + 浏览器 / 一些支持的应用 将直接使用 HTTP 代理, 而不经过虚拟网卡设备 (Android 10+) + + 启用双列显示 + 配置文件列表以双列显示,允许在屏幕上显示更多内容。需要重启应用生效。 + + 反馈 反馈改进或漏洞至 GitHub - 加入Telegram Group - 未找到Telegram app + 加入 Telegram Group + 未找到 Telegram app 隐私权政策 关于 源代码 + Open Source licenses Telegram 频道 备份配置 - 存储位置: [%s], 卸载App或清除存储后备份将被清除 + 存储位置: [%s], 卸载 App 或清除存储后备份将被清除 还原配置 分享配置 推广 - 一些推广,点击查看详情(捐赠可去除) + 一些推广, 点击查看详情 (捐赠可去除) 自动更新订阅 在后台按一定时间间隔自动更新您的订阅。受设备影响,此功能不一定总是有效 - 自动更新间隔(分钟,最小值15) + 自动更新间隔(分钟,最小值 15) 日志级别 + Outbound 域名预解析方式 模式 点此查看更多帮助 语言 @@ -229,40 +255,48 @@ 导出当前组配置至剪贴板 订阅分组设置 备注 - 可选地址(url) + 可选地址 (url) 别名正则过滤 启用更新 启用自动更新 - 前置代理别名 - 落地代理別名 - 请确保别名存在并唯一 + 允许不安全的 HTTP 地址 + 前置代理配置文件别名 + 落地代理配置文件別名 + 请确保配置文件别名存在并唯一 更新当前组订阅 - 测试当前组配置Tcping + 测试当前组配置 Tcping 测试当前组配置真连接 - Geo 资源文件 + 资源文件 按测试结果排序 过滤配置文件 所有分组 删除 %d 个重复配置 + 删除 %d 个配置 + 导入 %d 个配置 + 导出 %d 个配置 + 更新 %d 个配置 启动服务 确定 + 域名策略 路由设置 - 用逗号(,)隔开,domain和ip二选一填写 + 用逗号(,)隔开, domain 和 ip 二选一填写 保存 清空 路由规则设置 添加规则 - 导入预设规则集 + 导入预定义规则集 将删除现有的规则集,是否确定继续? 从剪贴板导入规则集 + 从 QRcode 导入规则集 导出规则集至剪贴板 锁定中,导入预设时不删除此规则 "检查网络连接" "测试中…" + 测试 %d 个配置中… "连接成功:延时 %d 毫秒" "失败:%s" "无互联网连接" @@ -274,18 +308,33 @@ 导入订阅失败 添加 添加链接 - 分片(Fragment) 设置 + 分片(Fragment)设置 分片方式 - 分片包长(最小-最大) - 分片间隔(最小-最大) + 分片包长(最小 - 最大) + 分片间隔(最小 - 最大) 启用分片(Fragment) + 检查更新 + 目前已是最新版本 + 发现新版本: %s + 立即更新 + 检查 Pre-release + 正在检查更新中… + 二维码 导出至剪贴板 导出完整配置至剪贴板 + + 二维码 + 导出至剪贴板 + 导出完整配置至剪贴板 + 编辑 + 删除 + + 二维码 导出至剪贴板 @@ -303,9 +352,22 @@ - 绕过大陆(Whitelist) - 黑名单(Blacklist) - 全局(Global) + 绕过大陆 (Whitelist) + 黑名单 (Blacklist) + 全局 (Global) + 伊朗 (Iran) + + + + 跟随配置文件 + 绕过 + 不绕过 + + + + 不解析 + 解析后添加至 DNS Hosts + 解析后替换原域名 diff --git a/V2rayNG/app/src/main/res/values-zh-rTW/strings.xml b/V2rayNG/app/src/main/res/values-zh-rTW/strings.xml index 6b236aa7..f8b938c5 100644 --- a/V2rayNG/app/src/main/res/values-zh-rTW/strings.xml +++ b/V2rayNG/app/src/main/res/values-zh-rTW/strings.xml @@ -7,23 +7,26 @@ 關閉導覽匣 資料遷移成功! 資料遷移失敗! + 請下拉刷新! 停止 無法取得此權限 + 無法取得此通知權限 瞭解更多 啟動服務 停止服務 啟動服務成功 啟動服務失敗 - + 設定檔 新增設定 儲存設定 刪除設定 從 QR Code 匯入設定 從剪貼簿匯入設定 + 從本地匯入 手動鍵入 [VMess] 手動鍵入 [VLESS] 手動鍵入 [Shadowsocks] @@ -32,11 +35,6 @@ 手動鍵入 [Trojan] 手動鍵入 [Wireguard] 手動鍵入 [Hysteria2] - 自訂設定 - 從剪貼簿匯入自訂設定 - 從本地匯入自訂設定 - 從 URL 匯入自訂設定 - 掃描 URL 匯入自訂設定 確定刪除? 刪除前請先測試!確認刪除? 備註 @@ -53,14 +51,14 @@ http host ws host httpupgrade host - splithttp host + xhttp host h2 host QUIC 加密方式 gRPC Authority path ws path httpupgrade path - splithttp path + xhttp path h2 path QUIC 加密金鑰 kcp seed @@ -79,12 +77,13 @@ 加密 (encryption) 流程 (flow) PublicKey + PreSharedKey (optional) ShortId SpiderX SecretKey - Reserved (可選) - 本機位址(可選IPv4/IPv6,逗號隔開) - MTU(可選, 預設1420) + Reserved (可選,逗號隔開) + 本機位址 (可選 IPv4/IPv6,逗號隔開) + MTU (可選, 預設 1420) 成功 失敗 無資料 @@ -97,20 +96,32 @@ 內容 剪貼簿內無資料 URL 無效 + 請不要使用不安全的 HTTP 協定訂閱位址 ​​確保 inbounds port 和設定中的一致 設定格式不正確 - Host(SNI)(可選) - 失敗,請使用檔案總管 - 新增檔案 - URL - 下載檔案 + Host (SNI) (可選) 禁止此項操作 混淆密碼 + 跳躍連接埠 (會覆蓋伺服器連接埠) + 連接埠跳躍間隔 (秒) + SHA256 憑證指紋 + 頻寬下行 (單位) + 頻寬上行 (單位) + XHTTP 模式 + XHTTP Extra 原始 JSON,格式: { XHTTPObject } - + + 失敗,請使用檔案總管 + 新增檔案 + 掃描 QRcode + URL + 下載檔案 新增資產網址 文件未找到 備註已經存在 + Geo 檔案來源 (可選) + + 載入 搜尋 全選 @@ -120,11 +131,14 @@ 正在下載內容 匯出至剪貼簿 從剪貼簿匯入 + Per-app settings + Enable per-app 設定 進階 + 核心設定 VPN 設定 Proxy 個別應用程式 常規:勾選的 App 啟用 Proxy,未勾選的直接連線;\n繞行模式:勾選的 App 直接連線,未勾選的啟用 Proxy。\n可在選單中選擇自動選中需 Proxy 應用 @@ -153,21 +167,26 @@ 啟用本機 DNS - DNS 請求匯入 core 由 DNS 模塊處理 (建議啟用,如果需要轉送略過區域網路及中國大陸) + DNS 請求匯入 core 由 DNS 模組處理 (建議啟用,如果需要轉送略過區域網路及中國大陸) 啟用假 DNS 本機退回假解析結果 (減低延時,但個別應用可能無法使用) IPv6 偏好 - App 優先使用 IPv6 位址連線伺服器,同时開啟 VPN 的 IPv6 路由 + App 優先使用 IPv6 位址連線伺服器,同時開啟 VPN 的 IPv6 路由 - 遠端DNS (udp/tcp/https/quic)(可選) + 遠端 DNS (udp/tcp/https/quic)(可選) DNS VPN DNS (僅支援 IPv4/v6) + VPN 是否繞過區域網 + + VPN 介面位址 - 國內 DNS (可選) DNS + 境内 DNS (可选) + DNS hosts (格式: 網域:位址,…) + domain:address,… 真連線延遲測試網址 (http/https) Url @@ -179,11 +198,8 @@ 跳過憑證驗證 (allowInsecure) 傳輸層安全選 tls 時,預設跳過憑證驗證 (allowInsecure) - SOCKS5 Proxy 埠 - SOCKS5 Proxy 埠 - - HTTP Proxy 埠 - HTTP Proxy 埠 + 本地 Proxy 埠 + 本地 Proxy 埠 本機 DNS 埠 本機 DNS 埠 @@ -194,6 +210,13 @@ 立即啟動掃碼 啟動時立即打開相機掃描,否則可在工具欄選擇掃碼或選照片 + 追加 HTTP 代理至 VPN + 瀏覽器 / 一些支援的應用 將直接使用 HTTP 代理, 而不經過虛擬網卡設備 (Android 10+) + + 啟用雙列顯示 + 設定檔清單以雙列顯示,允許在螢幕上顯示更多內容。需要重啟應用生效。 + + 意見回饋 前往 GitHub 回報錯誤 加入 Telegram 群組 @@ -201,6 +224,7 @@ 隱私權政策 關於 原始碼 + Open Source licenses Telegram 頻道 備份設定 儲存位置: [%s], 卸載App或清除儲存後備份將被清除 @@ -212,14 +236,15 @@ 自動更新訂閱 在後台以一定時間間隔自動更新您的訂閱。受設備影響,此功能不一定總是有效 - 自動更新間隔(分鐘,最小值15) + 自動更新間隔(分鐘,最小值 15) 記錄層級 + Outbound 網域預解析方式 模式 輕觸以檢視說明 語言 介面顏色設定 - UI mode settings + 介面顯示模式 Logcat 複製 @@ -235,21 +260,26 @@ 別名正規過濾 啟用更新 啟用自動更新 - 前置代理别名 - 落地代理別名 - 请确保别名存在并唯一 + 允許不安全的 HTTP 位址 + 前置代理設定檔别名 + 落地代理設定檔別名 + 请确保設定檔别名存在并唯一 更新目前群組訂閱 偵測目前群組設定 Tcping 偵測目前群組設定真延遲 - Geo 資源檔案 + 資源檔案 依偵測結果排序 過濾設定 所有分組 - Delete %d duplicate configurations - + 刪除 %d 個重複配置 + 刪除 %d 個配置 + 匯入 %d 個配置 + 匯出 %d 個配置 + 更新 %d 個配置 啟動服務 確定 + 網域策略 轉送設定 以半形逗號「,」分隔,domain和ip二選一填寫 @@ -257,14 +287,16 @@ 清除 路由規則設定 新增規則 - 匯入預設規則集 + 匯入預先定義的規則集 將刪除現有的規則集,是否確定繼續? 從剪貼簿匯入規則集 + 從 QRcode 匯入規則集 匯出規則集至剪貼簿 鎖定中,匯入預設時不刪除此規則 "測試連線能力" "測試中……" + 測試 %d 個配置中… "成功:%d ms延遲" "測試網際網路連線失敗:%s" "無法使用網際網路" @@ -274,20 +306,35 @@ 匯入訂閱成功 匯入訂閱失敗 - 添加 - 添加連結 + 新增資源 + 新增連結 分片(Fragment) 設定 分片方式 分片包長(最小-最大) 分片間隔(最小-最大) 啟用分片(Fragment) + 檢查更新 + 当前已是最新版本 + 發現新版本: %s + 立即更新 + 檢查 Pre-release + 正在檢查更新中… + QR Code 匯出至剪貼簿 匯出完整設定至剪貼簿 + + QR Code + 匯出至剪貼簿 + 匯出完整設定至剪貼簿 + 編輯 + 刪除 + + QR Code 匯出至剪貼簿 @@ -305,9 +352,22 @@ - 繞過大陸(Whitelist) - 黑名單(Blacklist) - 全域(Global) + 繞過大陸 (Whitelist) + 黑名單 (Blacklist) + 全域 (Global) + 伊朗 (Iran) + + + + 跟隨設定檔 + 繞過 + 不繞過 + + + + 不解析 + 解析後加入 DNS Hosts + 解析後替換原網域名稱 diff --git a/V2rayNG/app/src/main/res/values/arrays.xml b/V2rayNG/app/src/main/res/values/arrays.xml index 535e6bd7..27f0846e 100644 --- a/V2rayNG/app/src/main/res/values/arrays.xml +++ b/V2rayNG/app/src/main/res/values/arrays.xml @@ -26,9 +26,8 @@ kcp ws httpupgrade - splithttp + xhttp h2 - quic grpc @@ -44,6 +43,7 @@ wechat-video dtls wireguard + dns @@ -124,43 +124,6 @@ xtls-rprx-vision-udp443 - - - - 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 - - - auto English @@ -171,6 +134,7 @@ فارسی عربي বাংলা + لۊری بختیاری @@ -184,6 +148,7 @@ fa ar bn + bqi-rIR @@ -203,4 +168,44 @@ direct block + + + auto + packet-up + stream-up + stream-one + + + + 0 + 1 + 2 + + + + 0 + 1 + 2 + 3 + 4 + 5 + 6 + + + + 10.10.14.x + 10.1.0.x + 10.0.0.x + 172.31.0.x + 172.20.0.x + 172.16.0.x + 192.168.100.x + + + + 0 + 1 + 2 + + \ No newline at end of file diff --git a/V2rayNG/app/src/main/res/values/colors.xml b/V2rayNG/app/src/main/res/values/colors.xml index 4d6f262c..8e328f84 100644 --- a/V2rayNG/app/src/main/res/values/colors.xml +++ b/V2rayNG/app/src/main/res/values/colors.xml @@ -7,6 +7,7 @@ #f97910 #9C9C9C #727272 + #E0E0E0 #F5F5F5 #000000 diff --git a/V2rayNG/app/src/main/res/values/dimens.xml b/V2rayNG/app/src/main/res/values/dimens.xml index 84a8a180..9de2f26f 100644 --- a/V2rayNG/app/src/main/res/values/dimens.xml +++ b/V2rayNG/app/src/main/res/values/dimens.xml @@ -1,21 +1,11 @@ - 50dp - 16dp - 16dp - 8dp - 50dp - 24dp - 72dp - 90dp - 60dp - 16dp - 16dp - 16dp - 3dp - - 16dp - 16dp - 8dp - 160dp + 4dp + 8dp + 16dp + 24dp + 36dp + 48dp + 64dp + 160dp diff --git a/V2rayNG/app/src/main/res/values/strings.xml b/V2rayNG/app/src/main/res/values/strings.xml index 9ee304d8..57106306 100644 --- a/V2rayNG/app/src/main/res/values/strings.xml +++ b/V2rayNG/app/src/main/res/values/strings.xml @@ -8,11 +8,13 @@ Close navigation drawer Data migration success! Data migration failed! + Please pull down to refresh! Stop Unable to obtain the permission - click for more + Unable to obtain the notification permission + Click for more Start Services Stop Services Start Services Success @@ -25,6 +27,7 @@ Delete config Import config from QRcode Import config from Clipboard + Import config from locally Type manually[VMess] Type manually[VLESS] Type manually[Shadowsocks] @@ -33,11 +36,6 @@ Type manually[Trojan] Type manually[Wireguard] Type manually[Hysteria2] - Custom config - Import custom config from Clipboard - Import custom config from locally - Import custom config from URL - Import custom config scan URL Confirm delete ? Please test before deleting! Confirm delete ? remarks @@ -54,14 +52,14 @@ http host ws host httpupgrade host - splithttp host + xhttp host h2 host QUIC security gRPC Authority path ws path httpupgrade path - splithttp path + xhttp path h2 path QUIC key kcp seed @@ -80,10 +78,11 @@ encryption flow PublicKey + PreSharedKey(optional) ShortId SpiderX SecretKey - Reserved(Optional) + Reserved(Optional, separated by commas) Local address (optional IPv4/IPv6, separated by commas) Mtu(optional, default 1420) Success @@ -98,20 +97,32 @@ Content There is no data in the clipboard Invalid URL + Please do not use the insecure HTTP protocol subscription address Ensure inbounds port is consistent with the settings Config malformed Host(SNI)(Optional) + Action not allowed + Obfs password + Port Hopping(will override the port) + Port Hopping Interval + pinSHA256 + Bandwidth down (units) + Bandwidth up (units) + XHTTP Mode + XHTTP Extra raw JSON, format: { XHTTPObject } + + File copy failed, please use File Manager Add asset Add files Add URL + Scan QRcode URL Download files Add asset URL File not found The remarks already exists - Action not allowed - Obfs password + Geo files source (optional) Loading @@ -123,20 +134,22 @@ Downloading content Export to Clipboard Import from Clipboard - + Per-app settings + Enable per-app Settings Advanced Settings + Core Settings VPN Settings Per-app proxy - General: Checked App is proxy, unchecked direct connection; \nbypass mode: checked app directly connected, unchecked proxy. \nThe option to automatically select the proxy application in the menu + General: Checked apps use proxy, unchecked apps connect directly; \nBypass mode: checked apps connect directly, unchecked apps use proxy. \nThe option to automatically select proxy applications is in the menu Auto connect at startup Automatically connects to the selected server at startup, which may be unsuccessful Mux Settings Enable Mux - Faster, but it may cause unstable connectivity\ncustomize how to handle TCP, UDP and QUIC below + Faster, but it may cause unstable connectivity\nCustomize how to handle TCP, UDP and QUIC below TCP connections(range -1 to 1024) XUDP connections(range -1 to 1024) Handling of QUIC in mux tunnel @@ -156,38 +169,40 @@ Use the sniffed domain name for routing only, and keep the target address as the IP address. Enable local DNS - DNS processed by core‘s DNS module (Recommended, if need routing Bypassing LAN and - mainland address) + DNS processed by core‘s DNS module (Recommended if you need routing bypassing LAN and mainland addresses) Enable fake DNS - local DNS returns fake IP address (faster, but it may not work for some apps) + Local DNS returns fake IP addresses (faster, but it may not work for some apps) Prefer IPv6 - Prefer IPv6 address and routes + Enable IPv6 routes and Prefer IPv6 addresses Remote DNS (udp/tcp/https/quic)(Optional) DNS VPN DNS (only IPv4/v6) + Does VPN bypass LAN + + VPN Interface Address Domestic DNS (Optional) DNS + DNS hosts (Format: domain:address,…) + domain:address,… + True delay test url (http/https) Url Allow connections from the LAN - Other devices can connect to proxy by your ip address through socks/http, Only enable in trusted network to avoid unauthorized connection - Allow connections from the LAN, Make sure you are in a trusted network + Other devices can connect to proxy by your IP address through local proxy. Only enable in trusted networks to avoid unauthorized connections + Allow connections from the LAN. Make sure you are in a trusted network allowInsecure - When TLS, the default allowInsecure + When TLS is selected, allow insecure connections by default - SOCKS5 proxy port - SOCKS5 proxy port - - HTTP proxy port - HTTP proxy port + Local proxy port + Local proxy port Local DNS port Local DNS port @@ -198,6 +213,13 @@ Start scanning immediately Open the camera to scan immediately at startup, otherwise you can choose to scan the code or select a photo in the toolbar + Append HTTP Proxy to VPN + HTTP proxy will be used directly from (browser/ some supported apps), without going through the virtual NIC device (Android 10+) + + Enable double column display + The profile list is displayed in double columns, allowing more content to be displayed on the screen. You need to restart the application to take effect. + + Feedback Feedback enhancements or bugs to GitHub Join Telegram Group @@ -205,6 +227,7 @@ Privacy policy About Source code + Open Source licenses Telegram channel Backup configuration Storage location: [%s], The backup will be cleared after uninstalling the app or clearing the storage @@ -215,10 +238,11 @@ Promotion,click for details(Donation can be removed) Automatic update subscriptions - Update your subscriptions automatically with an interval in background. Depending on the device, this feature may not always work + Update your subscriptions automatically at set intervals in the background. Depending on the device, this feature may not always work Auto Update Interval (Minutes, Min value 15) Log Level + Outbound domain pre-resolve method Mode Click me for more help Language @@ -239,31 +263,38 @@ Remarks regular filter Enable update Enable automatic update - Previous proxy remarks - Next proxy remarks - The remarks exists and is unique + Allow insecure HTTP address + Previous proxy configuration remarks + Next proxy configuration remarks + The configuration remarks exists and is unique Update current group subscription Tcping current group configuration Real delay current group configuration - Geo asset files + Asset files Sorting by test results Filter configuration file All groups Delete %d duplicate configurations + Delete %d configurations + Import %d configurations + Export %d configurations + Update %d configurations Start Service Confirm + Domain strategy Routing Settings - Separated by commas(,),choose domain or ip + Separated by commas(,), choose domain or ip Save Clear Routing Rule Settings Add rule - Import ruleset + Import predefined rulesets Existing rulesets will be deleted, are you sure to continue? Import ruleset from clipboard + Import ruleset from QRcode Export ruleset to clipboard Locked, keep this rule when import presets domain @@ -271,13 +302,14 @@ port protocol [http,tls,bittorrent] - network"[udp|tcp]" + network [udp|tcp] outboundTag Check Connectivity Testing… - Success: HTTP connection took %dms + Testing %d configurations… + Success: Connection took %dms Fail to detect internet connection: %s Internet Unavailable Error code: #%d @@ -292,12 +324,27 @@ Fragment Interval (min-max) Enable Fragment + Check for update + Already on the latest version + New version found: %s + Update now + Check Pre-release + Checking for update… + QRcode Export to clipboard Export full configuration to clipboard + + QRcode + Export to clipboard + Export full configuration to clipboard + Edit + Delete + + QRcode Export to clipboard @@ -318,6 +365,19 @@ China Whitelist China Blacklist Global + Iran Whitelist + + + + Follow config + Bypass + Not Bypass + + + + Do not resolve + Resolve and add to DNS Hosts + Resolve and replace domain diff --git a/V2rayNG/app/src/main/res/values/styles.xml b/V2rayNG/app/src/main/res/values/themes.xml similarity index 80% rename from V2rayNG/app/src/main/res/values/styles.xml rename to V2rayNG/app/src/main/res/values/themes.xml index aae3aa73..6ad204c0 100644 --- a/V2rayNG/app/src/main/res/values/styles.xml +++ b/V2rayNG/app/src/main/res/values/themes.xml @@ -2,9 +2,10 @@ @@ -14,7 +15,7 @@ - + + + diff --git a/V2rayNG/app/src/main/res/xml/app_widget_provider.xml b/V2rayNG/app/src/main/res/xml/app_widget_provider.xml index f457545f..877b1385 100644 --- a/V2rayNG/app/src/main/res/xml/app_widget_provider.xml +++ b/V2rayNG/app/src/main/res/xml/app_widget_provider.xml @@ -1,6 +1,6 @@ \ No newline at end of file diff --git a/V2rayNG/app/src/main/res/xml/network_security_config.xml b/V2rayNG/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 00000000..24579b10 --- /dev/null +++ b/V2rayNG/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,11 @@ + + + + + + + + + diff --git a/V2rayNG/app/src/main/res/xml/pref_settings.xml b/V2rayNG/app/src/main/res/xml/pref_settings.xml index 0649030f..b5ee7aab 100644 --- a/V2rayNG/app/src/main/res/xml/pref_settings.xml +++ b/V2rayNG/app/src/main/res/xml/pref_settings.xml @@ -19,6 +19,11 @@ android:title="@string/title_pref_is_booted" /> + + + + + + + + @@ -62,6 +88,11 @@ android:summary="@string/summary_pref_start_scan_immediate" android:title="@string/title_pref_start_scan_immediate" /> + + - - - + - - + android:key="pref_dns_hosts" + android:summary="@string/summary_pref_dns_hosts" + android:title="@string/title_pref_dns_hosts" /> + + + + + + + + - + + \ No newline at end of file diff --git a/V2rayNG/app/src/test/java/com/v2ray/ang/ExampleUnitTest.java b/V2rayNG/app/src/test/java/com/v2ray/ang/ExampleUnitTest.java deleted file mode 100644 index 3e28d640..00000000 --- a/V2rayNG/app/src/test/java/com/v2ray/ang/ExampleUnitTest.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.v2ray.ang; - -import static org.junit.Assert.assertEquals; - -import org.junit.Test; - -/** - * To work on unit tests, switch the Test Artifact in the Build Variants view. - */ -public class ExampleUnitTest { - @Test - public void addition_isCorrect() throws Exception { - assertEquals(4, 2 + 2); - } -} \ No newline at end of file diff --git a/V2rayNG/app/src/test/java/com/v2ray/ang/HttpUtilTest.kt b/V2rayNG/app/src/test/java/com/v2ray/ang/HttpUtilTest.kt new file mode 100644 index 00000000..07d87f4d --- /dev/null +++ b/V2rayNG/app/src/test/java/com/v2ray/ang/HttpUtilTest.kt @@ -0,0 +1,41 @@ +package com.v2ray.ang + +import com.v2ray.ang.util.HttpUtil +import org.junit.Assert.assertEquals +import org.junit.Test + +class HttpUtilTest { + + @Test + fun testIdnToASCII() { + // Regular URL remains unchanged + val regularUrl = "https://example.com/path" + assertEquals(regularUrl, HttpUtil.toIdnUrl(regularUrl)) + + // Non-ASCII URL converts to ASCII (Punycode) + val nonAsciiUrl = "https://例子.测试/path" + val expectedNonAscii = "https://xn--fsqu00a.xn--0zwm56d/path" + assertEquals(expectedNonAscii, HttpUtil.toIdnUrl(nonAsciiUrl)) + + // Mixed URL only converts the host part + val mixedUrl = "https://例子.com/测试" + val expectedMixed = "https://xn--fsqu00a.com/测试" + assertEquals(expectedMixed, HttpUtil.toIdnUrl(mixedUrl)) + + // URL with Basic Authentication using regular domain + val basicAuthUrl = "https://user:password@example.com/path" + assertEquals(basicAuthUrl, HttpUtil.toIdnUrl(basicAuthUrl)) + + // URL with Basic Authentication using non-ASCII domain + val basicAuthNonAscii = "https://user:password@例子.测试/path" + val expectedBasicAuthNonAscii = "https://user:password@xn--fsqu00a.xn--0zwm56d/path" + assertEquals(expectedBasicAuthNonAscii, HttpUtil.toIdnUrl(basicAuthNonAscii)) + + // URL with non-ASCII username and password + val nonAsciiAuth = "https://用户:密码@example.com/path" + // Basic auth credentials should remain unchanged as they're percent-encoded separately + assertEquals(nonAsciiAuth, HttpUtil.toIdnUrl(nonAsciiAuth)) + } + + +} \ No newline at end of file diff --git a/V2rayNG/app/src/test/kotlin/com/v2ray/ang/ExampleUnitTest.kt b/V2rayNG/app/src/test/java/com/v2ray/ang/UtilsTest.kt similarity index 65% rename from V2rayNG/app/src/test/kotlin/com/v2ray/ang/ExampleUnitTest.kt rename to V2rayNG/app/src/test/java/com/v2ray/ang/UtilsTest.kt index 7cda0075..77dd53ab 100644 --- a/V2rayNG/app/src/test/kotlin/com/v2ray/ang/ExampleUnitTest.kt +++ b/V2rayNG/app/src/test/java/com/v2ray/ang/UtilsTest.kt @@ -1,10 +1,17 @@ +package com.v2ray.ang + import com.v2ray.ang.util.Utils import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test -class UtilTest { +/** + * Example local unit test, which will execute on the development machine (host). + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +class UtilsTest { @Test fun test_parseInt() { @@ -37,5 +44,19 @@ class UtilTest { assertTrue(Utils.isIpAddress("240e:1234:abcd:12::6666")) assertTrue(Utils.isIpAddress("240e:1234:abcd:12::/64")) } -} + @Test + fun test_IsIpInCidr() { + assertTrue(Utils.isIpInCidr("192.168.1.1", "192.168.1.0/24")) + assertTrue(Utils.isIpInCidr("192.168.1.254", "192.168.1.0/24")) + assertFalse(Utils.isIpInCidr("192.168.2.1", "192.168.1.0/24")) + + assertTrue(Utils.isIpInCidr("10.0.0.0", "10.0.0.0/8")) + assertTrue(Utils.isIpInCidr("10.255.255.255", "10.0.0.0/8")) + assertFalse(Utils.isIpInCidr("11.0.0.0", "10.0.0.0/8")) + + assertFalse(Utils.isIpInCidr("invalid-ip", "192.168.1.0/24")) + assertFalse(Utils.isIpInCidr("192.168.1.1", "invalid-cidr")) + } + +} \ No newline at end of file diff --git a/V2rayNG/build.gradle.kts b/V2rayNG/build.gradle.kts index 22f79b4f..9998df72 100644 --- a/V2rayNG/build.gradle.kts +++ b/V2rayNG/build.gradle.kts @@ -1,6 +1,13 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. plugins { - id("com.android.application") version "8.4.2" apply false - id("com.android.library") version "8.4.2" apply false - id("org.jetbrains.kotlin.android") version "1.9.23" apply false + alias(libs.plugins.android.application) apply false + alias(libs.plugins.android.library) apply false + alias(libs.plugins.kotlin.android) apply false } + +buildscript { + dependencies { + classpath(libs.gradle.license.plugin) + } +} + diff --git a/V2rayNG/gradle.properties b/V2rayNG/gradle.properties index 9e2f93c2..6f65f16e 100644 --- a/V2rayNG/gradle.properties +++ b/V2rayNG/gradle.properties @@ -1,6 +1,24 @@ -kotlin.incremental=true +# Project-wide Gradle settings. +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx4096m -Dfile.encoding=UTF-8 +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. For more details, visit +# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects +# org.gradle.parallel=true +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app's APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn android.useAndroidX=true -android.enableJetifier=true +# Kotlin code style for this project: "official" or "obsolete": kotlin.code.style=official +# Enables namespacing of each library's R class so that its R class includes only the +# resources declared in the library itself and none from the library's dependencies, +# thereby reducing the size of the R class for that library android.nonTransitiveRClass=true -org.gradle.jvmargs=-Xmx2048m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 +kotlin.incremental=true \ No newline at end of file diff --git a/V2rayNG/gradle/libs.versions.toml b/V2rayNG/gradle/libs.versions.toml index f8aa64d9..04900e8c 100644 --- a/V2rayNG/gradle/libs.versions.toml +++ b/V2rayNG/gradle/libs.versions.toml @@ -1,63 +1,66 @@ [versions] -activityKtx = "1.9.2" -appcompat = "1.7.0" -cardview = "1.0.0" -constraintlayout = "2.1.4" -core = "3.5.3" -editorkit = "2.9.0" -flexbox = "3.0.0" -fragmentKtx = "1.8.3" -gson = "2.11.0" +agp = "8.10.1" +desugarJdkLibs = "2.1.5" +gradleLicensePlugin = "0.9.8" +kotlin = "2.1.21" +coreKtx = "1.16.0" junit = "4.13.2" -kotlinReflect = "2.0.20" -kotlinxCoroutinesCore = "1.9.0" -legacySupportV4 = "1.0.0" -lifecycleViewmodelKtx = "2.8.5" +junitVersion = "1.2.1" +espressoCore = "3.6.1" +appcompat = "1.7.1" material = "1.12.0" -mmkvStatic = "1.3.9" +activity = "1.10.1" +constraintlayout = "2.2.1" +mmkvStatic = "1.3.12" +gson = "2.12.1" +quickieFoss = "1.14.0" +kotlinxCoroutinesAndroid = "1.10.2" +kotlinxCoroutinesCore = "1.10.2" +swiperefreshlayout = "1.1.0" +toasty = "1.5.2" +editorkit = "2.9.0" +core = "3.5.3" +workRuntimeKtx = "2.10.2" +lifecycleViewmodelKtx = "2.9.1" multidex = "2.0.1" +mockitoMockitoInline = "5.2.0" +flexbox = "3.0.0" preferenceKtx = "1.2.1" -quickieBundled = "1.10.0" -recyclerview = "1.3.2" -rxandroid = "3.0.2" -rxjava = "3.1.9" -rxpermissions = "0.12" -toastcompat = "1.1.0" -viewpager2 = "1.1.0" -workRuntimeKtx = "2.9.1" - +recyclerview = "1.4.0" [libraries] -activity-ktx = { module = "androidx.activity:activity-ktx", version.ref = "activityKtx" } -appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" } -cardview = { module = "androidx.cardview:cardview", version.ref = "cardview" } -constraintlayout = { module = "androidx.constraintlayout:constraintlayout", version.ref = "constraintlayout" } -core = { module = "com.google.zxing:core", version.ref = "core" } -editorkit = { module = "com.blacksquircle.ui:editorkit", version.ref = "editorkit" } -flexbox = { module = "com.google.android.flexbox:flexbox", version.ref = "flexbox" } -fragment-ktx = { module = "androidx.fragment:fragment-ktx", version.ref = "fragmentKtx" } +androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } +androidx-swiperefreshlayout = { module = "androidx.swiperefreshlayout:swiperefreshlayout", version.ref = "swiperefreshlayout" } +desugar-jdk-libs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugarJdkLibs" } +gradle-license-plugin = { module = "com.jaredsburrows:gradle-license-plugin", version.ref = "gradleLicensePlugin" } +junit = { group = "junit", name = "junit", version.ref = "junit" } +androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } +androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } +androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } +material = { group = "com.google.android.material", name = "material", version.ref = "material" } +androidx-activity = { group = "androidx.activity", name = "activity", version.ref = "activity" } +androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" } +mmkv-static = { module = "com.tencent:mmkv-static", version.ref = "mmkvStatic" } gson = { module = "com.google.code.gson:gson", version.ref = "gson" } -junit = { module = "junit:junit", version.ref = "junit" } -kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlinReflect" } -kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinxCoroutinesCore" } -kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutinesCore" } +quickie-foss = { module = "com.github.T8RIN.QuickieExtended:quickie-foss", version.ref = "quickieFoss" } +kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinxCoroutinesAndroid" } +kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "kotlinxCoroutinesCore" } +toasty = { module = "com.github.GrenderG:Toasty", version.ref = "toasty" } +editorkit = { module = "com.blacksquircle.ui:editorkit", version.ref = "editorkit" } language-base = { module = "com.blacksquircle.ui:language-base", version.ref = "editorkit" } language-json = { module = "com.blacksquircle.ui:language-json", version.ref = "editorkit" } -legacy-support-v4 = { module = "androidx.legacy:legacy-support-v4", version.ref = "legacySupportV4" } +core = { module = "com.google.zxing:core", version.ref = "core" } +work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "workRuntimeKtx" } +work-multiprocess = { module = "androidx.work:work-multiprocess", version.ref = "workRuntimeKtx" } +lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "lifecycleViewmodelKtx" } lifecycle-livedata-ktx = { module = "androidx.lifecycle:lifecycle-livedata-ktx", version.ref = "lifecycleViewmodelKtx" } lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycleViewmodelKtx" } -lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "lifecycleViewmodelKtx" } -material = { module = "com.google.android.material:material", version.ref = "material" } -mmkv-static = { module = "com.tencent:mmkv-static", version.ref = "mmkvStatic" } multidex = { module = "androidx.multidex:multidex", version.ref = "multidex" } -quickie-bundled = { module = "io.github.g00fy2.quickie:quickie-bundled", version.ref = "quickieBundled" } +org-mockito-mockito-inline = { module = "org.mockito:mockito-inline", version.ref = "mockitoMockitoInline" } +mockito-kotlin = { module = "org.mockito.kotlin:mockito-kotlin", version.ref = "mockitoMockitoInline" } +flexbox = { module = "com.google.android.flexbox:flexbox", version.ref = "flexbox" } recyclerview = { module = "androidx.recyclerview:recyclerview", version.ref = "recyclerview" } preference-ktx = { module = "androidx.preference:preference-ktx", version.ref = "preferenceKtx" } -rxandroid = { module = "io.reactivex.rxjava3:rxandroid", version.ref = "rxandroid" } -rxjava = { module = "io.reactivex.rxjava3:rxjava", version.ref = "rxjava" } -rxpermissions = { module = "com.github.tbruyelle:rxpermissions", version.ref = "rxpermissions" } -toastcompat = { module = "me.drakeet.support:toastcompat", version.ref = "toastcompat" } -viewpager2 = { module = "androidx.viewpager2:viewpager2", version.ref = "viewpager2" } -work-multiprocess = { module = "androidx.work:work-multiprocess", version.ref = "workRuntimeKtx" } -work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "workRuntimeKtx" } - -[plugins] \ No newline at end of file +[plugins] +android-application = { id = "com.android.application", version.ref = "agp" } +kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +android-library = { id = "com.android.library", version.ref = "agp" } diff --git a/V2rayNG/gradle/wrapper/gradle-wrapper.jar b/V2rayNG/gradle/wrapper/gradle-wrapper.jar index 13372aef..e708b1c0 100644 Binary files a/V2rayNG/gradle/wrapper/gradle-wrapper.jar and b/V2rayNG/gradle/wrapper/gradle-wrapper.jar differ diff --git a/V2rayNG/gradle/wrapper/gradle-wrapper.properties b/V2rayNG/gradle/wrapper/gradle-wrapper.properties index 8b1a7919..b2eeb9db 100644 --- a/V2rayNG/gradle/wrapper/gradle-wrapper.properties +++ b/V2rayNG/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ -#Sun Jul 28 13:40:50 CST 2024 +#Thu Nov 14 12:42:51 BDT 2024 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.1-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/V2rayNG/gradlew b/V2rayNG/gradlew index 9d82f789..4f906e0c 100755 --- a/V2rayNG/gradlew +++ b/V2rayNG/gradlew @@ -1,4 +1,20 @@ -#!/usr/bin/env bash +#!/usr/bin/env sh + +# +# Copyright 2015 the original author or authors. +# +# 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 +# +# https://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. +# ############################################################################## ## @@ -6,42 +22,6 @@ ## ############################################################################## -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS="" - -APP_NAME="Gradle" -APP_BASE_NAME=`basename "$0"` - -# Use the maximum available, or set MAX_FD != -1 to use that value. -MAX_FD="maximum" - -warn ( ) { - echo "$*" -} - -die ( ) { - echo - echo "$*" - echo - exit 1 -} - -# OS specific support (must be 'true' or 'false'). -cygwin=false -msys=false -darwin=false -case "`uname`" in - CYGWIN* ) - cygwin=true - ;; - Darwin* ) - darwin=true - ;; - MINGW* ) - msys=true - ;; -esac - # Attempt to set APP_HOME # Resolve links: $0 may be a link PRG="$0" @@ -60,8 +40,49 @@ cd "`dirname \"$PRG\"`/" >/dev/null APP_HOME="`pwd -P`" cd "$SAVED" >/dev/null +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + # Determine the Java command to use to start the JVM. if [ -n "$JAVA_HOME" ] ; then if [ -x "$JAVA_HOME/jre/sh/java" ] ; then @@ -85,7 +106,7 @@ location of your Java installation." fi # Increase the maximum file descriptors if we can. -if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then MAX_FD_LIMIT=`ulimit -H -n` if [ $? -eq 0 ] ; then if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then @@ -105,10 +126,11 @@ if $darwin; then GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" fi -# For Cygwin, switch paths to Windows format before running java -if $cygwin ; then +# For Cygwin or MSYS, switch paths to Windows format before running java +if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then APP_HOME=`cygpath --path --mixed "$APP_HOME"` CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + JAVACMD=`cygpath --unix "$JAVACMD"` # We build the pattern for arguments to be converted via cygpath @@ -134,27 +156,30 @@ if $cygwin ; then else eval `echo args$i`="\"$arg\"" fi - i=$((i+1)) + i=`expr $i + 1` done case $i in - (0) set -- ;; - (1) set -- "$args0" ;; - (2) set -- "$args0" "$args1" ;; - (3) set -- "$args0" "$args1" "$args2" ;; - (4) set -- "$args0" "$args1" "$args2" "$args3" ;; - (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; - (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; - (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; - (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; - (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + 0) set -- ;; + 1) set -- "$args0" ;; + 2) set -- "$args0" "$args1" ;; + 3) set -- "$args0" "$args1" "$args2" ;; + 4) set -- "$args0" "$args1" "$args2" "$args3" ;; + 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; esac fi -# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules -function splitJvmOpts() { - JVM_OPTS=("$@") +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " } -eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS -JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME" +APP_ARGS=`save "$@"` -exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@" +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +exec "$JAVACMD" "$@" diff --git a/V2rayNG/gradlew.bat b/V2rayNG/gradlew.bat index 8a0b282a..107acd32 100644 --- a/V2rayNG/gradlew.bat +++ b/V2rayNG/gradlew.bat @@ -1,3 +1,19 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + @if "%DEBUG%" == "" @echo off @rem ########################################################################## @rem @@ -8,20 +24,23 @@ @rem Set local scope for the variables with windows NT shell if "%OS%"=="Windows_NT" setlocal -@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS= - set DIRNAME=%~dp0 if "%DIRNAME%" == "" set DIRNAME=. set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + @rem Find java.exe if defined JAVA_HOME goto findJavaFromJavaHome set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 -if "%ERRORLEVEL%" == "0" goto init +if "%ERRORLEVEL%" == "0" goto execute echo. echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. @@ -35,7 +54,7 @@ goto fail set JAVA_HOME=%JAVA_HOME:"=% set JAVA_EXE=%JAVA_HOME%/bin/java.exe -if exist "%JAVA_EXE%" goto init +if exist "%JAVA_EXE%" goto execute echo. echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% @@ -45,34 +64,14 @@ echo location of your Java installation. goto fail -:init -@rem Get command-line arguments, handling Windowz variants - -if not "%OS%" == "Windows_NT" goto win9xME_args -if "%@eval[2+2]" == "4" goto 4NT_args - -:win9xME_args -@rem Slurp the command line arguments. -set CMD_LINE_ARGS= -set _SKIP=2 - -:win9xME_args_slurp -if "x%~1" == "x" goto execute - -set CMD_LINE_ARGS=%* -goto execute - -:4NT_args -@rem Get arguments from the 4NT Shell from JP Software -set CMD_LINE_ARGS=%$ - :execute @rem Setup the command line set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + @rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* :end @rem End local scope for the variables with windows NT shell diff --git a/V2rayNG/settings.gradle.kts b/V2rayNG/settings.gradle.kts index 60ba4300..7ac30b30 100644 --- a/V2rayNG/settings.gradle.kts +++ b/V2rayNG/settings.gradle.kts @@ -1,8 +1,14 @@ pluginManagement { repositories { - gradlePluginPortal() - google() + google { + content { + includeGroupByRegex("com\\.android.*") + includeGroupByRegex("com\\.google.*") + includeGroupByRegex("androidx.*") + } + } mavenCentral() + gradlePluginPortal() } } dependencyResolutionManagement { @@ -10,9 +16,9 @@ dependencyResolutionManagement { repositories { google() mavenCentral() - jcenter() maven { url = uri("https://jitpack.io") } } } + rootProject.name = "v2rayNG" include(":app") diff --git a/badvpn b/badvpn new file mode 160000 index 00000000..e6848008 --- /dev/null +++ b/badvpn @@ -0,0 +1 @@ +Subproject commit e68480088a48c8930d29f54e30e9d6deaaf32c47 diff --git a/compile-tun2socks.sh b/compile-tun2socks.sh new file mode 100644 index 00000000..c0db6218 --- /dev/null +++ b/compile-tun2socks.sh @@ -0,0 +1,32 @@ +#!/bin/bash +set -o errexit +set -o pipefail +set -o nounset +# Set magic variables for current file & dir +__dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +__file="${__dir}/$(basename "${BASH_SOURCE[0]}")" +__base="$(basename ${__file} .sh)" +if [[ ! -d $NDK_HOME ]]; then + echo "Android NDK: NDK_HOME not found. please set env \$NDK_HOME" + exit 1 +fi +TMPDIR=$(mktemp -d) +clear_tmp () { + rm -rf $TMPDIR +} +trap 'echo -e "Aborted, error $? in command: $BASH_COMMAND"; trap ERR; clear_tmp; exit 1' ERR INT +install -m644 $__dir/tun2socks.mk $TMPDIR/ +pushd $TMPDIR +ln -s $__dir/badvpn badvpn +ln -s $__dir/libancillary libancillary +$NDK_HOME/ndk-build \ + NDK_PROJECT_PATH=. \ + APP_BUILD_SCRIPT=./tun2socks.mk \ + APP_ABI=all \ + APP_PLATFORM=android-21 \ + NDK_LIBS_OUT=$TMPDIR/libs \ + NDK_OUT=$TMPDIR/tmp \ + APP_SHORT_COMMANDS=false LOCAL_SHORT_COMMANDS=false -B -j4 +cp -r $TMPDIR/libs $__dir/ +popd +rm -rf $TMPDIR diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt new file mode 100644 index 00000000..34267c99 --- /dev/null +++ b/fastlane/metadata/android/en-US/full_description.txt @@ -0,0 +1,27 @@ +

A V2Ray client for Android, support Xray core and v2fly core

+ +

Telegram Channel

+ +

github_2dust

+ +

Usage

+ +

Geoip and Geosite

+ +
    +
  • geoip.dat and geosite.dat files are in Android/data/com.v2ray.ang/files/assets (path may differ on some Android device)
  • +
  • download feature will get enhanced version in this repo (Note it need a working proxy)
  • +
  • latest official domain list and ip list can be imported manually
  • +
  • possible to use third party dat file in the same folder, like h2y
  • +
+ +

More in our wiki

+ +

Development guide

+ +

Android project under V2rayNG folder can be compiled directly in Android Studio, or using Gradle wrapper. But the v2ray core inside the aar is (probably) outdated. +The aar can be compiled from the Golang project AndroidLibV2rayLite or AndroidLibXrayLite. +For a quick start, read guide for Go Mobile and Makefiles for Go Developers

+ +

v2rayNG can run on Android Emulators. For WSA, VPN permission need to be granted via +appops set [package name] ACTIVATE_VPN allow

diff --git a/fastlane/metadata/android/en-US/images/icon.png b/fastlane/metadata/android/en-US/images/icon.png new file mode 100644 index 00000000..03a4ce8a Binary files /dev/null and b/fastlane/metadata/android/en-US/images/icon.png differ diff --git a/fastlane/metadata/android/en-US/short_description.txt b/fastlane/metadata/android/en-US/short_description.txt new file mode 100644 index 00000000..38a05c70 --- /dev/null +++ b/fastlane/metadata/android/en-US/short_description.txt @@ -0,0 +1 @@ +A V2Ray client for Android, support Xray core and v2fly core diff --git a/fastlane/metadata/android/en-US/title.txt b/fastlane/metadata/android/en-US/title.txt new file mode 100644 index 00000000..84cf2a17 --- /dev/null +++ b/fastlane/metadata/android/en-US/title.txt @@ -0,0 +1 @@ +v2rayNG diff --git a/hysteria b/hysteria new file mode 160000 index 00000000..2adeec29 --- /dev/null +++ b/hysteria @@ -0,0 +1 @@ +Subproject commit 2adeec2900a7a0e3689f118580174cc528f9995a diff --git a/libancillary b/libancillary new file mode 160000 index 00000000..232d69a5 --- /dev/null +++ b/libancillary @@ -0,0 +1 @@ +Subproject commit 232d69a5ebb4461b572bd3f3b97088091e01c243 diff --git a/libhysteria2.sh b/libhysteria2.sh new file mode 100644 index 00000000..1bd88967 --- /dev/null +++ b/libhysteria2.sh @@ -0,0 +1,20 @@ +#!/bin/bash + +targets=( + "aarch64-linux-android21 arm64 arm64-v8a" + "armv7a-linux-androideabi21 arm armeabi-v7a" + "x86_64-linux-android21 amd64 x86_64" + "i686-linux-android21 386 x86" +) + +cd "hysteria" || exit + +for target in "${targets[@]}"; do + IFS=' ' read -r ndk_target goarch abi <<< "$target" + + echo "Building for ${abi} with ${ndk_target} (${goarch})" + + CC="${NDK_HOME}/toolchains/llvm/prebuilt/linux-x86_64/bin/${ndk_target}-clang" CGO_ENABLED=1 GOOS=android GOARCH=$goarch go build -o libs/$abi/libhysteria2.so -trimpath -ldflags "-s -w -buildid=" -buildvcs=false ./app + + echo "Built libhysteria2.so for ${abi}" +done diff --git a/tun2socks.mk b/tun2socks.mk new file mode 100644 index 00000000..1fab134e --- /dev/null +++ b/tun2socks.mk @@ -0,0 +1,124 @@ +# Copyright (C) 2009 The Android Open Source Project +# +# 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. +# +# +LOCAL_PATH := $(call my-dir) +ROOT_PATH := $(LOCAL_PATH) +######################################################## +## libancillary +######################################################## +include $(CLEAR_VARS) +ANCILLARY_SOURCE := fd_recv.c fd_send.c +LOCAL_MODULE := libancillary +#LOCAL_CFLAGS += -I$(LOCAL_PATH)/libancillary +LOCAL_C_INCLUDES := $(LOCAL_PATH)/libancillary +LOCAL_SRC_FILES := $(addprefix libancillary/, $(ANCILLARY_SOURCE)) +include $(BUILD_STATIC_LIBRARY) +######################################################## +## tun2socks +######################################################## +include $(CLEAR_VARS) +LOCAL_CFLAGS := -std=gnu99 +LOCAL_CFLAGS += -DBADVPN_THREADWORK_USE_PTHREAD -DBADVPN_LINUX -DBADVPN_BREACTOR_BADVPN -D_GNU_SOURCE +LOCAL_CFLAGS += -DBADVPN_USE_SIGNALFD -DBADVPN_USE_EPOLL +LOCAL_CFLAGS += -DBADVPN_LITTLE_ENDIAN -DBADVPN_THREAD_SAFE +LOCAL_CFLAGS += -DNDEBUG -DANDROID +LOCAL_CFLAGS += -I +LOCAL_STATIC_LIBRARIES := libancillary +LOCAL_C_INCLUDES := \ + $(LOCAL_PATH)/badvpn/lwip/src/include/ipv4 \ + $(LOCAL_PATH)/badvpn/lwip/src/include/ipv6 \ + $(LOCAL_PATH)/badvpn/lwip/src/include \ + $(LOCAL_PATH)/badvpn/lwip/custom \ + $(LOCAL_PATH)/badvpn \ + $(LOCAL_PATH)/libancillary +TUN2SOCKS_SOURCES := \ + base/BLog_syslog.c \ + system/BReactor_badvpn.c \ + system/BSignal.c \ + system/BConnection_common.c \ + system/BConnection_unix.c \ + system/BTime.c \ + system/BUnixSignal.c \ + system/BNetwork.c \ + system/BDatagram_common.c \ + system/BDatagram_unix.c \ + flow/StreamRecvInterface.c \ + flow/PacketRecvInterface.c \ + flow/PacketPassInterface.c \ + flow/StreamPassInterface.c \ + flow/SinglePacketBuffer.c \ + flow/BufferWriter.c \ + flow/PacketBuffer.c \ + flow/PacketStreamSender.c \ + flow/PacketPassConnector.c \ + flow/PacketProtoFlow.c \ + flow/PacketPassFairQueue.c \ + flow/PacketProtoEncoder.c \ + flow/PacketProtoDecoder.c \ + socksclient/BSocksClient.c \ + tuntap/BTap.c \ + lwip/src/core/udp.c \ + lwip/src/core/memp.c \ + lwip/src/core/init.c \ + lwip/src/core/pbuf.c \ + lwip/src/core/tcp.c \ + lwip/src/core/tcp_out.c \ + lwip/src/core/netif.c \ + lwip/src/core/def.c \ + lwip/src/core/ip.c \ + lwip/src/core/mem.c \ + lwip/src/core/tcp_in.c \ + lwip/src/core/stats.c \ + lwip/src/core/inet_chksum.c \ + lwip/src/core/timeouts.c \ + lwip/src/core/ipv4/icmp.c \ + lwip/src/core/ipv4/igmp.c \ + lwip/src/core/ipv4/ip4_addr.c \ + lwip/src/core/ipv4/ip4_frag.c \ + lwip/src/core/ipv4/ip4.c \ + lwip/src/core/ipv4/autoip.c \ + lwip/src/core/ipv6/ethip6.c \ + lwip/src/core/ipv6/inet6.c \ + lwip/src/core/ipv6/ip6_addr.c \ + lwip/src/core/ipv6/mld6.c \ + lwip/src/core/ipv6/dhcp6.c \ + lwip/src/core/ipv6/icmp6.c \ + lwip/src/core/ipv6/ip6.c \ + lwip/src/core/ipv6/ip6_frag.c \ + lwip/src/core/ipv6/nd6.c \ + lwip/custom/sys.c \ + tun2socks/tun2socks.c \ + base/DebugObject.c \ + base/BLog.c \ + base/BPending.c \ + flowextra/PacketPassInactivityMonitor.c \ + tun2socks/SocksUdpGwClient.c \ + udpgw_client/UdpGwClient.c \ + socks_udp_client/SocksUdpClient.c +LOCAL_MODULE := tun2socks +LOCAL_LDLIBS := -ldl -llog +LOCAL_LDFLAGS=-Wl,--build-id=none +LOCAL_SRC_FILES := $(addprefix badvpn/, $(TUN2SOCKS_SOURCES)) +LOCAL_BUILD_SCRIPT := BUILD_EXECUTABLE +LOCAL_MAKEFILE := $(local-makefile) +$(call check-defined-LOCAL_MODULE,$(LOCAL_BUILD_SCRIPT)) +$(call check-LOCAL_MODULE,$(LOCAL_MAKEFILE)) +$(call check-LOCAL_MODULE_FILENAME) +# we are building target objects +my := TARGET_ +$(call handle-module-filename,lib,$(TARGET_SONAME_EXTENSION)) +$(call handle-module-built) +LOCAL_MODULE_CLASS := EXECUTABLE +include $(BUILD_SYSTEM)/build-module.mk