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.
This commit is contained in:
2dust committed 2026-02-23 20:58:01 +08:00
1 parent d642d09844
commit 84ab54fea2
11 files changed
+296 -58

No files matched your search

@@ -34,14 +34,12 @@ class AngApplication : MultiDexApplication() {
MMKV.initialize(this)
// Ensure critical preference defaults are present in MMKV early
SettingsManager.ensureDefaultSettings()
SettingsManager.setNightMode()
// Initialize WorkManager with the custom configuration
WorkManager.initialize(this, workManagerConfiguration)
SettingsManager.initRoutingRulesets(this)
SettingsManager.migrateHysteria2PinSHA256()
// Ensure critical preference defaults are present in MMKV early
SettingsManager.initApp(this)
SettingsManager.setNightMode()
es.dmoral.toasty.Toasty.Config.getInstance()
.setGravity(android.view.Gravity.BOTTOM, 0, 300)
@@ -16,6 +16,9 @@ object AppConfig {
/** Legacy configuration keys. */
const val ANG_CONFIG = "ang_config"
// Default subscription ID for ungrouped servers
const val DEFAULT_SUBSCRIPTION_ID = "__default_subscription__"
/** Preferences mapped to MMKV storage. */
const val PREF_SNIFFING_ENABLED = "pref_sniffing_enabled"
const val PREF_ROUTE_ONLY_ENABLED = "pref_route_only_enabled"
@@ -1,6 +1,7 @@
package com.v2ray.ang.handler
import com.tencent.mmkv.MMKV
import com.v2ray.ang.AppConfig.DEFAULT_SUBSCRIPTION_ID
import com.v2ray.ang.AppConfig.PREF_IS_BOOTED
import com.v2ray.ang.AppConfig.PREF_ROUTING_RULESET
import com.v2ray.ang.dto.AssetUrlCache
@@ -28,6 +29,7 @@ object MmkvManager {
private const val ID_SETTING = "SETTING"
private const val KEY_SELECTED_SERVER = "SELECTED_SERVER"
private const val KEY_ANG_CONFIGS = "ANG_CONFIGS"
private const val KEY_SUB_SERVER_PREFIX = "SUB_SERVERS_"
private const val KEY_SUB_IDS = "SUB_IDS"
private const val KEY_WEBDAV_CONFIG = "WEBDAV_CONFIG"
@@ -44,6 +46,24 @@ object MmkvManager {
//region Server
/**
* Reads the legacy server list from KEY_ANG_CONFIGS for migration.
* This method is for migration purposes only.
*
* @return The JSON string of legacy server list, or null if not exists.
*/
fun readLegacyServerList(): String? {
return mainStorage.decodeString(KEY_ANG_CONFIGS)
}
// /**
// * 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.
*
@@ -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 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>) {
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.
*/
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> {
val json = mainStorage.decodeString(KEY_ANG_CONFIGS)
return if (json.isNullOrBlank()) {
@@ -84,6 +150,7 @@ object MmkvManager {
JsonUtil.fromJson(json, Array<String>::class.java)?.toMutableList() ?: mutableListOf()
}
}
*/
/**
* Decodes the server configuration.
@@ -123,6 +190,21 @@ object MmkvManager {
fun encodeServerConfig(guid: String, config: ProfileItem): String {
val key = guid.ifBlank { Utils.getUuid() }
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()
if (!serverList.contains(key)) {
serverList.add(0, key)
@@ -131,6 +213,7 @@ object MmkvManager {
mainStorage.encode(KEY_SELECTED_SERVER, key)
}
}
*/
// val profile = ProfileLiteItem(
// configType = config.configType,
// subscriptionId = config.subscriptionId,
@@ -151,12 +234,30 @@ object MmkvManager {
if (guid.isBlank()) {
return
}
// Get config to determine which subscription to update
val config = decodeServerConfig(guid)
val subId = getSubscriptionId(config?.subscriptionId)
// Remove from appropriate server list
val serverList = decodeServerList(subId)
serverList.remove(guid)
encodeServerList(serverList, subId)
// Legacy code - keep for reference during migration
/*
if (getSelectServer() == guid) {
mainStorage.remove(KEY_SELECTED_SERVER)
}
val serverList = decodeServerList()
serverList.remove(guid)
encodeServerList(serverList)
*/
// Clean up storage
if (getSelectServer() == guid) {
mainStorage.remove(KEY_SELECTED_SERVER)
}
profileFullStorage.remove(guid)
//profileStorage.remove(guid)
serverAffStorage.remove(guid)
@@ -165,12 +266,26 @@ object MmkvManager {
/**
* Removes the server configurations via subscription ID.
*
* @param subid The subscription ID.
* @param subscriptionId The subscription ID.
*/
fun removeServerViaSubid(subid: String) {
if (subid.isBlank()) {
return
fun removeServerViaSubid(subscriptionId: String?) {
val subId = getSubscriptionId(subscriptionId)
val serverList = decodeServerList(subId)
// Remove all servers in the list
serverList.forEach { guid ->
if (getSelectServer() == guid) {
mainStorage.remove(KEY_SELECTED_SERVER)
}
profileFullStorage.remove(guid)
serverAffStorage.remove(guid)
}
serverList.clear()
encodeServerList(serverList, subId)
// Legacy code - keep for reference during migration
/*
profileFullStorage.allKeys()?.forEach { key ->
decodeServerConfig(key)?.let { config ->
if (config.subscriptionId == subid) {
@@ -178,6 +293,7 @@ object MmkvManager {
}
}
}
*/
}
/**
@@ -292,6 +408,10 @@ object MmkvManager {
//region Subscriptions
private fun getSubscriptionId(subscriptionId: String?):String {
return subscriptionId?.ifEmpty { DEFAULT_SUBSCRIPTION_ID } ?: DEFAULT_SUBSCRIPTION_ID
}
/**
* Initializes the subscription list.
*/
@@ -331,6 +451,11 @@ object MmkvManager {
* @param subid The subscription ID.
*/
fun removeSubscription(subid: String) {
// Protect default subscription from being deleted
if (subid == DEFAULT_SUBSCRIPTION_ID) {
return
}
subStorage.remove(subid)
val subsList = decodeSubsList()
subsList.remove(subid)
@@ -7,20 +7,24 @@ import android.util.Log
import androidx.appcompat.app.AppCompatDelegate
import com.v2ray.ang.AppConfig
import com.v2ray.ang.AppConfig.ANG_PACKAGE
import com.v2ray.ang.AppConfig.DEFAULT_SUBSCRIPTION_ID
import com.v2ray.ang.AppConfig.GEOIP_PRIVATE
import com.v2ray.ang.AppConfig.GEOSITE_PRIVATE
import com.v2ray.ang.AppConfig.TAG_DIRECT
import com.v2ray.ang.AppConfig.VPN
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.RulesetItem
import com.v2ray.ang.dto.SubscriptionItem
import com.v2ray.ang.dto.V2rayConfig
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.enums.Language
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.enums.RoutingType
import com.v2ray.ang.dto.RulesetItem
import com.v2ray.ang.dto.ServersCache
import com.v2ray.ang.dto.V2rayConfig
import com.v2ray.ang.enums.VpnInterfaceAddressConfig
import com.v2ray.ang.handler.MmkvManager.decodeAllServerList
import com.v2ray.ang.handler.MmkvManager.decodeServerConfig
import com.v2ray.ang.handler.MmkvManager.decodeServerList
import com.v2ray.ang.handler.MmkvManager.decodeSubsList
import com.v2ray.ang.handler.MmkvManager.decodeSubscription
import com.v2ray.ang.handler.MmkvManager.encodeSubscription
import com.v2ray.ang.util.JsonUtil
import com.v2ray.ang.util.Utils
import java.io.File
@@ -30,11 +34,19 @@ import java.util.Locale
object SettingsManager {
fun initApp(context: Context) {
ensureDefaultSettings()
ensureDefaultSubscription()
initRoutingRulesets(context)
migrateServerListToSubscriptions()
migrateHysteria2PinSHA256()
}
/**
* Initialize routing rulesets.
* @param context The application context.
*/
fun initRoutingRulesets(context: Context) {
private fun initRoutingRulesets(context: Context) {
val exist = MmkvManager.decodeRoutingRulesets()
if (exist.isNullOrEmpty()) {
val rulesetList = getPresetRoutingRulesets(context)
@@ -222,14 +234,10 @@ object SettingsManager {
if (remarks.isNullOrEmpty()) {
return null
}
val serverList = decodeServerList()
for (guid in serverList) {
val profile = decodeServerConfig(guid)
if (profile != null && profile.remarks == remarks) {
return profile
}
}
return null
val serverList = decodeAllServerList()
return serverList
.mapNotNull { guid -> decodeServerConfig(guid) }
.firstOrNull { it.remarks == remarks }
}
/**
@@ -400,7 +408,7 @@ object SettingsManager {
/**
* Ensure default settings are present in MMKV.
*/
fun ensureDefaultSettings() {
private fun ensureDefaultSettings() {
// Write defaults in the exact order requested by the user
ensureDefaultValue(AppConfig.PREF_MODE, AppConfig.VPN)
ensureDefaultValue(AppConfig.PREF_VPN_DNS, AppConfig.DNS_VPN)
@@ -424,14 +432,14 @@ object SettingsManager {
}
}
fun migrateHysteria2PinSHA256() {
private fun migrateHysteria2PinSHA256() {
// Check if migration has already been done
val migrationKey = "hysteria2_pin_sha256_migrated"
if (MmkvManager.decodeSettingsBool(migrationKey, false)) {
return
}
val serverList = decodeServerList()
val serverList = decodeAllServerList()
for (guid in serverList) {
val profile = decodeServerConfig(guid) ?: continue
@@ -449,4 +457,76 @@ object SettingsManager {
MmkvManager.encodeSettings(migrationKey, true)
}
/**
* Migrates server list from legacy KEY_ANG_CONFIGS to subscription-based storage.
* This method should be called once during app initialization after the storage structure change.
* Servers are grouped by their subscriptionId into respective subscription's serverList.
* Servers without subscription are moved to the default subscription.
* After migration, KEY_ANG_CONFIGS is removed.
*/
private fun migrateServerListToSubscriptions() {
// Check if migration has already been done
val migrationKey = "server_list_to_subscriptions_migrated"
if (MmkvManager.decodeSettingsBool(migrationKey, false)) {
return
}
// Ensure default subscription exists before migration
ensureDefaultSubscription()
// Read existing server list from legacy KEY_ANG_CONFIGS
val oldJson = MmkvManager.readLegacyServerList()
if (oldJson.isNullOrBlank()) {
// No data to migrate, mark as done
MmkvManager.encodeSettings(migrationKey, true)
return
}
val guids = JsonUtil.fromJson(oldJson, Array<String>::class.java) ?: run {
MmkvManager.encodeSettings(migrationKey, true)
return
}
val subscriptionServerMap = mutableMapOf<String, MutableList<String>>()
// Group servers by subscription (use default subscription for empty subscriptionId)
guids.forEach { guid ->
val config = decodeServerConfig(guid) ?: return@forEach
val subId = config.subscriptionId.ifEmpty { DEFAULT_SUBSCRIPTION_ID }
subscriptionServerMap.getOrPut(subId) { mutableListOf() }.add(guid)
}
// Update each subscription's serverList (including default subscription)
subscriptionServerMap.forEach { (subId, serverGuids) ->
MmkvManager.encodeServerList(serverGuids, subId)
}
// 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)
}
}
}
}
@@ -6,8 +6,6 @@ import android.util.Log
import com.google.gson.JsonArray
import com.v2ray.ang.AppConfig
import com.v2ray.ang.dto.ConfigResult
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.enums.NetworkType
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.RulesetItem
import com.v2ray.ang.dto.V2rayConfig
@@ -15,6 +13,8 @@ import com.v2ray.ang.dto.V2rayConfig.OutboundBean
import com.v2ray.ang.dto.V2rayConfig.OutboundBean.OutSettingsBean
import com.v2ray.ang.dto.V2rayConfig.OutboundBean.StreamSettingsBean
import com.v2ray.ang.dto.V2rayConfig.RoutingBean.RulesBean
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.enums.NetworkType
import com.v2ray.ang.extension.isNotNullEmpty
import com.v2ray.ang.extension.nullIfBlank
import com.v2ray.ang.fmt.HttpFmt
@@ -139,7 +139,7 @@ object V2rayConfigManager {
private fun getV2rayGroupConfig(context: Context, guid: String, config: ProfileItem): ConfigResult {
val result = ConfigResult(false)
val serverList = MmkvManager.decodeServerList()
val serverList = MmkvManager.decodeAllServerList()
val configList = serverList
.mapNotNull { id -> MmkvManager.decodeServerConfig(id) }
.filter { profile ->
@@ -18,6 +18,7 @@ import com.v2ray.ang.extension.toastError
import com.v2ray.ang.extension.toastSuccess
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.SettingsChangeManager
import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.handler.WebDavManager
import com.v2ray.ang.util.ZipUtil
import kotlinx.coroutines.Dispatchers
@@ -124,6 +125,8 @@ class BackupActivity : HelperBaseActivity() {
val count = MMKV.restoreAllFromDirectory(backupDir)
SettingsChangeManager.makeSetupGroupTab()
SettingsChangeManager.makeRestartService()
SettingsManager.initApp(this)
return count > 0
}
@@ -164,6 +164,7 @@ class GroupServerFragment : BaseFragment<FragmentGroupServerBinding>() {
val intent = Intent().putExtra("guid", guid)
.putExtra("isRunning", mainViewModel.isRunning.value)
.putExtra("createConfigType", profile.configType.value)
.putExtra("subscriptionId", subId)
when (profile.configType) {
EConfigType.CUSTOM -> {
ownerActivity.startActivity(intent.setClass(ownerActivity, ServerCustomConfigActivity::class.java))
@@ -145,7 +145,7 @@ class SubEditActivity : BaseActivity() {
del_config = menu.findItem(R.id.del_config)
save_config = menu.findItem(R.id.save_config)
if (editSubId.isEmpty()) {
if (editSubId.isEmpty() || editSubId == AppConfig.DEFAULT_SUBSCRIPTION_ID) {
del_config?.isVisible = false
}
@@ -5,7 +5,9 @@ import android.text.TextUtils
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView
import com.v2ray.ang.AppConfig
import com.v2ray.ang.contracts.BaseAdapterListener
import com.v2ray.ang.databinding.ItemRecyclerSubSettingBinding
import com.v2ray.ang.helper.ItemTouchHelperAdapter
@@ -37,6 +39,7 @@ class SubSettingRecyclerAdapter(
holder.itemSubSettingBinding.layoutRemove.setOnClickListener {
adapterListener?.onRemove(subId, position)
}
holder.itemSubSettingBinding.layoutRemove.isVisible = subId != AppConfig.DEFAULT_SUBSCRIPTION_ID
holder.itemSubSettingBinding.chkEnable.setOnCheckedChangeListener { it, isChecked ->
if (!it.isPressed) return@setOnCheckedChangeListener
@@ -30,7 +30,7 @@ class TaskerActivity : BaseActivity() {
lstData.add("Default")
lstGuid.add(AppConfig.TASKER_DEFAULT_GUID)
MmkvManager.decodeServerList().forEach { key ->
MmkvManager.decodeAllServerList().forEach { key ->
MmkvManager.decodeServerConfig(key)?.let { config ->
lstData.add(config.remarks)
lstGuid.add(key)
@@ -35,7 +35,7 @@ import kotlinx.coroutines.withContext
import java.util.Collections
class MainViewModel(application: Application) : AndroidViewModel(application) {
private var serverList = MmkvManager.decodeServerList()
private var serverList = mutableListOf<String>() // MmkvManager.decodeServerList()
var subscriptionId: String = MmkvManager.decodeSettingsString(AppConfig.CACHE_SUBSCRIPTION_ID, "").orEmpty()
//var keywordFilter: String = MmkvManager.MmkvManager.decodeSettingsString(AppConfig.CACHE_KEYWORD_FILTER, "")?:""
@@ -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() {
serverList = MmkvManager.decodeServerList()
serverList = if (subscriptionId.isEmpty()) {
MmkvManager.decodeAllServerList()
} else {
MmkvManager.decodeServerList(subscriptionId)
}
updateCache()
updateListAction.value = -1
}
@@ -129,14 +134,13 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
*/
fun swapServer(fromPosition: Int, toPosition: Int) {
if (subscriptionId.isEmpty()) {
Collections.swap(serverList, fromPosition, toPosition)
} else {
val fromPosition2 = serverList.indexOf(serversCache[fromPosition].guid)
val toPosition2 = serverList.indexOf(serversCache[toPosition].guid)
Collections.swap(serverList, fromPosition2, toPosition2)
return
}
Collections.swap(serverList, fromPosition, toPosition)
Collections.swap(serversCache, fromPosition, toPosition)
MmkvManager.encodeServerList(serverList)
MmkvManager.encodeServerList(serverList, subscriptionId)
}
/**
@@ -160,9 +164,9 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
// MmkvManager.encodeServerConfig(guid, config)
// }
if (subscriptionId.isNotEmpty() && subscriptionId != profile.subscriptionId) {
continue
}
// if (subscriptionId.isNotEmpty() && subscriptionId != profile.subscriptionId) {
// continue
// }
if (keywordFilter.isEmpty() || profile.remarks.lowercase().contains(keywordFilter.lowercase())) {
serversCache.add(ServersCache(guid, profile))
@@ -279,14 +283,21 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
}
val groups = mutableListOf<GroupMapItem>()
groups.add(
GroupMapItem(
id = "",
remarks = context.getString(R.string.filter_config_all)
if (subscriptions.count() > 1) {
groups.add(
GroupMapItem(
id = "",
remarks = context.getString(R.string.filter_config_all)
)
)
)
}
subscriptions.forEach { sub ->
groups.add(GroupMapItem(id = sub.guid, remarks = sub.subscription.remarks))
groups.add(
GroupMapItem(
id = sub.guid,
remarks = sub.subscription.remarks
)
)
}
return groups
}
@@ -368,24 +379,38 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
* Sorts servers by their test results.
*/
fun sortByTestResults() {
if (subscriptionId.isEmpty()) {
MmkvManager.decodeSubsList().forEach { guid ->
sortByTestResultsForSub(guid)
}
} else {
sortByTestResultsForSub(subscriptionId)
}
}
/**
* Sorts servers by their test results for a specific subscription.
* @param subId The subscription ID to sort servers for.
*/
private fun sortByTestResultsForSub(subId: String) {
data class ServerDelay(var guid: String, var testDelayMillis: Long)
val serverDelays = mutableListOf<ServerDelay>()
val serverList = MmkvManager.decodeServerList()
serverList.forEach { key ->
val serverListToSort = MmkvManager.decodeServerList(subId)
serverListToSort.forEach { key ->
val delay = MmkvManager.decodeServerAffiliationInfo(key)?.testDelayMillis ?: 0L
serverDelays.add(ServerDelay(key, if (delay <= 0L) 999999 else delay))
}
serverDelays.sortBy { it.testDelayMillis }
serverDelays.forEach {
serverList.remove(it.guid)
serverList.add(it.guid)
}
val sortedServerList = serverDelays.map { it.guid }.toMutableList()
MmkvManager.encodeServerList(serverList)
// Save the sorted list for this subscription
MmkvManager.encodeServerList(sortedServerList, subId)
}
/**
* Initializes assets.
* @param assets The asset manager.