From 6a6a44a380ce294d70ed240446c1833aeecd0623 Mon Sep 17 00:00:00 2001 From: eliotcougar Date: Thu, 10 Sep 2026 07:16:17 +0300 Subject: [PATCH] Keep proxy-chain deletion targets tied to stable row keys (#6200) Store the pending member key instead of its list position and resolve that key when deletion is confirmed. Remove the member and its corresponding row key together; if the target no longer exists, leave the draft unchanged. Extract the deletion fix from the accessibility assembly without importing accessibility actions, dialog wording changes, or drag refactors. Reuse the same key-based removal for blank members. Validation: 75 JVM tests passed, including five deletion regressions; Play Store debug Kotlin compilation and x86_64 assembly passed. Emulator smoke check covered confirmation across rotation and immediate blank-member removal. Keyboard/D-pad and runtime reorder-during-confirmation scenarios were not run. --- .../v2ray/ang/ui/server/ProxyChainMembers.kt | 14 +++++ .../ang/ui/server/ServerProxyChainActivity.kt | 23 +++---- .../ang/ui/server/ProxyChainMembersTest.kt | 60 +++++++++++++++++++ 3 files changed, 87 insertions(+), 10 deletions(-) create mode 100644 V2rayNG/app/src/main/java/com/v2ray/ang/ui/server/ProxyChainMembers.kt create mode 100644 V2rayNG/app/src/test/java/com/v2ray/ang/ui/server/ProxyChainMembersTest.kt diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/server/ProxyChainMembers.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/server/ProxyChainMembers.kt new file mode 100644 index 00000000..e01f59f9 --- /dev/null +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/server/ProxyChainMembers.kt @@ -0,0 +1,14 @@ +package com.v2ray.ang.ui.server + +/** Removes one draft member and its row key together, resolving its current position at confirmation. */ +internal fun withoutProxyChainMember( + members: List, + memberKeys: List, + memberKey: String, +): Pair, List> { + val index = memberKeys.indexOf(memberKey) + if (index < 0) return members to memberKeys + + return members.toMutableList().also { it.removeAt(index) } to + memberKeys.toMutableList().also { it.removeAt(index) } +} diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/server/ServerProxyChainActivity.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/server/ServerProxyChainActivity.kt index fbb99ac7..a0e1acc6 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/server/ServerProxyChainActivity.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/server/ServerProxyChainActivity.kt @@ -203,7 +203,7 @@ fun ProxyChainScreen( var members by rememberSaveable { mutableStateOf(initialMembers) } var memberKeys by rememberSaveable { mutableStateOf(List(initialMembers.size) { UUID.randomUUID().toString() }) } var showProfileDeleteConfirm by remember { mutableStateOf(false) } - var memberToDeleteIndex by rememberSaveable { mutableStateOf(null) } + var memberToDeleteKey by rememberSaveable { mutableStateOf(null) } val showDelete = editGuid.isNotEmpty() && !isRunning val lazyListState = rememberLazyListState() @@ -285,7 +285,8 @@ fun ProxyChainScreen( } itemsIndexed(items = members, key = { index, _ -> memberKeys[index] }) { index, member -> - ReorderableItem(reorderableState, key = memberKeys[index]) { isDragging -> + val memberKey = memberKeys[index] + ReorderableItem(reorderableState, key = memberKey) { isDragging -> val elevation by animateDpAsState(if (isDragging) 4.dp else 0.dp) Surface(shadowElevation = elevation) { Row( @@ -314,10 +315,11 @@ fun ProxyChainScreen( ) IconButton(onClick = { if (member.isBlank()) { - members = members.toMutableList().also { it.removeAt(index) } - memberKeys = memberKeys.toMutableList().also { it.removeAt(index) } + val (remainingMembers, remainingKeys) = withoutProxyChainMember(members, memberKeys, memberKey) + members = remainingMembers + memberKeys = remainingKeys } else { - memberToDeleteIndex = index + memberToDeleteKey = memberKey } }) { Icon( @@ -339,15 +341,16 @@ fun ProxyChainScreen( onDismiss = { showProfileDeleteConfirm = false } ) } - memberToDeleteIndex?.let { index -> + memberToDeleteKey?.let { memberKey -> DeleteConfirmDialog( message = stringResource(R.string.confirm_delete_proxy_chain_member), onConfirm = { - members = members.toMutableList().also { it.removeAt(index) } - memberKeys = memberKeys.toMutableList().also { it.removeAt(index) } - memberToDeleteIndex = null + val (remainingMembers, remainingKeys) = withoutProxyChainMember(members, memberKeys, memberKey) + members = remainingMembers + memberKeys = remainingKeys + memberToDeleteKey = null }, - onDismiss = { memberToDeleteIndex = null } + onDismiss = { memberToDeleteKey = null } ) } } diff --git a/V2rayNG/app/src/test/java/com/v2ray/ang/ui/server/ProxyChainMembersTest.kt b/V2rayNG/app/src/test/java/com/v2ray/ang/ui/server/ProxyChainMembersTest.kt new file mode 100644 index 00000000..96b7c3a2 --- /dev/null +++ b/V2rayNG/app/src/test/java/com/v2ray/ang/ui/server/ProxyChainMembersTest.kt @@ -0,0 +1,60 @@ +package com.v2ray.ang.ui.server + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Test + +class ProxyChainMembersTest { + @Test + fun removalFollowsThePendingKeyAfterReordering() { + val pendingKey = "two" + val members = listOf("Two", "One", "Three") + val keys = listOf("two", "one", "three") + + val (remainingMembers, remainingKeys) = withoutProxyChainMember(members, keys, pendingKey) + + assertEquals(listOf("One", "Three"), remainingMembers) + assertEquals(listOf("one", "three"), remainingKeys) + assertEquals(listOf("Two", "One", "Three"), members) + assertEquals(listOf("two", "one", "three"), keys) + } + + @Test + fun duplicateNamesDoNotChangeWhichMemberIsRemoved() { + val (members, keys) = withoutProxyChainMember( + listOf("Same", "Same", "Other"), + listOf("first", "second", "third"), + "second", + ) + + assertEquals(listOf("Same", "Other"), members) + assertEquals(listOf("first", "third"), keys) + } + + @Test + fun missingMemberLeavesBothListsUnchanged() { + val members = listOf("One", "Three") + val keys = listOf("one", "three") + + val (remainingMembers, remainingKeys) = withoutProxyChainMember(members, keys, "two") + + assertSame(members, remainingMembers) + assertSame(keys, remainingKeys) + } + + @Test + fun emptyChainRemainsEmpty() { + assertEquals( + emptyList() to emptyList(), + withoutProxyChainMember(emptyList(), emptyList(), "missing"), + ) + } + + @Test + fun blankMemberIsRemovedWithItsOwnKey() { + assertEquals( + listOf("One") to listOf("one"), + withoutProxyChainMember(listOf("One", ""), listOf("one", "blank"), "blank"), + ) + } +}