mirror of
https://github.com/2dust/v2rayNG.git
synced 2026-10-29 06:31:54 +00:00
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:
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user