From 7a07565f72427b2c8e445934e1499eedecd1e221 Mon Sep 17 00:00:00 2001 From: eliotcougar Date: Mon, 17 Aug 2026 05:20:43 +0300 Subject: [PATCH] Harden profile replacement ordering (#6093) --- .../com/v2ray/ang/handler/AngConfigManager.kt | 232 ++++++------------ .../java/com/v2ray/ang/handler/MmkvManager.kt | 182 ++++++++++++-- .../v2ray/ang/handler/ProfileReplacement.kt | 76 ++++++ .../ang/handler/ProfileReplacementTest.kt | 140 +++++++++++ 4 files changed, 455 insertions(+), 175 deletions(-) create mode 100644 V2rayNG/app/src/main/java/com/v2ray/ang/handler/ProfileReplacement.kt create mode 100644 V2rayNG/app/src/test/java/com/v2ray/ang/handler/ProfileReplacementTest.kt diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/handler/AngConfigManager.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/handler/AngConfigManager.kt index c7a87719..241985b1 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/handler/AngConfigManager.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/handler/AngConfigManager.kt @@ -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 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 { - 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, subid: String): Map { - val keyToProfile = mutableMapOf() + private fun commitProfiles( + configs: List, + subid: String, + append: Boolean, + ) { + val keyToProfile = linkedMapOf() + val rawConfigs = mutableMapOf() - // 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, 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::class.java) ?: arrayOf() if (serverList.isNotEmpty()) { - val removedSelected = getRemovedSelectedProfile(subid, append) - if (!append) { - MmkvManager.removeServerViaSubid(subid) - } - var count = 0 - val keyToProfile = mutableMapOf() - 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) } diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/handler/MmkvManager.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/handler/MmkvManager.kt index e0c9be02..32e187ed 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/handler/MmkvManager.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/handler/MmkvManager.kt @@ -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 withProfileIndexLock(block: () -> T): T { + return synchronized(mainStorage) { + mainStorage.lock() + try { + block() + } finally { + mainStorage.unlock() + } + } + } + + private fun removeProfilePayloads(guids: Collection) { + 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, 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? { + val targetKey = serverListKey(subscriptionId) + val keys = mainStorage.allKeys() ?: return null + if (targetKey !in keys) return null + + val referencedServers = mutableSetOf() + 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::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, 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 { - 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, + rawConfigs: Map, + 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) + } } /** diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/handler/ProfileReplacement.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/handler/ProfileReplacement.kt new file mode 100644 index 00000000..4558bc78 --- /dev/null +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/handler/ProfileReplacement.kt @@ -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, + 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, + replacementServers: Set, + protectedServer: String?, + serversReferencedByOtherGroups: Set?, + ): Set { + 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) + } +} diff --git a/V2rayNG/app/src/test/java/com/v2ray/ang/handler/ProfileReplacementTest.kt b/V2rayNG/app/src/test/java/com/v2ray/ang/handler/ProfileReplacementTest.kt new file mode 100644 index 00000000..6866ee55 --- /dev/null +++ b/V2rayNG/app/src/test/java/com/v2ray/ang/handler/ProfileReplacementTest.kt @@ -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(), 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 + } +}