Respect system contact avatar preference when contact sharing.
This commit is contained in:
parent
6ef1b67b46
commit
8fbefe42bc
14 changed files with 392 additions and 44 deletions
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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() }
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
*/
|
||||
|
|
|
|||
Loading…
Reference in a new issue