fix(ui): prevent LazyColumn duplicate-key crash in contact lists (COLUMBA-99)#977
fix(ui): prevent LazyColumn duplicate-key crash in contact lists (COLUMBA-99)#977sentry[bot] wants to merge 1 commit into
Conversation
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
Greptile SummaryThis PR fixes an
Confidence Score: 3/5The crash fix is effective for the common case, but two of three files omit the lowercase normalization that the third applies, leaving those screens able to re-crash if hashes arrive with mixed casing. Two files (SavedPeersScreen and MessageDeliveryRetrievalCard) skip the
Important Files Changed
Flowchart%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[Raw contact/peer list from ViewModel] --> B{distinctBy destinationHash}
B -->|First occurrence kept| C[Deduplicated list]
B -->|Later duplicates| D[Silently dropped from UI]
C --> E[LazyColumn items with key=destinationHash]
E --> F[No duplicate-key crash]
D --> G[Contact hidden from user\nActions on visible item\nmay miss other identity records]
style D fill:#ffcccc,stroke:#cc0000
style G fill:#ffcccc,stroke:#cc0000
style F fill:#ccffcc,stroke:#009900
Prompt To Fix All With AIFix the following 3 code review issues. Work through them one at a time, proposing concise fixes.
---
### Issue 1 of 3
app/src/main/java/network/columba/app/ui/screens/ContactsScreen.kt:522-523
**`distinctBy` silently drops legitimate contact records**
When a user has the same destination registered under multiple local identities, `distinctBy { it.destinationHash }` keeps only the first occurrence and quietly discards the rest. This means UI-triggered actions (delete, pin, open chat) will operate on whichever `ContactEntity` happens to be first in iteration order, silently ignoring the other identity's record. The root cause is that the composite PK `[destinationHash, identityHash]` means these are genuinely distinct rows. The crash-safe fix is to expose `identityHash` through the UI model and build a composite key — `"pinned_${contact.destinationHash.lowercase()}_${contact.identityHash.lowercase()}"` — rather than filtering out rows that the user has explicitly created.
### Issue 2 of 3
app/src/main/java/network/columba/app/ui/screens/SavedPeersScreen.kt:88
The `distinctBy` calls in `SavedPeersScreen` and `MessageDeliveryRetrievalCard` omit the `.lowercase()` normalization that `ContactsScreen` applies. If a `destinationHash` ever arrives with inconsistent casing (e.g., `"ABC123"` vs `"abc123"`), `distinctBy` would treat them as distinct — meaning no deduplication and a possible surviving duplicate key that can still crash the `LazyColumn`. Align with the `ContactsScreen` approach by normalizing to lowercase before `distinctBy`.
```suggestion
val uniqueSavedPeers = savedPeers.distinctBy { it.destinationHash.lowercase() }
```
### Issue 3 of 3
app/src/main/java/network/columba/app/ui/screens/settings/cards/MessageDeliveryRetrievalCard.kt:958
Same missing lowercase normalization as in `SavedPeersScreen`. `distinctBy { it.destinationHash }` without case-folding leaves a crack open: if the same relay is announced with mixed-case hashes the deduplication silently fails and the crash can recur. Apply `.lowercase()` to match the pattern already established in `ContactsScreen`.
```suggestion
val uniqueRelays = availableRelays.distinctBy { it.destinationHash.lowercase() }
```
Reviews (1): Last reviewed commit: "fix(ui): prevent LazyColumn duplicate-ke..." | Re-trigger Greptile |
| val pinnedContacts = | ||
| contactsState.groupedContacts.pinned.distinctBy { it.destinationHash.lowercase() } |
There was a problem hiding this comment.
distinctBy silently drops legitimate contact records
When a user has the same destination registered under multiple local identities, distinctBy { it.destinationHash } keeps only the first occurrence and quietly discards the rest. This means UI-triggered actions (delete, pin, open chat) will operate on whichever ContactEntity happens to be first in iteration order, silently ignoring the other identity's record. The root cause is that the composite PK [destinationHash, identityHash] means these are genuinely distinct rows. The crash-safe fix is to expose identityHash through the UI model and build a composite key — "pinned_${contact.destinationHash.lowercase()}_${contact.identityHash.lowercase()}" — rather than filtering out rows that the user has explicitly created.
Prompt To Fix With AI
This is a comment left during a code review.
Path: app/src/main/java/network/columba/app/ui/screens/ContactsScreen.kt
Line: 522-523
Comment:
**`distinctBy` silently drops legitimate contact records**
When a user has the same destination registered under multiple local identities, `distinctBy { it.destinationHash }` keeps only the first occurrence and quietly discards the rest. This means UI-triggered actions (delete, pin, open chat) will operate on whichever `ContactEntity` happens to be first in iteration order, silently ignoring the other identity's record. The root cause is that the composite PK `[destinationHash, identityHash]` means these are genuinely distinct rows. The crash-safe fix is to expose `identityHash` through the UI model and build a composite key — `"pinned_${contact.destinationHash.lowercase()}_${contact.identityHash.lowercase()}"` — rather than filtering out rows that the user has explicitly created.
How can I resolve this? If you propose a fix, please make it concise.Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
| ) { | ||
| // Deduplicate by destinationHash to prevent LazyColumn duplicate-key | ||
| // crash (COLUMBA-99) — multiple announce rows can share a destinationHash. | ||
| val uniqueSavedPeers = savedPeers.distinctBy { it.destinationHash } |
There was a problem hiding this comment.
The
distinctBy calls in SavedPeersScreen and MessageDeliveryRetrievalCard omit the .lowercase() normalization that ContactsScreen applies. If a destinationHash ever arrives with inconsistent casing (e.g., "ABC123" vs "abc123"), distinctBy would treat them as distinct — meaning no deduplication and a possible surviving duplicate key that can still crash the LazyColumn. Align with the ContactsScreen approach by normalizing to lowercase before distinctBy.
| val uniqueSavedPeers = savedPeers.distinctBy { it.destinationHash } | |
| val uniqueSavedPeers = savedPeers.distinctBy { it.destinationHash.lowercase() } |
Prompt To Fix With AI
This is a comment left during a code review.
Path: app/src/main/java/network/columba/app/ui/screens/SavedPeersScreen.kt
Line: 88
Comment:
The `distinctBy` calls in `SavedPeersScreen` and `MessageDeliveryRetrievalCard` omit the `.lowercase()` normalization that `ContactsScreen` applies. If a `destinationHash` ever arrives with inconsistent casing (e.g., `"ABC123"` vs `"abc123"`), `distinctBy` would treat them as distinct — meaning no deduplication and a possible surviving duplicate key that can still crash the `LazyColumn`. Align with the `ContactsScreen` approach by normalizing to lowercase before `distinctBy`.
```suggestion
val uniqueSavedPeers = savedPeers.distinctBy { it.destinationHash.lowercase() }
```
How can I resolve this? If you propose a fix, please make it concise.| items(availableRelays, key = { it.destinationHash }) { relay -> | ||
| // Deduplicate by destinationHash to prevent LazyColumn duplicate-key | ||
| // crash (COLUMBA-99) — multiple announce rows can share a destinationHash. | ||
| val uniqueRelays = availableRelays.distinctBy { it.destinationHash } |
There was a problem hiding this comment.
Same missing lowercase normalization as in
SavedPeersScreen. distinctBy { it.destinationHash } without case-folding leaves a crack open: if the same relay is announced with mixed-case hashes the deduplication silently fails and the crash can recur. Apply .lowercase() to match the pattern already established in ContactsScreen.
| val uniqueRelays = availableRelays.distinctBy { it.destinationHash } | |
| val uniqueRelays = availableRelays.distinctBy { it.destinationHash.lowercase() } |
Prompt To Fix With AI
This is a comment left during a code review.
Path: app/src/main/java/network/columba/app/ui/screens/settings/cards/MessageDeliveryRetrievalCard.kt
Line: 958
Comment:
Same missing lowercase normalization as in `SavedPeersScreen`. `distinctBy { it.destinationHash }` without case-folding leaves a crack open: if the same relay is announced with mixed-case hashes the deduplication silently fails and the crash can recur. Apply `.lowercase()` to match the pattern already established in `ContactsScreen`.
```suggestion
val uniqueRelays = availableRelays.distinctBy { it.destinationHash.lowercase() }
```
How can I resolve this? If you propose a fix, please make it concise.
This PR addresses
IllegalArgumentException: Key "<hash>" was already usedoccurring in LazyColumns/LazyRows that display lists of contacts or announces.Root Cause:
The
ContactEntityin the database uses a composite primary key[destinationHash, identityHash]. This means it's legitimate for two differentContactEntityrecords (belonging to different local identities) to share the samedestinationHash. However, several UI components were usingdestinationHashalone as thekeyforLazyColumnitems. When a list contained two items with the samedestinationHash, Compose would crash due to the non-unique key.Solution:
To resolve this,
distinctBy { it.destinationHash }has been applied to the data lists (e.g.,pinnedContacts,allContacts,availableRelays,savedPeers) immediately before they are passed to theLazyColumn'sitemsfunction. This ensures that only uniquedestinationHashvalues are used as keys within a single list, preventing theIllegalArgumentException.Affected Files:
app/src/main/java/network/columba/app/ui/screens/ContactsScreen.ktapp/src/main/java/network/columba/app/ui/screens/settings/cards/MessageDeliveryRetrievalCard.ktapp/src/main/java/network/columba/app/ui/screens/SavedPeersScreen.ktShareLocationBottomSheet.ktandLocationSharingCard.ktwere also identified as potentially problematic but already contained similardistinctBydeduplication logic, so no changes were required there.This fix is a pragmatic solution given the current data models, ensuring UI stability when handling contacts that may share destination hashes across different local identities.
Fixes COLUMBA-99