Compare commits

...

49 Commits

Author SHA1 Message Date
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
2dust edc4856cb8 up 2.0.10 2026-02-22 14:21:20 +08:00
2dust 4dd7eee8fe Throttle notification speed queries
https://github.com/2dust/v2rayNG/issues/5267
2026-02-22 12:54:19 +08:00
2dust 516637954c Use theme primary color for help link text
https://github.com/2dust/v2rayNG/issues/5273
2026-02-22 12:12:46 +08:00
2dust 9e714bfcb3 Disable mux for HYSTERIA
https://github.com/2dust/v2rayNG/issues/5282
2026-02-22 12:08:13 +08:00
2dust 858dc5237a Update AGP to 9.0.1 & Gradle to 9.3.1 2026-02-22 11:32:17 +08:00
2dust 2b4a4f7ecc Increase Toasty bottom offset to 300
https://github.com/2dust/v2rayNG/issues/5306
2026-02-22 11:31:04 +08:00
2dust f9f0c905a4 up 2.0.9 2026-02-07 11:06:16 +08:00
2dust f997335113 Update libs.versions.toml 2026-02-07 11:05:47 +08:00
AmirMohammad Yazdanmanesh ed344cd281 Improve accessibility: mark decorative icons across remaining layouts (#5265)
Add importantForAccessibility="no" to decorative ImageViews in check
update, bypass list, routing, subscription, user asset, and widget
layouts. Add contentDescription to QR code ImageView.
2026-02-07 10:46:10 +08:00
2dust 94775f120a Migrate Hysteria2 pinSHA256 to pinnedCA256
Rename and migrate Hysteria2 certificate pin field from pinSHA256 to pinnedCA256 across code and UI, and add a one-time migration at startup.

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

This ensures existing Hysteria2 profiles keep their pin data while the code and UI use the new field name.
2026-02-07 10:42:19 +08:00
72 changed files with 1030 additions and 343 deletions
+3 -3
View File
@@ -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 = 708
versionName = "2.0.8"
versionCode = 716
versionName = "2.0.16"
multiDexEnabled = true
val abiFilterList = (properties["ABI_FILTERS"] as? String)?.split(';')
@@ -34,16 +34,15 @@ class AngApplication : MultiDexApplication() {
MMKV.initialize(this)
// Ensure critical preference defaults are present in MMKV early
SettingsManager.ensureDefaultSettings()
SettingsManager.setNightMode()
// Initialize WorkManager with the custom configuration
WorkManager.initialize(this, workManagerConfiguration)
SettingsManager.initRoutingRulesets(this)
// Ensure critical preference defaults are present in MMKV early
SettingsManager.initApp(this)
SettingsManager.setNightMode()
es.dmoral.toasty.Toasty.Config.getInstance()
.setGravity(android.view.Gravity.BOTTOM, 0, 200)
.setGravity(android.view.Gravity.BOTTOM, 0, 300)
.apply()
}
}
@@ -16,6 +16,9 @@ object AppConfig {
/** Legacy configuration keys. */
const val ANG_CONFIG = "ang_config"
// Default subscription ID for ungrouped servers
const val DEFAULT_SUBSCRIPTION_ID = "__default_subscription__"
/** Preferences mapped to MMKV storage. */
const val PREF_SNIFFING_ENABLED = "pref_sniffing_enabled"
const val PREF_ROUTE_ONLY_ENABLED = "pref_route_only_enabled"
@@ -48,6 +51,7 @@ object AppConfig {
const val PREF_CONFIRM_REMOVE = "pref_confirm_remove"
const val PREF_START_SCAN_IMMEDIATE = "pref_start_scan_immediate"
const val PREF_DOUBLE_COLUMN_DISPLAY = "pref_double_column_display"
const val PREF_GROUP_ALL_DISPLAY = "pref_group_all_display"
const val PREF_LANGUAGE = "pref_language"
const val PREF_UI_MODE_NIGHT = "pref_ui_mode_night"
const val PREF_PREFER_IPV6 = "pref_prefer_ipv6"
@@ -73,7 +77,6 @@ object AppConfig {
/** Cache keys. */
const val CACHE_SUBSCRIPTION_ID = "cache_subscription_id"
const val CACHE_KEYWORD_FILTER = "cache_keyword_filter"
/** Protocol identifiers. */
const val PROTOCOL_FREEDOM = "freedom"
@@ -129,6 +132,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"
@@ -124,7 +124,7 @@ data class ProfileItem(
&& this.obfsPassword == obj.obfsPassword
&& this.portHopping == obj.portHopping
&& this.portHoppingInterval == obj.portHoppingInterval
&& this.pinSHA256 == obj.pinSHA256
&& this.pinnedCA256 == obj.pinnedCA256
)
}
}
@@ -0,0 +1,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
@@ -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() {
@@ -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
@@ -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,11 @@
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
@@ -44,7 +44,7 @@ object Hysteria2Fmt : FmtBase() {
if (config.portHopping.isNotNullEmpty()) {
config.portHoppingInterval = queryParam["mportHopInt"]
}
config.pinSHA256 = queryParam["pinSHA256"]
config.pinnedCA256 = queryParam["pinSHA256"]
}
@@ -75,8 +75,8 @@ object Hysteria2Fmt : FmtBase() {
if (config.portHoppingInterval.isNotNullEmpty()) {
dicQuery["mportHopInt"] = config.portHoppingInterval.orEmpty()
}
if (config.pinSHA256.isNotNullEmpty()) {
dicQuery["pinSHA256"] = config.pinSHA256.orEmpty()
if (config.pinnedCA256.isNotNullEmpty()) {
dicQuery["pinSHA256"] = config.pinnedCA256.orEmpty()
}
return toUri(config, config.password, dicQuery)
@@ -2,10 +2,10 @@ 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.Utils
@@ -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
@@ -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
@@ -3,11 +3,11 @@ 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
@@ -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
@@ -7,10 +7,12 @@ import android.util.Log
import com.v2ray.ang.AppConfig
import com.v2ray.ang.AppConfig.HY2
import com.v2ray.ang.R
import com.v2ray.ang.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
@@ -219,41 +221,137 @@ 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)
}
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.
*
@@ -319,22 +417,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,72 +453,71 @@ 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
return null
}
return 0
}
/**
* Updates the configuration via all subscriptions.
*
* @return The number of configurations updated.
* @return Detailed result of the subscription update operation.
*/
fun updateConfigViaSubAll(): Int {
var count = 0
try {
MmkvManager.decodeSubscriptions().forEach {
count += updateConfigViaSub(it)
fun updateConfigViaSubAll(): SubscriptionUpdateResult {
return try {
val subscriptions = MmkvManager.decodeSubscriptions()
subscriptions.fold(SubscriptionUpdateResult()) { acc, subscription ->
acc + updateConfigViaSub(subscription)
}
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to update config via all subscriptions", e)
return 0
SubscriptionUpdateResult()
}
return count
}
/**
* Updates the configuration via a subscription.
*
* @param it The subscription item.
* @return The number of configurations updated.
* @return Subscription update result.
*/
fun updateConfigViaSub(it: 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)
@@ -443,18 +539,25 @@ object AngConfigManager {
}
}
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")
return SubscriptionUpdateResult(
configCount = count,
successCount = 1
)
} else {
// Got response but no valid configs parsed
return SubscriptionUpdateResult(failureCount = 1)
}
return count
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to update config via subscription", e)
return 0
return SubscriptionUpdateResult(failureCount = 1)
}
}
@@ -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.
*/
@@ -9,6 +9,7 @@ import android.content.Context
import android.content.Intent
import android.graphics.Color
import android.os.Build
import android.util.Log
import androidx.annotation.RequiresApi
import androidx.core.app.NotificationCompat
import com.v2ray.ang.AppConfig
@@ -30,6 +31,7 @@ object NotificationManager {
private const val NOTIFICATION_PENDING_INTENT_STOP_V2RAY = 1
private const val NOTIFICATION_PENDING_INTENT_RESTART_V2RAY = 2
private const val NOTIFICATION_ICON_THRESHOLD = 3000
private const val QUERY_INTERVAL_MS = 3000L
private var lastQueryTime = 0L
private var mBuilder: NotificationCompat.Builder? = null
@@ -44,7 +46,6 @@ object NotificationManager {
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_SPEED_ENABLED) != true) return
if (speedNotificationJob != null || V2RayServiceManager.isRunning() == false) return
lastQueryTime = System.currentTimeMillis()
var lastZeroSpeed = false
val outboundTags = currentConfig?.getAllOutboundTags()
outboundTags?.remove(AppConfig.TAG_DIRECT)
@@ -52,7 +53,17 @@ object NotificationManager {
speedNotificationJob = CoroutineScope(Dispatchers.IO).launch {
while (isActive) {
val queryTime = System.currentTimeMillis()
val sinceLastQueryInSeconds = (queryTime - lastQueryTime) / 1000.0
val sinceLastQueryIn = (queryTime - lastQueryTime)
// If the query interval is too short, skip this round to avoid excessive CPU usage
if (sinceLastQueryIn < QUERY_INTERVAL_MS) {
Log.w(AppConfig.TAG, "Query interval too short: ${sinceLastQueryIn}ms, skipping")
lastQueryTime = queryTime
delay(QUERY_INTERVAL_MS)
continue
}
val sinceLastQueryInSeconds = sinceLastQueryIn / 1000.0
var proxyTotal = 0L
val text = StringBuilder()
outboundTags?.forEach {
@@ -78,7 +89,7 @@ object NotificationManager {
}
lastZeroSpeed = zeroSpeed
lastQueryTime = queryTime
delay(3000)
delay(QUERY_INTERVAL_MS)
}
}
}
@@ -89,6 +100,10 @@ object NotificationManager {
*/
fun showNotification(currentConfig: ProfileItem?) {
val service = getService() ?: return
// Reset last query time to avoid querying stats too soon after showing the notification
lastQueryTime = System.currentTimeMillis()
val flags = PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
val startMainIntent = Intent(service, MainActivity::class.java)
@@ -7,19 +7,25 @@ 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.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.Utils
import java.io.File
@@ -29,11 +35,19 @@ import java.util.Locale
object SettingsManager {
fun initApp(context: Context) {
ensureDefaultSettings()
//ensureDefaultSubscription()
initRoutingRulesets(context)
migrateServerListToSubscriptions()
migrateHysteria2PinSHA256()
}
/**
* Initialize routing rulesets.
* @param context The application context.
*/
fun initRoutingRulesets(context: Context) {
private fun initRoutingRulesets(context: Context) {
val exist = MmkvManager.decodeRoutingRulesets()
if (exist.isNullOrEmpty()) {
val rulesetList = getPresetRoutingRulesets(context)
@@ -221,14 +235,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) {
// Log.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)
}
/**
@@ -256,7 +293,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() }
@@ -399,7 +436,7 @@ object SettingsManager {
/**
* Ensure default settings are present in MMKV.
*/
fun ensureDefaultSettings() {
private fun ensureDefaultSettings() {
// Write defaults in the exact order requested by the user
ensureDefaultValue(AppConfig.PREF_MODE, AppConfig.VPN)
ensureDefaultValue(AppConfig.PREF_VPN_DNS, AppConfig.DNS_VPN)
@@ -422,4 +459,100 @@ object SettingsManager {
MmkvManager.encodeSettings(key, default)
}
}
private fun migrateHysteria2PinSHA256() {
// Check if migration has already been done
val migrationKey = "hysteria2_pin_sha256_migrated"
if (MmkvManager.decodeSettingsBool(migrationKey, false)) {
return
}
val serverList = decodeAllServerList()
for (guid in serverList) {
val profile = decodeServerConfig(guid) ?: continue
if (profile.configType != EConfigType.HYSTERIA2) {
continue
}
if (profile.pinSHA256.isNullOrEmpty() || !profile.pinnedCA256.isNullOrEmpty()) {
continue
}
profile.pinnedCA256 = profile.pinSHA256
profile.pinSHA256 = null
MmkvManager.encodeServerConfig(guid, profile)
}
MmkvManager.encodeSettings(migrationKey, true)
}
/**
* Migrates server list from legacy KEY_ANG_CONFIGS to subscription-based storage.
* This method should be called once during app initialization after the storage structure change.
* Servers are grouped by their subscriptionId into respective subscription's serverList.
* Servers without subscription are moved to the default subscription.
* After migration, KEY_ANG_CONFIGS is removed.
*/
private fun migrateServerListToSubscriptions() {
// Check if migration has already been done
val migrationKey = "server_list_to_subscriptions_migrated"
if (MmkvManager.decodeSettingsBool(migrationKey, false)) {
return
}
// Ensure default subscription exists before migration
ensureDefaultSubscription()
// Read existing server list from legacy KEY_ANG_CONFIGS
val oldJson = MmkvManager.readLegacyServerList()
if (oldJson.isNullOrBlank()) {
// No data to migrate, mark as done
MmkvManager.encodeSettings(migrationKey, true)
return
}
val guids = JsonUtil.fromJson(oldJson, Array<String>::class.java) ?: run {
MmkvManager.encodeSettings(migrationKey, true)
return
}
val subscriptionServerMap = mutableMapOf<String, MutableList<String>>()
// Group servers by subscription (use default subscription for empty subscriptionId)
guids.forEach { guid ->
val config = decodeServerConfig(guid) ?: return@forEach
val subId = config.subscriptionId.ifEmpty { DEFAULT_SUBSCRIPTION_ID }
subscriptionServerMap.getOrPut(subId) { mutableListOf() }.add(guid)
}
// Update each subscription's serverList (including default subscription)
subscriptionServerMap.forEach { (subId, serverGuids) ->
MmkvManager.encodeServerList(serverGuids, subId)
}
// Mark migration as complete
MmkvManager.encodeSettings(migrationKey, true)
}
/**
* Ensures the default subscription exists for ungrouped servers.
* This subscription is used internally to store servers without a subscription.
* Made public for migration in SettingsManager.
*/
private fun ensureDefaultSubscription() {
if (decodeSubscription(DEFAULT_SUBSCRIPTION_ID) == null) {
val defaultSub = SubscriptionItem(
remarks = "Default",
)
encodeSubscription(DEFAULT_SUBSCRIPTION_ID, defaultSub)
// Move top
val subsList = decodeSubsList()
if (subsList.count() > 1) {
swapSubscriptions(0, subsList.count() - 1)
}
}
}
}
@@ -10,10 +10,10 @@ import android.util.Log
import androidx.core.content.ContextCompat
import com.v2ray.ang.AppConfig
import com.v2ray.ang.R
import com.v2ray.ang.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.MessageUtil
@@ -57,9 +57,12 @@ object V2RayServiceManager {
* @param guid The GUID of the server configuration to use (optional).
*/
fun startVService(context: Context, guid: String? = null) {
Log.i(AppConfig.TAG, "StartCore-Manager: startVService from ${context::class.java.simpleName}")
if (guid != null) {
MmkvManager.setSelectServer(guid)
}
startContextService(context)
}
@@ -91,15 +94,30 @@ object V2RayServiceManager {
*/
private fun startContextService(context: Context) {
if (coreController.isRunning) {
Log.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) {
Log.e(AppConfig.TAG, "StartCore-Manager: No server selected")
return
}
val config = MmkvManager.decodeServerConfig(guid)
if (config == null) {
Log.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
) {
Log.e(AppConfig.TAG, "StartCore-Manager: Invalid server configuration")
return
}
// val result = V2rayConfigUtil.getV2rayConfig(context, guid)
// if (!result.status) return
@@ -108,12 +126,21 @@ object V2RayServiceManager {
} else {
context.toast(R.string.toast_services_start)
}
val intent = if (SettingsManager.isVpnMode()) {
val isVpnMode = SettingsManager.isVpnMode()
val intent = if (isVpnMode) {
Log.i(AppConfig.TAG, "StartCore-Manager: Starting VPN service")
Intent(context.applicationContext, V2RayVpnService::class.java)
} else {
Log.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) {
Log.e(AppConfig.TAG, "StartCore-Manager: Failed to start service", e)
}
}
/**
@@ -123,15 +150,34 @@ object V2RayServiceManager {
*/
fun startCoreLoop(vpnInterface: ParcelFileDescriptor?): Boolean {
if (coreController.isRunning) {
Log.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) {
Log.e(AppConfig.TAG, "StartCore-Manager: Service is null")
return false
}
val guid = MmkvManager.getSelectServer()
if (guid == null) {
Log.e(AppConfig.TAG, "StartCore-Manager: No server selected")
return false
}
val config = MmkvManager.decodeServerConfig(guid)
if (config == null) {
Log.e(AppConfig.TAG, "StartCore-Manager: Failed to decode server config")
return false
}
Log.i(AppConfig.TAG, "StartCore-Manager: Starting core loop for ${config.remarks}")
val result = V2rayConfigManager.getV2rayConfig(service, guid)
if (!result.status) {
Log.e(AppConfig.TAG, "StartCore-Manager: Failed to get V2Ray config")
return false
}
try {
val mFilter = IntentFilter(AppConfig.BROADCAST_ACTION_SERVICE)
@@ -140,7 +186,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)
Log.e(AppConfig.TAG, "StartCore-Manager: Failed to register receiver", e)
return false
}
@@ -154,11 +200,12 @@ object V2RayServiceManager {
NotificationManager.showNotification(currentConfig)
coreController.startLoop(result.content, tunFd)
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to start Core loop", e)
Log.e(AppConfig.TAG, "StartCore-Manager: Failed to start core loop", e)
return false
}
if (coreController.isRunning == false) {
Log.e(AppConfig.TAG, "StartCore-Manager: Core failed to start")
MessageUtil.sendMsg2UI(service, AppConfig.MSG_STATE_START_FAILURE, "")
NotificationManager.cancelNotification()
return false
@@ -166,11 +213,10 @@ object V2RayServiceManager {
try {
MessageUtil.sendMsg2UI(service, AppConfig.MSG_STATE_START_SUCCESS, "")
//NotificationManager.showNotification(currentConfig)
NotificationManager.startSpeedNotification(currentConfig)
Log.i(AppConfig.TAG, "StartCore-Manager: Core started successfully")
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to startup service", e)
Log.e(AppConfig.TAG, "StartCore-Manager: Failed to complete startup", e)
return false
}
return true
@@ -189,7 +235,7 @@ object V2RayServiceManager {
try {
coreController.stopLoop()
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to stop V2Ray loop", e)
Log.e(AppConfig.TAG, "StartCore-Manager: Failed to stop V2Ray loop", e)
}
}
}
@@ -200,7 +246,7 @@ object V2RayServiceManager {
try {
service.unregisterReceiver(mMsgReceive)
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to unregister broadcast receiver", e)
Log.e(AppConfig.TAG, "StartCore-Manager: Failed to unregister receiver", e)
}
return true
@@ -234,14 +280,14 @@ object V2RayServiceManager {
try {
time = coreController.measureDelay(SettingsManager.getDelayTestUrl())
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to measure delay with primary URL", e)
Log.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)
Log.e(AppConfig.TAG, "StartCore-Manager: Failed to measure delay", e)
errorStr = e.message?.substringAfter("\":") ?: "empty message"
}
}
@@ -293,7 +339,7 @@ object V2RayServiceManager {
serviceControl.stopService()
0
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to stop service in callback", e)
Log.e(AppConfig.TAG, "StartCore-Manager: Failed to stop service", e)
-1
}
}
@@ -340,12 +386,12 @@ object V2RayServiceManager {
}
AppConfig.MSG_STATE_STOP -> {
Log.i(AppConfig.TAG, "Stop Service")
Log.i(AppConfig.TAG, "StartCore-Manager: Stop service")
serviceControl.stopService()
}
AppConfig.MSG_STATE_RESTART -> {
Log.i(AppConfig.TAG, "Restart Service")
Log.i(AppConfig.TAG, "StartCore-Manager: Restart service")
serviceControl.stopService()
Thread.sleep(500L)
startVService(serviceControl.getService())
@@ -358,12 +404,12 @@ object V2RayServiceManager {
when (intent?.action) {
Intent.ACTION_SCREEN_OFF -> {
Log.i(AppConfig.TAG, "SCREEN_OFF, stop querying stats")
Log.i(AppConfig.TAG, "StartCore-Manager: Screen off")
NotificationManager.stopSpeedNotification(currentConfig)
}
Intent.ACTION_SCREEN_ON -> {
Log.i(AppConfig.TAG, "SCREEN_ON, start querying stats")
Log.i(AppConfig.TAG, "StartCore-Manager: Screen on")
NotificationManager.startSpeedNotification(currentConfig)
}
}
@@ -6,8 +6,6 @@ import android.util.Log
import com.google.gson.JsonArray
import com.v2ray.ang.AppConfig
import com.v2ray.ang.dto.ConfigResult
import com.v2ray.ang.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
@@ -139,7 +139,7 @@ object V2rayConfigManager {
private fun getV2rayGroupConfig(context: Context, guid: String, config: ProfileItem): ConfigResult {
val result = ConfigResult(false)
val serverList = MmkvManager.decodeServerList()
val serverList = MmkvManager.decodeAllServerList()
val configList = serverList
.mapNotNull { id -> MmkvManager.decodeServerConfig(id) }
.filter { profile ->
@@ -467,6 +467,19 @@ 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
}
v2rayConfig.routing.rules.add(rule)
} catch (e: Exception) {
@@ -776,13 +789,14 @@ object V2rayConfigManager {
|| protocol.equals(EConfigType.TROJAN.name, true)
|| protocol.equals(EConfigType.WIREGUARD.name, true)
|| protocol.equals(EConfigType.HYSTERIA2.name, true)
|| protocol.equals(EConfigType.HYSTERIA.name, true)
) {
muxEnabled = false
} else if (outbound.streamSettings?.network == NetworkType.XHTTP.type) {
muxEnabled = false
}
if (muxEnabled == true) {
if (muxEnabled) {
outbound.mux?.enabled = true
outbound.mux?.concurrency = MmkvManager.decodeSettingsString(AppConfig.PREF_MUX_CONCURRENCY, "8").orEmpty().toInt()
outbound.mux?.xudpConcurrency = MmkvManager.decodeSettingsString(AppConfig.PREF_MUX_XUDP_CONCURRENCY, "16").orEmpty().toInt()
@@ -79,5 +79,6 @@ class MmkvPreferenceDataStore : PreferenceDataStore() {
}
// Notify listeners that require service restart or reinit
SettingsChangeManager.makeRestartService()
SettingsChangeManager.makeSetupGroupTab()
}
}
@@ -3,6 +3,8 @@ package com.v2ray.ang.receiver
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.util.Log
import com.v2ray.ang.AppConfig
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.V2RayServiceManager
@@ -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
Log.i(AppConfig.TAG, "BootReceiver received: ${intent?.action}")
if (context == null || intent?.action != Intent.ACTION_BOOT_COMPLETED) {
Log.w(AppConfig.TAG, "BootReceiver: Invalid context or action")
return
}
if (!MmkvManager.decodeStartOnBoot()) {
Log.i(AppConfig.TAG, "BootReceiver: Auto-start on boot is disabled")
return
}
if (MmkvManager.getSelectServer().isNullOrEmpty()) {
Log.w(AppConfig.TAG, "BootReceiver: No server selected")
return
}
Log.i(AppConfig.TAG, "BootReceiver: Starting V2Ray service")
V2RayServiceManager.startVService(context)
}
}
@@ -4,6 +4,8 @@ import android.app.Service
import android.content.Context
import android.content.Intent
import android.os.IBinder
import android.util.Log
import com.v2ray.ang.AppConfig
import com.v2ray.ang.contracts.ServiceControl
import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.handler.V2RayServiceManager
@@ -16,6 +18,7 @@ class V2RayProxyOnlyService : Service(), ServiceControl {
*/
override fun onCreate() {
super.onCreate()
Log.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 {
Log.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
@@ -28,6 +29,7 @@ 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()
Log.i(AppConfig.TAG, "StartCore-VPN: Service created")
val policy = StrictMode.ThreadPolicy.Builder().permitAll().build()
StrictMode.setThreadPolicy(policy)
V2RayServiceManager.serviceControl = SoftReference(this)
}
override fun onRevoke() {
Log.w(AppConfig.TAG, "StartCore-VPN: Permission revoked")
stopAllService()
}
@@ -88,10 +92,12 @@ class V2RayVpnService : VpnService(), ServiceControl {
override fun onDestroy() {
super.onDestroy()
Log.i(AppConfig.TAG, "StartCore-VPN: Service destroyed")
NotificationManager.cancelNotification()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
Log.i(AppConfig.TAG, "StartCore-VPN: Service command received")
setupVpnService()
startService()
return START_STICKY
@@ -103,12 +109,12 @@ class V2RayVpnService : VpnService(), ServiceControl {
}
override fun startService() {
if (mInterface == null) {
Log.e(AppConfig.TAG, "Failed to create VPN interface")
if (!::mInterface.isInitialized) {
Log.e(AppConfig.TAG, "StartCore-VPN: Interface not initialized")
return
}
if (!V2RayServiceManager.startCoreLoop(mInterface)) {
Log.e(AppConfig.TAG, "Failed to start V2Ray core loop")
Log.e(AppConfig.TAG, "StartCore-VPN: Failed to start core loop")
stopAllService()
return
}
@@ -136,13 +142,13 @@ class V2RayVpnService : VpnService(), ServiceControl {
private fun setupVpnService() {
val prepare = prepare(this)
if (prepare != null) {
Log.e(AppConfig.TAG, "VPN preparation failed")
Log.e(AppConfig.TAG, "StartCore-VPN: Permission not granted")
stopSelf()
return
}
if (configureVpnService() != true) {
Log.e(AppConfig.TAG, "VPN configuration failed")
Log.e(AppConfig.TAG, "StartCore-VPN: Configuration failed")
stopSelf()
return
}
@@ -165,9 +171,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) {
Log.w(AppConfig.TAG, "Failed to close old interface", e)
}
// Configure platform-specific features
@@ -244,7 +252,7 @@ class V2RayVpnService : VpnService(), ServiceControl {
try {
connectivity.requestNetwork(defaultNetworkRequest, defaultNetworkCallback)
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to request default network", e)
Log.e(AppConfig.TAG, "StartCore-VPN: Failed to request network", e)
}
}
@@ -297,7 +305,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)
Log.e(AppConfig.TAG, "StartCore-VPN: Failed to configure app", e)
}
}
}
@@ -330,8 +338,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) {
Log.w(AppConfig.TAG, "StartCore-VPN: Failed to unregister callback", e)
}
}
@@ -349,9 +357,11 @@ class V2RayVpnService : VpnService(), ServiceControl {
stopSelf()
try {
mInterface.close()
if (::mInterface.isInitialized) {
mInterface.close()
}
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to close VPN interface", e)
Log.e(AppConfig.TAG, "StartCore-VPN: Failed to close interface", e)
}
}
}
@@ -18,6 +18,7 @@ import com.v2ray.ang.extension.toastError
import com.v2ray.ang.extension.toastSuccess
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.SettingsChangeManager
import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.handler.WebDavManager
import com.v2ray.ang.util.ZipUtil
import kotlinx.coroutines.Dispatchers
@@ -124,6 +125,8 @@ class BackupActivity : HelperBaseActivity() {
val count = MMKV.restoreAllFromDirectory(backupDir)
SettingsChangeManager.makeSetupGroupTab()
SettingsChangeManager.makeRestartService()
SettingsManager.initApp(this)
return count > 0
}
@@ -11,13 +11,14 @@ 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
@@ -28,7 +29,8 @@ 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,6 +70,10 @@ class GroupServerFragment : BaseFragment<FragmentGroupServerBinding>() {
itemTouchHelper = ItemTouchHelper(SimpleItemTouchHelperCallback(adapter, allowSwipe = false))
itemTouchHelper?.attachToRecyclerView(binding.recyclerView)
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
@@ -142,7 +148,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 +170,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 +218,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 +277,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)
}
}
}
@@ -94,6 +94,7 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
})
binding.fab.setOnClickListener { handleFabAction() }
binding.fabLocate.setOnClickListener { locateSelectedServer() }
binding.layoutTest.setOnClickListener { handleLayoutTestClick() }
setupGroupTab()
@@ -433,18 +434,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()
}
@@ -560,6 +570,47 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
}
}
/**
* 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)
}
}
override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean {
if (keyCode == KeyEvent.KEYCODE_BACK || keyCode == KeyEvent.KEYCODE_BUTTON_B) {
moveTaskToBack(false)
@@ -20,9 +20,9 @@ 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.toast
import com.v2ray.ang.extension.toastSuccess
@@ -129,7 +129,6 @@ class ServerActivity : BaseActivity() {
private val et_obfs_password: EditText? by lazy { findViewById(R.id.et_obfs_password) }
private val et_port_hop: EditText? by lazy { findViewById(R.id.et_port_hop) }
private val et_port_hop_interval: EditText? by lazy { findViewById(R.id.et_port_hop_interval) }
private val et_pinsha256: EditText? by lazy { findViewById(R.id.et_pinsha256) }
private val et_bandwidth_down: EditText? by lazy { findViewById(R.id.et_bandwidth_down) }
private val et_bandwidth_up: EditText? by lazy { findViewById(R.id.et_bandwidth_up) }
private val et_extra: EditText? by lazy { findViewById(R.id.et_extra) }
@@ -367,7 +366,6 @@ class ServerActivity : BaseActivity() {
et_obfs_password?.text = Utils.getEditable(config.obfsPassword)
et_port_hop?.text = Utils.getEditable(config.portHopping)
et_port_hop_interval?.text = Utils.getEditable(config.portHoppingInterval)
et_pinsha256?.text = Utils.getEditable(config.pinSHA256)
et_bandwidth_down?.text = Utils.getEditable(config.bandwidthDown)
et_bandwidth_up?.text = Utils.getEditable(config.bandwidthUp)
}
@@ -536,7 +534,6 @@ class ServerActivity : BaseActivity() {
config.obfsPassword = et_obfs_password?.text?.toString()
config.portHopping = et_port_hop?.text?.toString()
config.portHoppingInterval = et_port_hop_interval?.text?.toString()
config.pinSHA256 = et_pinsha256?.text?.toString()
config.bandwidthDown = et_bandwidth_down?.text?.toString()
config.bandwidthUp = et_bandwidth_up?.text?.toString()
}
@@ -11,8 +11,8 @@ 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
@@ -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
@@ -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
@@ -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)
}
@@ -18,8 +18,6 @@ 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
@@ -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()
}
}
@@ -5,7 +5,9 @@ import android.text.TextUtils
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView
import com.v2ray.ang.AppConfig
import com.v2ray.ang.contracts.BaseAdapterListener
import com.v2ray.ang.databinding.ItemRecyclerSubSettingBinding
import com.v2ray.ang.helper.ItemTouchHelperAdapter
@@ -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)
@@ -5,6 +5,7 @@ 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,15 +38,13 @@ 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)
@@ -17,6 +17,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
@@ -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>() }
@@ -69,10 +71,15 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
}
/**
* Reloads the server list.
* Reloads the server list based on current subscription filter.
*/
fun reloadServerList() {
serverList = MmkvManager.decodeServerList()
serverList = if (subscriptionId.isEmpty()) {
MmkvManager.decodeAllServerList()
} else {
MmkvManager.decodeServerList(subscriptionId)
}
updateCache()
updateListAction.value = -1
}
@@ -90,38 +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
@@ -15,7 +15,7 @@ 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,7 +47,18 @@ 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 {
@@ -29,6 +29,7 @@
<ImageView
android:layout_width="@dimen/image_size_dp24"
android:layout_height="@dimen/image_size_dp24"
android:importantForAccessibility="no"
app:srcCompat="@drawable/ic_backup_24dp" />
<LinearLayout
@@ -61,6 +62,7 @@
<ImageView
android:layout_width="@dimen/image_size_dp24"
android:layout_height="@dimen/image_size_dp24"
android:importantForAccessibility="no"
app:srcCompat="@drawable/ic_share_24dp" />
<TextView
@@ -85,6 +87,7 @@
<ImageView
android:layout_width="@dimen/image_size_dp24"
android:layout_height="@dimen/image_size_dp24"
android:importantForAccessibility="no"
app:srcCompat="@drawable/ic_restore_24dp" />
<TextView
@@ -117,6 +120,7 @@
<ImageView
android:layout_width="@dimen/image_size_dp24"
android:layout_height="@dimen/image_size_dp24"
android:importantForAccessibility="no"
app:srcCompat="@drawable/ic_settings_24dp" />
<LinearLayout
@@ -68,6 +68,7 @@
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackgroundBorderless"
android:clickable="true"
android:contentDescription="@string/switch_bypass_apps_mode"
android:focusable="true"
android:gravity="center"
android:padding="@dimen/padding_spacing_dp8">
@@ -75,6 +76,7 @@
<ImageView
android:layout_width="@dimen/image_size_dp24"
android:layout_height="@dimen/image_size_dp24"
android:importantForAccessibility="no"
app:srcCompat="@drawable/ic_about_24dp" />
</LinearLayout>
@@ -22,6 +22,7 @@
<ImageView
android:layout_width="@dimen/image_size_dp24"
android:layout_height="@dimen/image_size_dp24"
android:importantForAccessibility="no"
app:srcCompat="@drawable/ic_source_code_24dp" />
<androidx.appcompat.widget.SwitchCompat
@@ -49,6 +50,7 @@
<ImageView
android:layout_width="@dimen/image_size_dp24"
android:layout_height="@dimen/image_size_dp24"
android:importantForAccessibility="no"
app:srcCompat="@drawable/ic_check_update_24dp" />
<TextView
@@ -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"
@@ -95,6 +96,18 @@
android:layout_gravity="bottom|end"
android:layout_marginEnd="@dimen/padding_spacing_dp16">
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/fab_locate"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:layout_marginBottom="@dimen/view_height_dp120"
android:clickable="true"
android:contentDescription="@string/title_server"
android:focusable="true"
android:src="@android:drawable/ic_menu_mylocation"
app:layout_anchorGravity="bottom|right|end" />
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/fab"
android:layout_width="wrap_content"
@@ -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>
@@ -11,6 +11,7 @@
android:id="@+id/iv_qcode"
android:layout_width="336dp"
android:layout_height="336dp"
android:contentDescription="@string/title_qr_code"
android:scaleType="fitXY"
app:srcCompat="@drawable/ic_fab_check" />
</LinearLayout>
@@ -43,6 +43,7 @@
android:layout_height="@dimen/padding_spacing_dp16"
android:layout_gravity="center"
android:layout_marginStart="@dimen/padding_spacing_dp16"
android:importantForAccessibility="no"
app:srcCompat="@drawable/ic_lock_24dp" />
</LinearLayout>
@@ -78,6 +79,7 @@
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackgroundBorderless"
android:clickable="true"
android:contentDescription="@string/menu_item_edit_config"
android:focusable="true"
android:gravity="center"
android:nextFocusLeft="@+id/info_container"
@@ -87,6 +89,7 @@
<ImageView
android:layout_width="@dimen/image_size_dp24"
android:layout_height="@dimen/image_size_dp24"
android:importantForAccessibility="no"
app:srcCompat="@drawable/ic_edit_24dp" />
</LinearLayout>
@@ -58,6 +58,7 @@
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackgroundBorderless"
android:clickable="true"
android:contentDescription="@string/title_configuration_share"
android:focusable="true"
android:gravity="center"
android:nextFocusLeft="@+id/info_container"
@@ -67,6 +68,7 @@
<ImageView
android:layout_width="wrap_content"
android:layout_height="@dimen/image_size_dp24"
android:importantForAccessibility="no"
app:srcCompat="@drawable/ic_share_24dp" />
</LinearLayout>
@@ -77,6 +79,7 @@
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackgroundBorderless"
android:clickable="true"
android:contentDescription="@string/menu_item_edit_config"
android:focusable="true"
android:gravity="center"
android:orientation="vertical"
@@ -85,6 +88,7 @@
<ImageView
android:layout_width="@dimen/image_size_dp24"
android:layout_height="@dimen/image_size_dp24"
android:importantForAccessibility="no"
app:srcCompat="@drawable/ic_edit_24dp" />
</LinearLayout>
@@ -95,6 +99,7 @@
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackgroundBorderless"
android:clickable="true"
android:contentDescription="@string/menu_item_del_config"
android:focusable="true"
android:gravity="center"
android:orientation="vertical"
@@ -103,6 +108,7 @@
<ImageView
android:layout_width="@dimen/image_size_dp24"
android:layout_height="@dimen/image_size_dp24"
android:importantForAccessibility="no"
app:srcCompat="@drawable/ic_delete_24dp" />
</LinearLayout>
@@ -58,12 +58,14 @@
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackgroundBorderless"
android:clickable="true"
android:contentDescription="@string/menu_item_edit_config"
android:focusable="true"
android:padding="@dimen/padding_spacing_dp8">
<ImageView
android:layout_width="@dimen/image_size_dp24"
android:layout_height="@dimen/image_size_dp24"
android:importantForAccessibility="no"
app:srcCompat="@drawable/ic_edit_24dp" />
</LinearLayout>
@@ -73,12 +75,14 @@
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackgroundBorderless"
android:clickable="true"
android:contentDescription="@string/menu_item_del_config"
android:focusable="true"
android:padding="@dimen/padding_spacing_dp8">
<ImageView
android:layout_width="@dimen/image_size_dp24"
android:layout_height="@dimen/image_size_dp24"
android:importantForAccessibility="no"
app:srcCompat="@drawable/ic_delete_24dp" />
</LinearLayout>
</LinearLayout>
@@ -80,10 +80,10 @@
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/server_lab_stream_pinsha256" />
android:text="@string/server_lab_pinned_ca256" />
<EditText
android:id="@+id/et_pinsha256"
android:id="@+id/et_pinned_ca256"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="text" />
@@ -8,4 +8,5 @@
android:text="@string/title_mode_help"
android:textAlignment="textStart"
android:textStyle="italic"
android:textColor="?attr/colorPrimary"
tools:ignore="UsingOnClickInXml" />
@@ -18,6 +18,7 @@
android:id="@+id/image_switch"
android:layout_width="45dp"
android:layout_height="45dp"
android:importantForAccessibility="no"
android:padding="@dimen/padding_spacing_dp16"
app:srcCompat="@drawable/ic_stat_name" />
</LinearLayout>
@@ -109,7 +109,6 @@
<string name="server_obfs_password">Obfs password</string>
<string name="server_lab_port_hop">Port Hopping</string>
<string name="server_lab_port_hop_interval">Port Hopping Interval</string>
<string name="server_lab_stream_pinsha256">pinSHA256</string>
<string name="server_lab_bandwidth_down">Bandwidth down (Supported units: k/m/g/t)</string>
<string name="server_lab_bandwidth_up">Bandwidth up (Supported units: k/m/g/t)</string>
<string name="server_lab_xhttp_mode">XHTTP Mode</string>
@@ -235,6 +234,8 @@
<string name="title_pref_double_column_display">Enable double column display</string>
<string name="summary_pref_double_column_display">The profile list is displayed in double columns, allowing more content to be displayed on the screen. You need to restart the application to take effect.</string>
<string name="title_pref_group_all_display">Enable show all groups</string>
<string name="summary_pref_group_all_display">Add an extra "All Tabs" page</string>
<!-- AboutActivity -->
<string name="title_pref_feedback">ملاحظات</string>
<string name="summary_pref_feedback">ملاحظات التحسينات أو الأخطاء إلى GitHub</string>
@@ -296,6 +297,10 @@
<string name="title_import_config_count">Import %d configurations</string>
<string name="title_export_config_count">Export %d configurations</string>
<string name="title_update_config_count">Update %d configurations</string>
<string name="title_update_subscription_result">Updated %1$d configs (%2$d success, %3$d failed, %4$d skipped)</string>
<string name="title_update_subscription_no_subscription">No subscriptions</string>
<string name="toast_server_not_found_in_group">Selected server not found in current group</string>
<string name="toast_fragment_not_available">Unable to locate current view</string>
<string name="tasker_start_service">بدء الخدمة</string>
<string name="tasker_setting_confirm">تأكيد</string>
@@ -108,7 +108,6 @@
<string name="server_obfs_password">Obfs password</string>
<string name="server_lab_port_hop">Port Hopping</string>
<string name="server_lab_port_hop_interval">Port Hopping Interval</string>
<string name="server_lab_stream_pinsha256">pinSHA256</string>
<string name="server_lab_bandwidth_down">Bandwidth down (Supported units: k/m/g/t)</string>
<string name="server_lab_bandwidth_up">Bandwidth up (Supported units: k/m/g/t)</string>
<string name="server_lab_xhttp_mode">XHTTP Mode</string>
@@ -235,6 +234,8 @@
<string name="title_pref_double_column_display">Enable double column display</string>
<string name="summary_pref_double_column_display">The profile list is displayed in double columns, allowing more content to be displayed on the screen. You need to restart the application to take effect.</string>
<string name="title_pref_group_all_display">Enable show all groups</string>
<string name="summary_pref_group_all_display">Add an extra "All Tabs" page</string>
<!-- AboutActivity -->
<string name="title_pref_feedback">মতামত</string>
<string name="summary_pref_feedback">মতামত উন্নয়ন বা বাগগুলি GitHub-এ পাঠান</string>
@@ -295,6 +296,10 @@
<string name="title_import_config_count">Import %d configurations</string>
<string name="title_export_config_count">Export %d configurations</string>
<string name="title_update_config_count">Update %d configurations</string>
<string name="title_update_subscription_result">Updated %1$d configs (%2$d success, %3$d failed, %4$d skipped)</string>
<string name="title_update_subscription_no_subscription">No subscriptions</string>
<string name="toast_server_not_found_in_group">Selected server not found in current group</string>
<string name="toast_fragment_not_available">Unable to locate current view</string>
<string name="tasker_start_service">সার্ভিস শুরু করুন</string>
<string name="tasker_setting_confirm">নিশ্চিত করুন</string>
@@ -108,7 +108,6 @@
<string name="server_obfs_password">رزم obfs</string>
<string name="server_lab_port_hop">پورت گوم (درگا سرورن ز نۊ هؽل اکونه)</string>
<string name="server_lab_port_hop_interval">فاسله پورت گوم (سانیه)</string>
<string name="server_lab_stream_pinsha256">pinSHA256</string>
<string name="server_lab_bandwidth_down">ب لم ٱووڌن پئنا باند (واهڌ)</string>
<string name="server_lab_bandwidth_up">وا روء رئڌن پئنا باند (واهڌ)</string>
<string name="server_lab_xhttp_mode">هالت XHTTP</string>
@@ -235,6 +234,8 @@
<string name="title_pref_double_column_display">ره وندن نشووݩ داڌن دو سۊتۊنی</string>
<string name="summary_pref_double_column_display">نومگه نمایه یل من دو سۊتۊن نشووݩ داڌه ابۊن وو چینۉ ترین موئتوا بیشتری ن سیل کۊنین. سی ره وستن، وا برنومه ن ز نۊ ره ونین.</string>
<string name="title_pref_group_all_display">نشووݩ داڌن پوی بونکۊ یل ن فعال کۊنین</string>
<string name="summary_pref_group_all_display">ی بلگه «پوی بلگه یل» ازاف کۊنین</string>
<!-- AboutActivity -->
<string name="title_pref_feedback">فشناڌن منشڌ</string>
<string name="summary_pref_feedback">فشناڌن منشڌ یا گوزارش موشکلا من Github</string>
@@ -296,6 +297,10 @@
<string name="title_import_config_count">و من ٱووردن %d کانفیگ</string>
<string name="title_export_config_count">و در کشیڌن %d کانفیگ</string>
<string name="title_update_config_count">ورۊ کردن %d کانفیگ</string>
<string name="title_update_subscription_result">%1$d کانفیگ ورۊ وابی (مووفق: %2$d، شکست خرده: %3$d، رڌ وابیڌه: %4$d)</string>
<string name="title_update_subscription_no_subscription">بؽ اشتراک</string>
<string name="toast_server_not_found_in_group">Selected server not found in current group</string>
<string name="toast_fragment_not_available">Unable to locate current view</string>
<string name="tasker_start_service">ره وندن خدمات</string>
<string name="tasker_setting_confirm">قوۊل</string>
@@ -416,7 +421,7 @@
<string-array name="policy_group_type">
<item>کم ترین پینگ</item>
<item>کم ترین بار(لود)</item>
<item>تصادفی</item>
<item>تساڌۊفی</item>
<item>گردشی</item>
</string-array>
@@ -108,7 +108,6 @@
<string name="server_obfs_password">رمز عبور obfs</string>
<string name="server_lab_port_hop">پورت پرش (درگاه سرور را بازنویسی می کند)</string>
<string name="server_lab_port_hop_interval">فاصله پورت پرش (ثانیه)</string>
<string name="server_lab_stream_pinsha256">pinSHA256</string>
<string name="server_lab_bandwidth_down">کاهش پهنای باند (واحد)</string>
<string name="server_lab_bandwidth_up">افزایش پهنای باند (واحد)</string>
<string name="server_lab_xhttp_mode">حالت XHTTP</string>
@@ -233,6 +232,8 @@
<string name="title_pref_double_column_display">فعال کردن نمایش دو ستون</string>
<string name="summary_pref_double_column_display">لیست نمایه در دو ستون نمایش داده می شود و امکان نمایش محتوای بیشتری را بر روی صفحه نمایش می دهد. برای اجرا باید برنامه را مجددا راه اندازی کنید.</string>
<string name="title_pref_group_all_display">Enable show all groups</string>
<string name="summary_pref_group_all_display">Add an extra "All Tabs" page</string>
<!-- AboutActivity -->
<string name="title_pref_feedback">بازخورد</string>
<string name="summary_pref_feedback">بازخورد یا گزارش اشکالات در گیت‌ هاب</string>
@@ -293,6 +294,10 @@
<string name="title_import_config_count">وارد کردن %d کانفیگ</string>
<string name="title_export_config_count">صادر کردن %d کانفیگ</string>
<string name="title_update_config_count">آپدیت کردن %d کانفیگ</string>
<string name="title_update_subscription_result">Updated %1$d configs (%2$d success, %3$d failed, %4$d skipped)</string>
<string name="title_update_subscription_no_subscription">No subscriptions</string>
<string name="toast_server_not_found_in_group">Selected server not found in current group</string>
<string name="toast_fragment_not_available">Unable to locate current view</string>
<string name="tasker_start_service">شروع خدمات</string>
<string name="tasker_setting_confirm">تایید</string>
@@ -108,7 +108,6 @@
<string name="server_obfs_password">Пароль obfs</string>
<string name="server_lab_port_hop">Смена портов (переопределяет порт)</string>
<string name="server_lab_port_hop_interval">Интервал смены портов</string>
<string name="server_lab_stream_pinsha256">pinSHA256</string>
<string name="server_lab_bandwidth_down">Входящая пропускная способность (допускаются: k/m/g/t)</string>
<string name="server_lab_bandwidth_up">Исходящая пропускная способность (допускаются: k/m/g/t)</string>
<string name="server_lab_xhttp_mode">Режим XHTTP</string>
@@ -234,6 +233,8 @@
<string name="title_pref_double_column_display">Профили в два столбца</string>
<string name="summary_pref_double_column_display">Список профилей отображается двумя столбцами, что позволяет показать больше информации на экране. Требуется перезапуск приложения.</string>
<string name="title_pref_group_all_display">Общая вкладка групп</string>
<string name="summary_pref_group_all_display">Показывать дополнительную вкладку со всеми профилями групп</string>
<!-- AboutActivity -->
<string name="title_pref_feedback">Обратная связь</string>
<string name="summary_pref_feedback">Предложить улучшение или сообщить об ошибке на GitHub</string>
@@ -294,6 +295,10 @@
<string name="title_import_config_count">Импортировано профилей: %d</string>
<string name="title_export_config_count">Экспортировано профилей: %d</string>
<string name="title_update_config_count">Обновлено профилей: %d</string>
<string name="title_update_subscription_result">Обновлено профилей: %1$d (успешно: %2$d, ошибок: %3$d, пропущено: %4$d)</string>
<string name="title_update_subscription_no_subscription">Нет подписок</string>
<string name="toast_server_not_found_in_group">Выбранный профиль не найден в текущей группе</string>
<string name="toast_fragment_not_available">Фрагмент недоступен</string>
<string name="tasker_start_service">Запуск службы</string>
<string name="tasker_setting_confirm">Подтвердить</string>
@@ -357,7 +362,7 @@
<string name="title_configuration_restore">Восстановление конфигурации</string>
<string name="title_configuration_share">Поделиться конфигурацией</string>
<string name="title_webdav_config_setting">Настройки WebDAV</string>
<string name="title_webdav_config_setting_unknown">Необходимо настроить WebDAV.</string>
<string name="title_webdav_config_setting_unknown">Необходимо настроить WebDAV</string>
<string name="title_webdav_url">URL сервера</string>
<string name="title_webdav_user">Пользователь</string>
<string name="title_webdav_pass">Пароль</string>
@@ -108,7 +108,6 @@
<string name="server_obfs_password">Obfs password</string>
<string name="server_lab_port_hop">Port Hopping</string>
<string name="server_lab_port_hop_interval">Port Hopping Interval</string>
<string name="server_lab_stream_pinsha256">pinSHA256</string>
<string name="server_lab_bandwidth_down">Bandwidth down (Supported units: k/m/g/t)</string>
<string name="server_lab_bandwidth_up">Bandwidth up (Supported units: k/m/g/t)</string>
<string name="server_lab_xhttp_mode">XHTTP Mode</string>
@@ -235,6 +234,8 @@
<string name="title_pref_double_column_display">Enable double column display</string>
<string name="summary_pref_double_column_display">The profile list is displayed in double columns, allowing more content to be displayed on the screen. You need to restart the application to take effect.</string>
<string name="title_pref_group_all_display">Enable show all groups</string>
<string name="summary_pref_group_all_display">Add an extra "All Tabs" page</string>
<!-- AboutActivity -->
<string name="title_pref_feedback">Phản hồi lỗi</string>
<string name="summary_pref_feedback">Phản hồi cải tiến hoặc lỗi lên GitHub</string>
@@ -296,6 +297,10 @@
<string name="title_import_config_count">Import %d configurations</string>
<string name="title_export_config_count">Export %d configurations</string>
<string name="title_update_config_count">Update %d configurations</string>
<string name="title_update_subscription_result">Updated %1$d configs (%2$d success, %3$d failed, %4$d skipped)</string>
<string name="title_update_subscription_no_subscription">No subscriptions</string>
<string name="toast_server_not_found_in_group">Selected server not found in current group</string>
<string name="toast_fragment_not_available">Unable to locate current view</string>
<string name="tasker_start_service">Khởi động v2rayNG</string>
<string name="tasker_setting_confirm">Xác nhận</string>
@@ -108,7 +108,6 @@
<string name="server_obfs_password">混淆密码</string>
<string name="server_lab_port_hop">跳跃端口 (会覆盖服务器端口)</string>
<string name="server_lab_port_hop_interval">端口跳跃间隔 (秒)</string>
<string name="server_lab_stream_pinsha256">SHA256 证书指纹</string>
<string name="server_lab_bandwidth_down">带宽下行 (支持的单位 k/m/g/t)</string>
<string name="server_lab_bandwidth_up">带宽上行 (支持的单位 k/m/g/t)</string>
<string name="server_lab_xhttp_mode">XHTTP 模式</string>
@@ -232,6 +231,9 @@
<string name="title_pref_double_column_display">启用双列显示</string>
<string name="summary_pref_double_column_display">配置列表以双列显示,允许在屏幕上显示更多内容。需要重启应用生效。</string>
<string name="title_pref_group_all_display">启用显示所有组</string>
<string name="summary_pref_group_all_display">添加一个额外的“所有选项卡”页面</string>
<!-- AboutActivity -->
<string name="title_pref_feedback">反馈</string>
<string name="summary_pref_feedback">反馈改进或漏洞至 GitHub</string>
@@ -293,6 +295,10 @@
<string name="title_import_config_count">导入 %d 个配置</string>
<string name="title_export_config_count">导出 %d 个配置</string>
<string name="title_update_config_count">更新 %d 个配置</string>
<string name="title_update_subscription_result">更新了 %1$d 个配置(%2$d 个成功,%3$d 个失败,%4$d 个跳过)</string>
<string name="title_update_subscription_no_subscription">无订阅</string>
<string name="toast_server_not_found_in_group">当前分组中未找到选中的服务器</string>
<string name="toast_fragment_not_available">无法定位当前视图</string>
<string name="tasker_start_service">启动服务</string>
<string name="tasker_setting_confirm">确定</string>
@@ -108,7 +108,6 @@
<string name="server_obfs_password">混淆密碼</string>
<string name="server_lab_port_hop">跳躍連接埠 (會覆蓋伺服器連接埠)</string>
<string name="server_lab_port_hop_interval">連接埠跳躍間隔 (秒)</string>
<string name="server_lab_stream_pinsha256">SHA256 憑證指紋</string>
<string name="server_lab_bandwidth_down">頻寬下行 (支持的單位 k/m/g/t)</string>
<string name="server_lab_bandwidth_up">頻寬上行 (支持的單位 k/m/g/t)</string>
<string name="server_lab_xhttp_mode">XHTTP 模式</string>
@@ -233,6 +232,9 @@
<string name="title_pref_double_column_display">啟用雙列顯示</string>
<string name="summary_pref_double_column_display">設定檔清單以雙列顯示,允許在螢幕上顯示更多內容。需要重啟應用生效。</string>
<string name="title_pref_group_all_display">啟用顯示所有群組</string>
<string name="summary_pref_group_all_display">新增一個額外的「所有選項卡」頁面</string>
<!-- AboutActivity -->
<string name="title_pref_feedback">意見回饋</string>
<string name="summary_pref_feedback">前往 GitHub 回報錯誤</string>
@@ -293,6 +295,10 @@
<string name="title_import_config_count">匯入 %d 個配置</string>
<string name="title_export_config_count">匯出 %d 個配置</string>
<string name="title_update_config_count">更新 %d 個配置</string>
<string name="title_update_subscription_result">更新了 %1$d 個配置(%2$d 個成功,%3$d 個失敗,%4$d 個跳過)</string>
<string name="title_update_subscription_no_subscription">無訂閱</string>
<string name="toast_server_not_found_in_group">當前分組中未找到選中的伺服器</string>
<string name="toast_fragment_not_available">無法定位當前視圖</string>
<string name="tasker_start_service">啟動服務</string>
<string name="tasker_setting_confirm">確定</string>
@@ -7,5 +7,6 @@
<dimen name="view_height_dp36">36dp</dimen>
<dimen name="view_height_dp48">48dp</dimen>
<dimen name="view_height_dp64">64dp</dimen>
<dimen name="view_height_dp120">120dp</dimen>
<dimen name="view_height_dp160">160dp</dimen>
</resources>
+8 -1
View File
@@ -109,7 +109,6 @@
<string name="server_obfs_password">Obfs password</string>
<string name="server_lab_port_hop">Port Hopping(will override the port)</string>
<string name="server_lab_port_hop_interval">Port Hopping Interval</string>
<string name="server_lab_stream_pinsha256">pinSHA256</string>
<string name="server_lab_bandwidth_down">Bandwidth down (Supported units: k/m/g/t)</string>
<string name="server_lab_bandwidth_up">Bandwidth up (Supported units: k/m/g/t)</string>
<string name="server_lab_xhttp_mode">XHTTP Mode</string>
@@ -238,12 +237,16 @@
<string name="title_pref_double_column_display">Enable double column display</string>
<string name="summary_pref_double_column_display">The profile list is displayed in double columns, allowing more content to be displayed on the screen. You need to restart the application to take effect.</string>
<string name="title_pref_group_all_display">Enable show all groups</string>
<string name="summary_pref_group_all_display">Add an extra "All Tabs" page</string>
<!-- AboutActivity -->
<string name="title_pref_feedback">Feedback</string>
<string name="summary_pref_feedback">Feedback enhancements or bugs to GitHub</string>
<string name="summary_pref_tg_group">Join Telegram Group</string>
<string name="toast_tg_app_not_found">Telegram app not found</string>
<string name="title_privacy_policy">Privacy policy</string>
<string name="title_qr_code">QR code</string>
<string name="title_about">About</string>
<string name="title_source_code">Source code</string>
<string name="title_oss_license">Open Source licenses</string>
@@ -298,6 +301,10 @@
<string name="title_import_config_count">Import %d configs</string>
<string name="title_export_config_count">Export %d configs</string>
<string name="title_update_config_count">Update %d configs</string>
<string name="title_update_subscription_result">Updated %1$d configs (%2$d success, %3$d failed, %4$d skipped)</string>
<string name="title_update_subscription_no_subscription">No subscriptions</string>
<string name="toast_server_not_found_in_group">Selected server not found in current group</string>
<string name="toast_fragment_not_available">Unable to locate current view</string>
<string name="tasker_start_service">Start Service</string>
<string name="tasker_setting_confirm">Confirm</string>
@@ -23,6 +23,11 @@
android:summary="@string/summary_pref_double_column_display"
android:title="@string/title_pref_double_column_display" />
<CheckBoxPreference
android:key="pref_group_all_display"
android:summary="@string/summary_pref_group_all_display"
android:title="@string/title_pref_group_all_display" />
<ListPreference
android:defaultValue="auto"
android:entries="@array/language_select"
+4 -4
View File
@@ -1,15 +1,15 @@
[versions]
agp = "9.0.0"
agp = "9.1.0"
desugarJdkLibs = "2.1.5"
gradleLicensePlugin = "0.9.8"
kotlin = "2.3.0"
kotlin = "2.3.10"
coreKtx = "1.17.0"
junit = "4.13.2"
junitVersion = "1.3.0"
espressoCore = "3.7.0"
appcompat = "1.7.1"
material = "1.13.0"
activity = "1.12.2"
activity = "1.12.4"
constraintlayout = "2.2.1"
mmkvStatic = "1.3.16"
gson = "2.13.2"
@@ -21,7 +21,7 @@ swiperefreshlayout = "1.2.0"
toasty = "1.5.2"
editorkit = "2.9.0"
core = "3.5.4"
workRuntimeKtx = "2.11.0"
workRuntimeKtx = "2.11.1"
lifecycleViewmodelKtx = "2.10.0"
multidex = "2.0.1"
mockitoMockitoInline = "5.2.0"
+1 -1
View File
@@ -1,6 +1,6 @@
#Thu Nov 14 12:42:51 BDT 2024
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists