diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchPagedDataSource.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchPagedDataSource.kt index 5fd5f6bc74..7247e939d9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchPagedDataSource.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchPagedDataSource.kt @@ -244,14 +244,8 @@ class ContactSearchPagedDataSource( return contactSearchPagedDataSourceRepository.querySignalContactLetterHeaders( query = query, includeSelfMode = section.includeSelfMode, - includePush = when (section.transportType) { - ContactSearchConfiguration.TransportType.PUSH, ContactSearchConfiguration.TransportType.ALL -> true - else -> false - }, - includeSms = when (section.transportType) { - ContactSearchConfiguration.TransportType.SMS, ContactSearchConfiguration.TransportType.ALL -> true - else -> false - } + includePush = true, + includeSms = false ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/NewConversationActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/NewConversationActivity.kt index e9bc5f5e63..f132a01386 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/NewConversationActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/NewConversationActivity.kt @@ -54,6 +54,7 @@ import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.recipients.ui.RecipientLookupFailureMessage import org.thoughtcrime.securesms.recipients.ui.RecipientPicker +import org.thoughtcrime.securesms.recipients.ui.RecipientPicker.DisplayMode import org.thoughtcrime.securesms.recipients.ui.RecipientPickerCallbacks import org.thoughtcrime.securesms.recipients.ui.RecipientPickerScaffold import org.thoughtcrime.securesms.recipients.ui.RecipientSelection @@ -311,6 +312,7 @@ private fun NewConversationRecipientPicker( searchQuery = uiState.searchQuery, isRefreshing = uiState.isRefreshingContacts, shouldResetContactsList = uiState.shouldResetContactsList, + displayModes = setOf(DisplayMode.PUSH, DisplayMode.ACTIVE_GROUPS, DisplayMode.INACTIVE_GROUPS, DisplayMode.SELF), callbacks = remember(callbacks) { RecipientPickerCallbacks( listActions = callbacks, diff --git a/app/src/test/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchPagedDataSourceTest_letterHeaders.kt b/app/src/test/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchPagedDataSourceTest_letterHeaders.kt new file mode 100644 index 0000000000..dfdef9f659 --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchPagedDataSourceTest_letterHeaders.kt @@ -0,0 +1,90 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.contacts.paged + +import android.app.Application +import androidx.core.content.contentValuesOf +import androidx.test.core.app.ApplicationProvider +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.signal.core.models.ServiceId.PNI +import org.signal.paging.PagedDataSource +import org.thoughtcrime.securesms.database.RecipientTable +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.keyvalue.StorySend +import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.testutil.RecipientTestRule +import java.util.UUID + +@Suppress("ClassName") +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE, application = Application::class) +class ContactSearchPagedDataSourceTest_letterHeaders { + + @get:Rule + val recipients = RecipientTestRule() + + @Test + fun `letter header lands on registered contact even when an unregistered system contact outranks it alphabetically`() { + recipients.createRecipient("Alice Anderson") + val charlieId = recipients.createRecipient("Charlie Chaplin") + insertUnregisteredSystemContact("Carrolyn") + + val dataSource = ContactSearchPagedDataSource( + contactConfiguration = ContactSearchConfiguration.build { + addSection( + ContactSearchConfiguration.Section.Individuals( + includeHeader = false, + includeSelfMode = RecipientTable.IncludeSelfMode.Exclude, + includeLetterHeaders = true, + transportType = ContactSearchConfiguration.TransportType.ALL + ) + ) + }, + contactSearchPagedDataSourceRepository = object : ContactSearchPagedDataSourceRepository(ApplicationProvider.getApplicationContext()) { + override fun getLatestStorySends(activeStoryCutoffDuration: Long): List = emptyList() + } + ) + + val totalSize = dataSource.size() + val rows = dataSource.load(0, totalSize, totalSize, PagedDataSource.CancellationSignal { false }) + + val charlie = rows.filterIsInstance() + .firstOrNull { it.recipient.id == charlieId } + assertNotNull("Charlie should be in the visible list. rows=$rows", charlie) + assertEquals( + "Charlie (registered) must carry the C header even though Carrolyn (unregistered system contact) sorts ahead of her. rows=$rows", + "C", + charlie!!.headerLetter + ) + } + + private fun insertUnregisteredSystemContact(name: String): RecipientId { + val rowId = SignalDatabase.recipients.writableDatabase.insertOrThrow( + RecipientTable.TABLE_NAME, + null, + contentValuesOf( + RecipientTable.TYPE to 0, + RecipientTable.E164 to "+15555550101", + RecipientTable.ACI_COLUMN to null, + RecipientTable.PNI_COLUMN to PNI.from(UUID.randomUUID()).toString(), + RecipientTable.REGISTERED to RecipientTable.RegisteredState.NOT_REGISTERED.id, + RecipientTable.PROFILE_SHARING to 1, + RecipientTable.SYSTEM_GIVEN_NAME to name, + RecipientTable.SYSTEM_JOINED_NAME to name, + RecipientTable.SYSTEM_CONTACT_URI to "content://com.android.contacts/contacts/lookup/abc/1", + RecipientTable.AVATAR_COLOR to "A110", + RecipientTable.MESSAGE_EXPIRATION_TIME_VERSION to 1 + ) + ) + return RecipientId.from(rowId) + } +} diff --git a/app/src/test/java/org/thoughtcrime/securesms/database/RecipientTableTest_letterHeaders.kt b/app/src/test/java/org/thoughtcrime/securesms/database/RecipientTableTest_letterHeaders.kt new file mode 100644 index 0000000000..a561e0e23e --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/database/RecipientTableTest_letterHeaders.kt @@ -0,0 +1,118 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.database + +import android.app.Application +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.signal.core.util.CursorUtil +import org.thoughtcrime.securesms.profiles.ProfileName +import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.testutil.RecipientTestRule + +@Suppress("ClassName") +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE, application = Application::class) +class RecipientTableTest_letterHeaders { + + @get:Rule + val recipients = RecipientTestRule() + + @Test + fun `letter header anchors are always in getSignalContacts`() { + recipients.createRecipient("Alice Anderson") + recipients.createRecipient("Bob Baker") + recipients.createRecipient("Charlie Chaplin") + recipients.createRecipient("David Dunn") + + assertHeaderAnchorsAreVisible() + } + + @Test + fun `hidden contact is not a letter header anchor`() { + recipients.createRecipient("Alice Anderson") + val hidden = recipients.createRecipient("Carrolyn Carter") + SignalDatabase.recipients.markHidden(hidden) + + assertHeaderAnchorsAreVisible() + } + + @Test + fun `blocked contact is not a letter header anchor`() { + recipients.createRecipient("Alice Anderson") + val blocked = recipients.createRecipient("Carrolyn Carter") + SignalDatabase.recipients.setBlocked(blocked, true) + + assertHeaderAnchorsAreVisible() + } + + @Test + fun `every visible letter section has a header anchor`() { + recipients.createRecipient(ProfileName.fromParts("Alice", "Anderson")) + recipients.createRecipient(ProfileName.fromParts("Bob", "Baker")) + recipients.createRecipient(ProfileName.fromParts("Charlie", "Chaplin")) + SignalDatabase.recipients.setSystemContactName(recipients.createRecipient(ProfileName.fromParts("Dave", "Dunn")), "Dave Dunn") + + val visibleLetters: Set = visibleSignalContacts().values + .filter { it.isNotEmpty() } + .mapNotNull { name -> name.firstOrNull()?.uppercaseChar()?.toString() } + .toSet() + + val headerLetters: Set = SignalDatabase.recipients.querySignalContactLetterHeaders( + "", + RecipientTable.IncludeSelfMode.Exclude, + includePush = true, + includeSms = false + ).values.toSet() + + assertTrue( + "Every visible letter must have a header anchor. visible=$visibleLetters headers=$headerLetters", + visibleLetters.all { it in headerLetters } + ) + } + + private fun assertHeaderAnchorsAreVisible() { + val visibleIds = visibleSignalContactIds() + val headers = SignalDatabase.recipients.querySignalContactLetterHeaders( + "", + RecipientTable.IncludeSelfMode.Exclude, + includePush = true, + includeSms = false + ) + val orphaned = headers.keys - visibleIds + assertTrue( + "Header anchors must all appear in getSignalContacts. orphaned=$orphaned headers=$headers visible=$visibleIds", + orphaned.isEmpty() + ) + } + + private fun visibleSignalContactIds(): Set { + return SignalDatabase.recipients.getSignalContacts(RecipientTable.IncludeSelfMode.Exclude).use { cursor -> + val ids = mutableSetOf() + while (cursor.moveToNext()) { + ids.add(RecipientId.from(CursorUtil.requireLong(cursor, RecipientTable.ID))) + } + ids + } + } + + private fun visibleSignalContacts(): Map { + return SignalDatabase.recipients.getSignalContacts(RecipientTable.IncludeSelfMode.Exclude).use { cursor -> + val rows = mutableMapOf() + while (cursor.moveToNext()) { + val id = RecipientId.from(CursorUtil.requireLong(cursor, RecipientTable.ID)) + val systemName = CursorUtil.requireString(cursor, RecipientTable.SYSTEM_JOINED_NAME) + val profileName = CursorUtil.requireString(cursor, RecipientTable.SEARCH_PROFILE_NAME) + rows[id] = systemName ?: profileName ?: "" + } + rows + } + } +}