Common Contact List Bugs and How to Catch Them
Common Contact List Bugs and How to Catch Them
Common Contact List Bugs and How to Catch Them
Contact lists are a core feature of almost every mobile and web application. When they fail, users experience frustration, missed communications, and a loss of trust in the product. This guide walks through the most frequent contact‑list defects, explains why they appear, shows how they manifest to real people, and gives concrete steps to reproduce, detect, fix, and prevent each issue. The article also demonstrates how persona‑driven autonomous exploration (the approach behind SUSA) surfaces bugs that scripted tests often miss, and provides a practical test matrix you can adopt immediately.
---
Why Contact List Defects Demand Attention
A contact list is more than a simple table of names and numbers. It is the gateway to messaging, calling, emailing, and often to deeper workflows such as sharing, emergency alerts, or payment verification. A defect in this entry point can cascade: a duplicated entry may cause a user to send a message to the wrong person; a missing country code can break international dialing; an inaccessible label can block users who rely on screen readers. Because the list is touched by many modules—UI, data persistence, sync, permissions, localization—bugs tend to hide in the seams between those layers.
Detecting these problems early saves rework, reduces support tickets, and protects brand reputation. The sections below break down twelve common patterns, each with a cause‑symptom‑fix narrative, reproducible steps, and preventive measures.
---
Common Contact List Bugs and How to Catch Them: Duplicate Entries
How Duplicates Arise
Duplicates typically appear when the app creates a new contact record without checking for an existing match, or when two data sources (local storage and a remote sync service) each insert the same record. Common triggers include:
- Importing contacts from SIM, Google, or Exchange without deduplication logic.
- Network retries that resend a create request after a timeout, while the original request already succeeded.
- Race conditions during account migration where both the old and new accounts write simultaneously.
What Users See
In the UI, the same name appears two or more times, often with identical phone numbers or email addresses. Tapping either entry may open the same detail screen, but actions like “Delete” or “Merge” can behave inconsistently—sometimes removing only one copy, leaving a phantom entry. Power users notice the clutter quickly; casual users may simply feel the list is “messy.”
Reproducing the Bug
- Start with a clean device or emulator.
- Add a contact named “Alice Smith” with number +1 555‑123‑4567.
- Trigger a contact import (e.g., via Settings → Accounts → Google → Sync Contacts).
- Repeat the import while the device is offline, then reconnect to force a retry.
- Open the contact list and scroll to verify two Alice Smith rows.
Detecting Duplicates Automatically
A simple assertion can be added to UI tests:
// Android Espresso example
@Test
fun contactListHasNoDuplicates() {
val names = onView(withId(R.id.contact_name))
.perform(RecyclerViewActions.scrollToPosition<RecyclerView.ViewHolder>(0))
.check(matches(isDisplayed()))
.extract { it.text.toString() }
val uniqueNames = names.distinct()
assertEquals(names.size, uniqueNames.size, "Duplicate contact names found")
}
For web apps, a Playwright snippet:
test('no duplicate names', async ({ page }) => {
await page.goto('/contacts');
const names = await page.$$eval('.contact-name', els => els.map(e => e.textContent.trim()));
const unique = [...new Set(names)];
expect(names.length).toBe(unique.length);
});
Fixing the Root Cause
- Implement a deterministic equality check (e.g., compare normalized phone numbers or email hashes) before inserting.
- Use upsert semantics (
INSERT … ON CONFLICT DO UPDATE) in SQLite or Room. - De‑duplicate during sync: fetch remote IDs, compare with local IDs, and ignore inserts that already exist.
Preventing Recurrence
- Write a unit test that feeds a list containing deliberate duplicates to the repository layer and asserts the output size equals the unique count.
- Add a static analysis rule that flags any
insertcall not guarded by acontainsorupsert. - Include the duplicate‑check scenario in the regression suite run on every PR.
---
Common Contact List Bugs and How to Catch Them: Missing Phone Number Formatting
Cause
Applications often store phone numbers as raw strings entered by the user. When displaying, they forget to apply locale‑specific formatting (e.g., adding parentheses, spaces, or dashes). The bug surfaces when the app later tries to dial the number directly from the string, leading to failed calls because the dialer expects a canonical E.164 format.
Symptom
A contact shows “(555) 123‑4567” in the UI, but tapping the call button initiates a dial to “5551234567” (missing the country code) or, conversely, the stored value is “+15551234567” while the UI shows “15551234567” without the plus sign. Users in regions that rely on prefixes (e.g., Japan’s leading zero) see calls fail silently.
Reproduction Steps
- Add a contact with number +44 20 7946 0958 (UK format).
- Verify the UI displays it as “020 7946 0958” (national format).
- Tap the call button and observe the dialed string in the device’s call log (use
adb logcat | grep Dialer). - The log shows the number without the leading “+44”, causing the call to be routed incorrectly.
Detection via Automated Checks
Create a test that calls the formatting function and compares output against a known‑good library (e.g., libphonenumber).
@Test
void phoneNumberFormatterUsesE164ForDialing() {
PhoneNumberUtil util = PhoneNumberUtil.getInstance();
Phonenumber.PhoneNumber numberObj = util.parse("+44 20 7946 0958", "GB");
String formatted = util.format(numberObj, PhoneNumberUtil.PhoneNumberFormat.E164);
assertEquals("+442079460958", formatted);
// Ensure the UI layer calls the same method before invoking Intent.ACTION_DIAL
}
In a web context, use Jest:
test('formatter returns E164 for dial', () => {
const input = '+44 20 7946 0958';
const formatted = formatForDial(input); // function under test
expect(formatted).toBe('+442079460958');
});
Fix
- Centralize all phone‑number handling in a utility class that always stores numbers in E.164 internally.
- Expose separate methods:
formatForDisplay(context, number)andformatForDial(number). - Ensure any UI component that triggers a call reads from the dial‑formatter, not the display‑formatter.
Prevention
- Add a contract test that asserts the output of
formatForDialmatches the regex^\+?[0-9]{6,15}$. - Enforce via lint: any direct use of
String.valueOf(phoneNumber)inside anIntentbuilder must be flagged. - Include a manual exploratory step where testers dial a number from each supported locale and verify the call succeeds.
---
Common Contact List Bugs and How to Catch Them: Incorrect Sorting / Ordering
Why Sorting Fails
Contact lists are often sorted by display name, but the app may neglect to normalize case, ignore diacritics, or use the raw Unicode code point order. Additionally, when contacts are grouped by account type (e.g., Google vs. Exchange), the app might concatenate lists without re‑sorting the combined result.
User Impact
Names appear out of alphabetical order, making it hard to locate a contact quickly. For example, “Ábel” may appear after “Zara” because the app sorts by raw UTF‑8 values where ‘Á’ (U+00C1) sorts after ‘Z’ (U+005A). Users with non‑Latin scripts may see their contacts scattered throughout the list.
Reproducing the Issue
- Add contacts: “Anna”, “Ábel”, “Zoe”, “Mike”.
- Open the contact list and observe the order.
- Expected: Anna, Ábel, Mike, Zoe (locale‑aware collation).
- Actual (broken): Anna, Mike, Zoe, Ábel.
Automated Detection
Use a test that retrieves the displayed list and verifies it matches a locale‑sorted array.
@Test
fun contactListIsLocaleSorted() {
val displayed = onView(withId(R.id.recycler_view))
.perform(RecyclerViewActions.scrollToPosition<RecyclerView.ViewHolder>(0))
.check(matches(isDisplayed()))
.extract { it.text.toString() }
val expected = displayed.sortedWith(Collator.getInstance(Locale.getDefault()))
assertEquals(expected, displayed)
}
For web, with Playwright:
test('list is sorted according to locale', async ({ page }) => {
await page.goto('/contacts');
const items = await page.$$eval('.contact-item', els => els.map(e => e.textContent.trim()));
const sorted = [...items].sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }));
expect(items).toEqual(sorted);
});
Fix
- Use
Collator(Java/Kotlin) orString.prototype.localeCompare(JS) with the appropriate locale and sensitivity set to'base'(ignores case and diacritics) for sorting. - When merging multiple data sources, sort the unified list after the merge, not each source individually.
- Store a separate
sortKeyfield (e.g., name stripped of accents, lower‑cased) and index on it for fast retrieval.
Prevention
- Add a unit test that feeds a list with known problematic characters (accents, ligatures, emojis) and asserts the output order matches
Collator. - Include a lint rule that bans direct use of
Collections.sort(list)without a comparator on contact‑name fields. - In exploratory testing, switch device language to a right‑to‑left locale (e.g., Arabic) and verify that the list respects the locale’s reading direction.
---
Common Contact List Bugs and How to Catch Them: Contact Merge Failures
Origin
When a user edits a contact that exists in multiple accounts (e.g., a Google contact and a device‑only contact), the app should merge the fields rather than create a duplicate. Merge logic can fail when:
- Conflicting fields (two different phone numbers) are not resolved according to a policy.
- The merge operation runs on a background thread while the UI reads stale data, causing a torn read.
- The app incorrectly treats a contact with the same name but different email as a distinct entity, bypassing the merge path.
What the User Experiences
After editing “Bob Lee” to add a second phone number, the user sees two Bob Lee entries: one with the original number, another with the new number. Attempting to delete one may remove both, or the UI may show a “Merge” button that does nothing. The inconsistency erodes confidence in the address book.
Steps to Reproduce
- Create a contact “Bob Lee” with number +1 555‑000‑1111 in the Google account.
- Sync the device so the contact appears locally.
- Disable Google sync temporarily.
- Edit the local copy, adding a second number +1 555‑000‑2222.
- Re‑enable Google sync and wait for the merge.
- Open the contact list and verify only one Bob Lee entry exists with both numbers.
Detecting Merge Problems
An end‑to‑end test can assert the final state:
@Test
void contactMergeResultsInSingleEntryWithAllFields() {
// Given
Contact google = new Contact("Bob Lee", Set.of("+15550001111"));
Contact local = new Contact("Bob Lee", Set.of("+15550002222"));
repository.save(google);
repository.save(local);
// When
repository.syncAccounts();
// Then
List<Contact> contacts = repository.findAllByName("Bob Lee");
assertEquals(1, contacts.size());
assertTrue(contacts.get(0).getNumbers().containsAll(Set.of("+15550001111", "+15550002222")));
}
Fix
- Implement a deterministic merge policy: prefer non‑empty fields, timestamp‑based resolution for conflicts, or user‑chosen “keep both”.
- Use a version vector or
updatedAttimestamp to detect concurrent updates and apply the policy atomically. - Expose the merge result via a LiveData/Flow so the UI updates only after the merge transaction commits.
Prevention
- Add a property‑based test that generates random pairs of contacts with overlapping and divergent fields, runs the merge function, and checks that the result contains the union of all non‑conflicting fields and respects the conflict rule.
- Include a manual exploratory scenario where a user edits a contact while offline, then goes online and expects a clean merge.
- Log merge decisions in debug builds to audit edge cases.
---
Common Contact List Bugs and How to Catch Them: Sync Failures with Backend
Cause
Contact sync relies on network calls, authentication tokens, and conflict‑resolution protocols. Failures arise when:
- Token expiration is not handled, leading to 401 responses that are silently ignored.
- The app assumes the server returns contacts sorted by
updatedAt, but the backend changes the order, causing the client to miss newer entries. - Partial responses (pagination) are not correctly assembled, resulting in missing contacts at the tail of the list.
Symptom
After a period of offline use, the user returns online and notices that newly added contacts on the web portal do not appear in the app. Conversely, deletions made on the server may still linger locally, producing “ghost” entries.
Reproduction
- Log in to the app and let it perform an initial sync (verify 10 contacts).
- Using a web portal, add a new contact “Charlie Doe” and delete an existing contact “Eve”.
- Force the app to go offline (enable airplane mode).
- Bring the device back online and trigger a manual sync (pull‑to‑refresh).
- Open the contact list: Charlie Doe is absent, Eve is still present.
Automated Sync Verification
Use a mock server (e.g., MockWebServer) to simulate success, token expiry, and pagination.
@Test
func syncHandlesTokenRefreshAndPagination() {
// Enqueue first page
server.enqueue(MockResponse()
.setResponseCode(200)
.setBody("""{"contacts":[{ "id":"1","name":"A" }],"nextCursor":"c1"}"""))
// Enqueue token expiry then refresh
server.enqueue(MockResponse().setResponseCode(401))
server.enqueue(MockResponse()
.setResponseCode(200)
.setBody("""{"contacts":[{"id":"2","name":"B"}],"nextCursor":null}"""))
// Trigger sync
viewModel.syncContacts()
// Advance virtual time
advanceTimeBy(5, TimeUnit.SECONDS)
// Verify both contacts persisted
val contacts = repository.getAll()
assertEquals(2, contacts.size)
assertTrue(contacts.any { it.name == "A" })
assertTrue(contacts.any { it.name == "B" })
}
Fix
- Implement a token interceptor that automatically retries failed requests after obtaining a fresh token.
- Treat pagination cursors as opaque; keep requesting until
nextCursoris null. - Store a
serverVersiontimestamp per contact and discard local entries whoseserverVersionis older than the received version (tombstone handling).
Prevention
- Create a contract test suite for the sync endpoint that validates pagination, error handling, and idempotency.
- Add a chaos‑engineering step in CI that introduces latency and random 5xx errors to ensure the client recovers gracefully.
- In exploratory testing, toggle airplane mode at various points during a sync and verify the app resumes correctly.
---
Common Contact List Bugs and How to Catch Them: Permission Handling Errors
Why Permissions Break
Modern OSes require runtime permissions for accessing contacts (Android READ_CONTACTS, WRITE_CONTACTS; iOS CNContactStore). Bugs appear when:
- The app requests permission only after the user tries to pick a contact, causing a confusing delay.
- Permission denial is not handled gracefully, leaving the UI in a loading state or showing a cryptic error.
- The app assumes permission is granted after a rationale dialog, but the user selects “Don’t ask again”, leading to a permanent block.
User Experience
Tapping the “Add Contact” button yields a blank screen or a toast saying “Unable to access contacts”. The user may think the app is broken and abandon the flow.
Reproducing the Fault
- Deny the contact permission in device settings.
- Launch the app and navigate to the contact picker.
- Observe that the app either crashes (null pointer) or shows an empty spinner indefinitely.
Automated Permission Checks
On Android, use UiAutomator to verify the permission dialog appears and the app handles the result:
@Test
void permissionDenialShowsExplanation() {
// Revoke permission via adb
UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
.executeShellCommand("pm revoke com.example.app android.permission.READ_CONTACTS");
// Launch contact picker
onView(withId(R.id.pick_contact)).perform(click());
// Expect a Snackbar or Toast with a helpful message
onView(withText(R.string.permission_contacts_explanation))
.check(matches(isDisplayed()));
}
On iOS with XCTest:
func testContactAccessDeniedShowsAlert() {
CNContactStore().requestAccess(for: .contacts) { granted, error in
// Simulate denial
XCTAssertFalse(granted)
}
let picker = CNContactPickerViewController()
picker.delegate = self
present(picker, animated: true)
// Wait for alert
let expectation = self.expectation(description: "alert appears")
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
XCTAssertNotNil(self.presentedViewController as? UIAlertController)
expectation.fulfill()
}
waitForExpectations(timeout: 2, handler: nil)
}
Fix
- Request permissions early in a dedicated onboarding screen, explaining why they are needed.
- Handle the denial case by showing a persistent banner that links to Settings (
ACTION_APPLICATION_DETAILS_SETTINGSon Android,UIApplicationOpenSettingsURLStringon iOS). - Cache the permission state and disable UI elements that depend on it until granted.
Prevention
- Add a unit test that mocks the permission manager and asserts the UI state transitions correctly for granted, denied, and permanently denied outcomes.
- Include a permission‑flow checklist in the QA test plan: launch app fresh, deny permission, attempt contact‑related action, verify graceful handling.
- Use automated tools like Google’s Play Console pre‑launch report to catch permission‑related crashes on a variety of device configurations.
---
Common Contact List Bugs and How to Catch Them: Accessibility Label Missing
Root Cause
Accessibility services (TalkBack, VoiceOver) rely on content descriptions or ARIA labels to convey purpose. When developers set only a visual text label and forget to assign contentDescription (Android) or aria-label/aria-labelledby (web), screen readers announce the raw view type (“button”, “image”) instead of the intended action.
Effect on Users
A visually impaired user cannot tell whether tapping an avatar will open the contact profile, start a chat, or delete the entry. This leads to missed interactions, accidental deletions, and abandonment of the app.
How to Reproduce
- Enable TalkBack (Android) or VoiceOver (iOS).
- Navigate to a contact list item.
- Listen to the spoken output: you hear “Button” rather than “Call John Doe”.
- Repeat for the “Edit”, “Delete”, and “Share” actions.
Automated Accessibility Checks
Use the Accessibility Test Framework (Android) or axe-core (web) in CI.
Android Espresso + AccessibilityTest:
@Test
fun contactItemHasContentDescription() {
onView(withId(R.id.contact_item))
.check(matches(hasDescendant(withContentDescription(containsString("Call")))))
.check(matches(hasDescendant(withContentDescription(containsString("Edit")))))
.check(matches(hasDescendant(withContentDescription(containsString("Delete")))))
}
Web Playwright + axe:
import { injectAxe, checkA11y } from 'jest-axe';
test('contact list passes axe', async ({ page }) => {
await page.goto('/contacts');
await injectAxe(page);
const { violations } = await page.evaluate(async () => {
return await axe.run();
});
expect(violations).toHaveLength(0);
});
Fix
- For each interactive element (avatar, name line, action icons), set a meaningful
contentDescriptionthat includes the contact’s name and the action (“Call Anna Smith”, “Send message to Anna Smith”). - On the web, use
aria-labelor ensure that visible text is sufficiently descriptive; if icons are used alone, supplement with visually hidden text (Call). - Test with a screen reader after each UI change.
Prevention
- Add a lint rule that flags any
ImageButtonorIconButtonlacking acontentDescriptionoraria-label. - Include an accessibility checklist in the definition of done: verify TalkBack/VoiceOver reads each list item correctly.
- Run automated accessibility scans on every PR and fail the build on new violations.
---
Common Contact List Bugs and How to Catch Them: Crash on Long Press / Context Menu
Why Crashes Happen
Long‑press gestures often trigger a ContextMenu or ActionMode. Crashes occur when:
- The adapter returns
nullfor the item at the tapped position (e.g., due to a stale list after a background update). - The menu tries to access a field that has been cleared (like a contact’s photo URI) without null‑checking.
- The app attempts to start an activity from a non‑activity context (e.g., from a
RecyclerView.ViewHolderinside a background thread).
What the User Sees
The app suddenly closes, presenting a system dialog (“App keeps stopping”). The user loses any unsaved edits and may distrust the app’s stability.
Steps to Reproduce
- Populate the contact list with at least one entry.
- Disable network and trigger a background sync that clears the list temporarily (e.g., via a mock server returning an empty array).
- While the list is empty or in the process of updating, long‑press on a visible item (the stale view may still be present).
- Observe a crash in Logcat:
NullPointerException: Attempt to invoke virtual method ... on a null object reference.
Detecting the Crash with Automated Tests
Use Espresso’s perform(longClick()) combined with IdlingResource to wait for background operations, then assert that no uncaught exception occurs.
@Test
fun longPressDoesNotCrashWhenListIsUpdating() {
// IdlingResource that tracks a LiveData loading state
val idle = object : IdlingResource {
// ... implementation omitted for brevity
}
IdlingRegistry.getInstance().register(idle)
onView(withId(R.id.recycler_view))
.perform(RecyclerViewActions.actionOnItemAtPosition<RecyclerView.ViewHolder>(0, longClick()))
// Verify a context menu appears (no crash)
onView(withText(R.string.menu_edit)).check(matches(isDisplayed()))
IdlingRegistry.getInstance().unregister(idle)
}
Fix
- Guard all accesses to the underlying data with null checks; if the item is unavailable, show a toast (“Item no longer available”) instead of crashing.
- Ensure that
ContextMenuInfois obtained from the view that triggered the gesture, not from a cached adapter position. - Post UI‑changing actions to the main thread (
runOnUiThreadorLifecycleOwner.lifecycleScope.launch(Dispatchers.Main)).
Prevention
- Write a property‑based test that randomizes list size, simulates background updates, and performs long presses at random indices, asserting that the app never throws an uncaught exception.
- Add a runtime error‑capture tool (Firebase Crashlytics) and set an alert for any new
NullPointerExceptionin theContactAdapter. - Include a manual exploratory step where testers rapidly toggle sync on/off while long‑pressing random items.
---
Common Contact List Bugs and How to Catch Them: Search Functionality Broken
Source of Defects
Search typically involves a query string that is matched against contact fields (name, phone, email). Bugs appear when:
- The query is not normalized (e.g., case‑sensitive, accent‑sensitive) while the stored data is normalized, causing missed matches.
- The search debounces too aggressively, so rapid typing yields no results until the user pauses.
- The search runs on a background thread but updates the UI on a stale adapter, leading to flickering or empty lists.
User Impact
A user types “Jon” expecting to find “Jonathan” and “Jonatan”, but sees no results. They may assume the contact does not exist and create a duplicate, worsening the duplication problem.
Reproducing the Fault
- Add contacts: “Jonathan Smith”, “Jonatan Liu”.
- In the search bar, type “jon” (lowercase).
- Observe that the list shows zero results.
- Change to “JON” (uppercase) and see results appear (if case‑sensitive bug).
Automated Search Verification
Parameterized test that feeds various query forms and checks the result set.
@ParameterizedTest
@CsvSource(
"jon, Jonathan Smith, Jonatan Liu",
"JON, Jonathan Smith, Jonatan Liu",
"jo n, Jonathan Smith", // space inside query should be ignored
"jonathan, Jonathan Smith"
)
fun searchReturnsCorrectMatches(query: String, vararg expectedNames: String) {
viewModel.setSearchQuery(query)
// Wait for debounce
advanceTimeBy(300, TimeUnit.MILLISECONDS)
val displayed = viewModel.contactList.value?.map { it.name } ?: emptyList()
expectedNames.forEach { assertTrue(displayed.contains(it)) }
}
Fix
- Normalize both query and contact fields to lowercase and strip diacritics using
java.text.Normalizeror a library like ICU4J. - Implement a debounce of 250 ms but also provide instantaneous results for exact matches on indexed fields (e.g., a SQLite
FTS5table). - Use
LiveDataorStateFlowto emit search results; ensure the UI collects from the same flow regardless of thread.
Prevention
- Add a unit test for the normalization function that covers accented characters, ligatures, and emojis.
- Include a performance benchmark: ensure search latency stays under 150 ms for a list of 10 k contacts.
- In exploratory testing, type rapidly (e.g., paste a long string) and verify the UI does not freeze or show stale results.
---
Common Contact List Bugs and How to Catch Them: Internationalization / Unicode Issues
Why Unicode Breaks
Contact names may contain characters outside the ASCII range: accented letters (é, ñ), CJK glyphs, emojis, or right‑to‑left scripts (Arabic, Hebrew). Problems arise when:
- The UI uses
android:textAllCaps="true"which incorrectly transforms certain scripts. - Text measuring assumes a fixed‑width font, causing truncation or overlapping.
- Backend storage limits fields to a byte length (e.g., VARCHAR(50)) without accounting for multi‑byte UTF‑8 characters, leading to silent truncation.
Effect on Users
A contact named “👩🚀 Astronaut” may appear as “👩🚀 Astron” (truncated after 50 bytes). An Arabic name “محمد” may be displayed left‑aligned, breaking readability. Users with non‑Latin names feel the app is “broken” for them.
Reproducing the Issue
- Add a contact with name “🧙♂️ Merlin 🧙♀️”.
- View the contact in the list and the detail screen.
- Observe that the emojis are missing or replaced with .
- For a length test, add a name exactly 150 Unicode characters long (which may be >150 bytes).
- Sync with the backend and verify the name is truncated.
Automated Unicode Checks
Use a test that sets a known Unicode string and asserts the retrieved
Test Your App Autonomously
Upload your APK or URL. SUSA explores like 10 real users — finds bugs, accessibility violations, and security issues. No scripts.
Try SUSA Free