Compare commits

...

29 Commits

Author SHA1 Message Date
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
2dust abd23b3227 up 2.0.8 2026-02-06 20:08:17 +08:00
2dust 7d3e002140 Update hev-socks5-tunnel 2026-02-06 19:45:41 +08:00
2dust 1adb22944a Update AndroidLibXrayLite 2026-02-06 19:45:34 +08:00
DHR60 f48253371d Follow encodeURIComponent rules strictly (#5250) 2026-02-06 19:38:45 +08:00
AmirMohammad Yazdanmanesh 74f701096e Improve accessibility: mark decorative icons as not important for accessibility (#5247) 2026-02-06 19:37:25 +08:00
DHR60 8eca5fe8af Add finalmask support (#5242) 2026-02-06 19:27:02 +08:00
solokot ff40b53e2f Update Russian translation (#5238) 2026-02-01 18:14:38 +08:00
2dust 5080d94bb7 Add description field to ProfileItem and generate descriptions 2026-01-30 17:24:13 +08:00
61 changed files with 682 additions and 202 deletions
+2 -2
View File
@@ -12,8 +12,8 @@ android {
applicationId = "com.v2ray.ang" applicationId = "com.v2ray.ang"
minSdk = 24 minSdk = 24
targetSdk = 36 targetSdk = 36
versionCode = 707 versionCode = 711
versionName = "2.0.7" versionName = "2.0.11"
multiDexEnabled = true multiDexEnabled = true
val abiFilterList = (properties["ABI_FILTERS"] as? String)?.split(';') val abiFilterList = (properties["ABI_FILTERS"] as? String)?.split(';')
@@ -34,16 +34,15 @@ class AngApplication : MultiDexApplication() {
MMKV.initialize(this) MMKV.initialize(this)
// Ensure critical preference defaults are present in MMKV early
SettingsManager.ensureDefaultSettings()
SettingsManager.setNightMode()
// Initialize WorkManager with the custom configuration // Initialize WorkManager with the custom configuration
WorkManager.initialize(this, workManagerConfiguration) 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() es.dmoral.toasty.Toasty.Config.getInstance()
.setGravity(android.view.Gravity.BOTTOM, 0, 200) .setGravity(android.view.Gravity.BOTTOM, 0, 300)
.apply() .apply()
} }
} }
@@ -16,6 +16,9 @@ object AppConfig {
/** Legacy configuration keys. */ /** Legacy configuration keys. */
const val ANG_CONFIG = "ang_config" const val ANG_CONFIG = "ang_config"
// Default subscription ID for ungrouped servers
const val DEFAULT_SUBSCRIPTION_ID = "__default_subscription__"
/** Preferences mapped to MMKV storage. */ /** Preferences mapped to MMKV storage. */
const val PREF_SNIFFING_ENABLED = "pref_sniffing_enabled" const val PREF_SNIFFING_ENABLED = "pref_sniffing_enabled"
const val PREF_ROUTE_ONLY_ENABLED = "pref_route_only_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_CONFIRM_REMOVE = "pref_confirm_remove"
const val PREF_START_SCAN_IMMEDIATE = "pref_start_scan_immediate" const val PREF_START_SCAN_IMMEDIATE = "pref_start_scan_immediate"
const val PREF_DOUBLE_COLUMN_DISPLAY = "pref_double_column_display" 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_LANGUAGE = "pref_language"
const val PREF_UI_MODE_NIGHT = "pref_ui_mode_night" const val PREF_UI_MODE_NIGHT = "pref_ui_mode_night"
const val PREF_PREFER_IPV6 = "pref_prefer_ipv6" const val PREF_PREFER_IPV6 = "pref_prefer_ipv6"
@@ -15,6 +15,7 @@ data class ProfileItem(
var addedTime: Long = System.currentTimeMillis(), var addedTime: Long = System.currentTimeMillis(),
var remarks: String = "", var remarks: String = "",
var description: String? = null,
var server: String? = null, var server: String? = null,
var serverPort: String? = null, var serverPort: String? = null,
@@ -123,7 +124,7 @@ data class ProfileItem(
&& this.obfsPassword == obj.obfsPassword && this.obfsPassword == obj.obfsPassword
&& this.portHopping == obj.portHopping && this.portHopping == obj.portHopping
&& this.portHoppingInterval == obj.portHoppingInterval && this.portHoppingInterval == obj.portHoppingInterval
&& this.pinSHA256 == obj.pinSHA256 && this.pinnedCA256 == obj.pinnedCA256
) )
} }
} }
@@ -162,7 +162,7 @@ data class V2rayConfig(
var realitySettings: TlsSettingsBean? = null, var realitySettings: TlsSettingsBean? = null,
var grpcSettings: GrpcSettingsBean? = null, var grpcSettings: GrpcSettingsBean? = null,
var hysteriaSettings: HysteriaSettingsBean? = null, var hysteriaSettings: HysteriaSettingsBean? = null,
var udpmasks: List<UdpMasksBean>? = null, var finalmask: FinalMaskBean? = null,
val dsSettings: Any? = null, val dsSettings: Any? = null,
var sockopt: SockoptBean? = null var sockopt: SockoptBean? = null
) { ) {
@@ -202,15 +202,8 @@ data class V2rayConfig(
var downlinkCapacity: Int = 100, var downlinkCapacity: Int = 100,
var congestion: Boolean = false, var congestion: Boolean = false,
var readBufferSize: Int = 1, var readBufferSize: Int = 1,
var writeBufferSize: Int = 1, var writeBufferSize: Int = 1
var header: HeaderBean = HeaderBean(), )
var seed: String? = null
) {
data class HeaderBean(
var type: String = "none",
var domain: String? = null
)
}
data class WsSettingsBean( data class WsSettingsBean(
var path: String? = null, var path: String? = null,
@@ -310,13 +303,19 @@ data class V2rayConfig(
) )
} }
data class UdpMasksBean( data class FinalMaskBean(
var type: String, var tcp: List<MaskBean>? = null,
var settings: UdpMasksSettingsBean? = null var udp: List<MaskBean>? = null
) { ) {
data class UdpMasksSettingsBean( data class MaskBean(
var password: String? = null var type: String,
) var settings: MaskSettingsBean? = null
) {
data class MaskSettingsBean(
var password: String? = null,
var domain: String? = null
)
}
} }
} }
@@ -1,8 +1,8 @@
package com.v2ray.ang.fmt package com.v2ray.ang.fmt
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.dto.ProfileItem import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.V2rayConfig import com.v2ray.ang.dto.V2rayConfig
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.util.JsonUtil import com.v2ray.ang.util.JsonUtil
object CustomFmt : FmtBase() { object CustomFmt : FmtBase() {
@@ -1,8 +1,8 @@
package com.v2ray.ang.fmt package com.v2ray.ang.fmt
import com.v2ray.ang.AppConfig import com.v2ray.ang.AppConfig
import com.v2ray.ang.enums.NetworkType
import com.v2ray.ang.dto.ProfileItem import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.enums.NetworkType
import com.v2ray.ang.extension.nullIfBlank import com.v2ray.ang.extension.nullIfBlank
import com.v2ray.ang.handler.MmkvManager import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.util.HttpUtil import com.v2ray.ang.util.HttpUtil
@@ -22,17 +22,17 @@ open class FmtBase {
val query = if (dicQuery != null) val query = if (dicQuery != null)
"?" + dicQuery.toList().joinToString( "?" + dicQuery.toList().joinToString(
separator = "&", separator = "&",
transform = { it.first + "=" + Utils.urlEncode(it.second) }) transform = { it.first + "=" + Utils.encodeURIComponent(it.second) })
else "" else ""
val url = String.format( val url = String.format(
"%s@%s:%s", "%s@%s:%s",
Utils.urlEncode(userInfo ?: ""), Utils.encodeURIComponent(userInfo ?: ""),
Utils.getIpv6Address(HttpUtil.toIdnDomain(config.server.orEmpty())), Utils.getIpv6Address(HttpUtil.toIdnDomain(config.server.orEmpty())),
config.serverPort config.serverPort
) )
return "${url}${query}#${Utils.urlEncode(config.remarks)}" return "${url}${query}#${Utils.encodeURIComponent(config.remarks)}"
} }
/** /**
@@ -43,7 +43,7 @@ open class FmtBase {
*/ */
fun getQueryParam(uri: URI): Map<String, String> { fun getQueryParam(uri: URI): Map<String, String> {
return uri.rawQuery.split("&") return uri.rawQuery.split("&")
.associate { it.split("=").let { (k, v) -> k to Utils.urlDecode(v) } } .associate { it.split("=").let { (k, v) -> k to Utils.decodeURIComponent(v) } }
} }
/** /**
@@ -1,8 +1,8 @@
package com.v2ray.ang.fmt package com.v2ray.ang.fmt
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.dto.ProfileItem import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.V2rayConfig.OutboundBean import com.v2ray.ang.dto.V2rayConfig.OutboundBean
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.extension.isNotNullEmpty import com.v2ray.ang.extension.isNotNullEmpty
import com.v2ray.ang.handler.V2rayConfigManager import com.v2ray.ang.handler.V2rayConfigManager
@@ -1,12 +1,11 @@
package com.v2ray.ang.fmt package com.v2ray.ang.fmt
import com.v2ray.ang.AppConfig 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.ProfileItem
import com.v2ray.ang.dto.V2rayConfig.OutboundBean import com.v2ray.ang.dto.V2rayConfig.OutboundBean
import com.v2ray.ang.dto.V2rayConfig.OutboundBean.StreamSettingsBean import com.v2ray.ang.dto.V2rayConfig.OutboundBean.StreamSettingsBean.FinalMaskBean
import com.v2ray.ang.dto.V2rayConfig.OutboundBean.StreamSettingsBean.UdpMasksBean.UdpMasksSettingsBean import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.enums.NetworkType
import com.v2ray.ang.extension.idnHost import com.v2ray.ang.extension.idnHost
import com.v2ray.ang.extension.isNotNullEmpty import com.v2ray.ang.extension.isNotNullEmpty
import com.v2ray.ang.extension.nullIfBlank import com.v2ray.ang.extension.nullIfBlank
@@ -27,7 +26,7 @@ object Hysteria2Fmt : FmtBase() {
val config = ProfileItem.create(EConfigType.HYSTERIA2) val config = ProfileItem.create(EConfigType.HYSTERIA2)
val uri = URI(Utils.fixIllegalUrl(str)) val uri = URI(Utils.fixIllegalUrl(str))
config.remarks = Utils.urlDecode(uri.fragment.orEmpty()).let { it.ifEmpty { "none" } } config.remarks = Utils.decodeURIComponent(uri.fragment.orEmpty()).let { it.ifEmpty { "none" } }
config.server = uri.idnHost config.server = uri.idnHost
config.serverPort = uri.port.toString() config.serverPort = uri.port.toString()
config.password = uri.userInfo config.password = uri.userInfo
@@ -45,7 +44,7 @@ object Hysteria2Fmt : FmtBase() {
if (config.portHopping.isNotNullEmpty()) { if (config.portHopping.isNotNullEmpty()) {
config.portHoppingInterval = queryParam["mportHopInt"] config.portHoppingInterval = queryParam["mportHopInt"]
} }
config.pinSHA256 = queryParam["pinSHA256"] config.pinnedCA256 = queryParam["pinSHA256"]
} }
@@ -76,8 +75,8 @@ object Hysteria2Fmt : FmtBase() {
if (config.portHoppingInterval.isNotNullEmpty()) { if (config.portHoppingInterval.isNotNullEmpty()) {
dicQuery["mportHopInt"] = config.portHoppingInterval.orEmpty() dicQuery["mportHopInt"] = config.portHoppingInterval.orEmpty()
} }
if (config.pinSHA256.isNotNullEmpty()) { if (config.pinnedCA256.isNotNullEmpty()) {
dicQuery["pinSHA256"] = config.pinSHA256.orEmpty() dicQuery["pinSHA256"] = config.pinnedCA256.orEmpty()
} }
return toUri(config, config.password, dicQuery) return toUri(config, config.password, dicQuery)
@@ -109,11 +108,13 @@ object Hysteria2Fmt : FmtBase() {
} }
if (profileItem.obfsPassword.isNotNullEmpty()) { if (profileItem.obfsPassword.isNotNullEmpty()) {
outboundBean.streamSettings?.udpmasks = mutableListOf( outboundBean.streamSettings?.finalmask = FinalMaskBean(
StreamSettingsBean.UdpMasksBean( udp = listOf(
type = "salamander", FinalMaskBean.MaskBean(
settings = UdpMasksSettingsBean( type = "salamander",
password = profileItem.obfsPassword settings = FinalMaskBean.MaskBean.MaskSettingsBean(
password = profileItem.obfsPassword
)
) )
) )
) )
@@ -2,10 +2,10 @@ package com.v2ray.ang.fmt
import android.util.Log import android.util.Log
import com.v2ray.ang.AppConfig import com.v2ray.ang.AppConfig
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.enums.NetworkType
import com.v2ray.ang.dto.ProfileItem import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.V2rayConfig.OutboundBean import com.v2ray.ang.dto.V2rayConfig.OutboundBean
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.enums.NetworkType
import com.v2ray.ang.extension.idnHost import com.v2ray.ang.extension.idnHost
import com.v2ray.ang.handler.V2rayConfigManager import com.v2ray.ang.handler.V2rayConfigManager
import com.v2ray.ang.util.Utils import com.v2ray.ang.util.Utils
@@ -36,7 +36,7 @@ object ShadowsocksFmt : FmtBase() {
if (uri.port <= 0) return null if (uri.port <= 0) return null
if (uri.userInfo.isNullOrEmpty()) return null if (uri.userInfo.isNullOrEmpty()) return null
config.remarks = Utils.urlDecode(uri.fragment.orEmpty()).let { it.ifEmpty { "none" } } config.remarks = Utils.decodeURIComponent(uri.fragment.orEmpty()).let { it.ifEmpty { "none" } }
config.server = uri.idnHost config.server = uri.idnHost
config.serverPort = uri.port.toString() config.serverPort = uri.port.toString()
@@ -83,7 +83,7 @@ object ShadowsocksFmt : FmtBase() {
if (indexSplit > 0) { if (indexSplit > 0) {
try { try {
config.remarks = config.remarks =
Utils.urlDecode(result.substring(indexSplit + 1, result.length)) Utils.decodeURIComponent(result.substring(indexSplit + 1, result.length))
} catch (e: Exception) { } catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to decode remarks in SS legacy URL", e) Log.e(AppConfig.TAG, "Failed to decode remarks in SS legacy URL", e)
} }
@@ -1,8 +1,8 @@
package com.v2ray.ang.fmt package com.v2ray.ang.fmt
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.dto.ProfileItem import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.V2rayConfig.OutboundBean import com.v2ray.ang.dto.V2rayConfig.OutboundBean
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.extension.idnHost import com.v2ray.ang.extension.idnHost
import com.v2ray.ang.extension.isNotNullEmpty import com.v2ray.ang.extension.isNotNullEmpty
import com.v2ray.ang.handler.V2rayConfigManager import com.v2ray.ang.handler.V2rayConfigManager
@@ -23,7 +23,7 @@ object SocksFmt : FmtBase() {
if (uri.idnHost.isEmpty()) return null if (uri.idnHost.isEmpty()) return null
if (uri.port <= 0) return null if (uri.port <= 0) return null
config.remarks = Utils.urlDecode(uri.fragment.orEmpty()).let { it.ifEmpty { "none" } } config.remarks = Utils.decodeURIComponent(uri.fragment.orEmpty()).let { it.ifEmpty { "none" } }
config.server = uri.idnHost config.server = uri.idnHost
config.serverPort = uri.port.toString() config.serverPort = uri.port.toString()
@@ -1,10 +1,10 @@
package com.v2ray.ang.fmt package com.v2ray.ang.fmt
import com.v2ray.ang.AppConfig 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.ProfileItem
import com.v2ray.ang.dto.V2rayConfig.OutboundBean 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.extension.idnHost
import com.v2ray.ang.handler.MmkvManager import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.V2rayConfigManager import com.v2ray.ang.handler.V2rayConfigManager
@@ -23,7 +23,7 @@ object TrojanFmt : FmtBase() {
val config = ProfileItem.create(EConfigType.TROJAN) val config = ProfileItem.create(EConfigType.TROJAN)
val uri = URI(Utils.fixIllegalUrl(str)) val uri = URI(Utils.fixIllegalUrl(str))
config.remarks = Utils.urlDecode(uri.fragment.orEmpty()).let { it.ifEmpty { "none" } } config.remarks = Utils.decodeURIComponent(uri.fragment.orEmpty()).let { it.ifEmpty { "none" } }
config.server = uri.idnHost config.server = uri.idnHost
config.serverPort = uri.port.toString() config.serverPort = uri.port.toString()
config.password = uri.userInfo config.password = uri.userInfo
@@ -1,9 +1,9 @@
package com.v2ray.ang.fmt package com.v2ray.ang.fmt
import com.v2ray.ang.AppConfig import com.v2ray.ang.AppConfig
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.dto.ProfileItem import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.V2rayConfig.OutboundBean import com.v2ray.ang.dto.V2rayConfig.OutboundBean
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.extension.idnHost import com.v2ray.ang.extension.idnHost
import com.v2ray.ang.handler.MmkvManager import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.V2rayConfigManager import com.v2ray.ang.handler.V2rayConfigManager
@@ -26,7 +26,7 @@ object VlessFmt : FmtBase() {
if (uri.rawQuery.isNullOrEmpty()) return null if (uri.rawQuery.isNullOrEmpty()) return null
val queryParam = getQueryParam(uri) val queryParam = getQueryParam(uri)
config.remarks = Utils.urlDecode(uri.fragment.orEmpty()).let { it.ifEmpty { "none" } } config.remarks = Utils.decodeURIComponent(uri.fragment.orEmpty()).let { it.ifEmpty { "none" } }
config.server = uri.idnHost config.server = uri.idnHost
config.serverPort = uri.port.toString() config.serverPort = uri.port.toString()
config.password = uri.userInfo config.password = uri.userInfo
@@ -3,11 +3,11 @@ package com.v2ray.ang.fmt
import android.text.TextUtils import android.text.TextUtils
import android.util.Log import android.util.Log
import com.v2ray.ang.AppConfig import com.v2ray.ang.AppConfig
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.enums.NetworkType
import com.v2ray.ang.dto.ProfileItem import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.V2rayConfig.OutboundBean import com.v2ray.ang.dto.V2rayConfig.OutboundBean
import com.v2ray.ang.dto.VmessQRCode import com.v2ray.ang.dto.VmessQRCode
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.enums.NetworkType
import com.v2ray.ang.extension.idnHost import com.v2ray.ang.extension.idnHost
import com.v2ray.ang.extension.nullIfBlank import com.v2ray.ang.extension.nullIfBlank
import com.v2ray.ang.handler.MmkvManager import com.v2ray.ang.handler.MmkvManager
@@ -163,7 +163,7 @@ object VmessFmt : FmtBase() {
if (uri.rawQuery.isNullOrEmpty()) return null if (uri.rawQuery.isNullOrEmpty()) return null
val queryParam = getQueryParam(uri) val queryParam = getQueryParam(uri)
config.remarks = Utils.urlDecode(uri.fragment.orEmpty()).let { it.ifEmpty { "none" } } config.remarks = Utils.decodeURIComponent(uri.fragment.orEmpty()).let { it.ifEmpty { "none" } }
config.server = uri.idnHost config.server = uri.idnHost
config.serverPort = uri.port.toString() config.serverPort = uri.port.toString()
config.password = uri.userInfo config.password = uri.userInfo
@@ -2,9 +2,9 @@ package com.v2ray.ang.fmt
import com.v2ray.ang.AppConfig import com.v2ray.ang.AppConfig
import com.v2ray.ang.AppConfig.WIREGUARD_LOCAL_ADDRESS_V4 import com.v2ray.ang.AppConfig.WIREGUARD_LOCAL_ADDRESS_V4
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.dto.ProfileItem import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.V2rayConfig.OutboundBean import com.v2ray.ang.dto.V2rayConfig.OutboundBean
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.extension.idnHost import com.v2ray.ang.extension.idnHost
import com.v2ray.ang.extension.nullIfBlank import com.v2ray.ang.extension.nullIfBlank
import com.v2ray.ang.extension.removeWhiteSpace import com.v2ray.ang.extension.removeWhiteSpace
@@ -26,7 +26,7 @@ object WireguardFmt : FmtBase() {
if (uri.rawQuery.isNullOrEmpty()) return null if (uri.rawQuery.isNullOrEmpty()) return null
val queryParam = getQueryParam(uri) val queryParam = getQueryParam(uri)
config.remarks = Utils.urlDecode(uri.fragment.orEmpty()).let { it.ifEmpty { "none" } } config.remarks = Utils.decodeURIComponent(uri.fragment.orEmpty()).let { it.ifEmpty { "none" } }
config.server = uri.idnHost config.server = uri.idnHost
config.serverPort = uri.port.toString() config.serverPort = uri.port.toString()
@@ -7,10 +7,11 @@ import android.util.Log
import com.v2ray.ang.AppConfig import com.v2ray.ang.AppConfig
import com.v2ray.ang.AppConfig.HY2 import com.v2ray.ang.AppConfig.HY2
import com.v2ray.ang.R import com.v2ray.ang.R
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.dto.ProfileItem import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.SubscriptionCache import com.v2ray.ang.dto.SubscriptionCache
import com.v2ray.ang.dto.SubscriptionItem import com.v2ray.ang.dto.SubscriptionItem
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.extension.isNotNullEmpty
import com.v2ray.ang.fmt.CustomFmt import com.v2ray.ang.fmt.CustomFmt
import com.v2ray.ang.fmt.Hysteria2Fmt import com.v2ray.ang.fmt.Hysteria2Fmt
import com.v2ray.ang.fmt.ShadowsocksFmt import com.v2ray.ang.fmt.ShadowsocksFmt
@@ -237,23 +238,77 @@ object AngConfigManager {
} }
val subItem = MmkvManager.decodeSubscription(subid) val subItem = MmkvManager.decodeSubscription(subid)
var count = 0
// Parse all configs first (no I/O during parsing)
val configs = mutableListOf<ProfileItem>()
servers.lines() servers.lines()
.distinct() .distinct()
.reversed() .reversed()
.forEach { .forEach {
val resId = parseConfig(it, subid, subItem, removedSelectedServer) val config = parseConfig(it, subid, subItem)
if (resId == 0) { if (config != null) {
count++ configs.add(config)
} }
} }
return count
// Batch save all parsed configs (only one serverList read/write)
if (configs.isNotEmpty()) {
val keys = batchSaveConfigs(configs, subid)
// Handle removed selected server
removedSelectedServer?.let { removed ->
val matchKey = keys.find { key ->
val savedConfig = MmkvManager.decodeServerConfig(key)
savedConfig != null &&
savedConfig.server == removed.server &&
savedConfig.serverPort == removed.serverPort
}
matchKey?.let { MmkvManager.setSelectServer(it) }
}
}
return configs.size
} catch (e: Exception) { } catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to parse batch config", e) Log.e(AppConfig.TAG, "Failed to parse batch config", e)
} }
return 0 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 The list of generated keys.
*/
private fun batchSaveConfigs(configs: List<ProfileItem>, subid: String): List<String> {
val keys = mutableListOf<String>()
// 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
}
}
keys.add(key)
}
// Write serverList once
MmkvManager.encodeServerList(serverList, subid)
return keys
}
/** /**
* Parses a custom configuration server. * Parses a custom configuration server.
* *
@@ -271,13 +326,14 @@ object AngConfigManager {
) { ) {
try { try {
val serverList: Array<Any> = val serverList: Array<Any> =
JsonUtil.fromJson(server, Array<Any>::class.java)?: arrayOf() JsonUtil.fromJson(server, Array<Any>::class.java) ?: arrayOf()
if (serverList.isNotEmpty()) { if (serverList.isNotEmpty()) {
var count = 0 var count = 0
for (srv in serverList.reversed()) { for (srv in serverList.reversed()) {
val config = CustomFmt.parse(JsonUtil.toJson(srv)) ?: continue val config = CustomFmt.parse(JsonUtil.toJson(srv)) ?: continue
config.subscriptionId = subid config.subscriptionId = subid
config.description = generateDescription(config)
val key = MmkvManager.encodeServerConfig("", config) val key = MmkvManager.encodeServerConfig("", config)
MmkvManager.encodeServerRaw(key, JsonUtil.toJsonPretty(srv) ?: "") MmkvManager.encodeServerRaw(key, JsonUtil.toJsonPretty(srv) ?: "")
count += 1 count += 1
@@ -292,6 +348,7 @@ object AngConfigManager {
// For compatibility // For compatibility
val config = CustomFmt.parse(server) ?: return 0 val config = CustomFmt.parse(server) ?: return 0
config.subscriptionId = subid config.subscriptionId = subid
config.description = generateDescription(config)
val key = MmkvManager.encodeServerConfig("", config) val key = MmkvManager.encodeServerConfig("", config)
MmkvManager.encodeServerRaw(key, server) MmkvManager.encodeServerRaw(key, server)
return 1 return 1
@@ -302,6 +359,7 @@ object AngConfigManager {
} else if (server.startsWith("[Interface]") && server.contains("[Peer]")) { } else if (server.startsWith("[Interface]") && server.contains("[Peer]")) {
try { try {
val config = WireguardFmt.parseWireguardConfFile(server) ?: return R.string.toast_incorrect_protocol val config = WireguardFmt.parseWireguardConfFile(server) ?: return R.string.toast_incorrect_protocol
config.description = generateDescription(config)
val key = MmkvManager.encodeServerConfig("", config) val key = MmkvManager.encodeServerConfig("", config)
MmkvManager.encodeServerRaw(key, server) MmkvManager.encodeServerRaw(key, server)
return 1 return 1
@@ -316,22 +374,21 @@ object AngConfigManager {
/** /**
* Parses the configuration from a QR code or string. * Parses the configuration from a QR code or string.
* Only parses and returns ProfileItem, does not save.
* *
* @param str The configuration string. * @param str The configuration string.
* @param subid The subscription ID. * @param subid The subscription ID.
* @param subItem The subscription item. * @param subItem The subscription item.
* @param removedSelectedServer The removed selected server. * @return The parsed ProfileItem or null if parsing fails or filtered out.
* @return The result code.
*/ */
private fun parseConfig( private fun parseConfig(
str: String?, str: String?,
subid: String, subid: String,
subItem: SubscriptionItem?, subItem: SubscriptionItem?
removedSelectedServer: ProfileItem? ): ProfileItem? {
): Int {
try { try {
if (str == null || TextUtils.isEmpty(str)) { if (str == null || TextUtils.isEmpty(str)) {
return R.string.toast_none_data return null
} }
val config = if (str.startsWith(EConfigType.VMESS.protocolScheme)) { val config = if (str.startsWith(EConfigType.VMESS.protocolScheme)) {
@@ -353,27 +410,24 @@ object AngConfigManager {
} }
if (config == null) { if (config == null) {
return R.string.toast_incorrect_protocol return null
} }
//filter
if (subItem?.filter != null && subItem.filter?.isNotEmpty() == true && config.remarks.isNotEmpty()) { // Apply filter
val matched = Regex(pattern = subItem.filter ?: "") if (subItem?.filter.isNotNullEmpty() && config.remarks.isNotNullEmpty()) {
val matched = Regex(pattern = subItem?.filter.orEmpty())
.containsMatchIn(input = config.remarks) .containsMatchIn(input = config.remarks)
if (!matched) return -1 if (!matched) return null
} }
config.subscriptionId = subid config.subscriptionId = subid
val guid = MmkvManager.encodeServerConfig("", config) config.description = generateDescription(config)
if (removedSelectedServer != null &&
config.server == removedSelectedServer.server && config.serverPort == removedSelectedServer.serverPort return config
) {
MmkvManager.setSelectServer(guid)
}
} catch (e: Exception) { } catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to parse config", e) Log.e(AppConfig.TAG, "Failed to parse config", e)
return -1 return null
} }
return 0
} }
/** /**
@@ -493,4 +547,25 @@ object AngConfigManager {
MmkvManager.encodeSubscription("", subItem) MmkvManager.encodeSubscription("", subItem)
return 1 return 1
} }
/** Generates a description for the profile.
*
* @param profile The profile item.
* @return The generated description.
*/
fun generateDescription(profile: ProfileItem): String {
// Hide xxx:xxx:***/xxx.xxx.xxx.***
val server = profile.server
val port = profile.serverPort
if (server.isNullOrBlank() && port.isNullOrBlank()) return ""
val addrPart = server?.let {
if (it.contains(":"))
it.split(":").take(2).joinToString(":", postfix = ":***")
else
it.split('.').dropLast(1).joinToString(".", postfix = ".***")
} ?: ""
return "$addrPart : ${port ?: ""}"
}
} }
@@ -1,6 +1,7 @@
package com.v2ray.ang.handler package com.v2ray.ang.handler
import com.tencent.mmkv.MMKV 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_IS_BOOTED
import com.v2ray.ang.AppConfig.PREF_ROUTING_RULESET import com.v2ray.ang.AppConfig.PREF_ROUTING_RULESET
import com.v2ray.ang.dto.AssetUrlCache import com.v2ray.ang.dto.AssetUrlCache
@@ -28,6 +29,7 @@ object MmkvManager {
private const val ID_SETTING = "SETTING" private const val ID_SETTING = "SETTING"
private const val KEY_SELECTED_SERVER = "SELECTED_SERVER" private const val KEY_SELECTED_SERVER = "SELECTED_SERVER"
private const val KEY_ANG_CONFIGS = "ANG_CONFIGS" 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_SUB_IDS = "SUB_IDS"
private const val KEY_WEBDAV_CONFIG = "WEBDAV_CONFIG" private const val KEY_WEBDAV_CONFIG = "WEBDAV_CONFIG"
@@ -44,6 +46,24 @@ object MmkvManager {
//region Server //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)
}
// /**
// * Removes the legacy KEY_ANG_CONFIGS after migration.
// * This method is for migration purposes only.
// */
// fun removeLegacyServerListKey() {
// mainStorage.remove(KEY_ANG_CONFIGS)
// }
/** /**
* Gets the selected server GUID. * Gets the selected server GUID.
* *
@@ -63,19 +83,65 @@ 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 serverList The list of server GUIDs.
* @param subscriptionId The subscription ID.
*/ */
fun encodeServerList(serverList: MutableList<String>, subscriptionId: String) {
val subId = getSubscriptionId(subscriptionId)
val key = "$KEY_SUB_SERVER_PREFIX$subId"
mainStorage.encode(key, JsonUtil.toJson(serverList))
}
// Legacy method for compatibility
// TODO: Remove after migration and update all callers
/*
fun encodeServerList(serverList: MutableList<String>) { fun encodeServerList(serverList: MutableList<String>) {
mainStorage.encode(KEY_ANG_CONFIGS, JsonUtil.toJson(serverList)) mainStorage.encode(KEY_ANG_CONFIGS, 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. * @return The list of server GUIDs.
*/ */
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 {
JsonUtil.fromJson(json, Array<String>::class.java)?.toMutableList() ?: mutableListOf()
}
}
/**
* 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>()
// Add servers from all subscriptions (including default subscription)
decodeSubsList().forEach { guid ->
allServers.addAll(decodeServerList(guid))
}
return allServers
}
// Legacy method for compatibility - reads all servers
// TODO: Remove after migration and update all callers
/*
fun decodeServerList(): MutableList<String> { fun decodeServerList(): MutableList<String> {
val json = mainStorage.decodeString(KEY_ANG_CONFIGS) val json = mainStorage.decodeString(KEY_ANG_CONFIGS)
return if (json.isNullOrBlank()) { return if (json.isNullOrBlank()) {
@@ -84,6 +150,7 @@ object MmkvManager {
JsonUtil.fromJson(json, Array<String>::class.java)?.toMutableList() ?: mutableListOf() JsonUtil.fromJson(json, Array<String>::class.java)?.toMutableList() ?: mutableListOf()
} }
} }
*/
/** /**
* Decodes the server configuration. * Decodes the server configuration.
@@ -123,6 +190,21 @@ object MmkvManager {
fun encodeServerConfig(guid: String, config: ProfileItem): String { fun encodeServerConfig(guid: String, config: ProfileItem): String {
val key = guid.ifBlank { Utils.getUuid() } val key = guid.ifBlank { Utils.getUuid() }
profileFullStorage.encode(key, JsonUtil.toJson(config)) profileFullStorage.encode(key, JsonUtil.toJson(config))
// 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, subId)
if (getSelectServer().isNullOrBlank()) {
mainStorage.encode(KEY_SELECTED_SERVER, key)
}
}
// Legacy code - keep for reference during migration
/*
val serverList = decodeServerList() val serverList = decodeServerList()
if (!serverList.contains(key)) { if (!serverList.contains(key)) {
serverList.add(0, key) serverList.add(0, key)
@@ -131,6 +213,7 @@ object MmkvManager {
mainStorage.encode(KEY_SELECTED_SERVER, key) mainStorage.encode(KEY_SELECTED_SERVER, key)
} }
} }
*/
// val profile = ProfileLiteItem( // val profile = ProfileLiteItem(
// configType = config.configType, // configType = config.configType,
// subscriptionId = config.subscriptionId, // subscriptionId = config.subscriptionId,
@@ -142,6 +225,16 @@ object MmkvManager {
return key 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. * Removes the server configuration.
* *
@@ -151,12 +244,30 @@ object MmkvManager {
if (guid.isBlank()) { if (guid.isBlank()) {
return 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)
// Legacy code - keep for reference during migration
/*
if (getSelectServer() == guid) { if (getSelectServer() == guid) {
mainStorage.remove(KEY_SELECTED_SERVER) mainStorage.remove(KEY_SELECTED_SERVER)
} }
val serverList = decodeServerList() val serverList = decodeServerList()
serverList.remove(guid) serverList.remove(guid)
encodeServerList(serverList) encodeServerList(serverList)
*/
// Clean up storage
if (getSelectServer() == guid) {
mainStorage.remove(KEY_SELECTED_SERVER)
}
profileFullStorage.remove(guid) profileFullStorage.remove(guid)
//profileStorage.remove(guid) //profileStorage.remove(guid)
serverAffStorage.remove(guid) serverAffStorage.remove(guid)
@@ -165,12 +276,26 @@ object MmkvManager {
/** /**
* Removes the server configurations via subscription ID. * Removes the server configurations via subscription ID.
* *
* @param subid The subscription ID. * @param subscriptionId The subscription ID.
*/ */
fun removeServerViaSubid(subid: String) { fun removeServerViaSubid(subscriptionId: String?) {
if (subid.isBlank()) { val subId = getSubscriptionId(subscriptionId)
return 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)
// Legacy code - keep for reference during migration
/*
profileFullStorage.allKeys()?.forEach { key -> profileFullStorage.allKeys()?.forEach { key ->
decodeServerConfig(key)?.let { config -> decodeServerConfig(key)?.let { config ->
if (config.subscriptionId == subid) { if (config.subscriptionId == subid) {
@@ -178,6 +303,7 @@ object MmkvManager {
} }
} }
} }
*/
} }
/** /**
@@ -292,6 +418,10 @@ object MmkvManager {
//region Subscriptions //region Subscriptions
private fun getSubscriptionId(subscriptionId: String?):String {
return subscriptionId?.ifEmpty { DEFAULT_SUBSCRIPTION_ID } ?: DEFAULT_SUBSCRIPTION_ID
}
/** /**
* Initializes the subscription list. * Initializes the subscription list.
*/ */
@@ -331,6 +461,11 @@ object MmkvManager {
* @param subid The subscription ID. * @param subid The subscription ID.
*/ */
fun removeSubscription(subid: String) { fun removeSubscription(subid: String) {
// Protect default subscription from being deleted
if (subid == DEFAULT_SUBSCRIPTION_ID) {
return
}
subStorage.remove(subid) subStorage.remove(subid)
val subsList = decodeSubsList() val subsList = decodeSubsList()
subsList.remove(subid) subsList.remove(subid)
@@ -9,6 +9,7 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.graphics.Color import android.graphics.Color
import android.os.Build import android.os.Build
import android.util.Log
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import com.v2ray.ang.AppConfig 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_STOP_V2RAY = 1
private const val NOTIFICATION_PENDING_INTENT_RESTART_V2RAY = 2 private const val NOTIFICATION_PENDING_INTENT_RESTART_V2RAY = 2
private const val NOTIFICATION_ICON_THRESHOLD = 3000 private const val NOTIFICATION_ICON_THRESHOLD = 3000
private const val QUERY_INTERVAL_MS = 3000L
private var lastQueryTime = 0L private var lastQueryTime = 0L
private var mBuilder: NotificationCompat.Builder? = null private var mBuilder: NotificationCompat.Builder? = null
@@ -44,7 +46,6 @@ object NotificationManager {
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_SPEED_ENABLED) != true) return if (MmkvManager.decodeSettingsBool(AppConfig.PREF_SPEED_ENABLED) != true) return
if (speedNotificationJob != null || V2RayServiceManager.isRunning() == false) return if (speedNotificationJob != null || V2RayServiceManager.isRunning() == false) return
lastQueryTime = System.currentTimeMillis()
var lastZeroSpeed = false var lastZeroSpeed = false
val outboundTags = currentConfig?.getAllOutboundTags() val outboundTags = currentConfig?.getAllOutboundTags()
outboundTags?.remove(AppConfig.TAG_DIRECT) outboundTags?.remove(AppConfig.TAG_DIRECT)
@@ -52,7 +53,17 @@ object NotificationManager {
speedNotificationJob = CoroutineScope(Dispatchers.IO).launch { speedNotificationJob = CoroutineScope(Dispatchers.IO).launch {
while (isActive) { while (isActive) {
val queryTime = System.currentTimeMillis() 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 var proxyTotal = 0L
val text = StringBuilder() val text = StringBuilder()
outboundTags?.forEach { outboundTags?.forEach {
@@ -78,7 +89,7 @@ object NotificationManager {
} }
lastZeroSpeed = zeroSpeed lastZeroSpeed = zeroSpeed
lastQueryTime = queryTime lastQueryTime = queryTime
delay(3000) delay(QUERY_INTERVAL_MS)
} }
} }
} }
@@ -89,6 +100,10 @@ object NotificationManager {
*/ */
fun showNotification(currentConfig: ProfileItem?) { fun showNotification(currentConfig: ProfileItem?) {
val service = getService() ?: return 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 flags = PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
val startMainIntent = Intent(service, MainActivity::class.java) val startMainIntent = Intent(service, MainActivity::class.java)
@@ -7,19 +7,24 @@ import android.util.Log
import androidx.appcompat.app.AppCompatDelegate import androidx.appcompat.app.AppCompatDelegate
import com.v2ray.ang.AppConfig import com.v2ray.ang.AppConfig
import com.v2ray.ang.AppConfig.ANG_PACKAGE 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.GEOIP_PRIVATE
import com.v2ray.ang.AppConfig.GEOSITE_PRIVATE import com.v2ray.ang.AppConfig.GEOSITE_PRIVATE
import com.v2ray.ang.AppConfig.TAG_DIRECT import com.v2ray.ang.AppConfig.TAG_DIRECT
import com.v2ray.ang.AppConfig.VPN 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.EConfigType
import com.v2ray.ang.enums.Language import com.v2ray.ang.enums.Language
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.enums.RoutingType 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.enums.VpnInterfaceAddressConfig
import com.v2ray.ang.handler.MmkvManager.decodeAllServerList
import com.v2ray.ang.handler.MmkvManager.decodeServerConfig import com.v2ray.ang.handler.MmkvManager.decodeServerConfig
import com.v2ray.ang.handler.MmkvManager.decodeServerList import com.v2ray.ang.handler.MmkvManager.decodeSubsList
import com.v2ray.ang.handler.MmkvManager.decodeSubscription
import com.v2ray.ang.handler.MmkvManager.encodeSubscription
import com.v2ray.ang.util.JsonUtil import com.v2ray.ang.util.JsonUtil
import com.v2ray.ang.util.Utils import com.v2ray.ang.util.Utils
import java.io.File import java.io.File
@@ -29,11 +34,19 @@ import java.util.Locale
object SettingsManager { object SettingsManager {
fun initApp(context: Context) {
ensureDefaultSettings()
ensureDefaultSubscription()
initRoutingRulesets(context)
migrateServerListToSubscriptions()
migrateHysteria2PinSHA256()
}
/** /**
* Initialize routing rulesets. * Initialize routing rulesets.
* @param context The application context. * @param context The application context.
*/ */
fun initRoutingRulesets(context: Context) { private fun initRoutingRulesets(context: Context) {
val exist = MmkvManager.decodeRoutingRulesets() val exist = MmkvManager.decodeRoutingRulesets()
if (exist.isNullOrEmpty()) { if (exist.isNullOrEmpty()) {
val rulesetList = getPresetRoutingRulesets(context) val rulesetList = getPresetRoutingRulesets(context)
@@ -221,14 +234,10 @@ object SettingsManager {
if (remarks.isNullOrEmpty()) { if (remarks.isNullOrEmpty()) {
return null return null
} }
val serverList = decodeServerList() val serverList = decodeAllServerList()
for (guid in serverList) { return serverList
val profile = decodeServerConfig(guid) .mapNotNull { guid -> decodeServerConfig(guid) }
if (profile != null && profile.remarks == remarks) { .firstOrNull { it.remarks == remarks }
return profile
}
}
return null
} }
/** /**
@@ -399,7 +408,7 @@ object SettingsManager {
/** /**
* Ensure default settings are present in MMKV. * Ensure default settings are present in MMKV.
*/ */
fun ensureDefaultSettings() { private fun ensureDefaultSettings() {
// Write defaults in the exact order requested by the user // Write defaults in the exact order requested by the user
ensureDefaultValue(AppConfig.PREF_MODE, AppConfig.VPN) ensureDefaultValue(AppConfig.PREF_MODE, AppConfig.VPN)
ensureDefaultValue(AppConfig.PREF_VPN_DNS, AppConfig.DNS_VPN) ensureDefaultValue(AppConfig.PREF_VPN_DNS, AppConfig.DNS_VPN)
@@ -422,4 +431,102 @@ object SettingsManager {
MmkvManager.encodeSettings(key, default) 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)
}
// Remove legacy KEY_ANG_CONFIGS data
// MmkvManager.removeLegacyServerListKey()
// 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 androidx.core.content.ContextCompat
import com.v2ray.ang.AppConfig import com.v2ray.ang.AppConfig
import com.v2ray.ang.R import com.v2ray.ang.R
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.extension.toast
import com.v2ray.ang.contracts.ServiceControl 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.V2RayProxyOnlyService
import com.v2ray.ang.service.V2RayVpnService import com.v2ray.ang.service.V2RayVpnService
import com.v2ray.ang.util.MessageUtil import com.v2ray.ang.util.MessageUtil
@@ -6,8 +6,6 @@ import android.util.Log
import com.google.gson.JsonArray import com.google.gson.JsonArray
import com.v2ray.ang.AppConfig import com.v2ray.ang.AppConfig
import com.v2ray.ang.dto.ConfigResult import com.v2ray.ang.dto.ConfigResult
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.enums.NetworkType
import com.v2ray.ang.dto.ProfileItem import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.RulesetItem import com.v2ray.ang.dto.RulesetItem
import com.v2ray.ang.dto.V2rayConfig import com.v2ray.ang.dto.V2rayConfig
@@ -15,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.OutSettingsBean
import com.v2ray.ang.dto.V2rayConfig.OutboundBean.StreamSettingsBean import com.v2ray.ang.dto.V2rayConfig.OutboundBean.StreamSettingsBean
import com.v2ray.ang.dto.V2rayConfig.RoutingBean.RulesBean import com.v2ray.ang.dto.V2rayConfig.RoutingBean.RulesBean
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.enums.NetworkType
import com.v2ray.ang.extension.isNotNullEmpty import com.v2ray.ang.extension.isNotNullEmpty
import com.v2ray.ang.extension.nullIfBlank import com.v2ray.ang.extension.nullIfBlank
import com.v2ray.ang.fmt.HttpFmt import com.v2ray.ang.fmt.HttpFmt
@@ -139,7 +139,7 @@ object V2rayConfigManager {
private fun getV2rayGroupConfig(context: Context, guid: String, config: ProfileItem): ConfigResult { private fun getV2rayGroupConfig(context: Context, guid: String, config: ProfileItem): ConfigResult {
val result = ConfigResult(false) val result = ConfigResult(false)
val serverList = MmkvManager.decodeServerList() val serverList = MmkvManager.decodeAllServerList()
val configList = serverList val configList = serverList
.mapNotNull { id -> MmkvManager.decodeServerConfig(id) } .mapNotNull { id -> MmkvManager.decodeServerConfig(id) }
.filter { profile -> .filter { profile ->
@@ -776,13 +776,14 @@ object V2rayConfigManager {
|| protocol.equals(EConfigType.TROJAN.name, true) || protocol.equals(EConfigType.TROJAN.name, true)
|| protocol.equals(EConfigType.WIREGUARD.name, true) || protocol.equals(EConfigType.WIREGUARD.name, true)
|| protocol.equals(EConfigType.HYSTERIA2.name, true) || protocol.equals(EConfigType.HYSTERIA2.name, true)
|| protocol.equals(EConfigType.HYSTERIA.name, true)
) { ) {
muxEnabled = false muxEnabled = false
} else if (outbound.streamSettings?.network == NetworkType.XHTTP.type) { } else if (outbound.streamSettings?.network == NetworkType.XHTTP.type) {
muxEnabled = false muxEnabled = false
} }
if (muxEnabled == true) { if (muxEnabled) {
outbound.mux?.enabled = true outbound.mux?.enabled = true
outbound.mux?.concurrency = MmkvManager.decodeSettingsString(AppConfig.PREF_MUX_CONCURRENCY, "8").orEmpty().toInt() outbound.mux?.concurrency = MmkvManager.decodeSettingsString(AppConfig.PREF_MUX_CONCURRENCY, "8").orEmpty().toInt()
outbound.mux?.xudpConcurrency = MmkvManager.decodeSettingsString(AppConfig.PREF_MUX_XUDP_CONCURRENCY, "16").orEmpty().toInt() outbound.mux?.xudpConcurrency = MmkvManager.decodeSettingsString(AppConfig.PREF_MUX_XUDP_CONCURRENCY, "16").orEmpty().toInt()
@@ -1183,19 +1184,39 @@ object V2rayConfigManager {
} }
NetworkType.KCP.type -> { NetworkType.KCP.type -> {
val kcpsetting = StreamSettingsBean.KcpSettingsBean() streamSettings.kcpSettings = StreamSettingsBean.KcpSettingsBean()
kcpsetting.header.type = headerType ?: "none" val udpMaskList = mutableListOf<StreamSettingsBean.FinalMaskBean.MaskBean>()
if (!headerType.isNullOrEmpty() && headerType != "none") {
val kcpHeaderType = when {
headerType == "wechat-video" -> "header-wechat"
else -> "header-$headerType"
}
udpMaskList.add(StreamSettingsBean.FinalMaskBean.MaskBean(
type = kcpHeaderType,
settings = if (headerType == "dns" && !host.isNullOrEmpty()) {
StreamSettingsBean.FinalMaskBean.MaskBean.MaskSettingsBean(
domain = host
)
} else {
null
}
))
}
if (seed.isNullOrEmpty()) { if (seed.isNullOrEmpty()) {
kcpsetting.seed = null udpMaskList.add(StreamSettingsBean.FinalMaskBean.MaskBean(
type = "mkcp-original"
))
} else { } else {
kcpsetting.seed = seed udpMaskList.add(StreamSettingsBean.FinalMaskBean.MaskBean(
type = "mkcp-aes128gcm",
settings = StreamSettingsBean.FinalMaskBean.MaskBean.MaskSettingsBean(
password = seed
)
))
} }
if (host.isNullOrEmpty()) { streamSettings.finalmask = StreamSettingsBean.FinalMaskBean(
kcpsetting.header.domain = null udp = udpMaskList.toList()
} else { )
kcpsetting.header.domain = host
}
streamSettings.kcpSettings = kcpsetting
} }
NetworkType.WS.type -> { NetworkType.WS.type -> {
@@ -79,5 +79,6 @@ class MmkvPreferenceDataStore : PreferenceDataStore() {
} }
// Notify listeners that require service restart or reinit // Notify listeners that require service restart or reinit
SettingsChangeManager.makeRestartService() SettingsChangeManager.makeRestartService()
SettingsChangeManager.makeSetupGroupTab()
} }
} }
@@ -6,7 +6,7 @@ import android.os.IBinder
import com.v2ray.ang.AppConfig import com.v2ray.ang.AppConfig
import com.v2ray.ang.AppConfig.MSG_MEASURE_CONFIG import com.v2ray.ang.AppConfig.MSG_MEASURE_CONFIG
import com.v2ray.ang.AppConfig.MSG_MEASURE_CONFIG_CANCEL import com.v2ray.ang.AppConfig.MSG_MEASURE_CONFIG_CANCEL
import com.v2ray.ang.extension.serializable import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.V2RayNativeManager import com.v2ray.ang.handler.V2RayNativeManager
import com.v2ray.ang.util.MessageUtil import com.v2ray.ang.util.MessageUtil
import java.util.Collections import java.util.Collections
@@ -54,8 +54,14 @@ class V2RayTestService : Service() {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.getIntExtra("key", 0)) { when (intent?.getIntExtra("key", 0)) {
MSG_MEASURE_CONFIG -> { MSG_MEASURE_CONFIG -> {
val guidsList = intent.serializable<ArrayList<String>>("content") val subscriptionId = intent.getStringExtra("content").orEmpty()
if (guidsList != null && guidsList.isNotEmpty()) { val guidsList = if (subscriptionId.isEmpty()) {
MmkvManager.decodeAllServerList()
} else {
MmkvManager.decodeServerList(subscriptionId)
}
if (guidsList.isNotEmpty()) {
lateinit var worker: RealPingWorkerService lateinit var worker: RealPingWorkerService
worker = RealPingWorkerService(this, guidsList) { status -> worker = RealPingWorkerService(this, guidsList) { status ->
// notify UI and remove the worker from active list when finished // notify UI and remove the worker from active list when finished
@@ -18,6 +18,7 @@ import com.v2ray.ang.extension.toastError
import com.v2ray.ang.extension.toastSuccess import com.v2ray.ang.extension.toastSuccess
import com.v2ray.ang.handler.MmkvManager import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.SettingsChangeManager import com.v2ray.ang.handler.SettingsChangeManager
import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.handler.WebDavManager import com.v2ray.ang.handler.WebDavManager
import com.v2ray.ang.util.ZipUtil import com.v2ray.ang.util.ZipUtil
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -124,6 +125,8 @@ class BackupActivity : HelperBaseActivity() {
val count = MMKV.restoreAllFromDirectory(backupDir) val count = MMKV.restoreAllFromDirectory(backupDir)
SettingsChangeManager.makeSetupGroupTab() SettingsChangeManager.makeSetupGroupTab()
SettingsChangeManager.makeRestartService() SettingsChangeManager.makeRestartService()
SettingsManager.initApp(this)
return count > 0 return count > 0
} }
@@ -16,8 +16,8 @@ import com.v2ray.ang.R
import com.v2ray.ang.contracts.MainAdapterListener import com.v2ray.ang.contracts.MainAdapterListener
import com.v2ray.ang.databinding.FragmentGroupServerBinding import com.v2ray.ang.databinding.FragmentGroupServerBinding
import com.v2ray.ang.databinding.ItemQrcodeBinding import com.v2ray.ang.databinding.ItemQrcodeBinding
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.dto.ProfileItem import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.extension.toast import com.v2ray.ang.extension.toast
import com.v2ray.ang.extension.toastError import com.v2ray.ang.extension.toastError
import com.v2ray.ang.extension.toastSuccess import com.v2ray.ang.extension.toastSuccess
@@ -164,6 +164,7 @@ class GroupServerFragment : BaseFragment<FragmentGroupServerBinding>() {
val intent = Intent().putExtra("guid", guid) val intent = Intent().putExtra("guid", guid)
.putExtra("isRunning", mainViewModel.isRunning.value) .putExtra("isRunning", mainViewModel.isRunning.value)
.putExtra("createConfigType", profile.configType.value) .putExtra("createConfigType", profile.configType.value)
.putExtra("subscriptionId", subId)
when (profile.configType) { when (profile.configType) {
EConfigType.CUSTOM -> { EConfigType.CUSTOM -> {
ownerActivity.startActivity(intent.setClass(ownerActivity, ServerCustomConfigActivity::class.java)) ownerActivity.startActivity(intent.setClass(ownerActivity, ServerCustomConfigActivity::class.java))
@@ -14,6 +14,8 @@ import com.v2ray.ang.databinding.ItemRecyclerFooterBinding
import com.v2ray.ang.databinding.ItemRecyclerMainBinding import com.v2ray.ang.databinding.ItemRecyclerMainBinding
import com.v2ray.ang.dto.ProfileItem import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.ServersCache import com.v2ray.ang.dto.ServersCache
import com.v2ray.ang.extension.nullIfBlank
import com.v2ray.ang.handler.AngConfigManager
import com.v2ray.ang.handler.MmkvManager import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.helper.ItemTouchHelperAdapter import com.v2ray.ang.helper.ItemTouchHelperAdapter
import com.v2ray.ang.helper.ItemTouchHelperViewHolder import com.v2ray.ang.helper.ItemTouchHelperViewHolder
@@ -121,19 +123,7 @@ class MainRecyclerAdapter(
* @return Formatted address string * @return Formatted address string
*/ */
private fun getAddress(profile: ProfileItem): String { private fun getAddress(profile: ProfileItem): String {
// Hide xxx:xxx:***/xxx.xxx.xxx.*** return profile.description.nullIfBlank() ?: AngConfigManager.generateDescription(profile)
val server = profile.server
val port = profile.serverPort
if (server.isNullOrBlank() && port.isNullOrBlank()) return ""
val addrPart = server?.let {
if (it.contains(":"))
it.split(":").take(2).joinToString(":", postfix = ":***")
else
it.split('.').dropLast(1).joinToString(".", postfix = ".***")
} ?: ""
return "$addrPart : ${port ?: ""}"
} }
/** /**
@@ -20,12 +20,13 @@ import com.v2ray.ang.AppConfig.TLS
import com.v2ray.ang.AppConfig.WIREGUARD_LOCAL_ADDRESS_V4 import com.v2ray.ang.AppConfig.WIREGUARD_LOCAL_ADDRESS_V4
import com.v2ray.ang.AppConfig.WIREGUARD_LOCAL_MTU import com.v2ray.ang.AppConfig.WIREGUARD_LOCAL_MTU
import com.v2ray.ang.R import com.v2ray.ang.R
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.enums.EConfigType import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.enums.NetworkType import com.v2ray.ang.enums.NetworkType
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.extension.isNotNullEmpty import com.v2ray.ang.extension.isNotNullEmpty
import com.v2ray.ang.extension.toast import com.v2ray.ang.extension.toast
import com.v2ray.ang.extension.toastSuccess import com.v2ray.ang.extension.toastSuccess
import com.v2ray.ang.handler.AngConfigManager
import com.v2ray.ang.handler.MmkvManager import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.util.JsonUtil import com.v2ray.ang.util.JsonUtil
import com.v2ray.ang.util.Utils import com.v2ray.ang.util.Utils
@@ -128,7 +129,6 @@ class ServerActivity : BaseActivity() {
private val et_obfs_password: EditText? by lazy { findViewById(R.id.et_obfs_password) } private val et_obfs_password: EditText? by lazy { findViewById(R.id.et_obfs_password) }
private val et_port_hop: EditText? by lazy { findViewById(R.id.et_port_hop) } private val et_port_hop: EditText? by lazy { findViewById(R.id.et_port_hop) }
private val et_port_hop_interval: EditText? by lazy { findViewById(R.id.et_port_hop_interval) } private val et_port_hop_interval: EditText? by lazy { findViewById(R.id.et_port_hop_interval) }
private val et_pinsha256: EditText? by lazy { findViewById(R.id.et_pinsha256) }
private val et_bandwidth_down: EditText? by lazy { findViewById(R.id.et_bandwidth_down) } private val et_bandwidth_down: EditText? by lazy { findViewById(R.id.et_bandwidth_down) }
private val et_bandwidth_up: EditText? by lazy { findViewById(R.id.et_bandwidth_up) } private val et_bandwidth_up: EditText? by lazy { findViewById(R.id.et_bandwidth_up) }
private val et_extra: EditText? by lazy { findViewById(R.id.et_extra) } private val et_extra: EditText? by lazy { findViewById(R.id.et_extra) }
@@ -366,7 +366,6 @@ class ServerActivity : BaseActivity() {
et_obfs_password?.text = Utils.getEditable(config.obfsPassword) et_obfs_password?.text = Utils.getEditable(config.obfsPassword)
et_port_hop?.text = Utils.getEditable(config.portHopping) et_port_hop?.text = Utils.getEditable(config.portHopping)
et_port_hop_interval?.text = Utils.getEditable(config.portHoppingInterval) et_port_hop_interval?.text = Utils.getEditable(config.portHoppingInterval)
et_pinsha256?.text = Utils.getEditable(config.pinSHA256)
et_bandwidth_down?.text = Utils.getEditable(config.bandwidthDown) et_bandwidth_down?.text = Utils.getEditable(config.bandwidthDown)
et_bandwidth_up?.text = Utils.getEditable(config.bandwidthUp) et_bandwidth_up?.text = Utils.getEditable(config.bandwidthUp)
} }
@@ -494,6 +493,8 @@ class ServerActivity : BaseActivity() {
saveStreamSettings(config) saveStreamSettings(config)
saveTls(config) saveTls(config)
config.description = AngConfigManager.generateDescription(config)
if (config.subscriptionId.isEmpty() && !subscriptionId.isNullOrEmpty()) { if (config.subscriptionId.isEmpty() && !subscriptionId.isNullOrEmpty()) {
config.subscriptionId = subscriptionId.orEmpty() config.subscriptionId = subscriptionId.orEmpty()
} }
@@ -533,7 +534,6 @@ class ServerActivity : BaseActivity() {
config.obfsPassword = et_obfs_password?.text?.toString() config.obfsPassword = et_obfs_password?.text?.toString()
config.portHopping = et_port_hop?.text?.toString() config.portHopping = et_port_hop?.text?.toString()
config.portHoppingInterval = et_port_hop_interval?.text?.toString() config.portHoppingInterval = et_port_hop_interval?.text?.toString()
config.pinSHA256 = et_pinsha256?.text?.toString()
config.bandwidthDown = et_bandwidth_down?.text?.toString() config.bandwidthDown = et_bandwidth_down?.text?.toString()
config.bandwidthUp = et_bandwidth_up?.text?.toString() config.bandwidthUp = et_bandwidth_up?.text?.toString()
} }
@@ -11,11 +11,12 @@ import com.blacksquircle.ui.language.json.JsonLanguage
import com.v2ray.ang.AppConfig import com.v2ray.ang.AppConfig
import com.v2ray.ang.R import com.v2ray.ang.R
import com.v2ray.ang.databinding.ActivityServerCustomConfigBinding import com.v2ray.ang.databinding.ActivityServerCustomConfigBinding
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.dto.ProfileItem import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.extension.toast import com.v2ray.ang.extension.toast
import com.v2ray.ang.extension.toastSuccess import com.v2ray.ang.extension.toastSuccess
import com.v2ray.ang.fmt.CustomFmt import com.v2ray.ang.fmt.CustomFmt
import com.v2ray.ang.handler.AngConfigManager
import com.v2ray.ang.handler.MmkvManager import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.util.Utils import com.v2ray.ang.util.Utils
@@ -89,6 +90,7 @@ class ServerCustomConfigActivity : BaseActivity() {
} }
config.server = profileItem?.server config.server = profileItem?.server
config.serverPort = profileItem?.serverPort config.serverPort = profileItem?.serverPort
config.description = AngConfigManager.generateDescription(config)
MmkvManager.encodeServerConfig(editGuid, config) MmkvManager.encodeServerConfig(editGuid, config)
MmkvManager.encodeServerRaw(editGuid, binding.editor.text.toString()) MmkvManager.encodeServerRaw(editGuid, binding.editor.text.toString())
@@ -8,8 +8,9 @@ import android.widget.ArrayAdapter
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import com.v2ray.ang.R import com.v2ray.ang.R
import com.v2ray.ang.databinding.ActivityServerGroupBinding import com.v2ray.ang.databinding.ActivityServerGroupBinding
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.dto.ProfileItem 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.toast
import com.v2ray.ang.extension.toastSuccess import com.v2ray.ang.extension.toastSuccess
import com.v2ray.ang.handler.MmkvManager import com.v2ray.ang.handler.MmkvManager
@@ -66,6 +67,11 @@ class ServerGroupActivity : BaseActivity() {
private fun clearServer(): Boolean { private fun clearServer(): Boolean {
binding.etRemarks.text = null binding.etRemarks.text = null
binding.etPolicyGroupFilter.text = null binding.etPolicyGroupFilter.text = null
if (subscriptionId.isNotNullEmpty()) {
val pos = subIds.indexOf(subscriptionId).let { if (it >= 0) it else 0 }
binding.spPolicyGroupSubId.setSelection(pos)
}
return true return true
} }
@@ -90,6 +96,9 @@ class ServerGroupActivity : BaseActivity() {
if (config.subscriptionId.isEmpty() && !subscriptionId.isNullOrEmpty()) { if (config.subscriptionId.isEmpty() && !subscriptionId.isNullOrEmpty()) {
config.subscriptionId = subscriptionId.orEmpty() config.subscriptionId = subscriptionId.orEmpty()
} }
config.description = "${binding.spPolicyGroupType.selectedItem} - ${binding.spPolicyGroupSubId.selectedItem} - ${config.policyGroupFilter}"
MmkvManager.encodeServerConfig(editGuid, config) MmkvManager.encodeServerConfig(editGuid, config)
toastSuccess(R.string.toast_success) toastSuccess(R.string.toast_success)
finish() finish()
@@ -15,8 +15,8 @@ import com.v2ray.ang.AppConfig.VPN
import com.v2ray.ang.R import com.v2ray.ang.R
import com.v2ray.ang.extension.toLongEx import com.v2ray.ang.extension.toLongEx
import com.v2ray.ang.handler.MmkvManager import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.helper.MmkvPreferenceDataStore
import com.v2ray.ang.handler.SubscriptionUpdater import com.v2ray.ang.handler.SubscriptionUpdater
import com.v2ray.ang.helper.MmkvPreferenceDataStore
import com.v2ray.ang.util.Utils import com.v2ray.ang.util.Utils
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
@@ -145,7 +145,7 @@ class SubEditActivity : BaseActivity() {
del_config = menu.findItem(R.id.del_config) del_config = menu.findItem(R.id.del_config)
save_config = menu.findItem(R.id.save_config) save_config = menu.findItem(R.id.save_config)
if (editSubId.isEmpty()) { if (editSubId.isEmpty() || editSubId == AppConfig.DEFAULT_SUBSCRIPTION_ID) {
del_config?.isVisible = false del_config?.isVisible = false
} }
@@ -5,7 +5,9 @@ import android.text.TextUtils
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.v2ray.ang.AppConfig
import com.v2ray.ang.contracts.BaseAdapterListener import com.v2ray.ang.contracts.BaseAdapterListener
import com.v2ray.ang.databinding.ItemRecyclerSubSettingBinding import com.v2ray.ang.databinding.ItemRecyclerSubSettingBinding
import com.v2ray.ang.helper.ItemTouchHelperAdapter import com.v2ray.ang.helper.ItemTouchHelperAdapter
@@ -37,6 +39,7 @@ class SubSettingRecyclerAdapter(
holder.itemSubSettingBinding.layoutRemove.setOnClickListener { holder.itemSubSettingBinding.layoutRemove.setOnClickListener {
adapterListener?.onRemove(subId, position) adapterListener?.onRemove(subId, position)
} }
holder.itemSubSettingBinding.layoutRemove.isVisible = subId != AppConfig.DEFAULT_SUBSCRIPTION_ID
holder.itemSubSettingBinding.chkEnable.setOnCheckedChangeListener { it, isChecked -> holder.itemSubSettingBinding.chkEnable.setOnCheckedChangeListener { it, isChecked ->
if (!it.isPressed) return@setOnCheckedChangeListener if (!it.isPressed) return@setOnCheckedChangeListener
@@ -30,7 +30,7 @@ class TaskerActivity : BaseActivity() {
lstData.add("Default") lstData.add("Default")
lstGuid.add(AppConfig.TASKER_DEFAULT_GUID) lstGuid.add(AppConfig.TASKER_DEFAULT_GUID)
MmkvManager.decodeServerList().forEach { key -> MmkvManager.decodeAllServerList().forEach { key ->
MmkvManager.decodeServerConfig(key)?.let { config -> MmkvManager.decodeServerConfig(key)?.let { config ->
lstData.add(config.remarks) lstData.add(config.remarks)
lstGuid.add(key) lstGuid.add(key)
@@ -328,13 +328,48 @@ object Utils {
*/ */
fun urlEncode(url: String): String { fun urlEncode(url: String): String {
return try { return try {
URLEncoder.encode(url, Charsets.UTF_8.toString()).replace("+", "%20") URLEncoder.encode(url, Charsets.UTF_8.toString())
} catch (e: Exception) { } catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to encode URL", e) Log.e(AppConfig.TAG, "Failed to encode URL", e)
url url
} }
} }
/**
* Decode a "encodeURIComponent" string.
*
* @param url The "encodeURIComponent" string.
* @return The decoded string, or the original string if decoding fails.
*/
fun decodeURIComponent(url: String): String {
return try {
// Decode strictly according to RFC 3986 / encodeURIComponent semantics.
// '+' is a literal plus and MUST NOT be interpreted as space.
// Inputs using '+' for spaces are non-conforming and rejected deliberately
// to avoid cross-language interoperability issues.
URLDecoder.decode(url.replace("+", "%2B"), Charsets.UTF_8.toString())
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to decode encodeURIComponent", e)
url
}
}
/**
* Encode a string to "encodeURIComponent" format.
*
* @param url The string to encode.
* @return The "encodeURIComponent" encoded string, or the original string if encoding fails.
*/
fun encodeURIComponent(url: String): String {
return try {
// Replace '+' with '%20' to conform to encodeURIComponent semantics.
URLEncoder.encode(url, Charsets.UTF_8.toString()).replace("+", "%20")
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to encode encodeURIComponent", e)
url
}
}
/** /**
* Read text from an asset file. * Read text from an asset file.
* *
@@ -35,7 +35,7 @@ import kotlinx.coroutines.withContext
import java.util.Collections import java.util.Collections
class MainViewModel(application: Application) : AndroidViewModel(application) { 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 subscriptionId: String = MmkvManager.decodeSettingsString(AppConfig.CACHE_SUBSCRIPTION_ID, "").orEmpty()
//var keywordFilter: String = MmkvManager.MmkvManager.decodeSettingsString(AppConfig.CACHE_KEYWORD_FILTER, "")?:"" //var keywordFilter: String = MmkvManager.MmkvManager.decodeSettingsString(AppConfig.CACHE_KEYWORD_FILTER, "")?:""
@@ -69,10 +69,15 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
} }
/** /**
* Reloads the server list. * Reloads the server list based on current subscription filter.
*/ */
fun reloadServerList() { fun reloadServerList() {
serverList = MmkvManager.decodeServerList() serverList = if (subscriptionId.isEmpty()) {
MmkvManager.decodeAllServerList()
} else {
MmkvManager.decodeServerList(subscriptionId)
}
updateCache() updateCache()
updateListAction.value = -1 updateListAction.value = -1
} }
@@ -129,14 +134,13 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
*/ */
fun swapServer(fromPosition: Int, toPosition: Int) { fun swapServer(fromPosition: Int, toPosition: Int) {
if (subscriptionId.isEmpty()) { if (subscriptionId.isEmpty()) {
Collections.swap(serverList, fromPosition, toPosition) return
} else {
val fromPosition2 = serverList.indexOf(serversCache[fromPosition].guid)
val toPosition2 = serverList.indexOf(serversCache[toPosition].guid)
Collections.swap(serverList, fromPosition2, toPosition2)
} }
Collections.swap(serverList, fromPosition, toPosition)
Collections.swap(serversCache, fromPosition, toPosition) Collections.swap(serversCache, fromPosition, toPosition)
MmkvManager.encodeServerList(serverList)
MmkvManager.encodeServerList(serverList, subscriptionId)
} }
/** /**
@@ -160,9 +164,9 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
// MmkvManager.encodeServerConfig(guid, config) // MmkvManager.encodeServerConfig(guid, config)
// } // }
if (subscriptionId.isNotEmpty() && subscriptionId != profile.subscriptionId) { // if (subscriptionId.isNotEmpty() && subscriptionId != profile.subscriptionId) {
continue // continue
} // }
if (keywordFilter.isEmpty() || profile.remarks.lowercase().contains(keywordFilter.lowercase())) { if (keywordFilter.isEmpty() || profile.remarks.lowercase().contains(keywordFilter.lowercase())) {
serversCache.add(ServersCache(guid, profile)) serversCache.add(ServersCache(guid, profile))
@@ -236,13 +240,11 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
MmkvManager.clearAllTestDelayResults(serversCache.map { it.guid }.toList()) MmkvManager.clearAllTestDelayResults(serversCache.map { it.guid }.toList())
updateListAction.value = -1 updateListAction.value = -1
val serversCopy = serversCache.toList()
viewModelScope.launch(Dispatchers.Default) { viewModelScope.launch(Dispatchers.Default) {
val guids = ArrayList<String>(serversCopy.map { it.guid }) if (serversCache.isEmpty()) {
if (guids.isEmpty()) {
return@launch return@launch
} }
MessageUtil.sendMsg2TestService(getApplication(), AppConfig.MSG_MEASURE_CONFIG, guids) MessageUtil.sendMsg2TestService(getApplication(), AppConfig.MSG_MEASURE_CONFIG, subscriptionId)
} }
} }
@@ -279,14 +281,23 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
} }
val groups = mutableListOf<GroupMapItem>() val groups = mutableListOf<GroupMapItem>()
groups.add( if (subscriptions.size > 1
GroupMapItem( && MmkvManager.decodeSettingsBool(AppConfig.PREF_GROUP_ALL_DISPLAY)
id = "", ) {
remarks = context.getString(R.string.filter_config_all) groups.add(
GroupMapItem(
id = "",
remarks = context.getString(R.string.filter_config_all)
)
) )
) }
subscriptions.forEach { sub -> 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 return groups
} }
@@ -368,24 +379,38 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
* Sorts servers by their test results. * Sorts servers by their test results.
*/ */
fun sortByTestResults() { 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) data class ServerDelay(var guid: String, var testDelayMillis: Long)
val serverDelays = mutableListOf<ServerDelay>() val serverDelays = mutableListOf<ServerDelay>()
val serverList = MmkvManager.decodeServerList() val serverListToSort = MmkvManager.decodeServerList(subId)
serverList.forEach { key ->
serverListToSort.forEach { key ->
val delay = MmkvManager.decodeServerAffiliationInfo(key)?.testDelayMillis ?: 0L val delay = MmkvManager.decodeServerAffiliationInfo(key)?.testDelayMillis ?: 0L
serverDelays.add(ServerDelay(key, if (delay <= 0L) 999999 else delay)) serverDelays.add(ServerDelay(key, if (delay <= 0L) 999999 else delay))
} }
serverDelays.sortBy { it.testDelayMillis } serverDelays.sortBy { it.testDelayMillis }
serverDelays.forEach { val sortedServerList = serverDelays.map { it.guid }.toMutableList()
serverList.remove(it.guid)
serverList.add(it.guid)
}
MmkvManager.encodeServerList(serverList) // Save the sorted list for this subscription
MmkvManager.encodeServerList(sortedServerList, subId)
} }
/** /**
* Initializes assets. * Initializes assets.
* @param assets The asset manager. * @param assets The asset manager.
@@ -29,6 +29,7 @@
<ImageView <ImageView
android:layout_width="@dimen/image_size_dp24" android:layout_width="@dimen/image_size_dp24"
android:layout_height="@dimen/image_size_dp24" android:layout_height="@dimen/image_size_dp24"
android:importantForAccessibility="no"
app:srcCompat="@drawable/ic_source_code_24dp" /> app:srcCompat="@drawable/ic_source_code_24dp" />
<TextView <TextView
@@ -53,6 +54,7 @@
<ImageView <ImageView
android:layout_width="@dimen/image_size_dp24" android:layout_width="@dimen/image_size_dp24"
android:layout_height="@dimen/image_size_dp24" android:layout_height="@dimen/image_size_dp24"
android:importantForAccessibility="no"
app:srcCompat="@drawable/license_24px" /> app:srcCompat="@drawable/license_24px" />
<TextView <TextView
@@ -77,6 +79,7 @@
<ImageView <ImageView
android:layout_width="@dimen/image_size_dp24" android:layout_width="@dimen/image_size_dp24"
android:layout_height="@dimen/image_size_dp24" android:layout_height="@dimen/image_size_dp24"
android:importantForAccessibility="no"
app:srcCompat="@drawable/ic_feedback_24dp" /> app:srcCompat="@drawable/ic_feedback_24dp" />
<TextView <TextView
@@ -102,6 +105,7 @@
<ImageView <ImageView
android:layout_width="@dimen/image_size_dp24" android:layout_width="@dimen/image_size_dp24"
android:layout_height="@dimen/image_size_dp24" android:layout_height="@dimen/image_size_dp24"
android:importantForAccessibility="no"
app:srcCompat="@drawable/ic_telegram_24dp" /> app:srcCompat="@drawable/ic_telegram_24dp" />
<TextView <TextView
@@ -126,6 +130,7 @@
<ImageView <ImageView
android:layout_width="@dimen/image_size_dp24" android:layout_width="@dimen/image_size_dp24"
android:layout_height="@dimen/image_size_dp24" android:layout_height="@dimen/image_size_dp24"
android:importantForAccessibility="no"
app:srcCompat="@drawable/ic_privacy_24dp" /> app:srcCompat="@drawable/ic_privacy_24dp" />
<TextView <TextView
@@ -29,6 +29,7 @@
<ImageView <ImageView
android:layout_width="@dimen/image_size_dp24" android:layout_width="@dimen/image_size_dp24"
android:layout_height="@dimen/image_size_dp24" android:layout_height="@dimen/image_size_dp24"
android:importantForAccessibility="no"
app:srcCompat="@drawable/ic_backup_24dp" /> app:srcCompat="@drawable/ic_backup_24dp" />
<LinearLayout <LinearLayout
@@ -61,6 +62,7 @@
<ImageView <ImageView
android:layout_width="@dimen/image_size_dp24" android:layout_width="@dimen/image_size_dp24"
android:layout_height="@dimen/image_size_dp24" android:layout_height="@dimen/image_size_dp24"
android:importantForAccessibility="no"
app:srcCompat="@drawable/ic_share_24dp" /> app:srcCompat="@drawable/ic_share_24dp" />
<TextView <TextView
@@ -85,6 +87,7 @@
<ImageView <ImageView
android:layout_width="@dimen/image_size_dp24" android:layout_width="@dimen/image_size_dp24"
android:layout_height="@dimen/image_size_dp24" android:layout_height="@dimen/image_size_dp24"
android:importantForAccessibility="no"
app:srcCompat="@drawable/ic_restore_24dp" /> app:srcCompat="@drawable/ic_restore_24dp" />
<TextView <TextView
@@ -117,6 +120,7 @@
<ImageView <ImageView
android:layout_width="@dimen/image_size_dp24" android:layout_width="@dimen/image_size_dp24"
android:layout_height="@dimen/image_size_dp24" android:layout_height="@dimen/image_size_dp24"
android:importantForAccessibility="no"
app:srcCompat="@drawable/ic_settings_24dp" /> app:srcCompat="@drawable/ic_settings_24dp" />
<LinearLayout <LinearLayout
@@ -68,6 +68,7 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:background="?attr/selectableItemBackgroundBorderless" android:background="?attr/selectableItemBackgroundBorderless"
android:clickable="true" android:clickable="true"
android:contentDescription="@string/switch_bypass_apps_mode"
android:focusable="true" android:focusable="true"
android:gravity="center" android:gravity="center"
android:padding="@dimen/padding_spacing_dp8"> android:padding="@dimen/padding_spacing_dp8">
@@ -75,6 +76,7 @@
<ImageView <ImageView
android:layout_width="@dimen/image_size_dp24" android:layout_width="@dimen/image_size_dp24"
android:layout_height="@dimen/image_size_dp24" android:layout_height="@dimen/image_size_dp24"
android:importantForAccessibility="no"
app:srcCompat="@drawable/ic_about_24dp" /> app:srcCompat="@drawable/ic_about_24dp" />
</LinearLayout> </LinearLayout>
@@ -22,6 +22,7 @@
<ImageView <ImageView
android:layout_width="@dimen/image_size_dp24" android:layout_width="@dimen/image_size_dp24"
android:layout_height="@dimen/image_size_dp24" android:layout_height="@dimen/image_size_dp24"
android:importantForAccessibility="no"
app:srcCompat="@drawable/ic_source_code_24dp" /> app:srcCompat="@drawable/ic_source_code_24dp" />
<androidx.appcompat.widget.SwitchCompat <androidx.appcompat.widget.SwitchCompat
@@ -49,6 +50,7 @@
<ImageView <ImageView
android:layout_width="@dimen/image_size_dp24" android:layout_width="@dimen/image_size_dp24"
android:layout_height="@dimen/image_size_dp24" android:layout_height="@dimen/image_size_dp24"
android:importantForAccessibility="no"
app:srcCompat="@drawable/ic_check_update_24dp" /> app:srcCompat="@drawable/ic_check_update_24dp" />
<TextView <TextView
@@ -65,6 +65,7 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="@dimen/view_height_dp64" android:layout_height="@dimen/view_height_dp64"
android:clickable="true" android:clickable="true"
android:contentDescription="@string/connection_test_pending"
android:focusable="true" android:focusable="true"
android:nextFocusLeft="@+id/view_pager" android:nextFocusLeft="@+id/view_pager"
android:nextFocusRight="@+id/fab" android:nextFocusRight="@+id/fab"
@@ -11,6 +11,7 @@
android:id="@+id/iv_qcode" android:id="@+id/iv_qcode"
android:layout_width="336dp" android:layout_width="336dp"
android:layout_height="336dp" android:layout_height="336dp"
android:contentDescription="@string/title_qr_code"
android:scaleType="fitXY" android:scaleType="fitXY"
app:srcCompat="@drawable/ic_fab_check" /> app:srcCompat="@drawable/ic_fab_check" />
</LinearLayout> </LinearLayout>
@@ -43,6 +43,7 @@
android:layout_height="@dimen/padding_spacing_dp16" android:layout_height="@dimen/padding_spacing_dp16"
android:layout_gravity="center" android:layout_gravity="center"
android:layout_marginStart="@dimen/padding_spacing_dp16" android:layout_marginStart="@dimen/padding_spacing_dp16"
android:importantForAccessibility="no"
app:srcCompat="@drawable/ic_lock_24dp" /> app:srcCompat="@drawable/ic_lock_24dp" />
</LinearLayout> </LinearLayout>
@@ -78,6 +79,7 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:background="?attr/selectableItemBackgroundBorderless" android:background="?attr/selectableItemBackgroundBorderless"
android:clickable="true" android:clickable="true"
android:contentDescription="@string/menu_item_edit_config"
android:focusable="true" android:focusable="true"
android:gravity="center" android:gravity="center"
android:nextFocusLeft="@+id/info_container" android:nextFocusLeft="@+id/info_container"
@@ -87,6 +89,7 @@
<ImageView <ImageView
android:layout_width="@dimen/image_size_dp24" android:layout_width="@dimen/image_size_dp24"
android:layout_height="@dimen/image_size_dp24" android:layout_height="@dimen/image_size_dp24"
android:importantForAccessibility="no"
app:srcCompat="@drawable/ic_edit_24dp" /> app:srcCompat="@drawable/ic_edit_24dp" />
</LinearLayout> </LinearLayout>
@@ -58,6 +58,7 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:background="?attr/selectableItemBackgroundBorderless" android:background="?attr/selectableItemBackgroundBorderless"
android:clickable="true" android:clickable="true"
android:contentDescription="@string/title_configuration_share"
android:focusable="true" android:focusable="true"
android:gravity="center" android:gravity="center"
android:nextFocusLeft="@+id/info_container" android:nextFocusLeft="@+id/info_container"
@@ -67,6 +68,7 @@
<ImageView <ImageView
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="@dimen/image_size_dp24" android:layout_height="@dimen/image_size_dp24"
android:importantForAccessibility="no"
app:srcCompat="@drawable/ic_share_24dp" /> app:srcCompat="@drawable/ic_share_24dp" />
</LinearLayout> </LinearLayout>
@@ -77,6 +79,7 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:background="?attr/selectableItemBackgroundBorderless" android:background="?attr/selectableItemBackgroundBorderless"
android:clickable="true" android:clickable="true"
android:contentDescription="@string/menu_item_edit_config"
android:focusable="true" android:focusable="true"
android:gravity="center" android:gravity="center"
android:orientation="vertical" android:orientation="vertical"
@@ -85,6 +88,7 @@
<ImageView <ImageView
android:layout_width="@dimen/image_size_dp24" android:layout_width="@dimen/image_size_dp24"
android:layout_height="@dimen/image_size_dp24" android:layout_height="@dimen/image_size_dp24"
android:importantForAccessibility="no"
app:srcCompat="@drawable/ic_edit_24dp" /> app:srcCompat="@drawable/ic_edit_24dp" />
</LinearLayout> </LinearLayout>
@@ -95,6 +99,7 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:background="?attr/selectableItemBackgroundBorderless" android:background="?attr/selectableItemBackgroundBorderless"
android:clickable="true" android:clickable="true"
android:contentDescription="@string/menu_item_del_config"
android:focusable="true" android:focusable="true"
android:gravity="center" android:gravity="center"
android:orientation="vertical" android:orientation="vertical"
@@ -103,6 +108,7 @@
<ImageView <ImageView
android:layout_width="@dimen/image_size_dp24" android:layout_width="@dimen/image_size_dp24"
android:layout_height="@dimen/image_size_dp24" android:layout_height="@dimen/image_size_dp24"
android:importantForAccessibility="no"
app:srcCompat="@drawable/ic_delete_24dp" /> app:srcCompat="@drawable/ic_delete_24dp" />
</LinearLayout> </LinearLayout>
@@ -58,12 +58,14 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:background="?attr/selectableItemBackgroundBorderless" android:background="?attr/selectableItemBackgroundBorderless"
android:clickable="true" android:clickable="true"
android:contentDescription="@string/menu_item_edit_config"
android:focusable="true" android:focusable="true"
android:padding="@dimen/padding_spacing_dp8"> android:padding="@dimen/padding_spacing_dp8">
<ImageView <ImageView
android:layout_width="@dimen/image_size_dp24" android:layout_width="@dimen/image_size_dp24"
android:layout_height="@dimen/image_size_dp24" android:layout_height="@dimen/image_size_dp24"
android:importantForAccessibility="no"
app:srcCompat="@drawable/ic_edit_24dp" /> app:srcCompat="@drawable/ic_edit_24dp" />
</LinearLayout> </LinearLayout>
@@ -73,12 +75,14 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:background="?attr/selectableItemBackgroundBorderless" android:background="?attr/selectableItemBackgroundBorderless"
android:clickable="true" android:clickable="true"
android:contentDescription="@string/menu_item_del_config"
android:focusable="true" android:focusable="true"
android:padding="@dimen/padding_spacing_dp8"> android:padding="@dimen/padding_spacing_dp8">
<ImageView <ImageView
android:layout_width="@dimen/image_size_dp24" android:layout_width="@dimen/image_size_dp24"
android:layout_height="@dimen/image_size_dp24" android:layout_height="@dimen/image_size_dp24"
android:importantForAccessibility="no"
app:srcCompat="@drawable/ic_delete_24dp" /> app:srcCompat="@drawable/ic_delete_24dp" />
</LinearLayout> </LinearLayout>
</LinearLayout> </LinearLayout>
@@ -80,10 +80,10 @@
<TextView <TextView
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="@string/server_lab_stream_pinsha256" /> android:text="@string/server_lab_pinned_ca256" />
<EditText <EditText
android:id="@+id/et_pinsha256" android:id="@+id/et_pinned_ca256"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:inputType="text" /> android:inputType="text" />
@@ -8,4 +8,5 @@
android:text="@string/title_mode_help" android:text="@string/title_mode_help"
android:textAlignment="textStart" android:textAlignment="textStart"
android:textStyle="italic" android:textStyle="italic"
android:textColor="?attr/colorPrimary"
tools:ignore="UsingOnClickInXml" /> tools:ignore="UsingOnClickInXml" />
@@ -18,6 +18,7 @@
android:id="@+id/image_switch" android:id="@+id/image_switch"
android:layout_width="45dp" android:layout_width="45dp"
android:layout_height="45dp" android:layout_height="45dp"
android:importantForAccessibility="no"
android:padding="@dimen/padding_spacing_dp16" android:padding="@dimen/padding_spacing_dp16"
app:srcCompat="@drawable/ic_stat_name" /> app:srcCompat="@drawable/ic_stat_name" />
</LinearLayout> </LinearLayout>
@@ -109,7 +109,6 @@
<string name="server_obfs_password">Obfs password</string> <string name="server_obfs_password">Obfs password</string>
<string name="server_lab_port_hop">Port Hopping</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_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_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_bandwidth_up">Bandwidth up (Supported units: k/m/g/t)</string>
<string name="server_lab_xhttp_mode">XHTTP Mode</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="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="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 --> <!-- AboutActivity -->
<string name="title_pref_feedback">ملاحظات</string> <string name="title_pref_feedback">ملاحظات</string>
<string name="summary_pref_feedback">ملاحظات التحسينات أو الأخطاء إلى GitHub</string> <string name="summary_pref_feedback">ملاحظات التحسينات أو الأخطاء إلى GitHub</string>
@@ -108,7 +108,6 @@
<string name="server_obfs_password">Obfs password</string> <string name="server_obfs_password">Obfs password</string>
<string name="server_lab_port_hop">Port Hopping</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_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_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_bandwidth_up">Bandwidth up (Supported units: k/m/g/t)</string>
<string name="server_lab_xhttp_mode">XHTTP Mode</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="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="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 --> <!-- AboutActivity -->
<string name="title_pref_feedback">মতামত</string> <string name="title_pref_feedback">মতামত</string>
<string name="summary_pref_feedback">মতামত উন্নয়ন বা বাগগুলি GitHub-এ পাঠান</string> <string name="summary_pref_feedback">মতামত উন্নয়ন বা বাগগুলি GitHub-এ পাঠান</string>
@@ -108,7 +108,6 @@
<string name="server_obfs_password">رزم obfs</string> <string name="server_obfs_password">رزم obfs</string>
<string name="server_lab_port_hop">پورت گوم (درگا سرورن ز نۊ هؽل اکونه)</string> <string name="server_lab_port_hop">پورت گوم (درگا سرورن ز نۊ هؽل اکونه)</string>
<string name="server_lab_port_hop_interval">فاسله پورت گوم (سانیه)</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_down">ب لم ٱووڌن پئنا باند (واهڌ)</string>
<string name="server_lab_bandwidth_up">وا روء رئڌن پئنا باند (واهڌ)</string> <string name="server_lab_bandwidth_up">وا روء رئڌن پئنا باند (واهڌ)</string>
<string name="server_lab_xhttp_mode">هالت XHTTP</string> <string name="server_lab_xhttp_mode">هالت XHTTP</string>
@@ -235,6 +234,8 @@
<string name="title_pref_double_column_display">ره وندن نشووݩ داڌن دو سۊتۊنی</string> <string name="title_pref_double_column_display">ره وندن نشووݩ داڌن دو سۊتۊنی</string>
<string name="summary_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 --> <!-- AboutActivity -->
<string name="title_pref_feedback">فشناڌن منشڌ</string> <string name="title_pref_feedback">فشناڌن منشڌ</string>
<string name="summary_pref_feedback">فشناڌن منشڌ یا گوزارش موشکلا من Github</string> <string name="summary_pref_feedback">فشناڌن منشڌ یا گوزارش موشکلا من Github</string>
@@ -108,7 +108,6 @@
<string name="server_obfs_password">رمز عبور obfs</string> <string name="server_obfs_password">رمز عبور obfs</string>
<string name="server_lab_port_hop">پورت پرش (درگاه سرور را بازنویسی می کند)</string> <string name="server_lab_port_hop">پورت پرش (درگاه سرور را بازنویسی می کند)</string>
<string name="server_lab_port_hop_interval">فاصله پورت پرش (ثانیه)</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_down">کاهش پهنای باند (واحد)</string>
<string name="server_lab_bandwidth_up">افزایش پهنای باند (واحد)</string> <string name="server_lab_bandwidth_up">افزایش پهنای باند (واحد)</string>
<string name="server_lab_xhttp_mode">حالت XHTTP</string> <string name="server_lab_xhttp_mode">حالت XHTTP</string>
@@ -233,6 +232,8 @@
<string name="title_pref_double_column_display">فعال کردن نمایش دو ستون</string> <string name="title_pref_double_column_display">فعال کردن نمایش دو ستون</string>
<string name="summary_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 --> <!-- AboutActivity -->
<string name="title_pref_feedback">بازخورد</string> <string name="title_pref_feedback">بازخورد</string>
<string name="summary_pref_feedback">بازخورد یا گزارش اشکالات در گیت‌ هاب</string> <string name="summary_pref_feedback">بازخورد یا گزارش اشکالات در گیت‌ هاب</string>
@@ -108,14 +108,13 @@
<string name="server_obfs_password">Пароль obfs</string> <string name="server_obfs_password">Пароль obfs</string>
<string name="server_lab_port_hop">Смена портов (переопределяет порт)</string> <string name="server_lab_port_hop">Смена портов (переопределяет порт)</string>
<string name="server_lab_port_hop_interval">Интервал смены портов</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_down">Входящая пропускная способность (допускаются: k/m/g/t)</string>
<string name="server_lab_bandwidth_up">Исходящая пропускная способность (допускаются: k/m/g/t)</string> <string name="server_lab_bandwidth_up">Исходящая пропускная способность (допускаются: k/m/g/t)</string>
<string name="server_lab_xhttp_mode">Режим XHTTP</string> <string name="server_lab_xhttp_mode">Режим XHTTP</string>
<string name="server_lab_xhttp_extra">Необработанный JSON XHTTP Extra, формат: { XHTTPObject }</string> <string name="server_lab_xhttp_extra">Необработанный JSON XHTTP Extra, формат: { XHTTPObject }</string>
<string name="server_lab_ech_config_list">EchConfigList</string> <string name="server_lab_ech_config_list">EchConfigList</string>
<string name="server_lab_ech_force_query">EchForceQuery</string> <string name="server_lab_ech_force_query">EchForceQuery</string>
<string name="server_lab_pinned_ca256">Certificate fingerprint (SHA-256)</string> <string name="server_lab_pinned_ca256">Отпечаток сертификата (SHA-256)</string>
<!-- UserAssetActivity --> <!-- UserAssetActivity -->
<string name="toast_asset_copy_failed">Невозможно скопировать файл, используйте файловый менеджер</string> <string name="toast_asset_copy_failed">Невозможно скопировать файл, используйте файловый менеджер</string>
@@ -234,6 +233,8 @@
<string name="title_pref_double_column_display">Профили в два столбца</string> <string name="title_pref_double_column_display">Профили в два столбца</string>
<string name="summary_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 --> <!-- AboutActivity -->
<string name="title_pref_feedback">Обратная связь</string> <string name="title_pref_feedback">Обратная связь</string>
<string name="summary_pref_feedback">Предложить улучшение или сообщить об ошибке на GitHub</string> <string name="summary_pref_feedback">Предложить улучшение или сообщить об ошибке на GitHub</string>
@@ -415,7 +416,7 @@
<string-array name="policy_group_type"> <string-array name="policy_group_type">
<item>Наименьшая задержка</item> <item>Наименьшая задержка</item>
<item>Наименьшая нагрузка</item> <item>Наименьшая нагрузка</item>
<item>Случайный</item> <item>Случайно</item>
<item>По очереди</item> <item>По очереди</item>
</string-array> </string-array>
@@ -108,7 +108,6 @@
<string name="server_obfs_password">Obfs password</string> <string name="server_obfs_password">Obfs password</string>
<string name="server_lab_port_hop">Port Hopping</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_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_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_bandwidth_up">Bandwidth up (Supported units: k/m/g/t)</string>
<string name="server_lab_xhttp_mode">XHTTP Mode</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="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="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 --> <!-- AboutActivity -->
<string name="title_pref_feedback">Phản hồi lỗi</string> <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> <string name="summary_pref_feedback">Phản hồi cải tiến hoặc lỗi lên GitHub</string>
@@ -108,7 +108,6 @@
<string name="server_obfs_password">混淆密码</string> <string name="server_obfs_password">混淆密码</string>
<string name="server_lab_port_hop">跳跃端口 (会覆盖服务器端口)</string> <string name="server_lab_port_hop">跳跃端口 (会覆盖服务器端口)</string>
<string name="server_lab_port_hop_interval">端口跳跃间隔 (秒)</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_down">带宽下行 (支持的单位 k/m/g/t)</string>
<string name="server_lab_bandwidth_up">带宽上行 (支持的单位 k/m/g/t)</string> <string name="server_lab_bandwidth_up">带宽上行 (支持的单位 k/m/g/t)</string>
<string name="server_lab_xhttp_mode">XHTTP 模式</string> <string name="server_lab_xhttp_mode">XHTTP 模式</string>
@@ -232,6 +231,9 @@
<string name="title_pref_double_column_display">启用双列显示</string> <string name="title_pref_double_column_display">启用双列显示</string>
<string name="summary_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 --> <!-- AboutActivity -->
<string name="title_pref_feedback">反馈</string> <string name="title_pref_feedback">反馈</string>
<string name="summary_pref_feedback">反馈改进或漏洞至 GitHub</string> <string name="summary_pref_feedback">反馈改进或漏洞至 GitHub</string>
@@ -108,7 +108,6 @@
<string name="server_obfs_password">混淆密碼</string> <string name="server_obfs_password">混淆密碼</string>
<string name="server_lab_port_hop">跳躍連接埠 (會覆蓋伺服器連接埠)</string> <string name="server_lab_port_hop">跳躍連接埠 (會覆蓋伺服器連接埠)</string>
<string name="server_lab_port_hop_interval">連接埠跳躍間隔 (秒)</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_down">頻寬下行 (支持的單位 k/m/g/t)</string>
<string name="server_lab_bandwidth_up">頻寬上行 (支持的單位 k/m/g/t)</string> <string name="server_lab_bandwidth_up">頻寬上行 (支持的單位 k/m/g/t)</string>
<string name="server_lab_xhttp_mode">XHTTP 模式</string> <string name="server_lab_xhttp_mode">XHTTP 模式</string>
@@ -233,6 +232,9 @@
<string name="title_pref_double_column_display">啟用雙列顯示</string> <string name="title_pref_double_column_display">啟用雙列顯示</string>
<string name="summary_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 --> <!-- AboutActivity -->
<string name="title_pref_feedback">意見回饋</string> <string name="title_pref_feedback">意見回饋</string>
<string name="summary_pref_feedback">前往 GitHub 回報錯誤</string> <string name="summary_pref_feedback">前往 GitHub 回報錯誤</string>
+4 -1
View File
@@ -109,7 +109,6 @@
<string name="server_obfs_password">Obfs password</string> <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">Port Hopping(will override the port)</string>
<string name="server_lab_port_hop_interval">Port Hopping Interval</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_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_bandwidth_up">Bandwidth up (Supported units: k/m/g/t)</string>
<string name="server_lab_xhttp_mode">XHTTP Mode</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="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="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 --> <!-- AboutActivity -->
<string name="title_pref_feedback">Feedback</string> <string name="title_pref_feedback">Feedback</string>
<string name="summary_pref_feedback">Feedback enhancements or bugs to GitHub</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="summary_pref_tg_group">Join Telegram Group</string>
<string name="toast_tg_app_not_found">Telegram app not found</string> <string name="toast_tg_app_not_found">Telegram app not found</string>
<string name="title_privacy_policy">Privacy policy</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_about">About</string>
<string name="title_source_code">Source code</string> <string name="title_source_code">Source code</string>
<string name="title_oss_license">Open Source licenses</string> <string name="title_oss_license">Open Source licenses</string>
@@ -23,6 +23,11 @@
android:summary="@string/summary_pref_double_column_display" android:summary="@string/summary_pref_double_column_display"
android:title="@string/title_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 <ListPreference
android:defaultValue="auto" android:defaultValue="auto"
android:entries="@array/language_select" android:entries="@array/language_select"
+4 -4
View File
@@ -1,15 +1,15 @@
[versions] [versions]
agp = "9.0.0" agp = "9.0.1"
desugarJdkLibs = "2.1.5" desugarJdkLibs = "2.1.5"
gradleLicensePlugin = "0.9.8" gradleLicensePlugin = "0.9.8"
kotlin = "2.3.0" kotlin = "2.3.10"
coreKtx = "1.17.0" coreKtx = "1.17.0"
junit = "4.13.2" junit = "4.13.2"
junitVersion = "1.3.0" junitVersion = "1.3.0"
espressoCore = "3.7.0" espressoCore = "3.7.0"
appcompat = "1.7.1" appcompat = "1.7.1"
material = "1.13.0" material = "1.13.0"
activity = "1.12.2" activity = "1.12.4"
constraintlayout = "2.2.1" constraintlayout = "2.2.1"
mmkvStatic = "1.3.16" mmkvStatic = "1.3.16"
gson = "2.13.2" gson = "2.13.2"
@@ -21,7 +21,7 @@ swiperefreshlayout = "1.2.0"
toasty = "1.5.2" toasty = "1.5.2"
editorkit = "2.9.0" editorkit = "2.9.0"
core = "3.5.4" core = "3.5.4"
workRuntimeKtx = "2.11.0" workRuntimeKtx = "2.11.1"
lifecycleViewmodelKtx = "2.10.0" lifecycleViewmodelKtx = "2.10.0"
multidex = "2.0.1" multidex = "2.0.1"
mockitoMockitoInline = "5.2.0" mockitoMockitoInline = "5.2.0"
+1 -1
View File
@@ -1,6 +1,6 @@
#Thu Nov 14 12:42:51 BDT 2024 #Thu Nov 14 12:42:51 BDT 2024
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists 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 zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists