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.
This commit is contained in:
eliotcougar authored and GitHub committed 2026-09-05 14:54:46 +08:00
1 parent 1af8f4677c
commit f4f992eb9b
2 files changed
+116 -1

No files matched your search

@@ -718,7 +718,8 @@ object MmkvManager {
return if (json.isNullOrBlank()) { return if (json.isNullOrBlank()) {
mutableListOf() mutableListOf()
} else { } else {
JsonUtil.fromJsonSafe(json, Array<String>::class.java)?.toMutableList() ?: mutableListOf() // Keep the first occurrence so a damaged index cannot produce duplicate Compose keys.
JsonUtil.fromJsonSafe(json, Array<String>::class.java)?.distinct()?.toMutableList() ?: mutableListOf()
} }
} }
@@ -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<String, String>()
private val subValues = mutableMapOf<String, String>()
@Before
fun prepareStorage() {
for ((storage, values) in listOf(main to mainValues, subs to subValues)) {
reset(storage)
whenever(storage.decodeString(any())).thenAnswer { values[it.getArgument<String>(0)] }
whenever(storage.encode(any<String>(), any<String>())).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<String>(), any<String>())
}
@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<String>(), MmkvManager.decodeSubsList())
listOf("", " ", "[]", "null").forEach { stored ->
mainValues["SUB_IDS"] = stored
assertEquals(emptyList<String>(), MmkvManager.decodeSubsList())
}
}
@Test
fun malformedIndexKeepsTheExistingEmptyFallback() {
mockStatic(Log::class.java).use {
mainValues["SUB_IDS"] = "{"
assertEquals(emptyList<String>(), 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> { MMKV.mmkvWithID("MAIN", MMKV.MULTI_PROCESS_MODE) }.thenReturn(main)
it.`when`<MMKV> { MMKV.mmkvWithID("SUB", MMKV.MULTI_PROCESS_MODE) }.thenReturn(subs)
it.`when`<MMKV> { MMKV.mmkvWithID("SETTING", MMKV.MULTI_PROCESS_MODE) }.thenReturn(settings)
MmkvManager.decodeSubscriptions()
MmkvManager.decodeSettingsString("test-initialize")
}
}
}
}