Harden profile replacement ordering (#6093)
This commit is contained in:
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
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user