Harden profile replacement ordering (#6093)

This commit is contained in:
eliotcougar authored and GitHub committed 2026-08-17 10:20:43 +08:00
1 parent 0456f835bc
commit 7a07565f72
4 files changed
+455 -175

No files matched your search

@@ -4,7 +4,6 @@ import android.content.Context
import android.graphics.Bitmap
import android.text.TextUtils
import com.v2ray.ang.AppConfig
import com.v2ray.ang.R
import com.v2ray.ang.core.CoreConfigManager
import com.v2ray.ang.dto.SubscriptionUpdateResult
import com.v2ray.ang.dto.UrlContentRequest
@@ -31,6 +30,11 @@ import java.net.URI
object AngConfigManager {
private data class ParsedProfile(
val profile: ProfileItem,
val rawConfig: String? = null,
)
// Parser mapping for different config types (lazy initialized)
private val configFmtParsers: Map<String, (String) -> ProfileItem?> by lazy {
mapOf(
@@ -177,23 +181,28 @@ object AngConfigManager {
* @return A pair containing the number of configurations and subscriptions imported.
*/
fun importBatchConfig(server: String?, subid: String, append: Boolean): Pair<Int, Int> {
var count = parseBatchConfig(Utils.decode(server), subid, append)
if (count <= 0) {
count = parseBatchConfig(server, subid, append)
}
if (count <= 0) {
count = parseCustomConfigServer(server, subid, append)
}
return try {
var count = parseBatchConfig(Utils.decode(server), subid, append)
if (count <= 0) {
count = parseBatchConfig(server, subid, append)
}
if (count <= 0) {
count = parseCustomConfigServer(server, subid, append)
}
var countSub = parseBatchSubscription(server)
if (countSub <= 0) {
countSub = parseBatchSubscription(Utils.decode(server))
}
if (countSub > 0) {
updateConfigViaSubAll()
}
var countSub = parseBatchSubscription(server)
if (countSub <= 0) {
countSub = parseBatchSubscription(Utils.decode(server))
}
if (countSub > 0) {
updateConfigViaSubAll()
}
return count to countSub
count to countSub
} catch (e: ProfileStorageException) {
LogUtil.e(AppConfig.TAG, "Failed to store imported profiles", e)
0 to 0
}
}
/**
@@ -236,9 +245,6 @@ object AngConfigManager {
if (servers == null) {
return 0
}
// Find the currently selected server that belongs to the same subscription before replacement.
val removedSelected = getRemovedSelectedProfile(subid, append)
val subItem = MmkvManager.decodeSubscription(subid)
// Parse all configs first (no I/O during parsing)
@@ -253,17 +259,17 @@ object AngConfigManager {
}
}
// Batch save all parsed configs (only one serverList read/write)
if (configs.isNotEmpty()) {
if (!append) {
MmkvManager.removeServerViaSubid(subid)
}
val keyToProfile = batchSaveConfigs(configs, subid)
val matchKey = findMatchedProfileKey(keyToProfile, removedSelected)
matchKey?.let { MmkvManager.setSelectServer(it) }
commitProfiles(
configs = configs.map(::ParsedProfile),
subid = subid,
append = append,
)
}
return configs.size
} catch (e: ProfileStorageException) {
throw e
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to parse batch config", e)
}
@@ -271,112 +277,32 @@ object AngConfigManager {
}
/**
* Batch save configurations to reduce serverList read/write operations.
* Reads serverList once, saves all configs, then writes serverList once.
* Commits parsed profiles before removing the profiles they replace.
*
* @param configs The list of ProfileItem to save.
* @param configs The parsed profiles to save.
* @param subid The subscription ID.
* @return Map of generated keys to their corresponding ProfileItem.
* @param append Whether to append to the existing server list.
*/
private fun batchSaveConfigs(configs: List<ProfileItem>, subid: String): Map<String, ProfileItem> {
val keyToProfile = mutableMapOf<String, ProfileItem>()
private fun commitProfiles(
configs: List<ParsedProfile>,
subid: String,
append: Boolean,
) {
val keyToProfile = linkedMapOf<String, ProfileItem>()
val rawConfigs = mutableMapOf<String, String>()
// Read serverList once
val serverList = MmkvManager.decodeServerList(subid)
configs.forEach { config ->
configs.forEach { parsed ->
val key = Utils.getUuid()
// Save profile directly without updating serverList
MmkvManager.encodeProfileDirect(key, JsonUtil.toJson(config))
if (!serverList.contains(key)) {
serverList.add(0, key)
}
keyToProfile[key] = config
keyToProfile[key] = parsed.profile
parsed.rawConfig?.let { raw -> rawConfigs[key] = raw }
}
// Write serverList once
MmkvManager.encodeServerList(serverList, subid)
return keyToProfile
}
/**
* Finds a matched profile key from the given key-profile map using multi-level matching.
* Matching priority (from highest to lowest):
* 1. Exact match: server + port + password
* 2. Match by remarks (exact match)
* 3. Match by server + port
* 4. Match by server only
*
* @param keyToProfile Map of server keys to their ProfileItem
* @param target Target profile to match
* @return Matched key or null
*/
private fun findMatchedProfileKey(keyToProfile: Map<String, ProfileItem>, target: ProfileItem?): String? {
if (keyToProfile.isEmpty()) return null
if (target == null) return null
// Level 0: Full match (remarks + server + port + password)
if (target.remarks.isNotBlank()) {
keyToProfile.entries.firstOrNull { (_, saved) ->
isSameText(saved.remarks, target.remarks) &&
isSameText(saved.server, target.server) &&
isSameText(saved.serverPort, target.serverPort) &&
isSameText(saved.password, target.password)
}?.key?.let { return it }
}
// Level 1: Match by remarks
if (target.remarks.isNotBlank()) {
keyToProfile.entries.firstOrNull { (_, saved) ->
isSameText(saved.remarks, target.remarks)
}?.key?.let { return it }
}
// Level 2: Exact match (server + port + password)
keyToProfile.entries.firstOrNull { (_, saved) ->
isSameText(saved.server, target.server) &&
isSameText(saved.serverPort, target.serverPort) &&
isSameText(saved.password, target.password)
}?.key?.let { return it }
// Level 3: Match by server + port
keyToProfile.entries.firstOrNull { (_, saved) ->
isSameText(saved.server, target.server) &&
isSameText(saved.serverPort, target.serverPort)
}?.key?.let { return it }
// Level 4: Match by server only
keyToProfile.entries.firstOrNull { (_, saved) ->
isSameText(saved.server, target.server)
}?.key?.let { return it }
// If old selected node cannot be matched, fall back to the first imported config.
return keyToProfile.keys.firstOrNull()
}
/**
* Returns the currently selected profile if it belongs to the target subscription and will be replaced.
*/
private fun getRemovedSelectedProfile(subid: String, append: Boolean): ProfileItem? {
if (subid.isBlank() || append) return null
return MmkvManager.getSelectServer()
.takeIf { it?.isNotBlank() == true }
?.let { MmkvManager.decodeServerConfig(it) }
?.takeIf { it.subscriptionId == subid }
}
/**
* Case-insensitive trimmed string comparison.
*
* @param left First string
* @param right Second string
* @return True if both are non-empty and equal (case-insensitive, trimmed)
*/
private fun isSameText(left: String?, right: String?): Boolean {
if (left.isNullOrBlank() || right.isNullOrBlank()) return false
return left.trim().equals(right.trim(), ignoreCase = true)
MmkvManager.saveServerProfiles(
profiles = keyToProfile,
rawConfigs = rawConfigs,
subscriptionId = subid,
append = append,
)
}
/**
@@ -400,56 +326,54 @@ object AngConfigManager {
JsonUtil.fromJson(server, Array<Any>::class.java) ?: arrayOf()
if (serverList.isNotEmpty()) {
val removedSelected = getRemovedSelectedProfile(subid, append)
if (!append) {
MmkvManager.removeServerViaSubid(subid)
}
var count = 0
val keyToProfile = mutableMapOf<String, ProfileItem>()
for (srv in serverList.reversed()) {
val config = CustomFmt.parse(JsonUtil.toJson(srv)) ?: continue
val configs = serverList.reversed().map { srv ->
val config = CustomFmt.parse(JsonUtil.toJson(srv))
config.subscriptionId = subid
config.description = generateDescription(config)
val key = MmkvManager.encodeServerConfig("", config)
MmkvManager.encodeServerRaw(key, JsonUtil.toJsonPretty(srv) ?: "")
keyToProfile[key] = config
count += 1
ParsedProfile(
profile = config,
rawConfig = JsonUtil.toJsonPretty(srv) ?: "",
)
}
if (count > 0) {
val matchKey = findMatchedProfileKey(keyToProfile, removedSelected)
matchKey?.let { MmkvManager.setSelectServer(it) }
}
return count
commitProfiles(configs, subid, append)
return configs.size
}
} catch (e: ProfileStorageException) {
throw e
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to parse custom config server JSON array", e)
}
try {
// For compatibility
val config = CustomFmt.parse(server) ?: return 0
val config = CustomFmt.parse(server)
config.subscriptionId = subid
config.description = generateDescription(config)
if (!append) {
MmkvManager.removeServerViaSubid(subid)
}
val key = MmkvManager.encodeServerConfig("", config)
MmkvManager.encodeServerRaw(key, server)
commitProfiles(
configs = listOf(ParsedProfile(config, server)),
subid = subid,
append = append,
)
return 1
} catch (e: ProfileStorageException) {
throw e
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to parse custom config server as single config", e)
}
return 0
} else if (server.startsWith("[Interface]") && server.contains("[Peer]")) {
try {
val config = WireguardFmt.parseWireguardConfFile(server) ?: return R.string.toast_incorrect_protocol
val config = WireguardFmt.parseWireguardConfFile(server)
config.subscriptionId = subid
config.description = generateDescription(config)
if (!append) {
MmkvManager.removeServerViaSubid(subid)
}
val key = MmkvManager.encodeServerConfig("", config)
MmkvManager.encodeServerRaw(key, server)
commitProfiles(
configs = listOf(ParsedProfile(config, server)),
subid = subid,
append = append,
)
return 1
} catch (e: ProfileStorageException) {
throw e
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to parse WireGuard config file", e)
}
@@ -31,6 +31,8 @@ import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.drop
internal class ProfileStorageException(message: String) : IllegalStateException(message)
object MmkvManager {
//region private
@@ -74,6 +76,58 @@ object MmkvManager {
private val assetStorage by lazy { MMKV.mmkvWithID(ID_ASSET, MMKV.MULTI_PROCESS_MODE) }
private val settingsStorage by lazy { MMKV.mmkvWithID(ID_SETTING, MMKV.MULTI_PROCESS_MODE) }
private inline fun <T> withProfileIndexLock(block: () -> T): T {
return synchronized(mainStorage) {
mainStorage.lock()
try {
block()
} finally {
mainStorage.unlock()
}
}
}
private fun removeProfilePayloads(guids: Collection<String>) {
if (guids.isEmpty()) return
val keys = guids.toTypedArray()
profileFullStorage.removeValuesForKeys(keys)
serverAffStorage.removeValuesForKeys(keys)
serverRawStorage.removeValuesForKeys(keys)
}
private fun requireStorageWrite(success: Boolean, message: String) {
if (!success) throw ProfileStorageException(message)
}
private fun persistServerList(serverList: List<String>, subscriptionId: String): Boolean {
return mainStorage.encode(serverListKey(subscriptionId), JsonUtil.toJson(serverList))
}
private fun serverListKey(subscriptionId: String): String {
return "$KEY_SUB_SERVER_PREFIX${getSubscriptionId(subscriptionId)}"
}
/**
* Returns every server referenced outside the target group, or null if the raw indexes
* cannot provide a complete view.
*/
private fun decodeServersReferencedByOtherGroups(subscriptionId: String): Set<String>? {
val targetKey = serverListKey(subscriptionId)
val keys = mainStorage.allKeys() ?: return null
if (targetKey !in keys) return null
val referencedServers = mutableSetOf<String>()
for (key in keys) {
if (!key.startsWith(KEY_SUB_SERVER_PREFIX) || key == targetKey) continue
val json = mainStorage.decodeString(key)
if (json.isNullOrBlank()) return null
val serverIds = JsonUtil.fromJsonSafe(json, Array<String>::class.java) ?: return null
referencedServers.addAll(serverIds)
}
return referencedServers
}
//endregion
/**
@@ -127,7 +181,9 @@ object MmkvManager {
* @param guid The server GUID.
*/
fun setSelectServer(guid: String) {
mainStorage.encode(KEY_SELECTED_SERVER, guid)
withProfileIndexLock {
mainStorage.encode(KEY_SELECTED_SERVER, guid)
}
}
/**
@@ -138,9 +194,9 @@ object MmkvManager {
* @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))
withProfileIndexLock {
persistServerList(serverList, subscriptionId)
}
}
@@ -153,9 +209,7 @@ object MmkvManager {
* @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)
val json = mainStorage.decodeString(serverListKey(subscriptionId))
return if (json.isNullOrBlank()) {
mutableListOf()
} else {
@@ -214,17 +268,28 @@ object MmkvManager {
*/
fun encodeServerConfig(guid: String, config: ProfileItem): String {
val key = guid.ifBlank { Utils.getUuid() }
profileFullStorage.encode(key, JsonUtil.toJson(config))
withProfileIndexLock {
requireStorageWrite(
profileFullStorage.encode(key, JsonUtil.toJson(config)),
"Failed to save profile payload",
)
// Use default subscription for servers without subscription
val subId = getSubscriptionId(config.subscriptionId)
val serverList = decodeServerList(subId)
// 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)
if (!serverList.contains(key)) {
serverList.add(0, key)
requireStorageWrite(
persistServerList(serverList, subId),
"Failed to publish profile index",
)
if (getSelectServer().isNullOrBlank()) {
requireStorageWrite(
mainStorage.encode(KEY_SELECTED_SERVER, key),
"Failed to update selected profile",
)
}
}
}
@@ -232,13 +297,88 @@ object MmkvManager {
}
/**
* Encodes the server configuration directly without updating serverList.
* Saves a profile batch before publishing its group index and removing replaced payloads.
*
* @param key The server GUID.
* @param configJson The server configuration JSON string.
* @param profiles Generated GUIDs and parsed profiles, in insertion order.
* @param rawConfigs Optional raw configuration payloads keyed by profile GUID.
* @param subscriptionId The destination subscription ID.
* @param append Whether to append to the existing group index.
*/
fun encodeProfileDirect(key: String, configJson: String) {
profileFullStorage.encode(key, configJson)
internal fun saveServerProfiles(
profiles: Map<String, ProfileItem>,
rawConfigs: Map<String, String>,
subscriptionId: String,
append: Boolean,
) {
if (profiles.isEmpty()) return
withProfileIndexLock {
val replacedServers = if (append) {
emptyList()
} else {
decodeServerList(subscriptionId).toList()
}
val previousSelection = getSelectServer()
val selectedProfile = if (!append &&
previousSelection != null &&
previousSelection in replacedServers
) {
decodeServerConfig(previousSelection)
} else {
null
}
val replacementSelection = ProfileReplacement.findSelectedReplacement(
profiles = profiles,
currentSelection = previousSelection,
selectedProfile = selectedProfile,
)
profiles.forEach { (guid, profile) ->
requireStorageWrite(
profileFullStorage.encode(guid, JsonUtil.toJson(profile)),
"Failed to save profile payload",
)
rawConfigs[guid]?.let { raw ->
requireStorageWrite(
serverRawStorage.encode(guid, raw),
"Failed to save raw profile payload",
)
}
}
val serverList = if (append) {
decodeServerList(subscriptionId)
} else {
mutableListOf()
}
val indexedServers = serverList.toHashSet()
profiles.keys.forEach { guid ->
if (indexedServers.add(guid)) {
serverList.add(0, guid)
}
}
requireStorageWrite(
persistServerList(serverList, subscriptionId),
"Failed to publish profile index",
)
replacementSelection?.let { guid ->
requireStorageWrite(
mainStorage.encode(KEY_SELECTED_SERVER, guid),
"Failed to update selected profile",
)
}
if (replacedServers.isEmpty()) return@withProfileIndexLock
val protectedServer = replacementSelection ?: previousSelection
val referencedByOtherGroups = decodeServersReferencedByOtherGroups(subscriptionId)
val removablePayloads = ProfileReplacement.findRemovablePayloads(
replacedServers = replacedServers,
replacementServers = profiles.keys,
protectedServer = protectedServer,
serversReferencedByOtherGroups = referencedByOtherGroups,
)
removeProfilePayloads(removablePayloads)
}
}
/**
@@ -0,0 +1,76 @@
package com.v2ray.ang.handler
import com.v2ray.ang.dto.entities.ProfileItem
internal object ProfileReplacement {
/**
* Finds the profile that should become selected after publishing a replacement batch.
* The first profile becomes selected when the store has no current selection.
*/
fun findSelectedReplacement(
profiles: Map<String, ProfileItem>,
currentSelection: String?,
selectedProfile: ProfileItem?,
): String? {
if (profiles.isEmpty()) return null
if (currentSelection.isNullOrBlank()) return profiles.keys.first()
if (selectedProfile == null) return null
if (selectedProfile.remarks.isNotBlank()) {
profiles.entries.firstOrNull { (_, candidate) ->
isSameText(candidate.remarks, selectedProfile.remarks) &&
isSameText(candidate.server, selectedProfile.server) &&
isSameText(candidate.serverPort, selectedProfile.serverPort) &&
isSameText(candidate.password, selectedProfile.password)
}?.key?.let { return it }
profiles.entries.firstOrNull { (_, candidate) ->
isSameText(candidate.remarks, selectedProfile.remarks)
}?.key?.let { return it }
}
profiles.entries.firstOrNull { (_, candidate) ->
isSameText(candidate.server, selectedProfile.server) &&
isSameText(candidate.serverPort, selectedProfile.serverPort) &&
isSameText(candidate.password, selectedProfile.password)
}?.key?.let { return it }
profiles.entries.firstOrNull { (_, candidate) ->
isSameText(candidate.server, selectedProfile.server) &&
isSameText(candidate.serverPort, selectedProfile.serverPort)
}?.key?.let { return it }
profiles.entries.firstOrNull { (_, candidate) ->
isSameText(candidate.server, selectedProfile.server)
}?.key?.let { return it }
return profiles.keys.firstOrNull()
}
/**
* Finds replaced payloads that are safe to remove.
*
* A null cross-group reference set means that at least one raw group index could not
* be read. In that case deletion fails closed.
*/
fun findRemovablePayloads(
replacedServers: Collection<String>,
replacementServers: Set<String>,
protectedServer: String?,
serversReferencedByOtherGroups: Set<String>?,
): Set<String> {
if (serversReferencedByOtherGroups == null) return emptySet()
return replacedServers.filterTo(linkedSetOf()) { guid ->
guid != protectedServer &&
guid !in replacementServers &&
guid !in serversReferencedByOtherGroups
}
}
private fun isSameText(left: String?, right: String?): Boolean {
if (left.isNullOrBlank() || right.isNullOrBlank()) return false
return left.trim().equals(right.trim(), ignoreCase = true)
}
}
@@ -0,0 +1,140 @@
package com.v2ray.ang.handler
import com.v2ray.ang.dto.entities.ProfileItem
import com.v2ray.ang.enums.EConfigType
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class ProfileReplacementTest {
@Test
fun `prefers a full match over a remarks-only match`() {
val profiles = linkedMapOf(
"remarks" to profile(remarks = "selected", server = "other"),
"full" to profile(remarks = "selected", server = "host", port = "443", password = "secret"),
)
val result = ProfileReplacement.findSelectedReplacement(
profiles = profiles,
currentSelection = "old",
selectedProfile = profile(
remarks = " selected ",
server = "HOST",
port = "443",
password = "secret",
),
)
assertEquals("full", result)
}
@Test
fun `uses a remarks match when no full match exists`() {
val profiles = linkedMapOf(
"other" to profile(remarks = "other", server = "host"),
"remarks" to profile(remarks = "selected", server = "other"),
)
val result = ProfileReplacement.findSelectedReplacement(
profiles = profiles,
currentSelection = "old",
selectedProfile = profile(remarks = "selected", server = "host"),
)
assertEquals("remarks", result)
}
@Test
fun `matches endpoint and password when remarks are unavailable`() {
val profiles = linkedMapOf(
"endpoint" to profile(server = "host", port = "443", password = "secret"),
"other" to profile(server = "other", port = "443", password = "secret"),
)
val result = ProfileReplacement.findSelectedReplacement(
profiles = profiles,
currentSelection = "old",
selectedProfile = profile(server = "HOST", port = "443", password = "secret"),
)
assertEquals("endpoint", result)
}
@Test
fun `falls back to the first replacement profile`() {
val profiles = linkedMapOf(
"first" to profile(server = "first"),
"second" to profile(server = "second"),
)
val result = ProfileReplacement.findSelectedReplacement(
profiles = profiles,
currentSelection = "old",
selectedProfile = profile(server = "unmatched"),
)
assertEquals("first", result)
}
@Test
fun `selects the first profile when there is no current selection`() {
val result = ProfileReplacement.findSelectedReplacement(
profiles = linkedMapOf(
"first" to profile(server = "first"),
"second" to profile(server = "second"),
),
currentSelection = null,
selectedProfile = null,
)
assertEquals("first", result)
}
@Test
fun `keeps an existing selection when it is outside the replaced group`() {
val result = ProfileReplacement.findSelectedReplacement(
profiles = mapOf("candidate" to profile(server = "host")),
currentSelection = "other-group",
selectedProfile = null,
)
assertNull(result)
}
@Test
fun `removes only unreferenced superseded payloads`() {
val result = ProfileReplacement.findRemovablePayloads(
replacedServers = listOf("orphan", "selected", "replacement", "cross-group"),
replacementServers = setOf("replacement"),
protectedServer = "selected",
serversReferencedByOtherGroups = setOf("cross-group"),
)
assertEquals(setOf("orphan"), result)
}
@Test
fun `keeps all payloads when another group index is unreadable`() {
val result = ProfileReplacement.findRemovablePayloads(
replacedServers = listOf("candidate"),
replacementServers = emptySet(),
protectedServer = null,
serversReferencedByOtherGroups = null,
)
assertEquals(emptySet<String>(), result)
}
private fun profile(
remarks: String = "",
server: String = "",
port: String = "",
password: String = "",
) = ProfileItem.create(EConfigType.VMESS).apply {
this.remarks = remarks
this.server = server
this.serverPort = port
this.password = password
}
}