Respect system contact avatar preference when contact sharing.

This commit is contained in:
Cody Henthorne 2026-10-30 12:34:01 -04:00
commit 8fbefe42bc
14 changed files with 392 additions and 44 deletions

View file

@ -20,6 +20,7 @@ import org.signal.core.util.requireLongOrNull
import org.signal.core.util.requireString
import org.thoughtcrime.securesms.database.RecipientTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.util.SignalE164Util
/**
* Populates a [ContactIndexDatabase] from the two sources that make up the merged contact list.
@ -44,7 +45,7 @@ class ContactIndexBuilder(private val context: Context) {
return try {
val hasPermission = Permissions.hasAll(context, Manifest.permission.READ_CONTACTS)
val links: Map<String, List<RecipientTable.SystemContactLink>> = SignalDatabase.recipients.getSystemContactLinksByLookupKey()
val links = SignalDatabase.recipients.getSystemContactLinks()
val signalOnly: List<RecipientTable.SignalOnlyContact> = SignalDatabase.recipients.getSignalOnlyContactsForIndex(excludeSystemContacts = hasPermission)
stopwatch.split("recipients")
@ -69,7 +70,7 @@ class ContactIndexBuilder(private val context: Context) {
Log.i(
TAG,
"Indexed $count rows. signalOnly=$signalOnlyIndexed/${signalOnly.size} addressBook=${addressBook.inserted}/${addressBook.inserted + addressBook.skipped + addressBook.invalidId} invalidId=${addressBook.invalidId} linkedToSignal=${links.values.sumOf { it.size }}"
"Indexed $count rows. signalOnly=$signalOnlyIndexed/${signalOnly.size} addressBook=${addressBook.inserted}/${addressBook.inserted + addressBook.skipped + addressBook.invalidId} invalidId=${addressBook.invalidId} linkedToSignal=${links.size}"
)
if (hasPermission) ContactIndexBuildResult.Success(count) else ContactIndexBuildResult.SignalOnly(count)
@ -105,7 +106,7 @@ class ContactIndexBuilder(private val context: Context) {
private fun insertAddressBook(
database: ContactIndexDatabase,
links: Map<String, List<RecipientTable.SystemContactLink>>,
systemContactLinks: List<RecipientTable.SystemContactLink>,
sortKeys: ContactSortKeyGenerator
): InsertCount {
val cursor: Cursor = SystemContactsRepository.getAllContactsForList(context) ?: run {
@ -118,6 +119,8 @@ class ContactIndexBuilder(private val context: Context) {
var invalidId = 0
cursor.use {
val links = SystemContactLinks(systemContactLinks, it.currentLookupKeys())
val batch = ArrayList<ContactIndexEntry>(BATCH_SIZE)
while (it.moveToNext()) {
@ -151,6 +154,18 @@ class ContactIndexBuilder(private val context: Context) {
return InsertCount(inserted, skipped, invalidId)
}
/** Leaves the cursor before its first row, ready for the pass that inserts. */
private fun Cursor.currentLookupKeys(): List<String> {
val keys = ArrayList<String>(count)
while (moveToNext()) {
requireString(ContactsContract.Contacts.LOOKUP_KEY)?.let { keys += it }
}
moveToPosition(-1)
return keys
}
/** Rows that made it into the index versus rows the provider gave us that had nothing to show. */
private data class InsertCount(val inserted: Int = 0, val skipped: Int = 0, val invalidId: Int = 0)
@ -177,11 +192,13 @@ class ContactIndexBuilder(private val context: Context) {
/** One entry per registered recipient, since two numbers on one contact are two Signal accounts. */
private fun Cursor.toEntries(
contactId: Long,
links: Map<String, List<RecipientTable.SystemContactLink>>,
links: SystemContactLinks,
sortKeys: ContactSortKeyGenerator
): List<ContactIndexEntry> {
val lookupKey = requireString(ContactsContract.Contacts.LOOKUP_KEY) ?: return emptyList()
val matched = links[lookupKey].orEmpty()
val matched = links.find(lookupKey) {
SystemContactsRepository.getDisplayNumbersForContact(context, contactId).mapNotNull { SignalE164Util.formatAsE164(it) }.toSet()
}
return if (matched.isEmpty()) {
listOfNotNull(toEntry(contactId, lookupKey, null, sortKeys))

View file

@ -59,7 +59,9 @@ data class ContactIndexRecord(
val contactId: Long?,
val hasPersonalName: Boolean,
val isOrgOnly: Boolean,
val hasPhoto: Boolean
val hasPhoto: Boolean,
/** Not stored in the index. Filled in per page, since profile photos change independently of it. */
val hasProfilePhoto: Boolean = false
) {
val isOnSignal: Boolean
get() = type == ContactIndexType.SIGNAL_ONLY || type == ContactIndexType.BOTH
@ -68,7 +70,7 @@ data class ContactIndexRecord(
get() = type == ContactIndexType.SYSTEM_ONLY || type == ContactIndexType.BOTH
override fun toString(): String {
return "ContactIndexRecord(position=$position, type=$type, recipientId=$recipientId, hasLookupKey=${lookupKey != null}, contactId=$contactId, hasPersonalName=$hasPersonalName, isOrgOnly=$isOrgOnly, hasPhoto=$hasPhoto)"
return "ContactIndexRecord(position=$position, type=$type, recipientId=$recipientId, hasLookupKey=${lookupKey != null}, contactId=$contactId, hasPersonalName=$hasPersonalName, isOrgOnly=$isOrgOnly, hasPhoto=$hasPhoto, hasProfilePhoto=$hasProfilePhoto)"
}
}

View file

@ -0,0 +1,76 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.contacts.index
import org.signal.core.models.recipients.RecipientId
import org.thoughtcrime.securesms.database.RecipientTable
/**
* Matches address book entries to linked recipients, falling back to a shared lookup key segment when a
* stored key has gone stale. Each segment names one raw contact, which belongs to one aggregate at a time.
*
* A stale key whose raw contacts were split across several current contacts is claimed by each of them,
* so a fallback match is only trusted when one contact claims it or the contact carries the recipient's
* number.
*/
class SystemContactLinks(
links: List<RecipientTable.SystemContactLink>,
currentLookupKeys: Collection<String>
) {
private val byLookupKey: Map<String, List<RecipientTable.SystemContactLink>> = links.groupBy { it.lookupKey }
private val bySegment: Map<String, List<RecipientTable.SystemContactLink>>
private val fallbackClaims: Map<RecipientId, Int>
init {
val currentKeys = currentLookupKeys.toHashSet()
bySegment = links
.filterNot { it.lookupKey in currentKeys }
.flatMap { link -> segmentsOf(link.lookupKey).map { it to link } }
.groupBy({ it.first }, { it.second })
fallbackClaims = currentKeys
.asSequence()
.filterNot { it in byLookupKey }
.flatMap { fallbackCandidates(it) }
.groupingBy { it.recipientId }
.eachCount()
}
fun find(lookupKey: String, e164s: () -> Set<String>): List<RecipientTable.SystemContactLink> {
byLookupKey[lookupKey]?.let { return it }
val candidates = fallbackCandidates(lookupKey)
if (candidates.isEmpty()) {
return candidates
}
val numbers by lazy(e164s)
return candidates.filter { link ->
(fallbackClaims[link.recipientId] ?: 0) <= 1 || (link.e164 != null && link.e164 in numbers)
}
}
private fun fallbackCandidates(lookupKey: String): List<RecipientTable.SystemContactLink> {
return segmentsOf(lookupKey)
.flatMap { bySegment[it].orEmpty() }
.distinctBy { it.recipientId }
}
companion object {
/** The provider escapes a literal `.` in a source id as `..`, so only a lone `.` separates segments. */
private val SEGMENT_SEPARATOR = Regex("(?<!\\.)\\.(?!\\.)")
private fun segmentsOf(lookupKey: String): List<String> {
val segments = if (lookupKey.contains("..")) lookupKey.split(SEGMENT_SEPARATOR) else lookupKey.split('.')
return segments.filter { it.isNotEmpty() }
}
}
}

View file

@ -8,6 +8,7 @@ package org.thoughtcrime.securesms.contactshare.screens.selectcontact
import android.content.Context
import kotlinx.coroutines.withContext
import org.signal.contacts.SystemContactsRepository
import org.signal.core.models.recipients.RecipientId
import org.signal.core.util.concurrent.SignalDispatchers
import org.signal.core.util.concurrent.SignalExecutors
import org.signal.core.util.logging.Log
@ -16,24 +17,37 @@ import org.thoughtcrime.securesms.contacts.index.ContactIndexRecord
import org.thoughtcrime.securesms.contacts.index.ContactIndexRepository
import org.thoughtcrime.securesms.contactshare.SharedContactSource
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
/** Backs the picker with the real contact index, owning the dispatcher hop so the view model does not. */
class ContactIndexSource(
private val repository: ContactIndexRepository,
private val context: Context = AppDependencies.application
private val context: Context = AppDependencies.application,
private val hasProfilePhoto: (RecipientId) -> Boolean = { id -> Recipient.resolved(id).let { it.profileAvatar != null && it.profileAvatarFileDetails.hasFile() } },
private val preferSystemContactPhotosSetting: () -> Boolean = { SignalStore.settings.isPreferSystemContactPhotos }
) {
companion object {
private val TAG = Log.tag(ContactIndexSource::class)
}
val preferSystemContactPhotos: Boolean
get() = preferSystemContactPhotosSetting()
suspend fun build(): ContactIndexBuildResult = withContext(SignalDispatchers.IO) {
repository.build()
}
/** A window of rows, filtered by [query] when it is not blank. */
suspend fun page(query: String, startPosition: Long, limit: Int): List<ContactIndexRecord> = withContext(SignalDispatchers.IO) {
repository.search(query, startPosition, limit)
val rows = repository.search(query, startPosition, limit)
if (preferSystemContactPhotos) {
rows
} else {
rows.map { row -> if (row.recipientId != null && hasProfilePhoto(row.recipientId)) row.copy(hasProfilePhoto = true) else row }
}
}
/** How many rows the unfiltered index holds, which is what lets a page report its true length. */

View file

@ -237,6 +237,7 @@ private fun ContactList(
is SelectContactRow.Contact -> {
ContactRow(
contact = row.contact,
preferSystemContactPhotos = state.preferSystemContactPhotos,
onClick = { onEvent(SelectContactEvent.ContactClicked(row.contact)) }
)
}
@ -315,6 +316,7 @@ private fun SectionHeader(label: String) {
@Composable
private fun ContactRow(
contact: ContactIndexRecord,
preferSystemContactPhotos: Boolean,
onClick: () -> Unit
) {
Row(
@ -325,7 +327,7 @@ private fun ContactRow(
.clickable(onClick = onClick)
.padding(start = ROW_START_PADDING, end = 16.dp)
) {
ContactAvatar(contact = contact)
ContactAvatar(contact = contact, preferSystemContactPhotos = preferSystemContactPhotos)
Spacer(modifier = Modifier.width(AVATAR_TO_NAME_GAP))
@ -352,14 +354,18 @@ private fun ContactRow(
}
/**
* The address book photo wins over the Signal profile photo, matching the sharing flow's preference
* for what the user themselves filed the contact under.
* Without the prefer system contact photos setting, a profile photo wins. Otherwise, and as the fallback,
* the provider's current photo is used, matching what the share screen offers once the row is picked.
*/
@Composable
private fun ContactAvatar(contact: ContactIndexRecord) {
private fun ContactAvatar(contact: ContactIndexRecord, preferSystemContactPhotos: Boolean) {
val modifier = Modifier.size(AVATAR_SIZE)
when {
contact.recipientId != null && !preferSystemContactPhotos && contact.hasProfilePhoto -> {
AvatarImage(recipientId = contact.recipientId, modifier = modifier)
}
contact.hasPhoto && contact.contactId != null -> {
if (LocalInspectionMode.current) {
Image(

View file

@ -14,7 +14,8 @@ data class SelectContactState(
val isLoading: Boolean = true,
/** Rows are paged, so emptiness is keyed off this rather than off the first loaded page. */
val indexCount: Int = 0,
val showPermissionDeniedSheet: Boolean = false
val showPermissionDeniedSheet: Boolean = false,
val preferSystemContactPhotos: Boolean = true
) {
val isEmpty: Boolean
get() = !isLoading && indexCount == 0

View file

@ -54,7 +54,7 @@ class SelectContactViewModel(
private const val MAX_SIZE = PAGE_SIZE * 6
}
private val _state = MutableStateFlow(SelectContactState())
private val _state = MutableStateFlow(SelectContactState(preferSystemContactPhotos = source.preferSystemContactPhotos))
val state: StateFlow<SelectContactState> = _state.asStateFlow()
private val _actions = Channel<SelectContactAction>(Channel.BUFFERED)

View file

@ -32,6 +32,7 @@ import org.thoughtcrime.securesms.contactshare.labelText
import org.thoughtcrime.securesms.contactshare.resolveSignalRecipient
import org.thoughtcrime.securesms.contactshare.screens.editname.ContactNameParts
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.profiles.AvatarHelper
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.RemoteConfig
@ -255,41 +256,38 @@ class ShareContactRepository(
return details
}
/**
* Address book photo first per the design, Signal profile photo as the alternative, and sharing no
* photo as the last choice. The last one is only offered when there is a photo to decline, since a
* card with no photo has nothing to choose between.
*/
/** The first option is the default. Sharing no photo is only offered when there is a photo to decline. */
private fun Contact.resolvePhotoOptions(signalRecipient: RecipientId?): List<ShareContactState.PhotoOption> {
val options = mutableListOf<ShareContactState.PhotoOption>()
val sharedAvatar = this.avatar
if (sharedAvatar != null && !sharedAvatar.isProfile) {
sharedAvatar.attachment?.uri?.let { uri ->
options += ShareContactState.PhotoOption(
id = PHOTO_ID_ADDRESS_BOOK,
photo = ShareContactState.ContactPhoto(uri = uri.toString(), isProfile = false)
)
}
val addressBookOption = sharedAvatar?.takeUnless { it.isProfile }?.attachment?.uri?.let { uri ->
ShareContactState.PhotoOption(
id = PHOTO_ID_ADDRESS_BOOK,
photo = ShareContactState.ContactPhoto(uri = uri.toString(), isProfile = false)
)
}
// A card built from a Signal profile already blobbed that photo, so blobbing again would offer
// the same image twice.
val alreadyBlobbed = sharedAvatar?.takeIf { it.isProfile }?.attachment?.uri?.toString()
val profileUri = alreadyBlobbed ?: signalRecipient?.let { profilePhotoBlobUri(it) }
if (profileUri != null) {
options += ShareContactState.PhotoOption(
val profileOption = (alreadyBlobbed ?: signalRecipient?.let { profilePhotoBlobUri(it) })?.let { uri ->
ShareContactState.PhotoOption(
id = PHOTO_ID_SIGNAL_PROFILE,
photo = ShareContactState.ContactPhoto(uri = profileUri, isProfile = true)
photo = ShareContactState.ContactPhoto(uri = uri, isProfile = true)
)
}
if (options.isNotEmpty()) {
options += ShareContactState.PhotoOption(id = PHOTO_ID_NONE, photo = null)
val options = if (SignalStore.settings.isPreferSystemContactPhotos) {
listOfNotNull(addressBookOption, profileOption)
} else {
listOfNotNull(profileOption, addressBookOption)
}
return options
return if (options.isNotEmpty()) {
options + ShareContactState.PhotoOption(id = PHOTO_ID_NONE, photo = null)
} else {
options
}
}
/** Blobs the stored profile avatar so it uploads as an ordinary attachment. */

View file

@ -3743,15 +3743,15 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
}
/**
* Every registered recipient linked to a system contact, keyed by that contact's lookup key.
* Every registered recipient linked to a system contact, with that contact's lookup key.
*
* The key comes from the uri path, not its last segment, which may be a legacy Data row id or a stale
* contact id. Two numbers on one contact share a lookup key and are separate Signal accounts, so all of
* them are kept.
*/
fun getSystemContactLinksByLookupKey(): Map<String, List<SystemContactLink>> {
fun getSystemContactLinks(): List<SystemContactLink> {
return readableDatabase
.select(ID, SYSTEM_CONTACT_URI, NICKNAME_JOINED_NAME, NICKNAME_GIVEN_NAME)
.select(ID, E164, SYSTEM_CONTACT_URI, NICKNAME_JOINED_NAME, NICKNAME_GIVEN_NAME)
.from(TABLE_NAME)
.where(
"$REGISTERED = ? AND $SYSTEM_CONTACT_URI NOT NULL AND $BLOCKED = 0 AND $HIDDEN = 0 AND $ID != ?",
@ -3769,12 +3769,12 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
SystemContactLink(
lookupKey = lookupKey,
recipientId = RecipientId.from(cursor.requireLong(ID)),
e164 = cursor.requireString(E164),
nickname = Util.getFirstNonEmpty(cursor.requireString(NICKNAME_JOINED_NAME), cursor.requireString(NICKNAME_GIVEN_NAME)).nullIfBlank()
)
}
}
.filterNotNull()
.groupBy { it.lookupKey }
}
/**
@ -3811,8 +3811,11 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
data class SystemContactLink(
val lookupKey: String,
val recipientId: RecipientId,
val e164: String?,
val nickname: String?
)
) {
override fun toString(): String = "SystemContactLink(recipientId=$recipientId)"
}
data class SignalOnlyContact(
val recipientId: RecipientId,

View file

@ -0,0 +1,115 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.contacts.index
import assertk.assertThat
import assertk.assertions.containsExactly
import assertk.assertions.containsExactlyInAnyOrder
import assertk.assertions.isEmpty
import assertk.assertions.isFalse
import org.junit.Test
import org.signal.core.models.recipients.RecipientId
import org.thoughtcrime.securesms.database.RecipientTable
class SystemContactLinksTest {
@Test
fun `an exact lookup key matches`() {
val links = links(listOf(link("3854i1fd3.2399r9-2F47", 10)), current = listOf("3854i1fd3.2399r9-2F47"))
assertThat(links.find("3854i1fd3.2399r9-2F47").ids()).containsExactly(10L)
}
@Test
fun `a stale lookup key matches on a shared segment`() {
val links = links(listOf(link("3854r15-2F47.2399r16-2F47", 24)), current = listOf("3854i344d.2399r16-2F47"))
assertThat(links.find("3854i344d.2399r16-2F47").ids()).containsExactly(24L)
}
@Test
fun `an exact match wins over a shared segment`() {
val links = links(
listOf(link("3854iaaa.2399r16-2F47", 1), link("3854ibbb", 2)),
current = listOf("3854ibbb")
)
assertThat(links.find("3854ibbb").ids()).containsExactly(2L)
}
@Test
fun `a key that still exists is never matched by its segments`() {
val links = links(listOf(link("3854iaaa.2399r16-2F47", 1)), current = listOf("3854iaaa.2399r16-2F47", "2399r16-2F47"))
assertThat(links.find("2399r16-2F47")).isEmpty()
}
@Test
fun `a contact with no shared segment is not linked`() {
val links = links(listOf(link("3854iaaa.2399r16-2F47", 1)), current = listOf("3854ibbb.2399r17-2F48"))
assertThat(links.find("3854ibbb.2399r17-2F48")).isEmpty()
}
@Test
fun `two accounts on one contact are both kept`() {
val links = links(listOf(link("3854iaaa", 1), link("3854iaaa", 2)), current = listOf("3854iaaa"))
assertThat(links.find("3854iaaa").ids()).containsExactlyInAnyOrder(1L, 2L)
}
@Test
fun `a stale key split across two contacts only links the one with the recipient's number`() {
val links = links(
listOf(link("3854ig1.3854ig2", 1, e164 = "+15555550101")),
current = listOf("3854ig1", "3854ig2")
)
assertThat(links.find("3854ig1") { setOf("+15555550199") }).isEmpty()
assertThat(links.find("3854ig2") { setOf("+15555550101") }.ids()).containsExactly(1L)
}
@Test
fun `a stale key split across two contacts is not linked when neither has the number`() {
val links = links(listOf(link("3854ig1.3854ig2", 1, e164 = null)), current = listOf("3854ig1", "3854ig2"))
assertThat(links.find("3854ig1") { setOf("+15555550101") }).isEmpty()
assertThat(links.find("3854ig2") { setOf("+15555550101") }).isEmpty()
}
@Test
fun `numbers are not read for an unambiguous fallback`() {
val links = links(listOf(link("3854r15.2399r16", 24)), current = listOf("3854i344d.2399r16"))
var read = false
links.find("3854i344d.2399r16") {
read = true
emptySet()
}
assertThat(read).isFalse()
}
@Test
fun `an escaped dot in a source id does not split the segment`() {
val links = links(listOf(link("3854iuser..name.2399r16-2F47", 1)), current = listOf("3854iuser", "3854iuser..name.3854ixyz"))
assertThat(links.find("3854iuser")).isEmpty()
assertThat(links.find("3854iuser..name.3854ixyz").ids()).containsExactly(1L)
}
private fun links(links: List<RecipientTable.SystemContactLink>, current: List<String>): SystemContactLinks {
return SystemContactLinks(links, current)
}
private fun SystemContactLinks.find(lookupKey: String): List<RecipientTable.SystemContactLink> = find(lookupKey) { emptySet() }
private fun link(lookupKey: String, id: Long, e164: String? = null): RecipientTable.SystemContactLink {
return RecipientTable.SystemContactLink(lookupKey = lookupKey, recipientId = RecipientId.from(id), e164 = e164, nickname = null)
}
private fun List<RecipientTable.SystemContactLink>.ids(): List<Long> = map { it.recipientId.toLong() }
}

View file

@ -7,6 +7,7 @@ package org.thoughtcrime.securesms.contactshare.screens.selectcontact
import android.app.Application
import assertk.assertThat
import assertk.assertions.containsExactly
import assertk.assertions.isEqualTo
import assertk.assertions.isNull
import io.mockk.every
@ -26,6 +27,7 @@ import org.robolectric.annotation.Config
import org.signal.contacts.SystemContactsRepository
import org.signal.core.models.recipients.RecipientId
import org.thoughtcrime.securesms.contacts.index.ContactIndexRecord
import org.thoughtcrime.securesms.contacts.index.ContactIndexRepository
import org.thoughtcrime.securesms.contacts.index.ContactIndexType
import org.thoughtcrime.securesms.contactshare.SharedContactSource
import org.thoughtcrime.securesms.testing.CoroutineDispatcherRule
@ -82,6 +84,35 @@ class ContactIndexSourceTest {
assertThat(resolved).isNull()
}
@Test
fun `a page marks rows whose recipient has a profile photo when system photos are not preferred`() = runTest(testDispatcher) {
val withPhoto = addressBookRecord(recipientId = RecipientId.from(7))
val withoutPhoto = addressBookRecord(recipientId = RecipientId.from(8)).copy(position = 2)
val addressBookOnly = addressBookRecord(recipientId = null).copy(position = 3)
val page = pagingSource(rows = listOf(withPhoto, withoutPhoto, addressBookOnly), preferSystemContactPhotos = false).page("", 1, 10)
assertThat(page.map { it.hasProfilePhoto }).containsExactly(true, false, false)
}
@Test
fun `a page leaves profile photos unmarked when system photos are preferred`() = runTest(testDispatcher) {
val page = pagingSource(rows = listOf(addressBookRecord(recipientId = RecipientId.from(7))), preferSystemContactPhotos = true).page("", 1, 10)
assertThat(page.map { it.hasProfilePhoto }).containsExactly(false)
}
private fun pagingSource(rows: List<ContactIndexRecord>, preferSystemContactPhotos: Boolean): ContactIndexSource {
val repository: ContactIndexRepository = mockk { every { search(any(), any(), any()) } returns rows }
return ContactIndexSource(
repository = repository,
context = RuntimeEnvironment.getApplication(),
hasProfilePhoto = { it == RecipientId.from(7) },
preferSystemContactPhotosSetting = { preferSystemContactPhotos }
)
}
private fun sourceThatThrows(error: Exception): ContactIndexSource {
val context = RuntimeEnvironment.getApplication()

View file

@ -19,6 +19,7 @@ import assertk.assertions.isFalse
import assertk.assertions.isTrue
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.CompletableDeferred
@ -68,6 +69,12 @@ class SelectContactViewModelTest {
assertThat(viewModel.state.value.indexCount).isEqualTo(2)
}
@Test
fun `the prefer system contact photos setting is published for the rows`() = runTest(testDispatcher) {
assertThat(SelectContactViewModel(fakeSource(preferSystemContactPhotos = true)).state.value.preferSystemContactPhotos).isTrue()
assertThat(SelectContactViewModel(fakeSource(preferSystemContactPhotos = false)).state.value.preferSystemContactPhotos).isFalse()
}
@Test
fun `changing the query is published so the pager can restart`() = runTest(testDispatcher) {
val viewModel = SelectContactViewModel(fakeSource(buildResult = ContactIndexBuildResult.Success(2)))
@ -370,10 +377,13 @@ class SelectContactViewModelTest {
private fun fakeSource(
buildResult: ContactIndexBuildResult = ContactIndexBuildResult.Success(0),
contacts: List<ContactIndexRecord> = emptyList(),
resolvesTo: SharedContactSource? = SharedContactSource.SignalContact(RecipientId.from(1))
resolvesTo: SharedContactSource? = SharedContactSource.SignalContact(RecipientId.from(1)),
preferSystemContactPhotos: Boolean = false
): ContactIndexSource {
val source: ContactIndexSource = mockk(relaxUnitFun = true)
every { source.preferSystemContactPhotos } returns preferSystemContactPhotos
coEvery { source.build() } returns buildResult
coEvery { source.count() } returns contacts.size
coEvery { source.resolve(any()) } returns resolvesTo

View file

@ -16,7 +16,9 @@ import assertk.assertions.isNull
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.mockkStatic
import io.mockk.unmockkObject
import io.mockk.unmockkStatic
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Before
@ -31,10 +33,13 @@ import org.thoughtcrime.securesms.contactshare.ContactCardReader
import org.thoughtcrime.securesms.contactshare.SharedContactSource
import org.thoughtcrime.securesms.contactshare.screens.editname.ContactNameParts
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.keyvalue.AccountValues
import org.thoughtcrime.securesms.keyvalue.SettingsValues
import org.thoughtcrime.securesms.profiles.AvatarHelper
import org.thoughtcrime.securesms.profiles.ProfileName
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.testutil.MockAppDependenciesRule
import org.thoughtcrime.securesms.testutil.MockSignalStoreRule
import org.thoughtcrime.securesms.util.RemoteConfig
import org.thoughtcrime.securesms.util.SignalE164Util
@ -51,6 +56,9 @@ class ShareContactRepositoryTest {
@get:Rule
val signalStore = MockSignalStoreRule(relaxed = setOf(AccountValues::class, SettingsValues::class))
@get:Rule
val appDependencies = MockAppDependenciesRule()
private val repository = ShareContactRepository(context = ApplicationProvider.getApplicationContext())
@Before
@ -88,6 +96,35 @@ class ShareContactRepositoryTest {
assertThat(loaded.state.avatar?.isEditable).isEqualTo(true)
}
@Test
fun `the address book photo is the default when system contact photos are preferred`() = runTest {
every { signalStore.settings.isPreferSystemContactPhotos } returns true
val loaded = loadWithBothPhotos()
assertThat(loaded.photoOptions.map { it.id }).containsExactly("address-book", "signal-profile", "none")
assertThat(loaded.state.avatar?.photo?.isProfile).isEqualTo(false)
}
@Test
fun `the profile photo is the default when system contact photos are not preferred`() = runTest {
every { signalStore.settings.isPreferSystemContactPhotos } returns false
val loaded = loadWithBothPhotos()
assertThat(loaded.photoOptions.map { it.id }).containsExactly("signal-profile", "address-book", "none")
assertThat(loaded.state.avatar?.photo?.isProfile).isEqualTo(true)
}
@Test
fun `the address book photo is still offered when the contact has no profile photo`() = runTest {
every { signalStore.settings.isPreferSystemContactPhotos } returns false
val loaded = load(contact(name = Contact.Name("Paige", "Hall", null, null, null, null), avatar = Contact.Avatar(Uri.parse("content://address-book/1"), false)))
assertThat(loaded.photoOptions.map { it.id }).containsExactly("address-book", "none")
}
@Test
fun `a company only card is marked as an organization`() = runTest {
assertThat(load(contact(organization = "Pacific Plumbing")).state.name?.isOrgOnly).isEqualTo(true)
@ -354,8 +391,26 @@ class ShareContactRepositoryTest {
}
}
private suspend fun loadWithBothPhotos(): LoadedContact {
val recipient = Recipient(id = RecipientId.from(7), isResolving = false)
val card = contact(name = Contact.Name("Paige", "Hall", null, null, null, null), avatar = Contact.Avatar(Uri.parse("content://address-book/1"), false))
mockkObject(Recipient.Companion)
mockkStatic(AvatarHelper::class)
try {
every { Recipient.resolved(recipient.id) } returns recipient
every { AvatarHelper.hasAvatar(any(), recipient.id) } returns true
every { AvatarHelper.getAvatarBytes(any(), recipient.id) } returns byteArrayOf(1)
every { AppDependencies.blobs.forData(any<ByteArray>()).createForSingleSessionOnDisk(any()) } returns Uri.parse("content://blob/1")
return load(card, source = SharedContactSource.AddressBook(Uri.EMPTY, recipient.id))
} finally {
unmockkStatic(AvatarHelper::class)
unmockkObject(Recipient.Companion)
}
}
/** Drives the real load path with a stubbed reader, so the defaults come from buildDetails. */
private suspend fun load(contact: Contact): LoadedContact {
private suspend fun load(contact: Contact, source: SharedContactSource = SharedContactSource.AddressBook(Uri.EMPTY)): LoadedContact {
val reader: ContactCardReader = mockk { every { readSystemContact(any()) } returns contact }
val repository = ShareContactRepository(
context = ApplicationProvider.getApplicationContext(),
@ -363,7 +418,7 @@ class ShareContactRepositoryTest {
reader = reader
)
return repository.load(source = SharedContactSource.AddressBook(Uri.EMPTY), recipientId = null)!!
return repository.load(source = source, recipientId = null)!!
}
private fun selection(

View file

@ -281,6 +281,26 @@ object SystemContactsRepository {
return if (lookupIndex >= 0 && lookupIndex + 1 < segments.size) segments[lookupIndex + 1] else null
}
/** Every phone number on one contact, as entered rather than E164 formatted. */
@JvmStatic
fun getDisplayNumbersForContact(context: Context, contactId: Long): List<String> {
val results: MutableList<String> = mutableListOf()
context.contentResolver.query(
ContactsContract.CommonDataKinds.Phone.CONTENT_URI,
arrayOf(ContactsContract.CommonDataKinds.Phone.NUMBER),
"${ContactsContract.CommonDataKinds.Phone.CONTACT_ID} = ?",
arrayOf(contactId.toString()),
null
).use { cursor ->
while (cursor != null && cursor.moveToNext()) {
cursor.requireString(ContactsContract.CommonDataKinds.Phone.NUMBER)?.takeIf { it.isNotEmpty() }?.let { results += it }
}
}
return results
}
/**
* Retrieves all unique display numbers in the system contacts. (By display, we mean not-E164-formatted)
*/