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 32e187eda..851207f39 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 @@ -718,7 +718,8 @@ object MmkvManager { return if (json.isNullOrBlank()) { mutableListOf() } else { - JsonUtil.fromJsonSafe(json, Array::class.java)?.toMutableList() ?: mutableListOf() + // Keep the first occurrence so a damaged index cannot produce duplicate Compose keys. + JsonUtil.fromJsonSafe(json, Array::class.java)?.distinct()?.toMutableList() ?: mutableListOf() } } diff --git a/V2rayNG/app/src/test/java/com/v2ray/ang/handler/SubscriptionIndexTest.kt b/V2rayNG/app/src/test/java/com/v2ray/ang/handler/SubscriptionIndexTest.kt new file mode 100644 index 000000000..79532d4be --- /dev/null +++ b/V2rayNG/app/src/test/java/com/v2ray/ang/handler/SubscriptionIndexTest.kt @@ -0,0 +1,114 @@ +package com.v2ray.ang.handler + +import android.util.Log +import com.tencent.mmkv.MMKV +import com.v2ray.ang.dto.entities.SubscriptionItem +import com.v2ray.ang.util.JsonUtil +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.BeforeClass +import org.junit.Test +import org.mockito.Mockito.mockStatic +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.reset +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +class SubscriptionIndexTest { + private val mainValues = mutableMapOf() + private val subValues = mutableMapOf() + + @Before + fun prepareStorage() { + for ((storage, values) in listOf(main to mainValues, subs to subValues)) { + reset(storage) + whenever(storage.decodeString(any())).thenAnswer { values[it.getArgument(0)] } + whenever(storage.encode(any(), any())).thenAnswer { + values[it.getArgument(0)] = it.getArgument(1) + true + } + whenever(storage.allKeys()).thenAnswer { values.keys.toTypedArray() } + } + } + + @Test + fun duplicateIdsKeepTheirFirstPositionWithoutWritingStorage() { + val stored = """["second","first","second","third","first"]""" + mainValues["SUB_IDS"] = stored + + assertEquals(listOf("second", "first", "third"), MmkvManager.decodeSubsList()) + assertEquals(stored, mainValues["SUB_IDS"]) + verify(main, never()).encode(any(), any()) + } + + @Test + fun duplicateIndexEntriesProduceOnlyOneSubscriptionRow() { + mainValues["SUB_IDS"] = """["b","a","b","a"]""" + subValues["a"] = JsonUtil.toJson(SubscriptionItem(remarks = "Alpha")) + subValues["b"] = JsonUtil.toJson(SubscriptionItem(remarks = "Beta")) + + val rows = MmkvManager.decodeSubscriptions() + + assertEquals(listOf("b", "a"), rows.map { it.guid }) + assertEquals(listOf("Beta", "Alpha"), rows.map { it.subscription.remarks }) + } + + @Test + fun repeatedAndBlankNamesDoNotMergeDifferentSubscriptions() { + mainValues["SUB_IDS"] = """["a","b","c","d"]""" + listOf("a" to "Same", "b" to "Same", "c" to "", "d" to " ").forEach { (id, name) -> + subValues[id] = JsonUtil.toJson(SubscriptionItem(remarks = name)) + } + + assertEquals(listOf("a", "b", "c", "d"), MmkvManager.decodeSubscriptions().map { it.guid }) + } + + @Test + fun decodedIndexRemainsMutableAndCanBeSavedInANewOrder() { + mainValues["SUB_IDS"] = """["a","b","a"]""" + val ids = MmkvManager.decodeSubsList() + ids.remove("a") + ids.add(0, "c") + MmkvManager.encodeSubsList(ids) + + assertEquals("""["c","b"]""", mainValues["SUB_IDS"]) + assertEquals(listOf("c", "b"), MmkvManager.decodeSubsList()) + } + + @Test + fun missingBlankAndEmptyIndexesRemainEmpty() { + assertEquals(emptyList(), MmkvManager.decodeSubsList()) + listOf("", " ", "[]", "null").forEach { stored -> + mainValues["SUB_IDS"] = stored + assertEquals(emptyList(), MmkvManager.decodeSubsList()) + } + } + + @Test + fun malformedIndexKeepsTheExistingEmptyFallback() { + mockStatic(Log::class.java).use { + mainValues["SUB_IDS"] = "{" + assertEquals(emptyList(), MmkvManager.decodeSubsList()) + } + } + + companion object { + private val main: MMKV = mock() + private val subs: MMKV = mock() + private val settings: MMKV = mock() + + @BeforeClass + @JvmStatic + fun initializeHandles() { + mockStatic(MMKV::class.java).use { + it.`when` { MMKV.mmkvWithID("MAIN", MMKV.MULTI_PROCESS_MODE) }.thenReturn(main) + it.`when` { MMKV.mmkvWithID("SUB", MMKV.MULTI_PROCESS_MODE) }.thenReturn(subs) + it.`when` { MMKV.mmkvWithID("SETTING", MMKV.MULTI_PROCESS_MODE) }.thenReturn(settings) + MmkvManager.decodeSubscriptions() + MmkvManager.decodeSettingsString("test-initialize") + } + } + } +}