Compare commits

..

87 Commits

Author SHA1 Message Date
2dust 659f2ded09 up 2.1.2 2026-04-24 10:52:42 +08:00
2dust 585d64b0be Bump Android Gradle Plugin to 9.1.1
Update agp from 9.1.0 to 9.1.1 in V2rayNG/gradle/libs.versions.toml to pick up the latest patch fixes and keep the build tooling current.
2026-04-24 10:52:02 +08:00
fanymagnet 2ae2e747e2 Replace package names on UIDs in custom config (#5527)
* Replace package names on UIDs in custom config

* Update V2rayNG/app/src/main/java/com/v2ray/ang/handler/V2rayConfigManager.kt

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Apply suggestions from code review

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Fix copilot review & detect tun optimization

---------

Co-authored-by: 2dust <31833384+2dust@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-24 10:39:19 +08:00
2dust 4199cee4e5 Use OkHttp with proxy auth and support SOCKS creds
Replace HttpURLConnection-based networking with OkHttp and add proxy username/password support. Introduces buildOkHttpClient, updated getUrlContent/getUrlContentWithUserAgent signatures, and downloadToFile; callers in AngConfigManager, SpeedtestManager, UpdateCheckerManager, PerAppProxyActivity, UserAssetActivity and UserAssetViewModel now pass SOCKS proxy credentials. Removes createProxyConnection and related URL-resolve logic, clears HTTP inbound auth/udp in V2rayConfigManager, and simplifies SettingsManager port generation. These changes enable authenticated proxy requests, improved redirect handling and more robust file downloads.
2026-04-23 20:40:17 +08:00
dependabot[bot] 8be8a912c4 Bump robinraju/release-downloader from 1.12 to 1.13 (#5520)
Bumps [robinraju/release-downloader](https://github.com/robinraju/release-downloader) from 1.12 to 1.13.
- [Release notes](https://github.com/robinraju/release-downloader/releases)
- [Commits](https://github.com/robinraju/release-downloader/compare/v1.12...v1.13)

---
updated-dependencies:
- dependency-name: robinraju/release-downloader
  dependency-version: '1.13'
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-21 19:44:50 +08:00
Kapkap5454 851f22680a Add negative UID handling to process routing (#5509)
* Handle unidentified package UID resolution

Add special handling for unidentified package names.

* Add method to create special unidentified app item

Added a method to create a special unidentified app item for the app picker.

* Support unidentified app UID and label

---------

Co-authored-by: 2dust <31833384+2dust@users.noreply.github.com>
2026-04-19 19:48:59 +08:00
2dust 12abc99bfc Update hev-socks5-tunnel 2026-04-19 19:31:11 +08:00
2dust 07e50637a2 Update AndroidLibXrayLite 2026-04-19 19:30:40 +08:00
2dust c1824c7598 up 2.1.1 2026-04-18 16:15:32 +08:00
2dust 18eebf999d Update AndroidLibXrayLite 2026-04-18 16:15:02 +08:00
solokot e37d6b7523 Update & improve Russian translation (#5505) 2026-04-18 16:10:49 +08:00
2dust 8c5375ec62 Add app picker and process picker integration
Introduce an AppPickerActivity to browse and select installed apps (with search, select-all and invert selection). Add AppSelectorAdapter, activity_app_picker layout and menu_app_picker menu, and register the activity in AndroidManifest.xml. Integrate the picker into RoutingEditActivity using an ActivityResult launcher and add a btn_process_picker to the routing edit layout; selected package list is loaded into the process EditText. Minor UI adjustments include padding for the EditText and marking system apps in the list.
2026-04-18 16:10:12 +08:00
2dust 30792299e8 up 2.1.0 2026-04-17 15:16:53 +08:00
solokot e6db0f6f9e Update Russian translation (#5486) 2026-04-17 15:14:36 +08:00
2dust 04478ab490 Support custom outbound tags in routing
Allow routing rules to reference outbounds by server remarks and inject missing outbounds into the generated V2ray config. Adds BUILTIN_OUTBOUND_TAGS, a pre-pass (injectCustomOutbounds) that converts referenced non-builtin tags to outbounds, and a fallback to the proxy tag when an outbound couldn't be injected. Updates getBalance to use AppConfig.TAG_PROXY constant. Replaces the outbound-tag Spinner with an AutoCompleteTextView + dropdown button that suggests builtin tags and existing profile remarks, adds a drop-down drawable and a hint string resource.
2026-04-17 15:10:19 +08:00
2dust f371da490a Kotlin formatting, import cleanup, and minor fixes 2026-04-17 14:00:38 +08:00
2dust 0a0ef6e6b5 Guard process finder init on Android Q+ 2026-04-16 13:52:35 +08:00
2dust fd57019e73 Add conditional process routing and routing strings 2026-04-16 09:56:35 +08:00
2dust 04ac6cb2d7 Update AndroidLibXrayLite 2026-04-16 09:21:54 +08:00
2dust 29a0be9629 Revert "Make tun MTU a list and port nullable"
This reverts commit f19f153f9c.
2026-04-16 09:21:15 +08:00
2dust 7aa27fd3de Add IPv6 enable preference and strings
https://github.com/2dust/v2rayNG/issues/5454
2026-04-15 19:33:55 +08:00
2dust fcc478ff63 Add launcher shortcuts to start/stop service
https://github.com/2dust/v2rayNG/issues/5485
2026-04-15 19:04:26 +08:00
2dust 54d5ad2342 Replace android.util.Log with LogUtil
Introduce a centralized LogUtil and migrate logging calls across the app to use it. Replaced android.util.Log imports/usages in many modules (fmt, handler, helper, receiver, service, ui, util, viewmodel, tests) and added a new LogUtil.kt file. Also trigger LogUtil.refreshLogLevel() when PREF_LOGLEVEL changes in MmkvPreferenceDataStore so runtime log level updates take effect.
2026-04-15 17:20:17 +08:00
2dust ba25e05536 Add process-based routing support
Introduce process field for routing rules and wire it through UI, config parsing, and runtime resolution. Adds a PackageUidResolver utility to map package names to UIDs (with caching) and converts user-entered package names into UIDs when building v2ray routing rules. Updates V2rayConfig types, RoutingEditActivity layout/bindings/strings to accept process input, and adjusts getRouting to receive Context. Also adds extra logging and a defensive check in V2RayServiceManager when resolving connection owner UID.
2026-04-15 15:46:06 +08:00
2dust 5a5a81fa0b Update hev-socks5-tunnel 2026-04-15 10:51:09 +08:00
2dust 322c4b305e Update AndroidLibXrayLite 2026-04-15 10:51:06 +08:00
2dust 590f11e671 Add Xray ProcessFinder and register it
https://github.com/2dust/AndroidLibXrayLite/commit/18c5fef49892be1c5755dd4e6ec672399521809a
https://github.com/2dust/AndroidLibXrayLite/commit/4371ac28643114042cf2499a61d381bf3d6bee75
2026-04-14 20:55:27 +08:00
2dust 872ec80292 Bug fix
https://github.com/2dust/v2rayNG/commit/d7abb8f49e3e9292c09a6fb9d2ca2f03530a7810
2026-04-14 20:49:47 +08:00
2dust f19f153f9c Make tun MTU a list and port nullable
https://github.com/XTLS/Xray-core/pull/5887
2026-04-14 14:57:12 +08:00
2dust d7abb8f49e Add dynamic local SOCKS port option
Introduce a dynamic SOCKS port feature: add PREF_DYNAMIC_SOCKS_PORT key, settings UI checkbox and localized strings, and pref XML entry. SettingsManager now holds a runtimeSocksPort, can generate and refresh a random port (generateRandomSocksPort, refreshRuntimeSocksPort) and respects the dynamic setting when returning getSocksPort(). V2Ray service startup triggers a runtime port refresh. Settings UI disables manual SOCKS port input when dynamic mode is enabled. Minor i18n additions (QR code/title) included for several locales.
2026-04-14 14:42:31 +08:00
2dust e54e713db7 Add local SOCKS username/password support 2026-04-13 20:17:30 +08:00
Copilot de91fb27db Fix VPN icon persisting in status bar after stopping service (#5476)
* Initial plan

* Fix VPN icon remaining in status bar after stopping service

Agent-Logs-Url: https://github.com/2dust/v2rayNG/sessions/4377b941-8f02-4d92-abc6-0f98fd6c9b60

Co-authored-by: 2dust <31833384+2dust@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: 2dust <31833384+2dust@users.noreply.github.com>
2026-04-13 19:35:55 +08:00
dependabot[bot] db8f46eae5 Bump android-actions/setup-android from 4.0.0 to 4.0.1 (#5456)
Bumps [android-actions/setup-android](https://github.com/android-actions/setup-android) from 4.0.0 to 4.0.1.
- [Release notes](https://github.com/android-actions/setup-android/releases)
- [Commits](https://github.com/android-actions/setup-android/compare/v4.0.0...v4.0.1)

---
updated-dependencies:
- dependency-name: android-actions/setup-android
  dependency-version: 4.0.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-11 19:27:05 +08:00
2dust 42e7f95e4f up 2.0.18 2026-04-03 09:19:22 +08:00
DHR60 0a52a79fa0 Support interval range (#5440) 2026-04-02 09:33:07 +08:00
DHR60 9dc1b1f2cf Support new fragment (#5437)
* Support new fragment

* Fix
2026-04-02 09:30:31 +08:00
DHR60 b61cad5a5f Support new hysteria2 stream settings (#5435)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-01 20:59:32 +08:00
2dust 66cd746ad2 Add server_lab_final_mask to translations 2026-04-01 10:18:59 +08:00
owo ae4a92b66b add finalmask into share link and support import manually (#5429)
* add finalmask into share link and support import manually

* remove temp test code

* Update V2rayNG/app/src/main/res/values/strings.xml

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update V2rayNG/app/src/main/java/com/v2ray/ang/ui/ServerActivity.kt

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update V2rayNG/app/src/main/java/com/v2ray/ang/fmt/FmtBase.kt

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Make finalmask Any and parse as raw string

---------

Co-authored-by: 2dust <31833384+2dust@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-01 10:12:50 +08:00
solokot 55c0ccf1b0 Update Russian translation (#5430) 2026-03-31 19:32:45 +08:00
2dust deedd26fac Respect append flag in custom config parsing
https://github.com/2dust/v2rayNG/pull/5424
2026-03-29 19:33:09 +08:00
owo e6c8307b96 remove old servers when update subscription (#5424)
* remove old servers when update subscription

* Supplementary Submission: Multiple Configuration Scenarios
2026-03-29 17:47:37 +08:00
2dust d6d5753283 Move locate action from FAB to menu
https://github.com/2dust/v2rayNG/issues/5423
2026-03-29 17:32:59 +08:00
2dust 014d510774 Disable SwipeRefreshLayout in GroupServerFragment
https://github.com/2dust/v2rayNG/issues/5417
2026-03-29 17:23:23 +08:00
2dust 42f89e69a7 up 2.0.17 2026-03-28 11:32:09 +08:00
2dust d5a0997775 Update AndroidLibXrayLite 2026-03-28 11:29:51 +08:00
owo af7db6f86b fix fab shadow issue (#5413) (#5416) 2026-03-27 09:22:47 +08:00
dependabot[bot] 7b7f43e12e Bump android-actions/setup-android from 3.2.0 to 4.0.0 (#5411)
Bumps [android-actions/setup-android](https://github.com/android-actions/setup-android) from 3.2.0 to 4.0.0.
- [Release notes](https://github.com/android-actions/setup-android/releases)
- [Commits](https://github.com/android-actions/setup-android/compare/v3.2.0...v4.0.0)

---
updated-dependencies:
- dependency-name: android-actions/setup-android
  dependency-version: 4.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-27 09:22:38 +08:00
2dust 8dd1df1451 up 2.0.16 2026-03-23 20:52:45 +08:00
2dust d4a73493b5 Update AndroidLibXrayLite 2026-03-23 20:51:04 +08:00
2dust 9fce03d0ed Increase swipe refresh trigger distance to 160dp
https://github.com/2dust/v2rayNG/issues/5367
2026-03-21 14:38:34 +08:00
owo a12ca3ea04 Feature/regex and protocol search (#5364)
* add windows compile options

* add regex search and search from protocol function(#5354)

* Delete compile-hevtun.bat

* Delete fix_headers.bat

* Extract matchesPattern to extension

---------

Co-authored-by: 2dust <31833384+2dust@users.noreply.github.com>
2026-03-21 14:22:10 +08:00
solokot 4e9df84d3e Update Russian translation (#5374) 2026-03-21 13:56:58 +08:00
2dust d3118b53f2 up 2.0.15 2026-03-13 17:08:02 +08:00
2dust cd45b51487 Add locate FAB and scroll-to-selected server
https://github.com/2dust/v2rayNG/issues/5165
2026-03-13 15:02:08 +08:00
2dust 6aa26d2621 Fix
https://github.com/2dust/v2rayNG/issues/5361
2026-03-13 10:49:30 +08:00
2dust 0b452707c3 Pull down to update subscription 2026-03-12 19:23:52 +08:00
2dust 303b642ade Refactor subscription import & matching logic 2026-03-12 18:03:21 +08:00
2dust 80eec036dd up 2.0.14 2026-03-10 19:22:50 +08:00
2dust f9a85366ea Update libs.versions.toml 2026-03-10 17:55:48 +08:00
2dust 378c891399 Update submodule commit SHAs 2026-03-09 20:37:30 +08:00
2dust d04b5eee37 Add detailed logging and validations to services 2026-03-09 20:11:53 +08:00
2dust 405cd7f55e Ensure default subscription on removals
Add removeSubscriptionWithDefault in SettingsManager to remove a subscription and recreate a default subscription if none remain. Modify MmkvManager.decodeAllServerList to include servers from DEFAULT_SUBSCRIPTION_ID when it's not listed, and remove the defensive check in MmkvManager.removeSubscription so removals are delegated to SettingsManager. Update callers (SubEditActivity, SubscriptionsViewModel) to use SettingsManager.removeSubscriptionWithDefault, remove UI restrictions hiding the delete action for the default subscription, and simplify group-display logic in MainViewModel. These changes ensure a default subscription always exists and server lists include default servers when appropriate.
2026-03-09 19:38:29 +08:00
2dust 850096789b When geoip:cn and geoip:private appear in the routing rules, load geoip-only-cn-private.dat to reduce memory usage. 2026-03-09 16:52:57 +08:00
2dust 3d42ac9dba Add logging and safety checks in boot and VPN service
https://github.com/2dust/v2rayNG/issues/5346
2026-03-08 16:11:33 +08:00
2dust d0265265f3 up 2.0.13 2026-03-03 16:11:10 +08:00
2dust 2d6dd33a7b Fix real ping test
https://github.com/2dust/v2rayNG/issues/5331
2026-03-01 17:19:42 +08:00
2dust 3a32373abe up 2.0.12 2026-02-28 15:10:40 +08:00
Hossein Abaspanah befb2937d1 Update Luri Bakhtiari translation (#5330) 2026-02-28 13:59:14 +08:00
solokot 0a375bec67 Update Russian translation (#5328) 2026-02-28 13:59:02 +08:00
2dust 029f8b67e7 Remove legacy profileStorage and migration code 2026-02-28 11:34:03 +08:00
2dust 7475d1757b Add detailed subscription update result 2026-02-27 17:44:40 +08:00
dependabot[bot] 5a16614a7b Bump actions/upload-artifact from 6 to 7 (#5324)
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 6 to 7.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-27 14:18:55 +08:00
solokot edb06b466a Update Russian translation (#5315) 2026-02-26 14:56:53 +08:00
Hossein Abaspanah 30b5391895 Update strings.xml (#5312) 2026-02-26 14:56:41 +08:00
2dust 6cd04b831f Improve search
https://github.com/2dust/v2rayNG/issues/5313
2026-02-24 16:55:16 +08:00
2dust 1e616198f0 up 2.0.11 2026-02-24 12:31:29 +08:00
2dust a710146e4f Use subscriptionId to fetch servers for real ping testing 2026-02-24 12:30:08 +08:00
2dust 9cd530d223 Batch parse and save subscription configs 2026-02-24 12:15:03 +08:00
2dust fc89f25591 Add 'Show All Groups' preference and handling 2026-02-24 10:38:28 +08:00
2dust 84ab54fea2 Migrate server storage to subscription lists
Introduce subscription-based server storage and migration from legacy KEY_ANG_CONFIGS. Added DEFAULT_SUBSCRIPTION_ID and new MmkvManager APIs to encode/decode server lists per subscription, decodeAllServerList, and readLegacyServerList; included helper getSubscriptionId and protection against deleting the default subscription. Refactored SettingsManager into initApp to ensure defaults, create a default subscription, initialize routing rulesets and perform server-list migration and other migrations. Updated callers (V2rayConfigManager, BackupActivity, GroupServerFragment, SubEditActivity, SubSettingRecyclerAdapter, TaskerActivity, MainViewModel) to use the new APIs and to handle subscriptionId where appropriate (including UI changes to hide delete for default subscription). Preserved legacy logic as commented references and added migration markers to avoid repeated migrations. Overall change enables grouping servers by subscription and provides a migration path for existing installations.
2026-02-23 20:58:01 +08:00
2dust d642d09844 Revert "Migrate servers to subscription-based storage"
This reverts commit 09461663cb.
2026-02-23 19:59:43 +08:00
2dust 6ae46c50e2 Revert "Bug fix for sort"
This reverts commit fe4b451988.
2026-02-23 19:59:29 +08:00
2dust fe4b451988 Bug fix for sort 2026-02-23 16:06:20 +08:00
AmirMohammad Yazdanmanesh 5c9b634039 Improve accessibility: add contentDescription to clickable action containers (#5286)
- Add contentDescription to action button containers (share, edit,
  delete) in subscription, routing, and user asset recycler items
- Add contentDescription to bypass mode info button and connection
  test area in main activity
- Mark 4 decorative icons in backup screen as not important for
  accessibility
2026-02-23 15:07:22 +08:00
2dust 09461663cb Migrate servers to subscription-based storage
Move server list storage into SubscriptionItem.serverList and add a default subscription for ungrouped servers. Introduce AppConfig.DEFAULT_SUBSCRIPTION_ID and update MmkvManager to read legacy data, encode/decode server lists by subscription, decodeAllServerList(), and protect default subscription from deletion. Add one-time migration logic and ensureDefaultSubscription() in SettingsManager, invoke migration during app startup in AngApplication, and update call sites (MainViewModel, V2rayConfigManager, TaskerActivity, etc.) to use the new subscription-aware APIs. Preserve legacy code paths as comments and mark migration completion with a settings flag.
2026-02-23 14:41:53 +08:00
2dust 50c1145815 Organize imports across Kotlin files 2026-02-23 10:49:50 +08:00
119 changed files with 2849 additions and 922 deletions
+5 -5
View File
@@ -22,7 +22,7 @@ jobs:
fetch-depth: '0'
- name: Setup Android SDK
uses: android-actions/setup-android@v3.2.0
uses: android-actions/setup-android@v4.0.1
with:
log-accepted-android-sdk-licenses: false
cmdline-tools-version: '13114758'
@@ -70,7 +70,7 @@ jobs:
popd
- name: Download libv2ray
uses: robinraju/release-downloader@v1.12
uses: robinraju/release-downloader@v1.13
with:
repository: '2dust/AndroidLibXrayLite'
tag: ${{ env.CURRENT_TAG }}
@@ -99,21 +99,21 @@ jobs:
./gradlew assembleRelease -Pandroid.injected.signing.store.file=${{ steps.android_keystore.outputs.filePath }} -Pandroid.injected.signing.store.password=${{ secrets.APP_KEYSTORE_PASSWORD }} -Pandroid.injected.signing.key.alias=${{ secrets.APP_KEYSTORE_ALIAS }} -Pandroid.injected.signing.key.password=${{ secrets.APP_KEY_PASSWORD }}
- name: Upload arm64-v8a APK
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
if: ${{ success() }}
with:
name: arm64-v8a
path: ${{ github.workspace }}/V2rayNG/app/build/outputs/apk/*/release/*arm64-v8a*.apk
- name: Upload armeabi-v7a APK
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
if: ${{ success() }}
with:
name: armeabi-v7a
path: ${{ github.workspace }}/V2rayNG/app/build/outputs/apk/*/release/*armeabi-v7a*.apk
- name: Upload x86 APK
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
if: ${{ success() }}
with:
name: x86-apk
+2 -2
View File
@@ -12,8 +12,8 @@ android {
applicationId = "com.v2ray.ang"
minSdk = 24
targetSdk = 36
versionCode = 710
versionName = "2.0.10"
versionCode = 722
versionName = "2.1.2"
multiDexEnabled = true
val abiFilterList = (properties["ABI_FILTERS"] as? String)?.split(';')
@@ -30,4 +30,34 @@
android:targetPackage="com.v2ray.ang.fdroid" />
<categories android:name="android.shortcut.conversation" />
</shortcut>
<shortcut
android:enabled="true"
android:icon="@drawable/ic_qu_start_24dp"
android:shortcutDisabledMessage="@string/toast_services_start"
android:shortcutId="shortcuts_start"
android:shortcutLongLabel="@string/toast_services_start"
android:shortcutShortLabel="@string/toast_services_start">
<!--suppress AndroidDomInspection -->
<intent
android:action="android.intent.action.VIEW"
android:targetClass="com.v2ray.ang.ui.ScStartActivity"
android:targetPackage="com.v2ray.ang.fdroid" />
<categories android:name="android.shortcut.conversation" />
</shortcut>
<shortcut
android:enabled="true"
android:icon="@drawable/ic_qu_stop_24dp"
android:shortcutDisabledMessage="@string/toast_services_stop"
android:shortcutId="shortcuts_stop"
android:shortcutLongLabel="@string/toast_services_stop"
android:shortcutShortLabel="@string/toast_services_stop">
<!--suppress AndroidDomInspection -->
<intent
android:action="android.intent.action.VIEW"
android:targetClass="com.v2ray.ang.ui.ScStopActivity"
android:targetPackage="com.v2ray.ang.fdroid" />
<categories android:name="android.shortcut.conversation" />
</shortcut>
</shortcuts>
+15
View File
@@ -88,6 +88,9 @@
<activity
android:name=".ui.PerAppProxyActivity"
android:exported="false" />
<activity
android:name=".ui.AppPickerActivity"
android:exported="false" />
<activity
android:name=".ui.ScannerActivity"
android:exported="false" />
@@ -122,6 +125,18 @@
android:exported="false"
android:process=":RunSoLibV2RayDaemon"
android:theme="@style/AppThemeDayNight.NoActionBar.Translucent" />
<activity
android:name=".ui.ScStartActivity"
android:excludeFromRecents="true"
android:exported="false"
android:process=":RunSoLibV2RayDaemon"
android:theme="@style/AppThemeDayNight.NoActionBar.Translucent" />
<activity
android:name=".ui.ScStopActivity"
android:excludeFromRecents="true"
android:exported="false"
android:process=":RunSoLibV2RayDaemon"
android:theme="@style/AppThemeDayNight.NoActionBar.Translucent" />
<activity
android:name=".ui.UrlSchemeActivity"
@@ -36,7 +36,6 @@
},
{
"tag": "tun",
"port": 0,
"protocol": "tun",
"settings": {
"name": "xray0",
@@ -34,14 +34,12 @@ class AngApplication : MultiDexApplication() {
MMKV.initialize(this)
// Ensure critical preference defaults are present in MMKV early
SettingsManager.ensureDefaultSettings()
SettingsManager.setNightMode()
// Initialize WorkManager with the custom configuration
WorkManager.initialize(this, workManagerConfiguration)
SettingsManager.initRoutingRulesets(this)
SettingsManager.migrateHysteria2PinSHA256()
// Ensure critical preference defaults are present in MMKV early
SettingsManager.initApp(this)
SettingsManager.setNightMode()
es.dmoral.toasty.Toasty.Config.getInstance()
.setGravity(android.view.Gravity.BOTTOM, 0, 300)
@@ -10,12 +10,15 @@ object AppConfig {
/** Directory names used in the app's file system. */
const val DIR_ASSETS = "assets"
const val WEBDAV_BACKUP_DIR = "backups"
const val WEBDAV_BACKUP_FILE_NAME = "backup_ng.zip"
const val WEBDAV_BACKUP_DIR = "backups"
const val WEBDAV_BACKUP_FILE_NAME = "backup_ng.zip"
/** Legacy configuration keys. */
const val ANG_CONFIG = "ang_config"
// Default subscription ID for ungrouped servers
const val DEFAULT_SUBSCRIPTION_ID = "__default_subscription__"
/** Preferences mapped to MMKV storage. */
const val PREF_SNIFFING_ENABLED = "pref_sniffing_enabled"
const val PREF_ROUTE_ONLY_ENABLED = "pref_route_only_enabled"
@@ -48,12 +51,17 @@ object AppConfig {
const val PREF_CONFIRM_REMOVE = "pref_confirm_remove"
const val PREF_START_SCAN_IMMEDIATE = "pref_start_scan_immediate"
const val PREF_DOUBLE_COLUMN_DISPLAY = "pref_double_column_display"
const val PREF_GROUP_ALL_DISPLAY = "pref_group_all_display"
const val PREF_LANGUAGE = "pref_language"
const val PREF_UI_MODE_NIGHT = "pref_ui_mode_night"
const val PREF_IPV6_ENABLED = "pref_ipv6_enabled"
const val PREF_PREFER_IPV6 = "pref_prefer_ipv6"
const val PREF_PROXY_SHARING = "pref_proxy_sharing_enabled"
const val PREF_ALLOW_INSECURE = "pref_allow_insecure"
const val PREF_SOCKS_PORT = "pref_socks_port"
const val PREF_DYNAMIC_SOCKS_PORT = "pref_dynamic_socks_port"
const val PREF_SOCKS_USERNAME = "pref_socks_username"
const val PREF_SOCKS_PASSWORD = "pref_socks_password"
const val PREF_REMOTE_DNS = "pref_remote_dns"
const val PREF_DOMESTIC_DNS = "pref_domestic_dns"
const val PREF_DNS_HOSTS = "pref_dns_hosts"
@@ -73,7 +81,6 @@ object AppConfig {
/** Cache keys. */
const val CACHE_SUBSCRIPTION_ID = "cache_subscription_id"
const val CACHE_KEYWORD_FILTER = "cache_keyword_filter"
/** Protocol identifiers. */
const val PROTOCOL_FREEDOM = "freedom"
@@ -117,7 +124,8 @@ object AppConfig {
const val TG_CHANNEL_URL = "https://t.me/github_2dust"
const val DELAY_TEST_URL = "https://www.gstatic.com/generate_204"
const val DELAY_TEST_URL2 = "https://www.google.com/generate_204"
// const val IP_API_URL = "https://speed.cloudflare.com/meta"
// const val IP_API_URL = "https://speed.cloudflare.com/meta"
const val IP_API_URL = "https://api.ip.sb/geoip"
/** DNS server addresses. */
@@ -129,6 +137,12 @@ object AppConfig {
const val GEOIP_PRIVATE = "geoip:private"
const val GEOIP_CN = "geoip:cn"
/** Geo data file names. */
const val GEOSITE_DAT = "geosite.dat"
const val GEOIP_DAT = "geoip.dat"
const val GEOIP_ONLY_CN_PRIVATE_DAT = "geoip-only-cn-private.dat"
const val GEOIP_ONLY_CN_PRIVATE_URL = "$GITHUB_RAW_URL/Loyalsoldier/geoip/release/$GEOIP_ONLY_CN_PRIVATE_DAT"
/** Ports and addresses for various services. */
const val PORT_LOCAL_DNS = "10853"
const val PORT_SOCKS = "10808"
@@ -205,6 +219,8 @@ object AppConfig {
const val REALITY = "reality"
const val HEADER_TYPE_HTTP = "http"
const val UNIDENTIFIED_PACKAGE = "__unknown_app__"
val DNS_ALIDNS_ADDRESSES = arrayListOf("223.5.5.5", "223.6.6.6", "2400:3200::1", "2400:3200:baba::1")
val DNS_CLOUDFLARE_ONE_ADDRESSES = arrayListOf("1.1.1.1", "1.0.0.1", "2606:4700:4700::1111", "2606:4700:4700::1001")
val DNS_CLOUDFLARE_DNS_COM_ADDRESSES = arrayListOf("104.16.132.229", "104.16.133.229", "2606:4700::6810:84e5", "2606:4700::6810:85e5")
@@ -265,4 +281,9 @@ object AppConfig {
"Chocolate4U/Iran-v2ray-rules"
)
val BUILTIN_OUTBOUND_TAGS = setOf(
TAG_PROXY,
TAG_DIRECT,
TAG_BLOCKED,
)
}
@@ -2,7 +2,7 @@ package com.v2ray.ang.contracts
import com.v2ray.ang.dto.ProfileItem
interface MainAdapterListener :BaseAdapterListener {
interface MainAdapterListener : BaseAdapterListener {
fun onEdit(guid: String, position: Int, profile: ProfileItem)
@@ -11,7 +11,7 @@ interface Tun2SocksControl {
* This initializes the VPN tunnel and connects it to the SOCKS proxy.
*/
fun startTun2Socks()
/**
* Stops the tun2socks process and cleans up resources.
*/
@@ -36,7 +36,7 @@ data class ProfileItem(
var authority: String? = null,
var xhttpMode: String? = null,
var xhttpExtra: String? = null,
var finalMask: String? = null,
var security: String? = null,
var sni: String? = null,
var alpn: String? = null,
@@ -4,6 +4,7 @@ data class RulesetItem(
var remarks: String? = "",
var ip: List<String>? = null,
var domain: List<String>? = null,
var process: List<String>? = null,
var outboundTag: String = "",
var port: String? = null,
var network: String? = null,
@@ -0,0 +1,24 @@
package com.v2ray.ang.dto
/**
* Result of subscription update operation
*/
data class SubscriptionUpdateResult(
val configCount: Int = 0, // Total configs updated
val successCount: Int = 0, // Subscriptions updated successfully
val failureCount: Int = 0, // Subscriptions failed to update
val skipCount: Int = 0 // Subscriptions skipped (disabled)
) {
/**
* Combine two results by adding their counts
*/
operator fun plus(other: SubscriptionUpdateResult): SubscriptionUpdateResult {
return SubscriptionUpdateResult(
configCount = this.configCount + other.configCount,
successCount = this.successCount + other.successCount,
failureCount = this.failureCount + other.failureCount,
skipCount = this.skipCount + other.skipCount
)
}
}
@@ -0,0 +1,10 @@
package com.v2ray.ang.dto
import java.io.Serializable
data class TestServiceMessage(
val key: Int,
val subscriptionId: String = "",
val serverGuids: List<String> = emptyList()
) : Serializable
@@ -31,7 +31,7 @@ data class V2rayConfig(
data class InboundBean(
var tag: String,
var port: Int,
var port: Int?,
var protocol: String,
var listen: String? = null,
var settings: InSettingsBean? = null,
@@ -44,10 +44,16 @@ data class V2rayConfig(
var auth: String? = null,
var udp: Boolean? = null,
var userLevel: Int? = null,
var accounts: List<SocksAccountBean>? = null,
var name: String? = null,
@SerializedName("MTU")
var mtu: Int? = null
)
) {
data class SocksAccountBean(
var user: String = "",
var pass: String = ""
)
}
data class SniffingBean(
var enabled: Boolean,
@@ -68,8 +74,6 @@ data class V2rayConfig(
) {
data class OutSettingsBean(
var vnext: List<VnextBean>? = null,
var fragment: FragmentBean? = null,
var noises: List<NoiseBean>? = null,
var servers: List<ServersBean>? = null,
/*Blackhole*/
var response: Response? = null,
@@ -108,18 +112,6 @@ data class V2rayConfig(
)
}
data class FragmentBean(
var packets: String? = null,
var length: String? = null,
var interval: String? = null
)
data class NoiseBean(
var type: String? = null,
var packet: String? = null,
var delay: String? = null
)
data class ServersBean(
var address: String = "",
var method: String? = null,
@@ -162,7 +154,7 @@ data class V2rayConfig(
var realitySettings: TlsSettingsBean? = null,
var grpcSettings: GrpcSettingsBean? = null,
var hysteriaSettings: HysteriaSettingsBean? = null,
var finalmask: FinalMaskBean? = null,
var finalmask: Any? = null,
val dsSettings: Any? = null,
var sockopt: SockoptBean? = null
) {
@@ -292,28 +284,51 @@ data class V2rayConfig(
data class HysteriaSettingsBean(
var version: Int,
var auth: String? = null,
var up: String? = null,
var down: String? = null,
var udphop: HysteriaUdpHopBean? = null
) {
data class HysteriaUdpHopBean(
var port: String? = null,
var interval: Int? = null
)
}
var auth: String? = null
)
//https://xtls.github.io/config/transport.html#finalmaskobject
data class FinalMaskBean(
var tcp: List<MaskBean>? = null,
var udp: List<MaskBean>? = null
var udp: List<MaskBean>? = null,
var quicParams: QuicParamsBean? = null
) {
data class MaskBean(
var type: String,
var settings: MaskSettingsBean? = null
) {
data class MaskSettingsBean(
var password: String? = null,
var domain: String? = null
val password: String? = null,
val domain: String? = null,
// fragment
val packets: String? = null,
val length: String? = null,
val delay: String? = null,
// val maxSplit: String? = null,
// noise
val reset: Int? = null,
val noise: List<NoiseMaskBean>? = null
) {
data class NoiseMaskBean(
val rand: String? = null,
// val randRange: String? = null,
// val type: String? = null,
// val packet: String? = null,
val delay: String? = null,
)
}
}
data class QuicParamsBean(
var congestion: String? = null,
var brutalUp: String? = null,
var brutalDown: String? = null,
var udpHop: UdpHopBean? = null,
) {
// Nested data class for the udpHop JSON object
data class UdpHopBean(
var ports: String? = null,
var interval: String? = null
)
}
}
@@ -409,8 +424,9 @@ data class V2rayConfig(
data class RulesBean(
var type: String = "field",
var ip: ArrayList<String>? = null,
var domain: ArrayList<String>? = null,
var ip: List<String>? = null,
var domain: List<String>? = null,
var process: List<String>? = null,
var outboundTag: String? = null,
var balancerTag: String? = null,
var port: String? = null,
@@ -15,7 +15,7 @@ enum class EConfigType(val value: Int, val protocolScheme: String) {
HYSTERIA2(9, AppConfig.HYSTERIA2),
HYSTERIA(900, AppConfig.HYSTERIA),
HTTP(10, AppConfig.HTTP),
POLICYGROUP (101, AppConfig.CUSTOM);
POLICYGROUP(101, AppConfig.CUSTOM);
companion object {
fun fromInt(value: Int) = entries.firstOrNull { it.value == value }
@@ -189,4 +189,15 @@ fun String.concatUrl(vararg paths: String): String {
}
return builder.toString()
}
/**
* Helper function to match text either by Regex or literal string.
*/
fun String.matchesPattern(regex: Regex?, keyword: String?, ignoreCase: Boolean = true): Boolean {
if (keyword.isNullOrEmpty()) {
return true
}
return regex?.containsMatchIn(this)
?: this.contains(keyword, ignoreCase = ignoreCase)
}
@@ -1,8 +1,8 @@
package com.v2ray.ang.fmt
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.V2rayConfig
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.util.JsonUtil
object CustomFmt : FmtBase() {
@@ -12,7 +12,7 @@ object CustomFmt : FmtBase() {
* @param str the JSON string to parse
* @return the parsed ProfileItem object, or null if parsing fails
*/
fun parse(str: String): ProfileItem? {
fun parse(str: String): ProfileItem {
val config = ProfileItem.create(EConfigType.CUSTOM)
val fullConfig = JsonUtil.fromJson(str, V2rayConfig::class.java)
@@ -1,8 +1,8 @@
package com.v2ray.ang.fmt
import com.v2ray.ang.AppConfig
import com.v2ray.ang.enums.NetworkType
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.enums.NetworkType
import com.v2ray.ang.extension.nullIfBlank
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.util.HttpUtil
@@ -67,6 +67,7 @@ open class FmtBase {
config.authority = queryParam["authority"]
config.xhttpMode = queryParam["mode"]
config.xhttpExtra = queryParam["extra"]
config.finalMask = queryParam["fm"]
config.security = queryParam["security"]
if (config.security != AppConfig.TLS && config.security != AppConfig.REALITY) {
@@ -110,6 +111,7 @@ open class FmtBase {
config.spiderX?.nullIfBlank()?.let { dicQuery["spx"] = it }
config.mldsa65Verify?.nullIfBlank()?.let { dicQuery["pqv"] = it }
config.flow?.nullIfBlank()?.let { dicQuery["flow"] = it }
config.finalMask?.nullIfBlank()?.let { dicQuery["fm"] = it }
// Add two keys for compatibility: "insecure" and "allowInsecure"
if (config.security == AppConfig.TLS) {
val insecureFlag = if (config.insecure == true) "1" else "0"
@@ -1,8 +1,8 @@
package com.v2ray.ang.fmt
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.V2rayConfig.OutboundBean
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.extension.isNotNullEmpty
import com.v2ray.ang.handler.V2rayConfigManager
@@ -1,11 +1,10 @@
package com.v2ray.ang.fmt
import com.v2ray.ang.AppConfig
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.enums.NetworkType
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.V2rayConfig.OutboundBean
import com.v2ray.ang.dto.V2rayConfig.OutboundBean.StreamSettingsBean.FinalMaskBean
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.enums.NetworkType
import com.v2ray.ang.extension.idnHost
import com.v2ray.ang.extension.isNotNullEmpty
import com.v2ray.ang.extension.nullIfBlank
@@ -21,7 +20,7 @@ object Hysteria2Fmt : FmtBase() {
* @param str the Hysteria2 URI string to parse
* @return the parsed ProfileItem object, or null if parsing fails
*/
fun parse(str: String): ProfileItem? {
fun parse(str: String): ProfileItem {
var allowInsecure = MmkvManager.decodeSettingsBool(AppConfig.PREF_ALLOW_INSECURE, false)
val config = ProfileItem.create(EConfigType.HYSTERIA2)
@@ -73,7 +72,37 @@ object Hysteria2Fmt : FmtBase() {
dicQuery["mport"] = config.portHopping.orEmpty()
}
if (config.portHoppingInterval.isNotNullEmpty()) {
dicQuery["mportHopInt"] = config.portHoppingInterval.orEmpty()
val rawInterval = config.portHoppingInterval?.trim().nullIfBlank()
val interval = if (rawInterval == null) {
null
} else {
val singleValue = rawInterval.toIntOrNull()
if (singleValue != null) {
if (singleValue < 5) {
null
} else {
rawInterval
}
} else {
val parts = rawInterval.split('-')
if (parts.size == 2) {
val start = parts[0].trim().toIntOrNull()
val end = parts[1].trim().toIntOrNull()
if (start != null && end != null) {
val minStart = maxOf(5, start)
val minEnd = maxOf(minStart, end)
(minStart + minEnd) / 2
} else {
null
}
} else {
null
}
}
}
if (interval != null) {
dicQuery["mportHopInt"] = interval.toString()
}
}
if (config.pinnedCA256.isNotNullEmpty()) {
dicQuery["pinSHA256"] = config.pinnedCA256.orEmpty()
@@ -107,18 +136,6 @@ object Hysteria2Fmt : FmtBase() {
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
}
}
@@ -1,13 +1,13 @@
package com.v2ray.ang.fmt
import android.util.Log
import com.v2ray.ang.AppConfig
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.enums.NetworkType
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.V2rayConfig.OutboundBean
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.enums.NetworkType
import com.v2ray.ang.extension.idnHost
import com.v2ray.ang.handler.V2rayConfigManager
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.Utils
import java.net.URI
@@ -85,7 +85,7 @@ object ShadowsocksFmt : FmtBase() {
config.remarks =
Utils.decodeURIComponent(result.substring(indexSplit + 1, result.length))
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to decode remarks in SS legacy URL", e)
LogUtil.e(AppConfig.TAG, "Failed to decode remarks in SS legacy URL", e)
}
result = result.substring(0, indexSplit)
@@ -1,8 +1,8 @@
package com.v2ray.ang.fmt
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.V2rayConfig.OutboundBean
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.extension.idnHost
import com.v2ray.ang.extension.isNotNullEmpty
import com.v2ray.ang.handler.V2rayConfigManager
@@ -1,10 +1,10 @@
package com.v2ray.ang.fmt
import com.v2ray.ang.AppConfig
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.enums.NetworkType
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.V2rayConfig.OutboundBean
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.enums.NetworkType
import com.v2ray.ang.extension.idnHost
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.V2rayConfigManager
@@ -18,7 +18,7 @@ object TrojanFmt : FmtBase() {
* @param str the Trojan URI string to parse
* @return the parsed ProfileItem object, or null if parsing fails
*/
fun parse(str: String): ProfileItem? {
fun parse(str: String): ProfileItem {
var allowInsecure = MmkvManager.decodeSettingsBool(AppConfig.PREF_ALLOW_INSECURE, false)
val config = ProfileItem.create(EConfigType.TROJAN)
@@ -1,9 +1,9 @@
package com.v2ray.ang.fmt
import com.v2ray.ang.AppConfig
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.V2rayConfig.OutboundBean
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.extension.idnHost
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.V2rayConfigManager
@@ -1,18 +1,18 @@
package com.v2ray.ang.fmt
import android.text.TextUtils
import android.util.Log
import com.v2ray.ang.AppConfig
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.enums.NetworkType
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.V2rayConfig.OutboundBean
import com.v2ray.ang.dto.VmessQRCode
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.enums.NetworkType
import com.v2ray.ang.extension.idnHost
import com.v2ray.ang.extension.nullIfBlank
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.V2rayConfigManager
import com.v2ray.ang.util.JsonUtil
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.Utils
import java.net.URI
@@ -34,7 +34,7 @@ object VmessFmt : FmtBase() {
var result = str.replace(EConfigType.VMESS.protocolScheme, "")
result = Utils.decode(result)
if (TextUtils.isEmpty(result)) {
Log.w(AppConfig.TAG, "Toast decoding failed")
LogUtil.w(AppConfig.TAG, "Toast decoding failed")
return null
}
val vmessQRCode = JsonUtil.fromJson(result, VmessQRCode::class.java) ?: return null
@@ -44,7 +44,7 @@ object VmessFmt : FmtBase() {
|| TextUtils.isEmpty(vmessQRCode.id)
|| TextUtils.isEmpty(vmessQRCode.net)
) {
Log.w(AppConfig.TAG, "Toast incorrect protocol")
LogUtil.w(AppConfig.TAG, "Toast incorrect protocol")
return null
}
@@ -2,9 +2,9 @@ package com.v2ray.ang.fmt
import com.v2ray.ang.AppConfig
import com.v2ray.ang.AppConfig.WIREGUARD_LOCAL_ADDRESS_V4
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.V2rayConfig.OutboundBean
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.extension.idnHost
import com.v2ray.ang.extension.nullIfBlank
import com.v2ray.ang.extension.removeWhiteSpace
@@ -46,7 +46,7 @@ object WireguardFmt : FmtBase() {
* @param str the Wireguard configuration file string to parse
* @return the parsed ProfileItem object, or null if parsing fails
*/
fun parseWireguardConfFile(str: String): ProfileItem? {
fun parseWireguardConfFile(str: String): ProfileItem {
val config = ProfileItem.create(EConfigType.WIREGUARD)
val interfaceParams: MutableMap<String, String> = mutableMapOf()
@@ -3,14 +3,15 @@ package com.v2ray.ang.handler
import android.content.Context
import android.graphics.Bitmap
import android.text.TextUtils
import android.util.Log
import com.v2ray.ang.AppConfig
import com.v2ray.ang.AppConfig.HY2
import com.v2ray.ang.R
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.SubscriptionCache
import com.v2ray.ang.dto.SubscriptionItem
import com.v2ray.ang.dto.SubscriptionUpdateResult
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.extension.isNotNullEmpty
import com.v2ray.ang.fmt.CustomFmt
import com.v2ray.ang.fmt.Hysteria2Fmt
import com.v2ray.ang.fmt.ShadowsocksFmt
@@ -21,6 +22,7 @@ import com.v2ray.ang.fmt.VmessFmt
import com.v2ray.ang.fmt.WireguardFmt
import com.v2ray.ang.util.HttpUtil
import com.v2ray.ang.util.JsonUtil
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.QRCodeDecoder
import com.v2ray.ang.util.Utils
import java.net.URI
@@ -45,7 +47,7 @@ object AngConfigManager {
Utils.setClipboard(context, conf)
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to share config to clipboard", e)
LogUtil.e(AppConfig.TAG, "Failed to share config to clipboard", e)
return -1
}
return 0
@@ -74,7 +76,7 @@ object AngConfigManager {
}
return sb.lines().count() - 1
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to share non-custom configs to clipboard", e)
LogUtil.e(AppConfig.TAG, "Failed to share non-custom configs to clipboard", e)
return -1
}
}
@@ -94,7 +96,7 @@ object AngConfigManager {
return QRCodeDecoder.createQRCode(conf)
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to share config as QR code", e)
LogUtil.e(AppConfig.TAG, "Failed to share config as QR code", e)
return null
}
}
@@ -116,7 +118,7 @@ object AngConfigManager {
return -1
}
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to share full content to clipboard", e)
LogUtil.e(AppConfig.TAG, "Failed to share full content to clipboard", e)
return -1
}
return 0
@@ -146,7 +148,7 @@ object AngConfigManager {
else -> {}
}
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to share config for GUID: $guid", e)
LogUtil.e(AppConfig.TAG, "Failed to share config for GUID: $guid", e)
return ""
}
}
@@ -165,7 +167,7 @@ object AngConfigManager {
count = parseBatchConfig(server, subid, append)
}
if (count <= 0) {
count = parseCustomConfigServer(server, subid)
count = parseCustomConfigServer(server, subid, append)
}
var countSub = parseBatchSubscription(server)
@@ -201,7 +203,7 @@ object AngConfigManager {
}
return count
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to parse batch subscription", e)
LogUtil.e(AppConfig.TAG, "Failed to parse batch subscription", e)
}
return 0
}
@@ -219,49 +221,146 @@ object AngConfigManager {
if (servers == null) {
return 0
}
val removedSelectedServer =
if (!TextUtils.isEmpty(subid) && !append) {
MmkvManager.decodeServerConfig(
MmkvManager.getSelectServer().orEmpty()
)?.let {
if (it.subscriptionId == subid) {
return@let it
}
return@let null
}
} else {
null
}
if (!append) {
MmkvManager.removeServerViaSubid(subid)
// Find the currently selected server that matches the subscription ID
val removedSelected = if (subid.isNotBlank() && !append) {
MmkvManager.getSelectServer()
.takeIf { it?.isNotBlank() == true }
?.let { MmkvManager.decodeServerConfig(it) }
?.takeIf { it.subscriptionId == subid }
} else {
null
}
val subItem = MmkvManager.decodeSubscription(subid)
var count = 0
// Parse all configs first (no I/O during parsing)
val configs = mutableListOf<ProfileItem>()
servers.lines()
.distinct()
.reversed()
.forEach {
val resId = parseConfig(it, subid, subItem, removedSelectedServer)
if (resId == 0) {
count++
val config = parseConfig(it, subid, subItem)
if (config != null) {
configs.add(config)
}
}
return count
// Batch save all parsed configs (only one serverList read/write)
if (configs.isNotEmpty()) {
if (!append) {
MmkvManager.removeServerViaSubid(subid)
}
val keyToProfile = batchSaveConfigs(configs, subid)
val matchKey = findMatchedProfileKey(keyToProfile, removedSelected)
matchKey?.let { MmkvManager.setSelectServer(it) }
}
return configs.size
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to parse batch config", e)
LogUtil.e(AppConfig.TAG, "Failed to parse batch config", e)
}
return 0
}
/**
* Batch save configurations to reduce serverList read/write operations.
* Reads serverList once, saves all configs, then writes serverList once.
*
* @param configs The list of ProfileItem to save.
* @param subid The subscription ID.
* @return Map of generated keys to their corresponding ProfileItem.
*/
private fun batchSaveConfigs(configs: List<ProfileItem>, subid: String): Map<String, ProfileItem> {
val keyToProfile = mutableMapOf<String, ProfileItem>()
// Read serverList once
val serverList = MmkvManager.decodeServerList(subid)
var needSetSelected = MmkvManager.getSelectServer().isNullOrBlank()
configs.forEach { config ->
val key = Utils.getUuid()
// Save profile directly without updating serverList
MmkvManager.encodeProfileDirect(key, JsonUtil.toJson(config))
if (!serverList.contains(key)) {
serverList.add(0, key)
if (needSetSelected) {
MmkvManager.setSelectServer(key)
needSetSelected = false
}
}
keyToProfile[key] = config
}
// Write serverList once
MmkvManager.encodeServerList(serverList, subid)
return keyToProfile
}
/**
* Finds a matched profile key from the given key-profile map using multi-level matching.
* Matching priority (from highest to lowest):
* 1. Exact match: server + port + password
* 2. Match by remarks (exact match)
* 3. Match by server + port
* 4. Match by server only
*
* @param keyToProfile Map of server keys to their ProfileItem
* @param target Target profile to match
* @return Matched key or null
*/
private fun findMatchedProfileKey(keyToProfile: Map<String, ProfileItem>, target: ProfileItem?): String? {
if (keyToProfile.isEmpty() || target == null) return null
// Level 1: Match by remarks
if (target.remarks.isNotBlank()) {
keyToProfile.entries.firstOrNull { (_, saved) ->
isSameText(saved.remarks, target.remarks)
}?.key?.let { return it }
}
// Level 2: Exact match (server + port + password)
keyToProfile.entries.firstOrNull { (_, saved) ->
isSameText(saved.server, target.server) &&
isSameText(saved.serverPort, target.serverPort) &&
isSameText(saved.password, target.password)
}?.key?.let { return it }
// Level 3: Match by server + port
keyToProfile.entries.firstOrNull { (_, saved) ->
isSameText(saved.server, target.server) &&
isSameText(saved.serverPort, target.serverPort)
}?.key?.let { return it }
// Level 4: Match by server only
keyToProfile.entries.firstOrNull { (_, saved) ->
isSameText(saved.server, target.server)
}?.key?.let { return it }
return null
}
/**
* Case-insensitive trimmed string comparison.
*
* @param left First string
* @param right Second string
* @return True if both are non-empty and equal (case-insensitive, trimmed)
*/
private fun isSameText(left: String?, right: String?): Boolean {
if (left.isNullOrBlank() || right.isNullOrBlank()) return false
return left.trim().equals(right.trim(), ignoreCase = true)
}
/**
* Parses a custom configuration server.
*
* @param server The server string.
* @param subid The subscription ID.
* @param append Whether to append the configurations.
* @return The number of configurations parsed.
*/
private fun parseCustomConfigServer(server: String?, subid: String): Int {
private fun parseCustomConfigServer(server: String?, subid: String, append: Boolean): Int {
if (server == null) {
return 0
}
@@ -274,6 +373,9 @@ object AngConfigManager {
JsonUtil.fromJson(server, Array<Any>::class.java) ?: arrayOf()
if (serverList.isNotEmpty()) {
if (!append) {
MmkvManager.removeServerViaSubid(subid)
}
var count = 0
for (srv in serverList.reversed()) {
val config = CustomFmt.parse(JsonUtil.toJson(srv)) ?: continue
@@ -286,7 +388,7 @@ object AngConfigManager {
return count
}
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to parse custom config server JSON array", e)
LogUtil.e(AppConfig.TAG, "Failed to parse custom config server JSON array", e)
}
try {
@@ -294,22 +396,28 @@ object AngConfigManager {
val config = CustomFmt.parse(server) ?: return 0
config.subscriptionId = subid
config.description = generateDescription(config)
if (!append) {
MmkvManager.removeServerViaSubid(subid)
}
val key = MmkvManager.encodeServerConfig("", config)
MmkvManager.encodeServerRaw(key, server)
return 1
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to parse custom config server as single config", e)
LogUtil.e(AppConfig.TAG, "Failed to parse custom config server as single config", e)
}
return 0
} else if (server.startsWith("[Interface]") && server.contains("[Peer]")) {
try {
val config = WireguardFmt.parseWireguardConfFile(server) ?: return R.string.toast_incorrect_protocol
config.description = generateDescription(config)
if (!append) {
MmkvManager.removeServerViaSubid(subid)
}
val key = MmkvManager.encodeServerConfig("", config)
MmkvManager.encodeServerRaw(key, server)
return 1
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to parse WireGuard config file", e)
LogUtil.e(AppConfig.TAG, "Failed to parse WireGuard config file", e)
}
return 0
} else {
@@ -319,22 +427,21 @@ object AngConfigManager {
/**
* Parses the configuration from a QR code or string.
* Only parses and returns ProfileItem, does not save.
*
* @param str The configuration string.
* @param subid The subscription ID.
* @param subItem The subscription item.
* @param removedSelectedServer The removed selected server.
* @return The result code.
* @return The parsed ProfileItem or null if parsing fails or filtered out.
*/
private fun parseConfig(
str: String?,
subid: String,
subItem: SubscriptionItem?,
removedSelectedServer: ProfileItem?
): Int {
subItem: SubscriptionItem?
): ProfileItem? {
try {
if (str == null || TextUtils.isEmpty(str)) {
return R.string.toast_none_data
return null
}
val config = if (str.startsWith(EConfigType.VMESS.protocolScheme)) {
@@ -356,105 +463,113 @@ object AngConfigManager {
}
if (config == null) {
return R.string.toast_incorrect_protocol
return null
}
//filter
if (subItem?.filter != null && subItem.filter?.isNotEmpty() == true && config.remarks.isNotEmpty()) {
val matched = Regex(pattern = subItem.filter ?: "")
// Apply filter
if (subItem?.filter.isNotNullEmpty() && config.remarks.isNotNullEmpty()) {
val matched = Regex(pattern = subItem?.filter.orEmpty())
.containsMatchIn(input = config.remarks)
if (!matched) return -1
if (!matched) return null
}
config.subscriptionId = subid
config.description = generateDescription(config)
val guid = MmkvManager.encodeServerConfig("", config)
if (removedSelectedServer != null &&
config.server == removedSelectedServer.server && config.serverPort == removedSelectedServer.serverPort
) {
MmkvManager.setSelectServer(guid)
}
return config
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to parse config", e)
return -1
LogUtil.e(AppConfig.TAG, "Failed to parse config", e)
return null
}
return 0
}
/**
* Updates the configuration via all subscriptions.
*
* @return The number of configurations updated.
* @return Detailed result of the subscription update operation.
*/
fun updateConfigViaSubAll(): Int {
var count = 0
try {
MmkvManager.decodeSubscriptions().forEach {
count += updateConfigViaSub(it)
fun updateConfigViaSubAll(): SubscriptionUpdateResult {
return try {
val subscriptions = MmkvManager.decodeSubscriptions()
subscriptions.fold(SubscriptionUpdateResult()) { acc, subscription ->
acc + updateConfigViaSub(subscription)
}
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to update config via all subscriptions", e)
return 0
LogUtil.e(AppConfig.TAG, "Failed to update config via all subscriptions", e)
SubscriptionUpdateResult()
}
return count
}
/**
* Updates the configuration via a subscription.
*
* @param it The subscription item.
* @return The number of configurations updated.
* @return Subscription update result.
*/
fun updateConfigViaSub(it: SubscriptionCache): Int {
fun updateConfigViaSub(it: SubscriptionCache): SubscriptionUpdateResult {
try {
// Check if disabled
if (!it.subscription.enabled) {
return SubscriptionUpdateResult(skipCount = 1)
}
// Validate subscription info
if (TextUtils.isEmpty(it.guid)
|| TextUtils.isEmpty(it.subscription.remarks)
|| TextUtils.isEmpty(it.subscription.url)
) {
return 0
}
if (!it.subscription.enabled) {
return 0
return SubscriptionUpdateResult(skipCount = 1)
}
val url = HttpUtil.toIdnUrl(it.subscription.url)
if (!Utils.isValidUrl(url)) {
return 0
return SubscriptionUpdateResult(failureCount = 1)
}
if (!it.subscription.allowInsecureUrl) {
if (!Utils.isValidSubUrl(url)) {
return 0
return SubscriptionUpdateResult(failureCount = 1)
}
}
Log.i(AppConfig.TAG, url)
LogUtil.i(AppConfig.TAG, url)
val userAgent = it.subscription.userAgent
val proxyUsername = SettingsManager.getSocksUsername()
val proxyPassword = SettingsManager.getSocksPassword()
var configText = try {
val httpPort = SettingsManager.getHttpPort()
HttpUtil.getUrlContentWithUserAgent(url, userAgent, 15000, httpPort)
HttpUtil.getUrlContentWithUserAgent(url, userAgent, 15000, httpPort, proxyUsername, proxyPassword)
} catch (e: Exception) {
Log.e(AppConfig.ANG_PACKAGE, "Update subscription: proxy not ready or other error", e)
LogUtil.e(AppConfig.ANG_PACKAGE, "Update subscription: proxy not ready or other error", e)
""
}
if (configText.isEmpty()) {
configText = try {
HttpUtil.getUrlContentWithUserAgent(url, userAgent)
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Update subscription: Failed to get URL content with user agent", e)
LogUtil.e(AppConfig.TAG, "Update subscription: Failed to get URL content with user agent", e)
""
}
}
if (configText.isEmpty()) {
return 0
return SubscriptionUpdateResult(failureCount = 1)
}
val count = parseConfigViaSub(configText, it.guid, false)
if (count > 0) {
it.subscription.lastUpdated = System.currentTimeMillis()
MmkvManager.encodeSubscription(it.guid, it.subscription)
Log.i(AppConfig.TAG, "Subscription updated: ${it.subscription.remarks}, $count configs")
LogUtil.i(AppConfig.TAG, "Subscription updated: ${it.subscription.remarks}, $count configs")
return SubscriptionUpdateResult(
configCount = count,
successCount = 1
)
} else {
// Got response but no valid configs parsed
return SubscriptionUpdateResult(failureCount = 1)
}
return count
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to update config via subscription", e)
return 0
LogUtil.e(AppConfig.TAG, "Failed to update config via subscription", e)
return SubscriptionUpdateResult(failureCount = 1)
}
}
@@ -472,7 +587,7 @@ object AngConfigManager {
count = parseBatchConfig(server, subid, append)
}
if (count <= 0) {
count = parseCustomConfigServer(server, subid)
count = parseCustomConfigServer(server, subid, append)
}
return count
}
@@ -1,6 +1,7 @@
package com.v2ray.ang.handler
import com.tencent.mmkv.MMKV
import com.v2ray.ang.AppConfig.DEFAULT_SUBSCRIPTION_ID
import com.v2ray.ang.AppConfig.PREF_IS_BOOTED
import com.v2ray.ang.AppConfig.PREF_ROUTING_RULESET
import com.v2ray.ang.dto.AssetUrlCache
@@ -18,7 +19,6 @@ object MmkvManager {
//region private
//private const val ID_PROFILE_CONFIG = "PROFILE_CONFIG"
private const val ID_MAIN = "MAIN"
private const val ID_PROFILE_FULL_CONFIG = "PROFILE_FULL_CONFIG"
private const val ID_SERVER_RAW = "SERVER_RAW"
@@ -28,10 +28,10 @@ object MmkvManager {
private const val ID_SETTING = "SETTING"
private const val KEY_SELECTED_SERVER = "SELECTED_SERVER"
private const val KEY_ANG_CONFIGS = "ANG_CONFIGS"
private const val KEY_SUB_SERVER_PREFIX = "SUB_SERVERS_"
private const val KEY_SUB_IDS = "SUB_IDS"
private const val KEY_WEBDAV_CONFIG = "WEBDAV_CONFIG"
//private val profileStorage by lazy { MMKV.mmkvWithID(ID_PROFILE_CONFIG, MMKV.MULTI_PROCESS_MODE) }
private val mainStorage by lazy { MMKV.mmkvWithID(ID_MAIN, MMKV.MULTI_PROCESS_MODE) }
private val profileFullStorage by lazy { MMKV.mmkvWithID(ID_PROFILE_FULL_CONFIG, MMKV.MULTI_PROCESS_MODE) }
private val serverRawStorage by lazy { MMKV.mmkvWithID(ID_SERVER_RAW, MMKV.MULTI_PROCESS_MODE) }
@@ -44,6 +44,17 @@ object MmkvManager {
//region Server
/**
* Reads the legacy server list from KEY_ANG_CONFIGS for migration.
* This method is for migration purposes only.
*
* @return The JSON string of legacy server list, or null if not exists.
*/
fun readLegacyServerList(): String? {
return mainStorage.decodeString(KEY_ANG_CONFIGS)
}
/**
* Gets the selected server GUID.
*
@@ -63,21 +74,31 @@ object MmkvManager {
}
/**
* Encodes the server list.
* Encodes the server list for a given subscription.
* Saves to the subscription's serverList (including default subscription for ungrouped servers).
*
* @param serverList The list of server GUIDs.
* @param subscriptionId The subscription ID.
*/
fun encodeServerList(serverList: MutableList<String>) {
mainStorage.encode(KEY_ANG_CONFIGS, JsonUtil.toJson(serverList))
fun encodeServerList(serverList: MutableList<String>, subscriptionId: String) {
val subId = getSubscriptionId(subscriptionId)
val key = "$KEY_SUB_SERVER_PREFIX$subId"
mainStorage.encode(key, JsonUtil.toJson(serverList))
}
/**
* Decodes the server list.
* Decodes the server list for a given subscription.
* If subscriptionId is empty, returns ungrouped servers.
* Otherwise, returns servers from the specified subscription's serverList.
*
* @param subscriptionId The subscription ID.
* @return The list of server GUIDs.
*/
fun decodeServerList(): MutableList<String> {
val json = mainStorage.decodeString(KEY_ANG_CONFIGS)
fun decodeServerList(subscriptionId: String): MutableList<String> {
val subId = getSubscriptionId(subscriptionId)
val key = "$KEY_SUB_SERVER_PREFIX$subId"
val json = mainStorage.decodeString(key)
return if (json.isNullOrBlank()) {
mutableListOf()
} else {
@@ -85,6 +106,30 @@ object MmkvManager {
}
}
/**
* Decodes all server list (merged from all subscriptions including default subscription).
* Use this when you need the complete server list.
*
* @return The list of all server GUIDs.
*/
fun decodeAllServerList(): MutableList<String> {
val allServers = mutableListOf<String>()
val subsList = decodeSubsList()
// If DEFAULT_SUBSCRIPTION_ID is not in the subscriptions list, add its servers
if (!subsList.contains(DEFAULT_SUBSCRIPTION_ID)) {
allServers.addAll(decodeServerList(DEFAULT_SUBSCRIPTION_ID))
}
// Add servers from all subscriptions
subsList.forEach { guid ->
allServers.addAll(decodeServerList(guid))
}
return allServers
}
/**
* Decodes the server configuration.
*
@@ -102,16 +147,6 @@ object MmkvManager {
return JsonUtil.fromJson(json, ProfileItem::class.java)
}
// fun decodeProfileConfig(guid: String): ProfileLiteItem? {
// if (guid.isBlank()) {
// return null
// }
// val json = profileStorage.decodeString(guid)
// if (json.isNullOrBlank()) {
// return null
// }
// return JsonUtil.fromJson(json, ProfileLiteItem::class.java)
// }
/**
* Encodes the server configuration.
@@ -123,25 +158,32 @@ object MmkvManager {
fun encodeServerConfig(guid: String, config: ProfileItem): String {
val key = guid.ifBlank { Utils.getUuid() }
profileFullStorage.encode(key, JsonUtil.toJson(config))
val serverList = decodeServerList()
// Use default subscription for servers without subscription
val subId = getSubscriptionId(config.subscriptionId)
val serverList = decodeServerList(subId)
if (!serverList.contains(key)) {
serverList.add(0, key)
encodeServerList(serverList)
encodeServerList(serverList, subId)
if (getSelectServer().isNullOrBlank()) {
mainStorage.encode(KEY_SELECTED_SERVER, key)
}
}
// val profile = ProfileLiteItem(
// configType = config.configType,
// subscriptionId = config.subscriptionId,
// remarks = config.remarks,
// server = config.getProxyOutbound()?.getServerAddress(),
// serverPort = config.getProxyOutbound()?.getServerPort(),
// )
// profileStorage.encode(key, JsonUtil.toJson(profile))
return key
}
/**
* Encodes the server configuration directly without updating serverList.
*
* @param key The server GUID.
* @param configJson The server configuration JSON string.
*/
fun encodeProfileDirect(key: String, configJson: String) {
profileFullStorage.encode(key, configJson)
}
/**
* Removes the server configuration.
*
@@ -151,33 +193,44 @@ object MmkvManager {
if (guid.isBlank()) {
return
}
// Get config to determine which subscription to update
val config = decodeServerConfig(guid)
val subId = getSubscriptionId(config?.subscriptionId)
// Remove from appropriate server list
val serverList = decodeServerList(subId)
serverList.remove(guid)
encodeServerList(serverList, subId)
// Clean up storage
if (getSelectServer() == guid) {
mainStorage.remove(KEY_SELECTED_SERVER)
}
val serverList = decodeServerList()
serverList.remove(guid)
encodeServerList(serverList)
profileFullStorage.remove(guid)
//profileStorage.remove(guid)
serverAffStorage.remove(guid)
}
/**
* Removes the server configurations via subscription ID.
*
* @param subid The subscription ID.
* @param subscriptionId The subscription ID.
*/
fun removeServerViaSubid(subid: String) {
if (subid.isBlank()) {
return
}
profileFullStorage.allKeys()?.forEach { key ->
decodeServerConfig(key)?.let { config ->
if (config.subscriptionId == subid) {
removeServer(key)
}
fun removeServerViaSubid(subscriptionId: String?) {
val subId = getSubscriptionId(subscriptionId)
val serverList = decodeServerList(subId)
// Remove all servers in the list
serverList.forEach { guid ->
if (getSelectServer() == guid) {
mainStorage.remove(KEY_SELECTED_SERVER)
}
profileFullStorage.remove(guid)
serverAffStorage.remove(guid)
}
serverList.clear()
encodeServerList(serverList, subId)
}
/**
@@ -235,7 +288,6 @@ object MmkvManager {
val count = profileFullStorage.allKeys()?.count() ?: 0
mainStorage.clearAll()
profileFullStorage.clearAll()
//profileStorage.clearAll()
serverAffStorage.clearAll()
return count
}
@@ -292,6 +344,10 @@ object MmkvManager {
//region Subscriptions
private fun getSubscriptionId(subscriptionId: String?): String {
return subscriptionId?.ifEmpty { DEFAULT_SUBSCRIPTION_ID } ?: DEFAULT_SUBSCRIPTION_ID
}
/**
* Initializes the subscription list.
*/
@@ -318,7 +374,7 @@ object MmkvManager {
decodeSubsList().forEach { key ->
val json = subStorage.decodeString(key)
if (!json.isNullOrBlank()) {
val item = JsonUtil.fromJson(json, SubscriptionItem::class.java)?: SubscriptionItem()
val item = JsonUtil.fromJson(json, SubscriptionItem::class.java) ?: SubscriptionItem()
subscriptions.add(SubscriptionCache(key, item))
}
}
@@ -386,7 +442,7 @@ object MmkvManager {
return if (json.isNullOrBlank()) {
mutableListOf()
} else {
JsonUtil.fromJson(json, Array<String>::class.java)?.toMutableList()?: mutableListOf()
JsonUtil.fromJson(json, Array<String>::class.java)?.toMutableList() ?: mutableListOf()
}
}
@@ -404,7 +460,7 @@ object MmkvManager {
assetStorage.allKeys()?.forEach { key ->
val json = assetStorage.decodeString(key)
if (!json.isNullOrBlank()) {
val item = JsonUtil.fromJson(json, AssetUrlItem::class.java)?: AssetUrlItem()
val item = JsonUtil.fromJson(json, AssetUrlItem::class.java) ?: AssetUrlItem()
assetUrlItems.add(AssetUrlCache(key, item))
}
}
@@ -454,7 +510,7 @@ object MmkvManager {
fun decodeRoutingRulesets(): MutableList<RulesetItem>? {
val ruleset = settingsStorage.decodeString(PREF_ROUTING_RULESET)
if (ruleset.isNullOrEmpty()) return null
return JsonUtil.fromJson(ruleset, Array<RulesetItem>::class.java)?.toMutableList()?: mutableListOf()
return JsonUtil.fromJson(ruleset, Array<RulesetItem>::class.java)?.toMutableList() ?: mutableListOf()
}
/**
@@ -9,7 +9,6 @@ import android.content.Context
import android.content.Intent
import android.graphics.Color
import android.os.Build
import android.util.Log
import androidx.annotation.RequiresApi
import androidx.core.app.NotificationCompat
import com.v2ray.ang.AppConfig
@@ -17,6 +16,7 @@ import com.v2ray.ang.R
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.extension.toSpeedString
import com.v2ray.ang.ui.MainActivity
import com.v2ray.ang.util.LogUtil
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -57,7 +57,7 @@ object NotificationManager {
// If the query interval is too short, skip this round to avoid excessive CPU usage
if (sinceLastQueryIn < QUERY_INTERVAL_MS) {
Log.w(AppConfig.TAG, "Query interval too short: ${sinceLastQueryIn}ms, skipping")
LogUtil.w(AppConfig.TAG, "Query interval too short: ${sinceLastQueryIn}ms, skipping")
lastQueryTime = queryTime
delay(QUERY_INTERVAL_MS)
continue
@@ -2,39 +2,57 @@ package com.v2ray.ang.handler
import android.content.Context
import android.content.res.AssetManager
import android.os.Build
import android.text.TextUtils
import android.util.Log
import androidx.appcompat.app.AppCompatDelegate
import com.v2ray.ang.AppConfig
import com.v2ray.ang.AppConfig.ANG_PACKAGE
import com.v2ray.ang.AppConfig.DEFAULT_SUBSCRIPTION_ID
import com.v2ray.ang.AppConfig.GEOIP_PRIVATE
import com.v2ray.ang.AppConfig.GEOSITE_PRIVATE
import com.v2ray.ang.AppConfig.TAG_DIRECT
import com.v2ray.ang.AppConfig.VPN
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.RulesetItem
import com.v2ray.ang.dto.SubscriptionItem
import com.v2ray.ang.dto.V2rayConfig
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.enums.Language
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.enums.RoutingType
import com.v2ray.ang.dto.RulesetItem
import com.v2ray.ang.dto.ServersCache
import com.v2ray.ang.dto.V2rayConfig
import com.v2ray.ang.enums.VpnInterfaceAddressConfig
import com.v2ray.ang.handler.MmkvManager.decodeAllServerList
import com.v2ray.ang.handler.MmkvManager.decodeServerConfig
import com.v2ray.ang.handler.MmkvManager.decodeServerList
import com.v2ray.ang.handler.MmkvManager.decodeSubsList
import com.v2ray.ang.handler.MmkvManager.decodeSubscription
import com.v2ray.ang.handler.MmkvManager.encodeSubscription
import com.v2ray.ang.handler.MmkvManager.removeSubscription
import com.v2ray.ang.util.JsonUtil
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.Utils
import java.io.File
import java.io.FileOutputStream
import java.util.Collections
import java.util.Locale
import kotlin.random.Random
object SettingsManager {
@Volatile
private var runtimeSocksPort: Int? = null
fun initApp(context: Context) {
ensureDefaultSettings()
//ensureDefaultSubscription()
initRoutingRulesets(context)
migrateServerListToSubscriptions()
migrateHysteria2PinSHA256()
}
/**
* Initialize routing rulesets.
* @param context The application context.
*/
fun initRoutingRulesets(context: Context) {
private fun initRoutingRulesets(context: Context) {
val exist = MmkvManager.decodeRoutingRulesets()
if (exist.isNullOrEmpty()) {
val rulesetList = getPresetRoutingRulesets(context)
@@ -87,7 +105,7 @@ object SettingsManager {
resetRoutingRulesetsCommon(rulesetList)
return true
} catch (e: Exception) {
Log.e(ANG_PACKAGE, "Failed to reset routing rulesets", e)
LogUtil.e(ANG_PACKAGE, "Failed to reset routing rulesets", e)
return false
}
}
@@ -206,7 +224,7 @@ object SettingsManager {
* @param toPosition The position to swap to.
*/
fun swapSubscriptions(fromPosition: Int, toPosition: Int) {
val subsList = MmkvManager.decodeSubsList()
val subsList = decodeSubsList()
if (subsList.isEmpty()) return
Collections.swap(subsList, fromPosition, toPosition)
@@ -222,14 +240,37 @@ object SettingsManager {
if (remarks.isNullOrEmpty()) {
return null
}
val serverList = decodeServerList()
for (guid in serverList) {
val profile = decodeServerConfig(guid)
if (profile != null && profile.remarks == remarks) {
return profile
}
val serverList = decodeAllServerList()
return serverList
.mapNotNull { guid -> decodeServerConfig(guid) }
.firstOrNull { it.remarks == remarks }
}
/**
* Removes the subscription.
* If there are no remaining subscriptions,
* it creates a new default subscription to ensure that ungroup
**/
fun removeSubscriptionWithDefault(subid: String) {
// val subsList = decodeSubsList()
// if (subsList.size == 1 && subsList.first() == DEFAULT_SUBSCRIPTION_ID) {
// LogUtil.i(ANG_PACKAGE,"Attempted to remove the only existing default subscription, operation ignored.")
// return
// }
// Remove the subscription
removeSubscription(subid)
// After removal, check if there are any subscriptions left. If not, create a default subscription.
val subsList2 = decodeSubsList()
if (subsList2.isNotEmpty()) {
return
}
return null
val defaultSub = SubscriptionItem(
remarks = "Default",
)
encodeSubscription(DEFAULT_SUBSCRIPTION_ID, defaultSub)
}
/**
@@ -237,7 +278,30 @@ object SettingsManager {
* @return The SOCKS port.
*/
fun getSocksPort(): Int {
return Utils.parseInt(MmkvManager.decodeSettingsString(AppConfig.PREF_SOCKS_PORT), AppConfig.PORT_SOCKS.toInt())
val port =
if (IsDynamicSocksPort()) {
runtimeSocksPort ?: refreshRuntimeSocksPort()
} else {
Utils.parseInt(MmkvManager.decodeSettingsString(AppConfig.PREF_SOCKS_PORT), AppConfig.PORT_SOCKS.toInt())
}
return port ?: AppConfig.PORT_SOCKS.toInt()
}
@Synchronized
fun refreshRuntimeSocksPort(): Int? {
if (IsDynamicSocksPort()) {
runtimeSocksPort = generateRandomSocksPort()
return runtimeSocksPort
}
return null
}
fun getSocksUsername(): String? {
return MmkvManager.decodeSettingsString(AppConfig.PREF_SOCKS_USERNAME)?.trim()?.takeIf { it.isNotEmpty() }
}
fun getSocksPassword(): String? {
return MmkvManager.decodeSettingsString(AppConfig.PREF_SOCKS_PASSWORD)?.trim()?.takeIf { it.isNotEmpty() }
}
/**
@@ -248,6 +312,14 @@ object SettingsManager {
return getSocksPort() + if (Utils.isXray()) 0 else 1
}
private fun IsDynamicSocksPort(): Boolean {
return MmkvManager.decodeSettingsBool(AppConfig.PREF_DYNAMIC_SOCKS_PORT, false)
}
private fun generateRandomSocksPort(): Int {
return Random.nextInt(10000, 65535)
}
/**
* Initialize assets.
* @param context The application context.
@@ -257,7 +329,7 @@ object SettingsManager {
val extFolder = Utils.userAssetPath(context)
try {
val geo = arrayOf("geosite.dat", "geoip.dat")
val geo = arrayOf(AppConfig.GEOSITE_DAT, AppConfig.GEOIP_DAT, AppConfig.GEOIP_ONLY_CN_PRIVATE_DAT)
assets.list("")
?.filter { geo.contains(it) }
?.filter { !File(extFolder, it).exists() }
@@ -268,10 +340,10 @@ object SettingsManager {
input.copyTo(output)
}
}
Log.i(AppConfig.TAG, "Copied from apk assets folder to ${target.absolutePath}")
LogUtil.i(AppConfig.TAG, "Copied from apk assets folder to ${target.absolutePath}")
}
} catch (e: Exception) {
Log.e(ANG_PACKAGE, "asset copy failed", e)
LogUtil.e(ANG_PACKAGE, "asset copy failed", e)
}
}
@@ -397,12 +469,34 @@ object SettingsManager {
return mode == null || mode == VPN
}
/**
* Check if process routing can be used.
*/
fun canUseProcessRouting(): Boolean {
// Android 10+
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
return false
}
// Must xray tun
if (isUsingHevTun()) {
return false
}
// Must have route only enabled
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_ROUTE_ONLY_ENABLED, false) == false) {
return false
}
return true
}
/**
* Ensure default settings are present in MMKV.
*/
fun ensureDefaultSettings() {
private fun ensureDefaultSettings() {
// Write defaults in the exact order requested by the user
ensureDefaultValue(AppConfig.PREF_MODE, AppConfig.VPN)
ensureDefaultValue(AppConfig.PREF_MODE, VPN)
ensureDefaultValue(AppConfig.PREF_VPN_DNS, AppConfig.DNS_VPN)
ensureDefaultValue(AppConfig.PREF_VPN_MTU, AppConfig.VPN_MTU.toString())
ensureDefaultValue(AppConfig.SUBSCRIPTION_AUTO_UPDATE_INTERVAL, AppConfig.SUBSCRIPTION_DEFAULT_UPDATE_INTERVAL)
@@ -424,14 +518,14 @@ object SettingsManager {
}
}
fun migrateHysteria2PinSHA256() {
private fun migrateHysteria2PinSHA256() {
// Check if migration has already been done
val migrationKey = "hysteria2_pin_sha256_migrated"
if (MmkvManager.decodeSettingsBool(migrationKey, false)) {
return
}
val serverList = decodeServerList()
val serverList = decodeAllServerList()
for (guid in serverList) {
val profile = decodeServerConfig(guid) ?: continue
@@ -449,4 +543,74 @@ object SettingsManager {
MmkvManager.encodeSettings(migrationKey, true)
}
/**
* Migrates server list from legacy KEY_ANG_CONFIGS to subscription-based storage.
* This method should be called once during app initialization after the storage structure change.
* Servers are grouped by their subscriptionId into respective subscription's serverList.
* Servers without subscription are moved to the default subscription.
* After migration, KEY_ANG_CONFIGS is removed.
*/
private fun migrateServerListToSubscriptions() {
// Check if migration has already been done
val migrationKey = "server_list_to_subscriptions_migrated"
if (MmkvManager.decodeSettingsBool(migrationKey, false)) {
return
}
// Ensure default subscription exists before migration
ensureDefaultSubscription()
// Read existing server list from legacy KEY_ANG_CONFIGS
val oldJson = MmkvManager.readLegacyServerList()
if (oldJson.isNullOrBlank()) {
// No data to migrate, mark as done
MmkvManager.encodeSettings(migrationKey, true)
return
}
val guids = JsonUtil.fromJson(oldJson, Array<String>::class.java) ?: run {
MmkvManager.encodeSettings(migrationKey, true)
return
}
val subscriptionServerMap = mutableMapOf<String, MutableList<String>>()
// Group servers by subscription (use default subscription for empty subscriptionId)
guids.forEach { guid ->
val config = decodeServerConfig(guid) ?: return@forEach
val subId = config.subscriptionId.ifEmpty { DEFAULT_SUBSCRIPTION_ID }
subscriptionServerMap.getOrPut(subId) { mutableListOf() }.add(guid)
}
// Update each subscription's serverList (including default subscription)
subscriptionServerMap.forEach { (subId, serverGuids) ->
MmkvManager.encodeServerList(serverGuids, subId)
}
// Mark migration as complete
MmkvManager.encodeSettings(migrationKey, true)
}
/**
* Ensures the default subscription exists for ungrouped servers.
* This subscription is used internally to store servers without a subscription.
* Made public for migration in SettingsManager.
*/
private fun ensureDefaultSubscription() {
if (decodeSubscription(DEFAULT_SUBSCRIPTION_ID) == null) {
val defaultSub = SubscriptionItem(
remarks = "Default",
)
encodeSubscription(DEFAULT_SUBSCRIPTION_ID, defaultSub)
// Move top
val subsList = decodeSubsList()
if (subsList.count() > 1) {
swapSubscriptions(0, subsList.count() - 1)
}
}
}
}
@@ -2,12 +2,12 @@ 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.R
import com.v2ray.ang.dto.IPAPIInfo
import com.v2ray.ang.util.HttpUtil
import com.v2ray.ang.util.JsonUtil
import com.v2ray.ang.util.LogUtil
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.isActive
import java.io.IOException
@@ -62,11 +62,11 @@ object SpeedtestManager {
socket.close()
return time
} catch (e: UnknownHostException) {
Log.e(AppConfig.TAG, "Unknown host: $url", e)
LogUtil.e(AppConfig.TAG, "Unknown host: $url", e)
} catch (e: IOException) {
Log.e(AppConfig.TAG, "socketConnectTime IOException: $e")
LogUtil.e(AppConfig.TAG, "socketConnectTime IOException: $e")
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to establish socket connection to $url:$port", e)
LogUtil.e(AppConfig.TAG, "Failed to establish socket connection to $url:$port", e)
}
return -1
}
@@ -83,49 +83,14 @@ object SpeedtestManager {
}
}
/**
* Tests the connection to a given URL and port.
*
* @param context The Context in which the test is running.
* @param port The port to connect to.
* @return A pair containing the elapsed time in milliseconds and the result message.
*/
fun testConnection(context: Context, port: Int): Pair<Long, String> {
var result: String
var elapsed = -1L
val conn = HttpUtil.createProxyConnection(SettingsManager.getDelayTestUrl(), port, 15000, 15000) ?: return Pair(elapsed, "")
try {
val start = SystemClock.elapsedRealtime()
val code = conn.responseCode
elapsed = SystemClock.elapsedRealtime() - start
result = when (code) {
204 -> context.getString(R.string.connection_test_available, elapsed)
200 if conn.contentLengthLong == 0L -> context.getString(R.string.connection_test_available, elapsed)
else -> throw IOException(
context.getString(R.string.connection_test_error_status_code, code)
)
}
} catch (e: IOException) {
Log.e(AppConfig.TAG, "Connection test IOException", e)
result = context.getString(R.string.connection_test_error, e.message)
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Connection test Exception", e)
result = context.getString(R.string.connection_test_error, e.message)
} finally {
conn.disconnect()
}
return Pair(elapsed, result)
}
fun getRemoteIPInfo(): String? {
val url = MmkvManager.decodeSettingsString(AppConfig.PREF_IP_API_URL)
.takeIf { !it.isNullOrBlank() } ?: AppConfig.IP_API_URL
val proxyUsername = SettingsManager.getSocksUsername()
val proxyPassword = SettingsManager.getSocksPassword()
val httpPort = SettingsManager.getHttpPort()
val content = HttpUtil.getUrlContent(url, 5000, httpPort) ?: return null
val content = HttpUtil.getUrlContent(url, 5000, httpPort, proxyUsername, proxyPassword) ?: return null
val ipInfo = JsonUtil.fromJson(content, IPAPIInfo::class.java) ?: return null
val ip = listOf(
@@ -5,13 +5,13 @@ import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.os.Build
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import com.v2ray.ang.AppConfig
import com.v2ray.ang.R
import com.v2ray.ang.util.LogUtil
object SubscriptionUpdater {
@@ -34,7 +34,7 @@ object SubscriptionUpdater {
*/
@SuppressLint("MissingPermission")
override suspend fun doWork(): Result {
Log.i(AppConfig.TAG, "subscription automatic update starting")
LogUtil.i(AppConfig.TAG, "subscription automatic update starting")
val subs = MmkvManager.decodeSubscriptions().filter { it.subscription.autoUpdate }
@@ -52,7 +52,7 @@ object SubscriptionUpdater {
notificationManager.createNotificationChannel(channel)
}
notificationManager.notify(3, notification.build())
Log.i(AppConfig.TAG, "subscription automatic update: ---${subItem.remarks}")
LogUtil.i(AppConfig.TAG, "subscription automatic update: ---${subItem.remarks}")
AngConfigManager.updateConfigViaSub(sub)
notification.setContentText("Updating ${subItem.remarks}")
}
@@ -2,7 +2,6 @@ package com.v2ray.ang.handler
import android.content.Context
import android.os.Build
import android.util.Log
import com.v2ray.ang.AppConfig
import com.v2ray.ang.BuildConfig
import com.v2ray.ang.dto.CheckUpdateResult
@@ -10,6 +9,7 @@ import com.v2ray.ang.dto.GitHubRelease
import com.v2ray.ang.extension.concatUrl
import com.v2ray.ang.util.HttpUtil
import com.v2ray.ang.util.JsonUtil
import com.v2ray.ang.util.LogUtil
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
@@ -23,10 +23,13 @@ object UpdateCheckerManager {
AppConfig.APP_API_URL.concatUrl("latest")
}
val proxyUsername = SettingsManager.getSocksUsername()
val proxyPassword = SettingsManager.getSocksPassword()
var response = HttpUtil.getUrlContent(url, 5000)
if (response.isNullOrEmpty()) {
val httpPort = SettingsManager.getHttpPort()
response = HttpUtil.getUrlContent(url, 5000, httpPort)
response = HttpUtil.getUrlContent(url, 5000, httpPort, proxyUsername, proxyPassword)
?: throw IllegalStateException("Failed to get response")
}
@@ -42,7 +45,7 @@ object UpdateCheckerManager {
}
val latestVersion = latestRelease.tagName.removePrefix("v")
Log.i(
LogUtil.i(
AppConfig.TAG,
"Found new version: $latestVersion (current: ${BuildConfig.VERSION_NAME})"
)
@@ -61,39 +64,6 @@ object UpdateCheckerManager {
}
}
suspend fun downloadApk(context: Context, downloadUrl: String): File? = withContext(Dispatchers.IO) {
try {
val httpPort = SettingsManager.getHttpPort()
val connection = HttpUtil.createProxyConnection(downloadUrl, httpPort, 10000, 10000, true)
?: throw IllegalStateException("Failed to create connection")
try {
val apkFile = File(context.cacheDir, "update.apk")
Log.i(AppConfig.TAG, "Downloading APK to: ${apkFile.absolutePath}")
FileOutputStream(apkFile).use { outputStream ->
connection.inputStream.use { inputStream ->
inputStream.copyTo(outputStream)
}
}
Log.i(AppConfig.TAG, "APK download completed")
return@withContext apkFile
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to download APK: ${e.message}")
return@withContext null
} finally {
try {
connection.disconnect()
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Error closing connection: ${e.message}")
}
}
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to initiate download: ${e.message}")
return@withContext null
}
}
private fun compareVersions(version1: String, version2: String): Int {
val v1 = version1.split(".")
val v2 = version2.split(".")
@@ -1,8 +1,8 @@
package com.v2ray.ang.handler
import android.content.Context
import android.util.Log
import com.v2ray.ang.AppConfig
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.Utils
import go.Seq
import libv2ray.CoreCallbackHandler
@@ -32,14 +32,14 @@ object V2RayNativeManager {
val assetPath = Utils.userAssetPath(context)
val deviceId = Utils.getDeviceIdForXUDPBaseKey()
Libv2ray.initCoreEnv(assetPath, deviceId)
Log.i(AppConfig.TAG, "V2Ray core environment initialized successfully")
LogUtil.i(AppConfig.TAG, "V2Ray core environment initialized successfully")
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to initialize V2Ray core environment", e)
LogUtil.e(AppConfig.TAG, "Failed to initialize V2Ray core environment", e)
initialized.set(false)
throw e
}
} else {
Log.d(AppConfig.TAG, "V2Ray core environment already initialized, skipping")
LogUtil.d(AppConfig.TAG, "V2Ray core environment already initialized, skipping")
}
}
@@ -53,7 +53,7 @@ object V2RayNativeManager {
return try {
Libv2ray.checkVersionX()
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to check V2Ray version", e)
LogUtil.e(AppConfig.TAG, "Failed to check V2Ray version", e)
"Unknown"
}
}
@@ -69,7 +69,7 @@ object V2RayNativeManager {
return try {
Libv2ray.measureOutboundDelay(config, testUrl)
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to measure outbound delay", e)
LogUtil.e(AppConfig.TAG, "Failed to measure outbound delay", e)
-1L
}
}
@@ -84,7 +84,7 @@ object V2RayNativeManager {
return try {
Libv2ray.newCoreController(handler)
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to create core controller", e)
LogUtil.e(AppConfig.TAG, "Failed to create core controller", e)
throw e
}
}
@@ -5,17 +5,20 @@ import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.net.ConnectivityManager
import android.os.Build
import android.os.ParcelFileDescriptor
import android.util.Log
import android.system.OsConstants
import androidx.core.content.ContextCompat
import com.v2ray.ang.AppConfig
import com.v2ray.ang.R
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.extension.toast
import com.v2ray.ang.contracts.ServiceControl
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.extension.toast
import com.v2ray.ang.service.V2RayProxyOnlyService
import com.v2ray.ang.service.V2RayVpnService
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.MessageUtil
import com.v2ray.ang.util.Utils
import kotlinx.coroutines.CoroutineScope
@@ -23,18 +26,26 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import libv2ray.CoreCallbackHandler
import libv2ray.CoreController
import libv2ray.ProcessFinder
import java.lang.ref.SoftReference
import java.net.InetSocketAddress
object V2RayServiceManager {
private val coreController: CoreController = V2RayNativeManager.newCoreController(CoreCallback())
private val mMsgReceive = ReceiveMessageHandler()
private var currentConfig: ProfileItem? = null
private var processFinder: XrayProcessFinder? = null
var serviceControl: SoftReference<ServiceControl>? = null
set(value) {
field = value
V2RayNativeManager.initCoreEnv(value?.get()?.getService())
val service = value?.get()?.getService()
V2RayNativeManager.initCoreEnv(service)
if (service != null && processFinder == null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
processFinder = XrayProcessFinder(service)
coreController.registerProcessFinder(processFinder)
}
}
/**
@@ -57,9 +68,12 @@ object V2RayServiceManager {
* @param guid The GUID of the server configuration to use (optional).
*/
fun startVService(context: Context, guid: String? = null) {
LogUtil.i(AppConfig.TAG, "StartCore-Manager: startVService from ${context::class.java.simpleName}")
if (guid != null) {
MmkvManager.setSelectServer(guid)
}
startContextService(context)
}
@@ -91,15 +105,33 @@ object V2RayServiceManager {
*/
private fun startContextService(context: Context) {
if (coreController.isRunning) {
LogUtil.w(AppConfig.TAG, "StartCore-Manager: Core already running")
return
}
val guid = MmkvManager.getSelectServer() ?: return
val config = MmkvManager.decodeServerConfig(guid) ?: return
val guid = MmkvManager.getSelectServer()
if (guid == null) {
LogUtil.e(AppConfig.TAG, "StartCore-Manager: No server selected")
return
}
val config = MmkvManager.decodeServerConfig(guid)
if (config == null) {
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Failed to decode server config")
return
}
if (config.configType != EConfigType.CUSTOM
&& config.configType != EConfigType.POLICYGROUP
&& !Utils.isValidUrl(config.server)
&& !Utils.isPureIpAddress(config.server.orEmpty())
) return
) {
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Invalid server configuration")
return
}
// refresh socks port when enabled dynamic socks port
SettingsManager.refreshRuntimeSocksPort()
// val result = V2rayConfigUtil.getV2rayConfig(context, guid)
// if (!result.status) return
@@ -108,12 +140,21 @@ object V2RayServiceManager {
} else {
context.toast(R.string.toast_services_start)
}
val intent = if (SettingsManager.isVpnMode()) {
val isVpnMode = SettingsManager.isVpnMode()
val intent = if (isVpnMode) {
LogUtil.i(AppConfig.TAG, "StartCore-Manager: Starting VPN service")
Intent(context.applicationContext, V2RayVpnService::class.java)
} else {
LogUtil.i(AppConfig.TAG, "StartCore-Manager: Starting Proxy service")
Intent(context.applicationContext, V2RayProxyOnlyService::class.java)
}
ContextCompat.startForegroundService(context, intent)
try {
ContextCompat.startForegroundService(context, intent)
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Failed to start service", e)
}
}
/**
@@ -123,15 +164,35 @@ object V2RayServiceManager {
*/
fun startCoreLoop(vpnInterface: ParcelFileDescriptor?): Boolean {
if (coreController.isRunning) {
LogUtil.w(AppConfig.TAG, "StartCore-Manager: Core already running")
return false
}
val service = getService() ?: return false
val guid = MmkvManager.getSelectServer() ?: return false
val config = MmkvManager.decodeServerConfig(guid) ?: return false
val result = V2rayConfigManager.getV2rayConfig(service, guid)
if (!result.status)
val service = getService()
if (service == null) {
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Service is null")
return false
}
val guid = MmkvManager.getSelectServer()
if (guid == null) {
LogUtil.e(AppConfig.TAG, "StartCore-Manager: No server selected")
return false
}
val config = MmkvManager.decodeServerConfig(guid)
if (config == null) {
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Failed to decode server config")
return false
}
LogUtil.i(AppConfig.TAG, "StartCore-Manager: Starting core loop for ${config.remarks}")
val result = V2rayConfigManager.getV2rayConfig(service, guid)
LogUtil.d(AppConfig.TAG, result.content)
if (!result.status) {
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Failed to get V2Ray config")
return false
}
try {
val mFilter = IntentFilter(AppConfig.BROADCAST_ACTION_SERVICE)
@@ -140,7 +201,7 @@ object V2RayServiceManager {
mFilter.addAction(Intent.ACTION_USER_PRESENT)
ContextCompat.registerReceiver(service, mMsgReceive, mFilter, Utils.receiverFlags())
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to register broadcast receiver", e)
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Failed to register receiver", e)
return false
}
@@ -154,11 +215,12 @@ object V2RayServiceManager {
NotificationManager.showNotification(currentConfig)
coreController.startLoop(result.content, tunFd)
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to start Core loop", e)
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Failed to start core loop", e)
return false
}
if (coreController.isRunning == false) {
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Core failed to start")
MessageUtil.sendMsg2UI(service, AppConfig.MSG_STATE_START_FAILURE, "")
NotificationManager.cancelNotification()
return false
@@ -166,11 +228,10 @@ object V2RayServiceManager {
try {
MessageUtil.sendMsg2UI(service, AppConfig.MSG_STATE_START_SUCCESS, "")
//NotificationManager.showNotification(currentConfig)
NotificationManager.startSpeedNotification(currentConfig)
LogUtil.i(AppConfig.TAG, "StartCore-Manager: Core started successfully")
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to startup service", e)
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Failed to complete startup", e)
return false
}
return true
@@ -189,7 +250,7 @@ object V2RayServiceManager {
try {
coreController.stopLoop()
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to stop V2Ray loop", e)
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Failed to stop V2Ray loop", e)
}
}
}
@@ -200,7 +261,7 @@ object V2RayServiceManager {
try {
service.unregisterReceiver(mMsgReceive)
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to unregister broadcast receiver", e)
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Failed to unregister receiver", e)
}
return true
@@ -234,14 +295,14 @@ object V2RayServiceManager {
try {
time = coreController.measureDelay(SettingsManager.getDelayTestUrl())
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to measure delay with primary URL", e)
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Failed to measure delay", e)
errorStr = e.message?.substringAfter("\":") ?: "empty message"
}
if (time == -1L) {
try {
time = coreController.measureDelay(SettingsManager.getDelayTestUrl(true))
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to measure delay with alternative URL", e)
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Failed to measure delay", e)
errorStr = e.message?.substringAfter("\":") ?: "empty message"
}
}
@@ -293,7 +354,7 @@ object V2RayServiceManager {
serviceControl.stopService()
0
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to stop service in callback", e)
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Failed to stop service", e)
-1
}
}
@@ -309,6 +370,43 @@ object V2RayServiceManager {
}
}
/**
* Process finder implementation for Xray core.
* Uses ConnectivityManager to find the owning UID of a connection based on network parameters.
*/
private class XrayProcessFinder(context: Context) : ProcessFinder {
private val cm: ConnectivityManager? = context.getSystemService(ConnectivityManager::class.java)
override fun findProcessByConnection(network: String, srcIP: String, srcPort: Long, destIP: String, destPort: Long): Long {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return -1L
if (cm == null) return -1L
val proto = when (network) {
"tcp" -> OsConstants.IPPROTO_TCP
"udp" -> OsConstants.IPPROTO_UDP
else -> return -1L
}
if (destIP.isBlank() || destPort == 0L) {
LogUtil.d(AppConfig.TAG, "ProcessFinder: Find $network connection from $srcIP:$srcPort to :$destPort, (no dest)")
return -1L
}
return try {
val uid = cm.getConnectionOwnerUid(
proto,
InetSocketAddress(srcIP, srcPort.toInt()),
InetSocketAddress(destIP, destPort.toInt())
).toLong()
LogUtil.d(AppConfig.TAG, "ProcessFinder: Find $network connection from $srcIP:$srcPort to $destIP:$destPort, uid=$uid")
//LogUtil.d(AppConfig.TAG, "ProcessFinder: Find $network connection from $srcIP:$srcPort to $destIP:$destPort, uid=$uid,${PackageUidResolver.uidToPackageName(uid.toString())}")
uid
} catch (e: Exception) {
-1L
}
}
}
/**
* Broadcast receiver for handling messages sent to the service.
* Handles registration, service control, and screen events.
@@ -340,12 +438,12 @@ object V2RayServiceManager {
}
AppConfig.MSG_STATE_STOP -> {
Log.i(AppConfig.TAG, "Stop Service")
LogUtil.i(AppConfig.TAG, "StartCore-Manager: Stop service")
serviceControl.stopService()
}
AppConfig.MSG_STATE_RESTART -> {
Log.i(AppConfig.TAG, "Restart Service")
LogUtil.i(AppConfig.TAG, "StartCore-Manager: Restart service")
serviceControl.stopService()
Thread.sleep(500L)
startVService(serviceControl.getService())
@@ -358,12 +456,12 @@ object V2RayServiceManager {
when (intent?.action) {
Intent.ACTION_SCREEN_OFF -> {
Log.i(AppConfig.TAG, "SCREEN_OFF, stop querying stats")
LogUtil.i(AppConfig.TAG, "StartCore-Manager: Screen off")
NotificationManager.stopSpeedNotification(currentConfig)
}
Intent.ACTION_SCREEN_ON -> {
Log.i(AppConfig.TAG, "SCREEN_ON, start querying stats")
LogUtil.i(AppConfig.TAG, "StartCore-Manager: Screen on")
NotificationManager.startSpeedNotification(currentConfig)
}
}
@@ -2,12 +2,10 @@ package com.v2ray.ang.handler
import android.content.Context
import android.text.TextUtils
import android.util.Log
import com.google.gson.JsonArray
import com.v2ray.ang.AppConfig
import com.v2ray.ang.AppConfig.BUILTIN_OUTBOUND_TAGS
import com.v2ray.ang.dto.ConfigResult
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.enums.NetworkType
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.RulesetItem
import com.v2ray.ang.dto.V2rayConfig
@@ -15,6 +13,8 @@ import com.v2ray.ang.dto.V2rayConfig.OutboundBean
import com.v2ray.ang.dto.V2rayConfig.OutboundBean.OutSettingsBean
import com.v2ray.ang.dto.V2rayConfig.OutboundBean.StreamSettingsBean
import com.v2ray.ang.dto.V2rayConfig.RoutingBean.RulesBean
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.enums.NetworkType
import com.v2ray.ang.extension.isNotNullEmpty
import com.v2ray.ang.extension.nullIfBlank
import com.v2ray.ang.fmt.HttpFmt
@@ -27,6 +27,8 @@ import com.v2ray.ang.fmt.VmessFmt
import com.v2ray.ang.fmt.WireguardFmt
import com.v2ray.ang.util.HttpUtil
import com.v2ray.ang.util.JsonUtil
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.PackageUidResolver
import com.v2ray.ang.util.Utils
object V2rayConfigManager {
@@ -53,7 +55,7 @@ object V2rayConfigManager {
getV2rayNormalConfig(context, guid, config)
}
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to get V2ray config", e)
LogUtil.e(AppConfig.TAG, "Failed to get V2ray config", e)
return ConfigResult(false)
}
}
@@ -77,7 +79,7 @@ object V2rayConfigManager {
getV2rayNormalConfig4Speedtest(context, guid, config)
}
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to get V2ray config for speedtest", e)
LogUtil.e(AppConfig.TAG, "Failed to get V2ray config for speedtest", e)
return ConfigResult(false)
}
}
@@ -96,36 +98,46 @@ object V2rayConfigManager {
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()
}
val json = JsonUtil.parseString(raw)?.takeIf { it.isJsonObject }?.asJsonObject ?: return result
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
// Check whether package names need to be replaced with UIDs
if (SettingsManager.canUseProcessRouting()) {
val rulesJson = json.get("routing")?.takeIf { it.isJsonObject }?.asJsonObject
?.get("rules")?.takeIf { it.isJsonArray }?.asJsonArray
?: JsonArray()
for (elem in rulesJson) {
val rule = elem.takeIf { it.isJsonObject }?.asJsonObject ?: continue
val process = rule.get("process")?.takeIf { it.isJsonArray }?.asJsonArray ?: continue
val packages = process.mapNotNull {
it.takeIf { it.isJsonPrimitive && it.asJsonPrimitive.isString }?.asString
}.takeIf { it.isNotEmpty() } ?: continue
val uids = PackageUidResolver.packageNamesToUids(context, packages).takeIf { it.isNotEmpty() } ?: continue
rule.add("process", JsonArray().apply { uids.forEach { add(it) } })
}
}
// 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)
// check if tun inbound exists
val inboundsJson = json.get("inbounds")?.takeIf { it.isJsonArray }?.asJsonArray
?: JsonArray().also { json.add("inbounds", it) }
val tunNotExists = inboundsJson.none { elem ->
elem.isJsonObject && elem.asJsonObject.get("protocol")
?.takeIf { it.isJsonPrimitive && it.asJsonPrimitive.isString }
?.asString == "tun"
}
val updatedRaw = JsonUtil.toJsonPretty(json) ?: return result
return ConfigResult(true, guid, updatedRaw)
if (tunNotExists) {
// add tun inbound from template
initV2rayConfig(context)?.let { templateConfig ->
templateConfig.inbounds.firstOrNull { it.tag == "tun" }?.let { inboundTun ->
inboundTun.settings?.mtu = SettingsManager.getVpnMtu()
inboundsJson.add(JsonUtil.parseString(JsonUtil.toJson(inboundTun)))
}
}
}
return JsonUtil.toJsonPretty(json)?.let { ConfigResult(true, guid, it) } ?: result
}
/**
@@ -139,7 +151,7 @@ object V2rayConfigManager {
private fun getV2rayGroupConfig(context: Context, guid: String, config: ProfileItem): ConfigResult {
val result = ConfigResult(false)
val serverList = MmkvManager.decodeServerList()
val serverList = MmkvManager.decodeAllServerList()
val configList = serverList
.mapNotNull { id -> MmkvManager.decodeServerConfig(id) }
.filter { profile ->
@@ -186,7 +198,7 @@ object V2rayConfigManager {
val address = config.server ?: return result
if (!Utils.isPureIpAddress(address)) {
if (!Utils.isValidUrl(address)) {
Log.w(AppConfig.TAG, "$address is an invalid ip or domain")
LogUtil.w(AppConfig.TAG, "$address is an invalid ip or domain")
return result
}
}
@@ -200,7 +212,7 @@ object V2rayConfigManager {
getOutbounds(v2rayConfig, config) ?: return result
getMoreOutbounds(v2rayConfig, config.subscriptionId)
getRouting(v2rayConfig)
getRouting(context, v2rayConfig)
getFakeDns(v2rayConfig)
@@ -233,7 +245,7 @@ object V2rayConfigManager {
.toList()
if (validConfigs.isEmpty()) {
Log.w(AppConfig.TAG, "All configs are invalid")
LogUtil.w(AppConfig.TAG, "All configs are invalid")
return null
}
@@ -257,7 +269,7 @@ object V2rayConfigManager {
outboundsList.addAll(v2rayConfig.outbounds)
v2rayConfig.outbounds = ArrayList(outboundsList)
getRouting(v2rayConfig)
getRouting(context, v2rayConfig)
getFakeDns(v2rayConfig)
@@ -295,7 +307,7 @@ object V2rayConfigManager {
val address = config.server ?: return result
if (!Utils.isPureIpAddress(address)) {
if (!Utils.isValidUrl(address)) {
Log.w(AppConfig.TAG, "$address is an invalid ip or domain")
LogUtil.w(AppConfig.TAG, "$address is an invalid ip or domain")
return result
}
}
@@ -373,12 +385,29 @@ object V2rayConfigManager {
private fun getInbounds(v2rayConfig: V2rayConfig): Boolean {
try {
val socksPort = SettingsManager.getSocksPort()
val socksUsername = SettingsManager.getSocksUsername()
val socksPassword = SettingsManager.getSocksPassword()
val inbound1 = v2rayConfig.inbounds[0]
if (inbound1.settings == null) {
inbound1.settings = V2rayConfig.InboundBean.InSettingsBean()
}
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_PROXY_SHARING) != true) {
inbound1.listen = AppConfig.LOOPBACK
}
inbound1.port = socksPort
if (socksUsername != null && socksPassword != null) {
inbound1.settings?.auth = "password"
inbound1.settings?.accounts = listOf(
V2rayConfig.InboundBean.InSettingsBean.SocksAccountBean(
user = socksUsername,
pass = socksPassword
)
)
} else {
inbound1.settings?.auth = "noauth"
inbound1.settings?.accounts = null
}
val fakedns = MmkvManager.decodeSettingsBool(AppConfig.PREF_FAKE_DNS_ENABLED) == true
val sniffAllTlsAndHttp =
MmkvManager.decodeSettingsBool(AppConfig.PREF_SNIFFING_ENABLED, true) != false
@@ -397,6 +426,8 @@ object V2rayConfigManager {
inbound2.tag = EConfigType.HTTP.name.lowercase()
inbound2.port = SettingsManager.getHttpPort()
inbound2.protocol = EConfigType.HTTP.name.lowercase()
inbound2.settings?.auth = null
inbound2.settings?.udp = null
v2rayConfig.inbounds.add(inbound2)
}
@@ -406,7 +437,7 @@ object V2rayConfigManager {
inboundTun?.sniffing = inbound1.sniffing
}
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to configure inbounds", e)
LogUtil.e(AppConfig.TAG, "Failed to configure inbounds", e)
return false
}
return true
@@ -427,6 +458,45 @@ object V2rayConfigManager {
}
}
/**
* Pre-pass: scans all routing rulesets for non-builtin outbound tags,
* looks up the matching profile by remarks, converts it to an OutboundBean,
* and appends it to v2rayConfig.outbounds. This must run before getRouting
* so that every custom tag is already present when routing rules are applied.
*
* @param v2rayConfig The V2ray configuration object to be modified
*/
private fun injectCustomOutbounds(v2rayConfig: V2rayConfig) {
val existingTags = v2rayConfig.outbounds.mapTo(mutableSetOf()) { it.tag }
val rulesetItems = MmkvManager.decodeRoutingRulesets() ?: return
rulesetItems
.filter { it.enabled }
.mapNotNull { it.outboundTag.takeIf { tag -> tag.isNotBlank() } }
.filter { it !in BUILTIN_OUTBOUND_TAGS }
.distinct()
.forEach { tag ->
if (tag in existingTags) return@forEach
try {
val profile = SettingsManager.getServerViaRemarks(tag) ?: run {
LogUtil.w(AppConfig.TAG, "Custom outbound tag '$tag' not found by remarks, skipping")
return@forEach
}
val outbound = convertProfile2Outbound(profile) ?: run {
LogUtil.w(AppConfig.TAG, "Could not convert profile '$tag' to outbound, skipping")
return@forEach
}
updateOutboundWithGlobalSettings(outbound)
outbound.tag = tag
v2rayConfig.outbounds.add(outbound)
existingTags.add(tag)
LogUtil.d(AppConfig.TAG, "Injected custom outbound: tag='$tag'")
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to inject custom outbound for tag '$tag', skipping", e)
}
}
}
/**
* Configures routing settings for V2ray.
*
@@ -435,19 +505,22 @@ object V2rayConfigManager {
* @param v2rayConfig The V2ray configuration object to be modified
* @return true if routing configuration was successful, false otherwise
*/
private fun getRouting(v2rayConfig: V2rayConfig): Boolean {
private fun getRouting(context: Context, v2rayConfig: V2rayConfig): Boolean {
try {
v2rayConfig.routing.domainStrategy =
MmkvManager.decodeSettingsString(AppConfig.PREF_ROUTING_DOMAIN_STRATEGY)
?: "AsIs"
// Pre-pass: inject outbounds referenced by remarks in routing rules
injectCustomOutbounds(v2rayConfig)
val rulesetItems = MmkvManager.decodeRoutingRulesets()
rulesetItems?.forEach { key ->
getRoutingUserRule(key, v2rayConfig)
getRoutingUserRule(context, key, v2rayConfig)
}
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to configure routing", e)
LogUtil.e(AppConfig.TAG, "Failed to configure routing", e)
return false
}
return true
@@ -459,7 +532,7 @@ object V2rayConfigManager {
* @param item The ruleset item to add
* @param v2rayConfig The V2ray configuration object to be modified
*/
private fun getRoutingUserRule(item: RulesetItem?, v2rayConfig: V2rayConfig) {
private fun getRoutingUserRule(context: Context, item: RulesetItem?, v2rayConfig: V2rayConfig) {
try {
if (item == null || !item.enabled) {
return
@@ -467,10 +540,45 @@ object V2rayConfigManager {
val rule = JsonUtil.fromJson(JsonUtil.toJson(item), RulesBean::class.java) ?: return
// Replace specific geoip rules with ext versions
rule.ip?.let { ipList ->
val updatedIpList = ArrayList<String>()
ipList.forEach { ip ->
when (ip) {
AppConfig.GEOIP_CN -> updatedIpList.add("ext:${AppConfig.GEOIP_ONLY_CN_PRIVATE_DAT}:cn")
AppConfig.GEOIP_PRIVATE -> updatedIpList.add("ext:${AppConfig.GEOIP_ONLY_CN_PRIVATE_DAT}:private")
else -> updatedIpList.add(ip)
}
}
rule.ip = updatedIpList
}
if (SettingsManager.canUseProcessRouting()) {
// Convert process package names to UIDs
rule.process?.let { processList ->
if (processList.isNotEmpty()) {
val uids = PackageUidResolver.packageNamesToUids(context, processList)
rule.process = uids.ifEmpty { null }
}
}
} else {
rule.process = null
}
// If the outbound tag is a custom one that failed to inject, fall back to proxy
val outboundTag = rule.outboundTag
if (!outboundTag.isNullOrBlank()
&& outboundTag !in BUILTIN_OUTBOUND_TAGS
&& v2rayConfig.outbounds.none { it.tag == outboundTag }
) {
LogUtil.w(AppConfig.TAG, "Outbound tag '$outboundTag' not found, falling back to '${AppConfig.TAG_PROXY}'")
rule.outboundTag = AppConfig.TAG_PROXY
}
v2rayConfig.routing.rules.add(rule)
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to apply routing user rule", e)
LogUtil.e(AppConfig.TAG, "Failed to apply routing user rule", e)
}
}
@@ -525,7 +633,7 @@ object V2rayConfigManager {
)
}
if(SettingsManager.isVpnMode()) {
if (SettingsManager.isVpnMode()) {
if (SettingsManager.isUsingHevTun()) {
//hev-socks5-tunnel dns routing
v2rayConfig.routing.rules.add(
@@ -559,7 +667,7 @@ object V2rayConfigManager {
)
}
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to configure custom local DNS", e)
LogUtil.e(AppConfig.TAG, "Failed to configure custom local DNS", e)
return false
}
return true
@@ -640,7 +748,7 @@ object V2rayConfigManager {
if (userHostsMap != null) hosts.putAll(userHostsMap)
}
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to configure user DNS hosts", e)
LogUtil.e(AppConfig.TAG, "Failed to configure user DNS hosts", e)
}
// DNS dns
@@ -666,7 +774,7 @@ object V2rayConfigManager {
)
)
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to configure DNS", e)
LogUtil.e(AppConfig.TAG, "Failed to configure DNS", e)
return false
}
return true
@@ -697,8 +805,6 @@ object V2rayConfigManager {
} else {
v2rayConfig.outbounds.add(outbound)
}
updateOutboundFragment(v2rayConfig)
return true
}
@@ -751,7 +857,7 @@ object V2rayConfigManager {
}
}
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to configure more outbounds", e)
LogUtil.e(AppConfig.TAG, "Failed to configure more outbounds", e)
return false
}
@@ -802,7 +908,7 @@ object V2rayConfigManager {
} else {
outbound.settings?.address as List<*>
}
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_PREFER_IPV6) != true) {
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_IPV6_ENABLED) != true) {
localTunAddr = listOf(localTunAddr.first())
}
outbound.settings?.address = localTunAddr
@@ -832,7 +938,7 @@ object V2rayConfigManager {
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to update outbound with global settings", e)
LogUtil.e(AppConfig.TAG, "Failed to update outbound with global settings", e)
return false
}
return true
@@ -847,13 +953,13 @@ object V2rayConfigManager {
private fun getBalance(v2rayConfig: V2rayConfig, config: ProfileItem) {
try {
v2rayConfig.routing.rules.forEach { rule ->
if (rule.outboundTag == "proxy") {
if (rule.outboundTag == AppConfig.TAG_PROXY) {
rule.outboundTag = null
rule.balancerTag = AppConfig.TAG_BALANCER
}
}
val lstSelector = listOf("proxy-")
val lstSelector = listOf("proxy-")
when (config.policyGroupType) {
// Least Ping goto else
"1" -> {
@@ -876,6 +982,7 @@ object V2rayConfigManager {
)
)
}
"2" -> {
// Random
val balancer = V2rayConfig.RoutingBean.BalancerBean(
@@ -887,6 +994,7 @@ object V2rayConfigManager {
)
v2rayConfig.routing.balancers = listOf(balancer)
}
"3" -> {
// Round Robin
val balancer = V2rayConfig.RoutingBean.BalancerBean(
@@ -898,6 +1006,7 @@ object V2rayConfigManager {
)
v2rayConfig.routing.balancers = listOf(balancer)
}
else -> {
// Default: Least Ping
val balancer = V2rayConfig.RoutingBean.BalancerBean(
@@ -933,7 +1042,7 @@ object V2rayConfigManager {
)
}
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to configure balance", e)
LogUtil.e(AppConfig.TAG, "Failed to configure balance", e)
}
}
@@ -942,70 +1051,82 @@ object V2rayConfigManager {
*
* Configures packet fragmentation for TLS and REALITY protocols if enabled.
*
* @param v2rayConfig The V2ray configuration object to be modified
* @param streamSettings The streamSettings object to be modified
* @return true if fragment configuration was successful, false otherwise
*/
private fun updateOutboundFragment(v2rayConfig: V2rayConfig): Boolean {
private fun updateOutboundFragment(streamSettings: StreamSettingsBean): Boolean {
try {
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_FRAGMENT_ENABLED, false) == false) {
return true
}
if (v2rayConfig.outbounds[0].streamSettings?.security != AppConfig.TLS
&& v2rayConfig.outbounds[0].streamSettings?.security != AppConfig.REALITY
if (streamSettings.security != AppConfig.TLS
&& streamSettings.security != AppConfig.REALITY
) {
return true
}
val fragmentOutbound =
OutboundBean(
protocol = AppConfig.PROTOCOL_FREEDOM,
tag = AppConfig.TAG_FRAGMENT,
mux = null
)
if (streamSettings.sockopt?.dialerProxy.isNotNullEmpty()) {
return true
}
var packets =
MmkvManager.decodeSettingsString(AppConfig.PREF_FRAGMENT_PACKETS) ?: "tlshello"
if (v2rayConfig.outbounds[0].streamSettings?.security == AppConfig.REALITY
if (streamSettings.security == AppConfig.REALITY
&& packets == "tlshello"
) {
packets = "1-3"
} else if (v2rayConfig.outbounds[0].streamSettings?.security == AppConfig.TLS
} else if (streamSettings.security == AppConfig.TLS
&& packets != "tlshello"
) {
packets = "tlshello"
}
fragmentOutbound.settings = OutSettingsBean(
fragment = OutSettingsBean.FragmentBean(
val fragmentMask = StreamSettingsBean.FinalMaskBean.MaskBean(
type = "fragment",
settings = StreamSettingsBean.FinalMaskBean.MaskBean.MaskSettingsBean(
packets = packets,
length = MmkvManager.decodeSettingsString(AppConfig.PREF_FRAGMENT_LENGTH)
?: "50-100",
interval = MmkvManager.decodeSettingsString(AppConfig.PREF_FRAGMENT_INTERVAL)
delay = MmkvManager.decodeSettingsString(AppConfig.PREF_FRAGMENT_INTERVAL)
?: "10-20"
),
noises = listOf(
OutSettingsBean.NoiseBean(
type = "rand",
packet = "10-20",
delay = "10-16",
)
)
val noiseMask = StreamSettingsBean.FinalMaskBean.MaskBean(
type = "noise",
settings = StreamSettingsBean.FinalMaskBean.MaskBean.MaskSettingsBean(
noise = listOf(
StreamSettingsBean.FinalMaskBean.MaskBean.MaskSettingsBean.NoiseMaskBean(
rand = "10-20",
delay = "10-16",
)
)
),
)
fragmentOutbound.streamSettings = StreamSettingsBean(
sockopt = StreamSettingsBean.SockoptBean(
TcpNoDelay = true,
mark = 255
)
)
v2rayConfig.outbounds.add(fragmentOutbound)
//proxy chain
v2rayConfig.outbounds[0].streamSettings?.sockopt =
StreamSettingsBean.SockoptBean(
dialerProxy = AppConfig.TAG_FRAGMENT
)
val finalMaskObj = streamSettings.finalmask?.let { existingFinalMask ->
JsonUtil.parseString(JsonUtil.toJson(existingFinalMask))
} ?: com.google.gson.JsonObject()
// finalmask.tcp / finalmask.udp are arrays; prepend mask at index 0.
fun prependMask(scope: String, mask: StreamSettingsBean.FinalMaskBean.MaskBean) {
val current = finalMaskObj.get(scope)
if (current != null && current.isJsonArray && current.asJsonArray.size() > 0) {
return
}
val newArray = JsonArray()
newArray.add(JsonUtil.parseString(JsonUtil.toJson(mask)))
if (current != null && current.isJsonArray) {
current.asJsonArray.forEach { newArray.add(it) }
}
finalMaskObj.add(scope, newArray)
}
prependMask("tcp", fragmentMask)
prependMask("udp", noiseMask)
streamSettings.finalmask = finalMaskObj
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to update outbound fragment", e)
LogUtil.e(AppConfig.TAG, "Failed to update outbound fragment", e)
return false
}
return true
@@ -1161,7 +1282,7 @@ object V2rayConfigManager {
val authority = profileItem.authority
val xhttpMode = profileItem.xhttpMode
val xhttpExtra = profileItem.xhttpExtra
val finalMask = profileItem.finalMask
var sni: String? = null
streamSettings.network = transport.ifEmpty { NetworkType.TCP.type }
when (streamSettings.network) {
@@ -1191,28 +1312,34 @@ object V2rayConfigManager {
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
}
))
udpMaskList.add(
StreamSettingsBean.FinalMaskBean.MaskBean(
type = kcpHeaderType,
settings = if (headerType == "dns" && !host.isNullOrEmpty()) {
StreamSettingsBean.FinalMaskBean.MaskBean.MaskSettingsBean(
domain = host
)
} else {
null
}
)
)
}
if (seed.isNullOrEmpty()) {
udpMaskList.add(StreamSettingsBean.FinalMaskBean.MaskBean(
type = "mkcp-original"
))
} else {
udpMaskList.add(StreamSettingsBean.FinalMaskBean.MaskBean(
type = "mkcp-aes128gcm",
settings = StreamSettingsBean.FinalMaskBean.MaskBean.MaskSettingsBean(
password = seed
udpMaskList.add(
StreamSettingsBean.FinalMaskBean.MaskBean(
type = "mkcp-original"
)
))
)
} else {
udpMaskList.add(
StreamSettingsBean.FinalMaskBean.MaskBean(
type = "mkcp-aes128gcm",
settings = StreamSettingsBean.FinalMaskBean.MaskBean.MaskSettingsBean(
password = seed
)
)
)
}
streamSettings.finalmask = StreamSettingsBean.FinalMaskBean(
udp = udpMaskList.toList()
@@ -1277,21 +1404,69 @@ object V2rayConfigManager {
val hysteriaSetting = StreamSettingsBean.HysteriaSettingsBean(
version = 2,
auth = profileItem.password.orEmpty(),
up = profileItem.bandwidthUp?.ifEmpty { "0" }.orEmpty(),
down = profileItem.bandwidthDown?.ifEmpty { "0" }.orEmpty(),
udphop = null
)
val quicParams = StreamSettingsBean.FinalMaskBean.QuicParamsBean(
brutalUp = profileItem.bandwidthUp?.nullIfBlank(),
brutalDown = profileItem.bandwidthDown?.nullIfBlank(),
)
quicParams.congestion = if (quicParams.brutalUp != null || quicParams.brutalDown != null) "brutal" else null
if (profileItem.portHopping.isNotNullEmpty()) {
hysteriaSetting.udphop = StreamSettingsBean.HysteriaSettingsBean.HysteriaUdpHopBean(
port = profileItem.portHopping,
interval = profileItem.portHoppingInterval
?.trim()
?.toIntOrNull()
?.takeIf { it >= 5 }
?: 30
val rawInterval = profileItem.portHoppingInterval?.trim().nullIfBlank()
val interval = if (rawInterval == null) {
"30"
} else {
val singleValue = rawInterval.toIntOrNull()
if (singleValue != null) {
if (singleValue < 5) {
"30"
} else {
rawInterval
}
} else {
val parts = rawInterval.split('-')
if (parts.size == 2) {
val start = parts[0].trim().toIntOrNull()
val end = parts[1].trim().toIntOrNull()
if (start != null && end != null) {
val minStart = maxOf(5, start)
val minEnd = maxOf(minStart, end)
"$minStart-$minEnd"
} else {
"30"
}
} else {
"30"
}
}
}
quicParams.udpHop = StreamSettingsBean.FinalMaskBean.QuicParamsBean.UdpHopBean(
ports = profileItem.portHopping,
interval = interval
)
}
val finalmask = StreamSettingsBean.FinalMaskBean(
quicParams = quicParams
)
if (profileItem.obfsPassword.isNotNullEmpty()) {
finalmask.udp = listOf(
StreamSettingsBean.FinalMaskBean.MaskBean(
type = "salamander",
settings = StreamSettingsBean.FinalMaskBean.MaskBean.MaskSettingsBean(
password = profileItem.obfsPassword.orEmpty()
)
)
)
}
streamSettings.hysteriaSettings = hysteriaSetting
streamSettings.finalmask = finalmask
}
}
finalMask?.let {
val parsedFinalMask = JsonUtil.parseString(finalMask)
if (parsedFinalMask != null) {
streamSettings.finalmask = parsedFinalMask
} else {
LogUtil.w("V2rayConfigManager", "Invalid finalMask JSON, keeping previously generated finalmask")
}
}
return sni
@@ -1325,7 +1500,7 @@ object V2rayConfigManager {
allowInsecure = allowInsecure,
serverName = sni.nullIfBlank(),
fingerprint = profileItem.fingerPrint.nullIfBlank(),
alpn = profileItem.alpn?.split(",")?.map { it.trim() }?.filter { it.isNotEmpty() }.takeIf { !it.isNullOrEmpty() },
alpn = profileItem.alpn?.split(",")?.map { it.trim() }?.filter { it.isNotEmpty() }.takeIf { !it.isNullOrEmpty() },
echConfigList = profileItem.echConfigList.nullIfBlank(),
echForceQuery = profileItem.echForceQuery.nullIfBlank(),
pinnedPeerCertSha256 = profileItem.pinnedCA256.nullIfBlank(),
@@ -1341,6 +1516,10 @@ object V2rayConfigManager {
streamSettings.tlsSettings = null
streamSettings.realitySettings = tlsSetting
}
if (profileItem.finalMask.isNullOrEmpty()) {
updateOutboundFragment(streamSettings)
}
}
//endregion
@@ -1,8 +1,8 @@
package com.v2ray.ang.handler
import android.util.Log
import com.v2ray.ang.AppConfig
import com.v2ray.ang.dto.WebDavConfig
import com.v2ray.ang.util.LogUtil
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.Credentials
@@ -67,14 +67,14 @@ object WebDavManager {
cl.newCall(req).execute().use { resp ->
val success = resp.isSuccessful
if (success) {
Log.i(AppConfig.TAG, "WebDAV upload success: $remote")
LogUtil.i(AppConfig.TAG, "WebDAV upload success: $remote")
} else {
Log.e(AppConfig.TAG, "WebDAV upload failed: $remote (HTTP ${resp.code})")
LogUtil.e(AppConfig.TAG, "WebDAV upload failed: $remote (HTTP ${resp.code})")
}
return@withContext success
}
} catch (e: Exception) {
Log.e(AppConfig.TAG, "WebDAV upload exception: $remote", e)
LogUtil.e(AppConfig.TAG, "WebDAV upload exception: $remote", e)
return@withContext false
}
}
@@ -93,7 +93,7 @@ object WebDavManager {
val req = applyAuth(Request.Builder().url(remote).get()).build()
cl.newCall(req).execute().use { resp ->
if (!resp.isSuccessful) {
Log.e(AppConfig.TAG, "WebDAV download failed: $remote (HTTP ${resp.code})")
LogUtil.e(AppConfig.TAG, "WebDAV download failed: $remote (HTTP ${resp.code})")
return@withContext false
}
@@ -104,11 +104,11 @@ object WebDavManager {
}
}
Log.i(AppConfig.TAG, "WebDAV download success: $remote")
LogUtil.i(AppConfig.TAG, "WebDAV download success: $remote")
return@withContext true
}
} catch (e: Exception) {
Log.e(AppConfig.TAG, "WebDAV download exception: $remote", e)
LogUtil.e(AppConfig.TAG, "WebDAV download exception: $remote", e)
return@withContext false
}
}
@@ -169,7 +169,7 @@ object WebDavManager {
cl.newCall(req).execute().use { resp ->
// 201 Created or 405 Method Not Allowed (already exists) are acceptable
if (resp.code != 201 && resp.code != 405 && resp.code != 409) {
Log.w(AppConfig.TAG, "WebDAV MKCOL $mkUrl returned ${resp.code}")
LogUtil.w(AppConfig.TAG, "WebDAV MKCOL $mkUrl returned ${resp.code}")
}
}
} catch (_: Exception) {
@@ -177,7 +177,7 @@ object WebDavManager {
}
}
} catch (e: Exception) {
Log.e(AppConfig.TAG, "WebDAV ensureRemoteDirs error", e)
LogUtil.e(AppConfig.TAG, "WebDAV ensureRemoteDirs error", e)
}
}
}
@@ -3,13 +3,13 @@ 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
import com.v2ray.ang.util.LogUtil
/**
* Helper for choosing and creating files using Android Storage Access Framework.
@@ -58,7 +58,7 @@ class FileChooserHelper(private val activity: AppCompatActivity) {
Intent.createChooser(intent, activity.getString(R.string.title_file_chooser))
)
} catch (ex: ActivityNotFoundException) {
Log.e(AppConfig.TAG, "File chooser activity not found", ex)
LogUtil.e(AppConfig.TAG, "File chooser activity not found", ex)
activity.toast(R.string.toast_require_file_manager)
fileChooserCallback?.invoke(null)
fileChooserCallback = null
@@ -79,7 +79,7 @@ class FileChooserHelper(private val activity: AppCompatActivity) {
try {
documentCreateLauncher.launch(fileName)
} catch (ex: ActivityNotFoundException) {
Log.e(AppConfig.TAG, "Document creator activity not found", ex)
LogUtil.e(AppConfig.TAG, "Document creator activity not found", ex)
activity.toast(R.string.toast_require_file_manager)
documentCreateCallback?.invoke(null)
documentCreateCallback = null
@@ -5,6 +5,7 @@ import com.v2ray.ang.AppConfig
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.SettingsChangeManager
import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.util.LogUtil
/**
* PreferenceDataStore implementation that bridges AndroidX Preference framework to MMKV storage.
@@ -73,11 +74,16 @@ class MmkvPreferenceDataStore : PreferenceDataStore() {
// Internal helper: notify other modules about setting changes
private fun notifySettingChanged(key: String) {
if (key == AppConfig.PREF_LOGLEVEL) {
LogUtil.refreshLogLevel()
}
// Call SettingsManager.setNightMode if UI mode changed
if (key == AppConfig.PREF_UI_MODE_NIGHT) {
SettingsManager.setNightMode()
}
// Notify listeners that require service restart or reinit
SettingsChangeManager.makeRestartService()
SettingsChangeManager.makeSetupGroupTab()
}
}
@@ -3,8 +3,10 @@ package com.v2ray.ang.receiver
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import com.v2ray.ang.AppConfig
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.V2RayServiceManager
import com.v2ray.ang.util.LogUtil
class BootReceiver : BroadcastReceiver() {
/**
@@ -16,8 +18,24 @@ class BootReceiver : BroadcastReceiver() {
* @param intent The Intent being received.
*/
override fun onReceive(context: Context?, intent: Intent?) {
if (context == null || intent?.action != Intent.ACTION_BOOT_COMPLETED) return
if (!MmkvManager.decodeStartOnBoot() || MmkvManager.getSelectServer().isNullOrEmpty()) return
LogUtil.i(AppConfig.TAG, "BootReceiver received: ${intent?.action}")
if (context == null || intent?.action != Intent.ACTION_BOOT_COMPLETED) {
LogUtil.w(AppConfig.TAG, "BootReceiver: Invalid context or action")
return
}
if (!MmkvManager.decodeStartOnBoot()) {
LogUtil.i(AppConfig.TAG, "BootReceiver: Auto-start on boot is disabled")
return
}
if (MmkvManager.getSelectServer().isNullOrEmpty()) {
LogUtil.w(AppConfig.TAG, "BootReceiver: No server selected")
return
}
LogUtil.i(AppConfig.TAG, "BootReceiver: Starting V2Ray service")
V2RayServiceManager.startVService(context)
}
}
@@ -6,6 +6,7 @@ import android.content.Intent
import android.text.TextUtils
import com.v2ray.ang.AppConfig
import com.v2ray.ang.handler.V2RayServiceManager
import com.v2ray.ang.util.LogUtil
class TaskerReceiver : BroadcastReceiver() {
@@ -35,7 +36,7 @@ class TaskerReceiver : BroadcastReceiver() {
V2RayServiceManager.stopVService(context)
}
} catch (e: Exception) {
android.util.Log.e(AppConfig.TAG, "Error processing Tasker broadcast", e)
LogUtil.e(AppConfig.TAG, "Error processing Tasker broadcast", e)
}
}
}
@@ -1,8 +1,8 @@
package com.v2ray.ang.service
import android.content.Context
import android.util.Log
import com.v2ray.ang.AppConfig
import com.v2ray.ang.util.LogUtil
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -16,7 +16,7 @@ class ProcessService {
* @param cmd The command to run.
*/
fun runProcess(context: Context, cmd: MutableList<String>) {
Log.i(AppConfig.TAG, cmd.toString())
LogUtil.i(AppConfig.TAG, cmd.toString())
try {
val proBuilder = ProcessBuilder(cmd)
@@ -27,14 +27,14 @@ class ProcessService {
CoroutineScope(Dispatchers.IO).launch {
Thread.sleep(50L)
Log.i(AppConfig.TAG, "runProcess check")
LogUtil.i(AppConfig.TAG, "runProcess check")
process?.waitFor()
Log.i(AppConfig.TAG, "runProcess exited")
LogUtil.i(AppConfig.TAG, "runProcess exited")
}
Log.i(AppConfig.TAG, process.toString())
LogUtil.i(AppConfig.TAG, process.toString())
} catch (e: Exception) {
Log.e(AppConfig.TAG, e.toString(), e)
LogUtil.e(AppConfig.TAG, e.toString(), e)
}
}
@@ -43,10 +43,10 @@ class ProcessService {
*/
fun stopProcess() {
try {
Log.i(AppConfig.TAG, "runProcess destroy")
LogUtil.i(AppConfig.TAG, "runProcess destroy")
process?.destroy()
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to destroy process", e)
LogUtil.e(AppConfig.TAG, "Failed to destroy process", e)
}
}
}
@@ -7,11 +7,11 @@ import android.content.IntentFilter
import android.graphics.drawable.Icon
import android.service.quicksettings.Tile
import android.service.quicksettings.TileService
import android.util.Log
import androidx.core.content.ContextCompat
import com.v2ray.ang.AppConfig
import com.v2ray.ang.R
import com.v2ray.ang.handler.V2RayServiceManager
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.MessageUtil
import com.v2ray.ang.util.Utils
import java.lang.ref.SoftReference
@@ -63,7 +63,7 @@ class QSTileService : TileService() {
applicationContext.unregisterReceiver(mMsgReceive)
mMsgReceive = null
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to unregister receiver", e)
LogUtil.e(AppConfig.TAG, "Failed to unregister receiver", e)
}
}
@@ -2,11 +2,11 @@ package com.v2ray.ang.service
import android.content.Context
import android.os.ParcelFileDescriptor
import android.util.Log
import com.v2ray.ang.AppConfig
import com.v2ray.ang.contracts.Tun2SocksControl
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.util.LogUtil
import java.io.File
/**
@@ -22,9 +22,11 @@ class TProxyService(
@JvmStatic
@Suppress("FunctionName")
private external fun TProxyStartService(configPath: String, fd: Int)
@JvmStatic
@Suppress("FunctionName")
private external fun TProxyStopService()
@JvmStatic
@Suppress("FunctionName")
private external fun TProxyGetStats(): LongArray?
@@ -38,32 +40,36 @@ class TProxyService(
* Starts the tun2socks process with the appropriate parameters.
*/
override fun startTun2Socks() {
// Log.i(AppConfig.TAG, "Starting HevSocks5Tunnel via JNI")
// LogUtil.i(AppConfig.TAG, "Starting HevSocks5Tunnel via JNI")
val configContent = buildConfig()
val configFile = File(context.filesDir, "hev-socks5-tunnel.yaml").apply {
writeText(configContent)
}
// Log.i(AppConfig.TAG, "Config file created: ${configFile.absolutePath}")
Log.d(AppConfig.TAG, "HevSocks5Tunnel Config content:\n$configContent")
// LogUtil.i(AppConfig.TAG, "Config file created: ${configFile.absolutePath}")
LogUtil.d(AppConfig.TAG, "HevSocks5Tunnel Config content:\n$configContent")
try {
// Log.i(AppConfig.TAG, "TProxyStartService...")
// LogUtil.i(AppConfig.TAG, "TProxyStartService...")
TProxyStartService(configFile.absolutePath, vpnInterface.fd)
} catch (e: Exception) {
Log.e(AppConfig.TAG, "HevSocks5Tunnel exception: ${e.message}")
LogUtil.e(AppConfig.TAG, "HevSocks5Tunnel exception: ${e.message}")
}
}
private fun buildConfig(): String {
val socksPort = SettingsManager.getSocksPort()
val socksUsername = SettingsManager.getSocksUsername()
val socksPassword = SettingsManager.getSocksPassword()
val vpnConfig = SettingsManager.getCurrentVpnInterfaceAddressConfig()
val escapedSocksUsername = socksUsername?.replace("'", "''")
val escapedSocksPassword = socksPassword?.replace("'", "''")
return buildString {
appendLine("tunnel:")
appendLine(" mtu: ${SettingsManager.getVpnMtu()}")
appendLine(" ipv4: ${vpnConfig.ipv4Client}")
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_PREFER_IPV6)) {
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_IPV6_ENABLED)) {
appendLine(" ipv6: '${vpnConfig.ipv6Client}'")
}
@@ -71,6 +77,10 @@ class TProxyService(
appendLine(" port: ${socksPort}")
appendLine(" address: ${AppConfig.LOOPBACK}")
appendLine(" udp: 'udp'")
if (escapedSocksUsername != null && escapedSocksPassword != null) {
appendLine(" username: '${escapedSocksUsername}'")
appendLine(" password: '${escapedSocksPassword}'")
}
// Read-write timeout settings
val timeoutSetting = MmkvManager.decodeSettingsString(AppConfig.PREF_HEV_TUNNEL_RW_TIMEOUT) ?: AppConfig.HEVTUN_RW_TIMEOUT
@@ -92,10 +102,10 @@ class TProxyService(
*/
override fun stopTun2Socks() {
try {
Log.i(AppConfig.TAG, "TProxyStopService...")
LogUtil.i(AppConfig.TAG, "TProxyStopService...")
TProxyStopService()
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to stop hev-socks5-tunnel", e)
LogUtil.e(AppConfig.TAG, "Failed to stop hev-socks5-tunnel", e)
}
}
}
@@ -4,9 +4,11 @@ import android.app.Service
import android.content.Context
import android.content.Intent
import android.os.IBinder
import com.v2ray.ang.AppConfig
import com.v2ray.ang.contracts.ServiceControl
import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.handler.V2RayServiceManager
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.MyContextWrapper
import java.lang.ref.SoftReference
@@ -16,6 +18,7 @@ class V2RayProxyOnlyService : Service(), ServiceControl {
*/
override fun onCreate() {
super.onCreate()
LogUtil.i(AppConfig.TAG, "StartCore-Proxy: Service created")
V2RayServiceManager.serviceControl = SoftReference(this)
}
@@ -27,6 +30,7 @@ class V2RayProxyOnlyService : Service(), ServiceControl {
* @return The start mode.
*/
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
LogUtil.i(AppConfig.TAG, "StartCore-Proxy: Service command received")
V2RayServiceManager.startCoreLoop(null)
return START_STICKY
}
@@ -6,7 +6,9 @@ import android.os.IBinder
import com.v2ray.ang.AppConfig
import com.v2ray.ang.AppConfig.MSG_MEASURE_CONFIG
import com.v2ray.ang.AppConfig.MSG_MEASURE_CONFIG_CANCEL
import com.v2ray.ang.dto.TestServiceMessage
import com.v2ray.ang.extension.serializable
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.V2RayNativeManager
import com.v2ray.ang.util.MessageUtil
import java.util.Collections
@@ -52,10 +54,18 @@ class V2RayTestService : Service() {
* @return The start mode.
*/
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.getIntExtra("key", 0)) {
val message = intent?.serializable<TestServiceMessage>("content") ?: return super.onStartCommand(intent, flags, startId)
when (message.key) {
MSG_MEASURE_CONFIG -> {
val guidsList = intent.serializable<ArrayList<String>>("content")
if (guidsList != null && guidsList.isNotEmpty()) {
val guidsList = if (message.serverGuids.isNotEmpty()) {
message.serverGuids
} else if (message.subscriptionId.isNotEmpty()) {
MmkvManager.decodeServerList(message.subscriptionId)
} else {
MmkvManager.decodeAllServerList()
}
if (guidsList.isNotEmpty()) {
lateinit var worker: RealPingWorkerService
worker = RealPingWorkerService(this, guidsList) { status ->
// notify UI and remove the worker from active list when finished
@@ -1,5 +1,6 @@
package com.v2ray.ang.service
import android.annotation.SuppressLint
import android.app.Service
import android.content.Context
import android.content.Intent
@@ -13,7 +14,6 @@ import android.net.VpnService
import android.os.Build
import android.os.ParcelFileDescriptor
import android.os.StrictMode
import android.util.Log
import androidx.annotation.RequiresApi
import com.v2ray.ang.AppConfig
import com.v2ray.ang.AppConfig.LOOPBACK
@@ -24,10 +24,12 @@ import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.NotificationManager
import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.handler.V2RayServiceManager
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.MyContextWrapper
import com.v2ray.ang.util.Utils
import java.lang.ref.SoftReference
@SuppressLint("VpnServicePolicy")
class V2RayVpnService : VpnService(), ServiceControl {
private lateinit var mInterface: ParcelFileDescriptor
private var isRunning = false
@@ -72,12 +74,14 @@ class V2RayVpnService : VpnService(), ServiceControl {
override fun onCreate() {
super.onCreate()
LogUtil.i(AppConfig.TAG, "StartCore-VPN: Service created")
val policy = StrictMode.ThreadPolicy.Builder().permitAll().build()
StrictMode.setThreadPolicy(policy)
V2RayServiceManager.serviceControl = SoftReference(this)
}
override fun onRevoke() {
LogUtil.w(AppConfig.TAG, "StartCore-VPN: Permission revoked")
stopAllService()
}
@@ -88,10 +92,27 @@ class V2RayVpnService : VpnService(), ServiceControl {
override fun onDestroy() {
super.onDestroy()
LogUtil.i(AppConfig.TAG, "StartCore-VPN: Service destroyed")
// Ensure VPN interface is properly closed when the service is destroyed without
// going through stopAllService() (e.g. when killed unexpectedly). isRunning is
// set to false at the start of stopAllService(), so this guard prevents a double-close.
if (isRunning) {
try {
if (::mInterface.isInitialized) {
mInterface.close()
LogUtil.i(AppConfig.TAG, "StartCore-VPN: VPN interface closed in onDestroy")
}
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "StartCore-VPN: Failed to close interface in onDestroy", e)
}
}
NotificationManager.cancelNotification()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
LogUtil.i(AppConfig.TAG, "StartCore-VPN: Service command received")
setupVpnService()
startService()
return START_STICKY
@@ -103,12 +124,12 @@ class V2RayVpnService : VpnService(), ServiceControl {
}
override fun startService() {
if (mInterface == null) {
Log.e(AppConfig.TAG, "Failed to create VPN interface")
if (!::mInterface.isInitialized) {
LogUtil.e(AppConfig.TAG, "StartCore-VPN: Interface not initialized")
return
}
if (!V2RayServiceManager.startCoreLoop(mInterface)) {
Log.e(AppConfig.TAG, "Failed to start V2Ray core loop")
LogUtil.e(AppConfig.TAG, "StartCore-VPN: Failed to start core loop")
stopAllService()
return
}
@@ -136,13 +157,13 @@ class V2RayVpnService : VpnService(), ServiceControl {
private fun setupVpnService() {
val prepare = prepare(this)
if (prepare != null) {
Log.e(AppConfig.TAG, "VPN preparation failed")
LogUtil.e(AppConfig.TAG, "StartCore-VPN: Permission not granted")
stopSelf()
return
}
if (configureVpnService() != true) {
Log.e(AppConfig.TAG, "VPN configuration failed")
LogUtil.e(AppConfig.TAG, "StartCore-VPN: Configuration failed")
stopSelf()
return
}
@@ -165,9 +186,11 @@ class V2RayVpnService : VpnService(), ServiceControl {
// Close the old interface since the parameters have been changed
try {
mInterface.close()
} catch (ignored: Exception) {
// ignored
if (::mInterface.isInitialized) {
mInterface.close()
}
} catch (e: Exception) {
LogUtil.w(AppConfig.TAG, "Failed to close old interface", e)
}
// Configure platform-specific features
@@ -179,7 +202,7 @@ class V2RayVpnService : VpnService(), ServiceControl {
isRunning = true
return true
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to establish VPN interface", e)
LogUtil.e(AppConfig.TAG, "Failed to establish VPN interface", e)
stopAllService()
}
return false
@@ -210,7 +233,7 @@ class V2RayVpnService : VpnService(), ServiceControl {
}
// Configure IPv6 if enabled
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_PREFER_IPV6) == true) {
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_IPV6_ENABLED) == true) {
builder.addAddress(vpnConfig.ipv6Client, 126)
if (bypassLan) {
builder.addRoute("2000::", 3) // Currently only 1/8 of total IPv6 is in use
@@ -244,7 +267,7 @@ class V2RayVpnService : VpnService(), ServiceControl {
try {
connectivity.requestNetwork(defaultNetworkRequest, defaultNetworkCallback)
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to request default network", e)
LogUtil.e(AppConfig.TAG, "StartCore-VPN: Failed to request network", e)
}
}
@@ -297,7 +320,7 @@ class V2RayVpnService : VpnService(), ServiceControl {
builder.addAllowedApplication(it)
}
} catch (e: PackageManager.NameNotFoundException) {
Log.e(AppConfig.TAG, "Failed to configure app in VPN: ${e.localizedMessage}", e)
LogUtil.e(AppConfig.TAG, "StartCore-VPN: Failed to configure app", e)
}
}
}
@@ -330,8 +353,8 @@ class V2RayVpnService : VpnService(), ServiceControl {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
try {
connectivity.unregisterNetworkCallback(defaultNetworkCallback)
} catch (ignored: Exception) {
// ignored
} catch (e: Exception) {
LogUtil.w(AppConfig.TAG, "StartCore-VPN: Failed to unregister callback", e)
}
}
@@ -348,10 +371,22 @@ class V2RayVpnService : VpnService(), ServiceControl {
//which means the first v2ray core somehow failed to stop and release the port.
stopSelf()
// Add a small delay to allow the async core stop operation to complete
// before closing the VPN interface, preventing a race condition that can
// leave the VPN icon in the status bar after stopping the service.
try {
mInterface.close()
Thread.sleep(100)
} catch (e: InterruptedException) {
LogUtil.w(AppConfig.TAG, "StartCore-VPN: Sleep interrupted", e)
}
try {
if (::mInterface.isInitialized) {
mInterface.close()
LogUtil.i(AppConfig.TAG, "StartCore-VPN: VPN interface closed")
}
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to close VPN interface", e)
LogUtil.e(AppConfig.TAG, "StartCore-VPN: Failed to close interface", e)
}
}
}
@@ -0,0 +1,198 @@
package com.v2ray.ang.ui
import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import androidx.appcompat.widget.SearchView
import androidx.lifecycle.lifecycleScope
import com.v2ray.ang.AppConfig
import com.v2ray.ang.R
import com.v2ray.ang.databinding.ActivityAppPickerBinding
import com.v2ray.ang.dto.AppInfo
import com.v2ray.ang.util.AppManagerUtil
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.PackageUidResolver
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.text.Collator
class AppPickerActivity : BaseActivity() {
companion object {
private const val EXTRA_SELECTED_PACKAGES = "selected_packages"
private const val EXTRA_PICKER_TITLE = "picker_title"
fun createIntent(
context: Context,
selectedPackages: Collection<String> = emptyList(),
title: String? = null
): Intent = Intent(context, AppPickerActivity::class.java).apply {
putStringArrayListExtra(EXTRA_SELECTED_PACKAGES, ArrayList(selectedPackages))
title?.let { putExtra(EXTRA_PICKER_TITLE, it) }
}
fun getSelectedPackages(intent: Intent?): List<String> {
return intent?.getStringArrayListExtra(EXTRA_SELECTED_PACKAGES).orEmpty()
}
}
private val binding by lazy { ActivityAppPickerBinding.inflate(layoutInflater) }
private val initialSelectedPackages by lazy {
intent.getStringArrayListExtra(EXTRA_SELECTED_PACKAGES).orEmpty()
}
private val selectedPackages = LinkedHashSet<String>()
private var appsAll: List<AppInfo> = emptyList()
private val adapter = AppSelectorAdapter(selectedPackages)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = resolveScreenTitle())
selectedPackages.addAll(initialSelectedPackages)
setupRecyclerView()
loadApps()
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.menu_app_picker, menu)
val searchItem = menu.findItem(R.id.search_view)
if (searchItem != null) {
val searchView = searchItem.actionView as SearchView
searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
override fun onQueryTextSubmit(query: String?): Boolean = false
override fun onQueryTextChange(newText: String?): Boolean {
filterApps(newText.orEmpty())
return false
}
})
}
return true
}
override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
R.id.select_all -> {
selectAllVisible()
true
}
R.id.invert_selection -> {
invertVisibleSelection()
true
}
else -> super.onOptionsItemSelected(item)
}
override fun finish() {
setResult(
RESULT_OK,
Intent().apply {
putStringArrayListExtra(EXTRA_SELECTED_PACKAGES, getSelectedPackages())
}
)
super.finish()
}
private fun setupRecyclerView() {
binding.recyclerView.adapter = adapter
addCustomDividerToRecyclerView(binding.recyclerView, this, R.drawable.custom_divider)
}
@SuppressLint("UseCompatLoadingForDrawables")
private fun createSpecialItemUnidentified(): AppInfo {
val icon = requireNotNull(
getDrawable(android.R.drawable.ic_menu_help)
?: getDrawable(android.R.drawable.sym_def_app_icon)
) { "No fallback drawable available" }
return AppInfo(
appName = getString(R.string.app_picker_unknown_app),
packageName = AppConfig.UNIDENTIFIED_PACKAGE,
appIcon = icon,
isSystemApp = false,
isSelected = 0
)
}
private fun loadApps() {
showLoading()
lifecycleScope.launch {
try {
val apps = withContext(Dispatchers.IO) {
val appsList = AppManagerUtil.loadNetworkAppList(this@AppPickerActivity)
val sortedApps = sortApps(appsList)
listOf(createSpecialItemUnidentified()) + sortedApps
}
appsAll = apps
updateDisplayedApps(apps)
} catch (e: Exception) {
LogUtil.e("AppPickerActivity", "Failed to load app list", e)
} finally {
hideLoading()
}
}
}
private fun filterApps(content: String) {
val key = content.uppercase()
val filteredApps = appsAll.filter { app ->
key.isEmpty() || matchesSearch(app, key)
}
updateDisplayedApps(filteredApps)
}
private fun sortApps(apps: List<AppInfo>): List<AppInfo> {
val collator = Collator.getInstance()
return apps.sortedWith { p1, p2 ->
val p1Selected = selectedPackages.contains(p1.packageName)
val p2Selected = selectedPackages.contains(p2.packageName)
when {
p1Selected && !p2Selected -> -1
!p1Selected && p2Selected -> 1
p1.isSystemApp && !p2.isSystemApp -> 1
!p1.isSystemApp && p2.isSystemApp -> -1
else -> collator.compare(p1.appName, p2.appName)
}
}
}
private fun matchesSearch(app: AppInfo, keyword: String): Boolean {
return app.appName.uppercase().contains(keyword) || app.packageName.uppercase().contains(keyword)
}
private fun updateDisplayedApps(apps: List<AppInfo>) {
adapter.submitList(apps)
}
private fun selectAllVisible() {
adapter.apps.forEach { app -> selectedPackages.add(app.packageName) }
adapter.refreshSelection()
}
private fun invertVisibleSelection() {
adapter.apps.forEach { app ->
if (selectedPackages.contains(app.packageName)) {
selectedPackages.remove(app.packageName)
} else {
selectedPackages.add(app.packageName)
}
}
adapter.refreshSelection()
}
private fun getSelectedPackages(): ArrayList<String> {
return ArrayList(selectedPackages.sorted())
}
private fun resolveScreenTitle(): String {
return intent.getStringExtra(EXTRA_PICKER_TITLE) ?: getString(R.string.per_app_proxy_settings)
}
}
@@ -0,0 +1,64 @@
package com.v2ray.ang.ui
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.v2ray.ang.databinding.ItemRecyclerBypassListBinding
import com.v2ray.ang.dto.AppInfo
class AppSelectorAdapter(
private val selectedPackages: MutableSet<String>
) : RecyclerView.Adapter<AppSelectorAdapter.AppViewHolder>() {
var apps: List<AppInfo> = emptyList()
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AppViewHolder {
val binding = ItemRecyclerBypassListBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return AppViewHolder(binding)
}
override fun getItemCount(): Int = apps.size
override fun onBindViewHolder(holder: AppViewHolder, position: Int) {
holder.bind(apps[position])
}
fun submitList(newApps: List<AppInfo>) {
apps = newApps
notifyDataSetChanged()
}
fun refreshSelection() {
notifyDataSetChanged()
}
inner class AppViewHolder(private val binding: ItemRecyclerBypassListBinding) : RecyclerView.ViewHolder(binding.root),
View.OnClickListener {
private lateinit var appInfo: AppInfo
fun bind(item: AppInfo) {
appInfo = item
binding.icon.setImageDrawable(item.appIcon)
binding.name.text = if (item.isSystemApp) {
String.format("** %s", item.appName)
} else {
item.appName
}
binding.packageName.text = item.packageName
binding.checkBox.isChecked = selectedPackages.contains(item.packageName)
itemView.setOnClickListener(this)
}
override fun onClick(v: View?) {
val packageName = appInfo.packageName
if (selectedPackages.contains(packageName)) {
selectedPackages.remove(packageName)
} else {
selectedPackages.add(packageName)
}
binding.checkBox.isChecked = selectedPackages.contains(packageName)
}
}
}
@@ -3,7 +3,6 @@ package com.v2ray.ang.ui
import android.app.AlertDialog
import android.content.Intent
import android.os.Bundle
import android.util.Log
import androidx.core.content.FileProvider
import androidx.lifecycle.lifecycleScope
import com.tencent.mmkv.MMKV
@@ -18,7 +17,9 @@ import com.v2ray.ang.extension.toastError
import com.v2ray.ang.extension.toastSuccess
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.SettingsChangeManager
import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.handler.WebDavManager
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.ZipUtil
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -37,7 +38,7 @@ class BackupActivity : HelperBaseActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
//setContentView(binding.root)
//setContentView(binding.root)
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.title_configuration_backup_restore))
binding.layoutBackup.setOnClickListener {
@@ -124,6 +125,8 @@ class BackupActivity : HelperBaseActivity() {
val count = MMKV.restoreAllFromDirectory(backupDir)
SettingsChangeManager.makeSetupGroupTab()
SettingsChangeManager.makeRestartService()
SettingsManager.initApp(this)
return count > 0
}
@@ -146,7 +149,7 @@ class BackupActivity : HelperBaseActivity() {
toastError(R.string.toast_failure)
}
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Error during file restore", e)
LogUtil.e(AppConfig.TAG, "Error during file restore", e)
toastError(R.string.toast_failure)
}
}
@@ -177,7 +180,7 @@ class BackupActivity : HelperBaseActivity() {
toastError(R.string.toast_failure)
}
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to backup configuration", e)
LogUtil.e(AppConfig.TAG, "Failed to backup configuration", e)
toastError(R.string.toast_failure)
}
}
@@ -214,7 +217,7 @@ class BackupActivity : HelperBaseActivity() {
val ok = try {
WebDavManager.uploadFile(tempFile, WEBDAV_BACKUP_FILE_NAME)
} catch (e: Exception) {
Log.e(AppConfig.TAG, "WebDAV upload error", e)
LogUtil.e(AppConfig.TAG, "WebDAV upload error", e)
false
}
@@ -222,7 +225,7 @@ class BackupActivity : HelperBaseActivity() {
if (ok) toastSuccess(R.string.toast_success) else toastError(R.string.toast_failure)
}
} catch (e: Exception) {
Log.e(AppConfig.TAG, "WebDAV backup error", e)
LogUtil.e(AppConfig.TAG, "WebDAV backup error", e)
withContext(Dispatchers.Main) {
toastError(R.string.toast_failure)
}
@@ -269,7 +272,7 @@ class BackupActivity : HelperBaseActivity() {
}
}
} catch (e: Exception) {
Log.e(AppConfig.TAG, "WebDAV download error", e)
LogUtil.e(AppConfig.TAG, "WebDAV download error", e)
withContext(Dispatchers.Main) { toastError(R.string.toast_failure) }
} finally {
try {
@@ -39,7 +39,7 @@ abstract class BaseFragment<VB : ViewBinding> : Fragment() {
* @param drawableResId The resource ID of the drawable to be used as the divider.
* @param orientation The orientation of the divider (DividerItemDecoration.VERTICAL or DividerItemDecoration.HORIZONTAL).
*/
fun addCustomDividerToRecyclerView(recyclerView: RecyclerView, drawableResId: Int, orientation: Int = DividerItemDecoration.VERTICAL) {
fun addCustomDividerToRecyclerView(recyclerView: RecyclerView, drawableResId: Int, orientation: Int = DividerItemDecoration.VERTICAL) {
// Get the drawable from resources
val drawable = ContextCompat.getDrawable(requireContext(), drawableResId)
requireNotNull(drawable) { "Drawable resource not found" }
@@ -1,7 +1,6 @@
package com.v2ray.ang.ui
import android.os.Bundle
import android.util.Log
import androidx.appcompat.app.AlertDialog
import androidx.lifecycle.lifecycleScope
import com.v2ray.ang.AppConfig
@@ -15,6 +14,7 @@ import com.v2ray.ang.extension.toastSuccess
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.UpdateCheckerManager
import com.v2ray.ang.handler.V2RayNativeManager
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.Utils
import kotlinx.coroutines.launch
@@ -56,10 +56,9 @@ class CheckUpdateActivity : BaseActivity() {
toastSuccess(R.string.update_already_latest_version)
}
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to check for updates: ${e.message}")
LogUtil.e(AppConfig.TAG, "Failed to check for updates: ${e.message}")
toastError(e.message ?: getString(R.string.toast_failure))
}
finally {
} finally {
hideLoading()
}
}
@@ -2,7 +2,6 @@ package com.v2ray.ang.ui
import android.content.Intent
import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
@@ -11,24 +10,27 @@ import androidx.fragment.app.activityViewModels
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import com.v2ray.ang.AppConfig
import com.v2ray.ang.R
import com.v2ray.ang.contracts.MainAdapterListener
import com.v2ray.ang.databinding.FragmentGroupServerBinding
import com.v2ray.ang.databinding.ItemQrcodeBinding
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.extension.toast
import com.v2ray.ang.extension.toastError
import com.v2ray.ang.extension.toastSuccess
import com.v2ray.ang.handler.AngConfigManager
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.helper.SimpleItemTouchHelperCallback
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.viewmodel.MainViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class GroupServerFragment : BaseFragment<FragmentGroupServerBinding>() {
class GroupServerFragment : BaseFragment<FragmentGroupServerBinding>(),
SwipeRefreshLayout.OnRefreshListener {
private val ownerActivity: MainActivity
get() = requireActivity() as MainActivity
private val mainViewModel: MainViewModel by activityViewModels()
@@ -68,15 +70,20 @@ class GroupServerFragment : BaseFragment<FragmentGroupServerBinding>() {
itemTouchHelper = ItemTouchHelper(SimpleItemTouchHelperCallback(adapter, allowSwipe = false))
itemTouchHelper?.attachToRecyclerView(binding.recyclerView)
binding.refreshLayout.isEnabled = false
// binding.refreshLayout.setOnRefreshListener(this)
// // Set the distance to trigger sync to 160dp
// binding.refreshLayout.setDistanceToTriggerSync((160 * resources.displayMetrics.density).toInt())
mainViewModel.updateListAction.observe(viewLifecycleOwner) { index ->
if (mainViewModel.subscriptionId != subId) {
return@observe
}
// Log.d(TAG, "GroupServerFragment updateListAction subId=$subId")
// LogUtil.d(TAG, "GroupServerFragment updateListAction subId=$subId")
adapter.setData(mainViewModel.serversCache, index)
}
// Log.d(TAG, "GroupServerFragment onViewCreated: subId=$subId")
// LogUtil.d(TAG, "GroupServerFragment onViewCreated: subId=$subId")
}
override fun onResume() {
@@ -105,7 +112,7 @@ class GroupServerFragment : BaseFragment<FragmentGroupServerBinding>() {
else -> ownerActivity.toast("else")
}
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Error when sharing server", e)
LogUtil.e(AppConfig.TAG, "Error when sharing server", e)
}
}.show()
}
@@ -142,7 +149,7 @@ class GroupServerFragment : BaseFragment<FragmentGroupServerBinding>() {
* @param guid The server unique identifier
*/
private fun shareFullContent(guid: String) {
ownerActivity.lifecycleScope.launch(Dispatchers.IO) {
lifecycleScope.launch(Dispatchers.IO) {
val result = AngConfigManager.shareFullContent2Clipboard(ownerActivity, guid)
launch(Dispatchers.Main) {
if (result == 0) {
@@ -164,6 +171,7 @@ class GroupServerFragment : BaseFragment<FragmentGroupServerBinding>() {
val intent = Intent().putExtra("guid", guid)
.putExtra("isRunning", mainViewModel.isRunning.value)
.putExtra("createConfigType", profile.configType.value)
.putExtra("subscriptionId", subId)
when (profile.configType) {
EConfigType.CUSTOM -> {
ownerActivity.startActivity(intent.setClass(ownerActivity, ServerCustomConfigActivity::class.java))
@@ -211,7 +219,7 @@ class GroupServerFragment : BaseFragment<FragmentGroupServerBinding>() {
* @param position The position in the list
*/
private fun removeServerSub(guid: String, position: Int) {
ownerActivity.mainViewModel.removeServer(guid)
mainViewModel.removeServer(guid)
adapter.removeServerSub(guid, position)
}
@@ -270,4 +278,43 @@ class GroupServerFragment : BaseFragment<FragmentGroupServerBinding>() {
shareServer(guid, profile, position, shareOptions, skip)
}
}
override fun onRefresh() {
ownerActivity.importConfigViaSub()
//binding.refreshLayout.isRefreshing = false
}
/**
* Scrolls to the currently selected server in the RecyclerView
*/
fun scrollToSelectedServer() {
val selectedGuid = MmkvManager.getSelectServer()
if (selectedGuid.isNullOrEmpty()) {
ownerActivity.toast(R.string.title_file_chooser)
return
}
// Find the position of the selected server
val serversCache = mainViewModel.serversCache
val position = serversCache.indexOfFirst { it.guid == selectedGuid }
val recyclerView = binding.recyclerView
if (position >= 0) {
// Get the layout manager
val layoutManager = recyclerView.layoutManager as? GridLayoutManager
if (layoutManager != null) {
// Scroll to position with offset to center it on screen
// First scroll to position, then adjust to center
recyclerView.post {
layoutManager.scrollToPositionWithOffset(position, recyclerView.height / 3)
}
} else {
// Fallback to smooth scroll if layout manager is not GridLayoutManager
recyclerView.smoothScrollToPosition(position)
}
} else {
ownerActivity.toast(R.string.toast_server_not_found_in_group)
}
}
}
@@ -20,9 +20,9 @@ import com.v2ray.ang.helper.QRCodeScannerHelper
* - 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
private lateinit var fileChooser: FileChooserHelper
private lateinit var permissionRequester: PermissionHelper
private lateinit var qrCodeScanner: QRCodeScannerHelper
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -1,11 +1,11 @@
package com.v2ray.ang.ui
import android.util.Log
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.v2ray.ang.AppConfig
import com.v2ray.ang.databinding.ItemRecyclerLogcatBinding
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.viewmodel.LogcatViewModel
class LogcatRecyclerAdapter(
@@ -33,7 +33,7 @@ class LogcatRecyclerAdapter(
onLongClick?.invoke(log) ?: false
}
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Error binding log view data", e)
LogUtil.e(AppConfig.TAG, "Error binding log view data", e)
}
}
@@ -5,7 +5,6 @@ import android.content.res.ColorStateList
import android.net.Uri
import android.net.VpnService
import android.os.Bundle
import android.util.Log
import android.view.KeyEvent
import android.view.Menu
import android.view.MenuItem
@@ -33,6 +32,7 @@ import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.SettingsChangeManager
import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.handler.V2RayServiceManager
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.Utils
import com.v2ray.ang.viewmodel.MainViewModel
import kotlinx.coroutines.Dispatchers
@@ -179,7 +179,7 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
binding.tvTestState.text = content
}
private fun applyRunningState(isLoading: Boolean, isRunning: Boolean) {
private fun applyRunningState(isLoading: Boolean, isRunning: Boolean) {
if (isLoading) {
binding.fab.setImageResource(R.drawable.ic_fab_check)
return
@@ -339,6 +339,10 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
true
}
R.id.locate_selected_config -> {
locateSelectedServer()
true
}
else -> super.onOptionsItemSelected(item)
}
@@ -381,7 +385,7 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
val clipboard = Utils.getClipboard(this)
importBatchConfig(clipboard)
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to import config from clipboard", e)
LogUtil.e(AppConfig.TAG, "Failed to import config from clipboard", e)
return false
}
return true
@@ -411,7 +415,7 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
toastError(R.string.toast_failure)
hideLoading()
}
Log.e(AppConfig.TAG, "Failed to import batch config", e)
LogUtil.e(AppConfig.TAG, "Failed to import batch config", e)
}
}
}
@@ -423,7 +427,7 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
try {
showFileChooser()
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to import config from local file", e)
LogUtil.e(AppConfig.TAG, "Failed to import config from local file", e)
return false
}
return true
@@ -433,18 +437,27 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
/**
* import config from sub
*/
private fun importConfigViaSub(): Boolean {
fun importConfigViaSub(): Boolean {
showLoading()
lifecycleScope.launch(Dispatchers.IO) {
val count = mainViewModel.updateConfigViaSubAll()
val result = mainViewModel.updateConfigViaSubAll()
delay(500L)
launch(Dispatchers.Main) {
if (count > 0) {
toast(getString(R.string.title_update_config_count, count))
mainViewModel.reloadServerList()
if (result.successCount + result.failureCount + result.skipCount == 0) {
toast(R.string.title_update_subscription_no_subscription)
} else if (result.successCount > 0 && result.failureCount + result.skipCount == 0) {
toast(getString(R.string.title_update_config_count, result.configCount))
} else {
toastError(R.string.toast_failure)
toast(
getString(
R.string.title_update_subscription_result,
result.configCount, result.successCount, result.failureCount, result.skipCount
)
)
}
if (result.configCount > 0) {
mainViewModel.reloadServerList()
}
hideLoading()
}
@@ -556,7 +569,48 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
importBatchConfig(input?.bufferedReader()?.readText())
}
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to read content from URI", e)
LogUtil.e(AppConfig.TAG, "Failed to read content from URI", e)
}
}
/**
* Locates and scrolls to the currently selected server.
* If the selected server is in a different group, automatically switches to that group first.
*/
private fun locateSelectedServer() {
val targetSubscriptionId = mainViewModel.findSubscriptionIdBySelect()
if (targetSubscriptionId.isNullOrEmpty()) {
toast(R.string.title_file_chooser)
return
}
val targetGroupIndex = groupPagerAdapter.groups.indexOfFirst { it.id == targetSubscriptionId }
if (targetGroupIndex < 0) {
toast(R.string.toast_server_not_found_in_group)
return
}
// Switch to target group if needed, then scroll to the server
if (binding.viewPager.currentItem != targetGroupIndex) {
binding.viewPager.setCurrentItem(targetGroupIndex, true)
binding.viewPager.postDelayed({ scrollToSelectedServer(targetGroupIndex) }, 1000)
} else {
scrollToSelectedServer(targetGroupIndex)
}
}
/**
* Scrolls to the selected server in the specified fragment.
* @param groupIndex The index of the group/fragment to scroll in
*/
private fun scrollToSelectedServer(groupIndex: Int) {
val itemId = groupPagerAdapter.getItemId(groupIndex)
val fragment = supportFragmentManager.findFragmentByTag("f$itemId") as? GroupServerFragment
if (fragment?.isAdded == true && fragment.view != null) {
fragment.scrollToSelectedServer()
} else {
toast(R.string.toast_fragment_not_available)
}
}
@@ -113,7 +113,7 @@ class MainRecyclerAdapter(
adapterListener?.onSelectServer(guid)
}
}
}
/**
@@ -3,7 +3,6 @@ package com.v2ray.ang.ui
import android.annotation.SuppressLint
import android.os.Bundle
import android.text.TextUtils
import android.util.Log
import android.view.Menu
import android.view.MenuItem
import android.widget.Toast
@@ -23,6 +22,7 @@ import com.v2ray.ang.handler.SettingsChangeManager
import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.util.AppManagerUtil
import com.v2ray.ang.util.HttpUtil
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.Utils
import com.v2ray.ang.viewmodel.PerAppProxyViewModel
import es.dmoral.toasty.Toasty
@@ -99,7 +99,7 @@ class PerAppProxyActivity : BaseActivity() {
binding.recyclerView.adapter = adapter
} catch (e: Exception) {
Log.e(ANG_PACKAGE, "Error loading apps", e)
LogUtil.e(ANG_PACKAGE, "Error loading apps", e)
} finally {
hideLoading()
}
@@ -133,6 +133,7 @@ class PerAppProxyActivity : BaseActivity() {
allowPerAppProxy()
true
}
R.id.invert_selection -> {
invertSelection()
allowPerAppProxy()
@@ -190,11 +191,13 @@ class PerAppProxyActivity : BaseActivity() {
lifecycleScope.launch(Dispatchers.IO) {
var content = HttpUtil.getUrlContent(url, 5000)
if (content.isNullOrEmpty()) {
val proxyUsername = SettingsManager.getSocksUsername()
val proxyPassword = SettingsManager.getSocksPassword()
val httpPort = SettingsManager.getHttpPort()
content = HttpUtil.getUrlContent(url, 5000, httpPort) ?: ""
content = HttpUtil.getUrlContent(url, 5000, httpPort, proxyUsername, proxyPassword) ?: ""
}
launch(Dispatchers.Main) {
//Log.i(AppConfig.TAG, content)
//LogUtil.i(AppConfig.TAG, content)
selectProxyApp(content, true)
toastSuccess(R.string.toast_success)
hideLoading()
@@ -258,7 +261,7 @@ class PerAppProxyActivity : BaseActivity() {
}
}
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Error selecting proxy app", e)
LogUtil.e(AppConfig.TAG, "Error selecting proxy app", e)
return false
}
return true
@@ -11,7 +11,7 @@ import com.v2ray.ang.viewmodel.PerAppProxyViewModel
class PerAppProxyAdapter(
val apps: List<AppInfo>,
val viewModel: PerAppProxyViewModel
) :RecyclerView.Adapter<PerAppProxyAdapter.BaseViewHolder>() {
) : RecyclerView.Adapter<PerAppProxyAdapter.BaseViewHolder>() {
companion object {
private const val VIEW_TYPE_HEADER = 0
@@ -3,14 +3,19 @@ package com.v2ray.ang.ui
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import android.widget.ArrayAdapter
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AlertDialog
import androidx.lifecycle.lifecycleScope
import com.v2ray.ang.AppConfig.BUILTIN_OUTBOUND_TAGS
import com.v2ray.ang.AppConfig.TAG_PROXY
import com.v2ray.ang.R
import com.v2ray.ang.databinding.ActivityRoutingEditBinding
import com.v2ray.ang.dto.RulesetItem
import com.v2ray.ang.extension.nullIfBlank
import com.v2ray.ang.extension.toast
import com.v2ray.ang.extension.toastSuccess
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.util.Utils
import kotlinx.coroutines.Dispatchers
@@ -19,22 +24,79 @@ import kotlinx.coroutines.launch
class RoutingEditActivity : BaseActivity() {
private val binding by lazy { ActivityRoutingEditBinding.inflate(layoutInflater) }
private val position by lazy { intent.getIntExtra("position", -1) }
private val outbound_tag: Array<out String> by lazy {
resources.getStringArray(R.array.outbound_tag)
private val processPickerLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == RESULT_OK) {
val selectedPackages = AppPickerActivity.getSelectedPackages(result.data)
binding.etProcess.text = Utils.getEditable(selectedPackages.joinToString(","))
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
//setContentView(binding.root)
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.routing_settings_rule_title))
setupOutboundTagInput()
setupProcessPicker()
val rulesetItem = SettingsManager.getRoutingRuleset(position)
if (rulesetItem != null) {
bindingServer(rulesetItem)
} else {
clearServer()
}
SettingsManager.canUseProcessRouting().let { canUse ->
binding.etProcess.isEnabled = canUse
binding.btnProcessPicker.isEnabled = canUse
}
}
private fun setupProcessPicker() {
binding.btnProcessPicker.setOnClickListener {
processPickerLauncher.launch(
AppPickerActivity.createIntent(
context = this,
selectedPackages = getSelectedProcessPackages(),
title = getString(R.string.routing_settings_process)
)
)
}
}
private fun getSelectedProcessPackages(): List<String> {
return binding.etProcess.text
.toString()
.split(",")
.map { it.trim() }
.filter { it.isNotEmpty() }
.distinct()
}
/**
* Sets up the AutoCompleteTextView for outbound tag:
* suggestions = built-in tags (proxy/direct/block) + all existing profile remarks.
* The dropdown button triggers showing the full list without typing.
*/
private fun setupOutboundTagInput() {
val profileRemarks = MmkvManager.decodeAllServerList()
.mapNotNull { id -> MmkvManager.decodeServerConfig(id)?.remarks }
.filter { it.isNotBlank() }
val suggestions = (BUILTIN_OUTBOUND_TAGS.toList() + profileRemarks).distinct()
val adapter = ArrayAdapter(this, android.R.layout.simple_dropdown_item_1line, suggestions)
binding.spOutboundTag.setAdapter(adapter)
// threshold=0 means show all suggestions even before typing; still need focus+request
binding.spOutboundTag.threshold = 0
// Dropdown arrow button shows the full suggestion list
binding.btnOutboundTagDropdown.setOnClickListener {
binding.spOutboundTag.requestFocus()
binding.spOutboundTag.showDropDown()
}
// Also show on field click when it already has focus
binding.spOutboundTag.setOnClickListener {
binding.spOutboundTag.showDropDown()
}
}
private fun bindingServer(rulesetItem: RulesetItem): Boolean {
@@ -42,18 +104,18 @@ class RoutingEditActivity : BaseActivity() {
binding.chkLocked.isChecked = rulesetItem.locked == true
binding.etDomain.text = Utils.getEditable(rulesetItem.domain?.joinToString(","))
binding.etIp.text = Utils.getEditable(rulesetItem.ip?.joinToString(","))
binding.etProcess.text = Utils.getEditable(rulesetItem.process?.joinToString(","))
binding.etPort.text = Utils.getEditable(rulesetItem.port)
binding.etProtocol.text = Utils.getEditable(rulesetItem.protocol?.joinToString(","))
binding.etNetwork.text = Utils.getEditable(rulesetItem.network)
val outbound = Utils.arrayFind(outbound_tag, rulesetItem.outboundTag)
binding.spOutboundTag.setSelection(outbound)
// Set text directly; filter won't fire because we're not using setText(filter=true)
binding.spOutboundTag.setText(rulesetItem.outboundTag, false)
return true
}
private fun clearServer(): Boolean {
binding.etRemarks.text = null
binding.spOutboundTag.setSelection(0)
binding.spOutboundTag.setText(BUILTIN_OUTBOUND_TAGS.first(), false)
return true
}
@@ -65,10 +127,11 @@ class RoutingEditActivity : BaseActivity() {
locked = binding.chkLocked.isChecked
domain = binding.etDomain.text.toString().nullIfBlank()?.split(",")?.map { it.trim() }?.filter { it.isNotEmpty() }
ip = binding.etIp.text.toString().nullIfBlank()?.split(",")?.map { it.trim() }?.filter { it.isNotEmpty() }
process = binding.etProcess.text.toString().nullIfBlank()?.split(",")?.map { it.trim() }?.filter { it.isNotEmpty() }
protocol = binding.etProtocol.text.toString().nullIfBlank()?.split(",")?.map { it.trim() }?.filter { it.isNotEmpty() }
port = binding.etPort.text.toString().nullIfBlank()
network = binding.etNetwork.text.toString().nullIfBlank()
outboundTag = outbound_tag[binding.spOutboundTag.selectedItemPosition]
outboundTag = binding.spOutboundTag.text.toString().trim().ifEmpty { TAG_PROXY }
}
if (rulesetItem.remarks.isNullOrEmpty()) {
@@ -3,7 +3,6 @@ package com.v2ray.ang.ui
import android.annotation.SuppressLint
import android.content.Intent
import android.os.Bundle
import android.util.Log
import android.view.Menu
import android.view.MenuItem
import androidx.activity.viewModels
@@ -21,6 +20,7 @@ import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.helper.SimpleItemTouchHelperCallback
import com.v2ray.ang.util.JsonUtil
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.Utils
import com.v2ray.ang.viewmodel.RoutingSettingsViewModel
import kotlinx.coroutines.Dispatchers
@@ -92,7 +92,7 @@ class RoutingSettingActivity : HelperBaseActivity() {
MmkvManager.encodeSettings(AppConfig.PREF_ROUTING_DOMAIN_STRATEGY, value)
binding.tvDomainStrategySummary.text = value
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to set domain strategy", e)
LogUtil.e(AppConfig.TAG, "Failed to set domain strategy", e)
}
}.show()
}
@@ -110,7 +110,7 @@ class RoutingSettingActivity : HelperBaseActivity() {
}
}
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to import predefined ruleset", e)
LogUtil.e(AppConfig.TAG, "Failed to import predefined ruleset", e)
}
}
.setNegativeButton(android.R.string.cancel) { _, _ ->
@@ -126,7 +126,7 @@ class RoutingSettingActivity : HelperBaseActivity() {
val clipboard = try {
Utils.getClipboard(this)
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to get clipboard content", e)
LogUtil.e(AppConfig.TAG, "Failed to get clipboard content", e)
toastError(R.string.toast_failure)
return@setPositiveButton
}
@@ -25,7 +25,7 @@ class RoutingSettingRecyclerAdapter(
val ruleset = rulesets[position]
holder.itemRoutingSettingBinding.remarks.text = ruleset.remarks
holder.itemRoutingSettingBinding.domainIp.text = (ruleset.domain ?: ruleset.ip ?: ruleset.port)?.toString()
holder.itemRoutingSettingBinding.domainIp.text = (ruleset.domain ?: ruleset.ip ?: ruleset.process ?: ruleset.port)?.toString()
holder.itemRoutingSettingBinding.outboundTag.text = ruleset.outboundTag
holder.itemRoutingSettingBinding.chkEnable.isChecked = ruleset.enabled
holder.itemRoutingSettingBinding.imgLocked.isVisible = ruleset.locked == true
@@ -0,0 +1,20 @@
package com.v2ray.ang.ui
import android.os.Bundle
import com.v2ray.ang.R
import com.v2ray.ang.handler.V2RayServiceManager
class ScStartActivity : BaseActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
moveTaskToBack(true)
setContentView(R.layout.activity_none)
if (!V2RayServiceManager.isRunning()) {
V2RayServiceManager.startVServiceFromToggle(this)
}
finish()
}
}
@@ -0,0 +1,19 @@
package com.v2ray.ang.ui
import android.os.Bundle
import com.v2ray.ang.R
import com.v2ray.ang.handler.V2RayServiceManager
class ScStopActivity : BaseActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
moveTaskToBack(true)
setContentView(R.layout.activity_none)
if (V2RayServiceManager.isRunning()) {
V2RayServiceManager.stopVService(this)
}
finish()
}
}
@@ -3,7 +3,6 @@ package com.v2ray.ang.ui
import android.content.Intent
import android.graphics.BitmapFactory
import android.os.Bundle
import android.util.Log
import android.view.Menu
import android.view.MenuItem
import com.v2ray.ang.AppConfig
@@ -11,6 +10,7 @@ import com.v2ray.ang.R
import com.v2ray.ang.databinding.ActivityNoneBinding
import com.v2ray.ang.extension.toast
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.QRCodeDecoder
import io.github.g00fy2.quickie.QRResult
import io.github.g00fy2.quickie.ScanCustomCode
@@ -18,7 +18,7 @@ import io.github.g00fy2.quickie.config.BarcodeFormat
import io.github.g00fy2.quickie.config.ScannerConfig
class ScannerActivity : HelperBaseActivity() {
private val binding by lazy { ActivityNoneBinding.inflate(layoutInflater) }
private val binding by lazy { ActivityNoneBinding.inflate(layoutInflater) }
private val scanQrCode = registerForActivityResult(ScanCustomCode(), ::handleResult)
@@ -95,7 +95,7 @@ class ScannerActivity : HelperBaseActivity() {
finished(text)
}
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to decode QR code from file", e)
LogUtil.e(AppConfig.TAG, "Failed to decode QR code from file", e)
toast(R.string.toast_decoding_failed)
}
}
@@ -20,10 +20,11 @@ import com.v2ray.ang.AppConfig.TLS
import com.v2ray.ang.AppConfig.WIREGUARD_LOCAL_ADDRESS_V4
import com.v2ray.ang.AppConfig.WIREGUARD_LOCAL_MTU
import com.v2ray.ang.R
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.enums.NetworkType
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.extension.isNotNullEmpty
import com.v2ray.ang.extension.nullIfBlank
import com.v2ray.ang.extension.toast
import com.v2ray.ang.extension.toastSuccess
import com.v2ray.ang.handler.AngConfigManager
@@ -132,6 +133,7 @@ class ServerActivity : BaseActivity() {
private val et_bandwidth_down: EditText? by lazy { findViewById(R.id.et_bandwidth_down) }
private val et_bandwidth_up: EditText? by lazy { findViewById(R.id.et_bandwidth_up) }
private val et_extra: EditText? by lazy { findViewById(R.id.et_extra) }
private val et_fm: EditText? by lazy { findViewById(R.id.et_fm) }
private val layout_extra: LinearLayout? by lazy { findViewById(R.id.layout_extra) }
private val et_ech_config_list: EditText? by lazy { findViewById(R.id.et_ech_config_list) }
private val container_ech_config_list: LinearLayout? by lazy { findViewById(R.id.lay_ech_config_list) }
@@ -241,6 +243,7 @@ class ServerActivity : BaseActivity() {
else -> null
}.orEmpty()
)
et_fm?.text = Utils.getEditable(config?.finalMask)
layout_extra?.visibility =
when (networks[position]) {
@@ -489,6 +492,13 @@ class ServerActivity : BaseActivity() {
}
}
if (et_fm?.text?.toString().isNotNullEmpty()) {
if (JsonUtil.parseString(et_fm?.text?.toString()) == null) {
toast(R.string.server_lab_final_mask)
return false
}
}
saveCommon(config)
saveStreamSettings(config)
saveTls(config)
@@ -498,7 +508,7 @@ class ServerActivity : BaseActivity() {
if (config.subscriptionId.isEmpty() && !subscriptionId.isNullOrEmpty()) {
config.subscriptionId = subscriptionId.orEmpty()
}
//Log.i(AppConfig.TAG, JsonUtil.toJsonPretty(config) ?: "")
//LogUtil.i(AppConfig.TAG, JsonUtil.toJsonPretty(config) ?: "")
MmkvManager.encodeServerConfig(editGuid, config)
toastSuccess(R.string.toast_success)
finish()
@@ -533,7 +543,7 @@ class ServerActivity : BaseActivity() {
} else if (config.configType == EConfigType.HYSTERIA2) {
config.obfsPassword = et_obfs_password?.text?.toString()
config.portHopping = et_port_hop?.text?.toString()
config.portHoppingInterval = et_port_hop_interval?.text?.toString()
config.portHoppingInterval = et_port_hop_interval?.text?.toString()?.trim()
config.bandwidthDown = et_bandwidth_down?.text?.toString()
config.bandwidthUp = et_bandwidth_up?.text?.toString()
}
@@ -557,7 +567,8 @@ class ServerActivity : BaseActivity() {
profileItem.serviceName = path
profileItem.authority = requestHost
profileItem.xhttpMode = transportTypes(networks[network])[type]
profileItem.xhttpExtra = et_extra?.text?.toString()?.trim()
profileItem.xhttpExtra = et_extra?.text?.toString()?.trim().nullIfBlank()
profileItem.finalMask = et_fm?.text?.toString()?.trim()?.nullIfBlank()
}
private fun saveTls(config: ProfileItem) {
@@ -2,7 +2,6 @@ package com.v2ray.ang.ui
import android.os.Bundle
import android.text.TextUtils
import android.util.Log
import android.view.Menu
import android.view.MenuItem
import androidx.appcompat.app.AlertDialog
@@ -11,13 +10,14 @@ import com.blacksquircle.ui.language.json.JsonLanguage
import com.v2ray.ang.AppConfig
import com.v2ray.ang.R
import com.v2ray.ang.databinding.ActivityServerCustomConfigBinding
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.extension.toast
import com.v2ray.ang.extension.toastSuccess
import com.v2ray.ang.fmt.CustomFmt
import com.v2ray.ang.handler.AngConfigManager
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.Utils
class ServerCustomConfigActivity : BaseActivity() {
@@ -79,7 +79,7 @@ class ServerCustomConfigActivity : BaseActivity() {
val profileItem = try {
CustomFmt.parse(binding.editor.text.toString())
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to parse custom configuration", e)
LogUtil.e(AppConfig.TAG, "Failed to parse custom configuration", e)
toast("${getString(R.string.toast_malformed_josn)} ${e.cause?.message}")
return false
}
@@ -8,12 +8,11 @@ import android.widget.ArrayAdapter
import androidx.appcompat.app.AlertDialog
import com.v2ray.ang.R
import com.v2ray.ang.databinding.ActivityServerGroupBinding
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.extension.isNotNullEmpty
import com.v2ray.ang.extension.toast
import com.v2ray.ang.extension.toastSuccess
import com.v2ray.ang.handler.AngConfigManager
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.util.Utils
@@ -98,7 +97,7 @@ class ServerGroupActivity : BaseActivity() {
config.subscriptionId = subscriptionId.orEmpty()
}
config.description = "${binding.spPolicyGroupType.selectedItem} - ${binding.spPolicyGroupSubId.selectedItem} - ${config.policyGroupFilter}"
config.description = "${binding.spPolicyGroupType.selectedItem} - ${binding.spPolicyGroupSubId.selectedItem} - ${config.policyGroupFilter}"
MmkvManager.encodeServerConfig(editGuid, config)
toastSuccess(R.string.toast_success)
@@ -15,8 +15,8 @@ import com.v2ray.ang.AppConfig.VPN
import com.v2ray.ang.R
import com.v2ray.ang.extension.toLongEx
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.helper.MmkvPreferenceDataStore
import com.v2ray.ang.handler.SubscriptionUpdater
import com.v2ray.ang.helper.MmkvPreferenceDataStore
import com.v2ray.ang.util.Utils
import java.util.concurrent.TimeUnit
@@ -51,6 +51,8 @@ class SettingsActivity : BaseActivity() {
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 mode by lazy { findPreference<ListPreference>(AppConfig.PREF_MODE) }
private val socksPort by lazy { findPreference<EditTextPreference>(AppConfig.PREF_SOCKS_PORT) }
private val dynamicSocksPort by lazy { findPreference<CheckBoxPreference>(AppConfig.PREF_DYNAMIC_SOCKS_PORT) }
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) }
@@ -112,15 +114,28 @@ class SettingsActivity : BaseActivity() {
updateHevTunSettings(newValue as Boolean)
true
}
dynamicSocksPort?.setOnPreferenceChangeListener { _, newValue ->
updateDynamicSocksPort(newValue as Boolean)
true
}
}
private fun initPreferenceSummaries() {
fun updateSummary(pref: androidx.preference.Preference) {
when (pref) {
is EditTextPreference -> {
pref.summary = pref.text.orEmpty()
if (pref.key == AppConfig.PREF_SOCKS_PASSWORD) {
pref.summary = if (pref.text.isNullOrEmpty()) "" else "******"
} else {
pref.summary = pref.text.orEmpty()
}
pref.setOnPreferenceChangeListener { p, newValue ->
p.summary = (newValue as? String).orEmpty()
if (p.key == AppConfig.PREF_SOCKS_PASSWORD) {
p.summary = if ((newValue as? String).isNullOrEmpty()) "" else "******"
} else {
p.summary = (newValue as? String).orEmpty()
}
true
}
}
@@ -167,6 +182,8 @@ class SettingsActivity : BaseActivity() {
// Initialize auto-update interval state
autoUpdateInterval?.isEnabled = MmkvManager.decodeSettingsBool(AppConfig.SUBSCRIPTION_AUTO_UPDATE, false)
updateDynamicSocksPort(MmkvManager.decodeSettingsBool(AppConfig.PREF_DYNAMIC_SOCKS_PORT, false))
}
private fun updateMode(value: String?) {
@@ -258,6 +275,10 @@ class SettingsActivity : BaseActivity() {
fragmentInterval?.isEnabled = enabled
}
private fun updateDynamicSocksPort(enabled: Boolean) {
socksPort?.isEnabled = !enabled
}
private fun updateHevTunSettings(enabled: Boolean) {
hevTunLogLevel?.isEnabled = enabled
hevTunRwTimeout?.isEnabled = enabled
@@ -14,6 +14,7 @@ import com.v2ray.ang.extension.toast
import com.v2ray.ang.extension.toastSuccess
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.SettingsChangeManager
import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.util.Utils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -118,7 +119,7 @@ class SubEditActivity : BaseActivity() {
AlertDialog.Builder(this).setMessage(R.string.del_config_comfirm)
.setPositiveButton(android.R.string.ok) { _, _ ->
lifecycleScope.launch(Dispatchers.IO) {
MmkvManager.removeSubscription(editSubId)
SettingsManager.removeSubscriptionWithDefault(editSubId)
launch(Dispatchers.Main) {
finish()
}
@@ -130,7 +131,7 @@ class SubEditActivity : BaseActivity() {
.show()
} else {
lifecycleScope.launch(Dispatchers.IO) {
MmkvManager.removeSubscription(editSubId)
SettingsManager.removeSubscriptionWithDefault(editSubId)
launch(Dispatchers.Main) {
finish()
}
@@ -145,10 +146,6 @@ class SubEditActivity : BaseActivity() {
del_config = menu.findItem(R.id.del_config)
save_config = menu.findItem(R.id.save_config)
if (editSubId.isEmpty()) {
del_config?.isVisible = false
}
return super.onCreateOptionsMenu(menu)
}
@@ -3,7 +3,6 @@ package com.v2ray.ang.ui
import android.annotation.SuppressLint
import android.content.Intent
import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuItem
@@ -18,11 +17,10 @@ import com.v2ray.ang.contracts.BaseAdapterListener
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.toastSuccess
import com.v2ray.ang.handler.AngConfigManager
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.helper.SimpleItemTouchHelperCallback
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.QRCodeDecoder
import com.v2ray.ang.util.Utils
import com.v2ray.ang.viewmodel.SubscriptionsViewModel
@@ -77,16 +75,23 @@ class SubSettingActivity : BaseActivity() {
showLoading()
lifecycleScope.launch(Dispatchers.IO) {
val count = AngConfigManager.updateConfigViaSubAll()
val result = AngConfigManager.updateConfigViaSubAll()
delay(500L)
launch(Dispatchers.Main) {
if (count > 0) {
toastSuccess(R.string.toast_success)
refreshData()
if (result.successCount + result.failureCount + result.skipCount == 0) {
toast(R.string.title_update_subscription_no_subscription)
} else if (result.successCount > 0 && result.failureCount + result.skipCount == 0) {
toast(getString(R.string.title_update_config_count, result.configCount))
} else {
toastError(R.string.toast_failure)
toast(
getString(
R.string.title_update_subscription_result,
result.configCount, result.successCount, result.failureCount, result.skipCount
)
)
}
hideLoading()
refreshData()
}
}
@@ -151,7 +156,7 @@ class SubSettingActivity : BaseActivity() {
else -> ownerActivity.toast("else")
}
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Share subscription failed", e)
LogUtil.e(AppConfig.TAG, "Share subscription failed", e)
}
}.show()
}
@@ -3,7 +3,6 @@ package com.v2ray.ang.ui
import android.content.Intent
import android.os.Bundle
import android.text.TextUtils
import android.util.Log
import android.view.Menu
import android.view.MenuItem
import android.view.View
@@ -13,6 +12,7 @@ import com.v2ray.ang.AppConfig
import com.v2ray.ang.R
import com.v2ray.ang.databinding.ActivityTaskerBinding
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.util.LogUtil
class TaskerActivity : BaseActivity() {
private val binding by lazy { ActivityTaskerBinding.inflate(layoutInflater) }
@@ -30,7 +30,7 @@ class TaskerActivity : BaseActivity() {
lstData.add("Default")
lstGuid.add(AppConfig.TASKER_DEFAULT_GUID)
MmkvManager.decodeServerList().forEach { key ->
MmkvManager.decodeAllServerList().forEach { key ->
MmkvManager.decodeServerConfig(key)?.let { config ->
lstData.add(config.remarks)
lstGuid.add(key)
@@ -62,7 +62,7 @@ class TaskerActivity : BaseActivity() {
}
}
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to initialize Tasker settings", e)
LogUtil.e(AppConfig.TAG, "Failed to initialize Tasker settings", e)
}
}
@@ -3,7 +3,6 @@ package com.v2ray.ang.ui
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.util.Log
import androidx.lifecycle.lifecycleScope
import com.v2ray.ang.AppConfig
import com.v2ray.ang.R
@@ -11,6 +10,7 @@ import com.v2ray.ang.databinding.ActivityLogcatBinding
import com.v2ray.ang.extension.toast
import com.v2ray.ang.extension.toastError
import com.v2ray.ang.handler.AngConfigManager
import com.v2ray.ang.util.LogUtil
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -55,7 +55,7 @@ class UrlSchemeActivity : BaseActivity() {
startActivity(Intent(this, MainActivity::class.java))
finish()
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Error processing URL scheme", e)
LogUtil.e(AppConfig.TAG, "Error processing URL scheme", e)
}
}
@@ -63,7 +63,7 @@ class UrlSchemeActivity : BaseActivity() {
if (uriString.isNullOrEmpty()) {
return
}
Log.i(AppConfig.TAG, uriString)
LogUtil.i(AppConfig.TAG, uriString)
var decodedUrl = URLDecoder.decode(uriString, "UTF-8")
val uri = Uri.parse(decodedUrl)
@@ -71,7 +71,7 @@ class UrlSchemeActivity : BaseActivity() {
if (uri.fragment.isNullOrEmpty() && !fragment.isNullOrEmpty()) {
decodedUrl += "#${fragment}"
}
Log.i(AppConfig.TAG, decodedUrl)
LogUtil.i(AppConfig.TAG, decodedUrl)
lifecycleScope.launch(Dispatchers.IO) {
val (count, countSub) = AngConfigManager.importBatchConfig(decodedUrl, "", false)
withContext(Dispatchers.Main) {
@@ -6,7 +6,6 @@ import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.provider.OpenableColumns
import android.util.Log
import android.view.Menu
import android.view.MenuItem
import androidx.activity.viewModels
@@ -22,6 +21,7 @@ import com.v2ray.ang.extension.toastError
import com.v2ray.ang.extension.toastSuccess
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.Utils
import com.v2ray.ang.viewmodel.UserAssetViewModel
import kotlinx.coroutines.Dispatchers
@@ -84,7 +84,7 @@ class UserAssetActivity : HelperBaseActivity() {
MmkvManager.encodeSettings(AppConfig.PREF_GEO_FILES_SOURCES, value)
binding.tvGeoFilesSourcesSummary.text = value
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to set geo files sources", e)
LogUtil.e(AppConfig.TAG, "Failed to set geo files sources", e)
}
}.show()
}
@@ -136,7 +136,7 @@ class UserAssetActivity : HelperBaseActivity() {
}.also { cursor.close() }
}
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to get cursor name", e)
LogUtil.e(AppConfig.TAG, "Failed to get cursor name", e)
null
}
@@ -162,7 +162,7 @@ class UserAssetActivity : HelperBaseActivity() {
.putExtra(UserAssetUrlActivity.ASSET_URL_QRCODE, url)
)
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to import asset from URL", e)
LogUtil.e(AppConfig.TAG, "Failed to import asset from URL", e)
return false
}
return true
@@ -173,9 +173,11 @@ class UserAssetActivity : HelperBaseActivity() {
showLoading()
toast(R.string.msg_downloading_content)
val proxyUsername = SettingsManager.getSocksUsername()
val proxyPassword = SettingsManager.getSocksPassword()
val httpPort = SettingsManager.getHttpPort()
lifecycleScope.launch(Dispatchers.IO) {
val result = viewModel.downloadGeoFiles(extDir, httpPort)
val result = viewModel.downloadGeoFiles(extDir, httpPort, proxyUsername, proxyPassword)
withContext(Dispatchers.Main) {
if (result.successCount > 0) {
toast(getString(R.string.title_update_config_count, result.successCount))
@@ -2,7 +2,6 @@ package com.v2ray.ang.ui
import android.os.Bundle
import android.text.TextUtils
import android.util.Log
import android.view.Menu
import android.view.MenuItem
import androidx.appcompat.app.AlertDialog
@@ -13,6 +12,7 @@ import com.v2ray.ang.dto.AssetUrlItem
import com.v2ray.ang.extension.toast
import com.v2ray.ang.extension.toastSuccess
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.Utils
import java.io.File
@@ -80,7 +80,7 @@ class UserAssetUrlActivity : BaseActivity() {
try {
file.delete()
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to delete asset file: ${file.path}", e)
LogUtil.e(AppConfig.TAG, "Failed to delete asset file: ${file.path}", e)
}
}
} else {
@@ -1,13 +1,10 @@
package com.v2ray.ang.util
import android.util.Log
import com.v2ray.ang.AppConfig
import com.v2ray.ang.AppConfig.LOOPBACK
import com.v2ray.ang.BuildConfig
import com.v2ray.ang.util.Utils.encode
import com.v2ray.ang.util.Utils.urlDecode
import java.io.File
import java.io.IOException
import java.net.HttpURLConnection
import java.net.IDN
import java.net.Inet6Address
import java.net.InetAddress
@@ -16,6 +13,10 @@ import java.net.MalformedURLException
import java.net.Proxy
import java.net.URI
import java.net.URL
import java.util.concurrent.TimeUnit
import okhttp3.Credentials
import okhttp3.OkHttpClient
import okhttp3.Request
object HttpUtil {
@@ -89,11 +90,11 @@ object HttpUtil {
val ipList = sortedAddresses.mapNotNull { it.hostAddress }
Log.i(AppConfig.TAG, "Resolved IPs for $host: ${ipList.joinToString()}")
LogUtil.i(AppConfig.TAG, "Resolved IPs for $host: ${ipList.joinToString()}")
return ipList
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to resolve host to IP", e)
LogUtil.e(AppConfig.TAG, "Failed to resolve host to IP", e)
return null
}
}
@@ -107,13 +108,31 @@ object HttpUtil {
* @param httpPort The HTTP port to use.
* @return The content of the URL as a string.
*/
fun getUrlContent(url: String, timeout: Int, httpPort: Int = 0): String? {
val conn = createProxyConnection(url, httpPort, timeout, timeout) ?: return null
fun getUrlContent(
url: String,
timeout: Int,
httpPort: Int = 0,
proxyUsername: String? = null,
proxyPassword: String? = null
): String? {
val client = buildOkHttpClient(timeout, httpPort, proxyUsername, proxyPassword, followRedirects = true)
val requestBuilder = Request.Builder()
.url(url)
.get()
.header("Connection", "close")
if (httpPort != 0 && !proxyUsername.isNullOrBlank() && !proxyPassword.isNullOrBlank()) {
requestBuilder.header("Proxy-Authorization", Credentials.basic(proxyUsername, proxyPassword))
}
try {
return conn.inputStream.bufferedReader().readText()
} catch (_: Exception) {
} finally {
conn.disconnect()
client.newCall(requestBuilder.build()).execute().use { response ->
if (!response.isSuccessful) {
LogUtil.w(AppConfig.TAG, "Failed to get URL content, code=${response.code}")
return null
}
return response.body?.string()
}
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to get URL content", e)
}
return null
}
@@ -128,127 +147,147 @@ object HttpUtil {
* @throws IOException If an I/O error occurs.
*/
@Throws(IOException::class)
fun getUrlContentWithUserAgent(url: String?, userAgent: String?, timeout: Int = 15000, httpPort: Int = 0): String {
fun getUrlContentWithUserAgent(
url: String?,
userAgent: String?,
timeout: Int = 15000,
httpPort: Int = 0,
proxyUsername: String? = null,
proxyPassword: String? = null
): String {
var currentUrl = url
var redirects = 0
val maxRedirects = 3
while (redirects++ < maxRedirects) {
if (currentUrl == null) continue
val conn = createProxyConnection(currentUrl, httpPort, timeout, timeout) ?: continue
val client = buildOkHttpClient(timeout, httpPort, proxyUsername, proxyPassword, followRedirects = false)
val finalUserAgent = if (userAgent.isNullOrBlank()) {
"v2rayNG/${BuildConfig.VERSION_NAME}"
} else {
userAgent
}
conn.setRequestProperty("User-agent", finalUserAgent)
conn.connect()
val requestBuilder = Request.Builder()
.url(currentUrl)
.get()
.header("User-agent", finalUserAgent)
.header("Connection", "close")
if (httpPort != 0 && !proxyUsername.isNullOrBlank() && !proxyPassword.isNullOrBlank()) {
requestBuilder.header("Proxy-Authorization", Credentials.basic(proxyUsername, proxyPassword))
}
val responseCode = conn.responseCode
when (responseCode) {
in 300..399 -> {
val location = resolveLocation(conn)
conn.disconnect()
if (location.isNullOrEmpty()) {
throw IOException("Redirect location not found")
client.newCall(requestBuilder.build()).execute().use { response ->
when {
response.isRedirect -> {
val location = response.header("Location")
if (location.isNullOrEmpty()) {
throw IOException("Redirect location not found")
}
currentUrl = resolveLocation(currentUrl, location)
if (currentUrl.isNullOrEmpty()) {
throw IOException("Failed to resolve redirect location")
}
continue
}
currentUrl = location
continue
}
else -> try {
return conn.inputStream.use { it.bufferedReader().readText() }
} finally {
conn.disconnect()
response.isSuccessful -> {
return response.body?.string() ?: ""
}
else -> {
throw IOException("Request failed with status code ${response.code}")
}
}
}
}
throw IOException("Too many redirects")
}
/**
* Creates an HttpURLConnection object connected through a proxy.
*
* @param urlStr The target URL address.
* @param port The port of the proxy server.
* @param connectTimeout The connection timeout in milliseconds (default is 15000 ms).
* @param readTimeout The read timeout in milliseconds (default is 15000 ms).
* @param needStream Whether the connection needs to support streaming.
* @return Returns a configured HttpURLConnection object, or null if it fails.
*/
fun createProxyConnection(
urlStr: String,
port: Int,
connectTimeout: Int = 15000,
readTimeout: Int = 15000,
needStream: Boolean = false
): HttpURLConnection? {
private fun buildOkHttpClient(
timeout: Int,
httpPort: Int,
proxyUsername: String?,
proxyPassword: String?,
followRedirects: Boolean
): OkHttpClient {
val builder = OkHttpClient.Builder()
.connectTimeout(timeout.toLong(), TimeUnit.MILLISECONDS)
.readTimeout(timeout.toLong(), TimeUnit.MILLISECONDS)
.followRedirects(followRedirects)
.followSslRedirects(followRedirects)
var conn: HttpURLConnection? = null
try {
val url = URL(urlStr)
// Create a connection
conn = if (port == 0) {
url.openConnection()
} else {
url.openConnection(
Proxy(
Proxy.Type.HTTP,
InetSocketAddress(LOOPBACK, port)
)
)
} as HttpURLConnection
// Set connection and read timeouts
conn.connectTimeout = connectTimeout
conn.readTimeout = readTimeout
if (!needStream) {
// Set request headers
conn.setRequestProperty("Connection", "close")
// Disable automatic redirects
conn.instanceFollowRedirects = false
// Disable caching
conn.useCaches = false
if (httpPort != 0) {
builder.proxy(Proxy(Proxy.Type.HTTP, InetSocketAddress(LOOPBACK, httpPort)))
if (!proxyUsername.isNullOrBlank() && !proxyPassword.isNullOrBlank()) {
builder.proxyAuthenticator { _, response ->
if (response.request.header("Proxy-Authorization") != null) {
null
} else {
response.request.newBuilder()
.header("Proxy-Authorization", Credentials.basic(proxyUsername, proxyPassword))
.build()
}
}
}
//Add Basic Authorization
url.userInfo?.let {
conn.setRequestProperty(
"Authorization",
"Basic ${encode(urlDecode(it))}"
)
}
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to create proxy connection", e)
// If an exception occurs, close the connection and return null
conn?.disconnect()
return null
}
return conn
return builder.build()
}
// Returns absolute URL string location header sets
fun resolveLocation(conn: HttpURLConnection): String? {
val raw = conn.getHeaderField("Location")?.trim()?.takeIf { it.isNotEmpty() } ?: return null
// Try check url is relative or absolute
private fun resolveLocation(baseUrl: String, raw: String): String? {
return try {
val locUri = URI(raw)
val baseUri = conn.url.toURI()
val baseUri = URI(baseUrl)
val resolved = if (locUri.isAbsolute) locUri else baseUri.resolve(locUri)
resolved.toURL().toString()
} catch (_: Exception) {
// Fallback: url resolver, also should handles //host/...
try {
URL(raw).toString() // absolute with protocol
URL(raw).toString()
} catch (_: MalformedURLException) {
try {
URL(conn.url, raw).toString()
URL(URL(baseUrl), raw).toString()
} catch (_: MalformedURLException) {
null
}
}
}
}
fun downloadToFile(
url: String,
targetFile: File,
timeout: Int = 15000,
httpPort: Int = 0,
proxyUsername: String? = null,
proxyPassword: String? = null
): Boolean {
val client = buildOkHttpClient(timeout, httpPort, proxyUsername, proxyPassword, followRedirects = true)
val requestBuilder = Request.Builder()
.url(url)
.get()
.header("Connection", "close")
if (httpPort != 0 && !proxyUsername.isNullOrBlank() && !proxyPassword.isNullOrBlank()) {
requestBuilder.header("Proxy-Authorization", Credentials.basic(proxyUsername, proxyPassword))
}
return try {
client.newCall(requestBuilder.build()).execute().use { response ->
if (!response.isSuccessful) {
LogUtil.w(AppConfig.TAG, "Failed to download file, code=${response.code}, url=$url")
return false
}
val body = response.body ?: return false
body.byteStream().use { input ->
targetFile.outputStream().use { output ->
input.copyTo(output)
}
}
true
}
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to download file: $url", e)
false
}
}
}
@@ -1,6 +1,5 @@
package com.v2ray.ang.util
import android.util.Log
import com.google.gson.Gson
import com.google.gson.GsonBuilder
import com.google.gson.JsonObject
@@ -72,7 +71,7 @@ object JsonUtil {
try {
return JsonParser.parseString(src).getAsJsonObject()
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to parse JSON string", e)
LogUtil.e(AppConfig.TAG, "Failed to parse JSON string", e)
return null
}
}
@@ -0,0 +1,78 @@
package com.v2ray.ang.util
import android.util.Log
import com.v2ray.ang.AppConfig
import com.v2ray.ang.handler.MmkvManager
import java.util.Locale
object LogUtil {
private const val DEFAULT_LEVEL = "warning"
private const val CACHE_UNSET = Int.MIN_VALUE
@Volatile
private var cachedMinPriority: Int = CACHE_UNSET
private fun parsePriority(level: String?): Int {
return when ((level ?: DEFAULT_LEVEL).lowercase(Locale.US)) {
"verbose" -> Log.VERBOSE
"debug" -> Log.DEBUG
"info" -> Log.INFO
"warn", "warning" -> Log.WARN
"error" -> Log.ERROR
"none", "off" -> Int.MAX_VALUE
else -> Log.WARN
}
}
@Suppress("unused")
fun refreshLogLevel() {
cachedMinPriority = parsePriority(MmkvManager.decodeSettingsString(AppConfig.PREF_LOGLEVEL, DEFAULT_LEVEL))
}
private fun minPriority(): Int {
val cached = cachedMinPriority
if (cached != CACHE_UNSET) {
return cached
}
return synchronized(this) {
val current = cachedMinPriority
if (current != CACHE_UNSET) {
current
} else {
parsePriority(MmkvManager.decodeSettingsString(AppConfig.PREF_LOGLEVEL, DEFAULT_LEVEL)).also {
cachedMinPriority = it
}
}
}
}
private fun isEnabled(priority: Int): Boolean {
return priority >= minPriority()
}
private fun log(priority: Int, tag: String, message: String, throwable: Throwable? = null) {
if (!isEnabled(priority)) return
when {
throwable == null -> Log.println(priority, tag, message)
priority >= Log.ERROR -> Log.e(tag, message, throwable)
priority == Log.WARN -> Log.w(tag, message, throwable)
priority == Log.INFO -> Log.i(tag, message, throwable)
priority == Log.DEBUG -> Log.d(tag, message, throwable)
else -> Log.v(tag, message, throwable)
}
}
fun d(tag: String = AppConfig.TAG, message: String) = log(Log.DEBUG, tag, message)
fun i(tag: String = AppConfig.TAG, message: String) = log(Log.INFO, tag, message)
fun w(tag: String = AppConfig.TAG, message: String) = log(Log.WARN, tag, message)
fun e(tag: String = AppConfig.TAG, message: String) = log(Log.ERROR, tag, message)
fun d(tag: String = AppConfig.TAG, message: String, throwable: Throwable) = log(Log.DEBUG, tag, message, throwable)
fun i(tag: String = AppConfig.TAG, message: String, throwable: Throwable) = log(Log.INFO, tag, message, throwable)
fun w(tag: String = AppConfig.TAG, message: String, throwable: Throwable) = log(Log.WARN, tag, message, throwable)
fun e(tag: String = AppConfig.TAG, message: String, throwable: Throwable) = log(Log.ERROR, tag, message, throwable)
}
@@ -3,8 +3,8 @@ package com.v2ray.ang.util
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.util.Log
import com.v2ray.ang.AppConfig
import com.v2ray.ang.dto.TestServiceMessage
import com.v2ray.ang.service.V2RayTestService
import java.io.Serializable
@@ -37,18 +37,16 @@ object MessageUtil {
* Sends a message to the test service.
*
* @param ctx The context.
* @param what The message identifier.
* @param content The message content.
* @param message The test service message containing key, subscriptionId, and serverGuids.
*/
fun sendMsg2TestService(ctx: Context, what: Int, content: Serializable) {
fun sendMsg2TestService(ctx: Context, message: TestServiceMessage) {
try {
val intent = Intent()
intent.component = ComponentName(ctx, V2RayTestService::class.java)
intent.putExtra("key", what)
intent.putExtra("content", content)
intent.putExtra("content", message)
ctx.startService(intent)
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to send message to test service", e)
LogUtil.e(AppConfig.TAG, "Failed to send message to test service", e)
}
}
@@ -69,7 +67,7 @@ object MessageUtil {
intent.putExtra("content", content)
ctx.sendBroadcast(intent)
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to send message with action: $action", e)
LogUtil.e(AppConfig.TAG, "Failed to send message with action: $action", e)
}
}
}
@@ -0,0 +1,50 @@
package com.v2ray.ang.util
import android.content.Context
import android.content.pm.PackageManager
import com.v2ray.ang.AppConfig
import java.util.concurrent.ConcurrentHashMap
object PackageUidResolver {
// In-process cache to avoid resolving the same package UID repeatedly.
private val packageUidCache = ConcurrentHashMap<String, String>()
val packageUidMap: Map<String, String>
get() = packageUidCache
fun packageNamesToUids(context: Context, packageNames: List<String>): List<String> {
return packageNames.mapNotNull { pkg ->
packageUidCache[pkg] ?: resolveUid(context, pkg)?.also { uid ->
packageUidCache[pkg] = uid
}
}
}
fun uidsToPackageNames(uids: List<String>): List<String> {
if (uids.isEmpty()) return emptyList()
return packageUidCache.filterValues { it in uids }.keys.toList()
}
fun uidToPackageName(uid: String): String? {
return packageUidCache.entries.firstOrNull { it.value == uid }?.key
}
private fun resolveUid(context: Context, packageName: String): String? {
// Special token for connections whose UID cannot be resolved (mapped to -1)
if (packageName == AppConfig.UNIDENTIFIED_PACKAGE) {
val uid = "-1"
LogUtil.d(AppConfig.TAG, "Special package: $packageName -> UID: $uid")
return uid
}
return try {
val uid = context.packageManager.getPackageUid(packageName, 0).toString()
LogUtil.d(AppConfig.TAG, "Package: $packageName -> UID: $uid")
uid
} catch (_: PackageManager.NameNotFoundException) {
LogUtil.w(AppConfig.TAG, "Package not found: $packageName")
null
}
}
}
@@ -11,7 +11,6 @@ import android.os.LocaleList
import android.provider.Settings
import android.text.Editable
import android.util.Base64
import android.util.Log
import android.util.Patterns
import android.webkit.URLUtil
import androidx.core.content.ContextCompat
@@ -79,7 +78,7 @@ object Utils {
val cmb = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
cmb.primaryClip?.getItemAt(0)?.text.toString()
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to get clipboard content", e)
LogUtil.e(AppConfig.TAG, "Failed to get clipboard content", e)
""
}
}
@@ -96,7 +95,7 @@ object Utils {
val clipData = ClipData.newPlainText(null, content)
cmb.setPrimaryClip(clipData)
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to set clipboard content", e)
LogUtil.e(AppConfig.TAG, "Failed to set clipboard content", e)
}
}
@@ -122,12 +121,12 @@ object Utils {
try {
return Base64.decode(text, Base64.NO_WRAP).toString(Charsets.UTF_8)
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to decode standard base64", e)
LogUtil.e(AppConfig.TAG, "Failed to decode standard base64", e)
}
try {
return Base64.decode(text, Base64.NO_WRAP.or(Base64.URL_SAFE)).toString(Charsets.UTF_8)
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to decode URL-safe base64", e)
LogUtil.e(AppConfig.TAG, "Failed to decode URL-safe base64", e)
}
return null
}
@@ -139,7 +138,7 @@ object Utils {
* @param removePadding
* @return The base64 encoded string, or an empty string if encoding fails.
*/
fun encode(text: String, removePadding : Boolean = false): String {
fun encode(text: String, removePadding: Boolean = false): String {
return try {
var encoded = Base64.encodeToString(text.toByteArray(Charsets.UTF_8), Base64.NO_WRAP)
if (removePadding) {
@@ -147,7 +146,7 @@ object Utils {
}
encoded
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to encode text to base64", e)
LogUtil.e(AppConfig.TAG, "Failed to encode text to base64", e)
""
}
}
@@ -190,7 +189,7 @@ object Utils {
return isIpv6Address(addr)
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to validate IP address", e)
LogUtil.e(AppConfig.TAG, "Failed to validate IP address", e)
return false
}
}
@@ -271,7 +270,7 @@ object Utils {
Patterns.DOMAIN_NAME.matcher(value).matches() ||
URLUtil.isValidUrl(value)
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to validate URL", e)
LogUtil.e(AppConfig.TAG, "Failed to validate URL", e)
false
}
}
@@ -287,7 +286,7 @@ object Utils {
val uri = uriString.toUri()
context.startActivity(Intent(Intent.ACTION_VIEW, uri))
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to open URI", e)
LogUtil.e(AppConfig.TAG, "Failed to open URI", e)
}
}
@@ -300,7 +299,7 @@ object Utils {
return try {
UUID.randomUUID().toString().replace("-", "")
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to generate UUID", e)
LogUtil.e(AppConfig.TAG, "Failed to generate UUID", e)
""
}
}
@@ -315,7 +314,7 @@ object Utils {
return try {
URLDecoder.decode(url, Charsets.UTF_8.toString())
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to decode URL", e)
LogUtil.e(AppConfig.TAG, "Failed to decode URL", e)
url
}
}
@@ -330,7 +329,7 @@ object Utils {
return try {
URLEncoder.encode(url, Charsets.UTF_8.toString())
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to encode URL", e)
LogUtil.e(AppConfig.TAG, "Failed to encode URL", e)
url
}
}
@@ -349,7 +348,7 @@ object Utils {
// 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)
LogUtil.e(AppConfig.TAG, "Failed to decode encodeURIComponent", e)
url
}
}
@@ -365,7 +364,7 @@ object Utils {
// 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)
LogUtil.e(AppConfig.TAG, "Failed to encode encodeURIComponent", e)
url
}
}
@@ -387,7 +386,7 @@ object Utils {
}
}
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to read asset file: $fileName", e)
LogUtil.e(AppConfig.TAG, "Failed to read asset file: $fileName", e)
""
}
}
@@ -405,7 +404,7 @@ object Utils {
context.getExternalFilesDir(AppConfig.DIR_ASSETS)?.absolutePath
?: context.getDir(AppConfig.DIR_ASSETS, 0).absolutePath
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to get user asset path", e)
LogUtil.e(AppConfig.TAG, "Failed to get user asset path", e)
""
}
}
@@ -420,7 +419,7 @@ object Utils {
val androidId = Settings.Secure.ANDROID_ID.toByteArray(Charsets.UTF_8)
Base64.encodeToString(androidId.copyOf(32), Base64.NO_PADDING.or(Base64.URL_SAFE))
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to generate device ID", e)
LogUtil.e(AppConfig.TAG, "Failed to generate device ID", e)
""
}
}
@@ -512,7 +511,7 @@ object Utils {
}
}
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to validate subscription URL", e)
LogUtil.e(AppConfig.TAG, "Failed to validate subscription URL", e)
}
return false
}
@@ -582,7 +581,7 @@ object Utils {
// Check if they're in the same subnet
return (ipLong and mask) == (cidrIpLong and mask)
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to check if IP is in CIDR", e)
LogUtil.e(AppConfig.TAG, "Failed to check if IP is in CIDR", e)
return false
}
}
@@ -599,7 +598,7 @@ object Utils {
val sdf = SimpleDateFormat(pattern, locale)
sdf.format(Date(ts))
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to format timestamp", e)
LogUtil.e(AppConfig.TAG, "Failed to format timestamp", e)
""
}
}
@@ -1,6 +1,5 @@
package com.v2ray.ang.util
import android.util.Log
import com.v2ray.ang.AppConfig
import java.io.BufferedOutputStream
import java.io.File
@@ -63,7 +62,7 @@ object ZipUtil {
zos.closeEntry()
zos.close()
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to zip folder", e)
LogUtil.e(AppConfig.TAG, "Failed to zip folder", e)
return false
}
return true
@@ -99,7 +98,7 @@ object ZipUtil {
}
}
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to unzip file", e)
LogUtil.e(AppConfig.TAG, "Failed to unzip file", e)
return false
}
return true
@@ -3,6 +3,7 @@ package com.v2ray.ang.viewmodel
import androidx.lifecycle.ViewModel
import com.v2ray.ang.AppConfig
import com.v2ray.ang.AppConfig.ANG_PACKAGE
import com.v2ray.ang.util.LogUtil
import java.io.IOException
class LogcatViewModel : ViewModel() {
@@ -28,7 +29,7 @@ class LogcatViewModel : ViewModel() {
logsetsAll.addAll(allText)
applyFilter()
} catch (e: IOException) {
android.util.Log.e(AppConfig.TAG, "Failed to get logcat", e)
LogUtil.e(AppConfig.TAG, "Failed to get logcat", e)
}
}
@@ -43,7 +44,7 @@ class LogcatViewModel : ViewModel() {
logsetsAll.clear()
filteredLogs = emptyList()
} catch (e: IOException) {
android.util.Log.e(AppConfig.TAG, "Failed to clear logcat", e)
LogUtil.e(AppConfig.TAG, "Failed to clear logcat", e)
}
}
@@ -6,7 +6,6 @@ import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.res.AssetManager
import android.util.Log
import androidx.core.content.ContextCompat
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.MutableLiveData
@@ -17,6 +16,9 @@ import com.v2ray.ang.R
import com.v2ray.ang.dto.GroupMapItem
import com.v2ray.ang.dto.ServersCache
import com.v2ray.ang.dto.SubscriptionCache
import com.v2ray.ang.dto.SubscriptionUpdateResult
import com.v2ray.ang.dto.TestServiceMessage
import com.v2ray.ang.extension.matchesPattern
import com.v2ray.ang.extension.serializable
import com.v2ray.ang.extension.toastError
import com.v2ray.ang.extension.toastSuccess
@@ -24,6 +26,7 @@ import com.v2ray.ang.handler.AngConfigManager
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.handler.SpeedtestManager
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.MessageUtil
import com.v2ray.ang.util.Utils
import kotlinx.coroutines.CoroutineScope
@@ -33,12 +36,11 @@ import kotlinx.coroutines.cancelChildren
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.Collections
import java.util.regex.PatternSyntaxException
class MainViewModel(application: Application) : AndroidViewModel(application) {
private var serverList = MmkvManager.decodeServerList()
private var serverList = mutableListOf<String>() // MmkvManager.decodeServerList()
var subscriptionId: String = MmkvManager.decodeSettingsString(AppConfig.CACHE_SUBSCRIPTION_ID, "").orEmpty()
//var keywordFilter: String = MmkvManager.MmkvManager.decodeSettingsString(AppConfig.CACHE_KEYWORD_FILTER, "")?:""
var keywordFilter = ""
val serversCache = mutableListOf<ServersCache>()
val isRunning by lazy { MutableLiveData<Boolean>() }
@@ -64,15 +66,20 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
getApplication<AngApplication>().unregisterReceiver(mMsgReceiver)
tcpingTestScope.coroutineContext[Job]?.cancelChildren()
SpeedtestManager.closeAllTcpSockets()
Log.i(AppConfig.TAG, "Main ViewModel is cleared")
LogUtil.i(AppConfig.TAG, "Main ViewModel is cleared")
super.onCleared()
}
/**
* Reloads the server list.
* Reloads the server list based on current subscription filter.
*/
fun reloadServerList() {
serverList = MmkvManager.decodeServerList()
serverList = if (subscriptionId.isEmpty()) {
MmkvManager.decodeAllServerList()
} else {
MmkvManager.decodeServerList(subscriptionId)
}
updateCache()
updateListAction.value = -1
}
@@ -90,38 +97,6 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
}
}
// /**
// * Appends a custom configuration server.
// * @param server The server configuration to append.
// * @return True if the server was successfully appended, false otherwise.
// */
// fun appendCustomConfigServer(server: String): Boolean {
// if (server.contains("inbounds")
// && server.contains("outbounds")
// && server.contains("routing")
// ) {
// try {
// val config = CustomFmt.parse(server) ?: return false
// config.subscriptionId = subscriptionId
// val key = MmkvManager.encodeServerConfig("", config)
// MmkvManager.encodeServerRaw(key, server)
// serverList.add(0, key)
//// val profile = ProfileLiteItem(
//// configType = config.configType,
//// subscriptionId = config.subscriptionId,
//// remarks = config.remarks,
//// server = config.getProxyOutbound()?.getServerAddress(),
//// serverPort = config.getProxyOutbound()?.getServerPort(),
//// )
// serversCache.add(0, ServersCache(key, config))
// return true
// } catch (e: Exception) {
// e.printStackTrace()
// }
// }
// return false
// }
/**
* Swaps the positions of two servers.
* @param fromPosition The initial position of the server.
@@ -129,14 +104,13 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
*/
fun swapServer(fromPosition: Int, toPosition: Int) {
if (subscriptionId.isEmpty()) {
Collections.swap(serverList, fromPosition, toPosition)
} else {
val fromPosition2 = serverList.indexOf(serversCache[fromPosition].guid)
val toPosition2 = serverList.indexOf(serversCache[toPosition].guid)
Collections.swap(serverList, fromPosition2, toPosition2)
return
}
Collections.swap(serverList, fromPosition, toPosition)
Collections.swap(serversCache, fromPosition, toPosition)
MmkvManager.encodeServerList(serverList)
MmkvManager.encodeServerList(serverList, subscriptionId)
}
/**
@@ -145,26 +119,28 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
@Synchronized
fun updateCache() {
serversCache.clear()
val kw = keywordFilter.trim()
val searchRegex = try {
if (kw.isNotEmpty()) Regex(kw, setOf(RegexOption.IGNORE_CASE)) else null
} catch (e: PatternSyntaxException) {
null // Fallback to literal search if regex is invalid
}
for (guid in serverList) {
val profile = MmkvManager.decodeServerConfig(guid) ?: continue
// var profile = MmkvManager.decodeProfileConfig(guid)
// if (profile == null) {
// val config = MmkvManager.decodeServerConfig(guid) ?: continue
// profile = ProfileLiteItem(
// configType = config.configType,
// subscriptionId = config.subscriptionId,
// remarks = config.remarks,
// server = config.getProxyOutbound()?.getServerAddress(),
// serverPort = config.getProxyOutbound()?.getServerPort(),
// )
// MmkvManager.encodeServerConfig(guid, config)
// }
if (subscriptionId.isNotEmpty() && subscriptionId != profile.subscriptionId) {
if (kw.isEmpty()) {
serversCache.add(ServersCache(guid, profile))
continue
}
if (keywordFilter.isEmpty() || profile.remarks.lowercase().contains(keywordFilter.lowercase())) {
val remarks = profile.remarks
val description = profile.description.orEmpty()
val server = profile.server.orEmpty()
val protocol = profile.configType.name
if (remarks.matchesPattern(searchRegex, kw)
|| description.matchesPattern(searchRegex, kw)
|| server.matchesPattern(searchRegex, kw)
|| protocol.matchesPattern(searchRegex, kw)
) {
serversCache.add(ServersCache(guid, profile))
}
}
@@ -172,13 +148,13 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
/**
* Updates the configuration via subscription for all servers.
* @return The number of updated configurations.
* @return Detailed result of the subscription update operation.
*/
fun updateConfigViaSubAll(): Int {
fun updateConfigViaSubAll(): SubscriptionUpdateResult {
if (subscriptionId.isEmpty()) {
return AngConfigManager.updateConfigViaSubAll()
} else {
val subItem = MmkvManager.decodeSubscription(subscriptionId) ?: return 0
val subItem = MmkvManager.decodeSubscription(subscriptionId) ?: return SubscriptionUpdateResult()
return AngConfigManager.updateConfigViaSub(SubscriptionCache(subscriptionId, subItem))
}
}
@@ -232,17 +208,25 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
* Tests the real ping for all servers.
*/
fun testAllRealPing() {
MessageUtil.sendMsg2TestService(getApplication(), AppConfig.MSG_MEASURE_CONFIG_CANCEL, "")
MessageUtil.sendMsg2TestService(
getApplication(),
TestServiceMessage(key = AppConfig.MSG_MEASURE_CONFIG_CANCEL)
)
MmkvManager.clearAllTestDelayResults(serversCache.map { it.guid }.toList())
updateListAction.value = -1
val serversCopy = serversCache.toList()
viewModelScope.launch(Dispatchers.Default) {
val guids = ArrayList<String>(serversCopy.map { it.guid })
if (guids.isEmpty()) {
if (serversCache.isEmpty()) {
return@launch
}
MessageUtil.sendMsg2TestService(getApplication(), AppConfig.MSG_MEASURE_CONFIG, guids)
MessageUtil.sendMsg2TestService(
getApplication(),
TestServiceMessage(
key = AppConfig.MSG_MEASURE_CONFIG,
subscriptionId = subscriptionId,
serverGuids = if (keywordFilter.isNotEmpty()) serversCache.map { it.guid } else emptyList()
)
)
}
}
@@ -279,14 +263,21 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
}
val groups = mutableListOf<GroupMapItem>()
groups.add(
GroupMapItem(
id = "",
remarks = context.getString(R.string.filter_config_all)
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_GROUP_ALL_DISPLAY)) {
groups.add(
GroupMapItem(
id = "",
remarks = context.getString(R.string.filter_config_all)
)
)
)
}
subscriptions.forEach { sub ->
groups.add(GroupMapItem(id = sub.guid, remarks = sub.subscription.remarks))
groups.add(
GroupMapItem(
id = sub.guid,
remarks = sub.subscription.remarks
)
)
}
return groups
}
@@ -368,24 +359,38 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
* Sorts servers by their test results.
*/
fun sortByTestResults() {
if (subscriptionId.isEmpty()) {
MmkvManager.decodeSubsList().forEach { guid ->
sortByTestResultsForSub(guid)
}
} else {
sortByTestResultsForSub(subscriptionId)
}
}
/**
* Sorts servers by their test results for a specific subscription.
* @param subId The subscription ID to sort servers for.
*/
private fun sortByTestResultsForSub(subId: String) {
data class ServerDelay(var guid: String, var testDelayMillis: Long)
val serverDelays = mutableListOf<ServerDelay>()
val serverList = MmkvManager.decodeServerList()
serverList.forEach { key ->
val serverListToSort = MmkvManager.decodeServerList(subId)
serverListToSort.forEach { key ->
val delay = MmkvManager.decodeServerAffiliationInfo(key)?.testDelayMillis ?: 0L
serverDelays.add(ServerDelay(key, if (delay <= 0L) 999999 else delay))
}
serverDelays.sortBy { it.testDelayMillis }
serverDelays.forEach {
serverList.remove(it.guid)
serverList.add(it.guid)
}
val sortedServerList = serverDelays.map { it.guid }.toMutableList()
MmkvManager.encodeServerList(serverList)
// Save the sorted list for this subscription
MmkvManager.encodeServerList(sortedServerList, subId)
}
/**
* Initializes assets.
* @param assets The asset manager.
@@ -405,10 +410,20 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
return
}
keywordFilter = keyword
MmkvManager.encodeSettings(AppConfig.CACHE_KEYWORD_FILTER, keywordFilter)
reloadServerList()
}
fun findSubscriptionIdBySelect(): String? {
// Get the selected server GUID
val selectedGuid = MmkvManager.getSelectServer()
if (selectedGuid.isNullOrEmpty()) {
return null
}
val config = MmkvManager.decodeServerConfig(selectedGuid)
return config?.subscriptionId
}
fun onTestsFinished() {
viewModelScope.launch(Dispatchers.Default) {
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_AUTO_REMOVE_INVALID_AFTER_TEST)) {
@@ -21,7 +21,7 @@ class SubscriptionsViewModel : ViewModel() {
fun remove(subId: String): Boolean {
val changed = subscriptions.removeAll { it.guid == subId }
if (changed) {
MmkvManager.removeSubscription(subId)
SettingsManager.removeSubscriptionWithDefault(subId)
SettingsChangeManager.makeSetupGroupTab()
}
return changed
@@ -1,6 +1,5 @@
package com.v2ray.ang.viewmodel
import android.util.Log
import androidx.lifecycle.ViewModel
import com.v2ray.ang.AppConfig
import com.v2ray.ang.dto.AssetUrlCache
@@ -8,14 +7,13 @@ 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.LogUtil
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")
private val builtInGeoFiles = listOf(AppConfig.GEOSITE_DAT, AppConfig.GEOIP_DAT, AppConfig.GEOIP_ONLY_CN_PRIVATE_DAT)
val itemCount: Int
get() = assets.size
@@ -47,10 +45,26 @@ class UserAssetViewModel : ViewModel() {
)
)
}
return builtInItems + savedAssets
// Force update URL for geoip-only-cn-private.dat
return (builtInItems + savedAssets).map { cache ->
if (cache.assetUrl.remarks == AppConfig.GEOIP_ONLY_CN_PRIVATE_DAT) {
cache.copy(
assetUrl = cache.assetUrl.copy(
url = AppConfig.GEOIP_ONLY_CN_PRIVATE_URL
)
)
} else {
cache
}
}
}
fun downloadGeoFiles(extDir: File, httpPort: Int): GeoDownloadResult {
fun downloadGeoFiles(
extDir: File,
httpPort: Int,
proxyUsername: String? = null,
proxyPassword: String? = null
): GeoDownloadResult {
val snapshot = getAssets()
var successCount = 0
val failures = mutableListOf<String>()
@@ -58,7 +72,7 @@ class UserAssetViewModel : ViewModel() {
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) }) {
if (portsToTry.any { tryDownload(item, extDir, it, proxyUsername, proxyPassword) }) {
successCount++
} else {
failures.add(item.remarks)
@@ -68,25 +82,22 @@ class UserAssetViewModel : ViewModel() {
return GeoDownloadResult(successCount, failures.size, failures)
}
private fun tryDownload(item: AssetUrlItem, extDir: File, httpPort: Int): Boolean {
private fun tryDownload(
item: AssetUrlItem,
extDir: File,
httpPort: Int,
proxyUsername: String? = null,
proxyPassword: String? = null
): 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)
}
}
if (HttpUtil.downloadToFile(item.url, targetTemp, 15000, httpPort, proxyUsername, proxyPassword)) {
targetTemp.renameTo(target)
return true
}
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to download geo file: ${item.remarks}", e)
} finally {
conn.disconnect()
LogUtil.e(AppConfig.TAG, "Failed to download geo file: ${item.remarks}", e)
}
return false
}
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="?attr/colorControlNormal"
android:pathData="M7,10l5,5 5,-5z" />
</vector>
@@ -0,0 +1,13 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="1024"
android:viewportHeight="1024">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M0,512C0,229.23 229.81,0 512,0 794.77,0 1024,229.81 1024,512 1024,794.77 794.19,1024 512,1024 229.23,1024 0,794.19 0,512Z" />
<path
android:fillColor="#FF000000"
android:pathData="M213.333333 789.461333V234.538667C213.333333 168.533333 285.013333 127.530667 341.930667 160.981333l471.68 277.461334c56.106667 32.981333 56.106667 114.133333 0 147.114666L341.930667 863.018667C285.056 896.469333 213.333333 855.466667 213.333333 789.461333z" />
</vector>
@@ -0,0 +1,13 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="1024"
android:viewportHeight="1024">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M0,512C0,229.23 229.81,0 512,0 794.77,0 1024,229.81 1024,512 1024,794.77 794.19,1024 512,1024 229.23,1024 0,794.19 0,512Z" />
<path
android:fillColor="#FF000000"
android:pathData="M256 192h512c35.392 0 64 28.608 64 64v512c0 35.392-28.608 64-64 64H256c-35.392 0-64-28.608-64-64V256c0-35.392 28.608-64 64-64z" />
</vector>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.recyclerview.widget.RecyclerView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/recycler_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scrollbars="vertical"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" />
@@ -29,6 +29,7 @@
<ImageView
android:layout_width="@dimen/image_size_dp24"
android:layout_height="@dimen/image_size_dp24"
android:importantForAccessibility="no"
app:srcCompat="@drawable/ic_backup_24dp" />
<LinearLayout
@@ -61,6 +62,7 @@
<ImageView
android:layout_width="@dimen/image_size_dp24"
android:layout_height="@dimen/image_size_dp24"
android:importantForAccessibility="no"
app:srcCompat="@drawable/ic_share_24dp" />
<TextView
@@ -85,6 +87,7 @@
<ImageView
android:layout_width="@dimen/image_size_dp24"
android:layout_height="@dimen/image_size_dp24"
android:importantForAccessibility="no"
app:srcCompat="@drawable/ic_restore_24dp" />
<TextView
@@ -117,6 +120,7 @@
<ImageView
android:layout_width="@dimen/image_size_dp24"
android:layout_height="@dimen/image_size_dp24"
android:importantForAccessibility="no"
app:srcCompat="@drawable/ic_settings_24dp" />
<LinearLayout
@@ -68,6 +68,7 @@
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackgroundBorderless"
android:clickable="true"
android:contentDescription="@string/switch_bypass_apps_mode"
android:focusable="true"
android:gravity="center"
android:padding="@dimen/padding_spacing_dp8">
@@ -8,7 +8,7 @@
tools:context=".ui.LogcatActivity">
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/refreshLayout"
android:id="@+id/refresh_layout"
android:layout_width="match_parent"
android:layout_height="match_parent">
@@ -65,6 +65,7 @@
android:layout_width="match_parent"
android:layout_height="@dimen/view_height_dp64"
android:clickable="true"
android:contentDescription="@string/connection_test_pending"
android:focusable="true"
android:nextFocusLeft="@+id/view_pager"
android:nextFocusRight="@+id/fab"
@@ -93,6 +94,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:layout_marginBottom="-16dp"
android:layout_marginEnd="@dimen/padding_spacing_dp16">
<com.google.android.material.floatingactionbutton.FloatingActionButton
@@ -107,6 +109,7 @@
android:nextFocusLeft="@+id/layout_test"
android:src="@drawable/ic_play_24dp"
app:tint="@color/colorWhite"
app:useCompatPadding="true"
app:layout_anchorGravity="bottom|right|end" />
</FrameLayout>
@@ -103,6 +103,47 @@
android:maxLines="10" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/padding_spacing_dp16"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/routing_settings_process" />
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/padding_spacing_dp8"
android:layout_marginBottom="@dimen/padding_spacing_dp8">
<EditText
android:id="@+id/et_process"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/routing_settings_tips"
android:inputType="textMultiLine"
android:maxLines="10"
android:minHeight="48dp"
android:paddingStart="0dp"
android:paddingEnd="48dp" />
<ImageButton
android:id="@+id/btn_process_picker"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentEnd="true"
android:layout_centerVertical="true"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/routing_settings_process"
android:src="@drawable/ic_per_apps_24dp" />
</RelativeLayout>
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
@@ -170,13 +211,33 @@
android:layout_height="wrap_content"
android:text="@string/routing_settings_outbound_tag" />
<Spinner
android:id="@+id/sp_outbound_tag"
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/padding_spacing_dp8"
android:layout_marginBottom="@dimen/padding_spacing_dp8"
android:entries="@array/outbound_tag" />
android:layout_marginBottom="@dimen/padding_spacing_dp8">
<AutoCompleteTextView
android:id="@+id/sp_outbound_tag"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:completionThreshold="0"
android:hint="@string/routing_settings_outbound_tag_hint"
android:inputType="text"
android:imeOptions="actionDone"
android:paddingEnd="40dp" />
<ImageButton
android:id="@+id/btn_outbound_tag_dropdown"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentEnd="true"
android:layout_centerVertical="true"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/routing_settings_outbound_tag"
android:src="@drawable/ic_arrow_drop_down" />
</RelativeLayout>
</LinearLayout>
</LinearLayout>
@@ -85,8 +85,7 @@
<EditText
android:id="@+id/et_port_hop_interval"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="number" />
android:layout_height="wrap_content" />
</LinearLayout>
@@ -3,10 +3,17 @@
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler_view"
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/refresh_layout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scrollbars="vertical" />
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scrollbars="vertical" />
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
</FrameLayout>
@@ -79,6 +79,7 @@
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackgroundBorderless"
android:clickable="true"
android:contentDescription="@string/menu_item_edit_config"
android:focusable="true"
android:gravity="center"
android:nextFocusLeft="@+id/info_container"
@@ -58,6 +58,7 @@
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackgroundBorderless"
android:clickable="true"
android:contentDescription="@string/title_configuration_share"
android:focusable="true"
android:gravity="center"
android:nextFocusLeft="@+id/info_container"
@@ -78,6 +79,7 @@
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackgroundBorderless"
android:clickable="true"
android:contentDescription="@string/menu_item_edit_config"
android:focusable="true"
android:gravity="center"
android:orientation="vertical"
@@ -97,6 +99,7 @@
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackgroundBorderless"
android:clickable="true"
android:contentDescription="@string/menu_item_del_config"
android:focusable="true"
android:gravity="center"
android:orientation="vertical"

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