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.graphics.Bitmap
|
||||||
import android.text.TextUtils
|
import android.text.TextUtils
|
||||||
import com.v2ray.ang.AppConfig
|
import com.v2ray.ang.AppConfig
|
||||||
import com.v2ray.ang.R
|
|
||||||
import com.v2ray.ang.core.CoreConfigManager
|
import com.v2ray.ang.core.CoreConfigManager
|
||||||
import com.v2ray.ang.dto.SubscriptionUpdateResult
|
import com.v2ray.ang.dto.SubscriptionUpdateResult
|
||||||
import com.v2ray.ang.dto.UrlContentRequest
|
import com.v2ray.ang.dto.UrlContentRequest
|
||||||
@@ -31,6 +30,11 @@ import java.net.URI
|
|||||||
|
|
||||||
object AngConfigManager {
|
object AngConfigManager {
|
||||||
|
|
||||||
|
private data class ParsedProfile(
|
||||||
|
val profile: ProfileItem,
|
||||||
|
val rawConfig: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
// Parser mapping for different config types (lazy initialized)
|
// Parser mapping for different config types (lazy initialized)
|
||||||
private val configFmtParsers: Map<String, (String) -> ProfileItem?> by lazy {
|
private val configFmtParsers: Map<String, (String) -> ProfileItem?> by lazy {
|
||||||
mapOf(
|
mapOf(
|
||||||
@@ -177,23 +181,28 @@ object AngConfigManager {
|
|||||||
* @return A pair containing the number of configurations and subscriptions imported.
|
* @return A pair containing the number of configurations and subscriptions imported.
|
||||||
*/
|
*/
|
||||||
fun importBatchConfig(server: String?, subid: String, append: Boolean): Pair<Int, Int> {
|
fun importBatchConfig(server: String?, subid: String, append: Boolean): Pair<Int, Int> {
|
||||||
var count = parseBatchConfig(Utils.decode(server), subid, append)
|
return try {
|
||||||
if (count <= 0) {
|
var count = parseBatchConfig(Utils.decode(server), subid, append)
|
||||||
count = parseBatchConfig(server, subid, append)
|
if (count <= 0) {
|
||||||
}
|
count = parseBatchConfig(server, subid, append)
|
||||||
if (count <= 0) {
|
}
|
||||||
count = parseCustomConfigServer(server, subid, append)
|
if (count <= 0) {
|
||||||
}
|
count = parseCustomConfigServer(server, subid, append)
|
||||||
|
}
|
||||||
|
|
||||||
var countSub = parseBatchSubscription(server)
|
var countSub = parseBatchSubscription(server)
|
||||||
if (countSub <= 0) {
|
if (countSub <= 0) {
|
||||||
countSub = parseBatchSubscription(Utils.decode(server))
|
countSub = parseBatchSubscription(Utils.decode(server))
|
||||||
}
|
}
|
||||||
if (countSub > 0) {
|
if (countSub > 0) {
|
||||||
updateConfigViaSubAll()
|
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) {
|
if (servers == null) {
|
||||||
return 0
|
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)
|
val subItem = MmkvManager.decodeSubscription(subid)
|
||||||
|
|
||||||
// Parse all configs first (no I/O during parsing)
|
// 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 (configs.isNotEmpty()) {
|
||||||
if (!append) {
|
commitProfiles(
|
||||||
MmkvManager.removeServerViaSubid(subid)
|
configs = configs.map(::ParsedProfile),
|
||||||
}
|
subid = subid,
|
||||||
val keyToProfile = batchSaveConfigs(configs, subid)
|
append = append,
|
||||||
val matchKey = findMatchedProfileKey(keyToProfile, removedSelected)
|
)
|
||||||
matchKey?.let { MmkvManager.setSelectServer(it) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return configs.size
|
return configs.size
|
||||||
|
} catch (e: ProfileStorageException) {
|
||||||
|
throw e
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
LogUtil.e(AppConfig.TAG, "Failed to parse batch config", e)
|
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.
|
* Commits parsed profiles before removing the profiles they replace.
|
||||||
* Reads serverList once, saves all configs, then writes serverList once.
|
|
||||||
*
|
*
|
||||||
* @param configs The list of ProfileItem to save.
|
* @param configs The parsed profiles to save.
|
||||||
* @param subid The subscription ID.
|
* @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> {
|
private fun commitProfiles(
|
||||||
val keyToProfile = mutableMapOf<String, ProfileItem>()
|
configs: List<ParsedProfile>,
|
||||||
|
subid: String,
|
||||||
|
append: Boolean,
|
||||||
|
) {
|
||||||
|
val keyToProfile = linkedMapOf<String, ProfileItem>()
|
||||||
|
val rawConfigs = mutableMapOf<String, String>()
|
||||||
|
|
||||||
// Read serverList once
|
configs.forEach { parsed ->
|
||||||
val serverList = MmkvManager.decodeServerList(subid)
|
|
||||||
|
|
||||||
configs.forEach { config ->
|
|
||||||
val key = Utils.getUuid()
|
val key = Utils.getUuid()
|
||||||
// Save profile directly without updating serverList
|
keyToProfile[key] = parsed.profile
|
||||||
MmkvManager.encodeProfileDirect(key, JsonUtil.toJson(config))
|
parsed.rawConfig?.let { raw -> rawConfigs[key] = raw }
|
||||||
|
|
||||||
if (!serverList.contains(key)) {
|
|
||||||
serverList.add(0, key)
|
|
||||||
}
|
|
||||||
keyToProfile[key] = config
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write serverList once
|
MmkvManager.saveServerProfiles(
|
||||||
MmkvManager.encodeServerList(serverList, subid)
|
profiles = keyToProfile,
|
||||||
return keyToProfile
|
rawConfigs = rawConfigs,
|
||||||
}
|
subscriptionId = subid,
|
||||||
|
append = append,
|
||||||
/**
|
)
|
||||||
* 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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -400,56 +326,54 @@ object AngConfigManager {
|
|||||||
JsonUtil.fromJson(server, Array<Any>::class.java) ?: arrayOf()
|
JsonUtil.fromJson(server, Array<Any>::class.java) ?: arrayOf()
|
||||||
|
|
||||||
if (serverList.isNotEmpty()) {
|
if (serverList.isNotEmpty()) {
|
||||||
val removedSelected = getRemovedSelectedProfile(subid, append)
|
val configs = serverList.reversed().map { srv ->
|
||||||
if (!append) {
|
val config = CustomFmt.parse(JsonUtil.toJson(srv))
|
||||||
MmkvManager.removeServerViaSubid(subid)
|
|
||||||
}
|
|
||||||
var count = 0
|
|
||||||
val keyToProfile = mutableMapOf<String, ProfileItem>()
|
|
||||||
for (srv in serverList.reversed()) {
|
|
||||||
val config = CustomFmt.parse(JsonUtil.toJson(srv)) ?: continue
|
|
||||||
config.subscriptionId = subid
|
config.subscriptionId = subid
|
||||||
config.description = generateDescription(config)
|
config.description = generateDescription(config)
|
||||||
val key = MmkvManager.encodeServerConfig("", config)
|
ParsedProfile(
|
||||||
MmkvManager.encodeServerRaw(key, JsonUtil.toJsonPretty(srv) ?: "")
|
profile = config,
|
||||||
keyToProfile[key] = config
|
rawConfig = JsonUtil.toJsonPretty(srv) ?: "",
|
||||||
count += 1
|
)
|
||||||
}
|
}
|
||||||
if (count > 0) {
|
commitProfiles(configs, subid, append)
|
||||||
val matchKey = findMatchedProfileKey(keyToProfile, removedSelected)
|
return configs.size
|
||||||
matchKey?.let { MmkvManager.setSelectServer(it) }
|
|
||||||
}
|
|
||||||
return count
|
|
||||||
}
|
}
|
||||||
|
} catch (e: ProfileStorageException) {
|
||||||
|
throw e
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
LogUtil.e(AppConfig.TAG, "Failed to parse custom config server JSON array", e)
|
LogUtil.e(AppConfig.TAG, "Failed to parse custom config server JSON array", e)
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// For compatibility
|
// For compatibility
|
||||||
val config = CustomFmt.parse(server) ?: return 0
|
val config = CustomFmt.parse(server)
|
||||||
config.subscriptionId = subid
|
config.subscriptionId = subid
|
||||||
config.description = generateDescription(config)
|
config.description = generateDescription(config)
|
||||||
if (!append) {
|
commitProfiles(
|
||||||
MmkvManager.removeServerViaSubid(subid)
|
configs = listOf(ParsedProfile(config, server)),
|
||||||
}
|
subid = subid,
|
||||||
val key = MmkvManager.encodeServerConfig("", config)
|
append = append,
|
||||||
MmkvManager.encodeServerRaw(key, server)
|
)
|
||||||
return 1
|
return 1
|
||||||
|
} catch (e: ProfileStorageException) {
|
||||||
|
throw e
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
LogUtil.e(AppConfig.TAG, "Failed to parse custom config server as single config", e)
|
LogUtil.e(AppConfig.TAG, "Failed to parse custom config server as single config", e)
|
||||||
}
|
}
|
||||||
return 0
|
return 0
|
||||||
} else if (server.startsWith("[Interface]") && server.contains("[Peer]")) {
|
} else if (server.startsWith("[Interface]") && server.contains("[Peer]")) {
|
||||||
try {
|
try {
|
||||||
val config = WireguardFmt.parseWireguardConfFile(server) ?: return R.string.toast_incorrect_protocol
|
val config = WireguardFmt.parseWireguardConfFile(server)
|
||||||
|
config.subscriptionId = subid
|
||||||
config.description = generateDescription(config)
|
config.description = generateDescription(config)
|
||||||
if (!append) {
|
commitProfiles(
|
||||||
MmkvManager.removeServerViaSubid(subid)
|
configs = listOf(ParsedProfile(config, server)),
|
||||||
}
|
subid = subid,
|
||||||
val key = MmkvManager.encodeServerConfig("", config)
|
append = append,
|
||||||
MmkvManager.encodeServerRaw(key, server)
|
)
|
||||||
return 1
|
return 1
|
||||||
|
} catch (e: ProfileStorageException) {
|
||||||
|
throw e
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
LogUtil.e(AppConfig.TAG, "Failed to parse WireGuard config file", e)
|
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.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.drop
|
import kotlinx.coroutines.flow.drop
|
||||||
|
|
||||||
|
internal class ProfileStorageException(message: String) : IllegalStateException(message)
|
||||||
|
|
||||||
object MmkvManager {
|
object MmkvManager {
|
||||||
|
|
||||||
//region private
|
//region private
|
||||||
@@ -74,6 +76,58 @@ object MmkvManager {
|
|||||||
private val assetStorage by lazy { MMKV.mmkvWithID(ID_ASSET, MMKV.MULTI_PROCESS_MODE) }
|
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 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
|
//endregion
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -127,7 +181,9 @@ object MmkvManager {
|
|||||||
* @param guid The server GUID.
|
* @param guid The server GUID.
|
||||||
*/
|
*/
|
||||||
fun setSelectServer(guid: String) {
|
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.
|
* @param subscriptionId The subscription ID.
|
||||||
*/
|
*/
|
||||||
fun encodeServerList(serverList: MutableList<String>, subscriptionId: String) {
|
fun encodeServerList(serverList: MutableList<String>, subscriptionId: String) {
|
||||||
val subId = getSubscriptionId(subscriptionId)
|
withProfileIndexLock {
|
||||||
val key = "$KEY_SUB_SERVER_PREFIX$subId"
|
persistServerList(serverList, subscriptionId)
|
||||||
mainStorage.encode(key, JsonUtil.toJson(serverList))
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -153,9 +209,7 @@ object MmkvManager {
|
|||||||
* @return The list of server GUIDs.
|
* @return The list of server GUIDs.
|
||||||
*/
|
*/
|
||||||
fun decodeServerList(subscriptionId: String): MutableList<String> {
|
fun decodeServerList(subscriptionId: String): MutableList<String> {
|
||||||
val subId = getSubscriptionId(subscriptionId)
|
val json = mainStorage.decodeString(serverListKey(subscriptionId))
|
||||||
val key = "$KEY_SUB_SERVER_PREFIX$subId"
|
|
||||||
val json = mainStorage.decodeString(key)
|
|
||||||
return if (json.isNullOrBlank()) {
|
return if (json.isNullOrBlank()) {
|
||||||
mutableListOf()
|
mutableListOf()
|
||||||
} else {
|
} else {
|
||||||
@@ -214,17 +268,28 @@ object MmkvManager {
|
|||||||
*/
|
*/
|
||||||
fun encodeServerConfig(guid: String, config: ProfileItem): String {
|
fun encodeServerConfig(guid: String, config: ProfileItem): String {
|
||||||
val key = guid.ifBlank { Utils.getUuid() }
|
val key = guid.ifBlank { Utils.getUuid() }
|
||||||
profileFullStorage.encode(key, JsonUtil.toJson(config))
|
withProfileIndexLock {
|
||||||
|
requireStorageWrite(
|
||||||
|
profileFullStorage.encode(key, JsonUtil.toJson(config)),
|
||||||
|
"Failed to save profile payload",
|
||||||
|
)
|
||||||
|
|
||||||
// Use default subscription for servers without subscription
|
// Use default subscription for servers without subscription
|
||||||
val subId = getSubscriptionId(config.subscriptionId)
|
val subId = getSubscriptionId(config.subscriptionId)
|
||||||
val serverList = decodeServerList(subId)
|
val serverList = decodeServerList(subId)
|
||||||
|
|
||||||
if (!serverList.contains(key)) {
|
if (!serverList.contains(key)) {
|
||||||
serverList.add(0, key)
|
serverList.add(0, key)
|
||||||
encodeServerList(serverList, subId)
|
requireStorageWrite(
|
||||||
if (getSelectServer().isNullOrBlank()) {
|
persistServerList(serverList, subId),
|
||||||
mainStorage.encode(KEY_SELECTED_SERVER, key)
|
"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 profiles Generated GUIDs and parsed profiles, in insertion order.
|
||||||
* @param configJson The server configuration JSON string.
|
* @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) {
|
internal fun saveServerProfiles(
|
||||||
profileFullStorage.encode(key, configJson)
|
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