Compare commits

..

72 Commits

Author SHA1 Message Date
2dust f9f0c905a4 up 2.0.9 2026-02-07 11:06:16 +08:00
2dust f997335113 Update libs.versions.toml 2026-02-07 11:05:47 +08:00
AmirMohammad Yazdanmanesh ed344cd281 Improve accessibility: mark decorative icons across remaining layouts (#5265)
Add importantForAccessibility="no" to decorative ImageViews in check
update, bypass list, routing, subscription, user asset, and widget
layouts. Add contentDescription to QR code ImageView.
2026-02-07 10:46:10 +08:00
2dust 94775f120a Migrate Hysteria2 pinSHA256 to pinnedCA256
Rename and migrate Hysteria2 certificate pin field from pinSHA256 to pinnedCA256 across code and UI, and add a one-time migration at startup.

Changes:
- Added SettingsManager.migrateHysteria2PinSHA256() and call in AngApplication to copy existing profile.pinSHA256 -> profile.pinnedCA256 and clear the old field, guarded by a migration flag.
- Updated Hysteria2Fmt to read/write the query param "pinSHA256" into the new ProfileItem.pinnedCA256 field.
- Updated ProfileItem equality to compare pinnedCA256 instead of pinSHA256.
- Removed the old EditText binding and reads/writes for et_pinsha256 in ServerActivity; UI now uses et_pinned_ca256 (layout id renamed).
- Updated layout and string resources: renamed the label/id to reflect pinned_ca256 and removed legacy pinSHA256 string entries in several locale files.

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

* Fix custom config
2026-01-16 19:52:18 +08:00
DHR60 a580494bde Update sdk (#5174) 2026-01-16 19:22:43 +08:00
2dust 66f100ebb3 up 2.0.2 2026-01-16 17:40:34 +08:00
2dust b0213ffdf6 Refactor FAB state handling in MainActivity 2026-01-16 17:40:09 +08:00
2dust 538b2eb0f8 Bug fix
https://github.com/2dust/v2rayNG/issues/5172
2026-01-16 16:36:01 +08:00
2dust f2093c4c52 Refactor real ping test logic into worker service 2026-01-16 16:28:35 +08:00
2dust 02ac57a4c6 Fix and improve port hopping interval handling
https://github.com/2dust/v2rayNG/issues/5171
2026-01-16 15:21:19 +08:00
2dust 379266f205 Add tun inbound to custom config if missing
Updated getV2rayCustomConfig to check for the presence of a 'tun' inbound in the configuration. If not present and HevTun is not used, the method adds a 'tun' inbound from the template configuration and updates the config accordingly.
2026-01-16 15:07:10 +08:00
2dust 23dbb35da7 Refactor MainActivity and clean up SettingsActivity 2026-01-15 17:54:15 +08:00
2dust 22a605ecda up 2.0.1 2026-01-15 14:51:11 +08:00
2dust c7dd45f4f2 Bug fix
https://github.com/2dust/v2rayNG/issues/5164
2026-01-15 14:49:29 +08:00
2dust 609e688d87 Fix HYSTERIA2 string 2026-01-15 14:30:10 +08:00
2dust a2a4ea79a9 Optimize coroutine dispatcher in V2RayTestService 2026-01-15 14:22:24 +08:00
2dust c08147c362 Refactor WebDAV backup path and file name handling 2026-01-15 11:52:18 +08:00
2dust c99fda9839 Bug fix
https://github.com/2dust/v2rayNG/issues/5158
2026-01-15 11:11:16 +08:00
2dust 587d103518 Fix enum declaration for RoutingType 2026-01-15 10:41:06 +08:00
Umor1st 192a4afe6d Add Russia whitelist routing option (#5161)
* Add Russia whitelist routing option

* Update RoutingType.kt

* Add custom routing file in assets

* Update localization
2026-01-15 10:34:11 +08:00
2dust ee69364f2b Remove hysteria submodule and related build steps 2026-01-15 10:32:10 +08:00
2dust 6be0bd7b21 Remove plugin framework and refactor Hysteria2 integration 2026-01-15 10:19:25 +08:00
117 changed files with 2208 additions and 2575 deletions
+6
View File
@@ -0,0 +1,6 @@
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "daily"
+7 -37
View File
@@ -16,7 +16,7 @@ jobs:
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v5 uses: actions/checkout@v6
with: with:
submodules: 'recursive' submodules: 'recursive'
fetch-depth: '0' fetch-depth: '0'
@@ -25,8 +25,8 @@ jobs:
uses: android-actions/setup-android@v3.2.0 uses: android-actions/setup-android@v3.2.0
with: with:
log-accepted-android-sdk-licenses: false log-accepted-android-sdk-licenses: false
cmdline-tools-version: '12266719' cmdline-tools-version: '13114758'
packages: 'platforms;android-35 build-tools;35.0.0 platform-tools' packages: 'platforms;android-36.1 build-tools;36.1.0 platform-tools'
- name: Install NDK - name: Install NDK
run: | run: |
@@ -77,38 +77,8 @@ jobs:
fileName: 'libv2ray.aar' fileName: 'libv2ray.aar'
out-file-path: V2rayNG/app/libs/ 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
if: steps.cache-libhysteria2-restore.outputs.cache-hit != 'true'
uses: actions/setup-go@v5.4.0
with:
go-version-file: 'AndroidLibXrayLite/go.mod'
cache: false
- name: Build libhysteria2
if: steps.cache-libhysteria2-restore.outputs.cache-hit != 'true'
run: |
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: Copy libhysteria2
run: |
cp -r ${{ github.workspace }}/hysteria/libs ${{ github.workspace }}/V2rayNG/app
- name: Setup Java - name: Setup Java
uses: actions/setup-java@v4.7.0 uses: actions/setup-java@v5
with: with:
distribution: 'temurin' distribution: 'temurin'
java-version: '21' java-version: '21'
@@ -129,21 +99,21 @@ jobs:
./gradlew assembleRelease -Pandroid.injected.signing.store.file=${{ steps.android_keystore.outputs.filePath }} -Pandroid.injected.signing.store.password=${{ secrets.APP_KEYSTORE_PASSWORD }} -Pandroid.injected.signing.key.alias=${{ secrets.APP_KEYSTORE_ALIAS }} -Pandroid.injected.signing.key.password=${{ secrets.APP_KEY_PASSWORD }} ./gradlew assembleRelease -Pandroid.injected.signing.store.file=${{ steps.android_keystore.outputs.filePath }} -Pandroid.injected.signing.store.password=${{ secrets.APP_KEYSTORE_PASSWORD }} -Pandroid.injected.signing.key.alias=${{ secrets.APP_KEYSTORE_ALIAS }} -Pandroid.injected.signing.key.password=${{ secrets.APP_KEY_PASSWORD }}
- name: Upload arm64-v8a APK - name: Upload arm64-v8a APK
uses: actions/upload-artifact@v4.6.2 uses: actions/upload-artifact@v6
if: ${{ success() }} if: ${{ success() }}
with: with:
name: arm64-v8a name: arm64-v8a
path: ${{ github.workspace }}/V2rayNG/app/build/outputs/apk/*/release/*arm64-v8a*.apk path: ${{ github.workspace }}/V2rayNG/app/build/outputs/apk/*/release/*arm64-v8a*.apk
- name: Upload armeabi-v7a APK - name: Upload armeabi-v7a APK
uses: actions/upload-artifact@v4.6.2 uses: actions/upload-artifact@v6
if: ${{ success() }} if: ${{ success() }}
with: with:
name: armeabi-v7a name: armeabi-v7a
path: ${{ github.workspace }}/V2rayNG/app/build/outputs/apk/*/release/*armeabi-v7a*.apk path: ${{ github.workspace }}/V2rayNG/app/build/outputs/apk/*/release/*armeabi-v7a*.apk
- name: Upload x86 APK - name: Upload x86 APK
uses: actions/upload-artifact@v4.6.2 uses: actions/upload-artifact@v6
if: ${{ success() }} if: ${{ success() }}
with: with:
name: x86-apk name: x86-apk
+1 -1
View File
@@ -11,6 +11,6 @@ jobs:
go: go:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v6
- name: Validate Fastlane Supply Metadata - name: Validate Fastlane Supply Metadata
uses: ashutoshgngwr/validate-fastlane-supply-metadata@v2.1.0 uses: ashutoshgngwr/validate-fastlane-supply-metadata@v2.1.0
-3
View File
@@ -1,6 +1,3 @@
[submodule "hysteria"]
path = hysteria
url = https://github.com/apernet/hysteria
[submodule "AndroidLibXrayLite"] [submodule "AndroidLibXrayLite"]
path = AndroidLibXrayLite path = AndroidLibXrayLite
url = https://github.com/2dust/AndroidLibXrayLite url = https://github.com/2dust/AndroidLibXrayLite
+2 -2
View File
@@ -12,8 +12,8 @@ android {
applicationId = "com.v2ray.ang" applicationId = "com.v2ray.ang"
minSdk = 24 minSdk = 24
targetSdk = 36 targetSdk = 36
versionCode = 700 versionCode = 709
versionName = "2.0.0" versionName = "2.0.9"
multiDexEnabled = true multiDexEnabled = true
val abiFilterList = (properties["ABI_FILTERS"] as? String)?.split(';') val abiFilterList = (properties["ABI_FILTERS"] as? String)?.split(';')
@@ -0,0 +1,43 @@
[
{
"remarks": "Bypass bittorrent",
"outboundTag": "direct",
"protocol": [
"bittorrent"
]
},
{
"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 Russia domains",
"outboundTag": "direct",
"domain": [
"geosite:category-ru"
]
},
{
"remarks": "Bypass Russia IP",
"outboundTag": "direct",
"ip": [
"geoip:ru"
]
}
]
@@ -42,6 +42,13 @@
"name": "xray0", "name": "xray0",
"MTU": 1500, "MTU": 1500,
"userLevel": 8 "userLevel": 8
},
"sniffing": {
"enabled": true,
"destOverride": [
"http",
"tls"
]
} }
} }
], ],
@@ -41,6 +41,7 @@ class AngApplication : MultiDexApplication() {
WorkManager.initialize(this, workManagerConfiguration) WorkManager.initialize(this, workManagerConfiguration)
SettingsManager.initRoutingRulesets(this) SettingsManager.initRoutingRulesets(this)
SettingsManager.migrateHysteria2PinSHA256()
es.dmoral.toasty.Toasty.Config.getInstance() es.dmoral.toasty.Toasty.Config.getInstance()
.setGravity(android.view.Gravity.BOTTOM, 0, 200) .setGravity(android.view.Gravity.BOTTOM, 0, 200)
@@ -9,7 +9,9 @@ object AppConfig {
/** Directory names used in the app's file system. */ /** Directory names used in the app's file system. */
const val DIR_ASSETS = "assets" const val DIR_ASSETS = "assets"
const val DIR_BACKUPS = "backups"
const val WEBDAV_BACKUP_DIR = "backups"
const val WEBDAV_BACKUP_FILE_NAME = "backup_ng.zip"
/** Legacy configuration keys. */ /** Legacy configuration keys. */
const val ANG_CONFIG = "ang_config" const val ANG_CONFIG = "ang_config"
@@ -95,6 +97,7 @@ object AppConfig {
const val TAG_FRAGMENT = "fragment" const val TAG_FRAGMENT = "fragment"
const val TAG_DNS = "dns-module" const val TAG_DNS = "dns-module"
const val TAG_DOMESTIC_DNS = "domestic-dns" const val TAG_DOMESTIC_DNS = "domestic-dns"
const val TAG_BALANCER = "proxy-round"
/** Network-related constants. */ /** Network-related constants. */
const val UPLINK = "uplink" const val UPLINK = "uplink"
@@ -169,6 +172,7 @@ object AppConfig {
const val TROJAN = "trojan://" const val TROJAN = "trojan://"
const val WIREGUARD = "wireguard://" const val WIREGUARD = "wireguard://"
const val TUIC = "tuic://" const val TUIC = "tuic://"
const val HYSTERIA = "hysteria://"
const val HYSTERIA2 = "hysteria2://" const val HYSTERIA2 = "hysteria2://"
const val HY2 = "hy2://" const val HY2 = "hy2://"
@@ -0,0 +1,32 @@
package com.v2ray.ang.contracts
/**
* A common Adapter -> host callback interface that includes common actions: edit, remove and refresh.
* Extend this interface or define more specific interfaces for different adapters as needed.
*/
interface BaseAdapterListener {
/**
* Request the host to edit the specified item.
* @param guid Unique identifier (GUID) of the item
* @param position Current position in the adapter (optional; host should validate it)
*/
fun onEdit(guid: String, position: Int)
/**
* Request the host to remove the specified item. Position is provided for optional animation or validation.
* @param guid Unique identifier (GUID) of the item
* @param position Current position in the adapter (optional; host should validate it)
*/
fun onRemove(guid: String, position: Int)
/**
* Request the host to share the specified URL.
* @param url The URL to be shared
*/
fun onShare(url: String)
/**
* Request the host to refresh data (for example, reload from the ViewModel or call notifyDataSetChanged).
*/
fun onRefreshData()
}
@@ -0,0 +1,13 @@
package com.v2ray.ang.contracts
import com.v2ray.ang.dto.ProfileItem
interface MainAdapterListener :BaseAdapterListener {
fun onEdit(guid: String, position: Int, profile: ProfileItem)
fun onSelectServer(guid: String)
fun onShare(guid: String, profile: ProfileItem, position: Int, more: Boolean)
}
@@ -1,4 +1,4 @@
package com.v2ray.ang.service package com.v2ray.ang.contracts
import android.app.Service import android.app.Service
@@ -1,4 +1,4 @@
package com.v2ray.ang.service package com.v2ray.ang.contracts
/** /**
* Interface that defines the control operations for tun2socks implementations. * Interface that defines the control operations for tun2socks implementations.
@@ -0,0 +1,6 @@
package com.v2ray.ang.dto
data class AssetUrlCache(
val guid: String,
val assetUrl: AssetUrlItem
)
@@ -4,6 +4,5 @@ data class ConfigResult(
var status: Boolean, var status: Boolean,
var guid: String? = null, var guid: String? = null,
var content: String = "", var content: String = "",
var socksPort: Int? = null,
) )
@@ -1,46 +0,0 @@
package com.v2ray.ang.dto
data class Hysteria2Bean(
val server: String?,
val auth: String?,
val lazy: Boolean? = true,
val obfs: ObfsBean? = null,
val socks5: Socks5Bean? = null,
val http: Socks5Bean? = null,
val tls: TlsBean? = null,
val transport: TransportBean? = null,
val bandwidth: BandwidthBean? = null,
) {
data class ObfsBean(
val type: String?,
val salamander: SalamanderBean?
) {
data class SalamanderBean(
val password: String?,
)
}
data class Socks5Bean(
val listen: String?,
)
data class TlsBean(
val sni: String?,
val insecure: Boolean?,
val pinSHA256: String?,
)
data class TransportBean(
val type: String?,
val udp: TransportUdpBean?
) {
data class TransportUdpBean(
val hopInterval: String?,
)
}
data class BandwidthBean(
val down: String?,
val up: String?,
)
}
@@ -5,6 +5,7 @@ import com.v2ray.ang.AppConfig.PORT_SOCKS
import com.v2ray.ang.AppConfig.TAG_BLOCKED import com.v2ray.ang.AppConfig.TAG_BLOCKED
import com.v2ray.ang.AppConfig.TAG_DIRECT import com.v2ray.ang.AppConfig.TAG_DIRECT
import com.v2ray.ang.AppConfig.TAG_PROXY import com.v2ray.ang.AppConfig.TAG_PROXY
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.util.Utils import com.v2ray.ang.util.Utils
data class ProfileItem( data class ProfileItem(
@@ -14,6 +15,7 @@ data class ProfileItem(
var addedTime: Long = System.currentTimeMillis(), var addedTime: Long = System.currentTimeMillis(),
var remarks: String = "", var remarks: String = "",
var description: String? = null,
var server: String? = null, var server: String? = null,
var serverPort: String? = null, var serverPort: String? = null,
@@ -42,6 +44,7 @@ data class ProfileItem(
var insecure: Boolean? = null, var insecure: Boolean? = null,
var echConfigList: String? = null, var echConfigList: String? = null,
var echForceQuery: String? = null, var echForceQuery: String? = null,
var pinnedCA256: String? = null,
var publicKey: String? = null, var publicKey: String? = null,
var shortId: String? = null, var shortId: String? = null,
@@ -121,7 +124,7 @@ data class ProfileItem(
&& this.obfsPassword == obj.obfsPassword && this.obfsPassword == obj.obfsPassword
&& this.portHopping == obj.portHopping && this.portHopping == obj.portHopping
&& this.portHoppingInterval == obj.portHoppingInterval && this.portHoppingInterval == obj.portHoppingInterval
&& this.pinSHA256 == obj.pinSHA256 && this.pinnedCA256 == obj.pinnedCA256
) )
} }
} }
@@ -0,0 +1,6 @@
package com.v2ray.ang.dto
data class SubscriptionCache(
val guid: String,
val subscription: SubscriptionItem
)
@@ -2,7 +2,7 @@ package com.v2ray.ang.dto
import com.google.gson.annotations.SerializedName import com.google.gson.annotations.SerializedName
import com.v2ray.ang.AppConfig import com.v2ray.ang.AppConfig
import com.v2ray.ang.util.Utils import com.v2ray.ang.enums.EConfigType
data class V2rayConfig( data class V2rayConfig(
var remarks: String? = null, var remarks: String? = null,
@@ -35,7 +35,7 @@ data class V2rayConfig(
var protocol: String, var protocol: String,
var listen: String? = null, var listen: String? = null,
var settings: InSettingsBean? = null, var settings: InSettingsBean? = null,
val sniffing: SniffingBean? = null, var sniffing: SniffingBean? = null,
val streamSettings: Any? = null, val streamSettings: Any? = null,
val allocate: Any? = null val allocate: Any? = null
) { ) {
@@ -76,7 +76,7 @@ data class V2rayConfig(
/*DNS*/ /*DNS*/
val network: String? = null, val network: String? = null,
var address: Any? = null, var address: Any? = null,
val port: Int? = null, var port: Int? = null,
/*Freedom*/ /*Freedom*/
var domainStrategy: String? = null, var domainStrategy: String? = null,
val redirect: String? = null, val redirect: String? = null,
@@ -89,6 +89,7 @@ data class V2rayConfig(
var reserved: List<Int>? = null, var reserved: List<Int>? = null,
var mtu: Int? = null, var mtu: Int? = null,
var obfsPassword: String? = null, var obfsPassword: String? = null,
var version: Int? = null,
) { ) {
data class VnextBean( data class VnextBean(
@@ -160,7 +161,8 @@ data class V2rayConfig(
var quicSettings: QuicSettingBean? = null, var quicSettings: QuicSettingBean? = null,
var realitySettings: TlsSettingsBean? = null, var realitySettings: TlsSettingsBean? = null,
var grpcSettings: GrpcSettingsBean? = null, var grpcSettings: GrpcSettingsBean? = null,
var hy2steriaSettings: Hy2steriaSettingsBean? = null, var hysteriaSettings: HysteriaSettingsBean? = null,
var finalmask: FinalMaskBean? = null,
val dsSettings: Any? = null, val dsSettings: Any? = null,
var sockopt: SockoptBean? = null var sockopt: SockoptBean? = null
) { ) {
@@ -200,15 +202,8 @@ data class V2rayConfig(
var downlinkCapacity: Int = 100, var downlinkCapacity: Int = 100,
var congestion: Boolean = false, var congestion: Boolean = false,
var readBufferSize: Int = 1, var readBufferSize: Int = 1,
var writeBufferSize: Int = 1, var writeBufferSize: Int = 1
var header: HeaderBean = HeaderBean(), )
var seed: String? = null
) {
data class HeaderBean(
var type: String = "none",
var domain: String? = null
)
}
data class WsSettingsBean( data class WsSettingsBean(
var path: String? = null, var path: String? = null,
@@ -247,7 +242,8 @@ data class V2rayConfig(
var dialerProxy: String? = null, var dialerProxy: String? = null,
var domainStrategy: String? = null, var domainStrategy: String? = null,
var happyEyeballs: HappyEyeballsBean? = null, var happyEyeballs: HappyEyeballsBean? = null,
) )
data class HappyEyeballsBean( data class HappyEyeballsBean(
var prioritizeIPv6: Boolean? = null, var prioritizeIPv6: Boolean? = null,
var maxConcurrentTry: Int? = 4, var maxConcurrentTry: Int? = 4,
@@ -269,6 +265,7 @@ data class V2rayConfig(
val enableSessionResumption: Boolean? = null, val enableSessionResumption: Boolean? = null,
var echConfigList: String? = null, var echConfigList: String? = null,
var echForceQuery: String? = null, var echForceQuery: String? = null,
var pinnedPeerCertSha256: String? = null,
// REALITY settings // REALITY settings
val show: Boolean = false, val show: Boolean = false,
var publicKey: String? = null, var publicKey: String? = null,
@@ -293,18 +290,33 @@ data class V2rayConfig(
var health_check_timeout: Int? = null var health_check_timeout: Int? = null
) )
data class Hy2steriaSettingsBean( data class HysteriaSettingsBean(
var password: String? = null, var version: Int,
var use_udp_extension: Boolean? = true, var auth: String? = null,
var congestion: Hy2CongestionBean? = null var up: String? = null,
var down: String? = null,
var udphop: HysteriaUdpHopBean? = null
) { ) {
data class Hy2CongestionBean( data class HysteriaUdpHopBean(
var type: String? = "bbr", var port: String? = null,
var up_mbps: Int? = null, var interval: Int? = null
var down_mbps: Int? = null,
) )
} }
data class FinalMaskBean(
var tcp: List<MaskBean>? = null,
var udp: List<MaskBean>? = null
) {
data class MaskBean(
var type: String,
var settings: MaskSettingsBean? = null
) {
data class MaskSettingsBean(
var password: String? = null,
var domain: String? = null
)
}
}
} }
data class MuxBean( data class MuxBean(
@@ -323,11 +335,14 @@ data class V2rayConfig(
|| protocol.equals(EConfigType.SOCKS.name, true) || protocol.equals(EConfigType.SOCKS.name, true)
|| protocol.equals(EConfigType.HTTP.name, true) || protocol.equals(EConfigType.HTTP.name, true)
|| protocol.equals(EConfigType.TROJAN.name, true) || protocol.equals(EConfigType.TROJAN.name, true)
|| protocol.equals(EConfigType.HYSTERIA2.name, true)
) { ) {
return settings?.servers?.first()?.address return settings?.servers?.first()?.address
} else if (protocol.equals(EConfigType.WIREGUARD.name, true)) { } else if (protocol.equals(EConfigType.WIREGUARD.name, true)) {
return settings?.peers?.first()?.endpoint?.substringBeforeLast(":") return settings?.peers?.first()?.endpoint?.substringBeforeLast(":")
} else if (protocol.equals(EConfigType.HYSTERIA2.name, true)
|| protocol.equals(EConfigType.HYSTERIA.name, true)
) {
return settings?.address as String?
} }
return null return null
} }
@@ -341,132 +356,14 @@ data class V2rayConfig(
|| protocol.equals(EConfigType.SOCKS.name, true) || protocol.equals(EConfigType.SOCKS.name, true)
|| protocol.equals(EConfigType.HTTP.name, true) || protocol.equals(EConfigType.HTTP.name, true)
|| protocol.equals(EConfigType.TROJAN.name, true) || protocol.equals(EConfigType.TROJAN.name, true)
|| protocol.equals(EConfigType.HYSTERIA2.name, true)
) { ) {
return settings?.servers?.first()?.port return settings?.servers?.first()?.port
} else if (protocol.equals(EConfigType.WIREGUARD.name, true)) { } else if (protocol.equals(EConfigType.WIREGUARD.name, true)) {
return settings?.peers?.first()?.endpoint?.substringAfterLast(":")?.toInt() return settings?.peers?.first()?.endpoint?.substringAfterLast(":")?.toInt()
} } else if (protocol.equals(EConfigType.HYSTERIA2.name, true)
return null || protocol.equals(EConfigType.HYSTERIA.name, true)
}
fun getServerAddressAndPort(): String {
val address = getServerAddress().orEmpty()
val port = getServerPort()
return Utils.getIpv6Address(address) + ":" + port
}
fun getPassword(): String? {
if (protocol.equals(EConfigType.VMESS.name, true)
|| protocol.equals(EConfigType.VLESS.name, true)
) { ) {
return settings?.vnext?.first()?.users?.first()?.id return settings?.port
} else if (protocol.equals(EConfigType.SHADOWSOCKS.name, true)
|| protocol.equals(EConfigType.TROJAN.name, true)
|| protocol.equals(EConfigType.HYSTERIA2.name, true)
) {
return settings?.servers?.first()?.password
} else if (protocol.equals(EConfigType.SOCKS.name, true)
|| protocol.equals(EConfigType.HTTP.name, true)
) {
return settings?.servers?.first()?.users?.first()?.pass
} else if (protocol.equals(EConfigType.WIREGUARD.name, true)) {
return settings?.secretKey
}
return null
}
fun getSecurityEncryption(): String? {
return when {
protocol.equals(EConfigType.VMESS.name, true) -> settings?.vnext?.first()?.users?.first()?.security
protocol.equals(EConfigType.VLESS.name, true) -> settings?.vnext?.first()?.users?.first()?.encryption
protocol.equals(EConfigType.SHADOWSOCKS.name, true) -> settings?.servers?.first()?.method
else -> null
}
}
fun getTransportSettingDetails(): List<String?>? {
if (protocol.equals(EConfigType.VMESS.name, true)
|| protocol.equals(EConfigType.VLESS.name, true)
|| protocol.equals(EConfigType.TROJAN.name, true)
|| protocol.equals(EConfigType.SHADOWSOCKS.name, true)
) {
val transport = streamSettings?.network ?: return null
return when (transport) {
NetworkType.TCP.type -> {
val tcpSetting = streamSettings?.tcpSettings ?: return null
listOf(
tcpSetting.header.type,
tcpSetting.header.request?.headers?.Host?.joinToString(",").orEmpty(),
tcpSetting.header.request?.path?.joinToString(",").orEmpty()
)
}
NetworkType.KCP.type -> {
val kcpSetting = streamSettings?.kcpSettings ?: return null
listOf(
kcpSetting.header.type,
"",
kcpSetting.seed.orEmpty()
)
}
NetworkType.WS.type -> {
val wsSetting = streamSettings?.wsSettings ?: return null
listOf(
"",
wsSetting.headers.Host,
wsSetting.path
)
}
NetworkType.HTTP_UPGRADE.type -> {
val httpupgradeSetting = streamSettings?.httpupgradeSettings ?: return null
listOf(
"",
httpupgradeSetting.host,
httpupgradeSetting.path
)
}
NetworkType.XHTTP.type -> {
val xhttpSettings = streamSettings?.xhttpSettings ?: return null
listOf(
"",
xhttpSettings.host,
xhttpSettings.path
)
}
NetworkType.H2.type -> {
val h2Setting = streamSettings?.httpSettings ?: return null
listOf(
"",
h2Setting.host.joinToString(","),
h2Setting.path
)
}
// "quic" -> {
// val quicSetting = streamSettings?.quicSettings ?: return null
// listOf(
// quicSetting.header.type,
// quicSetting.security,
// quicSetting.key
// )
// }
NetworkType.GRPC.type -> {
val grpcSetting = streamSettings?.grpcSettings ?: return null
listOf(
if (grpcSetting.multiMode == true) "multi" else "gun",
grpcSetting.authority.orEmpty(),
grpcSetting.serviceName
)
}
else -> null
}
} }
return null return null
} }
@@ -1,8 +1,7 @@
package com.v2ray.ang.dto package com.v2ray.ang.enums
import com.v2ray.ang.AppConfig import com.v2ray.ang.AppConfig
enum class EConfigType(val value: Int, val protocolScheme: String) { enum class EConfigType(val value: Int, val protocolScheme: String) {
VMESS(1, AppConfig.VMESS), VMESS(1, AppConfig.VMESS),
CUSTOM(2, AppConfig.CUSTOM), CUSTOM(2, AppConfig.CUSTOM),
@@ -14,10 +13,11 @@ enum class EConfigType(val value: Int, val protocolScheme: String) {
// TUIC(8, AppConfig.TUIC), // TUIC(8, AppConfig.TUIC),
HYSTERIA2(9, AppConfig.HYSTERIA2), HYSTERIA2(9, AppConfig.HYSTERIA2),
HYSTERIA(900, AppConfig.HYSTERIA),
HTTP(10, AppConfig.HTTP), HTTP(10, AppConfig.HTTP),
POLICYGROUP (101, AppConfig.CUSTOM); POLICYGROUP (101, AppConfig.CUSTOM);
companion object { companion object {
fun fromInt(value: Int) = entries.firstOrNull { it.value == value } fun fromInt(value: Int) = entries.firstOrNull { it.value == value }
} }
} }
@@ -1,4 +1,4 @@
package com.v2ray.ang.dto package com.v2ray.ang.enums
enum class Language(val code: String) { enum class Language(val code: String) {
AUTO("auto"), AUTO("auto"),
@@ -1,4 +1,4 @@
package com.v2ray.ang.dto package com.v2ray.ang.enums
enum class NetworkType(val type: String) { enum class NetworkType(val type: String) {
TCP("tcp"), TCP("tcp"),
@@ -10,7 +10,8 @@ enum class NetworkType(val type: String) {
H2("h2"), H2("h2"),
//QUIC("quic"), //QUIC("quic"),
GRPC("grpc"); GRPC("grpc"),
HYSTERIA("hysteria");
companion object { companion object {
fun fromString(type: String?) = entries.find { it.type == type } ?: TCP fun fromString(type: String?) = entries.find { it.type == type } ?: TCP
@@ -0,0 +1,44 @@
package com.v2ray.ang.enums
import android.Manifest
import android.os.Build
import androidx.annotation.RequiresApi
/**
* Permission types used in the app, handling API level differences.
*/
enum class PermissionType {
/** Camera permission (used for scanning QR codes) */
CAMERA {
override fun getPermission(): String = Manifest.permission.CAMERA
},
/** Read storage / media permission (adapts to Android version) */
READ_STORAGE {
override fun getPermission(): String {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
Manifest.permission.READ_MEDIA_IMAGES
} else {
Manifest.permission.READ_EXTERNAL_STORAGE
}
}
},
/** Notification permission (Android 13+) */
POST_NOTIFICATIONS {
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
override fun getPermission(): String = Manifest.permission.POST_NOTIFICATIONS
};
/** Return the actual Android permission string */
abstract fun getPermission(): String
/** Return a human-readable label for the permission */
fun getLabel(): String {
return when (this) {
CAMERA -> "Camera"
READ_STORAGE -> "Storage"
POST_NOTIFICATIONS -> "Notification"
}
}
}
@@ -1,10 +1,11 @@
package com.v2ray.ang.dto package com.v2ray.ang.enums
enum class RoutingType(val fileName: String) { enum class RoutingType(val fileName: String) {
WHITE("custom_routing_white"), WHITE("custom_routing_white"),
BLACK("custom_routing_black"), BLACK("custom_routing_black"),
GLOBAL("custom_routing_global"), GLOBAL("custom_routing_global"),
WHITE_IRAN("custom_routing_white_iran"); WHITE_IRAN("custom_routing_white_iran"),
WHITE_RUSSIA("custom_routing_white_russia");
companion object { companion object {
fun fromIndex(index: Int): RoutingType { fun fromIndex(index: Int): RoutingType {
@@ -13,6 +14,7 @@ enum class RoutingType(val fileName: String) {
1 -> BLACK 1 -> BLACK
2 -> GLOBAL 2 -> GLOBAL
3 -> WHITE_IRAN 3 -> WHITE_IRAN
4 -> WHITE_RUSSIA
else -> WHITE else -> WHITE
} }
} }
@@ -1,4 +1,4 @@
package com.v2ray.ang.dto package com.v2ray.ang.enums
/** /**
* VPN interface address configuration enum class * VPN interface address configuration enum class
@@ -11,6 +11,7 @@ import com.v2ray.ang.AngApplication
import es.dmoral.toasty.Toasty import es.dmoral.toasty.Toasty
import java.io.Serializable import java.io.Serializable
import java.net.URI import java.net.URI
import java.util.Locale
val Context.v2RayApplication: AngApplication? val Context.v2RayApplication: AngApplication?
get() = applicationContext as? AngApplication get() = applicationContext as? AngApplication
@@ -92,7 +93,7 @@ fun Long.toTrafficString(): String {
size /= DIVISOR size /= DIVISOR
unitIndex++ unitIndex++
} }
return String.format("%.1f %s", size, units[unitIndex]) return String.format(Locale.getDefault(), "%.1f %s", size, units[unitIndex])
} }
val URI.idnHost: String val URI.idnHost: String
@@ -105,6 +106,13 @@ val URI.idnHost: String
*/ */
fun String?.removeWhiteSpace(): String? = this?.replace(" ", "") fun String?.removeWhiteSpace(): String? = this?.replace(" ", "")
/**
* Returns null if the string is null or blank, otherwise returns the string itself.
*
* @return The string or null.
*/
fun String?.nullIfBlank(): String? = this?.takeIf { it.isNotBlank() }
/** /**
* Converts the string to a Long value, or returns 0 if the conversion fails. * Converts the string to a Long value, or returns 0 if the conversion fails.
* *
@@ -168,7 +176,7 @@ inline fun <reified T : Serializable> Intent.serializable(key: String): T? = whe
* *
* @return True if the CharSequence is not null and not empty, false otherwise. * @return True if the CharSequence is not null and not empty, false otherwise.
*/ */
fun CharSequence?.isNotNullEmpty(): Boolean = this != null && this.isNotEmpty() fun CharSequence?.isNotNullEmpty(): Boolean = !this.isNullOrBlank()
fun String.concatUrl(vararg paths: String): String { fun String.concatUrl(vararg paths: String): String {
val builder = StringBuilder(this.trimEnd('/')) val builder = StringBuilder(this.trimEnd('/'))
@@ -1,6 +1,6 @@
package com.v2ray.ang.fmt package com.v2ray.ang.fmt
import com.v2ray.ang.dto.EConfigType import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.dto.ProfileItem import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.V2rayConfig import com.v2ray.ang.dto.V2rayConfig
import com.v2ray.ang.util.JsonUtil import com.v2ray.ang.util.JsonUtil
@@ -1,9 +1,9 @@
package com.v2ray.ang.fmt package com.v2ray.ang.fmt
import com.v2ray.ang.AppConfig import com.v2ray.ang.AppConfig
import com.v2ray.ang.dto.NetworkType import com.v2ray.ang.enums.NetworkType
import com.v2ray.ang.dto.ProfileItem import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.extension.isNotNullEmpty import com.v2ray.ang.extension.nullIfBlank
import com.v2ray.ang.handler.MmkvManager import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.util.HttpUtil import com.v2ray.ang.util.HttpUtil
import com.v2ray.ang.util.Utils import com.v2ray.ang.util.Utils
@@ -22,17 +22,17 @@ open class FmtBase {
val query = if (dicQuery != null) val query = if (dicQuery != null)
"?" + dicQuery.toList().joinToString( "?" + dicQuery.toList().joinToString(
separator = "&", separator = "&",
transform = { it.first + "=" + Utils.urlEncode(it.second) }) transform = { it.first + "=" + Utils.encodeURIComponent(it.second) })
else "" else ""
val url = String.format( val url = String.format(
"%s@%s:%s", "%s@%s:%s",
Utils.urlEncode(userInfo ?: ""), Utils.encodeURIComponent(userInfo ?: ""),
Utils.getIpv6Address(HttpUtil.toIdnDomain(config.server.orEmpty())), Utils.getIpv6Address(HttpUtil.toIdnDomain(config.server.orEmpty())),
config.serverPort config.serverPort
) )
return "${url}${query}#${Utils.urlEncode(config.remarks)}" return "${url}${query}#${Utils.encodeURIComponent(config.remarks)}"
} }
/** /**
@@ -43,7 +43,7 @@ open class FmtBase {
*/ */
fun getQueryParam(uri: URI): Map<String, String> { fun getQueryParam(uri: URI): Map<String, String> {
return uri.rawQuery.split("&") return uri.rawQuery.split("&")
.associate { it.split("=").let { (k, v) -> k to Utils.urlDecode(v) } } .associate { it.split("=").let { (k, v) -> k to Utils.decodeURIComponent(v) } }
} }
/** /**
@@ -83,6 +83,7 @@ open class FmtBase {
config.fingerPrint = queryParam["fp"] config.fingerPrint = queryParam["fp"]
config.alpn = queryParam["alpn"] config.alpn = queryParam["alpn"]
config.echConfigList = queryParam["ech"] config.echConfigList = queryParam["ech"]
config.pinnedCA256 = queryParam["pcs"]
config.publicKey = queryParam["pbk"] config.publicKey = queryParam["pbk"]
config.shortId = queryParam["sid"] config.shortId = queryParam["sid"]
config.spiderX = queryParam["spx"] config.spiderX = queryParam["spx"]
@@ -99,15 +100,16 @@ open class FmtBase {
fun getQueryDic(config: ProfileItem): HashMap<String, String> { fun getQueryDic(config: ProfileItem): HashMap<String, String> {
val dicQuery = HashMap<String, String>() val dicQuery = HashMap<String, String>()
dicQuery["security"] = config.security?.ifEmpty { "none" }.orEmpty() dicQuery["security"] = config.security?.ifEmpty { "none" }.orEmpty()
config.sni.let { if (it.isNotNullEmpty()) dicQuery["sni"] = it.orEmpty() } config.sni?.nullIfBlank()?.let { dicQuery["sni"] = it }
config.alpn.let { if (it.isNotNullEmpty()) dicQuery["alpn"] = it.orEmpty() } config.alpn?.nullIfBlank()?.let { dicQuery["alpn"] = it }
config.echConfigList.let { if (it.isNotNullEmpty()) dicQuery["ech"] = it.orEmpty() } config.echConfigList?.nullIfBlank()?.let { dicQuery["ech"] = it }
config.fingerPrint.let { if (it.isNotNullEmpty()) dicQuery["fp"] = it.orEmpty() } config.pinnedCA256?.nullIfBlank()?.let { dicQuery["pcs"] = it }
config.publicKey.let { if (it.isNotNullEmpty()) dicQuery["pbk"] = it.orEmpty() } config.fingerPrint?.nullIfBlank()?.let { dicQuery["fp"] = it }
config.shortId.let { if (it.isNotNullEmpty()) dicQuery["sid"] = it.orEmpty() } config.publicKey?.nullIfBlank()?.let { dicQuery["pbk"] = it }
config.spiderX.let { if (it.isNotNullEmpty()) dicQuery["spx"] = it.orEmpty() } config.shortId?.nullIfBlank()?.let { dicQuery["sid"] = it }
config.mldsa65Verify.let { if (it.isNotNullEmpty()) dicQuery["pqv"] = it.orEmpty() } config.spiderX?.nullIfBlank()?.let { dicQuery["spx"] = it }
config.flow.let { if (it.isNotNullEmpty()) dicQuery["flow"] = it.orEmpty() } config.mldsa65Verify?.nullIfBlank()?.let { dicQuery["pqv"] = it }
config.flow?.nullIfBlank()?.let { dicQuery["flow"] = it }
// Add two keys for compatibility: "insecure" and "allowInsecure" // Add two keys for compatibility: "insecure" and "allowInsecure"
if (config.security == AppConfig.TLS) { if (config.security == AppConfig.TLS) {
val insecureFlag = if (config.insecure == true) "1" else "0" val insecureFlag = if (config.insecure == true) "1" else "0"
@@ -121,43 +123,45 @@ open class FmtBase {
when (networkType) { when (networkType) {
NetworkType.TCP -> { NetworkType.TCP -> {
dicQuery["headerType"] = config.headerType?.ifEmpty { "none" }.orEmpty() dicQuery["headerType"] = config.headerType?.ifEmpty { "none" }.orEmpty()
config.host.let { if (it.isNotNullEmpty()) dicQuery["host"] = it.orEmpty() } config.host?.nullIfBlank()?.let { dicQuery["host"] = it }
} }
NetworkType.KCP -> { NetworkType.KCP -> {
dicQuery["headerType"] = config.headerType?.ifEmpty { "none" }.orEmpty() dicQuery["headerType"] = config.headerType?.ifEmpty { "none" }.orEmpty()
config.seed.let { if (it.isNotNullEmpty()) dicQuery["seed"] = it.orEmpty() } config.seed?.nullIfBlank()?.let { dicQuery["seed"] = it }
} }
NetworkType.WS, NetworkType.HTTP_UPGRADE -> { NetworkType.WS, NetworkType.HTTP_UPGRADE -> {
config.host.let { if (it.isNotNullEmpty()) dicQuery["host"] = it.orEmpty() } config.host?.nullIfBlank()?.let { dicQuery["host"] = it }
config.path.let { if (it.isNotNullEmpty()) dicQuery["path"] = it.orEmpty() } config.path?.nullIfBlank()?.let { dicQuery["path"] = it }
} }
NetworkType.XHTTP -> { NetworkType.XHTTP -> {
config.host.let { if (it.isNotNullEmpty()) dicQuery["host"] = it.orEmpty() } config.host?.nullIfBlank()?.let { dicQuery["host"] = it }
config.path.let { if (it.isNotNullEmpty()) dicQuery["path"] = it.orEmpty() } config.path?.nullIfBlank()?.let { dicQuery["path"] = it }
config.xhttpMode.let { if (it.isNotNullEmpty()) dicQuery["mode"] = it.orEmpty() } config.xhttpMode?.nullIfBlank()?.let { dicQuery["mode"] = it }
config.xhttpExtra.let { if (it.isNotNullEmpty()) dicQuery["extra"] = it.orEmpty() } config.xhttpExtra?.nullIfBlank()?.let { dicQuery["extra"] = it }
} }
NetworkType.HTTP, NetworkType.H2 -> { NetworkType.HTTP, NetworkType.H2 -> {
dicQuery["type"] = "http" dicQuery["type"] = "http"
config.host.let { if (it.isNotNullEmpty()) dicQuery["host"] = it.orEmpty() } config.host?.nullIfBlank()?.let { dicQuery["host"] = it }
config.path.let { if (it.isNotNullEmpty()) dicQuery["path"] = it.orEmpty() } config.path?.nullIfBlank()?.let { dicQuery["path"] = it }
} }
// NetworkType.QUIC -> { // NetworkType.QUIC -> {
// dicQuery["headerType"] = config.headerType?.ifEmpty { "none" }.orEmpty() // dicQuery["headerType"] = config.headerType?.ifEmpty { "none" }.orEmpty()
// config.quicSecurity.let { if (it.isNotNullEmpty()) dicQuery["quicSecurity"] = it.orEmpty() } // config.quicSecurity?.nullIfBlank()?.let { dicQuery["quicSecurity"] = it }
// config.quicKey.let { if (it.isNotNullEmpty()) dicQuery["key"] = it.orEmpty() } // config.quicKey?.nullIfBlank()?.let { dicQuery["key"] = it }
// } // }
NetworkType.GRPC -> { NetworkType.GRPC -> {
config.mode.let { if (it.isNotNullEmpty()) dicQuery["mode"] = it.orEmpty() } config.mode?.nullIfBlank()?.let { dicQuery["mode"] = it }
config.authority.let { if (it.isNotNullEmpty()) dicQuery["authority"] = it.orEmpty() } config.authority?.nullIfBlank()?.let { dicQuery["authority"] = it }
config.serviceName.let { if (it.isNotNullEmpty()) dicQuery["serviceName"] = it.orEmpty() } config.serviceName?.nullIfBlank()?.let { dicQuery["serviceName"] = it }
} }
else -> {}
} }
return dicQuery return dicQuery
@@ -1,6 +1,6 @@
package com.v2ray.ang.fmt package com.v2ray.ang.fmt
import com.v2ray.ang.dto.EConfigType import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.dto.ProfileItem import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.V2rayConfig.OutboundBean import com.v2ray.ang.dto.V2rayConfig.OutboundBean
import com.v2ray.ang.extension.isNotNullEmpty import com.v2ray.ang.extension.isNotNullEmpty
@@ -1,13 +1,14 @@
package com.v2ray.ang.fmt package com.v2ray.ang.fmt
import com.v2ray.ang.AppConfig import com.v2ray.ang.AppConfig
import com.v2ray.ang.AppConfig.LOOPBACK import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.dto.EConfigType import com.v2ray.ang.enums.NetworkType
import com.v2ray.ang.dto.Hysteria2Bean
import com.v2ray.ang.dto.ProfileItem import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.V2rayConfig.OutboundBean import com.v2ray.ang.dto.V2rayConfig.OutboundBean
import com.v2ray.ang.dto.V2rayConfig.OutboundBean.StreamSettingsBean.FinalMaskBean
import com.v2ray.ang.extension.idnHost import com.v2ray.ang.extension.idnHost
import com.v2ray.ang.extension.isNotNullEmpty import com.v2ray.ang.extension.isNotNullEmpty
import com.v2ray.ang.extension.nullIfBlank
import com.v2ray.ang.handler.MmkvManager import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.V2rayConfigManager import com.v2ray.ang.handler.V2rayConfigManager
import com.v2ray.ang.util.Utils import com.v2ray.ang.util.Utils
@@ -25,11 +26,12 @@ object Hysteria2Fmt : FmtBase() {
val config = ProfileItem.create(EConfigType.HYSTERIA2) val config = ProfileItem.create(EConfigType.HYSTERIA2)
val uri = URI(Utils.fixIllegalUrl(str)) val uri = URI(Utils.fixIllegalUrl(str))
config.remarks = Utils.urlDecode(uri.fragment.orEmpty()).let { if (it.isEmpty()) "none" else it } config.remarks = Utils.decodeURIComponent(uri.fragment.orEmpty()).let { it.ifEmpty { "none" } }
config.server = uri.idnHost config.server = uri.idnHost
config.serverPort = uri.port.toString() config.serverPort = uri.port.toString()
config.password = uri.userInfo config.password = uri.userInfo
config.security = AppConfig.TLS config.security = AppConfig.TLS
config.network = NetworkType.HYSTERIA.type
if (!uri.rawQuery.isNullOrEmpty()) { if (!uri.rawQuery.isNullOrEmpty()) {
val queryParam = getQueryParam(uri) val queryParam = getQueryParam(uri)
@@ -42,7 +44,7 @@ object Hysteria2Fmt : FmtBase() {
if (config.portHopping.isNotNullEmpty()) { if (config.portHopping.isNotNullEmpty()) {
config.portHoppingInterval = queryParam["mportHopInt"] config.portHoppingInterval = queryParam["mportHopInt"]
} }
config.pinSHA256 = queryParam["pinSHA256"] config.pinnedCA256 = queryParam["pinSHA256"]
} }
@@ -59,8 +61,8 @@ object Hysteria2Fmt : FmtBase() {
val dicQuery = HashMap<String, String>() val dicQuery = HashMap<String, String>()
config.security.let { if (it != null) dicQuery["security"] = it } config.security.let { if (it != null) dicQuery["security"] = it }
config.sni.let { if (it.isNotNullEmpty()) dicQuery["sni"] = it.orEmpty() } config.sni?.nullIfBlank()?.let { dicQuery["sni"] = it }
config.alpn.let { if (it.isNotNullEmpty()) dicQuery["alpn"] = it.orEmpty() } config.alpn?.nullIfBlank()?.let { dicQuery["alpn"] = it }
config.insecure.let { dicQuery["insecure"] = if (it == true) "1" else "0" } config.insecure.let { dicQuery["insecure"] = if (it == true) "1" else "0" }
if (config.obfsPassword.isNotNullEmpty()) { if (config.obfsPassword.isNotNullEmpty()) {
@@ -73,71 +75,13 @@ object Hysteria2Fmt : FmtBase() {
if (config.portHoppingInterval.isNotNullEmpty()) { if (config.portHoppingInterval.isNotNullEmpty()) {
dicQuery["mportHopInt"] = config.portHoppingInterval.orEmpty() dicQuery["mportHopInt"] = config.portHoppingInterval.orEmpty()
} }
if (config.pinSHA256.isNotNullEmpty()) { if (config.pinnedCA256.isNotNullEmpty()) {
dicQuery["pinSHA256"] = config.pinSHA256.orEmpty() dicQuery["pinSHA256"] = config.pinnedCA256.orEmpty()
} }
return toUri(config, config.password, dicQuery) 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?.takeIf { it.isNotEmpty() } ?: "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. * Converts a ProfileItem object to an OutboundBean object.
* *
@@ -145,7 +89,36 @@ object Hysteria2Fmt : FmtBase() {
* @return the converted OutboundBean object, or null if conversion fails * @return the converted OutboundBean object, or null if conversion fails
*/ */
fun toOutbound(profileItem: ProfileItem): OutboundBean? { fun toOutbound(profileItem: ProfileItem): OutboundBean? {
val outboundBean = V2rayConfigManager.createInitOutbound(EConfigType.HYSTERIA2) val outboundBean = V2rayConfigManager.createInitOutbound(EConfigType.HYSTERIA2) ?: return null
profileItem.network = NetworkType.HYSTERIA.type
profileItem.alpn = "h3"
outboundBean.settings?.let { server ->
server.address = getServerAddress(profileItem)
server.port = profileItem.serverPort.orEmpty().toInt()
server.version = 2
}
val sni = outboundBean.streamSettings?.let {
V2rayConfigManager.populateTransportSettings(it, profileItem)
}
outboundBean.streamSettings?.let {
V2rayConfigManager.populateTlsSettings(it, profileItem, sni)
}
if (profileItem.obfsPassword.isNotNullEmpty()) {
outboundBean.streamSettings?.finalmask = FinalMaskBean(
udp = listOf(
FinalMaskBean.MaskBean(
type = "salamander",
settings = FinalMaskBean.MaskBean.MaskSettingsBean(
password = profileItem.obfsPassword
)
)
)
)
}
return outboundBean return outboundBean
} }
} }
@@ -2,8 +2,8 @@ package com.v2ray.ang.fmt
import android.util.Log import android.util.Log
import com.v2ray.ang.AppConfig import com.v2ray.ang.AppConfig
import com.v2ray.ang.dto.EConfigType import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.dto.NetworkType import com.v2ray.ang.enums.NetworkType
import com.v2ray.ang.dto.ProfileItem import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.V2rayConfig.OutboundBean import com.v2ray.ang.dto.V2rayConfig.OutboundBean
import com.v2ray.ang.extension.idnHost import com.v2ray.ang.extension.idnHost
@@ -36,7 +36,7 @@ object ShadowsocksFmt : FmtBase() {
if (uri.port <= 0) return null if (uri.port <= 0) return null
if (uri.userInfo.isNullOrEmpty()) return null if (uri.userInfo.isNullOrEmpty()) return null
config.remarks = Utils.urlDecode(uri.fragment.orEmpty()).let { if (it.isEmpty()) "none" else it } config.remarks = Utils.decodeURIComponent(uri.fragment.orEmpty()).let { it.ifEmpty { "none" } }
config.server = uri.idnHost config.server = uri.idnHost
config.serverPort = uri.port.toString() config.serverPort = uri.port.toString()
@@ -83,7 +83,7 @@ object ShadowsocksFmt : FmtBase() {
if (indexSplit > 0) { if (indexSplit > 0) {
try { try {
config.remarks = config.remarks =
Utils.urlDecode(result.substring(indexSplit + 1, result.length)) Utils.decodeURIComponent(result.substring(indexSplit + 1, result.length))
} catch (e: Exception) { } catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to decode remarks in SS legacy URL", e) Log.e(AppConfig.TAG, "Failed to decode remarks in SS legacy URL", e)
} }
@@ -1,6 +1,6 @@
package com.v2ray.ang.fmt package com.v2ray.ang.fmt
import com.v2ray.ang.dto.EConfigType import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.dto.ProfileItem import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.V2rayConfig.OutboundBean import com.v2ray.ang.dto.V2rayConfig.OutboundBean
import com.v2ray.ang.extension.idnHost import com.v2ray.ang.extension.idnHost
@@ -23,7 +23,7 @@ object SocksFmt : FmtBase() {
if (uri.idnHost.isEmpty()) return null if (uri.idnHost.isEmpty()) return null
if (uri.port <= 0) return null if (uri.port <= 0) return null
config.remarks = Utils.urlDecode(uri.fragment.orEmpty()).let { if (it.isEmpty()) "none" else it } config.remarks = Utils.decodeURIComponent(uri.fragment.orEmpty()).let { it.ifEmpty { "none" } }
config.server = uri.idnHost config.server = uri.idnHost
config.serverPort = uri.port.toString() config.serverPort = uri.port.toString()
@@ -1,8 +1,8 @@
package com.v2ray.ang.fmt package com.v2ray.ang.fmt
import com.v2ray.ang.AppConfig import com.v2ray.ang.AppConfig
import com.v2ray.ang.dto.EConfigType import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.dto.NetworkType import com.v2ray.ang.enums.NetworkType
import com.v2ray.ang.dto.ProfileItem import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.V2rayConfig.OutboundBean import com.v2ray.ang.dto.V2rayConfig.OutboundBean
import com.v2ray.ang.extension.idnHost import com.v2ray.ang.extension.idnHost
@@ -23,7 +23,7 @@ object TrojanFmt : FmtBase() {
val config = ProfileItem.create(EConfigType.TROJAN) val config = ProfileItem.create(EConfigType.TROJAN)
val uri = URI(Utils.fixIllegalUrl(str)) val uri = URI(Utils.fixIllegalUrl(str))
config.remarks = Utils.urlDecode(uri.fragment.orEmpty()).let { if (it.isEmpty()) "none" else it } config.remarks = Utils.decodeURIComponent(uri.fragment.orEmpty()).let { it.ifEmpty { "none" } }
config.server = uri.idnHost config.server = uri.idnHost
config.serverPort = uri.port.toString() config.serverPort = uri.port.toString()
config.password = uri.userInfo config.password = uri.userInfo
@@ -1,7 +1,7 @@
package com.v2ray.ang.fmt package com.v2ray.ang.fmt
import com.v2ray.ang.AppConfig import com.v2ray.ang.AppConfig
import com.v2ray.ang.dto.EConfigType import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.dto.ProfileItem import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.V2rayConfig.OutboundBean import com.v2ray.ang.dto.V2rayConfig.OutboundBean
import com.v2ray.ang.extension.idnHost import com.v2ray.ang.extension.idnHost
@@ -26,7 +26,7 @@ object VlessFmt : FmtBase() {
if (uri.rawQuery.isNullOrEmpty()) return null if (uri.rawQuery.isNullOrEmpty()) return null
val queryParam = getQueryParam(uri) val queryParam = getQueryParam(uri)
config.remarks = Utils.urlDecode(uri.fragment.orEmpty()).let { if (it.isEmpty()) "none" else it } config.remarks = Utils.decodeURIComponent(uri.fragment.orEmpty()).let { it.ifEmpty { "none" } }
config.server = uri.idnHost config.server = uri.idnHost
config.serverPort = uri.port.toString() config.serverPort = uri.port.toString()
config.password = uri.userInfo config.password = uri.userInfo
@@ -3,13 +3,13 @@ package com.v2ray.ang.fmt
import android.text.TextUtils import android.text.TextUtils
import android.util.Log import android.util.Log
import com.v2ray.ang.AppConfig import com.v2ray.ang.AppConfig
import com.v2ray.ang.dto.EConfigType import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.dto.NetworkType import com.v2ray.ang.enums.NetworkType
import com.v2ray.ang.dto.ProfileItem import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.V2rayConfig.OutboundBean import com.v2ray.ang.dto.V2rayConfig.OutboundBean
import com.v2ray.ang.dto.VmessQRCode import com.v2ray.ang.dto.VmessQRCode
import com.v2ray.ang.extension.idnHost import com.v2ray.ang.extension.idnHost
import com.v2ray.ang.extension.isNotNullEmpty import com.v2ray.ang.extension.nullIfBlank
import com.v2ray.ang.handler.MmkvManager import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.V2rayConfigManager import com.v2ray.ang.handler.V2rayConfigManager
import com.v2ray.ang.util.JsonUtil import com.v2ray.ang.util.JsonUtil
@@ -132,8 +132,8 @@ object VmessFmt : FmtBase() {
else -> {} else -> {}
} }
config.host.let { if (it.isNotNullEmpty()) vmessQRCode.host = it.orEmpty() } config.host?.nullIfBlank()?.let { vmessQRCode.host = it }
config.path.let { if (it.isNotNullEmpty()) vmessQRCode.path = it.orEmpty() } config.path?.nullIfBlank()?.let { vmessQRCode.path = it }
vmessQRCode.tls = config.security.orEmpty() vmessQRCode.tls = config.security.orEmpty()
vmessQRCode.sni = config.sni.orEmpty() vmessQRCode.sni = config.sni.orEmpty()
@@ -163,7 +163,7 @@ object VmessFmt : FmtBase() {
if (uri.rawQuery.isNullOrEmpty()) return null if (uri.rawQuery.isNullOrEmpty()) return null
val queryParam = getQueryParam(uri) val queryParam = getQueryParam(uri)
config.remarks = Utils.urlDecode(uri.fragment.orEmpty()).let { if (it.isEmpty()) "none" else it } config.remarks = Utils.decodeURIComponent(uri.fragment.orEmpty()).let { it.ifEmpty { "none" } }
config.server = uri.idnHost config.server = uri.idnHost
config.serverPort = uri.port.toString() config.serverPort = uri.port.toString()
config.password = uri.userInfo config.password = uri.userInfo
@@ -2,10 +2,11 @@ package com.v2ray.ang.fmt
import com.v2ray.ang.AppConfig import com.v2ray.ang.AppConfig
import com.v2ray.ang.AppConfig.WIREGUARD_LOCAL_ADDRESS_V4 import com.v2ray.ang.AppConfig.WIREGUARD_LOCAL_ADDRESS_V4
import com.v2ray.ang.dto.EConfigType import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.dto.ProfileItem import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.V2rayConfig.OutboundBean import com.v2ray.ang.dto.V2rayConfig.OutboundBean
import com.v2ray.ang.extension.idnHost import com.v2ray.ang.extension.idnHost
import com.v2ray.ang.extension.nullIfBlank
import com.v2ray.ang.extension.removeWhiteSpace import com.v2ray.ang.extension.removeWhiteSpace
import com.v2ray.ang.handler.V2rayConfigManager import com.v2ray.ang.handler.V2rayConfigManager
import com.v2ray.ang.util.Utils import com.v2ray.ang.util.Utils
@@ -25,14 +26,14 @@ object WireguardFmt : FmtBase() {
if (uri.rawQuery.isNullOrEmpty()) return null if (uri.rawQuery.isNullOrEmpty()) return null
val queryParam = getQueryParam(uri) val queryParam = getQueryParam(uri)
config.remarks = Utils.urlDecode(uri.fragment.orEmpty()).let { if (it.isEmpty()) "none" else it } config.remarks = Utils.decodeURIComponent(uri.fragment.orEmpty()).let { it.ifEmpty { "none" } }
config.server = uri.idnHost config.server = uri.idnHost
config.serverPort = uri.port.toString() config.serverPort = uri.port.toString()
config.secretKey = uri.userInfo.orEmpty() config.secretKey = uri.userInfo.orEmpty()
config.localAddress = queryParam["address"] ?: WIREGUARD_LOCAL_ADDRESS_V4 config.localAddress = queryParam["address"] ?: WIREGUARD_LOCAL_ADDRESS_V4
config.publicKey = queryParam["publickey"].orEmpty() config.publicKey = queryParam["publickey"].orEmpty()
config.preSharedKey = queryParam["presharedkey"]?.takeIf { it.isNotEmpty() } config.preSharedKey = queryParam["presharedkey"]?.nullIfBlank()
config.mtu = Utils.parseInt(queryParam["mtu"] ?: AppConfig.WIREGUARD_LOCAL_MTU) config.mtu = Utils.parseInt(queryParam["mtu"] ?: AppConfig.WIREGUARD_LOCAL_MTU)
config.reserved = queryParam["reserved"] ?: "0,0,0" config.reserved = queryParam["reserved"] ?: "0,0,0"
@@ -84,7 +85,7 @@ object WireguardFmt : FmtBase() {
config.localAddress = interfaceParams["address"] ?: WIREGUARD_LOCAL_ADDRESS_V4 config.localAddress = interfaceParams["address"] ?: WIREGUARD_LOCAL_ADDRESS_V4
config.mtu = Utils.parseInt(interfaceParams["mtu"] ?: AppConfig.WIREGUARD_LOCAL_MTU) config.mtu = Utils.parseInt(interfaceParams["mtu"] ?: AppConfig.WIREGUARD_LOCAL_MTU)
config.publicKey = peerParams["publickey"].orEmpty() config.publicKey = peerParams["publickey"].orEmpty()
config.preSharedKey = peerParams["presharedkey"]?.takeIf { it.isNotEmpty() } config.preSharedKey = peerParams["presharedkey"]?.nullIfBlank()
val endpoint = peerParams["endpoint"].orEmpty() val endpoint = peerParams["endpoint"].orEmpty()
val endpointParts = endpoint.split(":", limit = 2) val endpointParts = endpoint.split(":", limit = 2)
if (endpointParts.size == 2) { if (endpointParts.size == 2) {
@@ -113,7 +114,7 @@ object WireguardFmt : FmtBase() {
wireguard.address = (profileItem.localAddress ?: WIREGUARD_LOCAL_ADDRESS_V4).split(",") wireguard.address = (profileItem.localAddress ?: WIREGUARD_LOCAL_ADDRESS_V4).split(",")
wireguard.peers?.firstOrNull()?.let { peer -> wireguard.peers?.firstOrNull()?.let { peer ->
peer.publicKey = profileItem.publicKey.orEmpty() peer.publicKey = profileItem.publicKey.orEmpty()
peer.preSharedKey = profileItem.preSharedKey?.takeIf { it.isNotEmpty() } peer.preSharedKey = profileItem.preSharedKey?.nullIfBlank()
peer.endpoint = Utils.getIpv6Address(profileItem.server) + ":${profileItem.serverPort}" peer.endpoint = Utils.getIpv6Address(profileItem.server) + ":${profileItem.serverPort}"
} }
wireguard.mtu = profileItem.mtu wireguard.mtu = profileItem.mtu
@@ -7,8 +7,9 @@ import android.util.Log
import com.v2ray.ang.AppConfig import com.v2ray.ang.AppConfig
import com.v2ray.ang.AppConfig.HY2 import com.v2ray.ang.AppConfig.HY2
import com.v2ray.ang.R import com.v2ray.ang.R
import com.v2ray.ang.dto.EConfigType import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.dto.ProfileItem import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.SubscriptionCache
import com.v2ray.ang.dto.SubscriptionItem import com.v2ray.ang.dto.SubscriptionItem
import com.v2ray.ang.fmt.CustomFmt import com.v2ray.ang.fmt.CustomFmt
import com.v2ray.ang.fmt.Hysteria2Fmt import com.v2ray.ang.fmt.Hysteria2Fmt
@@ -110,13 +111,6 @@ object AngConfigManager {
if (guid == null) return -1 if (guid == null) return -1
val result = V2rayConfigManager.getV2rayConfig(context, guid) val result = V2rayConfigManager.getV2rayConfig(context, guid)
if (result.status) { 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) Utils.setClipboard(context, result.content)
} else { } else {
return -1 return -1
@@ -149,6 +143,7 @@ object AngConfigManager {
EConfigType.WIREGUARD -> WireguardFmt.toUri(config) EConfigType.WIREGUARD -> WireguardFmt.toUri(config)
EConfigType.HYSTERIA2 -> Hysteria2Fmt.toUri(config) EConfigType.HYSTERIA2 -> Hysteria2Fmt.toUri(config)
EConfigType.POLICYGROUP -> "" EConfigType.POLICYGROUP -> ""
else -> {}
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to share config for GUID: $guid", e) Log.e(AppConfig.TAG, "Failed to share config for GUID: $guid", e)
@@ -276,13 +271,14 @@ object AngConfigManager {
) { ) {
try { try {
val serverList: Array<Any> = val serverList: Array<Any> =
JsonUtil.fromJson(server, Array<Any>::class.java)?: arrayOf() JsonUtil.fromJson(server, Array<Any>::class.java) ?: arrayOf()
if (serverList.isNotEmpty()) { if (serverList.isNotEmpty()) {
var count = 0 var count = 0
for (srv in serverList.reversed()) { for (srv in serverList.reversed()) {
val config = CustomFmt.parse(JsonUtil.toJson(srv)) ?: continue val config = CustomFmt.parse(JsonUtil.toJson(srv)) ?: continue
config.subscriptionId = subid config.subscriptionId = subid
config.description = generateDescription(config)
val key = MmkvManager.encodeServerConfig("", config) val key = MmkvManager.encodeServerConfig("", config)
MmkvManager.encodeServerRaw(key, JsonUtil.toJsonPretty(srv) ?: "") MmkvManager.encodeServerRaw(key, JsonUtil.toJsonPretty(srv) ?: "")
count += 1 count += 1
@@ -297,6 +293,7 @@ object AngConfigManager {
// For compatibility // For compatibility
val config = CustomFmt.parse(server) ?: return 0 val config = CustomFmt.parse(server) ?: return 0
config.subscriptionId = subid config.subscriptionId = subid
config.description = generateDescription(config)
val key = MmkvManager.encodeServerConfig("", config) val key = MmkvManager.encodeServerConfig("", config)
MmkvManager.encodeServerRaw(key, server) MmkvManager.encodeServerRaw(key, server)
return 1 return 1
@@ -307,6 +304,7 @@ object AngConfigManager {
} else if (server.startsWith("[Interface]") && server.contains("[Peer]")) { } else if (server.startsWith("[Interface]") && server.contains("[Peer]")) {
try { try {
val config = WireguardFmt.parseWireguardConfFile(server) ?: return R.string.toast_incorrect_protocol val config = WireguardFmt.parseWireguardConfFile(server) ?: return R.string.toast_incorrect_protocol
config.description = generateDescription(config)
val key = MmkvManager.encodeServerConfig("", config) val key = MmkvManager.encodeServerConfig("", config)
MmkvManager.encodeServerRaw(key, server) MmkvManager.encodeServerRaw(key, server)
return 1 return 1
@@ -368,6 +366,7 @@ object AngConfigManager {
} }
config.subscriptionId = subid config.subscriptionId = subid
config.description = generateDescription(config)
val guid = MmkvManager.encodeServerConfig("", config) val guid = MmkvManager.encodeServerConfig("", config)
if (removedSelectedServer != null && if (removedSelectedServer != null &&
config.server == removedSelectedServer.server && config.serverPort == removedSelectedServer.serverPort config.server == removedSelectedServer.server && config.serverPort == removedSelectedServer.serverPort
@@ -405,28 +404,28 @@ object AngConfigManager {
* @param it The subscription item. * @param it The subscription item.
* @return The number of configurations updated. * @return The number of configurations updated.
*/ */
fun updateConfigViaSub(it: Pair<String, SubscriptionItem>): Int { fun updateConfigViaSub(it: SubscriptionCache): Int {
try { try {
if (TextUtils.isEmpty(it.first) if (TextUtils.isEmpty(it.guid)
|| TextUtils.isEmpty(it.second.remarks) || TextUtils.isEmpty(it.subscription.remarks)
|| TextUtils.isEmpty(it.second.url) || TextUtils.isEmpty(it.subscription.url)
) { ) {
return 0 return 0
} }
if (!it.second.enabled) { if (!it.subscription.enabled) {
return 0 return 0
} }
val url = HttpUtil.toIdnUrl(it.second.url) val url = HttpUtil.toIdnUrl(it.subscription.url)
if (!Utils.isValidUrl(url)) { if (!Utils.isValidUrl(url)) {
return 0 return 0
} }
if (!it.second.allowInsecureUrl) { if (!it.subscription.allowInsecureUrl) {
if (!Utils.isValidSubUrl(url)) { if (!Utils.isValidSubUrl(url)) {
return 0 return 0
} }
} }
Log.i(AppConfig.TAG, url) Log.i(AppConfig.TAG, url)
val userAgent = it.second.userAgent val userAgent = it.subscription.userAgent
var configText = try { var configText = try {
val httpPort = SettingsManager.getHttpPort() val httpPort = SettingsManager.getHttpPort()
@@ -446,11 +445,11 @@ object AngConfigManager {
if (configText.isEmpty()) { if (configText.isEmpty()) {
return 0 return 0
} }
val count = parseConfigViaSub(configText, it.first, false) val count = parseConfigViaSub(configText, it.guid, false)
if (count > 0) { if (count > 0) {
it.second.lastUpdated = System.currentTimeMillis() it.subscription.lastUpdated = System.currentTimeMillis()
MmkvManager.encodeSubscription(it.first, it.second) MmkvManager.encodeSubscription(it.guid, it.subscription)
Log.i(AppConfig.TAG, "Subscription updated: ${it.second.remarks}, $count configs") Log.i(AppConfig.TAG, "Subscription updated: ${it.subscription.remarks}, $count configs")
} }
return count return count
} catch (e: Exception) { } catch (e: Exception) {
@@ -487,7 +486,7 @@ object AngConfigManager {
private fun importUrlAsSubscription(url: String): Int { private fun importUrlAsSubscription(url: String): Int {
val subscriptions = MmkvManager.decodeSubscriptions() val subscriptions = MmkvManager.decodeSubscriptions()
subscriptions.forEach { subscriptions.forEach {
if (it.second.url == url) { if (it.subscription.url == url) {
return 0 return 0
} }
} }
@@ -498,4 +497,25 @@ object AngConfigManager {
MmkvManager.encodeSubscription("", subItem) MmkvManager.encodeSubscription("", subItem)
return 1 return 1
} }
/** Generates a description for the profile.
*
* @param profile The profile item.
* @return The generated description.
*/
fun generateDescription(profile: ProfileItem): String {
// Hide xxx:xxx:***/xxx.xxx.xxx.***
val server = profile.server
val port = profile.serverPort
if (server.isNullOrBlank() && port.isNullOrBlank()) return ""
val addrPart = server?.let {
if (it.contains(":"))
it.split(":").take(2).joinToString(":", postfix = ":***")
else
it.split('.').dropLast(1).joinToString(".", postfix = ".***")
} ?: ""
return "$addrPart : ${port ?: ""}"
}
} }
@@ -3,10 +3,12 @@ package com.v2ray.ang.handler
import com.tencent.mmkv.MMKV import com.tencent.mmkv.MMKV
import com.v2ray.ang.AppConfig.PREF_IS_BOOTED import com.v2ray.ang.AppConfig.PREF_IS_BOOTED
import com.v2ray.ang.AppConfig.PREF_ROUTING_RULESET import com.v2ray.ang.AppConfig.PREF_ROUTING_RULESET
import com.v2ray.ang.dto.AssetUrlCache
import com.v2ray.ang.dto.AssetUrlItem import com.v2ray.ang.dto.AssetUrlItem
import com.v2ray.ang.dto.ProfileItem import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.RulesetItem import com.v2ray.ang.dto.RulesetItem
import com.v2ray.ang.dto.ServerAffiliationInfo import com.v2ray.ang.dto.ServerAffiliationInfo
import com.v2ray.ang.dto.SubscriptionCache
import com.v2ray.ang.dto.SubscriptionItem import com.v2ray.ang.dto.SubscriptionItem
import com.v2ray.ang.dto.WebDavConfig import com.v2ray.ang.dto.WebDavConfig
import com.v2ray.ang.util.JsonUtil import com.v2ray.ang.util.JsonUtil
@@ -309,15 +311,15 @@ object MmkvManager {
* *
* @return The list of subscriptions. * @return The list of subscriptions.
*/ */
fun decodeSubscriptions(): List<Pair<String, SubscriptionItem>> { fun decodeSubscriptions(): List<SubscriptionCache> {
initSubsList() initSubsList()
val subscriptions = mutableListOf<Pair<String, SubscriptionItem>>() val subscriptions = mutableListOf<SubscriptionCache>()
decodeSubsList().forEach { key -> decodeSubsList().forEach { key ->
val json = subStorage.decodeString(key) val json = subStorage.decodeString(key)
if (!json.isNullOrBlank()) { if (!json.isNullOrBlank()) {
val item = JsonUtil.fromJson(json, SubscriptionItem::class.java)?: SubscriptionItem() val item = JsonUtil.fromJson(json, SubscriptionItem::class.java)?: SubscriptionItem()
subscriptions.add(Pair(key, item)) subscriptions.add(SubscriptionCache(key, item))
} }
} }
return subscriptions return subscriptions
@@ -397,16 +399,16 @@ object MmkvManager {
* *
* @return The list of asset URLs. * @return The list of asset URLs.
*/ */
fun decodeAssetUrls(): List<Pair<String, AssetUrlItem>> { fun decodeAssetUrls(): List<AssetUrlCache> {
val assetUrlItems = mutableListOf<Pair<String, AssetUrlItem>>() val assetUrlItems = mutableListOf<AssetUrlCache>()
assetStorage.allKeys()?.forEach { key -> assetStorage.allKeys()?.forEach { key ->
val json = assetStorage.decodeString(key) val json = assetStorage.decodeString(key)
if (!json.isNullOrBlank()) { if (!json.isNullOrBlank()) {
val item = JsonUtil.fromJson(json, AssetUrlItem::class.java)?: AssetUrlItem() val item = JsonUtil.fromJson(json, AssetUrlItem::class.java)?: AssetUrlItem()
assetUrlItems.add(Pair(key, item)) assetUrlItems.add(AssetUrlCache(key, item))
} }
} }
return assetUrlItems.sortedBy { (_, value) -> value.addedTime } return assetUrlItems.sortedBy { it.assetUrl.addedTime }
} }
/** /**
@@ -1,141 +0,0 @@
package com.v2ray.ang.handler
import android.content.Context
import android.os.SystemClock
import android.util.Log
import com.v2ray.ang.AppConfig
import com.v2ray.ang.dto.EConfigType
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.fmt.Hysteria2Fmt
import com.v2ray.ang.service.ProcessService
import com.v2ray.ang.util.JsonUtil
import com.v2ray.ang.util.Utils
import java.io.File
object PluginServiceManager {
private const val HYSTERIA2 = "libhysteria2.so"
private val procService: ProcessService by lazy {
ProcessService()
}
/**
* Run the plugin based on the provided configuration.
*
* @param context The context to use.
* @param config The profile configuration.
* @param socksPort The port information.
*/
fun runPlugin(context: Context, config: ProfileItem?, socksPort: Int?) {
Log.i(AppConfig.TAG, "Starting plugin execution")
if (config == null) {
Log.w(AppConfig.TAG, "Cannot run plugin: config is null")
return
}
try {
if (config.configType == EConfigType.HYSTERIA2) {
if (socksPort == null) {
Log.w(AppConfig.TAG, "Cannot run plugin: socksPort is null")
return
}
Log.i(AppConfig.TAG, "Running Hysteria2 plugin")
val configFile = genConfigHy2(context, config, socksPort) ?: return
val cmd = genCmdHy2(context, configFile)
procService.runProcess(context, cmd)
}
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Error running plugin", e)
}
}
/**
* Stop the running plugin.
*/
fun stopPlugin() {
stopHy2()
}
/**
* Perform a real ping using Hysteria2.
*
* @param context The context to use.
* @param config The profile configuration.
* @return The ping delay in milliseconds, or -1 if it fails.
*/
fun realPingHy2(context: Context, config: ProfileItem?): Long {
Log.i(AppConfig.TAG, "realPingHy2")
val retFailure = -1L
if (config?.configType?.equals(EConfigType.HYSTERIA2) == true) {
val socksPort = Utils.findFreePort(listOf(0))
val configFile = genConfigHy2(context, config, socksPort) ?: return retFailure
val cmd = genCmdHy2(context, configFile)
val proc = ProcessService()
proc.runProcess(context, cmd)
Thread.sleep(1000L)
val delay = SpeedtestManager.testConnection(context, socksPort)
proc.stopProcess()
return delay.first
}
return retFailure
}
/**
* Generate the configuration file for Hysteria2.
*
* @param context The context to use.
* @param config The profile configuration.
* @param socksPort The port information.
* @return The generated configuration file.
*/
private fun genConfigHy2(context: Context, config: ProfileItem, socksPort: Int): File? {
Log.i(AppConfig.TAG, "runPlugin $HYSTERIA2")
val hy2Config = Hysteria2Fmt.toNativeConfig(config, socksPort) ?: return null
val configFile = File(context.noBackupFilesDir, "hy2_${SystemClock.elapsedRealtime()}.json")
Log.i(AppConfig.TAG, "runPlugin ${configFile.absolutePath}")
configFile.parentFile?.mkdirs()
configFile.writeText(JsonUtil.toJson(hy2Config))
Log.i(AppConfig.TAG, JsonUtil.toJson(hy2Config))
return configFile
}
/**
* Generate the command to run Hysteria2.
*
* @param context The context to use.
* @param configFile The configuration file.
* @return The command to run Hysteria2.
*/
private fun genCmdHy2(context: Context, configFile: File): MutableList<String> {
return mutableListOf(
File(context.applicationInfo.nativeLibraryDir, HYSTERIA2).absolutePath,
"--disable-update-check",
"--config",
configFile.absolutePath,
"--log-level",
"warn",
"client"
)
}
/**
* Stop the Hysteria2 process.
*/
private fun stopHy2() {
try {
Log.i(AppConfig.TAG, "$HYSTERIA2 destroy")
procService.stopProcess()
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to stop Hysteria2 process", e)
}
}
}
@@ -10,13 +10,15 @@ import com.v2ray.ang.AppConfig.ANG_PACKAGE
import com.v2ray.ang.AppConfig.GEOIP_PRIVATE import com.v2ray.ang.AppConfig.GEOIP_PRIVATE
import com.v2ray.ang.AppConfig.GEOSITE_PRIVATE import com.v2ray.ang.AppConfig.GEOSITE_PRIVATE
import com.v2ray.ang.AppConfig.TAG_DIRECT import com.v2ray.ang.AppConfig.TAG_DIRECT
import com.v2ray.ang.dto.EConfigType import com.v2ray.ang.AppConfig.VPN
import com.v2ray.ang.dto.Language import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.enums.Language
import com.v2ray.ang.dto.ProfileItem import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.RoutingType import com.v2ray.ang.enums.RoutingType
import com.v2ray.ang.dto.RulesetItem import com.v2ray.ang.dto.RulesetItem
import com.v2ray.ang.dto.ServersCache
import com.v2ray.ang.dto.V2rayConfig import com.v2ray.ang.dto.V2rayConfig
import com.v2ray.ang.dto.VpnInterfaceAddressConfig import com.v2ray.ang.enums.VpnInterfaceAddressConfig
import com.v2ray.ang.handler.MmkvManager.decodeServerConfig import com.v2ray.ang.handler.MmkvManager.decodeServerConfig
import com.v2ray.ang.handler.MmkvManager.decodeServerList import com.v2ray.ang.handler.MmkvManager.decodeServerList
import com.v2ray.ang.util.JsonUtil import com.v2ray.ang.util.JsonUtil
@@ -386,6 +388,15 @@ object SettingsManager {
return MmkvManager.decodeSettingsBool(AppConfig.PREF_USE_HEV_TUNNEL, true) return MmkvManager.decodeSettingsBool(AppConfig.PREF_USE_HEV_TUNNEL, true)
} }
/**
* Check if VPN mode is enabled.
* @return True if VPN mode is enabled, false otherwise.
*/
fun isVpnMode(): Boolean {
val mode = MmkvManager.decodeSettingsString(AppConfig.PREF_MODE)
return mode == null || mode == VPN
}
/** /**
* Ensure default settings are present in MMKV. * Ensure default settings are present in MMKV.
*/ */
@@ -412,4 +423,30 @@ object SettingsManager {
MmkvManager.encodeSettings(key, default) MmkvManager.encodeSettings(key, default)
} }
} }
fun migrateHysteria2PinSHA256() {
// Check if migration has already been done
val migrationKey = "hysteria2_pin_sha256_migrated"
if (MmkvManager.decodeSettingsBool(migrationKey, false)) {
return
}
val serverList = decodeServerList()
for (guid in serverList) {
val profile = decodeServerConfig(guid) ?: continue
if (profile.configType != EConfigType.HYSTERIA2) {
continue
}
if (profile.pinSHA256.isNullOrEmpty() || !profile.pinnedCA256.isNullOrEmpty()) {
continue
}
profile.pinnedCA256 = profile.pinSHA256
profile.pinSHA256 = null
MmkvManager.encodeServerConfig(guid, profile)
}
MmkvManager.encodeSettings(migrationKey, true)
}
} }
@@ -36,10 +36,10 @@ object SubscriptionUpdater {
override suspend fun doWork(): Result { override suspend fun doWork(): Result {
Log.i(AppConfig.TAG, "subscription automatic update starting") Log.i(AppConfig.TAG, "subscription automatic update starting")
val subs = MmkvManager.decodeSubscriptions().filter { it.second.autoUpdate } val subs = MmkvManager.decodeSubscriptions().filter { it.subscription.autoUpdate }
for (sub in subs) { for (sub in subs) {
val subItem = sub.second val subItem = sub.subscription
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
notification.setChannelId(AppConfig.SUBSCRIPTION_UPDATE_CHANNEL) notification.setChannelId(AppConfig.SUBSCRIPTION_UPDATE_CHANNEL)
@@ -53,7 +53,7 @@ object SubscriptionUpdater {
} }
notificationManager.notify(3, notification.build()) notificationManager.notify(3, notification.build())
Log.i(AppConfig.TAG, "subscription automatic update: ---${subItem.remarks}") Log.i(AppConfig.TAG, "subscription automatic update: ---${subItem.remarks}")
AngConfigManager.updateConfigViaSub(Pair(sub.first, subItem)) AngConfigManager.updateConfigViaSub(sub)
notification.setContentText("Updating ${subItem.remarks}") notification.setContentText("Updating ${subItem.remarks}")
} }
notificationManager.cancel(3) notificationManager.cancel(3)
@@ -10,10 +10,10 @@ import android.util.Log
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import com.v2ray.ang.AppConfig import com.v2ray.ang.AppConfig
import com.v2ray.ang.R import com.v2ray.ang.R
import com.v2ray.ang.dto.EConfigType import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.dto.ProfileItem import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.extension.toast import com.v2ray.ang.extension.toast
import com.v2ray.ang.service.ServiceControl import com.v2ray.ang.contracts.ServiceControl
import com.v2ray.ang.service.V2RayProxyOnlyService import com.v2ray.ang.service.V2RayProxyOnlyService
import com.v2ray.ang.service.V2RayVpnService import com.v2ray.ang.service.V2RayVpnService
import com.v2ray.ang.util.MessageUtil import com.v2ray.ang.util.MessageUtil
@@ -108,7 +108,7 @@ object V2RayServiceManager {
} else { } else {
context.toast(R.string.toast_services_start) context.toast(R.string.toast_services_start)
} }
val intent = if ((MmkvManager.decodeSettingsString(AppConfig.PREF_MODE) ?: AppConfig.VPN) == AppConfig.VPN) { val intent = if (SettingsManager.isVpnMode()) {
Intent(context.applicationContext, V2RayVpnService::class.java) Intent(context.applicationContext, V2RayVpnService::class.java)
} else { } else {
Intent(context.applicationContext, V2RayProxyOnlyService::class.java) Intent(context.applicationContext, V2RayProxyOnlyService::class.java)
@@ -151,6 +151,7 @@ object V2RayServiceManager {
} }
try { try {
NotificationManager.showNotification(currentConfig)
coreController.startLoop(result.content, tunFd) coreController.startLoop(result.content, tunFd)
} catch (e: Exception) { } catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to start Core loop", e) Log.e(AppConfig.TAG, "Failed to start Core loop", e)
@@ -165,10 +166,9 @@ object V2RayServiceManager {
try { try {
MessageUtil.sendMsg2UI(service, AppConfig.MSG_STATE_START_SUCCESS, "") MessageUtil.sendMsg2UI(service, AppConfig.MSG_STATE_START_SUCCESS, "")
NotificationManager.showNotification(currentConfig) //NotificationManager.showNotification(currentConfig)
NotificationManager.startSpeedNotification(currentConfig) NotificationManager.startSpeedNotification(currentConfig)
PluginServiceManager.runPlugin(service, config, result.socksPort)
} catch (e: Exception) { } catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to startup service", e) Log.e(AppConfig.TAG, "Failed to startup service", e)
return false return false
@@ -202,7 +202,6 @@ object V2RayServiceManager {
} catch (e: Exception) { } catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to unregister broadcast receiver", e) Log.e(AppConfig.TAG, "Failed to unregister broadcast receiver", e)
} }
PluginServiceManager.stopPlugin()
return true return true
} }
@@ -3,10 +3,11 @@ package com.v2ray.ang.handler
import android.content.Context import android.content.Context
import android.text.TextUtils import android.text.TextUtils
import android.util.Log import android.util.Log
import com.google.gson.JsonArray
import com.v2ray.ang.AppConfig import com.v2ray.ang.AppConfig
import com.v2ray.ang.dto.ConfigResult import com.v2ray.ang.dto.ConfigResult
import com.v2ray.ang.dto.EConfigType import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.dto.NetworkType import com.v2ray.ang.enums.NetworkType
import com.v2ray.ang.dto.ProfileItem import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.RulesetItem import com.v2ray.ang.dto.RulesetItem
import com.v2ray.ang.dto.V2rayConfig import com.v2ray.ang.dto.V2rayConfig
@@ -15,7 +16,9 @@ import com.v2ray.ang.dto.V2rayConfig.OutboundBean.OutSettingsBean
import com.v2ray.ang.dto.V2rayConfig.OutboundBean.StreamSettingsBean import com.v2ray.ang.dto.V2rayConfig.OutboundBean.StreamSettingsBean
import com.v2ray.ang.dto.V2rayConfig.RoutingBean.RulesBean import com.v2ray.ang.dto.V2rayConfig.RoutingBean.RulesBean
import com.v2ray.ang.extension.isNotNullEmpty import com.v2ray.ang.extension.isNotNullEmpty
import com.v2ray.ang.extension.nullIfBlank
import com.v2ray.ang.fmt.HttpFmt import com.v2ray.ang.fmt.HttpFmt
import com.v2ray.ang.fmt.Hysteria2Fmt
import com.v2ray.ang.fmt.ShadowsocksFmt import com.v2ray.ang.fmt.ShadowsocksFmt
import com.v2ray.ang.fmt.SocksFmt import com.v2ray.ang.fmt.SocksFmt
import com.v2ray.ang.fmt.TrojanFmt import com.v2ray.ang.fmt.TrojanFmt
@@ -43,7 +46,7 @@ object V2rayConfigManager {
try { try {
val config = MmkvManager.decodeServerConfig(guid) ?: return ConfigResult(false) val config = MmkvManager.decodeServerConfig(guid) ?: return ConfigResult(false)
return if (config.configType == EConfigType.CUSTOM) { return if (config.configType == EConfigType.CUSTOM) {
getV2rayCustomConfig(guid, config) getV2rayCustomConfig(context, guid, config)
} else if (config.configType == EConfigType.POLICYGROUP) { } else if (config.configType == EConfigType.POLICYGROUP) {
getV2rayGroupConfig(context, guid, config) getV2rayGroupConfig(context, guid, config)
} else { } else {
@@ -66,7 +69,7 @@ object V2rayConfigManager {
try { try {
val config = MmkvManager.decodeServerConfig(guid) ?: return ConfigResult(false) val config = MmkvManager.decodeServerConfig(guid) ?: return ConfigResult(false)
return if (config.configType == EConfigType.CUSTOM) { return if (config.configType == EConfigType.CUSTOM) {
getV2rayCustomConfig(guid, config) getV2rayCustomConfig(context, guid, config)
} else if (config.configType == EConfigType.POLICYGROUP) { } else if (config.configType == EConfigType.POLICYGROUP) {
// The number of policy groups will not be very large, so no special handling is needed. // The number of policy groups will not be very large, so no special handling is needed.
getV2rayGroupConfig(context, guid, config) getV2rayGroupConfig(context, guid, config)
@@ -86,9 +89,43 @@ object V2rayConfigManager {
* @param config The profile item containing the configuration details. * @param config The profile item containing the configuration details.
* @return A ConfigResult object containing the result of the configuration retrieval. * @return A ConfigResult object containing the result of the configuration retrieval.
*/ */
private fun getV2rayCustomConfig(guid: String, config: ProfileItem): ConfigResult { private fun getV2rayCustomConfig(context: Context, guid: String, config: ProfileItem): ConfigResult {
val raw = MmkvManager.decodeServerRaw(guid) ?: return ConfigResult(false) val raw = MmkvManager.decodeServerRaw(guid) ?: return ConfigResult(false)
return ConfigResult(true, guid, raw) val result = ConfigResult(true, guid, raw)
if (!needTun()) {
return result
}
// check if tun inbound exists
val json = JsonUtil.parseString(raw) ?: return result
val inboundsJson = if (json.has("inbounds") && json.get("inbounds")?.isJsonNull == false) {
json.getAsJsonArray("inbounds")
} else {
JsonArray()
}
for (i in 0 until inboundsJson.size()) {
val elem = inboundsJson.get(i)
if (elem.isJsonObject) {
val inb = elem.asJsonObject
val tag = if (inb.has("tag") && inb.get("tag")?.isJsonNull == false) inb.get("tag").asString else ""
if (tag == "tun") return result
}
}
// add tun inbound from template
val templateConfig = initV2rayConfig(context) ?: return result
val inboundTun = templateConfig.inbounds.firstOrNull { it.tag == "tun" } ?: return result
inboundTun.settings?.mtu = SettingsManager.getVpnMtu()
// add to json
inboundsJson.add(JsonUtil.parseString(JsonUtil.toJson(inboundTun)))
if (inboundsJson.size() == 1) {
json.add("inbounds", inboundsJson)
}
val updatedRaw = JsonUtil.toJsonPretty(json) ?: return result
return ConfigResult(true, guid, updatedRaw)
} }
/** /**
@@ -160,12 +197,8 @@ object V2rayConfigManager {
getInbounds(v2rayConfig) getInbounds(v2rayConfig)
if (config.configType == EConfigType.HYSTERIA2) { getOutbounds(v2rayConfig, config) ?: return result
result.socksPort = getPlusOutbounds(v2rayConfig, config) ?: return result getMoreOutbounds(v2rayConfig, config.subscriptionId)
} else {
getOutbounds(v2rayConfig, config) ?: return result
getMoreOutbounds(v2rayConfig, config.subscriptionId)
}
getRouting(v2rayConfig) getRouting(v2rayConfig)
@@ -196,7 +229,6 @@ object V2rayConfigManager {
val validConfigs = configList.asSequence().filter { it.server.isNotNullEmpty() } val validConfigs = configList.asSequence().filter { it.server.isNotNullEmpty() }
.filter { !Utils.isPureIpAddress(it.server!!) || Utils.isValidUrl(it.server!!) } .filter { !Utils.isPureIpAddress(it.server!!) || Utils.isValidUrl(it.server!!) }
.filter { it.configType != EConfigType.CUSTOM } .filter { it.configType != EConfigType.CUSTOM }
.filter { it.configType != EConfigType.HYSTERIA2 }
.filter { it.configType != EConfigType.POLICYGROUP } .filter { it.configType != EConfigType.POLICYGROUP }
.toList() .toList()
@@ -270,12 +302,8 @@ object V2rayConfigManager {
val v2rayConfig = initV2rayConfig(context) ?: return result val v2rayConfig = initV2rayConfig(context) ?: return result
if (config.configType == EConfigType.HYSTERIA2) { getOutbounds(v2rayConfig, config) ?: return result
result.socksPort = getPlusOutbounds(v2rayConfig, config) ?: return result getMoreOutbounds(v2rayConfig, config.subscriptionId)
} else {
getOutbounds(v2rayConfig, config) ?: return result
getMoreOutbounds(v2rayConfig, config.subscriptionId)
}
v2rayConfig.log.loglevel = MmkvManager.decodeSettingsString(AppConfig.PREF_LOGLEVEL) ?: "warning" v2rayConfig.log.loglevel = MmkvManager.decodeSettingsString(AppConfig.PREF_LOGLEVEL) ?: "warning"
v2rayConfig.inbounds.clear() v2rayConfig.inbounds.clear()
@@ -307,18 +335,18 @@ object V2rayConfigManager {
*/ */
private fun initV2rayConfig(context: Context): V2rayConfig? { private fun initV2rayConfig(context: Context): V2rayConfig? {
var assets = "" var assets = ""
if (SettingsManager.isUsingHevTun()) { if (needTun()) {
assets = initConfigCache ?: Utils.readTextFromAssets(context, "v2ray_config.json")
if (TextUtils.isEmpty(assets)) {
return null
}
initConfigCache = assets
} else {
assets = initConfigCacheWithTun ?: Utils.readTextFromAssets(context, "v2ray_config_with_tun.json") assets = initConfigCacheWithTun ?: Utils.readTextFromAssets(context, "v2ray_config_with_tun.json")
if (TextUtils.isEmpty(assets)) { if (TextUtils.isEmpty(assets)) {
return null return null
} }
initConfigCacheWithTun = assets initConfigCacheWithTun = assets
} else {
assets = initConfigCache ?: Utils.readTextFromAssets(context, "v2ray_config.json")
if (TextUtils.isEmpty(assets)) {
return null
}
initConfigCache = assets
} }
val config = JsonUtil.fromJson(assets, V2rayConfig::class.java) val config = JsonUtil.fromJson(assets, V2rayConfig::class.java)
return config return config
@@ -330,6 +358,10 @@ object V2rayConfigManager {
//region some sub function //region some sub function
private fun needTun(): Boolean {
return SettingsManager.isVpnMode() && !SettingsManager.isUsingHevTun()
}
/** /**
* Configures the inbound settings for V2ray. * Configures the inbound settings for V2ray.
* *
@@ -368,9 +400,10 @@ object V2rayConfigManager {
v2rayConfig.inbounds.add(inbound2) v2rayConfig.inbounds.add(inbound2)
} }
if (!SettingsManager.isUsingHevTun()) { if (needTun()) {
val inboundTun = v2rayConfig.inbounds.firstOrNull { e -> e.tag == "tun" } val inboundTun = v2rayConfig.inbounds.firstOrNull { e -> e.tag == "tun" }
inboundTun?.settings?.mtu = SettingsManager.getVpnMtu() inboundTun?.settings?.mtu = SettingsManager.getVpnMtu()
inboundTun?.sniffing = inbound1.sniffing
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to configure inbounds", e) Log.e(AppConfig.TAG, "Failed to configure inbounds", e)
@@ -492,23 +525,25 @@ object V2rayConfigManager {
) )
} }
if (SettingsManager.isUsingHevTun()) { if(SettingsManager.isVpnMode()) {
//hev-socks5-tunnel dns routing if (SettingsManager.isUsingHevTun()) {
v2rayConfig.routing.rules.add( //hev-socks5-tunnel dns routing
0, RulesBean( v2rayConfig.routing.rules.add(
inboundTag = arrayListOf("socks"), 0, RulesBean(
outboundTag = "dns-out", inboundTag = arrayListOf("socks"),
port = "53", outboundTag = "dns-out",
port = "53",
)
) )
) } else {
} else { v2rayConfig.routing.rules.add(
v2rayConfig.routing.rules.add( 0, RulesBean(
0, RulesBean( inboundTag = arrayListOf("tun"),
inboundTag = arrayListOf("tun"), outboundTag = "dns-out",
outboundTag = "dns-out", port = "53",
port = "53", )
) )
) }
} }
// DNS outbound // DNS outbound
@@ -667,44 +702,6 @@ object V2rayConfigManager {
return true 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. * Configures additional outbound connections for proxy chaining.
* *
@@ -844,65 +841,93 @@ object V2rayConfigManager {
* Configures load balancing settings for the V2ray configuration. * Configures load balancing settings for the V2ray configuration.
* *
* @param v2rayConfig The V2ray configuration object to be modified with balancing settings * @param v2rayConfig The V2ray configuration object to be modified with balancing settings
* @param config The profile item containing policy group settings
*/ */
private fun getBalance(v2rayConfig: V2rayConfig, config: ProfileItem) { private fun getBalance(v2rayConfig: V2rayConfig, config: ProfileItem) {
try { try {
v2rayConfig.routing.rules.forEach { rule -> v2rayConfig.routing.rules.forEach { rule ->
if (rule.outboundTag == "proxy") { if (rule.outboundTag == "proxy") {
rule.outboundTag = null rule.outboundTag = null
rule.balancerTag = "proxy-round" rule.balancerTag = AppConfig.TAG_BALANCER
} }
} }
if (config.policyGroupType == "0") { val lstSelector = listOf("proxy-")
val balancer = V2rayConfig.RoutingBean.BalancerBean( when (config.policyGroupType) {
tag = "proxy-round", // Least Ping goto else
selector = listOf("proxy-"), "1" -> {
strategy = V2rayConfig.RoutingBean.StrategyObject( // Least Load
type = "leastPing" val balancer = V2rayConfig.RoutingBean.BalancerBean(
tag = AppConfig.TAG_BALANCER,
selector = lstSelector,
strategy = V2rayConfig.RoutingBean.StrategyObject(
type = "leastLoad"
)
) )
) v2rayConfig.routing.balancers = listOf(balancer)
v2rayConfig.routing.balancers = listOf(balancer) v2rayConfig.burstObservatory = V2rayConfig.BurstObservatoryObject(
v2rayConfig.observatory = V2rayConfig.ObservatoryObject( subjectSelector = lstSelector,
subjectSelector = listOf("proxy-"), pingConfig = V2rayConfig.BurstObservatoryObject.PingConfigObject(
probeUrl = MmkvManager.decodeSettingsString(AppConfig.PREF_DELAY_TEST_URL) ?: AppConfig.DELAY_TEST_URL, destination = MmkvManager.decodeSettingsString(AppConfig.PREF_DELAY_TEST_URL) ?: AppConfig.DELAY_TEST_URL,
probeInterval = "3m", interval = "5m",
enableConcurrency = true sampling = 2,
) timeout = "30s"
} else { )
val balancer = V2rayConfig.RoutingBean.BalancerBean(
tag = "proxy-round",
selector = listOf("proxy-"),
strategy = V2rayConfig.RoutingBean.StrategyObject(
type = "leastLoad"
) )
) }
v2rayConfig.routing.balancers = listOf(balancer) "2" -> {
v2rayConfig.burstObservatory = V2rayConfig.BurstObservatoryObject( // Random
subjectSelector = listOf("proxy-"), val balancer = V2rayConfig.RoutingBean.BalancerBean(
pingConfig = V2rayConfig.BurstObservatoryObject.PingConfigObject( tag = AppConfig.TAG_BALANCER,
destination = MmkvManager.decodeSettingsString(AppConfig.PREF_DELAY_TEST_URL) ?: AppConfig.DELAY_TEST_URL, selector = lstSelector,
interval = "5m", strategy = V2rayConfig.RoutingBean.StrategyObject(
sampling = 2, type = "random"
timeout = "30s" )
) )
) v2rayConfig.routing.balancers = listOf(balancer)
}
"3" -> {
// Round Robin
val balancer = V2rayConfig.RoutingBean.BalancerBean(
tag = AppConfig.TAG_BALANCER,
selector = lstSelector,
strategy = V2rayConfig.RoutingBean.StrategyObject(
type = "roundRobin"
)
)
v2rayConfig.routing.balancers = listOf(balancer)
}
else -> {
// Default: Least Ping
val balancer = V2rayConfig.RoutingBean.BalancerBean(
tag = AppConfig.TAG_BALANCER,
selector = lstSelector,
strategy = V2rayConfig.RoutingBean.StrategyObject(
type = "leastPing"
)
)
v2rayConfig.routing.balancers = listOf(balancer)
v2rayConfig.observatory = V2rayConfig.ObservatoryObject(
subjectSelector = lstSelector,
probeUrl = MmkvManager.decodeSettingsString(AppConfig.PREF_DELAY_TEST_URL) ?: AppConfig.DELAY_TEST_URL,
probeInterval = "3m",
enableConcurrency = true
)
}
} }
if (v2rayConfig.routing.domainStrategy == "IPIfNonMatch") { if (v2rayConfig.routing.domainStrategy == "IPIfNonMatch") {
v2rayConfig.routing.rules.add( v2rayConfig.routing.rules.add(
RulesBean( RulesBean(
ip = arrayListOf("0.0.0.0/0", "::/0"), ip = arrayListOf("0.0.0.0/0", "::/0"),
balancerTag = "proxy-round", balancerTag = AppConfig.TAG_BALANCER,
type = "field"
) )
) )
} else { } else {
v2rayConfig.routing.rules.add( v2rayConfig.routing.rules.add(
RulesBean( RulesBean(
network = "tcp,udp", network = "tcp,udp",
balancerTag = "proxy-round", balancerTag = AppConfig.TAG_BALANCER,
type = "field"
) )
) )
} }
@@ -1046,9 +1071,10 @@ object V2rayConfigManager {
EConfigType.VLESS -> VlessFmt.toOutbound(profileItem) EConfigType.VLESS -> VlessFmt.toOutbound(profileItem)
EConfigType.TROJAN -> TrojanFmt.toOutbound(profileItem) EConfigType.TROJAN -> TrojanFmt.toOutbound(profileItem)
EConfigType.WIREGUARD -> WireguardFmt.toOutbound(profileItem) EConfigType.WIREGUARD -> WireguardFmt.toOutbound(profileItem)
EConfigType.HYSTERIA2 -> null EConfigType.HYSTERIA2 -> Hysteria2Fmt.toOutbound(profileItem)
EConfigType.HTTP -> HttpFmt.toOutbound(profileItem) EConfigType.HTTP -> HttpFmt.toOutbound(profileItem)
EConfigType.POLICYGROUP -> null EConfigType.POLICYGROUP -> null
else -> null
} }
} }
@@ -1079,8 +1105,7 @@ object V2rayConfigManager {
EConfigType.SHADOWSOCKS, EConfigType.SHADOWSOCKS,
EConfigType.SOCKS, EConfigType.SOCKS,
EConfigType.HTTP, EConfigType.HTTP,
EConfigType.TROJAN, EConfigType.TROJAN ->
EConfigType.HYSTERIA2 ->
return OutboundBean( return OutboundBean(
protocol = configType.name.lowercase(), protocol = configType.name.lowercase(),
settings = OutSettingsBean( settings = OutSettingsBean(
@@ -1098,6 +1123,16 @@ object V2rayConfigManager {
) )
) )
EConfigType.HYSTERIA,
EConfigType.HYSTERIA2 ->
return OutboundBean(
protocol = EConfigType.HYSTERIA.name.lowercase(),
settings = OutSettingsBean(
servers = null
),
streamSettings = StreamSettingsBean()
)
EConfigType.CUSTOM -> null EConfigType.CUSTOM -> null
EConfigType.POLICYGROUP -> null EConfigType.POLICYGROUP -> null
} }
@@ -1127,7 +1162,7 @@ object V2rayConfigManager {
val xhttpExtra = profileItem.xhttpExtra val xhttpExtra = profileItem.xhttpExtra
var sni: String? = null var sni: String? = null
streamSettings.network = if (transport.isEmpty()) NetworkType.TCP.type else transport streamSettings.network = transport.ifEmpty { NetworkType.TCP.type }
when (streamSettings.network) { when (streamSettings.network) {
NetworkType.TCP.type -> { NetworkType.TCP.type -> {
val tcpSetting = StreamSettingsBean.TcpSettingsBean() val tcpSetting = StreamSettingsBean.TcpSettingsBean()
@@ -1148,19 +1183,39 @@ object V2rayConfigManager {
} }
NetworkType.KCP.type -> { NetworkType.KCP.type -> {
val kcpsetting = StreamSettingsBean.KcpSettingsBean() streamSettings.kcpSettings = StreamSettingsBean.KcpSettingsBean()
kcpsetting.header.type = headerType ?: "none" val udpMaskList = mutableListOf<StreamSettingsBean.FinalMaskBean.MaskBean>()
if (!headerType.isNullOrEmpty() && headerType != "none") {
val kcpHeaderType = when {
headerType == "wechat-video" -> "header-wechat"
else -> "header-$headerType"
}
udpMaskList.add(StreamSettingsBean.FinalMaskBean.MaskBean(
type = kcpHeaderType,
settings = if (headerType == "dns" && !host.isNullOrEmpty()) {
StreamSettingsBean.FinalMaskBean.MaskBean.MaskSettingsBean(
domain = host
)
} else {
null
}
))
}
if (seed.isNullOrEmpty()) { if (seed.isNullOrEmpty()) {
kcpsetting.seed = null udpMaskList.add(StreamSettingsBean.FinalMaskBean.MaskBean(
type = "mkcp-original"
))
} else { } else {
kcpsetting.seed = seed udpMaskList.add(StreamSettingsBean.FinalMaskBean.MaskBean(
type = "mkcp-aes128gcm",
settings = StreamSettingsBean.FinalMaskBean.MaskBean.MaskSettingsBean(
password = seed
)
))
} }
if (host.isNullOrEmpty()) { streamSettings.finalmask = StreamSettingsBean.FinalMaskBean(
kcpsetting.header.domain = null udp = udpMaskList.toList()
} else { )
kcpsetting.header.domain = host
}
streamSettings.kcpSettings = kcpsetting
} }
NetworkType.WS.type -> { NetworkType.WS.type -> {
@@ -1216,6 +1271,27 @@ object V2rayConfigManager {
sni = authority sni = authority
streamSettings.grpcSettings = grpcSetting streamSettings.grpcSettings = grpcSetting
} }
NetworkType.HYSTERIA.type -> {
val hysteriaSetting = StreamSettingsBean.HysteriaSettingsBean(
version = 2,
auth = profileItem.password.orEmpty(),
up = profileItem.bandwidthUp?.ifEmpty { "0" }.orEmpty(),
down = profileItem.bandwidthDown?.ifEmpty { "0" }.orEmpty(),
udphop = null
)
if (profileItem.portHopping.isNotNullEmpty()) {
hysteriaSetting.udphop = StreamSettingsBean.HysteriaSettingsBean.HysteriaUdpHopBean(
port = profileItem.portHopping,
interval = profileItem.portHoppingInterval
?.trim()
?.toIntOrNull()
?.takeIf { it >= 5 }
?: 30
)
}
streamSettings.hysteriaSettings = hysteriaSetting
}
} }
return sni return sni
} }
@@ -1241,28 +1317,21 @@ object V2rayConfigManager {
} else { } else {
profileItem.sni profileItem.sni
} }
val fingerprint = profileItem.fingerPrint
val alpns = profileItem.alpn
val echConfigList = profileItem.echConfigList
val echForceQuery = profileItem.echForceQuery
val publicKey = profileItem.publicKey
val shortId = profileItem.shortId
val spiderX = profileItem.spiderX
val mldsa65Verify = profileItem.mldsa65Verify
streamSettings.security = if (streamSecurity.isEmpty()) null else streamSecurity streamSettings.security = streamSecurity.nullIfBlank()
if (streamSettings.security == null) return if (streamSettings.security == null) return
val tlsSetting = StreamSettingsBean.TlsSettingsBean( val tlsSetting = StreamSettingsBean.TlsSettingsBean(
allowInsecure = allowInsecure, allowInsecure = allowInsecure,
serverName = if (sni.isNullOrEmpty()) null else sni, serverName = sni.nullIfBlank(),
fingerprint = if (fingerprint.isNullOrEmpty()) null else fingerprint, fingerprint = profileItem.fingerPrint.nullIfBlank(),
alpn = if (alpns.isNullOrEmpty()) null else alpns.split(",").map { it.trim() }.filter { it.isNotEmpty() }, alpn = profileItem.alpn?.split(",")?.map { it.trim() }?.filter { it.isNotEmpty() }.takeIf { !it.isNullOrEmpty() },
echConfigList = if (echConfigList.isNullOrEmpty()) null else echConfigList, echConfigList = profileItem.echConfigList.nullIfBlank(),
echForceQuery = if (echForceQuery.isNullOrEmpty()) null else echForceQuery, echForceQuery = profileItem.echForceQuery.nullIfBlank(),
publicKey = if (publicKey.isNullOrEmpty()) null else publicKey, pinnedPeerCertSha256 = profileItem.pinnedCA256.nullIfBlank(),
shortId = if (shortId.isNullOrEmpty()) null else shortId, publicKey = profileItem.publicKey.nullIfBlank(),
spiderX = if (spiderX.isNullOrEmpty()) null else spiderX, shortId = profileItem.shortId.nullIfBlank(),
mldsa65Verify = if (mldsa65Verify.isNullOrEmpty()) null else mldsa65Verify, spiderX = profileItem.spiderX.nullIfBlank(),
mldsa65Verify = profileItem.mldsa65Verify.nullIfBlank(),
) )
if (streamSettings.security == AppConfig.TLS) { if (streamSettings.security == AppConfig.TLS) {
streamSettings.tlsSettings = tlsSetting streamSettings.tlsSettings = tlsSetting
@@ -35,18 +35,18 @@ object WebDavManager {
} }
/** /**
* Upload a local file to a remote relative path under the configured remoteBasePath. * Upload a local file to a remote file name under the configured remoteBasePath.
* The provided `remoteRelativePath` should be relative (e.g. "backup_ng.zip"). * The provided `remoteFileName` should be a file name (e.g. "backup_ng.zip").
* The method will attempt to create parent directories via MKCOL before PUT. * The method will attempt to create parent directories via MKCOL before PUT.
* *
* @param localFile File to upload. * @param localFile File to upload.
* @param remoteRelativePath Remote path relative to configured remoteBasePath. * @param remoteFileName Remote file name relative to configured remoteBasePath.
* @return true if upload succeeded (HTTP 2xx), false otherwise. * @return true if upload succeeded (HTTP 2xx), false otherwise.
*/ */
suspend fun uploadFile(localFile: File, remoteRelativePath: String): Boolean = withContext(Dispatchers.IO) { suspend fun uploadFile(localFile: File, remoteFileName: String): Boolean = withContext(Dispatchers.IO) {
val remote = buildRemoteUrl(remoteFileName)
try { try {
val cl = client ?: return@withContext false val cl = client ?: return@withContext false
val remote = buildRemoteUrl(remoteRelativePath)
// Ensure parent directories exist // Ensure parent directories exist
val dirPath = remote.substringBeforeLast('/') val dirPath = remote.substringBeforeLast('/')
@@ -67,14 +67,14 @@ object WebDavManager {
cl.newCall(req).execute().use { resp -> cl.newCall(req).execute().use { resp ->
val success = resp.isSuccessful val success = resp.isSuccessful
if (success) { if (success) {
Log.i(AppConfig.TAG, "WebDAV upload success: $remoteRelativePath") Log.i(AppConfig.TAG, "WebDAV upload success: $remote")
} else { } else {
Log.e(AppConfig.TAG, "WebDAV upload failed: $remoteRelativePath (HTTP ${resp.code})") Log.e(AppConfig.TAG, "WebDAV upload failed: $remote (HTTP ${resp.code})")
} }
return@withContext success return@withContext success
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.e(AppConfig.TAG, "WebDAV upload exception: $remoteRelativePath", e) Log.e(AppConfig.TAG, "WebDAV upload exception: $remote", e)
return@withContext false return@withContext false
} }
} }
@@ -82,18 +82,18 @@ object WebDavManager {
/** /**
* Download a remote file (relative to configured remoteBasePath) into a local file. * Download a remote file (relative to configured remoteBasePath) into a local file.
* *
* @param remoteRelativePath Remote path relative to configured remoteBasePath. * @param remoteFileName Remote file name relative to configured remoteBasePath.
* @param destFile Local destination file to write to. * @param destFile Local destination file to write to.
* @return true if download and write succeeded, false otherwise. * @return true if download and write succeeded, false otherwise.
*/ */
suspend fun downloadFile(remoteRelativePath: String, destFile: File): Boolean = withContext(Dispatchers.IO) { suspend fun downloadFile(remoteFileName: String, destFile: File): Boolean = withContext(Dispatchers.IO) {
val remote = buildRemoteUrl(remoteFileName)
try { try {
val cl = client ?: return@withContext false val cl = client ?: return@withContext false
val remote = buildRemoteUrl(remoteRelativePath)
val req = applyAuth(Request.Builder().url(remote).get()).build() val req = applyAuth(Request.Builder().url(remote).get()).build()
cl.newCall(req).execute().use { resp -> cl.newCall(req).execute().use { resp ->
if (!resp.isSuccessful) { if (!resp.isSuccessful) {
Log.e(AppConfig.TAG, "WebDAV download failed: $remoteRelativePath (HTTP ${resp.code})") Log.e(AppConfig.TAG, "WebDAV download failed: $remote (HTTP ${resp.code})")
return@withContext false return@withContext false
} }
@@ -104,29 +104,31 @@ object WebDavManager {
} }
} }
Log.i(AppConfig.TAG, "WebDAV download success: $remoteRelativePath") Log.i(AppConfig.TAG, "WebDAV download success: $remote")
return@withContext true return@withContext true
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.e(AppConfig.TAG, "WebDAV download exception: $remoteRelativePath", e) Log.e(AppConfig.TAG, "WebDAV download exception: $remote", e)
return@withContext false return@withContext false
} }
} }
/** /**
* Build a full remote URL by combining the configured base URL, the configured * Build a full remote URL by combining the configured base URL, the configured
* remote base path and a relative path provided by the caller. * remote base path and a file name provided by the caller.
* *
* Example: baseUrl="https://example.com/remote.php/dav", remoteBasePath="backups", * Example: baseUrl="https://example.com/remote.php/dav", remoteBasePath="backups",
* remoteRelativePath="backup_ng.zip" => "https://example.com/remote.php/dav/backups/backup_ng.zip" * remoteFileName="backup_ng.zip" => "https://example.com/remote.php/dav/backups/backup_ng.zip"
* *
* @param remoteRelativePath A path relative to the configured remoteBasePath (no leading slash required). * @param remoteFileName A file name relative to the configured remoteBasePath (no leading slash required).
* @return Full URL string used for HTTP operations. * @return Full URL string used for HTTP operations.
*/ */
private fun buildRemoteUrl(remoteRelativePath: String): String { private fun buildRemoteUrl(remoteFileName: String): String {
val base = cfg?.baseUrl?.trimEnd('/') ?: "" val base = cfg?.baseUrl?.trimEnd('/') ?: ""
val basePath = cfg?.remoteBasePath?.trim('/') ?: "" // Use configured remoteBasePath when not empty; otherwise fallback to AppConfig.WEBDAV_BACKUP_DIR
val rel = remoteRelativePath.trimStart('/') val basePathConfigured = cfg?.remoteBasePath?.trim('/')?.takeIf { it.isNotEmpty() }
val basePath = basePathConfigured ?: AppConfig.WEBDAV_BACKUP_DIR
val rel = remoteFileName.trimStart('/')
return if (basePath.isEmpty()) "$base/$rel" else "$base/$basePath/$rel" return if (basePath.isEmpty()) "$base/$rel" else "$base/$basePath/$rel"
} }
@@ -170,7 +172,7 @@ object WebDavManager {
Log.w(AppConfig.TAG, "WebDAV MKCOL $mkUrl returned ${resp.code}") Log.w(AppConfig.TAG, "WebDAV MKCOL $mkUrl returned ${resp.code}")
} }
} }
} catch (ignored: Exception) { } catch (_: Exception) {
// best-effort, continue // best-effort, continue
} }
} }
@@ -0,0 +1,88 @@
package com.v2ray.ang.helper
import android.content.ActivityNotFoundException
import android.content.Intent
import android.net.Uri
import android.util.Log
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import com.v2ray.ang.AppConfig
import com.v2ray.ang.R
import com.v2ray.ang.extension.toast
/**
* Helper for choosing and creating files using Android Storage Access Framework.
* Supports both file selection (ACTION_GET_CONTENT) and file creation (CreateDocument).
*/
class FileChooserHelper(private val activity: AppCompatActivity) {
private var fileChooserCallback: ((Uri?) -> Unit)? = null
private var documentCreateCallback: ((Uri?) -> Unit)? = null
private val fileChooserLauncher: ActivityResultLauncher<Intent> =
activity.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
val uri = result.data?.data
if (result.resultCode == AppCompatActivity.RESULT_OK && uri != null) {
fileChooserCallback?.invoke(uri)
} else {
fileChooserCallback?.invoke(null)
}
fileChooserCallback = null
}
private val documentCreateLauncher: ActivityResultLauncher<String> =
activity.registerForActivityResult(ActivityResultContracts.CreateDocument("application/zip")) { uri ->
documentCreateCallback?.invoke(uri)
documentCreateCallback = null
}
/**
* Launch file chooser with ACTION_GET_CONTENT intent to select an existing file.
*
* @param mimeType MIME type filter for files
* @param onResult Callback invoked with the selected file URI (null if cancelled)
*/
fun launch(
mimeType: String = "*/*",
onResult: (Uri?) -> Unit
) {
fileChooserCallback = onResult
val intent = Intent(Intent.ACTION_GET_CONTENT).apply {
type = mimeType
addCategory(Intent.CATEGORY_OPENABLE)
}
try {
fileChooserLauncher.launch(
Intent.createChooser(intent, activity.getString(R.string.title_file_chooser))
)
} catch (ex: ActivityNotFoundException) {
Log.e(AppConfig.TAG, "File chooser activity not found", ex)
activity.toast(R.string.toast_require_file_manager)
fileChooserCallback?.invoke(null)
fileChooserCallback = null
}
}
/**
* Launch document creator to create a new file at user-selected location.
*
* @param fileName Default file name for the new document
* @param onResult Callback invoked with the created file URI (null if cancelled)
*/
fun createDocument(
fileName: String,
onResult: (Uri?) -> Unit
) {
documentCreateCallback = onResult
try {
documentCreateLauncher.launch(fileName)
} catch (ex: ActivityNotFoundException) {
Log.e(AppConfig.TAG, "Document creator activity not found", ex)
activity.toast(R.string.toast_require_file_manager)
documentCreateCallback?.invoke(null)
documentCreateCallback = null
}
}
}
@@ -1,7 +1,10 @@
package com.v2ray.ang.handler package com.v2ray.ang.helper
import androidx.preference.PreferenceDataStore import androidx.preference.PreferenceDataStore
import com.v2ray.ang.AppConfig import com.v2ray.ang.AppConfig
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.SettingsChangeManager
import com.v2ray.ang.handler.SettingsManager
/** /**
* PreferenceDataStore implementation that bridges AndroidX Preference framework to MMKV storage. * PreferenceDataStore implementation that bridges AndroidX Preference framework to MMKV storage.
@@ -0,0 +1,46 @@
package com.v2ray.ang.helper
import android.content.pm.PackageManager
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import com.v2ray.ang.R
import com.v2ray.ang.enums.PermissionType
import com.v2ray.ang.extension.toast
/**
* Helper for requesting permissions.
*/
class PermissionHelper(private val activity: AppCompatActivity) {
private var permissionCallback: ((Boolean) -> Unit)? = null
private val permissionLauncher: ActivityResultLauncher<String> =
activity.registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted ->
permissionCallback?.invoke(isGranted)
permissionCallback = null
}
/**
* Check the permission and request it if not granted.
*
* @param permissionType the type of permission
* @param onGranted called when permission is granted (called immediately if already granted)
*/
fun request(permissionType: PermissionType, onGranted: () -> Unit) {
val permission = permissionType.getPermission()
if (ContextCompat.checkSelfPermission(activity, permission) == PackageManager.PERMISSION_GRANTED) {
onGranted()
} else {
permissionCallback = { isGranted ->
if (isGranted) {
onGranted()
} else {
val message = "${activity.getString(R.string.toast_permission_denied)} ${permissionType.getLabel()}"
activity.toast(message)
}
}
permissionLauncher.launch(permission)
}
}
}
@@ -0,0 +1,38 @@
package com.v2ray.ang.helper
import android.content.Intent
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import com.v2ray.ang.ui.ScannerActivity
/**
* Helper for scanning QR codes.
*
* This class encapsulates the logic for launching the QR code scanner activity
* and handling the scan result.
*/
class QRCodeScannerHelper(private val activity: AppCompatActivity) {
private var scanCallback: ((String?) -> Unit)? = null
private val scanLauncher: ActivityResultLauncher<Intent> =
activity.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == AppCompatActivity.RESULT_OK) {
val scanResult = result.data?.getStringExtra("SCAN_RESULT")
scanCallback?.invoke(scanResult)
} else {
scanCallback?.invoke(null)
}
scanCallback = null
}
/**
* Launch the QR code scanner activity.
*
* @param onResult Callback invoked with the scan result (null if cancelled or failed)
*/
fun launch(onResult: (String?) -> Unit) {
scanCallback = onResult
scanLauncher.launch(Intent(activity, ScannerActivity::class.java))
}
}
@@ -1,32 +0,0 @@
/******************************************************************************
* *
* Copyright (C) 2021 by nekohasekai <contact-sagernet@sekai.icu> *
* Copyright (C) 2021 by Max Lv <max.c.lv@gmail.com> *
* Copyright (C) 2021 by Mygod Studio <contact-shadowsocks-android@mygod.be> *
* *
* This program is free software: you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation, either version 3 of the License, or *
* (at your option) any later version. *
* *
* This program is distributed in the hope that it will be useful, *
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
* GNU General Public License for more details. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
******************************************************************************/
package com.v2ray.ang.plugin
import android.content.pm.ResolveInfo
class NativePlugin(resolveInfo: ResolveInfo) : ResolvedPlugin(resolveInfo) {
init {
check(resolveInfo.providerInfo != null)
}
override val componentInfo get() = resolveInfo.providerInfo!!
}
@@ -1,43 +0,0 @@
/******************************************************************************
* *
* Copyright (C) 2021 by nekohasekai <contact-sagernet@sekai.icu> *
* Copyright (C) 2021 by Max Lv <max.c.lv@gmail.com> *
* Copyright (C) 2021 by Mygod Studio <contact-shadowsocks-android@mygod.be> *
* *
* This program is free software: you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation, either version 3 of the License, or *
* (at your option) any later version. *
* *
* This program is distributed in the hope that it will be useful, *
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
* GNU General Public License for more details. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
******************************************************************************/
package com.v2ray.ang.plugin
import android.graphics.drawable.Drawable
abstract class Plugin {
abstract val id: String
abstract val label: CharSequence
abstract val version: Int
abstract val versionName: String
open val icon: Drawable? get() = null
open val defaultConfig: String? get() = null
open val packageName: String get() = ""
open val directBootAware: Boolean get() = true
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
return id == (other as Plugin).id
}
override fun hashCode() = id.hashCode()
}
@@ -1,33 +0,0 @@
/******************************************************************************
* *
* Copyright (C) 2021 by nekohasekai <contact-sagernet@sekai.icu> *
* *
* This program is free software: you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation, either version 3 of the License, or *
* (at your option) any later version. *
* *
* This program is distributed in the hope that it will be useful, *
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
* GNU General Public License for more details. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
******************************************************************************/
package com.v2ray.ang.plugin
object PluginContract {
const val ACTION_NATIVE_PLUGIN = "io.nekohasekai.sagernet.plugin.ACTION_NATIVE_PLUGIN"
const val EXTRA_ENTRY = "io.nekohasekai.sagernet.plugin.EXTRA_ENTRY"
const val METADATA_KEY_ID = "io.nekohasekai.sagernet.plugin.id"
const val METADATA_KEY_EXECUTABLE_PATH = "io.nekohasekai.sagernet.plugin.executable_path"
const val METHOD_GET_EXECUTABLE = "sagernet:getExecutable"
const val COLUMN_PATH = "path"
const val COLUMN_MODE = "mode"
const val SCHEME = "plugin"
}
@@ -1,54 +0,0 @@
/******************************************************************************
* *
* Copyright (C) 2021 by nekohasekai <contact-sagernet@sekai.icu> *
* Copyright (C) 2021 by Max Lv <max.c.lv@gmail.com> *
* Copyright (C) 2021 by Mygod Studio <contact-shadowsocks-android@mygod.be> *
* *
* This program is free software: you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation, either version 3 of the License, or *
* (at your option) any later version. *
* *
* This program is distributed in the hope that it will be useful, *
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
* GNU General Public License for more details. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
******************************************************************************/
package com.v2ray.ang.plugin
import android.content.Intent
import android.content.pm.PackageManager
import com.v2ray.ang.AngApplication
class PluginList : ArrayList<Plugin>() {
init {
addAll(
AngApplication.application.packageManager.queryIntentContentProviders(
Intent(PluginContract.ACTION_NATIVE_PLUGIN), PackageManager.GET_META_DATA
)
.filter { it.providerInfo.exported }.map { NativePlugin(it) })
}
val lookup = mutableMapOf<String, Plugin>().apply {
for (plugin in this@PluginList.toList()) {
fun check(old: Plugin?) {
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)
}*/
}
check(put(plugin.id, plugin))
}
}
}
@@ -1,232 +0,0 @@
/******************************************************************************
* *
* Copyright (C) 2021 by nekohasekai <contact-AngApplication@sekai.icu> *
* Copyright (C) 2021 by Max Lv <max.c.lv@gmail.com> *
* Copyright (C) 2021 by Mygod Studio <contact-shadowsocks-android@mygod.be> *
* *
* This program is free software: you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation, either version 3 of the License, or *
* (at your option) any later version. *
* *
* This program is distributed in the hope that it will be useful, *
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
* GNU General Public License for more details. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
******************************************************************************/
package com.v2ray.ang.plugin
import android.annotation.SuppressLint
import android.content.BroadcastReceiver
import android.content.ContentResolver
import android.content.Intent
import android.content.pm.ComponentInfo
import android.content.pm.PackageManager
import android.content.pm.ProviderInfo
import android.database.Cursor
import android.net.Uri
import android.os.Build
import android.system.Os
import androidx.core.os.bundleOf
import com.v2ray.ang.AngApplication
import com.v2ray.ang.extension.listenForPackageChanges
import com.v2ray.ang.extension.toast
import com.v2ray.ang.plugin.PluginContract.METADATA_KEY_ID
import java.io.File
import java.io.FileNotFoundException
object PluginManager {
class PluginNotFoundException(val plugin: String) : FileNotFoundException(plugin)
private var receiver: BroadcastReceiver? = null
private var cachedPlugins: PluginList? = null
fun fetchPlugins() = synchronized(this) {
if (receiver == null) receiver = AngApplication.application.listenForPackageChanges {
synchronized(this) {
receiver = null
cachedPlugins = null
}
}
if (cachedPlugins == null) cachedPlugins = PluginList()
cachedPlugins!!
}
private fun buildUri(id: String, authority: String) = Uri.Builder()
.scheme(PluginContract.SCHEME)
.authority(authority)
.path("/$id")
.build()
data class InitResult(
val path: String,
)
@Throws(Throwable::class)
fun init(pluginId: String): InitResult? {
if (pluginId.isEmpty()) return null
var throwable: Throwable? = null
try {
val result = initNative(pluginId)
if (result != null) return result
} catch (t: Throwable) {
if (throwable == null) throwable = t //Logs.w(t)
}
throw throwable ?: PluginNotFoundException(pluginId)
}
private fun initNative(pluginId: String): InitResult? {
var flags = PackageManager.GET_META_DATA
if (Build.VERSION.SDK_INT >= 24) {
flags =
flags or PackageManager.MATCH_DIRECT_BOOT_UNAWARE or PackageManager.MATCH_DIRECT_BOOT_AWARE
}
var providers = AngApplication.application.packageManager.queryIntentContentProviders(
Intent(PluginContract.ACTION_NATIVE_PLUGIN, buildUri(pluginId, "com.github.dyhkwong.AngApplication")), flags
)
.filter { it.providerInfo.exported }
if (providers.isEmpty()) {
providers = AngApplication.application.packageManager.queryIntentContentProviders(
Intent(PluginContract.ACTION_NATIVE_PLUGIN, buildUri(pluginId, "io.nekohasekai.AngApplication")), flags
)
.filter { it.providerInfo.exported }
}
if (providers.isEmpty()) {
providers = AngApplication.application.packageManager.queryIntentContentProviders(
Intent(PluginContract.ACTION_NATIVE_PLUGIN, buildUri(pluginId, "moe.matsuri.lite")), flags
)
.filter { it.providerInfo.exported }
}
if (providers.isEmpty()) {
providers = AngApplication.application.packageManager.queryIntentContentProviders(
Intent(PluginContract.ACTION_NATIVE_PLUGIN, buildUri(pluginId, "fr.husi")), flags
)
.filter { it.providerInfo.exported }
}
if (providers.isEmpty()) {
providers = AngApplication.application.packageManager.queryIntentContentProviders(
Intent(PluginContract.ACTION_NATIVE_PLUGIN), PackageManager.GET_META_DATA
).filter {
it.providerInfo.exported &&
it.providerInfo.metaData.containsKey(METADATA_KEY_ID) &&
it.providerInfo.metaData.getString(METADATA_KEY_ID) == pluginId
}
if (providers.size > 1) {
providers = listOf(providers[0]) // What if there is more than one?
}
}
if (providers.isEmpty()) return null
if (providers.size > 1) {
val message =
"Conflicting plugins found from: ${providers.joinToString { it.providerInfo.packageName }}"
AngApplication.application.toast(message)
throw IllegalStateException(message)
}
val provider = providers.single().providerInfo
var failure: Throwable? = null
try {
initNativeFaster(provider)?.also { return InitResult(it) }
} catch (t: Throwable) {
// Logs.w("Initializing native plugin faster mode failed")
failure = t
}
val uri = Uri.Builder().apply {
scheme(ContentResolver.SCHEME_CONTENT)
authority(provider.authority)
}.build()
try {
return initNativeFast(
AngApplication.application.contentResolver,
pluginId,
uri
)?.let { InitResult(it) }
} catch (t: Throwable) {
// Logs.w("Initializing native plugin fast mode failed")
failure?.also { t.addSuppressed(it) }
failure = t
}
try {
return initNativeSlow(
AngApplication.application.contentResolver,
pluginId,
uri
)?.let { InitResult(it) }
} catch (t: Throwable) {
failure.also { t.addSuppressed(it) }
throw t
}
}
private fun initNativeFaster(provider: ProviderInfo): String? {
return provider.loadString(PluginContract.METADATA_KEY_EXECUTABLE_PATH)
?.let { relativePath ->
File(provider.applicationInfo.nativeLibraryDir).resolve(relativePath).apply {
check(canExecute())
}.absolutePath
}
}
private fun initNativeFast(cr: ContentResolver, pluginId: String, uri: Uri): String? {
return cr.call(uri, PluginContract.METHOD_GET_EXECUTABLE, null, bundleOf())
?.getString(PluginContract.EXTRA_ENTRY)?.also {
check(File(it).canExecute())
}
}
@SuppressLint("Recycle")
private fun initNativeSlow(cr: ContentResolver, pluginId: String, uri: Uri): String? {
var initialized = false
fun entryNotFound(): Nothing =
throw IndexOutOfBoundsException("Plugin entry binary not found")
val pluginDir = File(AngApplication.application.noBackupFilesDir, "plugin")
(cr.query(
uri,
arrayOf(PluginContract.COLUMN_PATH, PluginContract.COLUMN_MODE),
null,
null,
null
)
?: return null).use { cursor ->
if (!cursor.moveToFirst()) entryNotFound()
pluginDir.deleteRecursively()
if (!pluginDir.mkdirs()) throw FileNotFoundException("Unable to create plugin directory")
val pluginDirPath = pluginDir.absolutePath + '/'
do {
val path = cursor.getString(0)
val file = File(pluginDir, path)
check(file.absolutePath.startsWith(pluginDirPath))
cr.openInputStream(uri.buildUpon().path(path).build())!!.use { inStream ->
file.outputStream().use { outStream -> inStream.copyTo(outStream) }
}
Os.chmod(
file.absolutePath, when (cursor.getType(1)) {
Cursor.FIELD_TYPE_INTEGER -> cursor.getInt(1)
Cursor.FIELD_TYPE_STRING -> cursor.getString(1).toInt(8)
else -> throw IllegalArgumentException("File mode should be of type int")
}
)
if (path == pluginId) initialized = true
} while (cursor.moveToNext())
}
if (!initialized) entryNotFound()
return File(pluginDir, pluginId).absolutePath
}
fun ComponentInfo.loadString(key: String) = when (val value = metaData.getString(key)) {
is String -> value
// is Int -> AngApplication.application.packageManager.getResourcesForApplication(applicationInfo)
// .getString(value)
null -> null
}
}
@@ -1,51 +0,0 @@
/******************************************************************************
* *
* Copyright (C) 2021 by nekohasekai <contact-sagernet@sekai.icu> *
* Copyright (C) 2021 by Max Lv <max.c.lv@gmail.com> *
* Copyright (C) 2021 by Mygod Studio <contact-shadowsocks-android@mygod.be> *
* *
* This program is free software: you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation, either version 3 of the License, or *
* (at your option) any later version. *
* *
* This program is distributed in the hope that it will be useful, *
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
* GNU General Public License for more details. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
******************************************************************************/
package com.v2ray.ang.plugin
import android.content.pm.ComponentInfo
import android.content.pm.PackageManager
import android.content.pm.ResolveInfo
import android.graphics.drawable.Drawable
import android.os.Build
import com.v2ray.ang.AngApplication
import com.v2ray.ang.plugin.PluginManager.loadString
abstract class ResolvedPlugin(protected val resolveInfo: ResolveInfo) : Plugin() {
protected abstract val componentInfo: ComponentInfo
override val id by lazy { componentInfo.loadString(PluginContract.METADATA_KEY_ID)!! }
override val version by lazy {
getPackageInfo(componentInfo.packageName).versionCode
}
override val versionName: String by lazy {
getPackageInfo(componentInfo.packageName).versionName!!
}
override val label: CharSequence get() = resolveInfo.loadLabel(AngApplication.application.packageManager)
override val icon: Drawable get() = resolveInfo.loadIcon(AngApplication.application.packageManager)
override val packageName: String get() = componentInfo.packageName
override val directBootAware get() = Build.VERSION.SDK_INT < 24 || componentInfo.directBootAware
fun getPackageInfo(packageName: String) = AngApplication.application.packageManager.getPackageInfo(
packageName, if (Build.VERSION.SDK_INT >= 28) PackageManager.GET_SIGNING_CERTIFICATES
else @Suppress("DEPRECATION") PackageManager.GET_SIGNATURES
)!!
}
@@ -0,0 +1,85 @@
package com.v2ray.ang.service
import android.content.Context
import com.v2ray.ang.AppConfig
import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.handler.V2RayNativeManager
import com.v2ray.ang.handler.V2rayConfigManager
import com.v2ray.ang.util.MessageUtil
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineName
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.joinAll
import kotlinx.coroutines.launch
import java.util.concurrent.Executors
import java.util.concurrent.atomic.AtomicInteger
/**
* Worker that runs a batch of real-ping tests independently.
* Each batch owns its own CoroutineScope/dispatcher and can be cancelled separately.
*/
class RealPingWorkerService(
private val context: Context,
private val guids: List<String>,
private val onFinish: (status: String) -> Unit = {}
) {
private val job = SupervisorJob()
private val cpu = Runtime.getRuntime().availableProcessors().coerceAtLeast(1)
private val dispatcher = Executors.newFixedThreadPool(cpu * 4).asCoroutineDispatcher()
private val scope = CoroutineScope(job + dispatcher + CoroutineName("RealPingBatchWorker"))
private val runningCount = AtomicInteger(0)
private val totalCount = AtomicInteger(0)
fun start() {
val jobs = guids.map { guid ->
totalCount.incrementAndGet()
scope.launch {
runningCount.incrementAndGet()
try {
val result = startRealPing(guid)
MessageUtil.sendMsg2UI(context, AppConfig.MSG_MEASURE_CONFIG_SUCCESS, Pair(guid, result))
} finally {
val count = totalCount.decrementAndGet()
val left = runningCount.decrementAndGet()
MessageUtil.sendMsg2UI(context, AppConfig.MSG_MEASURE_CONFIG_NOTIFY, "$left / $count")
}
}
}
scope.launch {
try {
joinAll(*jobs.toTypedArray())
onFinish("0")
} catch (_: CancellationException) {
onFinish("-1")
} finally {
close()
}
}
}
fun cancel() {
job.cancel()
}
private fun close() {
try {
dispatcher.close()
} catch (_: Throwable) {
// ignore
}
}
private fun startRealPing(guid: String): Long {
val retFailure = -1L
val configResult = V2rayConfigManager.getV2rayConfig4Speedtest(context, guid)
if (!configResult.status) {
return retFailure
}
return V2RayNativeManager.measureOutboundDelay(configResult.content, SettingsManager.getDelayTestUrl())
}
}
@@ -4,6 +4,7 @@ import android.content.Context
import android.os.ParcelFileDescriptor import android.os.ParcelFileDescriptor
import android.util.Log import android.util.Log
import com.v2ray.ang.AppConfig import com.v2ray.ang.AppConfig
import com.v2ray.ang.contracts.Tun2SocksControl
import com.v2ray.ang.handler.MmkvManager import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.SettingsManager import com.v2ray.ang.handler.SettingsManager
import java.io.File import java.io.File
@@ -4,6 +4,7 @@ import android.app.Service
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.IBinder import android.os.IBinder
import com.v2ray.ang.contracts.ServiceControl
import com.v2ray.ang.handler.SettingsManager import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.handler.V2RayServiceManager import com.v2ray.ang.handler.V2RayServiceManager
import com.v2ray.ang.util.MyContextWrapper import com.v2ray.ang.util.MyContextWrapper
@@ -6,37 +6,15 @@ import android.os.IBinder
import com.v2ray.ang.AppConfig import com.v2ray.ang.AppConfig
import com.v2ray.ang.AppConfig.MSG_MEASURE_CONFIG import com.v2ray.ang.AppConfig.MSG_MEASURE_CONFIG
import com.v2ray.ang.AppConfig.MSG_MEASURE_CONFIG_CANCEL import com.v2ray.ang.AppConfig.MSG_MEASURE_CONFIG_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.extension.serializable
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.PluginServiceManager
import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.handler.V2RayNativeManager import com.v2ray.ang.handler.V2RayNativeManager
import com.v2ray.ang.handler.V2rayConfigManager
import com.v2ray.ang.util.MessageUtil import com.v2ray.ang.util.MessageUtil
import kotlinx.coroutines.CancellationException import java.util.Collections
import kotlinx.coroutines.CoroutineName
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancelChildren
import kotlinx.coroutines.joinAll
import kotlinx.coroutines.launch
import java.util.concurrent.atomic.AtomicInteger
class V2RayTestService : Service() { class V2RayTestService : Service() {
private val realTestJob = SupervisorJob()
private val realDispatcher = Dispatchers.IO.limitedParallelism(
Runtime.getRuntime().availableProcessors() * 3
)
private val realTestScope = CoroutineScope(
realTestJob + realDispatcher + CoroutineName("RealTest")
)
// simple counter for currently running tasks // manage active batch workers so each batch is independent and cancellable
private val realTestRunningCount = AtomicInteger(0) private val activeWorkers = Collections.synchronizedList(mutableListOf<RealPingWorkerService>())
private val realTestCount = AtomicInteger(0)
/** /**
* Initializes the V2Ray environment. * Initializes the V2Ray environment.
@@ -60,7 +38,10 @@ class V2RayTestService : Service() {
*/ */
override fun onDestroy() { override fun onDestroy() {
super.onDestroy() super.onDestroy()
realTestJob.cancel() // cancel any active workers
val snapshot = ArrayList(activeWorkers)
snapshot.forEach { it.cancel() }
activeWorkers.clear()
} }
/** /**
@@ -75,70 +56,24 @@ class V2RayTestService : Service() {
MSG_MEASURE_CONFIG -> { MSG_MEASURE_CONFIG -> {
val guidsList = intent.serializable<ArrayList<String>>("content") val guidsList = intent.serializable<ArrayList<String>>("content")
if (guidsList != null && guidsList.isNotEmpty()) { if (guidsList != null && guidsList.isNotEmpty()) {
startBatchRealPing(guidsList) lateinit var worker: RealPingWorkerService
worker = RealPingWorkerService(this, guidsList) { status ->
// notify UI and remove the worker from active list when finished
MessageUtil.sendMsg2UI(this@V2RayTestService, AppConfig.MSG_MEASURE_CONFIG_FINISH, status)
activeWorkers.remove(worker)
}
activeWorkers.add(worker)
worker.start()
} }
} }
MSG_MEASURE_CONFIG_CANCEL -> { MSG_MEASURE_CONFIG_CANCEL -> {
realTestJob.cancelChildren() // cancel all running batch workers independently
val snapshot = ArrayList(activeWorkers)
snapshot.forEach { it.cancel() }
activeWorkers.clear()
} }
} }
return super.onStartCommand(intent, flags, startId) return super.onStartCommand(intent, flags, startId)
} }
/**
* Starts batch real ping tests.
* @param guidsList The list of GUIDs to test.
*/
private fun startBatchRealPing(guidsList: List<String>) {
val jobs = guidsList.map { guid ->
realTestCount.incrementAndGet()
realTestScope.launch {
realTestRunningCount.incrementAndGet()
try {
val result = startRealPing(guid)
MessageUtil.sendMsg2UI(this@V2RayTestService, MSG_MEASURE_CONFIG_SUCCESS, Pair(guid, result))
} finally {
val count = realTestCount.decrementAndGet()
val left = realTestRunningCount.decrementAndGet()
MessageUtil.sendMsg2UI(this@V2RayTestService, AppConfig.MSG_MEASURE_CONFIG_NOTIFY, "$left / $count")
}
}
}
realTestScope.launch {
try {
joinAll(*jobs.toTypedArray())
notifyAllTasksCompleted("0")
} catch (_: CancellationException) {
notifyAllTasksCompleted("-1")
}
}
}
private fun notifyAllTasksCompleted(status: String) {
MessageUtil.sendMsg2UI(this@V2RayTestService, AppConfig.MSG_MEASURE_CONFIG_FINISH, status)
}
/**
* Starts the real ping test.
* @param guid The GUID of the configuration.
* @return The ping result.
*/
private fun startRealPing(guid: String): Long {
val retFailure = -1L
val config = MmkvManager.decodeServerConfig(guid) ?: return retFailure
if (config.configType == EConfigType.HYSTERIA2) {
val delay = PluginServiceManager.realPingHy2(this, config)
return delay
} else {
val configResult = V2rayConfigManager.getV2rayConfig4Speedtest(this, guid)
if (!configResult.status) {
return retFailure
}
return V2RayNativeManager.measureOutboundDelay(configResult.content, SettingsManager.getDelayTestUrl())
}
}
} }
@@ -18,6 +18,8 @@ import androidx.annotation.RequiresApi
import com.v2ray.ang.AppConfig import com.v2ray.ang.AppConfig
import com.v2ray.ang.AppConfig.LOOPBACK import com.v2ray.ang.AppConfig.LOOPBACK
import com.v2ray.ang.BuildConfig import com.v2ray.ang.BuildConfig
import com.v2ray.ang.contracts.ServiceControl
import com.v2ray.ang.contracts.Tun2SocksControl
import com.v2ray.ang.handler.MmkvManager import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.NotificationManager import com.v2ray.ang.handler.NotificationManager
import com.v2ray.ang.handler.SettingsManager import com.v2ray.ang.handler.SettingsManager
@@ -76,7 +78,7 @@ class V2RayVpnService : VpnService(), ServiceControl {
} }
override fun onRevoke() { override fun onRevoke() {
stopV2Ray() stopAllService()
} }
// override fun onLowMemory() { // override fun onLowMemory() {
@@ -105,11 +107,15 @@ class V2RayVpnService : VpnService(), ServiceControl {
Log.e(AppConfig.TAG, "Failed to create VPN interface") Log.e(AppConfig.TAG, "Failed to create VPN interface")
return return
} }
V2RayServiceManager.startCoreLoop(mInterface) if (!V2RayServiceManager.startCoreLoop(mInterface)) {
Log.e(AppConfig.TAG, "Failed to start V2Ray core loop")
stopAllService()
return
}
} }
override fun stopService() { override fun stopService() {
stopV2Ray(true) stopAllService(true)
} }
override fun vpnProtect(socket: Int): Boolean { override fun vpnProtect(socket: Int): Boolean {
@@ -130,10 +136,14 @@ class V2RayVpnService : VpnService(), ServiceControl {
private fun setupVpnService() { private fun setupVpnService() {
val prepare = prepare(this) val prepare = prepare(this)
if (prepare != null) { if (prepare != null) {
Log.e(AppConfig.TAG, "VPN preparation failed")
stopSelf()
return return
} }
if (configureVpnService() != true) { if (configureVpnService() != true) {
Log.e(AppConfig.TAG, "VPN configuration failed")
stopSelf()
return return
} }
@@ -170,7 +180,7 @@ class V2RayVpnService : VpnService(), ServiceControl {
return true return true
} catch (e: Exception) { } catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to establish VPN interface", e) Log.e(AppConfig.TAG, "Failed to establish VPN interface", e)
stopV2Ray() stopAllService()
} }
return false return false
} }
@@ -311,11 +321,7 @@ class V2RayVpnService : VpnService(), ServiceControl {
tun2SocksService?.startTun2Socks() tun2SocksService?.startTun2Socks()
} }
/** private fun stopAllService(isForced: Boolean = true) {
* Stops the V2Ray service.
* @param isForced Whether to force stop the service.
*/
private fun stopV2Ray(isForced: Boolean = true) {
// val configName = defaultDPreference.getPrefString(PREF_CURR_CONFIG_GUID, "") // val configName = defaultDPreference.getPrefString(PREF_CURR_CONFIG_GUID, "")
// val emptyInfo = VpnNetworkInfo() // val emptyInfo = VpnNetworkInfo()
// val info = loadVpnNetworkInfo(configName, emptyInfo)!! + (lastNetworkInfo ?: emptyInfo) // val info = loadVpnNetworkInfo(configName, emptyInfo)!! + (lastNetworkInfo ?: emptyInfo)
@@ -1,25 +1,19 @@
package com.v2ray.ang.ui package com.v2ray.ang.ui
import android.Manifest
import android.app.AlertDialog import android.app.AlertDialog
import android.content.ActivityNotFoundException
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.util.Log import android.util.Log
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.tencent.mmkv.MMKV import com.tencent.mmkv.MMKV
import com.v2ray.ang.AppConfig import com.v2ray.ang.AppConfig
import com.v2ray.ang.AppConfig.WEBDAV_BACKUP_FILE_NAME
import com.v2ray.ang.BuildConfig import com.v2ray.ang.BuildConfig
import com.v2ray.ang.R import com.v2ray.ang.R
import com.v2ray.ang.databinding.ActivityBackupBinding import com.v2ray.ang.databinding.ActivityBackupBinding
import com.v2ray.ang.databinding.DialogWebdavBinding import com.v2ray.ang.databinding.DialogWebdavBinding
import com.v2ray.ang.dto.WebDavConfig import com.v2ray.ang.dto.WebDavConfig
import com.v2ray.ang.extension.toast
import com.v2ray.ang.extension.toastError import com.v2ray.ang.extension.toastError
import com.v2ray.ang.extension.toastSuccess import com.v2ray.ang.extension.toastSuccess
import com.v2ray.ang.handler.MmkvManager import com.v2ray.ang.handler.MmkvManager
@@ -33,54 +27,13 @@ import java.io.File
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Locale import java.util.Locale
class BackupActivity : BaseActivity() { class BackupActivity : HelperBaseActivity() {
private val binding by lazy { ActivityBackupBinding.inflate(layoutInflater) } private val binding by lazy { ActivityBackupBinding.inflate(layoutInflater) }
private val config_backup_options: Array<out String> by lazy { private val config_backup_options: Array<out String> by lazy {
resources.getStringArray(R.array.config_backup_options) resources.getStringArray(R.array.config_backup_options)
} }
companion object {
private const val BACKUP_FILE_NAME = "backup_ng.zip"
}
private val requestPermissionLauncher =
registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted ->
if (isGranted) {
try {
showFileChooser()
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to show file chooser", e)
}
} else {
toast(R.string.toast_permission_denied)
}
}
private val createBackupFile =
registerForActivityResult(ActivityResultContracts.CreateDocument("application/zip")) { uri ->
if (uri != null) {
try {
val ret = backupConfigurationToCache()
if (ret.first) {
// Copy the cached zip file to user-selected location
contentResolver.openOutputStream(uri)?.use { output ->
File(ret.second).inputStream().use { input ->
input.copyTo(output)
}
}
// Clean up cache file
File(ret.second).delete()
toastSuccess(R.string.toast_success)
} else {
toastError(R.string.toast_failure)
}
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to backup configuration", e)
toastError(R.string.toast_failure)
}
}
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@@ -175,42 +128,29 @@ class BackupActivity : BaseActivity() {
} }
private fun showFileChooser() { private fun showFileChooser() {
val intent = Intent(Intent.ACTION_GET_CONTENT).apply { launchFileChooser { uri ->
type = "*/*" if (uri == null) {
addCategory(Intent.CATEGORY_OPENABLE) return@launchFileChooser
} }
try {
try { val targetFile =
chooseFile.launch(Intent.createChooser(intent, getString(R.string.title_file_chooser))) File(this.cacheDir.absolutePath, "${System.currentTimeMillis()}.zip")
} catch (ex: ActivityNotFoundException) { contentResolver.openInputStream(uri).use { input ->
Log.e(AppConfig.TAG, "File chooser activity not found", ex) targetFile.outputStream().use { fileOut ->
toast(R.string.toast_require_file_manager) input?.copyTo(fileOut)
}
}
private val chooseFile =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
val uri = result.data?.data
if (result.resultCode == RESULT_OK && uri != null) {
try {
val targetFile =
File(this.cacheDir.absolutePath, "${System.currentTimeMillis()}.zip")
contentResolver.openInputStream(uri).use { input ->
targetFile.outputStream().use { fileOut ->
input?.copyTo(fileOut)
}
} }
if (restoreConfiguration(targetFile)) { }
toastSuccess(R.string.toast_success) if (restoreConfiguration(targetFile)) {
} else { toastSuccess(R.string.toast_success)
toastError(R.string.toast_failure) } else {
}
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Error during file restore", e)
toastError(R.string.toast_failure) toastError(R.string.toast_failure)
} }
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Error during file restore", e)
toastError(R.string.toast_failure)
} }
} }
}
private fun backupViaLocal() { private fun backupViaLocal() {
val dateFormatted = SimpleDateFormat( val dateFormatted = SimpleDateFormat(
@@ -218,25 +158,34 @@ class BackupActivity : BaseActivity() {
Locale.getDefault() Locale.getDefault()
).format(System.currentTimeMillis()) ).format(System.currentTimeMillis())
val defaultFileName = "${getString(R.string.app_name)}_${dateFormatted}.zip" val defaultFileName = "${getString(R.string.app_name)}_${dateFormatted}.zip"
createBackupFile.launch(defaultFileName)
launchCreateDocument(defaultFileName) { uri ->
if (uri != null) {
try {
val ret = backupConfigurationToCache()
if (ret.first) {
// Copy the cached zip file to user-selected location
contentResolver.openOutputStream(uri)?.use { output ->
File(ret.second).inputStream().use { input ->
input.copyTo(output)
}
}
// Clean up cache file
File(ret.second).delete()
toastSuccess(R.string.toast_success)
} else {
toastError(R.string.toast_failure)
}
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to backup configuration", e)
toastError(R.string.toast_failure)
}
}
}
} }
private fun restoreViaLocal() { private fun restoreViaLocal() {
val permission = showFileChooser()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
Manifest.permission.READ_MEDIA_IMAGES
} else {
Manifest.permission.READ_EXTERNAL_STORAGE
}
if (ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED) {
try {
showFileChooser()
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to show file chooser", e)
}
} else {
requestPermissionLauncher.launch(permission)
}
} }
private fun backupViaWebDav() { private fun backupViaWebDav() {
@@ -263,7 +212,7 @@ class BackupActivity : BaseActivity() {
WebDavManager.init(saved) WebDavManager.init(saved)
val ok = try { val ok = try {
WebDavManager.uploadFile(tempFile, BACKUP_FILE_NAME) WebDavManager.uploadFile(tempFile, WEBDAV_BACKUP_FILE_NAME)
} catch (e: Exception) { } catch (e: Exception) {
Log.e(AppConfig.TAG, "WebDAV upload error", e) Log.e(AppConfig.TAG, "WebDAV upload error", e)
false false
@@ -303,7 +252,7 @@ class BackupActivity : BaseActivity() {
try { try {
target = File(cacheDir, "download_${System.currentTimeMillis()}.zip") target = File(cacheDir, "download_${System.currentTimeMillis()}.zip")
WebDavManager.init(saved) WebDavManager.init(saved)
val ok = WebDavManager.downloadFile(BACKUP_FILE_NAME, target) val ok = WebDavManager.downloadFile(WEBDAV_BACKUP_FILE_NAME, target)
if (!ok) { if (!ok) {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
toastError(R.string.toast_failure) toastError(R.string.toast_failure)
@@ -351,7 +300,7 @@ class BackupActivity : BaseActivity() {
val url = dialogBinding.etWebdavUrl.text.toString().trim() val url = dialogBinding.etWebdavUrl.text.toString().trim()
val user = dialogBinding.etWebdavUser.text.toString().trim().ifEmpty { null } val user = dialogBinding.etWebdavUser.text.toString().trim().ifEmpty { null }
val pass = dialogBinding.etWebdavPass.text.toString() val pass = dialogBinding.etWebdavPass.text.toString()
val remotePath = dialogBinding.etWebdavRemotePath.text.toString().trim().ifEmpty { "/" } val remotePath = dialogBinding.etWebdavRemotePath.text.toString().trim().ifEmpty { AppConfig.WEBDAV_BACKUP_DIR }
val cfg = WebDavConfig(baseUrl = url, username = user, password = pass, remoteBasePath = remotePath) val cfg = WebDavConfig(baseUrl = url, username = user, password = pass, remoteBasePath = remotePath)
MmkvManager.encodeWebDavConfig(cfg) MmkvManager.encodeWebDavConfig(cfg)
toastSuccess(R.string.toast_success) toastSuccess(R.string.toast_success)
@@ -39,6 +39,7 @@ abstract class BaseActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
supportActionBar?.setDisplayHomeAsUpEnabled(true) supportActionBar?.setDisplayHomeAsUpEnabled(true)
if (!Utils.getDarkModeStatus(this)) { if (!Utils.getDarkModeStatus(this)) {
WindowCompat.getInsetsController(window, window.decorView).apply { WindowCompat.getInsetsController(window, window.decorView).apply {
@@ -212,5 +213,4 @@ abstract class BaseActivity : AppCompatActivity() {
protected fun isLoadingVisible(): Boolean { protected fun isLoadingVisible(): Boolean {
return progressBar?.visibility == View.VISIBLE return progressBar?.visibility == View.VISIBLE
} }
} }
@@ -11,6 +11,8 @@ import com.v2ray.ang.dto.GroupMapItem
class GroupPagerAdapter(activity: FragmentActivity, var groups: List<GroupMapItem>) : FragmentStateAdapter(activity) { class GroupPagerAdapter(activity: FragmentActivity, var groups: List<GroupMapItem>) : FragmentStateAdapter(activity) {
override fun getItemCount(): Int = groups.size override fun getItemCount(): Int = groups.size
override fun createFragment(position: Int) = GroupServerFragment.newInstance(groups[position].id) override fun createFragment(position: Int) = GroupServerFragment.newInstance(groups[position].id)
override fun getItemId(position: Int): Long = groups[position].id.hashCode().toLong()
override fun containsItem(itemId: Long): Boolean = groups.any { it.id.hashCode().toLong() == itemId }
@SuppressLint("NotifyDataSetChanged") @SuppressLint("NotifyDataSetChanged")
fun update(groups: List<GroupMapItem>) { fun update(groups: List<GroupMapItem>) {
@@ -1,27 +1,48 @@
package com.v2ray.ang.ui package com.v2ray.ang.ui
import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.util.Log import android.util.Log
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.ItemTouchHelper
import com.v2ray.ang.AppConfig import com.v2ray.ang.AppConfig
import com.v2ray.ang.AppConfig.TAG
import com.v2ray.ang.R import com.v2ray.ang.R
import com.v2ray.ang.contracts.MainAdapterListener
import com.v2ray.ang.databinding.FragmentGroupServerBinding import com.v2ray.ang.databinding.FragmentGroupServerBinding
import com.v2ray.ang.databinding.ItemQrcodeBinding
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.extension.toast
import com.v2ray.ang.extension.toastError
import com.v2ray.ang.extension.toastSuccess
import com.v2ray.ang.handler.AngConfigManager
import com.v2ray.ang.handler.MmkvManager import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.helper.SimpleItemTouchHelperCallback import com.v2ray.ang.helper.SimpleItemTouchHelperCallback
import com.v2ray.ang.viewmodel.MainViewModel import com.v2ray.ang.viewmodel.MainViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class GroupServerFragment : BaseFragment<FragmentGroupServerBinding>() { class GroupServerFragment : BaseFragment<FragmentGroupServerBinding>() {
private val ownerActivity: MainActivity
get() = requireActivity() as MainActivity
private val mainViewModel: MainViewModel by activityViewModels() private val mainViewModel: MainViewModel by activityViewModels()
private lateinit var adapter: MainRecyclerAdapter private lateinit var adapter: MainRecyclerAdapter
private var itemTouchHelper: ItemTouchHelper? = null private var itemTouchHelper: ItemTouchHelper? = null
private val subId: String by lazy { arguments?.getString(ARG_SUB_ID).orEmpty() } private val subId: String by lazy { arguments?.getString(ARG_SUB_ID).orEmpty() }
private val share_method: Array<out String> by lazy {
ownerActivity.resources.getStringArray(R.array.share_method)
}
private val share_method_more: Array<out String> by lazy {
ownerActivity.resources.getStringArray(R.array.share_method_more)
}
companion object { companion object {
private const val ARG_SUB_ID = "subscriptionId" private const val ARG_SUB_ID = "subscriptionId"
fun newInstance(subId: String) = GroupServerFragment().apply { fun newInstance(subId: String) = GroupServerFragment().apply {
@@ -34,7 +55,7 @@ class GroupServerFragment : BaseFragment<FragmentGroupServerBinding>() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
adapter = MainRecyclerAdapter(requireActivity() as MainActivity) adapter = MainRecyclerAdapter(mainViewModel, ActivityAdapterListener())
binding.recyclerView.setHasFixedSize(true) binding.recyclerView.setHasFixedSize(true)
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_DOUBLE_COLUMN_DISPLAY, false)) { if (MmkvManager.decodeSettingsBool(AppConfig.PREF_DOUBLE_COLUMN_DISPLAY, false)) {
binding.recyclerView.layoutManager = GridLayoutManager(requireContext(), 2) binding.recyclerView.layoutManager = GridLayoutManager(requireContext(), 2)
@@ -51,18 +72,202 @@ class GroupServerFragment : BaseFragment<FragmentGroupServerBinding>() {
if (mainViewModel.subscriptionId != subId) { if (mainViewModel.subscriptionId != subId) {
return@observe return@observe
} }
Log.d(TAG, "GroupServerFragment updateListAction subId=$subId") // Log.d(TAG, "GroupServerFragment updateListAction subId=$subId")
adapter.setData(mainViewModel.serversCache, index) adapter.setData(mainViewModel.serversCache, index)
} }
mainViewModel.isRunning.observe(viewLifecycleOwner) { isRunning ->
adapter.isRunning = isRunning
}
Log.d(TAG, "GroupServerFragment onViewCreated: subId=$subId") // Log.d(TAG, "GroupServerFragment onViewCreated: subId=$subId")
} }
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
mainViewModel.subscriptionIdChanged(subId) mainViewModel.subscriptionIdChanged(subId)
} }
}
/**
* Shares server configuration
* Displays a dialog with sharing options and executes the selected action
* @param guid The server unique identifier
* @param profile The server configuration
* @param position The position in the list
* @param shareOptions The list of share options
* @param skip The number of options to skip
*/
private fun shareServer(guid: String, profile: ProfileItem, position: Int, shareOptions: List<String>, skip: Int) {
AlertDialog.Builder(ownerActivity).setItems(shareOptions.toTypedArray()) { _, i ->
try {
when (i + skip) {
0 -> showQRCode(guid)
1 -> share2Clipboard(guid)
2 -> shareFullContent(guid)
3 -> editServer(guid, profile)
4 -> removeServer(guid, position)
else -> ownerActivity.toast("else")
}
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Error when sharing server", e)
}
}.show()
}
/**
* Displays QR code for the server configuration
* @param guid The server unique identifier
*/
private fun showQRCode(guid: String) {
val ivBinding = ItemQrcodeBinding.inflate(LayoutInflater.from(ownerActivity))
ivBinding.ivQcode.setImageBitmap(AngConfigManager.share2QRCode(guid))
if (share_method.isNotEmpty()) {
ivBinding.ivQcode.contentDescription = share_method[0]
} else {
ivBinding.ivQcode.contentDescription = "QR Code"
}
AlertDialog.Builder(ownerActivity).setView(ivBinding.root).show()
}
/**
* Shares server configuration to clipboard
* @param guid The server unique identifier
*/
private fun share2Clipboard(guid: String) {
if (AngConfigManager.share2Clipboard(ownerActivity, guid) == 0) {
ownerActivity.toastSuccess(R.string.toast_success)
} else {
ownerActivity.toastError(R.string.toast_failure)
}
}
/**
* Shares full server configuration content to clipboard
* @param guid The server unique identifier
*/
private fun shareFullContent(guid: String) {
ownerActivity.lifecycleScope.launch(Dispatchers.IO) {
val result = AngConfigManager.shareFullContent2Clipboard(ownerActivity, guid)
launch(Dispatchers.Main) {
if (result == 0) {
ownerActivity.toastSuccess(R.string.toast_success)
} else {
ownerActivity.toastError(R.string.toast_failure)
}
}
}
}
/**
* Edits server configuration
* Opens appropriate editing interface based on configuration type
* @param guid The server unique identifier
* @param profile The server configuration
*/
private fun editServer(guid: String, profile: ProfileItem) {
val intent = Intent().putExtra("guid", guid)
.putExtra("isRunning", mainViewModel.isRunning.value)
.putExtra("createConfigType", profile.configType.value)
when (profile.configType) {
EConfigType.CUSTOM -> {
ownerActivity.startActivity(intent.setClass(ownerActivity, ServerCustomConfigActivity::class.java))
}
EConfigType.POLICYGROUP -> {
ownerActivity.startActivity(intent.setClass(ownerActivity, ServerGroupActivity::class.java))
}
else -> {
ownerActivity.startActivity(intent.setClass(ownerActivity, ServerActivity::class.java))
}
}
}
/**
* Removes server configuration
* Handles confirmation dialog and related checks
* @param guid The server unique identifier
* @param position The position in the list
*/
private fun removeServer(guid: String, position: Int) {
if (guid == MmkvManager.getSelectServer()) {
ownerActivity.toast(R.string.toast_action_not_allowed)
return
}
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_CONFIRM_REMOVE)) {
AlertDialog.Builder(ownerActivity).setMessage(R.string.del_config_comfirm)
.setPositiveButton(android.R.string.ok) { _, _ ->
removeServerSub(guid, position)
}
.setNegativeButton(android.R.string.cancel) { _, _ ->
//do noting
}
.show()
} else {
removeServerSub(guid, position)
}
}
/**
* Executes the actual server removal process
* @param guid The server unique identifier
* @param position The position in the list
*/
private fun removeServerSub(guid: String, position: Int) {
ownerActivity.mainViewModel.removeServer(guid)
adapter.removeServerSub(guid, position)
}
/**
* Sets the selected server
* Updates UI and restarts service if needed
* @param guid The server unique identifier to select
*/
private fun setSelectServer(guid: String) {
val selected = MmkvManager.getSelectServer()
if (guid != selected) {
MmkvManager.setSelectServer(guid)
val fromPosition = mainViewModel.getPosition(selected.orEmpty())
val toPosition = mainViewModel.getPosition(guid)
adapter.setSelectServer(fromPosition, toPosition)
if (mainViewModel.isRunning.value == true) {
ownerActivity.restartV2Ray()
}
}
}
private inner class ActivityAdapterListener : MainAdapterListener {
override fun onEdit(guid: String, position: Int) {
}
override fun onShare(url: String) {
}
override fun onRefreshData() {
}
override fun onRemove(guid: String, position: Int) {
removeServer(guid, position)
}
override fun onEdit(guid: String, position: Int, profile: ProfileItem) {
editServer(guid, profile)
}
override fun onSelectServer(guid: String) {
setSelectServer(guid)
}
override fun onShare(guid: String, profile: ProfileItem, position: Int, more: Boolean) {
val isCustom = profile.configType == EConfigType.CUSTOM || profile.configType == EConfigType.POLICYGROUP
val (shareOptions, skip) = if (more) {
val options = if (isCustom) share_method_more.asList().takeLast(3) else share_method_more.asList()
options to if (isCustom) 2 else 0
} else {
val options = if (isCustom) share_method.asList().takeLast(1) else share_method.asList()
options to if (isCustom) 2 else 0
}
shareServer(guid, profile, position, shareOptions, skip)
}
}
}
@@ -0,0 +1,90 @@
package com.v2ray.ang.ui
import android.net.Uri
import android.os.Bundle
import com.v2ray.ang.enums.PermissionType
import com.v2ray.ang.helper.FileChooserHelper
import com.v2ray.ang.helper.PermissionHelper
import com.v2ray.ang.helper.QRCodeScannerHelper
/**
* HelperBaseActivity extends BaseActivity and provides additional helpers for
* activities that need file chooser, permission requesting, or QR code scanning functionality.
*
* Activities that don't need these features should extend BaseActivity directly.
* Activities that need file selection, permissions, or QR code scanning should extend this class.
*
* Additional Responsibilities:
* - Provide file chooser helpers for selecting and creating files.
* - Provide permission request helpers with callbacks.
* - Provide QR code scanning helpers with camera permission handling.
*/
abstract class HelperBaseActivity : BaseActivity() {
private lateinit var fileChooser : FileChooserHelper
private lateinit var permissionRequester : PermissionHelper
private lateinit var qrCodeScanner : QRCodeScannerHelper
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
fileChooser = FileChooserHelper(this)
permissionRequester = PermissionHelper(this)
qrCodeScanner = QRCodeScannerHelper(this)
}
/**
* Check if permission is granted and request it if not.
* Convenience method that delegates to permissionRequester.
*
* @param permissionType The type of permission to check and request
* @param onGranted Callback to execute when permission is granted
*/
protected fun checkAndRequestPermission(
permissionType: PermissionType,
onGranted: () -> Unit
) {
permissionRequester.request(permissionType, onGranted)
}
/**
* Launch file chooser with ACTION_GET_CONTENT intent.
* Convenience method that delegates to fileChooser helper.
*
* @param mimeType MIME type filter for files
* @param onResult Callback invoked with the selected file URI (null if cancelled)
*/
protected fun launchFileChooser(
mimeType: String = "*/*",
onResult: (Uri?) -> Unit
) {
checkAndRequestPermission(PermissionType.READ_STORAGE) {
fileChooser.launch(mimeType, onResult)
}
}
/**
* Launch document creator to create a new file at user-selected location.
* Convenience method that delegates to fileChooser helper.
* Note: No permission check needed as CreateDocument uses Storage Access Framework.
*
* @param fileName Default file name for the new document
* @param onResult Callback invoked with the created file URI (null if cancelled)
*/
protected fun launchCreateDocument(
fileName: String,
onResult: (Uri?) -> Unit
) {
fileChooser.createDocument(fileName, onResult)
}
/**
* Launch QR code scanner with camera permission check.
* Convenience method that delegates to qrCodeScanner helper.
*
* @param onResult Callback invoked with the scan result string (null if cancelled or failed)
*/
protected fun launchQRCodeScanner(onResult: (String?) -> Unit) {
checkAndRequestPermission(PermissionType.CAMERA) {
qrCodeScanner.launch(onResult)
}
}
}
@@ -2,37 +2,34 @@ package com.v2ray.ang.ui
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.os.Bundle import android.os.Bundle
import android.util.Log
import android.view.Menu import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
import androidx.activity.viewModels
import androidx.appcompat.widget.SearchView import androidx.appcompat.widget.SearchView
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import com.v2ray.ang.AppConfig
import com.v2ray.ang.AppConfig.ANG_PACKAGE
import com.v2ray.ang.R import com.v2ray.ang.R
import com.v2ray.ang.databinding.ActivityLogcatBinding import com.v2ray.ang.databinding.ActivityLogcatBinding
import com.v2ray.ang.extension.toast
import com.v2ray.ang.extension.toastSuccess import com.v2ray.ang.extension.toastSuccess
import com.v2ray.ang.util.Utils import com.v2ray.ang.util.Utils
import com.v2ray.ang.viewmodel.LogcatViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.IOException
class LogcatActivity : BaseActivity(), SwipeRefreshLayout.OnRefreshListener { class LogcatActivity : BaseActivity(), SwipeRefreshLayout.OnRefreshListener {
private val binding by lazy { ActivityLogcatBinding.inflate(layoutInflater) } private val binding by lazy { ActivityLogcatBinding.inflate(layoutInflater) }
private val viewModel: LogcatViewModel by viewModels()
private var logsetsAll: MutableList<String> = mutableListOf() private lateinit var adapter: LogcatRecyclerAdapter
var logsets: MutableList<String> = mutableListOf()
private val adapter by lazy { LogcatRecyclerAdapter(this) }
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
//setContentView(binding.root)
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.title_logcat)) setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.title_logcat))
adapter = LogcatRecyclerAdapter(viewModel, ::onLogLongClick)
binding.recyclerView.setHasFixedSize(true) binding.recyclerView.setHasFixedSize(true)
binding.recyclerView.layoutManager = LinearLayoutManager(this) binding.recyclerView.layoutManager = LinearLayoutManager(this)
addCustomDividerToRecyclerView(binding.recyclerView, this, R.drawable.custom_divider) addCustomDividerToRecyclerView(binding.recyclerView, this, R.drawable.custom_divider)
@@ -40,58 +37,12 @@ class LogcatActivity : BaseActivity(), SwipeRefreshLayout.OnRefreshListener {
binding.refreshLayout.setOnRefreshListener(this) binding.refreshLayout.setOnRefreshListener(this)
logsets.add(getString(R.string.pull_down_to_refresh)) toast(getString(R.string.pull_down_to_refresh))
} }
private fun getLogcat() { private fun onLogLongClick(log: String): Boolean {
Utils.setClipboard(this, log)
try { return true
binding.refreshLayout.isRefreshing = true
lifecycleScope.launch(Dispatchers.Default) {
val lst = LinkedHashSet<String>()
lst.add("logcat")
lst.add("-d")
lst.add("-v")
lst.add("time")
lst.add("-s")
lst.add("GoLog,${ANG_PACKAGE},AndroidRuntime,System.err")
val process = withContext(Dispatchers.IO) {
Runtime.getRuntime().exec(lst.toTypedArray())
}
val allText = process.inputStream.bufferedReader().use { it.readLines() }.reversed()
launch(Dispatchers.Main) {
logsetsAll = allText.toMutableList()
logsets = allText.toMutableList()
refreshData()
binding.refreshLayout.isRefreshing = false
}
}
} catch (e: IOException) {
Log.e(AppConfig.TAG, "Failed to get logcat", e)
}
}
private fun clearLogcat() {
try {
lifecycleScope.launch(Dispatchers.Default) {
val lst = LinkedHashSet<String>()
lst.add("logcat")
lst.add("-c")
withContext(Dispatchers.IO) {
val process = Runtime.getRuntime().exec(lst.toTypedArray())
process.waitFor()
}
launch(Dispatchers.Main) {
logsetsAll.clear()
logsets.clear()
refreshData()
}
}
} catch (e: IOException) {
Log.e(AppConfig.TAG, "Failed to clear logcat", e)
}
} }
override fun onCreateOptionsMenu(menu: Menu): Boolean { override fun onCreateOptionsMenu(menu: Menu): Boolean {
@@ -104,12 +55,14 @@ class LogcatActivity : BaseActivity(), SwipeRefreshLayout.OnRefreshListener {
override fun onQueryTextSubmit(query: String?): Boolean = false override fun onQueryTextSubmit(query: String?): Boolean = false
override fun onQueryTextChange(newText: String?): Boolean { override fun onQueryTextChange(newText: String?): Boolean {
filterLogs(newText) viewModel.filter(newText)
refreshData()
return false return false
} }
}) })
searchView.setOnCloseListener { searchView.setOnCloseListener {
filterLogs("") viewModel.filter("")
refreshData()
false false
} }
} }
@@ -119,33 +72,33 @@ class LogcatActivity : BaseActivity(), SwipeRefreshLayout.OnRefreshListener {
override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) { override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
R.id.copy_all -> { R.id.copy_all -> {
Utils.setClipboard(this, logsets.joinToString("\n")) val all = viewModel.getAll().joinToString("\n")
Utils.setClipboard(this, all)
toastSuccess(R.string.toast_success) toastSuccess(R.string.toast_success)
true true
} }
R.id.clear_all -> { R.id.clear_all -> {
clearLogcat() lifecycleScope.launch(Dispatchers.IO) {
viewModel.clearLogcat()
withContext(Dispatchers.Main) {
refreshData()
}
}
true true
} }
else -> super.onOptionsItemSelected(item) else -> super.onOptionsItemSelected(item)
} }
private fun filterLogs(content: String?): Boolean {
val key = content?.trim()
logsets = if (key.isNullOrEmpty()) {
logsetsAll.toMutableList()
} else {
logsetsAll.filter { it.contains(key) }.toMutableList()
}
refreshData()
return true
}
override fun onRefresh() { override fun onRefresh() {
getLogcat() lifecycleScope.launch(Dispatchers.IO) {
viewModel.loadLogcat()
withContext(Dispatchers.Main) {
binding.refreshLayout.isRefreshing = false
refreshData()
}
}
} }
@SuppressLint("NotifyDataSetChanged") @SuppressLint("NotifyDataSetChanged")
@@ -6,17 +6,20 @@ import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.v2ray.ang.AppConfig import com.v2ray.ang.AppConfig
import com.v2ray.ang.databinding.ItemRecyclerLogcatBinding import com.v2ray.ang.databinding.ItemRecyclerLogcatBinding
import com.v2ray.ang.util.Utils import com.v2ray.ang.viewmodel.LogcatViewModel
class LogcatRecyclerAdapter(val activity: LogcatActivity) : RecyclerView.Adapter<LogcatRecyclerAdapter.MainViewHolder>() { class LogcatRecyclerAdapter(
private var mActivity: LogcatActivity = activity private val viewModel: LogcatViewModel,
private val onLongClick: ((String) -> Boolean)? = null
) : RecyclerView.Adapter<LogcatRecyclerAdapter.MainViewHolder>() {
override fun getItemCount() = viewModel.getAll().size
override fun getItemCount() = mActivity.logsets.size
override fun onBindViewHolder(holder: MainViewHolder, position: Int) { override fun onBindViewHolder(holder: MainViewHolder, position: Int) {
try { try {
val log = mActivity.logsets[position] val logs = viewModel.getAll()
val log = logs[position]
if (log.isEmpty()) { if (log.isEmpty()) {
holder.itemSubSettingBinding.logTag.text = "" holder.itemSubSettingBinding.logTag.text = ""
holder.itemSubSettingBinding.logContent.text = "" holder.itemSubSettingBinding.logContent.text = ""
@@ -27,8 +30,7 @@ class LogcatRecyclerAdapter(val activity: LogcatActivity) : RecyclerView.Adapter
} }
holder.itemView.setOnLongClickListener { holder.itemView.setOnLongClickListener {
Utils.setClipboard(mActivity, log) onLongClick?.invoke(log) ?: false
true
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.e(AppConfig.TAG, "Error binding log view data", e) Log.e(AppConfig.TAG, "Error binding log view data", e)
@@ -1,12 +1,9 @@
package com.v2ray.ang.ui package com.v2ray.ang.ui
import android.Manifest
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager
import android.content.res.ColorStateList import android.content.res.ColorStateList
import android.net.Uri import android.net.Uri
import android.net.VpnService import android.net.VpnService
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.util.Log import android.util.Log
import android.view.KeyEvent import android.view.KeyEvent
@@ -25,15 +22,16 @@ import androidx.lifecycle.lifecycleScope
import com.google.android.material.navigation.NavigationView import com.google.android.material.navigation.NavigationView
import com.google.android.material.tabs.TabLayoutMediator import com.google.android.material.tabs.TabLayoutMediator
import com.v2ray.ang.AppConfig import com.v2ray.ang.AppConfig
import com.v2ray.ang.AppConfig.VPN
import com.v2ray.ang.R import com.v2ray.ang.R
import com.v2ray.ang.databinding.ActivityMainBinding import com.v2ray.ang.databinding.ActivityMainBinding
import com.v2ray.ang.dto.EConfigType import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.enums.PermissionType
import com.v2ray.ang.extension.toast import com.v2ray.ang.extension.toast
import com.v2ray.ang.extension.toastError import com.v2ray.ang.extension.toastError
import com.v2ray.ang.handler.AngConfigManager import com.v2ray.ang.handler.AngConfigManager
import com.v2ray.ang.handler.MmkvManager import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.SettingsChangeManager import com.v2ray.ang.handler.SettingsChangeManager
import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.handler.V2RayServiceManager import com.v2ray.ang.handler.V2RayServiceManager
import com.v2ray.ang.util.Utils import com.v2ray.ang.util.Utils
import com.v2ray.ang.viewmodel.MainViewModel import com.v2ray.ang.viewmodel.MainViewModel
@@ -42,7 +40,7 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedListener { class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelectedListener {
private val binding by lazy { private val binding by lazy {
ActivityMainBinding.inflate(layoutInflater) ActivityMainBinding.inflate(layoutInflater)
} }
@@ -65,102 +63,24 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
} }
} }
// register activity result for requesting permission
private val requestPermissionLauncher =
registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { isGranted: Boolean ->
if (isGranted) {
when (pendingAction) {
Action.IMPORT_QR_CODE_CONFIG ->
scanQRCodeForConfig.launch(Intent(this, ScannerActivity::class.java))
Action.READ_CONTENT_FROM_URI ->
chooseFileForCustomConfig.launch(Intent.createChooser(Intent(Intent.ACTION_GET_CONTENT).apply {
type = "*/*"
addCategory(Intent.CATEGORY_OPENABLE)
}, getString(R.string.title_file_chooser)))
Action.POST_NOTIFICATIONS -> {}
else -> {}
}
} else {
toast(R.string.toast_permission_denied)
}
pendingAction = Action.NONE
}
private var pendingAction: Action = Action.NONE
enum class Action {
NONE,
IMPORT_QR_CODE_CONFIG,
READ_CONTENT_FROM_URI,
POST_NOTIFICATIONS
}
private val chooseFileForCustomConfig = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
val uri = it.data?.data
if (it.resultCode == RESULT_OK && uri != null) {
readContentFromUri(uri)
}
}
private val scanQRCodeForConfig = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (it.resultCode == RESULT_OK) {
importBatchConfig(it.data?.getStringExtra("SCAN_RESULT"))
}
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(binding.root) setContentView(binding.root)
setupToolbar(binding.toolbar,false, getString(R.string.title_server)) setupToolbar(binding.toolbar, false, getString(R.string.title_server))
binding.fab.setOnClickListener {
if (mainViewModel.isRunning.value == true) {
V2RayServiceManager.stopVService(this)
} else if ((MmkvManager.decodeSettingsString(AppConfig.PREF_MODE) ?: VPN) == VPN) {
val intent = VpnService.prepare(this)
if (intent == null) {
startV2Ray()
} else {
requestVpnPermission.launch(intent)
}
} else {
startV2Ray()
}
}
binding.layoutTest.setOnClickListener {
if (mainViewModel.isRunning.value == true) {
setTestState(getString(R.string.connection_test_testing))
mainViewModel.testCurrentServerRealPing()
} else {
// tv_test_state.text = getString(R.string.connection_test_fail)
}
}
// setup viewpager and tablayout
groupPagerAdapter = GroupPagerAdapter(this, emptyList()) groupPagerAdapter = GroupPagerAdapter(this, emptyList())
binding.viewPager.adapter = groupPagerAdapter binding.viewPager.adapter = groupPagerAdapter
binding.viewPager.isUserInputEnabled = true binding.viewPager.isUserInputEnabled = true
// setup navigation drawer
val toggle = ActionBarDrawerToggle( val toggle = ActionBarDrawerToggle(
this, binding.drawerLayout, binding.toolbar, R.string.navigation_drawer_open, R.string.navigation_drawer_close this, binding.drawerLayout, binding.toolbar, R.string.navigation_drawer_open, R.string.navigation_drawer_close
) )
binding.drawerLayout.addDrawerListener(toggle) binding.drawerLayout.addDrawerListener(toggle)
toggle.syncState() toggle.syncState()
binding.navView.setNavigationItemSelectedListener(this) binding.navView.setNavigationItemSelectedListener(this)
setupGroupTab()
setupViewModel()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
pendingAction = Action.POST_NOTIFICATIONS
requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) { onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() { override fun handleOnBackPressed() {
if (binding.drawerLayout.isDrawerOpen(GravityCompat.START)) { if (binding.drawerLayout.isDrawerOpen(GravityCompat.START)) {
@@ -172,25 +92,22 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
} }
} }
}) })
binding.fab.setOnClickListener { handleFabAction() }
binding.layoutTest.setOnClickListener { handleLayoutTestClick() }
setupGroupTab()
setupViewModel()
mainViewModel.reloadServerList() mainViewModel.reloadServerList()
checkAndRequestPermission(PermissionType.POST_NOTIFICATIONS) {
}
} }
private fun setupViewModel() { private fun setupViewModel() {
mainViewModel.updateTestResultAction.observe(this) { setTestState(it) } mainViewModel.updateTestResultAction.observe(this) { setTestState(it) }
mainViewModel.isRunning.observe(this) { isRunning -> mainViewModel.isRunning.observe(this) { isRunning ->
if (isRunning) { applyRunningState(false, isRunning)
binding.fab.setImageResource(R.drawable.ic_stop_24dp)
binding.fab.backgroundTintList = ColorStateList.valueOf(ContextCompat.getColor(this, R.color.color_fab_active))
binding.fab.contentDescription = getString(R.string.action_stop_service)
setTestState(getString(R.string.connection_connected))
binding.layoutTest.isFocusable = true
} else {
binding.fab.setImageResource(R.drawable.ic_play_24dp)
binding.fab.backgroundTintList = ColorStateList.valueOf(ContextCompat.getColor(this, R.color.color_fab_inactive))
binding.fab.contentDescription = getString(R.string.tasker_start_service)
setTestState(getString(R.string.connection_not_connected))
binding.layoutTest.isFocusable = false
}
} }
mainViewModel.startListenBroadcast() mainViewModel.startListenBroadcast()
mainViewModel.initAssets(assets) mainViewModel.initAssets(assets)
@@ -214,6 +131,32 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
binding.tabGroup.isVisible = groups.size > 1 binding.tabGroup.isVisible = groups.size > 1
} }
private fun handleFabAction() {
applyRunningState(isLoading = true, isRunning = false)
if (mainViewModel.isRunning.value == true) {
V2RayServiceManager.stopVService(this)
} else if (SettingsManager.isVpnMode()) {
val intent = VpnService.prepare(this)
if (intent == null) {
startV2Ray()
} else {
requestVpnPermission.launch(intent)
}
} else {
startV2Ray()
}
}
private fun handleLayoutTestClick() {
if (mainViewModel.isRunning.value == true) {
setTestState(getString(R.string.connection_test_testing))
mainViewModel.testCurrentServerRealPing()
} else {
// service not running: keep existing no-op (could show a message if desired)
}
}
private fun startV2Ray() { private fun startV2Ray() {
if (MmkvManager.getSelectServer().isNullOrEmpty()) { if (MmkvManager.getSelectServer().isNullOrEmpty()) {
toast(R.string.title_file_chooser) toast(R.string.title_file_chooser)
@@ -232,6 +175,31 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
} }
} }
private fun setTestState(content: String?) {
binding.tvTestState.text = content
}
private fun applyRunningState(isLoading: Boolean, isRunning: Boolean) {
if (isLoading) {
binding.fab.setImageResource(R.drawable.ic_fab_check)
return
}
if (isRunning) {
binding.fab.setImageResource(R.drawable.ic_stop_24dp)
binding.fab.backgroundTintList = ColorStateList.valueOf(ContextCompat.getColor(this, R.color.color_fab_active))
binding.fab.contentDescription = getString(R.string.action_stop_service)
setTestState(getString(R.string.connection_connected))
binding.layoutTest.isFocusable = true
} else {
binding.fab.setImageResource(R.drawable.ic_play_24dp)
binding.fab.backgroundTintList = ColorStateList.valueOf(ContextCompat.getColor(this, R.color.color_fab_inactive))
binding.fab.contentDescription = getString(R.string.tasker_start_service)
setTestState(getString(R.string.connection_not_connected))
binding.layoutTest.isFocusable = false
}
}
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
} }
@@ -396,12 +364,10 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
* import config from qrcode * import config from qrcode
*/ */
private fun importQRcode(): Boolean { private fun importQRcode(): Boolean {
val permission = Manifest.permission.CAMERA launchQRCodeScanner { scanResult ->
if (ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED) { if (scanResult != null) {
scanQRCodeForConfig.launch(Intent(this, ScannerActivity::class.java)) importBatchConfig(scanResult)
} else { }
pendingAction = Action.IMPORT_QR_CODE_CONFIG
requestPermissionLauncher.launch(permission)
} }
return true return true
} }
@@ -572,21 +538,12 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
* show file chooser * show file chooser
*/ */
private fun showFileChooser() { private fun showFileChooser() {
val intent = Intent(Intent.ACTION_GET_CONTENT) launchFileChooser { uri ->
intent.type = "*/*" if (uri == null) {
intent.addCategory(Intent.CATEGORY_OPENABLE) return@launchFileChooser
}
val permission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { readContentFromUri(uri)
Manifest.permission.READ_MEDIA_IMAGES
} else {
Manifest.permission.READ_EXTERNAL_STORAGE
}
if (ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED) {
pendingAction = Action.READ_CONTENT_FROM_URI
chooseFileForCustomConfig.launch(Intent.createChooser(intent, getString(R.string.title_file_chooser)))
} else {
requestPermissionLauncher.launch(permission)
} }
} }
@@ -594,38 +551,15 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
* read content from uri * read content from uri
*/ */
private fun readContentFromUri(uri: Uri) { private fun readContentFromUri(uri: Uri) {
val permission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { try {
Manifest.permission.READ_MEDIA_IMAGES contentResolver.openInputStream(uri).use { input ->
} else { importBatchConfig(input?.bufferedReader()?.readText())
Manifest.permission.READ_EXTERNAL_STORAGE
}
if (ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED) {
try {
contentResolver.openInputStream(uri).use { input ->
importBatchConfig(input?.bufferedReader()?.readText())
}
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to read content from URI", e)
} }
} else { } catch (e: Exception) {
requestPermissionLauncher.launch(permission) Log.e(AppConfig.TAG, "Failed to read content from URI", e)
} }
} }
private fun setTestState(content: String?) {
binding.tvTestState.text = content
}
// val mConnection = object : ServiceConnection {
// override fun onServiceDisconnected(name: ComponentName?) {
// }
//
// override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
// sendMsg(AppConfig.MSG_REGISTER_CLIENT, "")
// }
// }
override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean { override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean {
if (keyCode == KeyEvent.KEYCODE_BACK || keyCode == KeyEvent.KEYCODE_BUTTON_B) { if (keyCode == KeyEvent.KEYCODE_BACK || keyCode == KeyEvent.KEYCODE_BUTTON_B) {
moveTaskToBack(false) moveTaskToBack(false)
@@ -1,59 +1,41 @@
package com.v2ray.ang.ui package com.v2ray.ang.ui
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.Intent
import android.graphics.Color import android.graphics.Color
import android.text.TextUtils
import android.util.Log
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.appcompat.app.AlertDialog
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.v2ray.ang.AngApplication.Companion.application
import com.v2ray.ang.AppConfig import com.v2ray.ang.AppConfig
import com.v2ray.ang.R import com.v2ray.ang.R
import com.v2ray.ang.databinding.ItemQrcodeBinding import com.v2ray.ang.contracts.MainAdapterListener
import com.v2ray.ang.databinding.ItemRecyclerFooterBinding import com.v2ray.ang.databinding.ItemRecyclerFooterBinding
import com.v2ray.ang.databinding.ItemRecyclerMainBinding import com.v2ray.ang.databinding.ItemRecyclerMainBinding
import com.v2ray.ang.dto.EConfigType
import com.v2ray.ang.dto.ProfileItem import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.ServersCache import com.v2ray.ang.dto.ServersCache
import com.v2ray.ang.extension.toast import com.v2ray.ang.extension.nullIfBlank
import com.v2ray.ang.extension.toastError
import com.v2ray.ang.extension.toastSuccess
import com.v2ray.ang.handler.AngConfigManager import com.v2ray.ang.handler.AngConfigManager
import com.v2ray.ang.handler.MmkvManager import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.helper.ItemTouchHelperAdapter import com.v2ray.ang.helper.ItemTouchHelperAdapter
import com.v2ray.ang.helper.ItemTouchHelperViewHolder import com.v2ray.ang.helper.ItemTouchHelperViewHolder
import kotlinx.coroutines.Dispatchers import com.v2ray.ang.viewmodel.MainViewModel
import kotlinx.coroutines.launch import java.util.Collections
class MainRecyclerAdapter(val activity: MainActivity) : RecyclerView.Adapter<MainRecyclerAdapter.BaseViewHolder>(), ItemTouchHelperAdapter { class MainRecyclerAdapter(
private val mainViewModel: MainViewModel,
private val adapterListener: MainAdapterListener?
) : RecyclerView.Adapter<MainRecyclerAdapter.BaseViewHolder>(), ItemTouchHelperAdapter {
companion object { companion object {
private const val VIEW_TYPE_ITEM = 1 private const val VIEW_TYPE_ITEM = 1
private const val VIEW_TYPE_FOOTER = 2 private const val VIEW_TYPE_FOOTER = 2
} }
private var mActivity: MainActivity = activity
private val share_method: Array<out String> by lazy {
mActivity.resources.getStringArray(R.array.share_method)
}
private val share_method_more: Array<out String> by lazy {
mActivity.resources.getStringArray(R.array.share_method_more)
}
var isRunning = false
private val doubleColumnDisplay = MmkvManager.decodeSettingsBool(AppConfig.PREF_DOUBLE_COLUMN_DISPLAY, false) private val doubleColumnDisplay = MmkvManager.decodeSettingsBool(AppConfig.PREF_DOUBLE_COLUMN_DISPLAY, false)
private var data: MutableList<ServersCache> = mutableListOf() private var data: MutableList<ServersCache> = mutableListOf()
@SuppressLint("NotifyDataSetChanged") @SuppressLint("NotifyDataSetChanged")
fun setData(newData: MutableList<ServersCache>?, position: Int = -1) { fun setData(newData: MutableList<ServersCache>?, position: Int = -1) {
if (android.os.Looper.myLooper() != android.os.Looper.getMainLooper()) {
mActivity.runOnUiThread { setData(newData, position) }
return
}
data = newData?.toMutableList() ?: mutableListOf() data = newData?.toMutableList() ?: mutableListOf()
if (position >= 0 && position in data.indices) { if (position >= 0 && position in data.indices) {
@@ -67,9 +49,9 @@ class MainRecyclerAdapter(val activity: MainActivity) : RecyclerView.Adapter<Mai
override fun onBindViewHolder(holder: BaseViewHolder, position: Int) { override fun onBindViewHolder(holder: BaseViewHolder, position: Int) {
if (holder is MainViewHolder) { if (holder is MainViewHolder) {
val context = holder.itemMainBinding.root.context
val guid = data[position].guid val guid = data[position].guid
val profile = data[position].profile val profile = data[position].profile
val isCustom = profile.configType == EConfigType.CUSTOM || profile.configType == EConfigType.POLICYGROUP
holder.itemView.setBackgroundColor(Color.TRANSPARENT) holder.itemView.setBackgroundColor(Color.TRANSPARENT)
@@ -82,9 +64,9 @@ class MainRecyclerAdapter(val activity: MainActivity) : RecyclerView.Adapter<Mai
val aff = MmkvManager.decodeServerAffiliationInfo(guid) val aff = MmkvManager.decodeServerAffiliationInfo(guid)
holder.itemMainBinding.tvTestResult.text = aff?.getTestDelayString().orEmpty() holder.itemMainBinding.tvTestResult.text = aff?.getTestDelayString().orEmpty()
if ((aff?.testDelayMillis ?: 0L) < 0L) { if ((aff?.testDelayMillis ?: 0L) < 0L) {
holder.itemMainBinding.tvTestResult.setTextColor(ContextCompat.getColor(mActivity, R.color.colorPingRed)) holder.itemMainBinding.tvTestResult.setTextColor(ContextCompat.getColor(context, R.color.colorPingRed))
} else { } else {
holder.itemMainBinding.tvTestResult.setTextColor(ContextCompat.getColor(mActivity, R.color.colorPing)) holder.itemMainBinding.tvTestResult.setTextColor(ContextCompat.getColor(context, R.color.colorPing))
} }
//layoutIndicator //layoutIndicator
@@ -106,11 +88,8 @@ class MainRecyclerAdapter(val activity: MainActivity) : RecyclerView.Adapter<Mai
holder.itemMainBinding.layoutRemove.visibility = View.GONE holder.itemMainBinding.layoutRemove.visibility = View.GONE
holder.itemMainBinding.layoutMore.visibility = View.VISIBLE holder.itemMainBinding.layoutMore.visibility = View.VISIBLE
//share method
val shareOptions = if (isCustom) share_method_more.asList().takeLast(3) else share_method_more.asList()
holder.itemMainBinding.layoutMore.setOnClickListener { holder.itemMainBinding.layoutMore.setOnClickListener {
shareServer(guid, profile, position, shareOptions, if (isCustom) 2 else 0) adapterListener?.onShare(guid, profile, position, true)
} }
} else { } else {
holder.itemMainBinding.layoutShare.visibility = View.VISIBLE holder.itemMainBinding.layoutShare.visibility = View.VISIBLE
@@ -118,34 +97,23 @@ class MainRecyclerAdapter(val activity: MainActivity) : RecyclerView.Adapter<Mai
holder.itemMainBinding.layoutRemove.visibility = View.VISIBLE holder.itemMainBinding.layoutRemove.visibility = View.VISIBLE
holder.itemMainBinding.layoutMore.visibility = View.GONE holder.itemMainBinding.layoutMore.visibility = View.GONE
//share method
val shareOptions = if (isCustom) share_method.asList().takeLast(1) else share_method.asList()
holder.itemMainBinding.layoutShare.setOnClickListener { holder.itemMainBinding.layoutShare.setOnClickListener {
shareServer(guid, profile, position, shareOptions, if (isCustom) 2 else 0) adapterListener?.onShare(guid, profile, position, false)
} }
holder.itemMainBinding.layoutEdit.setOnClickListener { holder.itemMainBinding.layoutEdit.setOnClickListener {
editServer(guid, profile) adapterListener?.onEdit(guid, position, profile)
} }
holder.itemMainBinding.layoutRemove.setOnClickListener { holder.itemMainBinding.layoutRemove.setOnClickListener {
removeServer(guid, position) adapterListener?.onRemove(guid, position)
} }
} }
holder.itemMainBinding.infoContainer.setOnClickListener { holder.itemMainBinding.infoContainer.setOnClickListener {
setSelectServer(guid) adapterListener?.onSelectServer(guid)
} }
} }
// if (holder is FooterViewHolder) {
// if (true) {
// holder.itemFooterBinding.layoutEdit.visibility = View.INVISIBLE
// } else {
// holder.itemFooterBinding.layoutEdit.setOnClickListener {
// Utils.openUri(mActivity, "${Utils.decode(AppConfig.PromotionUrl)}?t=${System.currentTimeMillis()}")
// }
// }
// }
} }
/** /**
@@ -155,19 +123,7 @@ class MainRecyclerAdapter(val activity: MainActivity) : RecyclerView.Adapter<Mai
* @return Formatted address string * @return Formatted address string
*/ */
private fun getAddress(profile: ProfileItem): String { private fun getAddress(profile: ProfileItem): String {
// Hide xxx:xxx:***/xxx.xxx.xxx.*** return profile.description.nullIfBlank() ?: AngConfigManager.generateDescription(profile)
val server = profile.server
val port = profile.serverPort
if (server.isNullOrBlank() && port.isNullOrBlank()) return ""
val addrPart = server?.let {
if (it.contains(":"))
it.split(":").take(2).joinToString(":", postfix = ":***")
else
it.split('.').dropLast(1).joinToString(".", postfix = ".***")
} ?: ""
return "$addrPart : ${port ?: ""}"
} }
/** /**
@@ -177,155 +133,25 @@ class MainRecyclerAdapter(val activity: MainActivity) : RecyclerView.Adapter<Mai
*/ */
private fun getSubscriptionRemarks(profile: ProfileItem): String { private fun getSubscriptionRemarks(profile: ProfileItem): String {
val subRemarks = val subRemarks =
if (mActivity.mainViewModel.subscriptionId.isEmpty()) if (mainViewModel.subscriptionId.isEmpty())
MmkvManager.decodeSubscription(profile.subscriptionId)?.remarks?.firstOrNull() MmkvManager.decodeSubscription(profile.subscriptionId)?.remarks?.firstOrNull()
else else
null null
return subRemarks?.toString() ?: "" return subRemarks?.toString() ?: ""
} }
/** fun removeServerSub(guid: String, position: Int) {
* Shares server configuration val idx = data.indexOfFirst { it.guid == guid }
* Displays a dialog with sharing options and executes the selected action if (idx >= 0) {
* @param guid The server unique identifier data.removeAt(idx)
* @param profile The server configuration notifyItemRemoved(idx)
* @param position The position in the list notifyItemRangeChanged(idx, data.size - idx)
* @param shareOptions The list of share options
* @param skip The number of options to skip
*/
private fun shareServer(guid: String, profile: ProfileItem, position: Int, shareOptions: List<String>, skip: Int) {
AlertDialog.Builder(mActivity).setItems(shareOptions.toTypedArray()) { _, i ->
try {
when (i + skip) {
0 -> showQRCode(guid)
1 -> share2Clipboard(guid)
2 -> shareFullContent(guid)
3 -> editServer(guid, profile)
4 -> removeServer(guid, position)
else -> mActivity.toast("else")
}
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Error when sharing server", e)
}
}.show()
}
/**
* Displays QR code for the server configuration
* @param guid The server unique identifier
*/
private fun showQRCode(guid: String) {
val ivBinding = ItemQrcodeBinding.inflate(LayoutInflater.from(mActivity))
ivBinding.ivQcode.setImageBitmap(AngConfigManager.share2QRCode(guid))
if (share_method.isNotEmpty()) {
ivBinding.ivQcode.contentDescription = share_method[0]
} else {
ivBinding.ivQcode.contentDescription = "QR Code"
}
AlertDialog.Builder(mActivity).setView(ivBinding.root).show()
}
/**
* Shares server configuration to clipboard
* @param guid The server unique identifier
*/
private fun share2Clipboard(guid: String) {
if (AngConfigManager.share2Clipboard(mActivity, guid) == 0) {
mActivity.toastSuccess(R.string.toast_success)
} else {
mActivity.toastError(R.string.toast_failure)
} }
} }
/** fun setSelectServer(fromPosition: Int, toPosition: Int) {
* Shares full server configuration content to clipboard notifyItemChanged(fromPosition)
* @param guid The server unique identifier notifyItemChanged(toPosition)
*/
private fun shareFullContent(guid: String) {
mActivity.lifecycleScope.launch(Dispatchers.IO) {
val result = AngConfigManager.shareFullContent2Clipboard(mActivity, guid)
launch(Dispatchers.Main) {
if (result == 0) {
mActivity.toastSuccess(R.string.toast_success)
} else {
mActivity.toastError(R.string.toast_failure)
}
}
}
}
/**
* Edits server configuration
* Opens appropriate editing interface based on configuration type
* @param guid The server unique identifier
* @param profile The server configuration
*/
private fun editServer(guid: String, profile: ProfileItem) {
val intent = Intent().putExtra("guid", guid)
.putExtra("isRunning", isRunning)
.putExtra("createConfigType", profile.configType.value)
if (profile.configType == EConfigType.CUSTOM) {
mActivity.startActivity(intent.setClass(mActivity, ServerCustomConfigActivity::class.java))
} else if (profile.configType == EConfigType.POLICYGROUP) {
mActivity.startActivity(intent.setClass(mActivity, ServerGroupActivity::class.java))
} else {
mActivity.startActivity(intent.setClass(mActivity, ServerActivity::class.java))
}
}
/**
* Removes server configuration
* Handles confirmation dialog and related checks
* @param guid The server unique identifier
* @param position The position in the list
*/
private fun removeServer(guid: String, position: Int) {
if (guid != MmkvManager.getSelectServer()) {
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_CONFIRM_REMOVE)) {
AlertDialog.Builder(mActivity).setMessage(R.string.del_config_comfirm)
.setPositiveButton(android.R.string.ok) { _, _ ->
removeServerSub(guid, position)
}
.setNegativeButton(android.R.string.cancel) { _, _ ->
//do noting
}
.show()
} else {
removeServerSub(guid, position)
}
} 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, data.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) {
mActivity.restartV2Ray()
}
}
} }
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BaseViewHolder { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BaseViewHolder {
@@ -363,7 +189,10 @@ class MainRecyclerAdapter(val activity: MainActivity) : RecyclerView.Adapter<Mai
BaseViewHolder(itemFooterBinding.root) BaseViewHolder(itemFooterBinding.root)
override fun onItemMove(fromPosition: Int, toPosition: Int): Boolean { override fun onItemMove(fromPosition: Int, toPosition: Int): Boolean {
mActivity.mainViewModel.swapServer(fromPosition, toPosition) mainViewModel.swapServer(fromPosition, toPosition)
if (fromPosition < data.size && toPosition < data.size) {
Collections.swap(data, fromPosition, toPosition)
}
notifyItemMoved(fromPosition, toPosition) notifyItemMoved(fromPosition, toPosition)
return true return true
} }
@@ -95,7 +95,7 @@ class PerAppProxyActivity : BaseActivity() {
} }
appsAll = apps appsAll = apps
adapter = PerAppProxyAdapter(this@PerAppProxyActivity, apps, viewModel) adapter = PerAppProxyAdapter(apps, viewModel)
binding.recyclerView.adapter = adapter binding.recyclerView.adapter = adapter
} catch (e: Exception) { } catch (e: Exception) {
@@ -292,7 +292,7 @@ class PerAppProxyActivity : BaseActivity() {
} }
} }
adapter = PerAppProxyAdapter(this, apps, adapter?.viewModel ?: viewModel) adapter = PerAppProxyAdapter(apps, adapter?.viewModel ?: viewModel)
binding.recyclerView.adapter = adapter binding.recyclerView.adapter = adapter
refreshData() refreshData()
return true return true
@@ -8,8 +8,10 @@ import com.v2ray.ang.databinding.ItemRecyclerBypassListBinding
import com.v2ray.ang.dto.AppInfo import com.v2ray.ang.dto.AppInfo
import com.v2ray.ang.viewmodel.PerAppProxyViewModel import com.v2ray.ang.viewmodel.PerAppProxyViewModel
class PerAppProxyAdapter(val activity: BaseActivity, val apps: List<AppInfo>, val viewModel: PerAppProxyViewModel) : class PerAppProxyAdapter(
RecyclerView.Adapter<PerAppProxyAdapter.BaseViewHolder>() { val apps: List<AppInfo>,
val viewModel: PerAppProxyViewModel
) :RecyclerView.Adapter<PerAppProxyAdapter.BaseViewHolder>() {
companion object { companion object {
private const val VIEW_TYPE_HEADER = 0 private const val VIEW_TYPE_HEADER = 0
@@ -37,6 +39,7 @@ class PerAppProxyAdapter(val activity: BaseActivity, val apps: List<AppInfo>, va
) )
BaseViewHolder(view) BaseViewHolder(view)
} }
else -> AppViewHolder(ItemRecyclerBypassListBinding.inflate(LayoutInflater.from(ctx), parent, false)) else -> AppViewHolder(ItemRecyclerBypassListBinding.inflate(LayoutInflater.from(ctx), parent, false))
} }
} }
@@ -8,6 +8,7 @@ import androidx.lifecycle.lifecycleScope
import com.v2ray.ang.R import com.v2ray.ang.R
import com.v2ray.ang.databinding.ActivityRoutingEditBinding import com.v2ray.ang.databinding.ActivityRoutingEditBinding
import com.v2ray.ang.dto.RulesetItem import com.v2ray.ang.dto.RulesetItem
import com.v2ray.ang.extension.nullIfBlank
import com.v2ray.ang.extension.toast import com.v2ray.ang.extension.toast
import com.v2ray.ang.extension.toastSuccess import com.v2ray.ang.extension.toastSuccess
import com.v2ray.ang.handler.SettingsManager import com.v2ray.ang.handler.SettingsManager
@@ -62,14 +63,11 @@ class RoutingEditActivity : BaseActivity() {
rulesetItem.apply { rulesetItem.apply {
remarks = binding.etRemarks.text.toString() remarks = binding.etRemarks.text.toString()
locked = binding.chkLocked.isChecked locked = binding.chkLocked.isChecked
domain = binding.etDomain.text.toString().takeIf { it.isNotEmpty() } domain = binding.etDomain.text.toString().nullIfBlank()?.split(",")?.map { it.trim() }?.filter { it.isNotEmpty() }
?.split(",")?.map { it.trim() }?.filter { it.isNotEmpty() } ip = binding.etIp.text.toString().nullIfBlank()?.split(",")?.map { it.trim() }?.filter { it.isNotEmpty() }
ip = binding.etIp.text.toString().takeIf { it.isNotEmpty() } protocol = binding.etProtocol.text.toString().nullIfBlank()?.split(",")?.map { it.trim() }?.filter { it.isNotEmpty() }
?.split(",")?.map { it.trim() }?.filter { it.isNotEmpty() } port = binding.etPort.text.toString().nullIfBlank()
protocol = binding.etProtocol.text.toString().takeIf { it.isNotEmpty() } network = binding.etNetwork.text.toString().nullIfBlank()
?.split(",")?.map { it.trim() }?.filter { it.isNotEmpty() }
port = binding.etPort.text.toString().takeIf { it.isNotEmpty() }
network = binding.etNetwork.text.toString().takeIf { it.isNotEmpty() }
outboundTag = outbound_tag[binding.spOutboundTag.selectedItemPosition] outboundTag = outbound_tag[binding.spOutboundTag.selectedItemPosition]
} }
@@ -1,13 +1,11 @@
package com.v2ray.ang.ui package com.v2ray.ang.ui
import android.Manifest
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.util.Log import android.util.Log
import android.view.Menu import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
@@ -15,8 +13,8 @@ import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import com.v2ray.ang.AppConfig import com.v2ray.ang.AppConfig
import com.v2ray.ang.R import com.v2ray.ang.R
import com.v2ray.ang.contracts.BaseAdapterListener
import com.v2ray.ang.databinding.ActivityRoutingSettingBinding import com.v2ray.ang.databinding.ActivityRoutingSettingBinding
import com.v2ray.ang.extension.toast
import com.v2ray.ang.extension.toastError import com.v2ray.ang.extension.toastError
import com.v2ray.ang.extension.toastSuccess import com.v2ray.ang.extension.toastSuccess
import com.v2ray.ang.handler.MmkvManager import com.v2ray.ang.handler.MmkvManager
@@ -29,9 +27,10 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
class RoutingSettingActivity : BaseActivity() { class RoutingSettingActivity : HelperBaseActivity() {
private val binding by lazy { ActivityRoutingSettingBinding.inflate(layoutInflater) } private val binding by lazy { ActivityRoutingSettingBinding.inflate(layoutInflater) }
private val ownerActivity: RoutingSettingActivity
get() = this
private val viewModel: RoutingSettingsViewModel by viewModels() private val viewModel: RoutingSettingsViewModel by viewModels()
private lateinit var adapter: RoutingSettingRecyclerAdapter private lateinit var adapter: RoutingSettingRecyclerAdapter
private var mItemTouchHelper: ItemTouchHelper? = null private var mItemTouchHelper: ItemTouchHelper? = null
@@ -42,22 +41,12 @@ class RoutingSettingActivity : BaseActivity() {
resources.getStringArray(R.array.preset_rulesets) resources.getStringArray(R.array.preset_rulesets)
} }
private val requestCameraPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { isGranted: Boolean ->
if (isGranted) {
scanQRcodeForRulesets.launch(Intent(this, ScannerActivity::class.java))
} else {
toast(R.string.toast_permission_denied)
}
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
//setContentView(binding.root) //setContentView(binding.root)
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.routing_settings_title)) setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.routing_settings_title))
adapter = RoutingSettingRecyclerAdapter(this, viewModel) adapter = RoutingSettingRecyclerAdapter(viewModel, ActivityAdapterListener())
binding.recyclerView.setHasFixedSize(true) binding.recyclerView.setHasFixedSize(true)
binding.recyclerView.layoutManager = LinearLayoutManager(this) binding.recyclerView.layoutManager = LinearLayoutManager(this)
@@ -87,7 +76,7 @@ class RoutingSettingActivity : BaseActivity() {
R.id.add_rule -> startActivity(Intent(this, RoutingEditActivity::class.java)).let { true } R.id.add_rule -> startActivity(Intent(this, RoutingEditActivity::class.java)).let { true }
R.id.import_predefined_rulesets -> importPredefined().let { true } R.id.import_predefined_rulesets -> importPredefined().let { true }
R.id.import_rulesets_from_clipboard -> importFromClipboard().let { true } R.id.import_rulesets_from_clipboard -> importFromClipboard().let { true }
R.id.import_rulesets_from_qrcode -> requestCameraPermissionLauncher.launch(Manifest.permission.CAMERA).let { true } R.id.import_rulesets_from_qrcode -> importQRcode()
R.id.export_rulesets_to_clipboard -> export2Clipboard().let { true } R.id.export_rulesets_to_clipboard -> export2Clipboard().let { true }
else -> super.onOptionsItemSelected(item) else -> super.onOptionsItemSelected(item)
} }
@@ -159,6 +148,15 @@ class RoutingSettingActivity : BaseActivity() {
.show() .show()
} }
private fun importQRcode(): Boolean {
launchQRCodeScanner { scanResult ->
if (scanResult != null) {
importRulesetsFromQRcode(scanResult)
}
}
return true
}
private fun export2Clipboard() { private fun export2Clipboard() {
val rulesetList = MmkvManager.decodeRoutingRulesets() val rulesetList = MmkvManager.decodeRoutingRulesets()
if (rulesetList.isNullOrEmpty()) { if (rulesetList.isNullOrEmpty()) {
@@ -169,11 +167,6 @@ class RoutingSettingActivity : BaseActivity() {
} }
} }
private val scanQRcodeForRulesets = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (it.resultCode == RESULT_OK) {
importRulesetsFromQRcode(it.data?.getStringExtra("SCAN_RESULT"))
}
}
private fun importRulesetsFromQRcode(qrcode: String?): Boolean { private fun importRulesetsFromQRcode(qrcode: String?): Boolean {
AlertDialog.Builder(this).setMessage(R.string.routing_settings_import_rulesets_tip) AlertDialog.Builder(this).setMessage(R.string.routing_settings_import_rulesets_tip)
@@ -202,4 +195,23 @@ class RoutingSettingActivity : BaseActivity() {
viewModel.reload() viewModel.reload()
adapter.notifyDataSetChanged() adapter.notifyDataSetChanged()
} }
private inner class ActivityAdapterListener : BaseAdapterListener {
override fun onEdit(guid: String, position: Int) {
startActivity(
Intent(ownerActivity, RoutingEditActivity::class.java)
.putExtra("position", position)
)
}
override fun onRemove(guid: String, position: Int) {
}
override fun onShare(url: String) {
}
override fun onRefreshData() {
refreshData()
}
}
} }
@@ -1,24 +1,23 @@
package com.v2ray.ang.ui package com.v2ray.ang.ui
import android.content.Intent
import android.graphics.Color import android.graphics.Color
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.v2ray.ang.contracts.BaseAdapterListener
import com.v2ray.ang.databinding.ItemRecyclerRoutingSettingBinding import com.v2ray.ang.databinding.ItemRecyclerRoutingSettingBinding
import com.v2ray.ang.helper.ItemTouchHelperAdapter import com.v2ray.ang.helper.ItemTouchHelperAdapter
import com.v2ray.ang.helper.ItemTouchHelperViewHolder import com.v2ray.ang.helper.ItemTouchHelperViewHolder
import com.v2ray.ang.viewmodel.RoutingSettingsViewModel import com.v2ray.ang.viewmodel.RoutingSettingsViewModel
class RoutingSettingRecyclerAdapter( class RoutingSettingRecyclerAdapter(
val activity: RoutingSettingActivity, private val viewModel: RoutingSettingsViewModel,
private val viewModel: RoutingSettingsViewModel private val adapterListener: BaseAdapterListener?
) : RecyclerView.Adapter<RoutingSettingRecyclerAdapter.MainViewHolder>(), ) : RecyclerView.Adapter<RoutingSettingRecyclerAdapter.MainViewHolder>(),
ItemTouchHelperAdapter { ItemTouchHelperAdapter {
private var mActivity: RoutingSettingActivity = activity
override fun getItemCount() = viewModel.getAll().size override fun getItemCount() = viewModel.getAll().size
override fun onBindViewHolder(holder: MainViewHolder, position: Int) { override fun onBindViewHolder(holder: MainViewHolder, position: Int) {
@@ -33,10 +32,7 @@ class RoutingSettingRecyclerAdapter(
holder.itemView.setBackgroundColor(Color.TRANSPARENT) holder.itemView.setBackgroundColor(Color.TRANSPARENT)
holder.itemRoutingSettingBinding.layoutEdit.setOnClickListener { holder.itemRoutingSettingBinding.layoutEdit.setOnClickListener {
mActivity.startActivity( adapterListener?.onEdit("", position)
Intent(mActivity, RoutingEditActivity::class.java)
.putExtra("position", position)
)
} }
holder.itemRoutingSettingBinding.chkEnable.setOnCheckedChangeListener { it, isChecked -> holder.itemRoutingSettingBinding.chkEnable.setOnCheckedChangeListener { it, isChecked ->
@@ -76,7 +72,7 @@ class RoutingSettingRecyclerAdapter(
} }
override fun onItemMoveCompleted() { override fun onItemMoveCompleted() {
mActivity.refreshData() adapterListener?.onRefreshData()
} }
override fun onItemDismiss(position: Int) { override fun onItemDismiss(position: Int) {
@@ -1,27 +1,13 @@
package com.v2ray.ang.ui package com.v2ray.ang.ui
import android.Manifest
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import androidx.activity.result.contract.ActivityResultContracts
import com.v2ray.ang.R import com.v2ray.ang.R
import com.v2ray.ang.extension.toast
import com.v2ray.ang.extension.toastError import com.v2ray.ang.extension.toastError
import com.v2ray.ang.extension.toastSuccess import com.v2ray.ang.extension.toastSuccess
import com.v2ray.ang.handler.AngConfigManager import com.v2ray.ang.handler.AngConfigManager
class ScScannerActivity : BaseActivity() { class ScScannerActivity : HelperBaseActivity() {
private val requestCameraPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { isGranted: Boolean ->
if (isGranted) {
scanQRCode.launch(Intent(this, ScannerActivity::class.java))
} else {
toast(R.string.toast_permission_denied)
finish()
}
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@@ -29,24 +15,20 @@ class ScScannerActivity : BaseActivity() {
importQRcode() importQRcode()
} }
private fun importQRcode(): Boolean { private fun importQRcode() {
requestCameraPermissionLauncher.launch(Manifest.permission.CAMERA) launchQRCodeScanner { scanResult ->
return true if (scanResult != null) {
} val (count, countSub) = AngConfigManager.importBatchConfig(scanResult, "", false)
private val scanQRCode = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { if (count + countSub > 0) {
if (it.resultCode == RESULT_OK) { toastSuccess(R.string.toast_success)
val scanResult = it.data?.getStringExtra("SCAN_RESULT").orEmpty() } else {
val (count, countSub) = AngConfigManager.importBatchConfig(scanResult, "", false) toastError(R.string.toast_failure)
}
if (count + countSub > 0) { startActivity(Intent(this, MainActivity::class.java))
toastSuccess(R.string.toast_success)
} else {
toastError(R.string.toast_failure)
} }
finish()
startActivity(Intent(this, MainActivity::class.java))
} }
finish()
} }
} }
@@ -1,64 +1,32 @@
package com.v2ray.ang.ui package com.v2ray.ang.ui
import android.Manifest
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.util.Log import android.util.Log
import android.view.Menu import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
import com.v2ray.ang.AppConfig import com.v2ray.ang.AppConfig
import com.v2ray.ang.R import com.v2ray.ang.R
import com.v2ray.ang.databinding.ActivityNoneBinding
import com.v2ray.ang.extension.toast import com.v2ray.ang.extension.toast
import com.v2ray.ang.handler.MmkvManager import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.util.QRCodeDecoder import com.v2ray.ang.util.QRCodeDecoder
import io.github.g00fy2.quickie.QRResult import io.github.g00fy2.quickie.QRResult
import io.github.g00fy2.quickie.ScanCustomCode import io.github.g00fy2.quickie.ScanCustomCode
import io.github.g00fy2.quickie.config.BarcodeFormat
import io.github.g00fy2.quickie.config.ScannerConfig import io.github.g00fy2.quickie.config.ScannerConfig
class ScannerActivity : BaseActivity() { class ScannerActivity : HelperBaseActivity() {
private val binding by lazy { ActivityNoneBinding.inflate(layoutInflater) }
private val scanQrCode = registerForActivityResult(ScanCustomCode(), ::handleResult) private val scanQrCode = registerForActivityResult(ScanCustomCode(), ::handleResult)
private val chooseFile = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
val uri = it.data?.data
if (it.resultCode == RESULT_OK && uri != null) {
try {
val inputStream = contentResolver.openInputStream(uri)
val bitmap = BitmapFactory.decodeStream(inputStream)
inputStream?.close()
val text = QRCodeDecoder.syncDecodeQRCode(bitmap)
if (text.isNullOrEmpty()) {
toast(R.string.toast_decoding_failed)
} else {
finished(text)
}
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to decode QR code from file", e)
toast(R.string.toast_decoding_failed)
}
}
}
private val requestPermissionLauncher =
registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { isGranted: Boolean ->
if (isGranted) {
showFileChooser()
} else {
toast(R.string.toast_permission_denied)
}
}
public override fun onCreate(savedInstanceState: Bundle?) { public override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.menu_item_import_config_qrcode))
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_START_SCAN_IMMEDIATE)) { if (MmkvManager.decodeSettingsBool(AppConfig.PREF_START_SCAN_IMMEDIATE)) {
launchScan() launchScan()
} }
@@ -70,6 +38,7 @@ class ScannerActivity : BaseActivity() {
setHapticSuccessFeedback(true) // enable (default) or disable haptic feedback when a barcode was detected setHapticSuccessFeedback(true) // enable (default) or disable haptic feedback when a barcode was detected
setShowTorchToggle(true) // show or hide (default) torch/flashlight toggle button setShowTorchToggle(true) // show or hide (default) torch/flashlight toggle button
setShowCloseButton(true) // show or hide (default) close button setShowCloseButton(true) // show or hide (default) close button
setBarcodeFormats(listOf(BarcodeFormat.QR_CODE))
} }
) )
} }
@@ -101,17 +70,7 @@ class ScannerActivity : BaseActivity() {
} }
R.id.select_photo -> { R.id.select_photo -> {
val permission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { showFileChooser()
Manifest.permission.READ_MEDIA_IMAGES
} else {
Manifest.permission.READ_EXTERNAL_STORAGE
}
if (ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED) {
showFileChooser()
} else {
requestPermissionLauncher.launch(permission)
}
true true
} }
@@ -120,15 +79,25 @@ class ScannerActivity : BaseActivity() {
} }
private fun showFileChooser() { private fun showFileChooser() {
val intent = Intent(Intent.ACTION_GET_CONTENT) launchFileChooser("image/*") { uri ->
intent.type = "image/*" if (uri == null) {
intent.addCategory(Intent.CATEGORY_OPENABLE) return@launchFileChooser
//intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true) }
try {
val inputStream = contentResolver.openInputStream(uri)
val bitmap = BitmapFactory.decodeStream(inputStream)
inputStream?.close()
try { val text = QRCodeDecoder.syncDecodeQRCode(bitmap)
chooseFile.launch(Intent.createChooser(intent, getString(R.string.title_file_chooser))) if (text.isNullOrEmpty()) {
} catch (ex: android.content.ActivityNotFoundException) { toast(R.string.toast_decoding_failed)
toast(R.string.toast_require_file_manager) } else {
finished(text)
}
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to decode QR code from file", e)
toast(R.string.toast_decoding_failed)
}
} }
} }
} }
@@ -20,12 +20,13 @@ import com.v2ray.ang.AppConfig.TLS
import com.v2ray.ang.AppConfig.WIREGUARD_LOCAL_ADDRESS_V4 import com.v2ray.ang.AppConfig.WIREGUARD_LOCAL_ADDRESS_V4
import com.v2ray.ang.AppConfig.WIREGUARD_LOCAL_MTU import com.v2ray.ang.AppConfig.WIREGUARD_LOCAL_MTU
import com.v2ray.ang.R import com.v2ray.ang.R
import com.v2ray.ang.dto.EConfigType import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.dto.NetworkType import com.v2ray.ang.enums.NetworkType
import com.v2ray.ang.dto.ProfileItem import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.extension.isNotNullEmpty import com.v2ray.ang.extension.isNotNullEmpty
import com.v2ray.ang.extension.toast import com.v2ray.ang.extension.toast
import com.v2ray.ang.extension.toastSuccess import com.v2ray.ang.extension.toastSuccess
import com.v2ray.ang.handler.AngConfigManager
import com.v2ray.ang.handler.MmkvManager import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.util.JsonUtil import com.v2ray.ang.util.JsonUtil
import com.v2ray.ang.util.Utils import com.v2ray.ang.util.Utils
@@ -128,7 +129,6 @@ class ServerActivity : BaseActivity() {
private val et_obfs_password: EditText? by lazy { findViewById(R.id.et_obfs_password) } private val et_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: 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_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_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_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 et_extra: EditText? by lazy { findViewById(R.id.et_extra) }
@@ -137,6 +137,8 @@ class ServerActivity : BaseActivity() {
private val container_ech_config_list: LinearLayout? by lazy { findViewById(R.id.lay_ech_config_list) } private val container_ech_config_list: LinearLayout? by lazy { findViewById(R.id.lay_ech_config_list) }
private val sp_ech_force_query: Spinner? by lazy { findViewById(R.id.sp_ech_force_query) } private val sp_ech_force_query: Spinner? by lazy { findViewById(R.id.sp_ech_force_query) }
private val container_ech_force_query: LinearLayout? by lazy { findViewById(R.id.lay_ech_force_query) } private val container_ech_force_query: LinearLayout? by lazy { findViewById(R.id.lay_ech_force_query) }
private val et_pinned_ca256: EditText? by lazy { findViewById(R.id.et_pinned_ca256) }
private val container_pinned_ca256: LinearLayout? by lazy { findViewById(R.id.lay_pinned_ca256) }
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
@@ -154,6 +156,7 @@ class ServerActivity : BaseActivity() {
EConfigType.WIREGUARD -> R.layout.activity_server_wireguard EConfigType.WIREGUARD -> R.layout.activity_server_wireguard
EConfigType.HYSTERIA2 -> R.layout.activity_server_hysteria2 EConfigType.HYSTERIA2 -> R.layout.activity_server_hysteria2
EConfigType.POLICYGROUP -> null EConfigType.POLICYGROUP -> null
else -> null
} ?: return } ?: return
setContentViewWithToolbar(layoutId, showHomeAsUp = true, title = (config?.configType ?: createConfigType).toString()) setContentViewWithToolbar(layoutId, showHomeAsUp = true, title = (config?.configType ?: createConfigType).toString())
@@ -273,7 +276,8 @@ class ServerActivity : BaseActivity() {
container_spider_x, container_spider_x,
container_mldsa65_verify, container_mldsa65_verify,
container_ech_config_list, container_ech_config_list,
container_ech_force_query container_ech_force_query,
container_pinned_ca256
).forEach { it?.visibility = View.GONE } ).forEach { it?.visibility = View.GONE }
} }
@@ -285,7 +289,8 @@ class ServerActivity : BaseActivity() {
container_alpn, container_alpn,
container_allow_insecure, container_allow_insecure,
container_ech_config_list, container_ech_config_list,
container_ech_force_query container_ech_force_query,
container_pinned_ca256
).forEach { it?.visibility = View.VISIBLE } ).forEach { it?.visibility = View.VISIBLE }
listOf( listOf(
container_public_key, container_public_key,
@@ -305,7 +310,8 @@ class ServerActivity : BaseActivity() {
container_alpn, container_alpn,
container_allow_insecure, container_allow_insecure,
container_ech_config_list, container_ech_config_list,
container_ech_force_query container_ech_force_query,
container_pinned_ca256
).forEach { it?.visibility = View.GONE } ).forEach { it?.visibility = View.GONE }
listOf( listOf(
container_public_key, container_public_key,
@@ -360,7 +366,6 @@ class ServerActivity : BaseActivity() {
et_obfs_password?.text = Utils.getEditable(config.obfsPassword) et_obfs_password?.text = Utils.getEditable(config.obfsPassword)
et_port_hop?.text = Utils.getEditable(config.portHopping) et_port_hop?.text = Utils.getEditable(config.portHopping)
et_port_hop_interval?.text = Utils.getEditable(config.portHoppingInterval) 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_down?.text = Utils.getEditable(config.bandwidthDown)
et_bandwidth_up?.text = Utils.getEditable(config.bandwidthUp) et_bandwidth_up?.text = Utils.getEditable(config.bandwidthUp)
} }
@@ -393,6 +398,7 @@ class ServerActivity : BaseActivity() {
val index = Utils.arrayFind(echForceQuerys, it) val index = Utils.arrayFind(echForceQuerys, it)
index.let { sp_ech_force_query?.setSelection(if (it >= 0) it else 0) } index.let { sp_ech_force_query?.setSelection(if (it >= 0) it else 0) }
} }
et_pinned_ca256?.text = Utils.getEditable(config.pinnedCA256)
} else if (config.security == REALITY) { } else if (config.security == REALITY) {
et_public_key?.text = Utils.getEditable(config.publicKey.orEmpty()) et_public_key?.text = Utils.getEditable(config.publicKey.orEmpty())
et_short_id?.text = Utils.getEditable(config.shortId.orEmpty()) et_short_id?.text = Utils.getEditable(config.shortId.orEmpty())
@@ -487,6 +493,8 @@ class ServerActivity : BaseActivity() {
saveStreamSettings(config) saveStreamSettings(config)
saveTls(config) saveTls(config)
config.description = AngConfigManager.generateDescription(config)
if (config.subscriptionId.isEmpty() && !subscriptionId.isNullOrEmpty()) { if (config.subscriptionId.isEmpty() && !subscriptionId.isNullOrEmpty()) {
config.subscriptionId = subscriptionId.orEmpty() config.subscriptionId = subscriptionId.orEmpty()
} }
@@ -526,7 +534,6 @@ class ServerActivity : BaseActivity() {
config.obfsPassword = et_obfs_password?.text?.toString() config.obfsPassword = et_obfs_password?.text?.toString()
config.portHopping = et_port_hop?.text?.toString() config.portHopping = et_port_hop?.text?.toString()
config.portHoppingInterval = et_port_hop_interval?.text?.toString() config.portHoppingInterval = et_port_hop_interval?.text?.toString()
config.pinSHA256 = et_pinsha256?.text?.toString()
config.bandwidthDown = et_bandwidth_down?.text?.toString() config.bandwidthDown = et_bandwidth_down?.text?.toString()
config.bandwidthUp = et_bandwidth_up?.text?.toString() config.bandwidthUp = et_bandwidth_up?.text?.toString()
} }
@@ -565,6 +572,7 @@ class ServerActivity : BaseActivity() {
val mldsa65Verify = et_mldsa65_verify?.text?.toString() val mldsa65Verify = et_mldsa65_verify?.text?.toString()
val echConfigList = et_ech_config_list?.text?.toString() val echConfigList = et_ech_config_list?.text?.toString()
val echForceQueryIndex = sp_ech_force_query?.selectedItemPosition ?: 0 val echForceQueryIndex = sp_ech_force_query?.selectedItemPosition ?: 0
val pinnedCA256 = et_pinned_ca256?.text?.toString()
val allowInsecure = val allowInsecure =
if (allowInsecureField == null || allowinsecures[allowInsecureField].isBlank()) { if (allowInsecureField == null || allowinsecures[allowInsecureField].isBlank()) {
@@ -584,6 +592,7 @@ class ServerActivity : BaseActivity() {
config.mldsa65Verify = mldsa65Verify config.mldsa65Verify = mldsa65Verify
config.echConfigList = echConfigList config.echConfigList = echConfigList
config.echForceQuery = echForceQuerys[echForceQueryIndex] config.echForceQuery = echForceQuerys[echForceQueryIndex]
config.pinnedCA256 = pinnedCA256
} }
private fun transportTypes(network: String?): Array<out String> { private fun transportTypes(network: String?): Array<out String> {
@@ -631,7 +640,7 @@ class ServerActivity : BaseActivity() {
finish() finish()
} }
} else { } else {
application.toast(R.string.toast_action_not_allowed) toast(R.string.toast_action_not_allowed)
} }
} }
return true return true
@@ -11,11 +11,12 @@ import com.blacksquircle.ui.language.json.JsonLanguage
import com.v2ray.ang.AppConfig import com.v2ray.ang.AppConfig
import com.v2ray.ang.R import com.v2ray.ang.R
import com.v2ray.ang.databinding.ActivityServerCustomConfigBinding import com.v2ray.ang.databinding.ActivityServerCustomConfigBinding
import com.v2ray.ang.dto.EConfigType import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.dto.ProfileItem import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.extension.toast import com.v2ray.ang.extension.toast
import com.v2ray.ang.extension.toastSuccess import com.v2ray.ang.extension.toastSuccess
import com.v2ray.ang.fmt.CustomFmt import com.v2ray.ang.fmt.CustomFmt
import com.v2ray.ang.handler.AngConfigManager
import com.v2ray.ang.handler.MmkvManager import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.util.Utils import com.v2ray.ang.util.Utils
@@ -89,6 +90,7 @@ class ServerCustomConfigActivity : BaseActivity() {
} }
config.server = profileItem?.server config.server = profileItem?.server
config.serverPort = profileItem?.serverPort config.serverPort = profileItem?.serverPort
config.description = AngConfigManager.generateDescription(config)
MmkvManager.encodeServerConfig(editGuid, config) MmkvManager.encodeServerConfig(editGuid, config)
MmkvManager.encodeServerRaw(editGuid, binding.editor.text.toString()) MmkvManager.encodeServerRaw(editGuid, binding.editor.text.toString())
@@ -8,10 +8,12 @@ import android.widget.ArrayAdapter
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import com.v2ray.ang.R import com.v2ray.ang.R
import com.v2ray.ang.databinding.ActivityServerGroupBinding import com.v2ray.ang.databinding.ActivityServerGroupBinding
import com.v2ray.ang.dto.EConfigType import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.dto.ProfileItem import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.extension.isNotNullEmpty
import com.v2ray.ang.extension.toast import com.v2ray.ang.extension.toast
import com.v2ray.ang.extension.toastSuccess import com.v2ray.ang.extension.toastSuccess
import com.v2ray.ang.handler.AngConfigManager
import com.v2ray.ang.handler.MmkvManager import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.util.Utils import com.v2ray.ang.util.Utils
@@ -66,6 +68,11 @@ class ServerGroupActivity : BaseActivity() {
private fun clearServer(): Boolean { private fun clearServer(): Boolean {
binding.etRemarks.text = null binding.etRemarks.text = null
binding.etPolicyGroupFilter.text = null binding.etPolicyGroupFilter.text = null
if (subscriptionId.isNotNullEmpty()) {
val pos = subIds.indexOf(subscriptionId).let { if (it >= 0) it else 0 }
binding.spPolicyGroupSubId.setSelection(pos)
}
return true return true
} }
@@ -90,6 +97,9 @@ class ServerGroupActivity : BaseActivity() {
if (config.subscriptionId.isEmpty() && !subscriptionId.isNullOrEmpty()) { if (config.subscriptionId.isEmpty() && !subscriptionId.isNullOrEmpty()) {
config.subscriptionId = subscriptionId.orEmpty() config.subscriptionId = subscriptionId.orEmpty()
} }
config.description = "${binding.spPolicyGroupType.selectedItem} - ${binding.spPolicyGroupSubId.selectedItem} - ${config.policyGroupFilter}"
MmkvManager.encodeServerConfig(editGuid, config) MmkvManager.encodeServerConfig(editGuid, config)
toastSuccess(R.string.toast_success) toastSuccess(R.string.toast_success)
finish() finish()
@@ -119,13 +129,13 @@ class ServerGroupActivity : BaseActivity() {
val displayList = mutableListOf(getString(R.string.filter_config_all)) //none val displayList = mutableListOf(getString(R.string.filter_config_all)) //none
subIds.clear() subIds.clear()
subIds.add("") // index 0 => All subIds.add("") // index 0 => All
subs.forEach { (id, item) -> subs.forEach { sub ->
val name = when { val name = when {
item.remarks.isNotBlank() -> item.remarks sub.subscription.remarks.isNotBlank() -> sub.subscription.remarks
else -> id else -> sub.guid
} }
displayList.add(name) displayList.add(name)
subIds.add(id) subIds.add(sub.guid)
} }
val subAdapter = ArrayAdapter(this, android.R.layout.simple_spinner_item, displayList) val subAdapter = ArrayAdapter(this, android.R.layout.simple_spinner_item, displayList)
subAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) subAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
@@ -15,29 +15,24 @@ import com.v2ray.ang.AppConfig.VPN
import com.v2ray.ang.R import com.v2ray.ang.R
import com.v2ray.ang.extension.toLongEx import com.v2ray.ang.extension.toLongEx
import com.v2ray.ang.handler.MmkvManager import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.MmkvPreferenceDataStore import com.v2ray.ang.helper.MmkvPreferenceDataStore
import com.v2ray.ang.handler.SubscriptionUpdater import com.v2ray.ang.handler.SubscriptionUpdater
import com.v2ray.ang.util.Utils import com.v2ray.ang.util.Utils
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
class SettingsActivity : BaseActivity() { class SettingsActivity : BaseActivity() {
//private val settingsViewModel: SettingsViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
//setContentView(R.layout.activity_settings)
setContentViewWithToolbar(R.layout.activity_settings, showHomeAsUp = true, title = getString(R.string.title_settings)) setContentViewWithToolbar(R.layout.activity_settings, showHomeAsUp = true, title = getString(R.string.title_settings))
//settingsViewModel.startListenPreferenceChange()
} }
class SettingsFragment : PreferenceFragmentCompat() { class SettingsFragment : PreferenceFragmentCompat() {
// private val perAppProxy by lazy { findPreference<CheckBoxPreference>(AppConfig.PREF_PER_APP_PROXY) }
private val localDns by lazy { findPreference<CheckBoxPreference>(AppConfig.PREF_LOCAL_DNS_ENABLED) } private val localDns by lazy { findPreference<CheckBoxPreference>(AppConfig.PREF_LOCAL_DNS_ENABLED) }
private val fakeDns by lazy { findPreference<CheckBoxPreference>(AppConfig.PREF_FAKE_DNS_ENABLED) } private val fakeDns by lazy { findPreference<CheckBoxPreference>(AppConfig.PREF_FAKE_DNS_ENABLED) }
private val appendHttpProxy by lazy { findPreference<CheckBoxPreference>(AppConfig.PREF_APPEND_HTTP_PROXY) } private val appendHttpProxy by lazy { findPreference<CheckBoxPreference>(AppConfig.PREF_APPEND_HTTP_PROXY) }
// private val localDnsPort by lazy { findPreference<EditTextPreference>(AppConfig.PREF_LOCAL_DNS_PORT) }
// private val localDnsPort by lazy { findPreference<EditTextPreference>(AppConfig.PREF_LOCAL_DNS_PORT) }
private val vpnDns by lazy { findPreference<EditTextPreference>(AppConfig.PREF_VPN_DNS) } private val vpnDns by lazy { findPreference<EditTextPreference>(AppConfig.PREF_VPN_DNS) }
private val vpnBypassLan by lazy { findPreference<ListPreference>(AppConfig.PREF_VPN_BYPASS_LAN) } private val vpnBypassLan by lazy { findPreference<ListPreference>(AppConfig.PREF_VPN_BYPASS_LAN) }
private val vpnInterfaceAddress by lazy { findPreference<ListPreference>(AppConfig.PREF_VPN_INTERFACE_ADDRESS_CONFIG_INDEX) } private val vpnInterfaceAddress by lazy { findPreference<ListPreference>(AppConfig.PREF_VPN_INTERFACE_ADDRESS_CONFIG_INDEX) }
@@ -55,18 +50,11 @@ class SettingsActivity : BaseActivity() {
private val autoUpdateCheck by lazy { findPreference<CheckBoxPreference>(AppConfig.SUBSCRIPTION_AUTO_UPDATE) } private val autoUpdateCheck by lazy { findPreference<CheckBoxPreference>(AppConfig.SUBSCRIPTION_AUTO_UPDATE) }
private val autoUpdateInterval by lazy { findPreference<EditTextPreference>(AppConfig.SUBSCRIPTION_AUTO_UPDATE_INTERVAL) } private val autoUpdateInterval by lazy { findPreference<EditTextPreference>(AppConfig.SUBSCRIPTION_AUTO_UPDATE_INTERVAL) }
// private val socksPort by lazy { findPreference<EditTextPreference>(AppConfig.PREF_SOCKS_PORT) }
// private val remoteDns by lazy { findPreference<EditTextPreference>(AppConfig.PREF_REMOTE_DNS) }
// private val domesticDns by lazy { findPreference<EditTextPreference>(AppConfig.PREF_DOMESTIC_DNS) }
// private val dnsHosts by lazy { findPreference<EditTextPreference>(AppConfig.PREF_DNS_HOSTS) }
// private val delayTestUrl by lazy { findPreference<EditTextPreference>(AppConfig.PREF_DELAY_TEST_URL) }
// private val ipApiUrl by lazy { findPreference<EditTextPreference>(AppConfig.PREF_IP_API_URL) }
private val mode by lazy { findPreference<ListPreference>(AppConfig.PREF_MODE) } private val mode by lazy { findPreference<ListPreference>(AppConfig.PREF_MODE) }
// private val hevTunLogLevel by lazy { findPreference<ListPreference>(AppConfig.PREF_HEV_TUNNEL_LOGLEVEL) } private val hevTunLogLevel by lazy { findPreference<ListPreference>(AppConfig.PREF_HEV_TUNNEL_LOGLEVEL) }
// private val hevTunRwTimeout by lazy { findPreference<EditTextPreference>(AppConfig.PREF_HEV_TUNNEL_RW_TIMEOUT) } private val hevTunRwTimeout by lazy { findPreference<EditTextPreference>(AppConfig.PREF_HEV_TUNNEL_RW_TIMEOUT) }
// private val useTun by lazy { findPreference<ListPreference>(AppConfig.PREF_TUN) } private val useHevTun by lazy { findPreference<CheckBoxPreference>(AppConfig.PREF_USE_HEV_TUNNEL) }
override fun onCreatePreferences(bundle: Bundle?, s: String?) { override fun onCreatePreferences(bundle: Bundle?, s: String?) {
// Use MMKV as the storage backend for all Preferences // Use MMKV as the storage backend for all Preferences
@@ -77,30 +65,10 @@ class SettingsActivity : BaseActivity() {
initPreferenceSummaries() initPreferenceSummaries()
// perAppProxy?.setOnPreferenceClickListener {
// startActivity(Intent(activity, PerAppProxyActivity::class.java))
// perAppProxy?.isChecked = true
// false
// }
localDns?.setOnPreferenceChangeListener { _, any -> localDns?.setOnPreferenceChangeListener { _, any ->
updateLocalDns(any as Boolean) updateLocalDns(any as Boolean)
true true
} }
// localDnsPort?.setOnPreferenceChangeListener { _, any ->
// val nval = any as String
// localDnsPort?.summary = nval.ifEmpty { AppConfig.PORT_LOCAL_DNS }
// true
// }
// vpnDns?.setOnPreferenceChangeListener { _, any ->
// vpnDns?.summary = any as String
// true
// }
// vpnMtu?.setOnPreferenceChangeListener { _, any ->
// val nval = any as String
// vpnMtu?.summary = nval.ifEmpty { AppConfig.VPN_MTU.toString() }
// true
// }
mux?.setOnPreferenceChangeListener { _, newValue -> mux?.setOnPreferenceChangeListener { _, newValue ->
updateMux(newValue as Boolean) updateMux(newValue as Boolean)
@@ -119,18 +87,6 @@ class SettingsActivity : BaseActivity() {
updateFragment(newValue as Boolean) updateFragment(newValue as Boolean)
true true
} }
// fragmentPackets?.setOnPreferenceChangeListener { _, newValue ->
// updateFragmentPackets(newValue as String)
// true
// }
// fragmentLength?.setOnPreferenceChangeListener { _, newValue ->
// updateFragmentLength(newValue as String)
// true
// }
// fragmentInterval?.setOnPreferenceChangeListener { _, newValue ->
// updateFragmentInterval(newValue as String)
// true
// }
autoUpdateCheck?.setOnPreferenceChangeListener { _, newValue -> autoUpdateCheck?.setOnPreferenceChangeListener { _, newValue ->
val value = newValue as Boolean val value = newValue as Boolean
@@ -141,65 +97,21 @@ class SettingsActivity : BaseActivity() {
} }
true true
} }
// autoUpdateInterval?.setOnPreferenceChangeListener { _, any -> mode?.setOnPreferenceChangeListener { pref, newValue ->
// var nval = any as String val valueStr = newValue.toString()
// (pref as? ListPreference)?.let { lp ->
// // It must be greater than 15 minutes because WorkManager couldn't run tasks under 15 minutes intervals val idx = lp.findIndexOfValue(valueStr)
// nval = lp.summary = if (idx >= 0) lp.entries[idx] else valueStr
// if (TextUtils.isEmpty(nval) || nval.toLongEx() < 15) AppConfig.SUBSCRIPTION_DEFAULT_UPDATE_INTERVAL else nval }
// autoUpdateInterval?.summary = nval updateMode(valueStr)
// configureUpdateTask(nval.toLongEx())
// true
// }
// socksPort?.setOnPreferenceChangeListener { _, any ->
// val nval = any as String
// socksPort?.summary = nval.ifEmpty { AppConfig.PORT_SOCKS }
// true
// }
//
// remoteDns?.setOnPreferenceChangeListener { _, any ->
// val nval = any as String
// remoteDns?.summary = nval.ifEmpty { AppConfig.DNS_PROXY }
// true
// }
// domesticDns?.setOnPreferenceChangeListener { _, any ->
// val nval = any as String
// domesticDns?.summary = nval.ifEmpty { AppConfig.DNS_DIRECT }
// true
// }
// dnsHosts?.setOnPreferenceChangeListener { _, any ->
// val nval = any as String
// dnsHosts?.summary = nval
// true
// }
// delayTestUrl?.setOnPreferenceChangeListener { _, any ->
// val nval = any as String
// delayTestUrl?.summary = nval.ifEmpty { AppConfig.DELAY_TEST_URL }
// true
// }
// ipApiUrl?.setOnPreferenceChangeListener { _, any ->
// val nval = any as String
// ipApiUrl?.summary = nval.ifEmpty { AppConfig.IP_API_URL }
// true
// }
mode?.setOnPreferenceChangeListener { _, newValue ->
updateMode(newValue.toString())
true true
} }
mode?.dialogLayoutResource = R.layout.preference_with_help_link mode?.dialogLayoutResource = R.layout.preference_with_help_link
//loglevel.summary = "LogLevel"
// useTun?.setOnPreferenceChangeListener { _, newValue -> useHevTun?.setOnPreferenceChangeListener { _, newValue ->
// updateHevTunSettings(newValue as String == AppConfig.TUN_hevsocks5) updateHevTunSettings(newValue as Boolean)
// true true
// } }
// hevTunRwTimeout?.setOnPreferenceChangeListener { _, any ->
// val nval = any as String
// hevTunRwTimeout?.summary = nval.ifEmpty { AppConfig.HEVTUN_RW_TIMEOUT }
// true
// }
} }
private fun initPreferenceSummaries() { private fun initPreferenceSummaries() {
@@ -212,6 +124,7 @@ class SettingsActivity : BaseActivity() {
true true
} }
} }
is ListPreference -> { is ListPreference -> {
pref.summary = pref.entry ?: "" pref.summary = pref.entry ?: ""
pref.setOnPreferenceChangeListener { p, newValue -> pref.setOnPreferenceChangeListener { p, newValue ->
@@ -221,6 +134,7 @@ class SettingsActivity : BaseActivity() {
true true
} }
} }
is CheckBoxPreference, is androidx.preference.SwitchPreferenceCompat -> { is CheckBoxPreference, is androidx.preference.SwitchPreferenceCompat -> {
} }
} }
@@ -240,6 +154,8 @@ class SettingsActivity : BaseActivity() {
override fun onStart() { override fun onStart() {
super.onStart() super.onStart()
updateHevTunSettings(MmkvManager.decodeSettingsBool(AppConfig.PREF_USE_HEV_TUNNEL, true))
// Initialize mode-dependent UI states // Initialize mode-dependent UI states
updateMode(MmkvManager.decodeSettingsString(AppConfig.PREF_MODE, VPN)) updateMode(MmkvManager.decodeSettingsString(AppConfig.PREF_MODE, VPN))
@@ -251,101 +167,10 @@ class SettingsActivity : BaseActivity() {
// Initialize auto-update interval state // Initialize auto-update interval state
autoUpdateInterval?.isEnabled = MmkvManager.decodeSettingsBool(AppConfig.SUBSCRIPTION_AUTO_UPDATE, false) autoUpdateInterval?.isEnabled = MmkvManager.decodeSettingsBool(AppConfig.SUBSCRIPTION_AUTO_UPDATE, false)
// 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)
// vpnDns?.summary = MmkvManager.decodeSettingsString(AppConfig.PREF_VPN_DNS, AppConfig.DNS_VPN)
// vpnMtu?.summary = MmkvManager.decodeSettingsString(AppConfig.PREF_VPN_MTU, AppConfig.VPN_MTU.toString())
// 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")
// fragment?.isChecked = MmkvManager.decodeSettingsBool(AppConfig.PREF_FRAGMENT_ENABLED, false)
// fragmentPackets?.summary = MmkvManager.decodeSettingsString(AppConfig.PREF_FRAGMENT_PACKETS, "tlshello")
// fragmentLength?.summary = MmkvManager.decodeSettingsString(AppConfig.PREF_FRAGMENT_LENGTH, "50-100")
// fragmentInterval?.summary = MmkvManager.decodeSettingsString(AppConfig.PREF_FRAGMENT_INTERVAL, "10-20")
// autoUpdateCheck?.isChecked = MmkvManager.decodeSettingsBool(AppConfig.SUBSCRIPTION_AUTO_UPDATE, false)
// autoUpdateInterval?.summary =
// MmkvManager.decodeSettingsString(AppConfig.SUBSCRIPTION_AUTO_UPDATE_INTERVAL, AppConfig.SUBSCRIPTION_DEFAULT_UPDATE_INTERVAL)
// 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)
// ipApiUrl?.summary = MmkvManager.decodeSettingsString(AppConfig.PREF_IP_API_URL, AppConfig.IP_API_URL)
// hevTunRwTimeout?.summary = MmkvManager.decodeSettingsString(AppConfig.PREF_HEV_TUNNEL_RW_TIMEOUT, AppConfig.HEVTUN_RW_TIMEOUT)
// updateHevTunSettings(MmkvManager.decodeSettingsString(AppConfig.PREF_TUN, AppConfig.TUN_hevsocks5) == AppConfig.TUN_hevsocks5)
// initSharedPreference()
} }
private fun initSharedPreference() { private fun updateMode(value: String?) {
// listOf( val vpn = value == VPN
// //localDnsPort,
// vpnDns,
// vpnMtu,
// muxConcurrency,
// muxXudpConcurrency,
// fragmentLength,
// fragmentInterval,
// autoUpdateInterval,
// socksPort,
// remoteDns,
// domesticDns,
// delayTestUrl,
// ipApiUrl,
// hevTunRwTimeout
// ).forEach { key ->
// key?.summary = key.text.toString()
// }
// listOf(
// AppConfig.PREF_SNIFFING_ENABLED,
// AppConfig.PREF_USE_HEV_TUNNEL
// ).forEach { key ->
// findPreference<CheckBoxPreference>(key)?.isChecked =
// MmkvManager.decodeSettingsBool(key, true)
// }
//
// listOf(
// AppConfig.PREF_ROUTE_ONLY_ENABLED,
// AppConfig.PREF_IS_BOOTED,
// AppConfig.PREF_BYPASS_APPS,
// AppConfig.PREF_SPEED_ENABLED,
// AppConfig.PREF_CONFIRM_REMOVE,
// AppConfig.PREF_START_SCAN_IMMEDIATE,
// AppConfig.PREF_DOUBLE_COLUMN_DISPLAY,
// AppConfig.PREF_PREFER_IPV6,
// AppConfig.PREF_PROXY_SHARING,
// AppConfig.PREF_ALLOW_INSECURE
// ).forEach { key ->
// findPreference<CheckBoxPreference>(key)?.isChecked =
// MmkvManager.decodeSettingsBool(key, false)
// }
//
// listOf(
// AppConfig.PREF_VPN_BYPASS_LAN,
// AppConfig.PREF_VPN_INTERFACE_ADDRESS_CONFIG_INDEX,
// AppConfig.PREF_ROUTING_DOMAIN_STRATEGY,
// AppConfig.PREF_MUX_XUDP_QUIC,
// AppConfig.PREF_FRAGMENT_PACKETS,
// AppConfig.PREF_LANGUAGE,
// AppConfig.PREF_UI_MODE_NIGHT,
// AppConfig.PREF_LOGLEVEL,
// AppConfig.PREF_OUTBOUND_DOMAIN_RESOLVE_METHOD,
// AppConfig.PREF_MODE,
// AppConfig.PREF_HEV_TUNNEL_LOGLEVEL
// ).forEach { key ->
// if (MmkvManager.decodeSettingsString(key) != null) {
// findPreference<ListPreference>(key)?.value = MmkvManager.decodeSettingsString(key)
// }
// }
}
private fun updateMode(mode: String?) {
val vpn = mode == VPN
// perAppProxy?.isEnabled = vpn
// perAppProxy?.isChecked = MmkvManager.decodeSettingsBool(AppConfig.PREF_PER_APP_PROXY, false)
localDns?.isEnabled = vpn localDns?.isEnabled = vpn
fakeDns?.isEnabled = vpn fakeDns?.isEnabled = vpn
appendHttpProxy?.isEnabled = vpn appendHttpProxy?.isEnabled = vpn
@@ -354,6 +179,8 @@ class SettingsActivity : BaseActivity() {
vpnBypassLan?.isEnabled = vpn vpnBypassLan?.isEnabled = vpn
vpnInterfaceAddress?.isEnabled = vpn vpnInterfaceAddress?.isEnabled = vpn
vpnMtu?.isEnabled = vpn vpnMtu?.isEnabled = vpn
useHevTun?.isEnabled = vpn
updateHevTunSettings(false)
if (vpn) { if (vpn) {
updateLocalDns( updateLocalDns(
MmkvManager.decodeSettingsBool( MmkvManager.decodeSettingsBool(
@@ -361,6 +188,12 @@ class SettingsActivity : BaseActivity() {
false false
) )
) )
updateHevTunSettings(
MmkvManager.decodeSettingsBool(
AppConfig.PREF_USE_HEV_TUNNEL,
false
)
)
} }
} }
@@ -423,29 +256,12 @@ class SettingsActivity : BaseActivity() {
fragmentPackets?.isEnabled = enabled fragmentPackets?.isEnabled = enabled
fragmentLength?.isEnabled = enabled fragmentLength?.isEnabled = enabled
fragmentInterval?.isEnabled = enabled fragmentInterval?.isEnabled = enabled
// if (enabled) {
// updateFragmentPackets(MmkvManager.decodeSettingsString(AppConfig.PREF_FRAGMENT_PACKETS, "tlshello"))
// updateFragmentLength(MmkvManager.decodeSettingsString(AppConfig.PREF_FRAGMENT_LENGTH, "50-100"))
// updateFragmentInterval(MmkvManager.decodeSettingsString(AppConfig.PREF_FRAGMENT_INTERVAL, "10-20"))
// }
} }
//
// private fun updateFragmentPackets(value: String?) { private fun updateHevTunSettings(enabled: Boolean) {
// fragmentPackets?.summary = value.toString() hevTunLogLevel?.isEnabled = enabled
// } hevTunRwTimeout?.isEnabled = enabled
// }
// private fun updateFragmentLength(value: String?) {
// fragmentLength?.summary = value.toString()
// }
//
// private fun updateFragmentInterval(value: String?) {
// fragmentInterval?.summary = value.toString()
// }
//
// private fun updateHevTunSettings(enabled: Boolean) {
// hevTunLogLevel?.isEnabled = enabled
// hevTunRwTimeout?.isEnabled = enabled
// }
} }
fun onModeHelpClicked(view: View) { fun onModeHelpClicked(view: View) {
@@ -3,18 +3,28 @@ package com.v2ray.ang.ui
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater
import android.view.Menu import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.appcompat.app.AlertDialog
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import com.v2ray.ang.AppConfig
import com.v2ray.ang.R import com.v2ray.ang.R
import com.v2ray.ang.contracts.BaseAdapterListener
import com.v2ray.ang.databinding.ActivitySubSettingBinding import com.v2ray.ang.databinding.ActivitySubSettingBinding
import com.v2ray.ang.databinding.ItemQrcodeBinding
import com.v2ray.ang.extension.toast
import com.v2ray.ang.extension.toastError import com.v2ray.ang.extension.toastError
import com.v2ray.ang.extension.toastSuccess import com.v2ray.ang.extension.toastSuccess
import com.v2ray.ang.handler.AngConfigManager import com.v2ray.ang.handler.AngConfigManager
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.helper.SimpleItemTouchHelperCallback import com.v2ray.ang.helper.SimpleItemTouchHelperCallback
import com.v2ray.ang.util.QRCodeDecoder
import com.v2ray.ang.util.Utils
import com.v2ray.ang.viewmodel.SubscriptionsViewModel import com.v2ray.ang.viewmodel.SubscriptionsViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@@ -22,17 +32,21 @@ import kotlinx.coroutines.launch
class SubSettingActivity : BaseActivity() { class SubSettingActivity : BaseActivity() {
private val binding by lazy { ActivitySubSettingBinding.inflate(layoutInflater) } private val binding by lazy { ActivitySubSettingBinding.inflate(layoutInflater) }
private val ownerActivity: SubSettingActivity
get() = this
private val viewModel: SubscriptionsViewModel by viewModels() private val viewModel: SubscriptionsViewModel by viewModels()
private lateinit var adapter: SubSettingRecyclerAdapter private lateinit var adapter: SubSettingRecyclerAdapter
private var mItemTouchHelper: ItemTouchHelper? = null private var mItemTouchHelper: ItemTouchHelper? = null
private val share_method: Array<out String> by lazy {
resources.getStringArray(R.array.share_sub_method)
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
//setContentView(binding.root) //setContentView(binding.root)
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.title_sub_setting)) setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.title_sub_setting))
adapter = SubSettingRecyclerAdapter(this, viewModel) adapter = SubSettingRecyclerAdapter(viewModel, ActivityAdapterListener())
binding.recyclerView.setHasFixedSize(true) binding.recyclerView.setHasFixedSize(true)
binding.recyclerView.layoutManager = LinearLayoutManager(this) binding.recyclerView.layoutManager = LinearLayoutManager(this)
@@ -88,4 +102,62 @@ class SubSettingActivity : BaseActivity() {
viewModel.reload() viewModel.reload()
adapter.notifyDataSetChanged() adapter.notifyDataSetChanged()
} }
private inner class ActivityAdapterListener : BaseAdapterListener {
override fun onEdit(guid: String, position: Int) {
startActivity(
Intent(ownerActivity, SubEditActivity::class.java)
.putExtra("subId", guid)
)
}
override fun onRemove(guid: String, position: Int) {
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_CONFIRM_REMOVE)) {
AlertDialog.Builder(ownerActivity)
.setMessage(R.string.del_config_comfirm)
.setPositiveButton(android.R.string.ok) { _, _ ->
viewModel.remove(guid)
refreshData()
}
.setNegativeButton(android.R.string.cancel, null)
.show()
} else {
viewModel.remove(guid)
refreshData()
}
}
override fun onShare(url: String) {
AlertDialog.Builder(ownerActivity)
.setItems(share_method.asList().toTypedArray()) { _, i ->
try {
when (i) {
0 -> {
val ivBinding =
ItemQrcodeBinding.inflate(LayoutInflater.from(ownerActivity))
ivBinding.ivQcode.setImageBitmap(
QRCodeDecoder.createQRCode(
url
)
)
AlertDialog.Builder(ownerActivity).setView(ivBinding.root).show()
}
1 -> {
Utils.setClipboard(ownerActivity, url)
}
else -> ownerActivity.toast("else")
}
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Share subscription failed", e)
}
}.show()
}
override fun onRefreshData() {
refreshData()
}
}
} }
@@ -1,43 +1,29 @@
package com.v2ray.ang.ui package com.v2ray.ang.ui
import android.content.Intent
import android.graphics.Color import android.graphics.Color
import android.text.TextUtils import android.text.TextUtils
import android.util.Log
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.appcompat.app.AlertDialog
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.v2ray.ang.AppConfig import com.v2ray.ang.contracts.BaseAdapterListener
import com.v2ray.ang.R
import com.v2ray.ang.databinding.ItemQrcodeBinding
import com.v2ray.ang.databinding.ItemRecyclerSubSettingBinding import com.v2ray.ang.databinding.ItemRecyclerSubSettingBinding
import com.v2ray.ang.extension.toast
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.helper.ItemTouchHelperAdapter import com.v2ray.ang.helper.ItemTouchHelperAdapter
import com.v2ray.ang.helper.ItemTouchHelperViewHolder import com.v2ray.ang.helper.ItemTouchHelperViewHolder
import com.v2ray.ang.util.QRCodeDecoder
import com.v2ray.ang.util.Utils import com.v2ray.ang.util.Utils
import com.v2ray.ang.viewmodel.SubscriptionsViewModel import com.v2ray.ang.viewmodel.SubscriptionsViewModel
class SubSettingRecyclerAdapter( class SubSettingRecyclerAdapter(
val activity: SubSettingActivity, private val viewModel: SubscriptionsViewModel,
private val viewModel: SubscriptionsViewModel private val adapterListener: BaseAdapterListener?
) : RecyclerView.Adapter<SubSettingRecyclerAdapter.MainViewHolder>(), ItemTouchHelperAdapter { ) : RecyclerView.Adapter<SubSettingRecyclerAdapter.MainViewHolder>(), ItemTouchHelperAdapter {
private var mActivity: SubSettingActivity = activity
private val share_method: Array<out String> by lazy {
mActivity.resources.getStringArray(R.array.share_sub_method)
}
override fun getItemCount() = viewModel.getAll().size override fun getItemCount() = viewModel.getAll().size
override fun onBindViewHolder(holder: MainViewHolder, position: Int) { override fun onBindViewHolder(holder: MainViewHolder, position: Int) {
val subscriptions = viewModel.getAll() val subscriptions = viewModel.getAll()
val subId = subscriptions[position].first val subId = subscriptions[position].guid
val subItem = subscriptions[position].second val subItem = subscriptions[position].subscription
holder.itemSubSettingBinding.tvName.text = subItem.remarks holder.itemSubSettingBinding.tvName.text = subItem.remarks
holder.itemSubSettingBinding.tvUrl.text = subItem.url holder.itemSubSettingBinding.tvUrl.text = subItem.url
holder.itemSubSettingBinding.chkEnable.isChecked = subItem.enabled holder.itemSubSettingBinding.chkEnable.isChecked = subItem.enabled
@@ -45,14 +31,11 @@ class SubSettingRecyclerAdapter(
holder.itemView.setBackgroundColor(Color.TRANSPARENT) holder.itemView.setBackgroundColor(Color.TRANSPARENT)
holder.itemSubSettingBinding.layoutEdit.setOnClickListener { holder.itemSubSettingBinding.layoutEdit.setOnClickListener {
mActivity.startActivity( adapterListener?.onEdit(subId, position)
Intent(mActivity, SubEditActivity::class.java)
.putExtra("subId", subId)
)
} }
holder.itemSubSettingBinding.layoutRemove.setOnClickListener { holder.itemSubSettingBinding.layoutRemove.setOnClickListener {
removeSubscription(subId, position) adapterListener?.onRemove(subId, position)
} }
holder.itemSubSettingBinding.chkEnable.setOnCheckedChangeListener { it, isChecked -> holder.itemSubSettingBinding.chkEnable.setOnCheckedChangeListener { it, isChecked ->
@@ -72,58 +55,11 @@ class SubSettingRecyclerAdapter(
holder.itemSubSettingBinding.chkEnable.visibility = View.VISIBLE holder.itemSubSettingBinding.chkEnable.visibility = View.VISIBLE
holder.itemSubSettingBinding.layoutLastUpdated.visibility = View.VISIBLE holder.itemSubSettingBinding.layoutLastUpdated.visibility = View.VISIBLE
holder.itemSubSettingBinding.layoutShare.setOnClickListener { holder.itemSubSettingBinding.layoutShare.setOnClickListener {
AlertDialog.Builder(mActivity) adapterListener?.onShare(subItem.url)
.setItems(share_method.asList().toTypedArray()) { _, i ->
try {
when (i) {
0 -> {
val ivBinding =
ItemQrcodeBinding.inflate(LayoutInflater.from(mActivity))
ivBinding.ivQcode.setImageBitmap(
QRCodeDecoder.createQRCode(
subItem.url
)
)
AlertDialog.Builder(mActivity).setView(ivBinding.root).show()
}
1 -> {
Utils.setClipboard(mActivity, subItem.url)
}
else -> mActivity.toast("else")
}
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Share subscription failed", e)
}
}.show()
} }
} }
} }
private fun removeSubscription(subId: String, position: Int) {
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_CONFIRM_REMOVE)) {
AlertDialog.Builder(mActivity).setMessage(R.string.del_config_comfirm)
.setPositiveButton(android.R.string.ok) { _, _ ->
removeSubscriptionSub(subId, position)
}
.setNegativeButton(android.R.string.cancel) { _, _ ->
//do noting
}
.show()
} else {
removeSubscriptionSub(subId, position)
}
}
private fun removeSubscriptionSub(subId: String, position: Int) {
viewModel.remove(subId)
notifyItemRemoved(position)
notifyItemRangeChanged(position, viewModel.getAll().size)
mActivity.refreshData()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MainViewHolder { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MainViewHolder {
return MainViewHolder( return MainViewHolder(
ItemRecyclerSubSettingBinding.inflate( ItemRecyclerSubSettingBinding.inflate(
@@ -154,7 +90,7 @@ class SubSettingRecyclerAdapter(
} }
override fun onItemMoveCompleted() { override fun onItemMoveCompleted() {
mActivity.refreshData() adapterListener?.onRefreshData()
} }
override fun onItemDismiss(position: Int) { override fun onItemDismiss(position: Int) {
@@ -23,7 +23,8 @@ class TaskerActivity : BaseActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(binding.root) //setContentView(binding.root)
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = "")
//add def value //add def value
lstData.add("Default") lstData.add("Default")
@@ -1,95 +1,52 @@
package com.v2ray.ang.ui package com.v2ray.ang.ui
import android.Manifest
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.app.AlertDialog import android.app.AlertDialog
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.provider.OpenableColumns import android.provider.OpenableColumns
import android.util.Log import android.util.Log
import android.view.LayoutInflater
import android.view.Menu import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
import android.view.View.GONE import androidx.activity.viewModels
import android.view.View.VISIBLE
import android.view.ViewGroup
import androidx.activity.result.contract.ActivityResultContracts
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.v2ray.ang.AppConfig import com.v2ray.ang.AppConfig
import com.v2ray.ang.R import com.v2ray.ang.R
import com.v2ray.ang.contracts.BaseAdapterListener
import com.v2ray.ang.databinding.ActivityUserAssetBinding import com.v2ray.ang.databinding.ActivityUserAssetBinding
import com.v2ray.ang.databinding.ItemRecyclerUserAssetBinding
import com.v2ray.ang.dto.AssetUrlItem import com.v2ray.ang.dto.AssetUrlItem
import com.v2ray.ang.extension.concatUrl
import com.v2ray.ang.extension.toTrafficString
import com.v2ray.ang.extension.toast import com.v2ray.ang.extension.toast
import com.v2ray.ang.extension.toastError import com.v2ray.ang.extension.toastError
import com.v2ray.ang.extension.toastSuccess import com.v2ray.ang.extension.toastSuccess
import com.v2ray.ang.handler.MmkvManager import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.SettingsManager import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.util.HttpUtil
import com.v2ray.ang.util.Utils import com.v2ray.ang.util.Utils
import com.v2ray.ang.viewmodel.UserAssetViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.File import java.io.File
import java.io.FileOutputStream
import java.net.HttpURLConnection
import java.text.DateFormat
import java.util.Date
class UserAssetActivity : BaseActivity() { class UserAssetActivity : HelperBaseActivity() {
private val binding by lazy { ActivityUserAssetBinding.inflate(layoutInflater) } private val binding by lazy { ActivityUserAssetBinding.inflate(layoutInflater) }
private val ownerActivity: UserAssetActivity
get() = this
private val viewModel: UserAssetViewModel by viewModels()
private lateinit var adapter: UserAssetAdapter
val extDir by lazy { File(Utils.userAssetPath(this)) } val extDir by lazy { File(Utils.userAssetPath(this)) }
val builtInGeoFiles = arrayOf("geosite.dat", "geoip.dat")
private val requestStoragePermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { isGranted: Boolean ->
if (isGranted) {
val intent = Intent(Intent.ACTION_GET_CONTENT)
intent.type = "*/*"
intent.addCategory(Intent.CATEGORY_OPENABLE)
try {
chooseFile.launch(
Intent.createChooser(
intent,
getString(R.string.title_file_chooser)
)
)
} catch (ex: android.content.ActivityNotFoundException) {
toast(R.string.toast_require_file_manager)
}
} else {
toast(R.string.toast_permission_denied)
}
}
private val requestCameraPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { isGranted: Boolean ->
if (isGranted) {
scanQRCodeForAssetURL.launch(Intent(this, ScannerActivity::class.java))
} else {
toast(R.string.toast_permission_denied)
}
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
//setContentView(binding.root)
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.title_user_asset_setting)) setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.title_user_asset_setting))
binding.recyclerView.setHasFixedSize(true) binding.recyclerView.setHasFixedSize(true)
binding.recyclerView.layoutManager = LinearLayoutManager(this) binding.recyclerView.layoutManager = LinearLayoutManager(this)
addCustomDividerToRecyclerView(binding.recyclerView, this, R.drawable.custom_divider) addCustomDividerToRecyclerView(binding.recyclerView, this, R.drawable.custom_divider)
binding.recyclerView.adapter = UserAssetAdapter() adapter = UserAssetAdapter(viewModel, extDir, ActivityAdapterListener())
binding.recyclerView.adapter = adapter
binding.tvGeoFilesSourcesSummary.text = getGeoFilesSources() binding.tvGeoFilesSourcesSummary.text = getGeoFilesSources()
binding.layoutGeoFilesSources.setOnClickListener { binding.layoutGeoFilesSources.setOnClickListener {
@@ -133,17 +90,11 @@ class UserAssetActivity : BaseActivity() {
} }
private fun showFileChooser() { private fun showFileChooser() {
val permission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { launchFileChooser { uri ->
Manifest.permission.READ_MEDIA_IMAGES if (uri == null) {
} else { return@launchFileChooser
Manifest.permission.READ_EXTERNAL_STORAGE }
}
requestStoragePermissionLauncher.launch(permission)
}
private val chooseFile = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
val uri = result.data?.data
if (result.resultCode == RESULT_OK && uri != null) {
val assetId = Utils.getUuid() val assetId = Utils.getUuid()
runCatching { runCatching {
val assetItem = AssetUrlItem( val assetItem = AssetUrlItem(
@@ -152,7 +103,7 @@ class UserAssetActivity : BaseActivity() {
) )
val assetList = MmkvManager.decodeAssetUrls() val assetList = MmkvManager.decodeAssetUrls()
if (assetList.any { it.second.remarks == assetItem.remarks && it.first != assetId }) { if (assetList.any { it.assetUrl.remarks == assetItem.remarks && it.guid != assetId }) {
toast(R.string.msg_remark_is_duplicate) toast(R.string.msg_remark_is_duplicate)
} else { } else {
MmkvManager.encodeAsset(assetId, assetItem) MmkvManager.encodeAsset(assetId, assetItem)
@@ -190,15 +141,14 @@ class UserAssetActivity : BaseActivity() {
} }
private fun importAssetFromQRcode(): Boolean { private fun importAssetFromQRcode(): Boolean {
requestCameraPermissionLauncher.launch(Manifest.permission.CAMERA) launchQRCodeScanner { scanResult ->
if (scanResult != null) {
importAsset(scanResult)
}
}
return true return true
} }
private val scanQRCodeForAssetURL = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (it.resultCode == RESULT_OK) {
importAsset(it.data?.getStringExtra("SCAN_RESULT"))
}
}
private fun importAsset(url: String?): Boolean { private fun importAsset(url: String?): Boolean {
try { try {
@@ -219,81 +169,25 @@ class UserAssetActivity : BaseActivity() {
} }
private fun downloadGeoFiles() { private fun downloadGeoFiles() {
refreshData()
showLoading() showLoading()
toast(R.string.msg_downloading_content) toast(R.string.msg_downloading_content)
val httpPort = SettingsManager.getHttpPort() val httpPort = SettingsManager.getHttpPort()
var assets = MmkvManager.decodeAssetUrls()
assets = addBuiltInGeoItems(assets)
var resultCount = 0
lifecycleScope.launch(Dispatchers.IO) { lifecycleScope.launch(Dispatchers.IO) {
assets.forEach { val result = viewModel.downloadGeoFiles(extDir, httpPort)
try {
var result = downloadGeo(it.second, 15000, httpPort)
if (!result) {
result = downloadGeo(it.second, 15000, 0)
}
if (result)
resultCount++
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to download geo file: ${it.second.remarks}", e)
}
}
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
if (resultCount > 0) { if (result.successCount > 0) {
toast(getString(R.string.title_update_config_count, resultCount)) toast(getString(R.string.title_update_config_count, result.successCount))
refreshData()
} else { } else {
toast(getString(R.string.toast_failure)) toast(getString(R.string.toast_failure))
} }
refreshData()
hideLoading() hideLoading()
} }
} }
} }
private fun downloadGeo(item: AssetUrlItem, timeout: Int, httpPort: Int): Boolean {
val targetTemp = File(extDir, item.remarks + "_temp")
val target = File(extDir, item.remarks)
Log.i(AppConfig.TAG, "Downloading geo file: ${item.remarks} from ${item.url}")
val conn = HttpUtil.createProxyConnection(item.url, httpPort, timeout, timeout, needStream = true) ?: return false
try {
val inputStream = conn.inputStream
val responseCode = conn.responseCode
if (responseCode == HttpURLConnection.HTTP_OK) {
FileOutputStream(targetTemp).use { output ->
inputStream.copyTo(output)
}
targetTemp.renameTo(target)
}
return true
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to download geo file: ${item.remarks}", e)
return false
} finally {
conn.disconnect()
}
}
private fun addBuiltInGeoItems(assets: List<Pair<String, AssetUrlItem>>): List<Pair<String, AssetUrlItem>> {
val list = mutableListOf<Pair<String, AssetUrlItem>>()
builtInGeoFiles
.filter { geoFile -> assets.none { it.second.remarks == geoFile } }
.forEach {
list.add(
Utils.getUuid() to AssetUrlItem(
it,
String.format(AppConfig.GITHUB_DOWNLOAD_URL, getGeoFilesSources()).concatUrl(it),
locked = true
)
)
}
return list + assets
}
fun initAssets() { fun initAssets() {
lifecycleScope.launch(Dispatchers.Default) { lifecycleScope.launch(Dispatchers.Default) {
SettingsManager.initAssets(this@UserAssetActivity, assets) SettingsManager.initAssets(this@UserAssetActivity, assets)
@@ -305,72 +199,41 @@ class UserAssetActivity : BaseActivity() {
@SuppressLint("NotifyDataSetChanged") @SuppressLint("NotifyDataSetChanged")
fun refreshData() { fun refreshData() {
binding.recyclerView.adapter?.notifyDataSetChanged() viewModel.reload(getGeoFilesSources())
adapter.notifyDataSetChanged()
} }
inner class UserAssetAdapter : RecyclerView.Adapter<UserAssetViewHolder>() { private inner class ActivityAdapterListener : BaseAdapterListener {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): UserAssetViewHolder { override fun onEdit(guid: String, position: Int) {
return UserAssetViewHolder( startActivity(
ItemRecyclerUserAssetBinding.inflate( Intent(ownerActivity, UserAssetUrlActivity::class.java)
LayoutInflater.from(parent.context), .putExtra("assetId", guid)
parent,
false
)
) )
} }
@SuppressLint("SetTextI18n") override fun onRemove(guid: String, position: Int) {
override fun onBindViewHolder(holder: UserAssetViewHolder, position: Int) { val asset = viewModel.getAsset(position)?.takeIf { it.guid == guid }
var assets = MmkvManager.decodeAssetUrls() ?: viewModel.getAssets().find { it.guid == guid }
assets = addBuiltInGeoItems(assets) ?: return
val item = assets.getOrNull(position) ?: return val file = extDir.listFiles()?.find { it.name == asset.assetUrl.remarks }
// file with name == item.second.remarks
val file = extDir.listFiles()?.find { it.name == item.second.remarks }
holder.itemUserAssetBinding.assetName.text = item.second.remarks AlertDialog.Builder(ownerActivity).setMessage(R.string.del_config_comfirm)
.setPositiveButton(android.R.string.ok) { _, _ ->
if (file != null) { file?.delete()
val dateFormat = DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.MEDIUM) MmkvManager.removeAssetUrl(guid)
holder.itemUserAssetBinding.assetProperties.text = initAssets()
"${file.length().toTrafficString()} • ${dateFormat.format(Date(file.lastModified()))}" }
} else { .setNegativeButton(android.R.string.cancel) { _, _ ->
holder.itemUserAssetBinding.assetProperties.text = getString(R.string.msg_file_not_found) // do nothing
} }
.show()
if (item.second.locked == true) {
holder.itemUserAssetBinding.layoutEdit.visibility = GONE
//holder.itemUserAssetBinding.layoutRemove.visibility = GONE
} else {
holder.itemUserAssetBinding.layoutEdit.visibility = item.second.url.let { if (it == "file") GONE else VISIBLE }
//holder.itemUserAssetBinding.layoutRemove.visibility = VISIBLE
}
holder.itemUserAssetBinding.layoutEdit.setOnClickListener {
val intent = Intent(this@UserAssetActivity, UserAssetUrlActivity::class.java)
intent.putExtra("assetId", item.first)
startActivity(intent)
}
holder.itemUserAssetBinding.layoutRemove.setOnClickListener {
AlertDialog.Builder(this@UserAssetActivity).setMessage(R.string.del_config_comfirm)
.setPositiveButton(android.R.string.ok) { _, _ ->
file?.delete()
MmkvManager.removeAssetUrl(item.first)
initAssets()
}
.setNegativeButton(android.R.string.cancel) { _, _ ->
//do noting
}
.show()
}
} }
override fun getItemCount(): Int { override fun onShare(url: String) {
var assets = MmkvManager.decodeAssetUrls() }
assets = addBuiltInGeoItems(assets)
return assets.size override fun onRefreshData() {
refreshData()
} }
} }
class UserAssetViewHolder(val itemUserAssetBinding: ItemRecyclerUserAssetBinding) :
RecyclerView.ViewHolder(itemUserAssetBinding.root)
} }
@@ -0,0 +1,71 @@
package com.v2ray.ang.ui
import android.annotation.SuppressLint
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.v2ray.ang.R
import com.v2ray.ang.contracts.BaseAdapterListener
import com.v2ray.ang.databinding.ItemRecyclerUserAssetBinding
import com.v2ray.ang.extension.toTrafficString
import com.v2ray.ang.viewmodel.UserAssetViewModel
import java.io.File
import java.text.DateFormat
import java.util.Date
class UserAssetAdapter(
private val viewModel: UserAssetViewModel,
private val extDir: File,
private val adapterListener: BaseAdapterListener?
) : RecyclerView.Adapter<UserAssetAdapter.UserAssetViewHolder>() {
override fun getItemCount() = viewModel.itemCount
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): UserAssetViewHolder {
return UserAssetViewHolder(
ItemRecyclerUserAssetBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
)
}
@SuppressLint("SetTextI18n")
override fun onBindViewHolder(holder: UserAssetViewHolder, position: Int) {
val item = viewModel.getAsset(position) ?: return
val file = extDir.listFiles()?.find { it.name == item.assetUrl.remarks }
holder.itemUserAssetBinding.assetName.text = item.assetUrl.remarks
if (file != null) {
val dateFormat = DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.MEDIUM)
holder.itemUserAssetBinding.assetProperties.text =
"${file.length().toTrafficString()} • ${dateFormat.format(Date(file.lastModified()))}"
} else {
holder.itemUserAssetBinding.assetProperties.text =
holder.itemUserAssetBinding.root.context.getString(R.string.msg_file_not_found)
}
if (item.assetUrl.locked == true) {
holder.itemUserAssetBinding.layoutEdit.visibility = View.GONE
} else {
holder.itemUserAssetBinding.layoutEdit.visibility = if (item.assetUrl.url == "file") {
View.GONE
} else {
View.VISIBLE
}
}
holder.itemUserAssetBinding.layoutEdit.setOnClickListener {
adapterListener?.onEdit(item.guid, position)
}
holder.itemUserAssetBinding.layoutRemove.setOnClickListener {
adapterListener?.onRemove(item.guid, position)
}
}
class UserAssetViewHolder(val itemUserAssetBinding: ItemRecyclerUserAssetBinding) :
RecyclerView.ViewHolder(itemUserAssetBinding.root)
}
@@ -93,7 +93,7 @@ class UserAssetUrlActivity : BaseActivity() {
// check remarks unique // check remarks unique
val assetList = MmkvManager.decodeAssetUrls() val assetList = MmkvManager.decodeAssetUrls()
if (assetList.any { it.second.remarks == assetItem.remarks && it.first != assetId }) { if (assetList.any { it.assetUrl.remarks == assetItem.remarks && it.guid != assetId }) {
toast(R.string.msg_remark_is_duplicate) toast(R.string.msg_remark_is_duplicate)
return false return false
} }
@@ -65,9 +65,9 @@ object QRCodeDecoder {
val qrReader = QRCodeReader() val qrReader = QRCodeReader()
try { try {
qrReader.decode(BinaryBitmap(GlobalHistogramBinarizer(source)), mapOf(DecodeHintType.TRY_HARDER to true)).text qrReader.decode(BinaryBitmap(GlobalHistogramBinarizer(source)), HINTS).text
} catch (e: NotFoundException) { } catch (e: NotFoundException) {
qrReader.decode(BinaryBitmap(GlobalHistogramBinarizer(source.invert())), mapOf(DecodeHintType.TRY_HARDER to true)).text qrReader.decode(BinaryBitmap(GlobalHistogramBinarizer(source.invert())), HINTS).text
} }
}.getOrNull() }.getOrNull()
} }
@@ -97,27 +97,9 @@ object QRCodeDecoder {
} }
init { init {
val allFormats: List<BarcodeFormat> = arrayListOf( // Keep decoding hints focused on QR codes and enable TRY_HARDER + UTF-8 charset for better success rate.
BarcodeFormat.AZTEC, HINTS[DecodeHintType.TRY_HARDER] = true
BarcodeFormat.CODABAR, HINTS[DecodeHintType.POSSIBLE_FORMATS] = listOf(BarcodeFormat.QR_CODE)
BarcodeFormat.CODE_39, HINTS[DecodeHintType.CHARACTER_SET] = Charsets.UTF_8.name()
BarcodeFormat.CODE_93,
BarcodeFormat.CODE_128,
BarcodeFormat.DATA_MATRIX,
BarcodeFormat.EAN_8,
BarcodeFormat.EAN_13,
BarcodeFormat.ITF,
BarcodeFormat.MAXICODE,
BarcodeFormat.PDF_417,
BarcodeFormat.QR_CODE,
BarcodeFormat.RSS_14,
BarcodeFormat.RSS_EXPANDED,
BarcodeFormat.UPC_A,
BarcodeFormat.UPC_E,
BarcodeFormat.UPC_EAN_EXTENSION
)
HINTS[DecodeHintType.TRY_HARDER] = BarcodeFormat.QR_CODE
HINTS[DecodeHintType.POSSIBLE_FORMATS] = allFormats
HINTS[DecodeHintType.CHARACTER_SET] = Charsets.UTF_8
} }
} }
@@ -328,13 +328,48 @@ object Utils {
*/ */
fun urlEncode(url: String): String { fun urlEncode(url: String): String {
return try { return try {
URLEncoder.encode(url, Charsets.UTF_8.toString()).replace("+", "%20") URLEncoder.encode(url, Charsets.UTF_8.toString())
} catch (e: Exception) { } catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to encode URL", e) Log.e(AppConfig.TAG, "Failed to encode URL", e)
url url
} }
} }
/**
* Decode a "encodeURIComponent" string.
*
* @param url The "encodeURIComponent" string.
* @return The decoded string, or the original string if decoding fails.
*/
fun decodeURIComponent(url: String): String {
return try {
// Decode strictly according to RFC 3986 / encodeURIComponent semantics.
// '+' is a literal plus and MUST NOT be interpreted as space.
// Inputs using '+' for spaces are non-conforming and rejected deliberately
// to avoid cross-language interoperability issues.
URLDecoder.decode(url.replace("+", "%2B"), Charsets.UTF_8.toString())
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to decode encodeURIComponent", e)
url
}
}
/**
* Encode a string to "encodeURIComponent" format.
*
* @param url The string to encode.
* @return The "encodeURIComponent" encoded string, or the original string if encoding fails.
*/
fun encodeURIComponent(url: String): String {
return try {
// Replace '+' with '%20' to conform to encodeURIComponent semantics.
URLEncoder.encode(url, Charsets.UTF_8.toString()).replace("+", "%20")
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to encode encodeURIComponent", e)
url
}
}
/** /**
* Read text from an asset file. * Read text from an asset file.
* *
@@ -375,24 +410,6 @@ object Utils {
} }
} }
/**
* 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. * Get the device ID for XUDP base key.
* *
@@ -0,0 +1,62 @@
package com.v2ray.ang.viewmodel
import androidx.lifecycle.ViewModel
import com.v2ray.ang.AppConfig
import com.v2ray.ang.AppConfig.ANG_PACKAGE
import java.io.IOException
class LogcatViewModel : ViewModel() {
private val logsetsAll: MutableList<String> = mutableListOf()
private var filteredLogs: List<String> = emptyList()
private var currentFilter: String = ""
fun getAll(): List<String> = filteredLogs
fun loadLogcat() {
try {
val lst = LinkedHashSet<String>()
lst.add("logcat")
lst.add("-d")
lst.add("-v")
lst.add("time")
lst.add("-s")
lst.add("GoLog,${ANG_PACKAGE},AndroidRuntime,System.err")
val process = Runtime.getRuntime().exec(lst.toTypedArray())
val allText = process.inputStream.bufferedReader().use { it.readLines() }.reversed()
logsetsAll.clear()
logsetsAll.addAll(allText)
applyFilter()
} catch (e: IOException) {
android.util.Log.e(AppConfig.TAG, "Failed to get logcat", e)
}
}
fun clearLogcat() {
try {
val lst = LinkedHashSet<String>()
lst.add("logcat")
lst.add("-c")
val process = Runtime.getRuntime().exec(lst.toTypedArray())
process.waitFor()
logsetsAll.clear()
filteredLogs = emptyList()
} catch (e: IOException) {
android.util.Log.e(AppConfig.TAG, "Failed to clear logcat", e)
}
}
fun filter(content: String?) {
currentFilter = content?.trim() ?: ""
applyFilter()
}
private fun applyFilter() {
filteredLogs = if (currentFilter.isEmpty()) {
logsetsAll.toList()
} else {
logsetsAll.filter { it.contains(currentFilter) }
}
}
}
@@ -15,8 +15,8 @@ import com.v2ray.ang.AngApplication
import com.v2ray.ang.AppConfig import com.v2ray.ang.AppConfig
import com.v2ray.ang.R import com.v2ray.ang.R
import com.v2ray.ang.dto.GroupMapItem import com.v2ray.ang.dto.GroupMapItem
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.ServersCache import com.v2ray.ang.dto.ServersCache
import com.v2ray.ang.dto.SubscriptionCache
import com.v2ray.ang.extension.serializable import com.v2ray.ang.extension.serializable
import com.v2ray.ang.extension.toastError import com.v2ray.ang.extension.toastError
import com.v2ray.ang.extension.toastSuccess import com.v2ray.ang.extension.toastSuccess
@@ -179,7 +179,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
return AngConfigManager.updateConfigViaSubAll() return AngConfigManager.updateConfigViaSubAll()
} else { } else {
val subItem = MmkvManager.decodeSubscription(subscriptionId) ?: return 0 val subItem = MmkvManager.decodeSubscription(subscriptionId) ?: return 0
return AngConfigManager.updateConfigViaSub(Pair(subscriptionId, subItem)) return AngConfigManager.updateConfigViaSub(SubscriptionCache(subscriptionId, subItem))
} }
} }
@@ -273,7 +273,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
fun getSubscriptions(context: Context): List<GroupMapItem> { fun getSubscriptions(context: Context): List<GroupMapItem> {
val subscriptions = MmkvManager.decodeSubscriptions() val subscriptions = MmkvManager.decodeSubscriptions()
if (subscriptionId.isNotEmpty() if (subscriptionId.isNotEmpty()
&& !subscriptions.map { it.first }.contains(subscriptionId) && !subscriptions.map { it.guid }.contains(subscriptionId)
) { ) {
subscriptionIdChanged("") subscriptionIdChanged("")
} }
@@ -285,8 +285,8 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
remarks = context.getString(R.string.filter_config_all) remarks = context.getString(R.string.filter_config_all)
) )
) )
subscriptions.forEach { (id, item) -> subscriptions.forEach { sub ->
groups.add(GroupMapItem(id = id, remarks = item.remarks)) groups.add(GroupMapItem(id = sub.guid, remarks = sub.subscription.remarks))
} }
return groups return groups
} }
@@ -309,20 +309,15 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
* @return The number of removed servers. * @return The number of removed servers.
*/ */
fun removeDuplicateServer(): Int { fun removeDuplicateServer(): Int {
val serversCacheCopy = mutableListOf<Pair<String, ProfileItem>>() val serversCacheCopy = serversCache.toList().toMutableList()
for (it in serversCache) {
val config = MmkvManager.decodeServerConfig(it.guid) ?: continue
serversCacheCopy.add(Pair(it.guid, config))
}
val deleteServer = mutableListOf<String>() val deleteServer = mutableListOf<String>()
serversCacheCopy.forEachIndexed { index, it -> serversCacheCopy.forEachIndexed { index, sc ->
val outbound = it.second val profile = sc.profile
serversCacheCopy.forEachIndexed { index2, it2 -> serversCacheCopy.forEachIndexed { index2, sc2 ->
if (index2 > index) { if (index2 > index) {
val outbound2 = it2.second val profile2 = sc2.profile
if (outbound.equals(outbound2) && !deleteServer.contains(it2.first)) { if (profile == profile2 && !deleteServer.contains(sc2.guid)) {
deleteServer.add(it2.first) deleteServer.add(sc2.guid)
} }
} }
} }
@@ -472,7 +467,10 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
} }
AppConfig.MSG_MEASURE_CONFIG_FINISH -> { AppConfig.MSG_MEASURE_CONFIG_FINISH -> {
onTestsFinished() val content = intent.getStringExtra("content")
if (content == "0") {
onTestsFinished()
}
} }
} }
} }
@@ -1,16 +1,17 @@
package com.v2ray.ang.viewmodel package com.v2ray.ang.viewmodel
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import com.v2ray.ang.dto.SubscriptionCache
import com.v2ray.ang.dto.SubscriptionItem import com.v2ray.ang.dto.SubscriptionItem
import com.v2ray.ang.handler.MmkvManager import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.SettingsChangeManager import com.v2ray.ang.handler.SettingsChangeManager
import com.v2ray.ang.handler.SettingsManager import com.v2ray.ang.handler.SettingsManager
class SubscriptionsViewModel : ViewModel() { class SubscriptionsViewModel : ViewModel() {
private val subscriptions: MutableList<Pair<String, SubscriptionItem>> = private val subscriptions: MutableList<SubscriptionCache> =
MmkvManager.decodeSubscriptions().toMutableList() MmkvManager.decodeSubscriptions().toMutableList()
fun getAll(): List<Pair<String, SubscriptionItem>> = subscriptions.toList() fun getAll(): List<SubscriptionCache> = subscriptions.toList()
fun reload() { fun reload() {
subscriptions.clear() subscriptions.clear()
@@ -18,7 +19,7 @@ class SubscriptionsViewModel : ViewModel() {
} }
fun remove(subId: String): Boolean { fun remove(subId: String): Boolean {
val changed = subscriptions.removeAll { it.first == subId } val changed = subscriptions.removeAll { it.guid == subId }
if (changed) { if (changed) {
MmkvManager.removeSubscription(subId) MmkvManager.removeSubscription(subId)
SettingsChangeManager.makeSetupGroupTab() SettingsChangeManager.makeSetupGroupTab()
@@ -27,9 +28,9 @@ class SubscriptionsViewModel : ViewModel() {
} }
fun update(subId: String, item: SubscriptionItem) { fun update(subId: String, item: SubscriptionItem) {
val idx = subscriptions.indexOfFirst { it.first == subId } val idx = subscriptions.indexOfFirst { it.guid == subId }
if (idx >= 0) { if (idx >= 0) {
subscriptions[idx] = Pair(subId, item) subscriptions[idx] = SubscriptionCache(subId, item)
MmkvManager.encodeSubscription(subId, item) MmkvManager.encodeSubscription(subId, item)
} }
} }
@@ -0,0 +1,99 @@
package com.v2ray.ang.viewmodel
import android.util.Log
import androidx.lifecycle.ViewModel
import com.v2ray.ang.AppConfig
import com.v2ray.ang.dto.AssetUrlCache
import com.v2ray.ang.dto.AssetUrlItem
import com.v2ray.ang.extension.concatUrl
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.util.HttpUtil
import com.v2ray.ang.util.Utils
import java.io.File
import java.io.FileOutputStream
import java.net.HttpURLConnection
class UserAssetViewModel : ViewModel() {
private val assets = mutableListOf<AssetUrlCache>()
private val builtInGeoFiles = listOf("geosite.dat", "geoip.dat")
val itemCount: Int
get() = assets.size
fun getAssets(): List<AssetUrlCache> = assets.toList()
fun getAsset(position: Int): AssetUrlCache? = assets.getOrNull(position)
fun reload(geoFilesSource: String) {
val decoded = MmkvManager.decodeAssetUrls()
assets.clear()
assets.addAll(buildAssetList(decoded, geoFilesSource))
}
private fun buildAssetList(
decodedAssets: List<AssetUrlCache>?,
geoFilesSource: String
): List<AssetUrlCache> {
val savedAssets = decodedAssets ?: emptyList()
val builtInItems = builtInGeoFiles
.filter { geoFile -> savedAssets.none { it.assetUrl.remarks == geoFile } }
.map {
AssetUrlCache(
Utils.getUuid(),
AssetUrlItem(
it,
String.format(AppConfig.GITHUB_DOWNLOAD_URL, geoFilesSource).concatUrl(it),
locked = true
)
)
}
return builtInItems + savedAssets
}
fun downloadGeoFiles(extDir: File, httpPort: Int): GeoDownloadResult {
val snapshot = getAssets()
var successCount = 0
val failures = mutableListOf<String>()
snapshot.forEach { cache ->
val item = cache.assetUrl
val portsToTry = if (httpPort == 0) listOf(0) else listOf(httpPort, 0)
if (portsToTry.any { tryDownload(item, extDir, it) }) {
successCount++
} else {
failures.add(item.remarks)
}
}
return GeoDownloadResult(successCount, failures.size, failures)
}
private fun tryDownload(item: AssetUrlItem, extDir: File, httpPort: Int): Boolean {
val targetTemp = File(extDir, item.remarks + "_temp")
val target = File(extDir, item.remarks)
val conn = HttpUtil.createProxyConnection(item.url, httpPort, 15000, 15000, needStream = true) ?: return false
try {
val responseCode = conn.responseCode
if (responseCode == HttpURLConnection.HTTP_OK) {
conn.inputStream.use { inputStream ->
FileOutputStream(targetTemp).use { output ->
inputStream.copyTo(output)
}
}
targetTemp.renameTo(target)
return true
}
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to download geo file: ${item.remarks}", e)
} finally {
conn.disconnect()
}
return false
}
data class GeoDownloadResult(
val successCount: Int,
val failureCount: Int,
val failedAssets: List<String>
)
}
@@ -29,6 +29,7 @@
<ImageView <ImageView
android:layout_width="@dimen/image_size_dp24" android:layout_width="@dimen/image_size_dp24"
android:layout_height="@dimen/image_size_dp24" android:layout_height="@dimen/image_size_dp24"
android:importantForAccessibility="no"
app:srcCompat="@drawable/ic_source_code_24dp" /> app:srcCompat="@drawable/ic_source_code_24dp" />
<TextView <TextView
@@ -53,6 +54,7 @@
<ImageView <ImageView
android:layout_width="@dimen/image_size_dp24" android:layout_width="@dimen/image_size_dp24"
android:layout_height="@dimen/image_size_dp24" android:layout_height="@dimen/image_size_dp24"
android:importantForAccessibility="no"
app:srcCompat="@drawable/license_24px" /> app:srcCompat="@drawable/license_24px" />
<TextView <TextView
@@ -77,6 +79,7 @@
<ImageView <ImageView
android:layout_width="@dimen/image_size_dp24" android:layout_width="@dimen/image_size_dp24"
android:layout_height="@dimen/image_size_dp24" android:layout_height="@dimen/image_size_dp24"
android:importantForAccessibility="no"
app:srcCompat="@drawable/ic_feedback_24dp" /> app:srcCompat="@drawable/ic_feedback_24dp" />
<TextView <TextView
@@ -102,6 +105,7 @@
<ImageView <ImageView
android:layout_width="@dimen/image_size_dp24" android:layout_width="@dimen/image_size_dp24"
android:layout_height="@dimen/image_size_dp24" android:layout_height="@dimen/image_size_dp24"
android:importantForAccessibility="no"
app:srcCompat="@drawable/ic_telegram_24dp" /> app:srcCompat="@drawable/ic_telegram_24dp" />
<TextView <TextView
@@ -126,6 +130,7 @@
<ImageView <ImageView
android:layout_width="@dimen/image_size_dp24" android:layout_width="@dimen/image_size_dp24"
android:layout_height="@dimen/image_size_dp24" android:layout_height="@dimen/image_size_dp24"
android:importantForAccessibility="no"
app:srcCompat="@drawable/ic_privacy_24dp" /> app:srcCompat="@drawable/ic_privacy_24dp" />
<TextView <TextView
@@ -75,6 +75,7 @@
<ImageView <ImageView
android:layout_width="@dimen/image_size_dp24" android:layout_width="@dimen/image_size_dp24"
android:layout_height="@dimen/image_size_dp24" android:layout_height="@dimen/image_size_dp24"
android:importantForAccessibility="no"
app:srcCompat="@drawable/ic_about_24dp" /> app:srcCompat="@drawable/ic_about_24dp" />
</LinearLayout> </LinearLayout>
@@ -22,6 +22,7 @@
<ImageView <ImageView
android:layout_width="@dimen/image_size_dp24" android:layout_width="@dimen/image_size_dp24"
android:layout_height="@dimen/image_size_dp24" android:layout_height="@dimen/image_size_dp24"
android:importantForAccessibility="no"
app:srcCompat="@drawable/ic_source_code_24dp" /> app:srcCompat="@drawable/ic_source_code_24dp" />
<androidx.appcompat.widget.SwitchCompat <androidx.appcompat.widget.SwitchCompat
@@ -49,6 +50,7 @@
<ImageView <ImageView
android:layout_width="@dimen/image_size_dp24" android:layout_width="@dimen/image_size_dp24"
android:layout_height="@dimen/image_size_dp24" android:layout_height="@dimen/image_size_dp24"
android:importantForAccessibility="no"
app:srcCompat="@drawable/ic_check_update_24dp" /> app:srcCompat="@drawable/ic_check_update_24dp" />
<TextView <TextView
@@ -11,6 +11,7 @@
android:id="@+id/iv_qcode" android:id="@+id/iv_qcode"
android:layout_width="336dp" android:layout_width="336dp"
android:layout_height="336dp" android:layout_height="336dp"
android:contentDescription="@string/title_qr_code"
android:scaleType="fitXY" android:scaleType="fitXY"
app:srcCompat="@drawable/ic_fab_check" /> app:srcCompat="@drawable/ic_fab_check" />
</LinearLayout> </LinearLayout>
@@ -43,6 +43,7 @@
android:layout_height="@dimen/padding_spacing_dp16" android:layout_height="@dimen/padding_spacing_dp16"
android:layout_gravity="center" android:layout_gravity="center"
android:layout_marginStart="@dimen/padding_spacing_dp16" android:layout_marginStart="@dimen/padding_spacing_dp16"
android:importantForAccessibility="no"
app:srcCompat="@drawable/ic_lock_24dp" /> app:srcCompat="@drawable/ic_lock_24dp" />
</LinearLayout> </LinearLayout>
@@ -87,6 +88,7 @@
<ImageView <ImageView
android:layout_width="@dimen/image_size_dp24" android:layout_width="@dimen/image_size_dp24"
android:layout_height="@dimen/image_size_dp24" android:layout_height="@dimen/image_size_dp24"
android:importantForAccessibility="no"
app:srcCompat="@drawable/ic_edit_24dp" /> app:srcCompat="@drawable/ic_edit_24dp" />
</LinearLayout> </LinearLayout>
@@ -67,6 +67,7 @@
<ImageView <ImageView
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="@dimen/image_size_dp24" android:layout_height="@dimen/image_size_dp24"
android:importantForAccessibility="no"
app:srcCompat="@drawable/ic_share_24dp" /> app:srcCompat="@drawable/ic_share_24dp" />
</LinearLayout> </LinearLayout>
@@ -85,6 +86,7 @@
<ImageView <ImageView
android:layout_width="@dimen/image_size_dp24" android:layout_width="@dimen/image_size_dp24"
android:layout_height="@dimen/image_size_dp24" android:layout_height="@dimen/image_size_dp24"
android:importantForAccessibility="no"
app:srcCompat="@drawable/ic_edit_24dp" /> app:srcCompat="@drawable/ic_edit_24dp" />
</LinearLayout> </LinearLayout>
@@ -103,6 +105,7 @@
<ImageView <ImageView
android:layout_width="@dimen/image_size_dp24" android:layout_width="@dimen/image_size_dp24"
android:layout_height="@dimen/image_size_dp24" android:layout_height="@dimen/image_size_dp24"
android:importantForAccessibility="no"
app:srcCompat="@drawable/ic_delete_24dp" /> app:srcCompat="@drawable/ic_delete_24dp" />
</LinearLayout> </LinearLayout>
@@ -64,6 +64,7 @@
<ImageView <ImageView
android:layout_width="@dimen/image_size_dp24" android:layout_width="@dimen/image_size_dp24"
android:layout_height="@dimen/image_size_dp24" android:layout_height="@dimen/image_size_dp24"
android:importantForAccessibility="no"
app:srcCompat="@drawable/ic_edit_24dp" /> app:srcCompat="@drawable/ic_edit_24dp" />
</LinearLayout> </LinearLayout>
@@ -79,6 +80,7 @@
<ImageView <ImageView
android:layout_width="@dimen/image_size_dp24" android:layout_width="@dimen/image_size_dp24"
android:layout_height="@dimen/image_size_dp24" android:layout_height="@dimen/image_size_dp24"
android:importantForAccessibility="no"
app:srcCompat="@drawable/ic_delete_24dp" /> app:srcCompat="@drawable/ic_delete_24dp" />
</LinearLayout> </LinearLayout>
</LinearLayout> </LinearLayout>
@@ -158,6 +158,27 @@
android:entries="@array/ech_force_query_value" /> android:entries="@array/ech_force_query_value" />
</LinearLayout> </LinearLayout>
<LinearLayout
android:id="@+id/lay_pinned_ca256"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="@dimen/padding_spacing_dp16"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/server_lab_pinned_ca256" />
<EditText
android:id="@+id/et_pinned_ca256"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="text"
android:nextFocusDown="@+id/sp_ech_force_query" />
</LinearLayout>
<LinearLayout <LinearLayout
android:id="@+id/lay_public_key" android:id="@+id/lay_public_key"
android:layout_width="match_parent" android:layout_width="match_parent"

Some files were not shown because too many files have changed in this diff Show More