From f4f992eb9b1cba8c3ee634e29de80f8406f1a889 Mon Sep 17 00:00:00 2001 From: eliotcougar Date: Sat, 5 Sep 2026 09:54:46 +0300 Subject: [PATCH] Deduplicate subscription IDs when reading the saved index (#6172) A saved SUB_IDS array can contain the same GUID more than once. Loading it verbatim creates repeated subscription rows with identical Compose and reorderable-list keys, which can crash the subscription screen. Deduplicate IDs in the shared reader while preserving the first-seen order and mutable-list contract. This protects every index consumer without changing subscription records, generating replacement IDs, or adding writes during reads. Different subscriptions with identical or blank names remain separate. Add six regression tests for duplicate rows, stable ordering, read-only decoding, mutable round trips, repeated names, and existing empty/invalid input behavior. Three assertions fail on unchanged upstream; all six pass with the fix. The full 63-test suite and Play Store debug build pass. On the API 37.1 Pixel 9 Pro emulator, a deliberately duplicated saved index loaded each subscription once, retained first-occurrence order, reopened safely, and edited the correct record. The raw index remained duplicated throughout the check; no app crash was observed. --- .../java/com/v2ray/ang/handler/MmkvManager.kt | 3 +- .../ang/handler/SubscriptionIndexTest.kt | 114 ++++++++++++++++++ 2 files changed, 116 insertions(+), 1 deletion(-) create mode 100644 V2rayNG/app/src/test/java/com/v2ray/ang/handler/SubscriptionIndexTest.kt 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") + } + } + } +}