Add ACI contact sharing support.

This commit is contained in:
Cody Henthorne
2026-09-15 18:26:05 -04:00
committed by Greyson Parrelli
parent a7241605ea
commit e94ce8a094
148 changed files with 5610 additions and 949 deletions
+2
View File
@@ -755,6 +755,8 @@ dependencies {
implementation(libs.androidx.asynclayoutinflater)
implementation(libs.androidx.asynclayoutinflater.appcompat)
implementation(libs.androidx.emoji2)
implementation(libs.androidx.paging.runtime)
implementation(libs.androidx.paging.compose)
implementation(libs.firebase.messaging) {
exclude(group = "com.google.firebase", module = "firebase-core")
exclude(group = "com.google.firebase", module = "firebase-analytics")
@@ -251,7 +251,7 @@ class V2ConversationItemShapeTest {
override fun onAddToContactsClicked(contact: Contact) = Unit
override fun onMessageSharedContactClicked(choices: MutableList<Recipient>) = Unit
override fun onMessageSharedContactClicked(contact: Contact, choices: MutableList<Recipient>) = Unit
override fun onInviteSharedContactClicked(contact: Contact) = Unit
@@ -137,7 +137,7 @@ class InternalConversationTestFragment : Fragment(R.layout.conversation_test_fra
Toast.makeText(requireContext(), "Can't touch this.", Toast.LENGTH_SHORT).show()
}
override fun onMessageSharedContactClicked(choices: MutableList<Recipient>) {
override fun onMessageSharedContactClicked(contact: Contact, choices: MutableList<Recipient>) {
Toast.makeText(requireContext(), "Can't touch this.", Toast.LENGTH_SHORT).show()
}
+5 -1
View File
@@ -814,7 +814,11 @@
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
android:windowSoftInputMode="adjustResize" />
<activity
android:name=".contactshare.SelectContactActivity"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
android:exported="false"
android:theme="@style/Theme.Signal.DayNight.NoActionBar" />
<activity
android:name=".contactshare.SharedContactDetailsActivityV2"
@@ -54,6 +54,7 @@ import org.thoughtcrime.securesms.apkupdate.ApkUpdateRefreshListener;
import org.thoughtcrime.securesms.avatar.AvatarPickerStorage;
import org.thoughtcrime.securesms.backup.v2.BackupRepository;
import org.thoughtcrime.securesms.clockskew.ClockSkewDetector;
import org.thoughtcrime.securesms.contacts.index.ContactIndexRepository;
import org.thoughtcrime.securesms.preferences.EditProxyActivity;
import org.thoughtcrime.securesms.conversation.drafts.DraftBlobs;
import org.thoughtcrime.securesms.crypto.AppAttachmentSecretStore;
@@ -242,6 +243,7 @@ public class ApplicationContext extends Application implements AppForegroundObse
.addPostRender(() -> DownloadLatestEmojiDataJob.scheduleIfNecessary(this))
.addPostRender(EmojiSearchIndexDownloadJob::scheduleIfNecessary)
.addPostRender(MessageSendLogCleanupJob::enqueue)
.addPostRender(() -> ContactIndexRepository.deleteAbandonedIndex(this))
.addPostRender(() -> JumboEmoji.updateCurrentVersion(this))
.addPostRender(RetrieveRemoteAnnouncementsJob::enqueue)
.addPostRender(AndroidTelecomUtil::registerPhoneAccount)
@@ -90,7 +90,7 @@ public interface BindableConversationItem extends Unbindable, GiphyMp4Playable,
void onViewOnceMessageClicked(@NonNull MmsMessageRecord messageRecord);
void onSharedContactDetailsClicked(@NonNull Contact contact, @NonNull View avatarTransitionView);
void onAddToContactsClicked(@NonNull Contact contact);
void onMessageSharedContactClicked(@NonNull List<Recipient> choices);
void onMessageSharedContactClicked(@NonNull Contact contact, @NonNull List<Recipient> choices);
void onInviteSharedContactClicked(@NonNull Contact contact);
void onReactionClicked(@NonNull MultiselectPart multiselectPart, long messageId, boolean isMms);
void onGroupMemberClicked(@NonNull RecipientId recipientId, @NonNull GroupId groupId);
@@ -53,6 +53,8 @@ fun RecipientTable.getContactsForBackup(selfId: Long): ContactArchiveExporter {
"${RecipientTable.TABLE_NAME}.${RecipientTable.NICKNAME_GIVEN_NAME}",
"${RecipientTable.TABLE_NAME}.${RecipientTable.NICKNAME_FAMILY_NAME}",
"${RecipientTable.TABLE_NAME}.${RecipientTable.NOTE}",
"${RecipientTable.TABLE_NAME}.${RecipientTable.SHARED_GIVEN_NAME}",
"${RecipientTable.TABLE_NAME}.${RecipientTable.SHARED_FAMILY_NAME}",
"${RecipientTable.TABLE_NAME}.${RecipientTable.MUTE_UNTIL}",
"${RecipientTable.TABLE_NAME}.${RecipientTable.CHAT_COLORS}",
"${RecipientTable.TABLE_NAME}.${RecipientTable.CUSTOM_CHAT_COLORS_ID}",
@@ -7,6 +7,7 @@ package org.thoughtcrime.securesms.backup.v2.exporters
import android.database.Cursor
import androidx.annotation.VisibleForTesting
import okio.ByteString
import okio.ByteString.Companion.toByteString
import org.json.JSONArray
import org.json.JSONException
@@ -744,8 +745,9 @@ private fun BackupMessageRecord.toRemoteProfileChangeUpdate(): ChatUpdateMessage
} else if (profileChangeDetails?.learnedProfileName != null) {
val e164 = profileChangeDetails.learnedProfileName.e164?.e164ToLong()
val username = profileChangeDetails.learnedProfileName.username
if (e164 != null || username.isNotNullOrBlank()) {
ChatUpdateMessage(learnedProfileChange = LearnedProfileChatUpdate(e164 = e164, username = username))
val sharedName = profileChangeDetails.learnedProfileName.sharedName
if (e164 != null || username.isNotNullOrBlank() || sharedName.isNotNullOrBlank()) {
ChatUpdateMessage(learnedProfileChange = LearnedProfileChatUpdate(e164 = e164, username = username, sharedName = sharedName))
} else {
Log.w(TAG, ExportSkips.emptyLearnedProfileChange(this.dateSent))
null
@@ -1087,12 +1089,26 @@ private fun BackupMessageRecord.toRemoteContactMessage(reactionRecords: List<Rea
postcode = address.postalCode ?: "",
country = address.country ?: ""
).takeUnless { it.street.isBlank() && it.pobox.isBlank() && it.neighborhood.isBlank() && it.city.isBlank() && it.region.isBlank() && it.postcode.isBlank() && it.country.isBlank() }
}
},
aci = ServiceId.ACI.parseOrNull(sharedContact.aci)?.takeIf { it.isValid }?.toByteString() ?: ByteString.EMPTY,
nickname = sharedContact.nickname.toRemote(),
note = sharedContact.note ?: ""
),
reactions = reactionRecords.toRemote(exportState)
)
}
private fun Contact.SignalNickname?.toRemote(): ContactAttachment.SignalNickname? {
if (this == null || this.isEmpty) {
return null
}
return ContactAttachment.SignalNickname(
given = this.given ?: "",
family = this.family ?: ""
)
}
private fun Contact.Name.toRemote(): ContactAttachment.Name? {
if (givenName.isNullOrEmpty() &&
familyName.isNullOrEmpty() &&
@@ -86,6 +86,7 @@ class ContactArchiveExporter(private val cursor: Cursor, private val selfId: Lon
.identityState(cursor.optionalInt(IdentityTable.VERIFIED).map { IdentityTable.VerifiedStatus.forState(it) }.orElse(IdentityTable.VerifiedStatus.DEFAULT).toRemote())
.note(cursor.requireString(RecipientTable.NOTE) ?: "")
.nickname(cursor.readNickname())
.sharedName(cursor.readSharedName())
.systemGivenName(cursor.requireString(RecipientTable.SYSTEM_GIVEN_NAME) ?: "")
.systemFamilyName(cursor.requireString(RecipientTable.SYSTEM_FAMILY_NAME) ?: "")
.systemNickname(cursor.requireString(RecipientTable.SYSTEM_NICKNAME) ?: "")
@@ -124,6 +125,20 @@ private fun Cursor.readNickname(): Contact.Name? {
)
}
private fun Cursor.readSharedName(): Contact.Name? {
val given = this.requireString(RecipientTable.SHARED_GIVEN_NAME)
val family = this.requireString(RecipientTable.SHARED_FAMILY_NAME)
if (given.isNullOrEmpty() && family.isNullOrEmpty()) {
return null
}
return Contact.Name(
given = given ?: "",
family = family ?: ""
)
}
private fun Recipient.HiddenState.toRemote(): Contact.Visibility {
return when (this) {
Recipient.HiddenState.NOT_HIDDEN -> return Contact.Visibility.VISIBLE
@@ -96,8 +96,8 @@ private fun DecryptedGroup.toRemote(isMember: Boolean, selfAci: ServiceId.ACI):
membersPendingAdminApproval = this.requestingMembers.map { it.toRemote() },
inviteLinkPassword = this.inviteLinkPassword,
description = this.description.takeUnless { it.isBlank() }?.let { Group.GroupAttributeBlob(descriptionText = it) },
announcements_only = this.isAnnouncementGroup == EnabledState.ENABLED,
members_banned = this.bannedMembers.map { it.toRemote() },
announcementsOnly = this.isAnnouncementGroup == EnabledState.ENABLED,
membersBanned = this.bannedMembers.map { it.toRemote() },
terminated = this.terminated
)
}
@@ -33,6 +33,7 @@ import org.signal.core.util.UuidUtil
import org.signal.core.util.asList
import org.signal.core.util.forEach
import org.signal.core.util.logging.Log
import org.signal.core.util.nullIfBlank
import org.signal.core.util.orNull
import org.signal.core.util.requireLong
import org.signal.core.util.toInt
@@ -438,7 +439,10 @@ class ChatItemArchiveImporter(
address.country
)
},
Contact.Avatar(null, backupContact.avatar.toLocalAttachment(voiceNote = false, borderless = false, gif = false, wasDownloaded = true), true)
Contact.Avatar(null, backupContact.avatar.toLocalAttachment(voiceNote = false, borderless = false, gif = false, wasDownloaded = true), true),
ServiceId.ACI.parseOrNull(backupContact.aci)?.takeIf { it.isValid }?.toString(),
backupContact.nickname.toLocal(),
backupContact.note.nullIfBlank()
)
}
@@ -924,7 +928,7 @@ class ChatItemArchiveImporter(
}
learnedProfileChange != null -> {
typeFlags = MessageTypes.PROFILE_CHANGE_TYPE
val profileChangeDetails = ProfileChangeDetails(learnedProfileName = ProfileChangeDetails.LearnedProfileName(e164 = learnedProfileChange.e164?.toString(), username = learnedProfileChange.username))
val profileChangeDetails = ProfileChangeDetails(learnedProfileName = ProfileChangeDetails.LearnedProfileName(e164 = learnedProfileChange.e164?.toString(), username = learnedProfileChange.username, sharedName = learnedProfileChange.sharedName))
val messageExtras = MessageExtras(profileChangeDetails = profileChangeDetails).encode()
put(MessageTable.MESSAGE_EXTRAS, messageExtras)
}
@@ -1301,6 +1305,14 @@ class ChatItemArchiveImporter(
return Contact.Name(this?.givenName, this?.familyName, this?.prefix, this?.suffix, this?.middleName, this?.nickname)
}
private fun ContactAttachment.SignalNickname?.toLocal(): Contact.SignalNickname? {
if (this == null) {
return null
}
return Contact.SignalNickname(given, family).takeUnless { it.isEmpty }
}
private fun ContactAttachment.Phone.Type?.toLocal(): Contact.Phone.Type {
return when (this) {
ContactAttachment.Phone.Type.HOME -> Contact.Phone.Type.HOME
@@ -64,6 +64,8 @@ object ContactArchiveImporter {
RecipientTable.NOTE to contact.note,
RecipientTable.NICKNAME_GIVEN_NAME to contact.nickname?.given,
RecipientTable.NICKNAME_FAMILY_NAME to contact.nickname?.family,
RecipientTable.SHARED_GIVEN_NAME to contact.sharedName?.given,
RecipientTable.SHARED_FAMILY_NAME to contact.sharedName?.family,
RecipientTable.SYSTEM_GIVEN_NAME to contact.systemGivenName,
RecipientTable.SYSTEM_FAMILY_NAME to contact.systemFamilyName,
RecipientTable.SYSTEM_NICKNAME to contact.systemNickname,
@@ -163,8 +163,8 @@ private fun Group.GroupSnapshot.toLocal(operations: GroupsV2Operations.GroupOper
requestingMembers = requestingMembers,
inviteLinkPassword = this.inviteLinkPassword,
description = this.description?.descriptionText ?: "",
isAnnouncementGroup = if (this.announcements_only) EnabledState.ENABLED else EnabledState.DISABLED,
bannedMembers = this.members_banned.map { it.toLocal() },
isAnnouncementGroup = if (this.announcementsOnly) EnabledState.ENABLED else EnabledState.DISABLED,
bannedMembers = this.membersBanned.map { it.toLocal() },
terminated = this.terminated,
isPlaceholderGroup = isPlaceholder
)
@@ -28,6 +28,7 @@ import org.signal.glide.decryptableuri.DecryptableUri;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.contactshare.Contact;
import org.thoughtcrime.securesms.contactshare.ContactUtil;
import org.thoughtcrime.securesms.contactshare.SharedContactPresentation;
import org.thoughtcrime.securesms.avatar.fallback.FallbackAvatar;
import org.thoughtcrime.securesms.avatar.fallback.FallbackAvatarDrawable;
import org.thoughtcrime.securesms.conversation.colors.AvatarColor;
@@ -52,7 +53,8 @@ public class SharedContactView extends LinearLayout implements RecipientForeverO
private TextView actionButtonView;
private ConversationItemFooter footer;
private Contact contact;
private Contact contact;
private SharedContactPresentation presentation;
private Locale locale;
private RequestManager requestManager;
private EventListener eventListener;
@@ -124,15 +126,20 @@ public class SharedContactView extends LinearLayout implements RecipientForeverO
cornerMask.mask(canvas);
}
public void setContact(@NonNull Contact contact, @NonNull RequestManager requestManager, @NonNull Locale locale) {
public void setContact(@NonNull Contact contact,
@NonNull SharedContactPresentation presentation,
@NonNull RequestManager requestManager,
@NonNull Locale locale)
{
this.requestManager = requestManager;
this.locale = locale;
this.contact = contact;
this.presentation = presentation;
activeRecipients.values().stream().forEach(recipient -> recipient.removeForeverObserver(this));
this.activeRecipients.clear();
for (RecipientId recipientId : ContactUtil.getExistingRecipients(contact)) {
for (RecipientId recipientId : presentation.getRecipientIds()) {
activeRecipients.put(recipientId, Recipient.live(recipientId));
}
@@ -177,7 +184,9 @@ public class SharedContactView extends LinearLayout implements RecipientForeverO
ViewUtil.getLeftMargin(disclosureView) +
disclosureView.getLayoutParams().width;
return horizontalPadding + Math.max(nameRowWidth, desiredTextWidth(actionButtonView));
int actionWidth = actionButtonView.getVisibility() == VISIBLE ? desiredTextWidth(actionButtonView) : 0;
return horizontalPadding + Math.max(nameRowWidth, actionWidth);
}
/**
@@ -245,48 +254,56 @@ public class SharedContactView extends LinearLayout implements RecipientForeverO
return new FallbackAvatarDrawable(getContext(), fallbackAvatar).circleCrop();
}
/**
* Shows message action for e164 contacts we found locally, invite for e164/email, and
* add for everything else.
*/
/** Address book membership comes off the recipient snapshot, so this re-runs via {@link #onRecipientChanged}. */
private void presentActionButtons(@NonNull Contact contact) {
List<Recipient> registered = new ArrayList<>(activeRecipients.size());
List<Recipient> registered = new ArrayList<>(activeRecipients.size());
boolean isSystemContact = false;
for (LiveRecipient recipient : activeRecipients.values()) {
if (recipient.get().getRegistered() == RecipientTable.RegisteredState.REGISTERED) {
registered.add(recipient.get());
}
isSystemContact |= recipient.get().isSystemContact();
}
boolean hasInviteTarget = !contact.getPhoneNumbers().isEmpty() || !contact.getEmails().isEmpty();
boolean hasContactDetails = !contact.getPhoneNumbers().isEmpty() ||
!contact.getEmails().isEmpty() ||
!contact.getPostalAddresses().isEmpty();
if (!registered.isEmpty()) {
actionButtonView.setVisibility(VISIBLE);
if (presentation.isOnSignal()) {
actionButtonView.setText(R.string.SharedContactView_message);
actionButtonView.setOnClickListener(v -> {
if (eventListener != null) {
eventListener.onMessageClicked(registered);
eventListener.onMessageClicked(contact, registered);
}
});
} else if (hasInviteTarget) {
} else if (isSystemContact) {
actionButtonView.setText(R.string.SharedContactView_invite_to_signal);
actionButtonView.setOnClickListener(v -> {
if (eventListener != null) {
eventListener.onInviteClicked(contact);
}
});
} else {
} else if (hasContactDetails) {
actionButtonView.setText(R.string.SharedContactView_add_to_contacts);
actionButtonView.setOnClickListener(v -> {
if (eventListener != null) {
eventListener.onAddToContactsClicked(contact);
}
});
} else {
actionButtonView.setText("");
actionButtonView.setOnClickListener(null);
actionButtonView.setVisibility(GONE);
}
}
public interface EventListener {
void onAddToContactsClicked(@NonNull Contact contact);
void onInviteClicked(@NonNull Contact contact);
void onMessageClicked(@NonNull List<Recipient> choices);
void onMessageClicked(@NonNull Contact contact, @NonNull List<Recipient> choices);
}
}
@@ -0,0 +1,152 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.contacts.index
import java.text.Collator
import java.text.Normalizer
import java.util.Locale
/**
* The single naming, sorting, and sectioning rule for the merged contact list.
*
* Both the system address book and the recipient table go through here, so that one comparator
* orders the whole list rather than each source arriving pre-sorted by a comparator we cannot
* reconcile with the other's.
*
* The precedence deliberately matches [org.thoughtcrime.securesms.recipients.Recipient]'s
* `getNameFromLocalData`, so a contact cannot appear under one name here and a different one
* everywhere else in the app. It is reimplemented rather than reused because building the index
* must not resolve a `Recipient` per row, which would populate the recipient cache with an entry
* for the entire address book.
*/
object ContactDisplayName {
/** Section for business entries, unnamed entries, and names that do not start with a letter. */
const val SECTION_OTHER = "#"
/** Escape character for the LIKE patterns built here, so a typed % or _ is not a wildcard. */
const val LIKE_ESCAPE = "\\"
private val COMBINING_MARKS = Regex("\\p{Mn}+")
private val NUMERIC_QUERY = Regex("[+\\-() 0-9]+")
/**
* A name for a recipient that has no address book entry.
*/
fun forSignalContact(
nickname: String?,
systemName: String?,
profileName: String?,
username: String?,
e164: String?,
email: String?
): String? {
return firstNotBlank(nickname, systemName, profileName, username, e164, email)
}
/**
* A name for an address book entry, which may or may not also be a Signal contact.
*
* The provider's own display name wins over the recipient table's cached copy of it. It is
* fresher, and it already falls back to the company name for business entries.
*/
fun forSystemContact(providerDisplayName: String?, nickname: String?): String? {
return firstNotBlank(nickname, providerDisplayName)
}
/**
* Which A-Z section a row belongs to.
*
* A row whose display name stands in for a personal name, because the provider had to fall back to
* a company, email, or phone number, goes to [SECTION_OTHER] rather than under that fallback's
* initial. A Signal nickname overrides that, since it is a real name to sort under.
*/
fun sectionFor(displayName: String, hasPersonalName: Boolean, hasNickname: Boolean): String {
if (!hasPersonalName && !hasNickname) {
return SECTION_OTHER
}
val first = normalize(displayName).firstOrNull { !it.isWhitespace() } ?: return SECTION_OTHER
return if (first.isLetter()) first.uppercaseChar().toString() else SECTION_OTHER
}
/**
* Normalizes a name to the form stored in the index's single searchable column. Lowercases and
* strips diacritics, so that a query typed without accents still matches the accented name.
*/
fun normalize(value: String): String {
val decomposed = Normalizer.normalize(value, Normalizer.Form.NFD)
return COMBINING_MARKS.replace(decomposed, "").lowercase(Locale.getDefault())
}
/**
* Builds the value for the index's single searchable column.
*
* Every name we know for a row is normalized into one space delimited string, deduped, and wrapped in
* spaces. The wrapping is what lets a word prefix search be expressed as a single `LIKE '% q%'`
* without needing a separate token table.
*/
fun searchTextOf(vararg names: String?): String {
val tokens = names
.asSequence()
.filterNotNull()
.map { normalize(it) }
.flatMap { it.split(' ') }
.map { it.trim() }
.filter { it.isNotEmpty() }
.distinct()
.toList()
return if (tokens.isEmpty()) " " else tokens.joinToString(separator = " ", prefix = " ", postfix = " ")
}
/**
* Turns a user's query into a LIKE pattern for the index's search column.
*
* Names match on word prefix. Stored search text is wrapped in spaces, so one leading space in the
* pattern matches the start of any word including the first, without needing a token table.
*
* Numbers match anywhere, because nobody types a phone number starting from its country code. That
* only finds Signal contacts, whose E164 is in the index. Address book numbers are not indexed,
* since indexing them would mean reading the Data table.
*/
fun searchPatternFor(query: String): String {
val normalized = normalize(query.trim())
val escaped = normalized
.replace(LIKE_ESCAPE, "$LIKE_ESCAPE$LIKE_ESCAPE")
.replace("%", "$LIKE_ESCAPE%")
.replace("_", "${LIKE_ESCAPE}_")
return if (normalized.isNotEmpty() && NUMERIC_QUERY.matches(normalized)) {
"%${escaped.filter { it.isDigit() }}%"
} else {
"% $escaped%"
}
}
private fun firstNotBlank(vararg candidates: String?): String? {
return candidates.firstOrNull { !it.isNullOrBlank() }?.trim()
}
}
/**
* Produces the sort keys the index is ordered by.
*
* [Collator] is not thread safe, so one of these belongs to a single index build and must not be
* shared. `PRIMARY` strength matches the rest of the app's name ordering, and means names differing
* only by case or accent tie. Ties are broken by display name at insert time so the resulting order
* is deterministic.
*/
class ContactSortKeyGenerator(locale: Locale = Locale.getDefault()) {
private val collator: Collator = Collator.getInstance(locale).apply { strength = Collator.PRIMARY }
fun of(displayName: String): ByteArray {
return collator.getCollationKey(displayName).toByteArray()
}
}
@@ -0,0 +1,210 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.contacts.index
import android.Manifest
import android.content.Context
import android.database.Cursor
import android.database.sqlite.SQLiteFullException
import android.provider.ContactsContract
import kotlinx.coroutines.CancellationException
import org.signal.contacts.SystemContactsRepository
import org.signal.core.ui.permissions.Permissions
import org.signal.core.util.Stopwatch
import org.signal.core.util.logging.Log
import org.signal.core.util.requireInt
import org.signal.core.util.requireLong
import org.signal.core.util.requireString
import org.thoughtcrime.securesms.database.RecipientTable
import org.thoughtcrime.securesms.database.SignalDatabase
/**
* Populates a [ContactIndexDatabase] from the two sources that make up the merged contact list.
*
* The overlap between them is resolved by lookup key rather than by phone number, which is what lets
* this avoid reading the Data table entirely. One query over registered recipients that carry a
* system contact URI gives us every address book entry that is also on Signal, bounded by Signal
* contact count rather than by address book size.
*/
class ContactIndexBuilder(private val context: Context) {
companion object {
private val TAG = Log.tag(ContactIndexBuilder::class.java)
/** Caps how many entries are in memory at once, so an index of any size stays bounded. */
private const val BATCH_SIZE = 500
}
fun build(database: ContactIndexDatabase): ContactIndexBuildResult {
val stopwatch = Stopwatch("contact-index")
return try {
val hasPermission = Permissions.hasAll(context, Manifest.permission.READ_CONTACTS)
val links: Map<String, List<RecipientTable.SystemContactLink>> = SignalDatabase.recipients.getSystemContactLinksByLookupKey()
val signalOnly: List<RecipientTable.SignalOnlyContact> = SignalDatabase.recipients.getSignalOnlyContactsForIndex(excludeSystemContacts = hasPermission)
stopwatch.split("recipients")
val sortKeys = ContactSortKeyGenerator()
var signalOnlyIndexed = 0
var addressBook = InsertCount()
val count = database.withinBuild {
signalOnlyIndexed = insertSignalOnly(database, signalOnly, sortKeys)
stopwatch.split("signal-only")
if (hasPermission) {
addressBook = insertAddressBook(database, links, sortKeys)
stopwatch.split("address-book")
} else {
Log.i(TAG, "No contacts permission. Indexing Signal contacts only.")
}
}
stopwatch.split("sort")
stopwatch.stop(TAG)
Log.i(
TAG,
"Indexed $count rows. signalOnly=$signalOnlyIndexed/${signalOnly.size} addressBook=${addressBook.inserted}/${addressBook.inserted + addressBook.skipped} linkedToSignal=${links.values.sumOf { it.size }}"
)
if (hasPermission) ContactIndexBuildResult.Success(count) else ContactIndexBuildResult.SignalOnly(count)
} catch (e: CancellationException) {
throw e
} catch (e: SQLiteFullException) {
Log.w(TAG, "Not enough space to build the contact index.", e)
ContactIndexBuildResult.OutOfSpace
} catch (e: Exception) {
Log.w(TAG, "Failed to build the contact index.", e)
ContactIndexBuildResult.Failure(e)
}
}
private fun insertSignalOnly(
database: ContactIndexDatabase,
contacts: List<RecipientTable.SignalOnlyContact>,
sortKeys: ContactSortKeyGenerator
): Int {
var inserted = 0
contacts
.asSequence()
.mapNotNull { it.toEntry(sortKeys) }
.chunked(BATCH_SIZE)
.forEach {
database.insert(it)
inserted += it.size
}
return inserted
}
private fun insertAddressBook(
database: ContactIndexDatabase,
links: Map<String, List<RecipientTable.SystemContactLink>>,
sortKeys: ContactSortKeyGenerator
): InsertCount {
val cursor: Cursor = SystemContactsRepository.getAllContactsForList(context) ?: run {
Log.w(TAG, "Contacts provider returned no cursor.")
return InsertCount()
}
var inserted = 0
var skipped = 0
cursor.use {
val batch = ArrayList<ContactIndexEntry>(BATCH_SIZE)
while (it.moveToNext()) {
val entries = it.toEntries(links, sortKeys)
if (entries.isEmpty()) {
skipped++
continue
}
batch += entries
if (batch.size >= BATCH_SIZE) {
database.insert(batch)
inserted += batch.size
batch.clear()
}
}
database.insert(batch)
inserted += batch.size
}
return InsertCount(inserted, skipped)
}
/** 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)
private fun RecipientTable.SignalOnlyContact.toEntry(sortKeys: ContactSortKeyGenerator): ContactIndexEntry? {
val displayName = ContactDisplayName.forSignalContact(
nickname = nickname,
systemName = systemName,
profileName = profileName,
username = username,
e164 = e164,
email = email
) ?: return null
return ContactIndexEntry(
sortKey = sortKeys.of(displayName),
type = ContactIndexType.SIGNAL_ONLY,
section = ContactDisplayName.sectionFor(displayName, hasPersonalName = true, hasNickname = nickname != null),
displayName = displayName,
searchText = ContactDisplayName.searchTextOf(nickname, systemName, profileName, username, e164, email),
recipientId = recipientId
)
}
/** One entry per registered recipient, since two numbers on one contact are two Signal accounts. */
private fun Cursor.toEntries(
links: Map<String, List<RecipientTable.SystemContactLink>>,
sortKeys: ContactSortKeyGenerator
): List<ContactIndexEntry> {
val lookupKey = requireString(ContactsContract.Contacts.LOOKUP_KEY) ?: return emptyList()
val matched = links[lookupKey].orEmpty()
return if (matched.isEmpty()) {
listOfNotNull(toEntry(lookupKey, null, sortKeys))
} else {
matched.mapNotNull { toEntry(lookupKey, it, sortKeys) }
}
}
private fun Cursor.toEntry(
lookupKey: String,
link: RecipientTable.SystemContactLink?,
sortKeys: ContactSortKeyGenerator
): ContactIndexEntry? {
val providerName = requireString(ContactsContract.Contacts.DISPLAY_NAME_PRIMARY)
// A contact with no name at all cannot be rendered or shared, so it is left out rather than
// shown as a blank row.
val displayName = ContactDisplayName.forSystemContact(providerName, link?.nickname) ?: return null
val hasPersonalName = SystemContactsRepository.isPersonalDisplayName(requireInt(ContactsContract.Contacts.DISPLAY_NAME_SOURCE))
return ContactIndexEntry(
sortKey = sortKeys.of(displayName),
type = if (link != null) ContactIndexType.BOTH else ContactIndexType.SYSTEM_ONLY,
section = ContactDisplayName.sectionFor(displayName, hasPersonalName, hasNickname = link?.nickname != null),
displayName = displayName,
searchText = ContactDisplayName.searchTextOf(providerName, link?.nickname),
recipientId = link?.recipientId,
lookupKey = lookupKey,
contactId = requireLong(ContactsContract.Contacts._ID),
hasPersonalName = hasPersonalName,
hasPhoto = requireString(ContactsContract.Contacts.PHOTO_URI) != null
)
}
}
@@ -0,0 +1,344 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.contacts.index
import android.app.Application
import android.content.ContentValues
import android.database.Cursor
import androidx.sqlite.db.SupportSQLiteDatabase
import net.zetetic.database.sqlcipher.SQLiteDatabase
import net.zetetic.database.sqlcipher.SQLiteOpenHelper
import org.signal.core.util.SqlUtil
import org.signal.core.util.Util
import org.signal.core.util.getTableRowCount
import org.signal.core.util.logging.Log
import org.signal.core.util.readToList
import org.signal.core.util.requireBoolean
import org.signal.core.util.requireInt
import org.signal.core.util.requireLong
import org.signal.core.util.requireLongOrNull
import org.signal.core.util.requireNonNullString
import org.signal.core.util.requireString
import org.signal.core.util.toInt
import org.signal.core.util.withinTransaction
import org.thoughtcrime.securesms.crypto.DatabaseSecret
import org.thoughtcrime.securesms.database.SqlCipherDatabaseHook
import org.thoughtcrime.securesms.database.SqlCipherDeletingErrorHandler
import org.thoughtcrime.securesms.database.SqlCipherLibraryLoader
import org.thoughtcrime.securesms.recipients.RecipientId
import java.util.concurrent.atomic.AtomicLong
/**
* A disposable index of the merged contact list: the system address book and the Signal recipients
* that are not in it, in one table, in one sort order.
*
* Three properties are worth understanding before changing anything here.
*
* **The key is ephemeral.** It is 32 random bytes held only in this object, never persisted. A file
* left behind by a previous process therefore cannot be decrypted by design, which is why opening
* always deletes and recreates, and why there is no schema version to migrate. Deletion is hygiene
* rather than the privacy boundary.
*
* **The row id is the sort position.** Rows are bulk inserted unsorted into a staging table and then
* copied across with `row_number() OVER (ORDER BY sort_key)`, so `_id` runs 1..n in display order
* with no gaps. Paging is a row id range scan, which is the cheapest access path SQLite has, and
* random access by list index is free. This is why the finished table carries no sort key and needs
* no index to browse.
*
* **Every query must be satisfiable in row id order.** This build of SQLCipher is compiled with
* `SQLITE_TEMP_STORE=2`, so a sort SQLite cannot answer from an index is materialized in memory
* instead of on disk. An `ORDER BY` other than `_id` silently reintroduces the memory cost this
* whole design exists to avoid, and it will not fail visibly until it is an out of memory on a
* device with a very large address book.
*/
class ContactIndexDatabase private constructor(
private val application: Application,
private val indexFileName: String,
databaseSecret: DatabaseSecret
) : SQLiteOpenHelper(
application,
indexFileName,
databaseSecret.asString(),
null,
DATABASE_VERSION,
0,
SqlCipherDeletingErrorHandler(indexFileName),
SqlCipherDatabaseHook(),
true
) {
companion object {
private val TAG = Log.tag(ContactIndexDatabase::class.java)
private const val FILE_PREFIX = "signal-contact-index-"
private const val FILE_SUFFIX = ".db"
private const val DATABASE_VERSION = 1
private const val KEY_SIZE = 32
const val TABLE_NAME = "contact_index"
private const val STAGING_TABLE_NAME = "contact_index_staging"
const val ID = "_id"
const val TYPE = "type"
const val SECTION = "section"
const val DISPLAY_NAME = "display_name"
/** Every name we know for the row, folded and space delimited. The only column search reads. */
const val SEARCH_TEXT = "search_text"
const val RECIPIENT_ID = "recipient_id"
const val LOOKUP_KEY = "lookup_key"
const val CONTACT_ID = "contact_id"
/** Drives the fallback glyph: a company or bare phone number has no initials worth showing. */
const val HAS_PERSONAL_NAME = "has_personal_name"
const val HAS_PHOTO = "has_photo"
/** Ordering columns, present only while staging. */
private const val SORT_KEY = "sort_key"
/** Sorts ahead of [SORT_KEY] so that "#" cannot land among the letters and split a section. */
private const val SORT_RANK = "sort_rank"
private val INSERT_COLUMNS = arrayOf(SORT_RANK, SORT_KEY, TYPE, SECTION, DISPLAY_NAME, SEARCH_TEXT, RECIPIENT_ID, LOOKUP_KEY, CONTACT_ID, HAS_PERSONAL_NAME, HAS_PHOTO)
private val COPY_COLUMNS = listOf(TYPE, SECTION, DISPLAY_NAME, SEARCH_TEXT, RECIPIENT_ID, LOOKUP_KEY, CONTACT_ID, HAS_PERSONAL_NAME, HAS_PHOTO)
private val ROW_COLUMNS = arrayOf(ID, TYPE, SECTION, DISPLAY_NAME, RECIPIENT_ID, LOOKUP_KEY, CONTACT_ID, HAS_PERSONAL_NAME, HAS_PHOTO)
private val CREATE_TABLE = """
CREATE TABLE $TABLE_NAME (
$ID INTEGER PRIMARY KEY,
$TYPE INTEGER NOT NULL,
$SECTION TEXT NOT NULL,
$DISPLAY_NAME TEXT NOT NULL,
$SEARCH_TEXT TEXT NOT NULL,
$RECIPIENT_ID INTEGER DEFAULT NULL,
$LOOKUP_KEY TEXT DEFAULT NULL,
$CONTACT_ID INTEGER DEFAULT NULL,
$HAS_PERSONAL_NAME INTEGER NOT NULL DEFAULT 1,
$HAS_PHOTO INTEGER NOT NULL DEFAULT 0
)
"""
private val CREATE_STAGING_TABLE = """
CREATE TABLE $STAGING_TABLE_NAME (
$SORT_RANK INTEGER NOT NULL,
$SORT_KEY BLOB NOT NULL,
$TYPE INTEGER NOT NULL,
$SECTION TEXT NOT NULL,
$DISPLAY_NAME TEXT NOT NULL,
$SEARCH_TEXT TEXT NOT NULL,
$RECIPIENT_ID INTEGER DEFAULT NULL,
$LOOKUP_KEY TEXT DEFAULT NULL,
$CONTACT_ID INTEGER DEFAULT NULL,
$HAS_PERSONAL_NAME INTEGER NOT NULL DEFAULT 1,
$HAS_PHOTO INTEGER NOT NULL DEFAULT 0
)
"""
/**
* Identifies this run of the app. An index file that does not carry it was left behind by a
* process that is gone, so it is safe to delete.
*/
private val RUN_ID = System.currentTimeMillis().toString()
private val instanceCounter = AtomicLong()
/** Opens a fresh index under a name no other instance can hold. */
fun create(application: Application): ContactIndexDatabase {
SqlCipherLibraryLoader.load()
val name = "$FILE_PREFIX$RUN_ID-${instanceCounter.incrementAndGet()}$FILE_SUFFIX"
return ContactIndexDatabase(application, name, DatabaseSecret(Util.getSecretBytes(KEY_SIZE)))
}
private fun deleteDatabaseFile(application: Application, databaseName: String) {
if (application.deleteDatabase(databaseName)) {
Log.i(TAG, "Deleted contact index $databaseName.")
}
}
/**
* Deletes indexes left behind by earlier runs.
*
* Only ever touches names from another run, and [closeAndDelete] only ever touches the name
* from this one, so the two cannot race over a file no matter when app start schedules this.
*/
fun deleteAbandonedFiles(application: Application) {
val directory = application.getDatabasePath("$FILE_PREFIX$FILE_SUFFIX").parentFile
if (directory == null) {
Log.w(TAG, "No database directory to scan.")
return
}
directory
.listFiles { file -> file.name.startsWith(FILE_PREFIX) && file.name.endsWith(FILE_SUFFIX) }
.orEmpty()
.map { it.name }
.filterNot { it.startsWith("$FILE_PREFIX$RUN_ID-") }
.forEach { deleteDatabaseFile(application, it) }
}
}
/** Closes the index and removes it from disk. */
fun closeAndDelete() {
close()
deleteDatabaseFile(application, indexFileName)
}
override fun onCreate(db: SQLiteDatabase) {
Log.i(TAG, "onCreate()")
db.execSQL(CREATE_TABLE)
}
/**
* Unreachable in practice. The key is per process, so a file from a previous process cannot be
* opened at all, let alone upgraded. Recreating rather than throwing keeps a wrong assumption here
* from taking down the app.
*/
override fun onUpgrade(db: SQLiteDatabase?, oldVersion: Int, newVersion: Int) {
Log.w(TAG, "onUpgrade($oldVersion, $newVersion) on a disposable index. Recreating.")
db?.execSQL("DROP TABLE IF EXISTS $TABLE_NAME")
db?.execSQL(CREATE_TABLE)
}
private val database: SupportSQLiteDatabase
get() = writableDatabase
/** Runs a whole build in one transaction, returning how many rows the finished index holds. */
fun withinBuild(block: () -> Unit): Int {
return database.withinTransaction {
beginBuild()
block()
endBuild()
}
}
private fun beginBuild() {
database.withinTransaction { db ->
db.execSQL("DROP TABLE IF EXISTS $STAGING_TABLE_NAME")
db.execSQL("DELETE FROM $TABLE_NAME")
db.execSQL(CREATE_STAGING_TABLE)
}
}
/**
* Bulk inserts a batch of rows into staging. Unordered and unindexed on purpose, so that inserting
* pays for no tree maintenance.
*/
fun insert(entries: List<ContactIndexEntry>) {
if (entries.isEmpty()) {
return
}
val values = entries.map { entry ->
ContentValues(INSERT_COLUMNS.size).apply {
// Everything that is not a letter sorts after everything that is, matching the design's
// trailing "#" section.
put(SORT_RANK, if (entry.section == ContactDisplayName.SECTION_OTHER) 1 else 0)
put(SORT_KEY, entry.sortKey)
put(TYPE, entry.type.id)
put(SECTION, entry.section)
put(DISPLAY_NAME, entry.displayName)
put(SEARCH_TEXT, entry.searchText)
put(RECIPIENT_ID, entry.recipientId?.toLong())
put(LOOKUP_KEY, entry.lookupKey)
put(CONTACT_ID, entry.contactId)
put(HAS_PERSONAL_NAME, entry.hasPersonalName.toInt())
put(HAS_PHOTO, entry.hasPhoto.toInt())
}
}
database.withinTransaction { db ->
SqlUtil.buildBulkInsert(STAGING_TABLE_NAME, INSERT_COLUMNS, values).forEach {
db.execSQL(it.where, it.whereArgs)
}
}
}
/**
* Sorts staging into the finished table, assigning row ids in display order, and drops staging.
*
* Ordered by section rank first, so sections stay contiguous, then by collation key, then by
* display name as a tie break so names that collate equally, differing only by case or accent, land
* in a deterministic order rather than an arbitrary one.
*/
private fun endBuild(): Int {
return database.withinTransaction { db ->
db.execSQL(
"""
INSERT INTO $TABLE_NAME ($ID, ${COPY_COLUMNS.joinToString(", ")})
SELECT row_number() OVER (ORDER BY $SORT_RANK, $SORT_KEY, $DISPLAY_NAME), ${COPY_COLUMNS.joinToString(", ")}
FROM $STAGING_TABLE_NAME
"""
)
db.execSQL("DROP TABLE $STAGING_TABLE_NAME")
count()
}
}
fun count(): Int {
return database.getTableRowCount(TABLE_NAME)
}
/**
* A window of the whole list, starting at [startPosition]. Because row ids are contiguous, the
* start position doubles as a list index and as a row id, so this is a range scan rather than an
* offset walk.
*/
fun getPage(startPosition: Long, limit: Int): List<ContactIndexRecord> {
return database
.query("SELECT ${ROW_COLUMNS.joinToString(", ")} FROM $TABLE_NAME WHERE $ID >= ? ORDER BY $ID LIMIT ?", arrayOf<Any>(startPosition, limit))
.readToList { cursor -> cursor.toRecord() }
.filterNotNull()
}
/**
* A window of the rows matching [query], in the same order as the full list.
*
* [startPosition] is a keyset cursor, not an offset, so page by passing the last row's position
* plus one rather than a count of rows already shown.
*/
fun search(query: String, startPosition: Long, limit: Int): List<ContactIndexRecord> {
return database
.query(
"""
SELECT ${ROW_COLUMNS.joinToString(", ")} FROM $TABLE_NAME
WHERE $SEARCH_TEXT LIKE ? ESCAPE '${ContactDisplayName.LIKE_ESCAPE}' AND $ID >= ?
ORDER BY $ID LIMIT ?
""",
arrayOf<Any>(ContactDisplayName.searchPatternFor(query), startPosition, limit)
)
.readToList { cursor -> cursor.toRecord() }
.filterNotNull()
}
private fun Cursor.toRecord(): ContactIndexRecord? {
val type = ContactIndexType.fromId(requireInt(TYPE))
if (type == null) {
Log.w(TAG, "Unknown contact index type. Skipping the row.")
return null
}
return ContactIndexRecord(
position = requireLong(ID),
type = type,
section = requireNonNullString(SECTION),
displayName = requireNonNullString(DISPLAY_NAME),
recipientId = requireLongOrNull(RECIPIENT_ID)?.let { RecipientId.from(it) },
lookupKey = requireString(LOOKUP_KEY),
contactId = requireLongOrNull(CONTACT_ID),
hasPersonalName = requireBoolean(HAS_PERSONAL_NAME),
hasPhoto = requireBoolean(HAS_PHOTO)
)
}
}
@@ -0,0 +1,89 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.contacts.index
import org.thoughtcrime.securesms.recipients.RecipientId
/**
* Which sources a row came from, which is what decides the badges and actions the row offers.
*/
enum class ContactIndexType(val id: Int) {
/** In the address book, not a registered Signal user. Can be invited. */
SYSTEM_ONLY(0),
/** A Signal recipient with no address book entry. Has no contact detail to share beyond a name. */
SIGNAL_ONLY(1),
/** In the address book and on Signal. */
BOTH(2);
companion object {
fun fromId(id: Int): ContactIndexType? = entries.firstOrNull { it.id == id }
}
}
/**
* A row on its way into the index. Carries the sort key, which the finished table does not keep.
*/
class ContactIndexEntry(
val sortKey: ByteArray,
val type: ContactIndexType,
val section: String,
val displayName: String,
val searchText: String,
val recipientId: RecipientId? = null,
val lookupKey: String? = null,
val contactId: Long? = null,
/**
* Whether [displayName] is a personal name. False when the provider had to fall back to a company,
* email, or phone number, which is what sends a row to the "#" section and makes it render a person
* glyph rather than initials.
*/
val hasPersonalName: Boolean = true,
val hasPhoto: Boolean = false
)
/** A row read back out of the index. Details are fetched from the provider once a row is selected. */
data class ContactIndexRecord(
/** Position in the full list, 1 based, which is also the row id. */
val position: Long,
val type: ContactIndexType,
val section: String,
val displayName: String,
val recipientId: RecipientId?,
val lookupKey: String?,
val contactId: Long?,
val hasPersonalName: Boolean,
val hasPhoto: Boolean
) {
val isOnSignal: Boolean
get() = type == ContactIndexType.SIGNAL_ONLY || type == ContactIndexType.BOTH
val isInAddressBook: Boolean
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, hasPhoto=$hasPhoto)"
}
}
/**
* Outcome of building the index.
*/
sealed interface ContactIndexBuildResult {
data class Success(val count: Int) : ContactIndexBuildResult
/**
* Built from Signal recipients alone. Not a failure, since the design shows Signal connections
* whether or not we can read the address book.
*/
data class SignalOnly(val count: Int) : ContactIndexBuildResult
/** Not enough free space to hold the index, so there is nothing to show. */
data object OutOfSpace : ContactIndexBuildResult
data class Failure(val cause: Throwable) : ContactIndexBuildResult
}
@@ -0,0 +1,109 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.contacts.index
import android.app.Application
import androidx.annotation.WorkerThread
import org.signal.core.util.logging.Log
import java.io.Closeable
/**
* Owns the lifetime of one contact index.
*
* Belongs to the view model of whatever shows the list rather than to the activity, so that it
* survives the activity being recreated, and is [close]d when the screen itself goes away. Closing
* deletes the index, and because the encryption key only ever existed in this object, anything left
* on disk after an abrupt process death is undecryptable rather than merely deleted.
*/
class ContactIndexRepository(private val application: Application) : Closeable {
companion object {
private val TAG = Log.tag(ContactIndexRepository::class.java)
/** Reclaims space from an index abandoned by a process that died before it could clean up. */
@JvmStatic
fun deleteAbandonedIndex(application: Application) {
ContactIndexDatabase.deleteAbandonedFiles(application)
}
}
private var database: ContactIndexDatabase? = null
@Synchronized
@WorkerThread
fun build(): ContactIndexBuildResult {
close()
val start = System.currentTimeMillis()
val created = ContactIndexDatabase.create(application)
val result = try {
ContactIndexBuilder(application).build(created)
} catch (t: Throwable) {
created.closeAndDelete()
throw t
}
Log.i(TAG, "Index build took ${System.currentTimeMillis() - start} ms.")
when (result) {
is ContactIndexBuildResult.Success, is ContactIndexBuildResult.SignalOnly -> {
database = created
}
else -> {
created.closeAndDelete()
}
}
return result
}
@Synchronized
@WorkerThread
fun getPage(startPosition: Long, limit: Int): List<ContactIndexRecord> {
val db = database ?: return emptyList()
return timed("page", startPosition, limit) { db.getPage(startPosition, limit) }
}
@Synchronized
@WorkerThread
fun search(query: String, startPosition: Long, limit: Int): List<ContactIndexRecord> {
val db = database ?: return emptyList()
return if (query.isBlank()) {
timed("page", startPosition, limit) { db.getPage(startPosition, limit) }
} else {
timed("search(len=${query.length})", startPosition, limit) { db.search(query, startPosition, limit) }
}
}
@Synchronized
@WorkerThread
fun count(): Int {
return database?.count() ?: 0
}
private fun timed(label: String, startPosition: Long, limit: Int, query: () -> List<ContactIndexRecord>): List<ContactIndexRecord> {
val start = System.currentTimeMillis()
val rows = query()
val duration = System.currentTimeMillis() - start
Log.d(TAG, "$label from=$startPosition limit=$limit rows=${rows.size} took=${duration}ms")
return rows
}
@Synchronized
override fun close() {
database?.let {
it.closeAndDelete()
Log.i(TAG, "Closed and deleted the contact index.")
}
database = null
}
}
@@ -1,674 +0,0 @@
package org.thoughtcrime.securesms.contactshare;
import android.net.Uri;
import android.os.Parcel;
import android.os.Parcelable;
import android.text.TextUtils;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;
import org.thoughtcrime.securesms.attachments.Attachment;
import org.signal.core.models.database.AttachmentId;
import org.thoughtcrime.securesms.attachments.UriAttachment;
import org.thoughtcrime.securesms.database.AttachmentTable;
import org.signal.core.util.JsonUtils;
import org.thoughtcrime.securesms.util.MediaUtil;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
public class Contact implements Parcelable {
@JsonProperty
private final Name name;
@JsonProperty
private final String organization;
@JsonProperty
private final List<Phone> phoneNumbers;
@JsonProperty
private final List<Email> emails;
@JsonProperty
private final List<PostalAddress> postalAddresses;
@JsonProperty
private final Avatar avatar;
public Contact(@JsonProperty("name") @Nullable Name name,
@JsonProperty("organization") @Nullable String organization,
@JsonProperty("phoneNumbers") @NonNull List<Phone> phoneNumbers,
@JsonProperty("emails") @NonNull List<Email> emails,
@JsonProperty("postalAddresses") @NonNull List<PostalAddress> postalAddresses,
@JsonProperty("avatar") @Nullable Avatar avatar)
{
this.name = name;
this.organization = organization;
this.phoneNumbers = new ArrayList<>(phoneNumbers.size());
this.emails = new ArrayList<>(emails.size());
this.postalAddresses = new ArrayList<>(postalAddresses.size());
this.avatar = avatar;
this.phoneNumbers.addAll(phoneNumbers);
this.emails.addAll(emails);
this.postalAddresses.addAll(postalAddresses);
}
public Contact(@NonNull Contact contact, @Nullable Avatar avatar) {
this(contact.getName(),
contact.getOrganization(),
contact.getPhoneNumbers(),
contact.getEmails(),
contact.getPostalAddresses(),
avatar);
}
private Contact(Parcel in) {
this(in.readParcelable(Name.class.getClassLoader()),
in.readString(),
in.createTypedArrayList(Phone.CREATOR),
in.createTypedArrayList(Email.CREATOR),
in.createTypedArrayList(PostalAddress.CREATOR),
in.readParcelable(Avatar.class.getClassLoader()));
}
public @NonNull Name getName() {
return name == null ? Name.EMPTY_NAME : name;
}
public @Nullable String getOrganization() {
return organization;
}
public @NonNull List<Phone> getPhoneNumbers() {
return phoneNumbers;
}
public @NonNull List<Email> getEmails() {
return emails;
}
public @NonNull List<PostalAddress> getPostalAddresses() {
return postalAddresses;
}
public @Nullable Avatar getAvatar() {
return avatar;
}
@JsonIgnore
public @Nullable Attachment getAvatarAttachment() {
return avatar != null ? avatar.getAttachment() : null;
}
public String serialize() throws IOException {
return JsonUtils.toJson(this);
}
public static Contact deserialize(@NonNull String serialized) throws IOException {
return JsonUtils.fromJson(serialized, Contact.class);
}
@Override
public int describeContents() {
return 0;
}
@Override
public void writeToParcel(Parcel dest, int flags) {
dest.writeParcelable(name, flags);
dest.writeString(organization);
dest.writeTypedList(phoneNumbers);
dest.writeTypedList(emails);
dest.writeTypedList(postalAddresses);
dest.writeParcelable(avatar, flags);
}
public static final Creator<Contact> CREATOR = new Creator<Contact>() {
@Override
public Contact createFromParcel(Parcel in) {
return new Contact(in);
}
@Override
public Contact[] newArray(int size) {
return new Contact[size];
}
};
public static class Name implements Parcelable {
@JsonProperty
private final String givenName;
@JsonProperty
private final String familyName;
@JsonProperty
private final String prefix;
@JsonProperty
private final String suffix;
@JsonProperty
private final String middleName;
@JsonProperty
private final String nickname;
public Name(
@JsonProperty("givenName") @Nullable String givenName,
@JsonProperty("familyName") @Nullable String familyName,
@JsonProperty("prefix") @Nullable String prefix,
@JsonProperty("suffix") @Nullable String suffix,
@JsonProperty("middleName") @Nullable String middleName,
@JsonProperty("nickname") @Nullable String nickname)
{
this.givenName = givenName;
this.familyName = familyName;
this.prefix = prefix;
this.suffix = suffix;
this.middleName = middleName;
this.nickname = nickname;
}
private Name(Parcel in) {
this(in.readString(), in.readString(), in.readString(), in.readString(), in.readString(), in.readString());
}
public @Nullable String getGivenName() {
return givenName;
}
public @Nullable String getFamilyName() {
return familyName;
}
public @Nullable String getPrefix() {
return prefix;
}
public @Nullable String getSuffix() {
return suffix;
}
public @Nullable String getMiddleName() {
return middleName;
}
public @Nullable String getNickname() {
return nickname;
}
public boolean isEmpty() {
return TextUtils.isEmpty(nickname) &&
TextUtils.isEmpty(givenName) &&
TextUtils.isEmpty(familyName) &&
TextUtils.isEmpty(prefix) &&
TextUtils.isEmpty(suffix) &&
TextUtils.isEmpty(middleName);
}
@Override
public int describeContents() {
return 0;
}
@Override
public void writeToParcel(Parcel dest, int flags) {
dest.writeString(givenName);
dest.writeString(familyName);
dest.writeString(prefix);
dest.writeString(suffix);
dest.writeString(middleName);
dest.writeString(nickname);
}
public static Name EMPTY_NAME = new Name("","","","","","");
public static final Creator<Name> CREATOR = new Creator<Name>() {
@Override
public Name createFromParcel(Parcel in) {
return new Name(in);
}
@Override
public Name[] newArray(int size) {
return new Name[size];
}
};
}
public static class Phone implements Selectable, Parcelable {
@JsonProperty
private final String number;
@JsonProperty
private final Type type;
@JsonProperty
private final String label;
@JsonIgnore
private boolean selected;
public Phone(@JsonProperty("number") @NonNull String number,
@JsonProperty("type") @NonNull Type type,
@JsonProperty("label") @Nullable String label)
{
this.number = number;
this.type = type;
this.label = label;
this.selected = true;
}
private Phone(Parcel in) {
this(in.readString(), Type.valueOf(in.readString()), in.readString());
}
public @NonNull String getNumber() {
return number;
}
public @NonNull Type getType() {
return type;
}
public @Nullable String getLabel() {
return label;
}
@Override
public void setSelected(boolean selected) {
this.selected = selected;
}
@Override
public boolean isSelected() {
return selected;
}
@Override
public int describeContents() {
return 0;
}
@Override
public void writeToParcel(Parcel dest, int flags) {
dest.writeString(number);
dest.writeString(type.name());
dest.writeString(label);
}
public static final Creator<Phone> CREATOR = new Creator<Phone>() {
@Override
public Phone createFromParcel(Parcel in) {
return new Phone(in);
}
@Override
public Phone[] newArray(int size) {
return new Phone[size];
}
};
public enum Type {
HOME, MOBILE, WORK, CUSTOM
}
}
public static class Email implements Selectable, Parcelable {
@JsonProperty
private final String email;
@JsonProperty
private final Type type;
@JsonProperty
private final String label;
@JsonIgnore
private boolean selected;
public Email(@JsonProperty("email") @NonNull String email,
@JsonProperty("type") @NonNull Type type,
@JsonProperty("label") @Nullable String label)
{
this.email = email;
this.type = type;
this.label = label;
this.selected = true;
}
private Email(Parcel in) {
this(in.readString(), Type.valueOf(in.readString()), in.readString());
}
public @NonNull String getEmail() {
return email;
}
public @NonNull Type getType() {
return type;
}
public @Nullable String getLabel() {
return label;
}
@Override
public void setSelected(boolean selected) {
this.selected = selected;
}
@Override
public boolean isSelected() {
return selected;
}
@Override
public int describeContents() {
return 0;
}
@Override
public void writeToParcel(Parcel dest, int flags) {
dest.writeString(email);
dest.writeString(type.name());
dest.writeString(label);
}
public static final Creator<Email> CREATOR = new Creator<Email>() {
@Override
public Email createFromParcel(Parcel in) {
return new Email(in);
}
@Override
public Email[] newArray(int size) {
return new Email[size];
}
};
public enum Type {
HOME, MOBILE, WORK, CUSTOM
}
}
public static class PostalAddress implements Selectable, Parcelable {
@JsonProperty
private final Type type;
@JsonProperty
private final String label;
@JsonProperty
private final String street;
@JsonProperty
private final String poBox;
@JsonProperty
private final String neighborhood;
@JsonProperty
private final String city;
@JsonProperty
private final String region;
@JsonProperty
private final String postalCode;
@JsonProperty
private final String country;
@JsonIgnore
private boolean selected;
public PostalAddress(@JsonProperty("type") @NonNull Type type,
@JsonProperty("label") @Nullable String label,
@JsonProperty("street") @Nullable String street,
@JsonProperty("poBox") @Nullable String poBox,
@JsonProperty("neighborhood") @Nullable String neighborhood,
@JsonProperty("city") @Nullable String city,
@JsonProperty("region") @Nullable String region,
@JsonProperty("postalCode") @Nullable String postalCode,
@JsonProperty("country") @Nullable String country)
{
this.type = type;
this.label = label;
this.street = street;
this.poBox = poBox;
this.neighborhood = neighborhood;
this.city = city;
this.region = region;
this.postalCode = postalCode;
this.country = country;
this.selected = true;
}
private PostalAddress(Parcel in) {
this(Type.valueOf(in.readString()),
in.readString(),
in.readString(),
in.readString(),
in.readString(),
in.readString(),
in.readString(),
in.readString(),
in.readString());
}
public @NonNull Type getType() {
return type;
}
public @Nullable String getLabel() {
return label;
}
public @Nullable String getStreet() {
return street;
}
public @Nullable String getPoBox() {
return poBox;
}
public @Nullable String getNeighborhood() {
return neighborhood;
}
public @Nullable String getCity() {
return city;
}
public @Nullable String getRegion() {
return region;
}
public @Nullable String getPostalCode() {
return postalCode;
}
public @Nullable String getCountry() {
return country;
}
@Override
public void setSelected(boolean selected) {
this.selected = selected;
}
@Override
public boolean isSelected() {
return selected;
}
@Override
public int describeContents() {
return 0;
}
@Override
public void writeToParcel(Parcel dest, int flags) {
dest.writeString(type.name());
dest.writeString(label);
dest.writeString(street);
dest.writeString(poBox);
dest.writeString(neighborhood);
dest.writeString(city);
dest.writeString(region);
dest.writeString(postalCode);
dest.writeString(country);
}
public static final Creator<PostalAddress> CREATOR = new Creator<PostalAddress>() {
@Override
public PostalAddress createFromParcel(Parcel in) {
return new PostalAddress(in);
}
@Override
public PostalAddress[] newArray(int size) {
return new PostalAddress[size];
}
};
@Override
public @NonNull String toString() {
StringBuilder builder = new StringBuilder();
if (!TextUtils.isEmpty(street)) {
builder.append(street).append('\n');
}
if (!TextUtils.isEmpty(poBox)) {
builder.append(poBox).append('\n');
}
if (!TextUtils.isEmpty(neighborhood)) {
builder.append(neighborhood).append('\n');
}
if (!TextUtils.isEmpty(city) && !TextUtils.isEmpty(region)) {
builder.append(city).append(", ").append(region);
} else if (!TextUtils.isEmpty(city)) {
builder.append(city).append(' ');
} else if (!TextUtils.isEmpty(region)) {
builder.append(region).append(' ');
}
if (!TextUtils.isEmpty(postalCode)) {
builder.append(postalCode);
}
if (!TextUtils.isEmpty(country)) {
builder.append('\n').append(country);
}
return builder.toString().trim();
}
public enum Type {
HOME, WORK, CUSTOM
}
}
public static class Avatar implements Selectable, Parcelable {
@JsonProperty
private final AttachmentId attachmentId;
@JsonProperty
private final boolean isProfile;
@JsonIgnore
private final Attachment attachment;
@JsonIgnore
private boolean selected;
public Avatar(@Nullable AttachmentId attachmentId, @Nullable Attachment attachment, boolean isProfile) {
this.attachmentId = attachmentId;
this.attachment = attachment;
this.isProfile = isProfile;
this.selected = true;
}
public Avatar(@Nullable Uri attachmentUri, boolean isProfile) {
this(null, attachmentFromUri(attachmentUri), isProfile);
}
@JsonCreator
private Avatar(@JsonProperty("attachmentId") @Nullable AttachmentId attachmentId, @JsonProperty("isProfile") boolean isProfile) {
this(attachmentId, null, isProfile);
}
private Avatar(Parcel in) {
this((Uri) in.readParcelable(Uri.class.getClassLoader()), in.readByte() != 0);
}
public @Nullable AttachmentId getAttachmentId() {
return attachmentId;
}
public @Nullable Attachment getAttachment() {
return attachment;
}
public boolean isProfile() {
return isProfile;
}
@Override
public void setSelected(boolean selected) {
this.selected = selected;
}
@Override
public boolean isSelected() {
return selected;
}
@Override
public int describeContents() {
return 0;
}
private static Attachment attachmentFromUri(@Nullable Uri uri) {
if (uri == null) return null;
return new UriAttachment(uri, MediaUtil.IMAGE_JPEG, AttachmentTable.TRANSFER_PROGRESS_DONE, 0, null, false, false, false, false, null, null, null, null, null, null);
}
@Override
public void writeToParcel(Parcel dest, int flags) {
dest.writeParcelable(attachment != null ? attachment.getUri() : null, flags);
dest.writeByte((byte) (isProfile ? 1 : 0));
}
public static final Creator<Avatar> CREATOR = new Creator<Avatar>() {
@Override
public Avatar createFromParcel(Parcel in) {
return new Avatar(in);
}
@Override
public Avatar[] newArray(int size) {
return new Avatar[size];
}
};
}
}
@@ -0,0 +1,330 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.contactshare
import android.net.Uri
import android.os.Parcel
import android.os.Parcelable
import com.fasterxml.jackson.annotation.JsonCreator
import com.fasterxml.jackson.annotation.JsonIgnore
import com.fasterxml.jackson.annotation.JsonProperty
import kotlinx.parcelize.Parcelize
import org.signal.core.models.database.AttachmentId
import org.signal.core.util.JsonUtils
import org.signal.core.util.readParcelableCompat
import org.thoughtcrime.securesms.attachments.Attachment
import org.thoughtcrime.securesms.attachments.UriAttachment
import org.thoughtcrime.securesms.database.AttachmentTable
import org.thoughtcrime.securesms.util.MediaUtil
import java.io.IOException
/**
* A contact card, either one being shared or one that was received.
*
* Persisted as JSON in [org.thoughtcrime.securesms.database.MessageTable.SHARED_CONTACTS], so the
* Jackson property names are a storage format and cannot be renamed. Unknown properties are ignored
* on read (see [JsonUtils]), which is what lets new fields be added without a migration.
*/
class Contact @JsonCreator constructor(
@JsonProperty("name") name: Name?,
@JsonProperty("organization") val organization: String?,
@JsonProperty("phoneNumbers") phoneNumbers: List<Phone>,
@JsonProperty("emails") emails: List<Email>,
@JsonProperty("postalAddresses") postalAddresses: List<PostalAddress>,
@JsonProperty("avatar") val avatar: Avatar?,
/**
* The ACI of the person on the card, when the sharer knew they were on Signal. A canonical UUID
* string rather than an [org.signal.core.models.ServiceId.ACI] so that it survives JSON without a
* converter; the mappers parse it at the wire boundary and drop it if it is not valid.
*/
@JsonProperty("aci") val aci: String? = null,
/** The sharer's own Signal nickname for this contact, which is not the vcard nickname on [Name]. */
@JsonProperty("nickname") val nickname: SignalNickname? = null,
/** The sharer's own Signal note about this contact. */
@JsonProperty("note") val note: String? = null
) : Parcelable {
@get:JsonProperty("name")
val name: Name = name ?: Name.EMPTY_NAME
@get:JsonProperty("phoneNumbers")
val phoneNumbers: List<Phone> = phoneNumbers.toList()
@get:JsonProperty("emails")
val emails: List<Email> = emails.toList()
@get:JsonProperty("postalAddresses")
val postalAddresses: List<PostalAddress> = postalAddresses.toList()
/** Replaces the avatar, keeping everything else. Used when an attachment id becomes known. */
constructor(contact: Contact, avatar: Avatar?) : this(
contact.name,
contact.organization,
contact.phoneNumbers,
contact.emails,
contact.postalAddresses,
avatar,
contact.aci,
contact.nickname,
contact.note
)
private constructor(parcel: Parcel) : this(
parcel.readParcelableCompat(Name::class.java),
parcel.readString(),
parcel.readParcelableListCompat(Phone::class.java),
parcel.readParcelableListCompat(Email::class.java),
parcel.readParcelableListCompat(PostalAddress::class.java),
parcel.readParcelableCompat(Avatar::class.java),
parcel.readString(),
parcel.readParcelableCompat(SignalNickname::class.java),
parcel.readString()
)
@get:JsonIgnore
val avatarAttachment: Attachment?
get() = avatar?.attachment
@Throws(IOException::class)
fun serialize(): String = JsonUtils.toJson(this)
override fun describeContents(): Int = 0
override fun writeToParcel(dest: Parcel, flags: Int) {
dest.writeParcelable(name, flags)
dest.writeString(organization)
dest.writeParcelableListCompat(phoneNumbers, flags)
dest.writeParcelableListCompat(emails, flags)
dest.writeParcelableListCompat(postalAddresses, flags)
dest.writeParcelable(avatar, flags)
dest.writeString(aci)
dest.writeParcelable(nickname, flags)
dest.writeString(note)
}
companion object {
@JvmField
val CREATOR: Parcelable.Creator<Contact> = object : Parcelable.Creator<Contact> {
override fun createFromParcel(parcel: Parcel): Contact = Contact(parcel)
override fun newArray(size: Int): Array<Contact?> = arrayOfNulls(size)
}
@JvmStatic
@Throws(IOException::class)
fun deserialize(serialized: String): Contact = JsonUtils.fromJson(serialized, Contact::class.java)
}
@Parcelize
class Name @JsonCreator constructor(
@JsonProperty("givenName") val givenName: String?,
@JsonProperty("familyName") val familyName: String?,
@JsonProperty("prefix") val prefix: String?,
@JsonProperty("suffix") val suffix: String?,
@JsonProperty("middleName") val middleName: String?,
@JsonProperty("nickname") val nickname: String?
) : Parcelable {
@get:JsonIgnore
val isEmpty: Boolean
get() = givenName.isNullOrEmpty() &&
familyName.isNullOrEmpty() &&
prefix.isNullOrEmpty() &&
suffix.isNullOrEmpty() &&
middleName.isNullOrEmpty() &&
nickname.isNullOrEmpty()
companion object {
@JvmField
val EMPTY_NAME = Name("", "", "", "", "", "")
}
}
/**
* The sharer's Signal nickname for the contact. Absent when both halves are empty, since a
* nickname with nothing in it says nothing.
*/
@Parcelize
class SignalNickname @JsonCreator constructor(
@JsonProperty("given") val given: String?,
@JsonProperty("family") val family: String?
) : Parcelable {
@get:JsonIgnore
val isEmpty: Boolean
get() = given.isNullOrEmpty() && family.isNullOrEmpty()
}
@Parcelize
class Phone @JsonCreator constructor(
@JsonProperty("number") val number: String,
@JsonProperty("type") val type: Type,
@JsonProperty("label") val label: String?
) : Selectable, Parcelable {
@JsonIgnore
private var selected = true
@JsonIgnore
override fun isSelected(): Boolean = selected
override fun setSelected(selected: Boolean) {
this.selected = selected
}
enum class Type {
HOME, MOBILE, WORK, CUSTOM
}
}
@Parcelize
class Email @JsonCreator constructor(
@JsonProperty("email") val email: String,
@JsonProperty("type") val type: Type,
@JsonProperty("label") val label: String?
) : Selectable, Parcelable {
@JsonIgnore
private var selected = true
@JsonIgnore
override fun isSelected(): Boolean = selected
override fun setSelected(selected: Boolean) {
this.selected = selected
}
enum class Type {
HOME, MOBILE, WORK, CUSTOM
}
}
@Parcelize
class PostalAddress @JsonCreator constructor(
@JsonProperty("type") val type: Type,
@JsonProperty("label") val label: String?,
@JsonProperty("street") val street: String?,
@JsonProperty("poBox") val poBox: String?,
@JsonProperty("neighborhood") val neighborhood: String?,
@JsonProperty("city") val city: String?,
@JsonProperty("region") val region: String?,
@JsonProperty("postalCode") val postalCode: String?,
@JsonProperty("country") val country: String?
) : Selectable, Parcelable {
@JsonIgnore
private var selected = true
@JsonIgnore
override fun isSelected(): Boolean = selected
override fun setSelected(selected: Boolean) {
this.selected = selected
}
override fun toString(): String {
val builder = StringBuilder()
if (!street.isNullOrEmpty()) {
builder.append(street).append('\n')
}
if (!poBox.isNullOrEmpty()) {
builder.append(poBox).append('\n')
}
if (!neighborhood.isNullOrEmpty()) {
builder.append(neighborhood).append('\n')
}
if (!city.isNullOrEmpty() && !region.isNullOrEmpty()) {
builder.append(city).append(", ").append(region)
} else if (!city.isNullOrEmpty()) {
builder.append(city).append(' ')
} else if (!region.isNullOrEmpty()) {
builder.append(region).append(' ')
}
if (!postalCode.isNullOrEmpty()) {
builder.append(postalCode)
}
if (!country.isNullOrEmpty()) {
builder.append('\n').append(country)
}
return builder.toString().trim()
}
enum class Type {
HOME, WORK, CUSTOM
}
}
class Avatar(
@get:JsonProperty("attachmentId") val attachmentId: AttachmentId?,
@get:JsonIgnore val attachment: Attachment?,
@get:JsonProperty("isProfile") val isProfile: Boolean
) : Selectable, Parcelable {
constructor(attachmentUri: Uri?, isProfile: Boolean) : this(null, attachmentFromUri(attachmentUri), isProfile)
@JsonCreator
private constructor(
@JsonProperty("attachmentId") attachmentId: AttachmentId?,
@JsonProperty("isProfile") isProfile: Boolean
) : this(attachmentId, null, isProfile)
private constructor(parcel: Parcel) : this(parcel.readParcelableCompat(Uri::class.java), parcel.readByte() != 0.toByte())
@JsonIgnore
private var selected = true
@JsonIgnore
override fun isSelected(): Boolean = selected
override fun setSelected(selected: Boolean) {
this.selected = selected
}
override fun describeContents(): Int = 0
override fun writeToParcel(dest: Parcel, flags: Int) {
dest.writeParcelable(attachment?.uri, flags)
dest.writeByte(if (isProfile) 1 else 0)
}
companion object {
@JvmField
val CREATOR: Parcelable.Creator<Avatar> = object : Parcelable.Creator<Avatar> {
override fun createFromParcel(parcel: Parcel): Avatar = Avatar(parcel)
override fun newArray(size: Int): Array<Avatar?> = arrayOfNulls(size)
}
private fun attachmentFromUri(uri: Uri?): Attachment? {
if (uri == null) {
return null
}
return UriAttachment(uri, MediaUtil.IMAGE_JPEG, AttachmentTable.TRANSFER_PROGRESS_DONE, 0, null, false, false, false, false, null, null, null, null, null, null)
}
}
}
}
/**
* [Parcelize] does not expose a `CREATOR` to source in the same compilation, so the lists are
* written element by element rather than as a typed list. Only ever read back by the matching
* helper below, and parcels never outlive the process, so the format is free to differ from the
* one the Java version used.
*/
private fun <T : Parcelable> Parcel.writeParcelableListCompat(values: List<T>, flags: Int) {
writeInt(values.size)
values.forEach { writeParcelable(it, flags) }
}
private fun <T : Parcelable> Parcel.readParcelableListCompat(clazz: Class<T>): List<T> {
return (0 until readInt()).mapNotNull { readParcelableCompat(clazz) }
}
@@ -33,17 +33,57 @@ class ContactCardReader(context: Context) {
private val context: Context = context.applicationContext
/** An address book entry, identified by a [ContactsContract] uri. Needs `READ_CONTACTS`, since the details live in the data table. */
@WorkerThread
fun read(uris: List<Uri>): List<Contact> {
return uris.mapNotNull { uri ->
if (ContactsContract.AUTHORITY == uri.authority) {
fromSystemContacts(ContactUtil.getContactIdFromUri(uri))
} else {
fromVcard(uri)
}
fun readSystemContact(uri: Uri): Contact? {
return try {
fromSystemContacts(ContactUtil.getContactIdFromUri(uri))
} catch (e: SecurityException) {
Log.w(TAG, "Not allowed to read the selected contact.", e)
null
}
}
/**
* The single phone number a system picker handed back, as a [SharedContactSource.SystemPhone] card.
*
* The provider reports the name as one string rather than as parts, so it goes in the given name and the
* editor is where it gets split.
*/
@WorkerThread
fun readSystemPhone(uri: Uri): Contact? {
val picked = try {
SystemContactsRepository.getPickedPhone(context, uri)
} catch (e: SecurityException) {
Log.w(TAG, "Not allowed to read the picked phone number.", e)
null
} ?: return null
val number = ContactUtil.getNormalizedPhoneNumber(picked.number)
if (number == null) {
Log.w(TAG, "The picked phone number could not be normalized.")
return null
}
val name = Name(picked.displayName?.takeUnless { it.isBlank() }, null, null, null, null, null)
if (name.isEmpty) {
Log.w(TAG, "The picked phone number has no name to render.")
return null
}
val phones = listOf(Phone(number, VCardUtil.phoneTypeFromContactType(picked.type), picked.label))
return Contact(name, null, phones, emptyList(), emptyList(), signalAvatar(phones))
}
/** A .vcf, which carries no contact id and so cannot go through [readSystemContact]. */
@WorkerThread
fun readVCard(uri: Uri): Contact? {
return fromVcard(uri)
}
private fun fromSystemContacts(contactId: Long): Contact? {
val phoneNumbers = phoneNumbers(contactId)
val emails = emails(contactId)
@@ -137,6 +177,10 @@ class ContactCardReader(context: Context) {
return Avatar(uri, false)
}
return signalAvatar(phoneNumbers)
}
private fun signalAvatar(phoneNumbers: List<Phone>): Avatar? {
return phoneNumbers
.asSequence()
.mapNotNull { SignalE164Util.formatAsE164(it.number) }
@@ -3,10 +3,12 @@ package org.thoughtcrime.securesms.contactshare;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.signal.core.models.ServiceId.ACI;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.attachments.Attachment;
import org.thoughtcrime.securesms.attachments.Cdn;
import org.thoughtcrime.securesms.attachments.PointerAttachment;
import org.thoughtcrime.securesms.util.RemoteConfig;
import org.whispersystems.signalservice.api.InvalidMessageStructureException;
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentPointer;
import org.whispersystems.signalservice.api.messages.shared.SharedContact;
@@ -68,11 +70,31 @@ public class ContactModelMapper {
.setMiddle(contact.getName().getMiddleName())
.build();
return new SharedContact.Builder().setName(name)
.withOrganization(contact.getOrganization())
.withPhones(phoneNumbers)
.withEmails(emails)
.withAddresses(postalAddresses);
SharedContact.Builder builder = new SharedContact.Builder().setName(name)
.withOrganization(contact.getOrganization())
.withPhones(phoneNumbers)
.withEmails(emails)
.withAddresses(postalAddresses);
if (!RemoteConfig.getContactSharingV2()) {
return builder;
}
builder.withNote(contact.getNote());
ACI aci = ACI.parseOrNull(contact.getAci());
if (aci != null) {
builder.withAci(aci);
}
Contact.SignalNickname nickname = contact.getNickname();
if (nickname != null && !nickname.isEmpty()) {
builder.withNickname(new SharedContact.Nickname.Builder().setGiven(nickname.getGiven())
.setFamily(nickname.getFamily())
.build());
}
return builder;
}
public static Contact remoteToLocal(@NonNull DataMessage.Contact contact) {
@@ -134,7 +156,30 @@ public class ContactModelMapper {
}
}
return new Contact(name, contact.organization, phoneNumbers, emails, postalAddresses, avatar);
if (!RemoteConfig.getContactSharingV2()) {
return new Contact(name, contact.organization, phoneNumbers, emails, postalAddresses, avatar, null, null, null);
}
ACI aci = ACI.parseOrNull(contact.aciBinary);
if (aci != null && !aci.isValid()) {
aci = null;
}
Contact.SignalNickname nickname = null;
if (contact.nickname != null) {
Contact.SignalNickname parsed = new Contact.SignalNickname(contact.nickname.given, contact.nickname.family);
nickname = parsed.isEmpty() ? null : parsed;
}
return new Contact(name,
contact.organization,
phoneNumbers,
emails,
postalAddresses,
avatar,
aci != null ? aci.toString() : null,
nickname,
contact.note);
}
private static Phone.Type remoteToLocalType(@Nullable DataMessage.Contact.Phone.Type type) {
@@ -7,7 +7,6 @@ package org.thoughtcrime.securesms.contactshare
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import androidx.core.content.IntentCompat
import org.signal.core.util.logging.Log
@@ -23,14 +22,17 @@ class ContactShareEditActivityV2 : PassphraseRequiredActivity() {
private val TAG = Log.tag(ContactShareEditActivityV2::class)
const val KEY_CONTACTS = "contacts"
private const val KEY_CONTACT_URIS = "contact_uris"
private const val KEY_SOURCE = "source"
private const val KEY_RECIPIENT_ID = "recipient_id"
/** @param recipientId the conversation being sent to, not the contact being shared. */
/**
* @param source the contact being shared, which may have no address book entry at all.
* @param recipientId the conversation being sent to, not the contact being shared.
*/
@JvmStatic
fun getIntent(context: Context, contactUris: List<Uri>, recipientId: RecipientId): Intent {
fun getIntent(context: Context, source: SharedContactSource, recipientId: RecipientId): Intent {
return Intent(context, ContactShareEditActivityV2::class.java).apply {
putParcelableArrayListExtra(KEY_CONTACT_URIS, ArrayList(contactUris))
putExtra(KEY_SOURCE, source)
putExtra(KEY_RECIPIENT_ID, recipientId)
}
}
@@ -39,18 +41,18 @@ class ContactShareEditActivityV2 : PassphraseRequiredActivity() {
override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {
super.onCreate(savedInstanceState, ready)
val uris: List<Uri> = IntentCompat.getParcelableArrayListExtra(intent, KEY_CONTACT_URIS, Uri::class.java) ?: emptyList()
val source = IntentCompat.getParcelableExtra(intent, KEY_SOURCE, SharedContactSource::class.java)
val recipientId = IntentCompat.getParcelableExtra(intent, KEY_RECIPIENT_ID, RecipientId::class.java)
if (uris.isEmpty() || recipientId == null) {
Log.w(TAG, "No contact uris supplied.")
if (source == null || recipientId == null) {
Log.w(TAG, "Nothing to share was supplied.")
finish()
return
}
if (savedInstanceState == null) {
supportFragmentManager.beginTransaction()
.replace(android.R.id.content, ContactShareEditFragment.create(uris, recipientId))
.replace(android.R.id.content, ContactShareEditFragment.create(source, recipientId))
.commit()
}
}
@@ -7,7 +7,6 @@ package org.thoughtcrime.securesms.contactshare
import android.app.Activity
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.widget.Toast
import androidx.activity.compose.BackHandler
@@ -24,7 +23,6 @@ import androidx.lifecycle.createSavedStateHandle
import org.signal.core.ui.compose.CollectActions
import org.signal.core.ui.compose.ComposeFragment
import org.signal.core.ui.compose.LocalChatColorProvider
import org.signal.core.util.getParcelableArrayListCompat
import org.signal.core.util.getParcelableCompat
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
@@ -48,13 +46,13 @@ class ContactShareEditFragment : ComposeFragment() {
companion object {
private val TAG = Log.tag(ContactShareEditFragment::class)
private const val ARG_CONTACT_URIS = "contact_uris"
private const val ARG_SOURCE = "source"
private const val ARG_RECIPIENT_ID = "recipient_id"
fun create(contactUris: List<Uri>, recipientId: RecipientId): ContactShareEditFragment {
fun create(source: SharedContactSource, recipientId: RecipientId): ContactShareEditFragment {
return ContactShareEditFragment().apply {
arguments = Bundle().apply {
putParcelableArrayList(ARG_CONTACT_URIS, ArrayList(contactUris))
putParcelable(ARG_SOURCE, source)
putParcelable(ARG_RECIPIENT_ID, recipientId)
}
}
@@ -68,7 +66,7 @@ class ContactShareEditFragment : ComposeFragment() {
private val viewModel: ShareContactViewModel by viewModel {
ShareContactViewModel(
uris = arguments?.getParcelableArrayListCompat(ARG_CONTACT_URIS, Uri::class.java) ?: emptyList(),
contactSource = arguments?.getParcelableCompat(ARG_SOURCE, SharedContactSource::class.java),
recipientId = recipientId,
repository = ShareContactRepository(),
savedState = it.createSavedStateHandle()
@@ -112,7 +110,7 @@ class ContactShareEditFragment : ComposeFragment() {
private fun handleAction(action: ShareContactAction, onEditName: (ContactNameParts?) -> Unit) {
when (action) {
ShareContactAction.Exit -> requireActivity().finish()
ShareContactAction.Exit -> requireActivity().onBackPressedDispatcher.onBackPressed()
ShareContactAction.InvalidContact -> {
Toast.makeText(requireContext(), R.string.ContactShareEditActivity_invalid_contact, Toast.LENGTH_SHORT).show()
@@ -6,10 +6,15 @@
package org.thoughtcrime.securesms.contactshare
import android.content.Context
import androidx.annotation.WorkerThread
import org.signal.core.models.ServiceId.ACI
import org.signal.core.util.nullIfBlank
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.profiles.ProfileName
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.RemoteConfig
import org.thoughtcrime.securesms.util.SignalE164Util
/** Detail ids are positional, so a selection can be resolved back against the contact it came from. */
@@ -20,11 +25,51 @@ internal const val ADDRESS_PREFIX = "address"
/** Not positional, since a contact carries at most one company. */
internal const val ORGANIZATION_ID = "organization"
/** Not positional either, and both are the sharer's own private annotations. */
internal const val NICKNAME_ID = "nickname"
internal const val NOTE_ID = "note"
/** The joined form, for the single row that stands in for both parts. */
internal fun Contact.SignalNickname.displayText(): String {
return listOfNotNull(this.given.nullIfBlank(), this.family.nullIfBlank()).joinToString(" ")
}
internal const val PHOTO_ID_ADDRESS_BOOK = "address-book"
internal const val PHOTO_ID_SIGNAL_PROFILE = "signal-profile"
/** Sharing no photo at all, which leaves the receiver to draw its own fallback from the name. */
internal const val PHOTO_ID_NONE = "none"
/** The card's claim about who it describes, absent for a card that predates ACI sharing. */
internal val Contact.signalAci: ACI?
get() {
if (!RemoteConfig.contactSharingV2) {
return null
}
return this.aci?.let { ACI.parseOrNull(it) }?.takeIf { it.isValid }
}
/**
* Whether the subject of the card is reachable on Signal. An ACI is proof on its own, so this is
* answerable for someone we have no phone number and no recipient row for, which is the whole point
* of carrying it.
*/
internal val Contact.isOnSignal: Boolean
get() {
val aci = this.signalAci ?: return this.resolveSignalRecipient() != null
val existing = SignalDatabase.recipients.getByAci(aci).orElse(null) ?: return true
return Recipient.resolved(existing).isRegistered
}
/** A lookup rather than an insert, so browsing contacts does not create recipient rows. */
internal fun Contact.resolveSignalRecipient(): RecipientId? {
this.signalAci
?.let { SignalDatabase.recipients.getByAci(it).orElse(null) }
?.takeIf { Recipient.resolved(it).isRegistered }
?.let { return it }
return this.phoneNumbers
.asSequence()
.mapNotNull { phone -> SignalE164Util.formatAsE164(phone.number) }
@@ -32,6 +77,24 @@ internal fun Contact.resolveSignalRecipient(): RecipientId? {
.firstOrNull { Recipient.resolved(it).isRegistered }
}
/**
* Resolves the subject to a recipient row, creating one from the card's ACI when there is none.
*
* Only for paths the user explicitly asked for, since a row is a durable side effect. Passively
* receiving a card must never seed one, or a sender could plant arbitrary rows by sending cards.
*/
@WorkerThread
internal fun Contact.resolveOrCreateSignalRecipient(): RecipientId? {
this.resolveSignalRecipient()?.let { return it }
val aci = this.signalAci ?: return null
val id = SignalDatabase.recipients.getOrInsertFromServiceId(aci)
SignalDatabase.recipients.setSharedName(id, ProfileName.fromParts(this.name.givenName, this.name.familyName))
return id
}
internal fun Contact.Phone.labelText(context: Context): String {
return when (this.type) {
Contact.Phone.Type.HOME -> context.getString(R.string.ContactShareEditActivity_type_home)
@@ -0,0 +1,36 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.contactshare
import android.content.Context
import android.content.Intent
import android.os.Bundle
import org.thoughtcrime.securesms.PassphraseRequiredActivity
/**
* Hosts [SelectContactFragment].
*/
class SelectContactActivity : PassphraseRequiredActivity() {
companion object {
const val KEY_SOURCE = "source"
@JvmStatic
fun getIntent(context: Context): Intent {
return Intent(context, SelectContactActivity::class.java)
}
}
override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {
super.onCreate(savedInstanceState, ready)
if (savedInstanceState == null) {
supportFragmentManager.beginTransaction()
.replace(android.R.id.content, SelectContactFragment.create())
.commit()
}
}
}
@@ -0,0 +1,107 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.contactshare
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.provider.ContactsContract
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContract
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.createSavedStateHandle
import androidx.paging.compose.collectAsLazyPagingItems
import org.signal.core.ui.compose.CollectActions
import org.signal.core.ui.compose.ComposeFragment
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.contacts.index.ContactIndexRepository
import org.thoughtcrime.securesms.contactshare.screens.selectcontact.ContactIndexSource
import org.thoughtcrime.securesms.contactshare.screens.selectcontact.SelectContactAction
import org.thoughtcrime.securesms.contactshare.screens.selectcontact.SelectContactEvent
import org.thoughtcrime.securesms.contactshare.screens.selectcontact.SelectContactScreen
import org.thoughtcrime.securesms.contactshare.screens.selectcontact.SelectContactViewModel
import org.thoughtcrime.securesms.util.viewModel
/**
* Lists the address book and Signal connections in one A-Z list.
*
* Unlike the system picker this does not need contacts permission to be useful, since Signal
* connections come from our own database, so the prompt lives inside the screen rather than in front
* of it.
*/
class SelectContactFragment : ComposeFragment() {
companion object {
fun create(): SelectContactFragment = SelectContactFragment()
}
/**
* Owned by the view model, so the index survives the activity being recreated but is released with
* the screen. Scoping it to the activity instead left a retained view model holding a repository
* the destroyed activity had already closed.
*/
private val viewModel: SelectContactViewModel by viewModel {
SelectContactViewModel(
source = ContactIndexSource(ContactIndexRepository(requireActivity().application)),
savedState = it.createSavedStateHandle()
)
}
@Composable
override fun FragmentContent() {
val state by viewModel.state.collectAsStateWithLifecycle()
val rows = viewModel.rows.collectAsLazyPagingItems()
val systemContactPicker = rememberLauncherForActivityResult(PickPhoneNumber) { uri: Uri? ->
viewModel.onEvent(SelectContactEvent.SystemContactPicked(uri))
}
CollectActions(viewModel.actions) { action -> handleAction(action, systemContactPicker) }
viewModel.contactsPermission.Content()
SelectContactScreen(
state = state,
rows = rows,
onEvent = viewModel::onEvent
)
}
private fun handleAction(action: SelectContactAction, systemContactPicker: ActivityResultLauncher<Unit>) {
when (action) {
SelectContactAction.Exit -> requireActivity().onBackPressedDispatcher.onBackPressed()
SelectContactAction.CouldNotOpenContact -> {
Toast.makeText(requireContext(), R.string.SelectContactScreen__couldnt_open_contact, Toast.LENGTH_SHORT).show()
}
is SelectContactAction.ContactResolved -> {
val intent = Intent().putExtra(SelectContactActivity.KEY_SOURCE, action.source)
requireActivity().setResult(Activity.RESULT_OK, intent)
requireActivity().finish()
}
SelectContactAction.LaunchSystemContactPicker -> systemContactPicker.launch(Unit)
}
}
}
/** Picks limited contact data, since we do not have contacts permission. */
private object PickPhoneNumber : ActivityResultContract<Unit, Uri?>() {
override fun createIntent(context: Context, input: Unit): Intent {
return Intent(Intent.ACTION_PICK, ContactsContract.CommonDataKinds.Phone.CONTENT_URI)
}
override fun parseResult(resultCode: Int, intent: Intent?): Uri? {
return if (resultCode == Activity.RESULT_OK) intent?.data else null
}
}
@@ -32,7 +32,10 @@ import org.thoughtcrime.securesms.contactshare.screens.details.SharedContactDeta
import org.thoughtcrime.securesms.contactshare.screens.details.SharedContactDetailsScreen
import org.thoughtcrime.securesms.contactshare.screens.details.SharedContactDetailsViewModel
import org.thoughtcrime.securesms.conversation.v2.AddToContactsContract
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.groups.ui.addtogroup.AddToGroupsActivity
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.viewModel
@@ -78,7 +81,7 @@ class SharedContactDetailsFragment : ComposeFragment() {
private fun handleAction(action: SharedContactDetailsAction) {
when (action) {
SharedContactDetailsAction.Exit -> requireActivity().finish()
SharedContactDetailsAction.Exit -> requireActivity().onBackPressedDispatcher.onBackPressed()
is SharedContactDetailsAction.CopyToClipboard -> {
requireContext().getSystemService<ClipboardManager>()?.setPrimaryClip(ClipData.newPlainText(null, action.text))
@@ -127,7 +130,20 @@ class SharedContactDetailsFragment : ComposeFragment() {
}
}
SharedContactDetailsAction.AddToGroup -> Log.i(TAG, "Not yet implemented: $action")
is SharedContactDetailsAction.AddToGroup -> addToGroup(action.recipientId)
}
}
private fun addToGroup(recipientId: RecipientId) {
lifecycleScope.launch {
val existingGroups = withContext(SignalDispatchers.IO) {
SignalDatabase.groups.getPushGroupsContainingMember(recipientId).map { it.recipientId }
}
launchIntent(
intent = AddToGroupsActivity.createIntent(requireContext(), recipientId, existingGroups),
missingAppMessage = null
)
}
}
@@ -0,0 +1,30 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.contactshare
import androidx.annotation.WorkerThread
import org.thoughtcrime.securesms.recipients.RecipientId
/** What a shared contact bubble needs to present itself. */
data class SharedContactPresentation(
val isOnSignal: Boolean,
val recipientIds: List<RecipientId>
) {
companion object {
/** For a message that carries no card, so callers never have to null check. */
@JvmField
val EMPTY = SharedContactPresentation(isOnSignal = false, recipientIds = emptyList())
@JvmStatic
@WorkerThread
fun resolve(contact: Contact): SharedContactPresentation {
return SharedContactPresentation(
isOnSignal = contact.isOnSignal,
recipientIds = ContactUtil.getExistingRecipients(contact)
)
}
}
}
@@ -0,0 +1,42 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.contactshare
import android.net.Uri
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
import org.thoughtcrime.securesms.recipients.RecipientId
/**
* Where a card being shared comes from.
*
* The picker can offer Signal connections that have no address book entry at all, and those have no
* contact URI to identify them by. A shared contact is therefore identified by whichever of the two
* sources it came from rather than by a URI alone.
*/
sealed interface SharedContactSource : Parcelable {
/**
* An address book entry, whether or not it is also on Signal.
*
* [recipientId] records which Signal account the picker row stood for, since one contact can hold
* several registered numbers.
*/
@Parcelize
data class AddressBook(val contactUri: Uri, val recipientId: RecipientId? = null) : SharedContactSource
/** One phone number picked from the system picker, so only a name and that number, never the rest of the entry. */
@Parcelize
data class SystemPhone(val dataUri: Uri) : SharedContactSource
/** A Signal connection with no address book entry, so there is nothing but a profile to share. */
@Parcelize
data class SignalContact(val recipientId: RecipientId) : SharedContactSource
/** A .vcf shared into a conversation from outside, which never passes through the picker. */
@Parcelize
data class VCard(val uri: Uri) : SharedContactSource
}
@@ -91,7 +91,7 @@ public final class VCardUtil {
vPostalAddress.getCountry()));
}
return new Contact(name, organization, phoneNumbers, emails, postalAddresses, null);
return new Contact(name, organization, phoneNumbers, emails, postalAddresses, null, null, null, null);
}
static Contact.Phone.Type phoneTypeFromContactType(int type) {
@@ -32,6 +32,5 @@ sealed interface SharedContactDetailsAction {
data class StartChat(val recipientId: RecipientId) : SharedContactDetailsAction
data class StartVideoCall(val recipientId: RecipientId) : SharedContactDetailsAction
data class StartAudioCall(val recipientId: RecipientId) : SharedContactDetailsAction
data object AddToGroup : SharedContactDetailsAction
data class AddToGroup(val recipientId: RecipientId) : SharedContactDetailsAction
}
@@ -9,15 +9,22 @@ import android.content.Context
import kotlinx.coroutines.withContext
import org.signal.core.util.concurrent.SignalDispatchers
import org.signal.core.util.nullIfBlank
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.contactshare.ADDRESS_PREFIX
import org.thoughtcrime.securesms.contactshare.Contact
import org.thoughtcrime.securesms.contactshare.ContactUtil
import org.thoughtcrime.securesms.contactshare.EMAIL_PREFIX
import org.thoughtcrime.securesms.contactshare.NICKNAME_ID
import org.thoughtcrime.securesms.contactshare.NOTE_ID
import org.thoughtcrime.securesms.contactshare.PHONE_PREFIX
import org.thoughtcrime.securesms.contactshare.displayLines
import org.thoughtcrime.securesms.contactshare.displayText
import org.thoughtcrime.securesms.contactshare.isOnSignal
import org.thoughtcrime.securesms.contactshare.labelText
import org.thoughtcrime.securesms.contactshare.resolveOrCreateSignalRecipient
import org.thoughtcrime.securesms.contactshare.resolveSignalRecipient
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.recipients.RecipientId
import java.util.Locale
/** Maps a received card into details screen state. */
@@ -30,9 +37,13 @@ class SharedContactDetailsRepository(
toState(contact)
}
suspend fun resolveOrCreateRecipient(contact: Contact): RecipientId? = withContext(SignalDispatchers.IO) {
contact.resolveOrCreateSignalRecipient()
}
private fun toState(contact: Contact): SharedContactDetailsState {
val isOnSignal = contact.isOnSignal
val signalRecipient = contact.resolveSignalRecipient()
val isOnSignal = signalRecipient != null
val displayName = ContactUtil.getDisplayName(contact)
return SharedContactDetailsState(
@@ -65,6 +76,24 @@ class SharedContactDetailsRepository(
)
}
this.nickname?.takeUnless { it.isEmpty }?.let { nickname ->
rows += SharedContactDetailsState.DetailRow(
id = NICKNAME_ID,
lines = listOf(nickname.displayText()),
label = context.getString(R.string.ShareContactScreen__nickname),
kind = SharedContactDetailsState.DetailKind.NICKNAME
)
}
this.note.nullIfBlank()?.let { note ->
rows += SharedContactDetailsState.DetailRow(
id = NOTE_ID,
lines = listOf(note),
label = context.getString(R.string.ShareContactScreen__notes),
kind = SharedContactDetailsState.DetailKind.NOTE
)
}
this.emails.forEachIndexed { index, email ->
rows += SharedContactDetailsState.DetailRow(
id = "$EMAIL_PREFIX:$index",
@@ -534,10 +534,11 @@ private fun previewState(
displayName = "Paige Hall",
photoUri = "",
signalRecipientId = if (isOnSignal) RecipientId.from(1L) else null,
actions = buildList {
if (!isOnSignal) add(ContactAction.INVITE_TO_SIGNAL)
if (withDetails) add(ContactAction.ADD_TO_PHONE_CONTACTS)
},
actions = SharedContactDetailsViewModel.contactActionsFor(
isOnSignal = isOnSignal,
hasInviteTarget = withDetails,
hasAnythingToSave = withDetails
),
details = details
)
}
@@ -46,7 +46,7 @@ data class SharedContactDetailsState(
enum class DetailKind {
PHONE,
/** Only ever from the card, never from our own recipient. Waiting on the wire fields. */
/** Only ever from the card, never from our own recipient. */
NICKNAME,
NOTE,
@@ -41,7 +41,9 @@ class SharedContactDetailsViewModel(
if (hasAnythingToSave) {
add(ContactAction.ADD_TO_PHONE_CONTACTS)
}
// ADD_TO_GROUP is not wired up yet, so it is deliberately not offered.
if (isOnSignal) {
add(ContactAction.ADD_TO_GROUP)
}
}
}
@@ -87,18 +89,22 @@ class SharedContactDetailsViewModel(
}
private suspend fun onActionClicked(contactAction: ContactAction) {
val action = when (contactAction) {
ContactAction.INVITE_TO_SIGNAL -> inviteAction()
ContactAction.ADD_TO_PHONE_CONTACTS -> SharedContactDetailsAction.AddToPhoneContacts
ContactAction.ADD_TO_GROUP -> SharedContactDetailsAction.AddToGroup
}
when (contactAction) {
ContactAction.ADD_TO_GROUP -> sendForRecipient { SharedContactDetailsAction.AddToGroup(it) }
if (action == null) {
Log.w(TAG, "Nothing on the card to send an invite to.")
return
}
ContactAction.ADD_TO_PHONE_CONTACTS -> _actions.send(SharedContactDetailsAction.AddToPhoneContacts)
_actions.send(action)
ContactAction.INVITE_TO_SIGNAL -> {
val action = inviteAction()
if (action == null) {
Log.w(TAG, "Nothing on the card to send an invite to.")
return
}
_actions.send(action)
}
}
}
private fun onDetailPressed(id: String) {
@@ -131,12 +137,17 @@ class SharedContactDetailsViewModel(
return
}
when (detailAction) {
DetailAction.MESSAGE -> return sendForRecipient { SharedContactDetailsAction.StartChat(it) }
DetailAction.VIDEO_CALL -> return sendForRecipient { SharedContactDetailsAction.StartVideoCall(it) }
DetailAction.AUDIO_CALL -> return sendForRecipient { SharedContactDetailsAction.StartAudioCall(it) }
else -> Unit
}
val action = when (detailAction) {
DetailAction.MESSAGE -> current.signalRecipientId?.let { SharedContactDetailsAction.StartChat(it) }
DetailAction.VIDEO_CALL -> current.signalRecipientId?.let { SharedContactDetailsAction.StartVideoCall(it) }
DetailAction.AUDIO_CALL -> current.signalRecipientId?.let { SharedContactDetailsAction.StartAudioCall(it) }
DetailAction.OPEN_IN_MAPS -> SharedContactDetailsAction.OpenInMaps(detail.copyText)
DetailAction.COPY -> SharedContactDetailsAction.CopyToClipboard(detail.copyText)
else -> null
}
if (action == null) {
@@ -148,13 +159,14 @@ class SharedContactDetailsViewModel(
}
private suspend fun sendForRecipient(action: (RecipientId) -> SharedContactDetailsAction) {
val recipientId = _state.value.signalRecipientId
val recipientId = repository.resolveOrCreateRecipient(contact)
if (recipientId == null) {
Log.w(TAG, "No matched recipient to act on.")
return
}
_state.update { it.copy(signalRecipientId = recipientId) }
_actions.send(action(recipientId))
}
@@ -0,0 +1,66 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.contactshare.screens.selectcontact
import androidx.paging.PagingSource
import androidx.paging.PagingState
import kotlinx.coroutines.CancellationException
import org.thoughtcrime.securesms.contacts.index.ContactIndexRecord
/**
* Pages the contact index, keyed by index position.
*
* Positions double as row ids, so paging in either direction is a range scan rather than an offset
* walk.
*/
class ContactIndexPagingSource(
private val source: ContactIndexSource,
private val query: String
) : PagingSource<Long, ContactIndexRecord>() {
companion object {
/** Index positions are one based, matching `row_number()`. */
const val FIRST_POSITION = 1L
}
/** Safe to hold, since a rebuild bumps the generation and replaces every source in the pager. */
private var totalRows: Int? = null
override suspend fun load(params: LoadParams<Long>): LoadResult<Long, ContactIndexRecord> {
val start = params.key ?: FIRST_POSITION
return try {
val rows = source.page(query, start, params.loadSize)
// A filtered count is not known without scanning the whole index, so search offers no placeholders.
val counted = if (query.isBlank()) countsAround(start, rows.size) else null
LoadResult.Page(
data = rows,
prevKey = if (query.isNotBlank() || start <= FIRST_POSITION) null else (start - params.loadSize).coerceAtLeast(FIRST_POSITION),
// A filtered query's last match may be anywhere, so the next key comes from the row.
nextKey = if (rows.size < params.loadSize) null else rows.last().position + 1,
itemsBefore = counted?.first ?: LoadResult.Page.COUNT_UNDEFINED,
itemsAfter = counted?.second ?: LoadResult.Page.COUNT_UNDEFINED
)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
LoadResult.Error(e)
}
}
/** Exact rather than approximate, since positions are contiguous and one based. */
private suspend fun countsAround(start: Long, loaded: Int): Pair<Int, Int> {
val total = totalRows ?: source.count().also { totalRows = it }
val before = (start - 1).coerceIn(0, total.toLong()).toInt()
return before to (total - before - loaded).coerceAtLeast(0)
}
/** Restarts from the top: the index is rebuilt from scratch, so an anchor would point elsewhere. */
override fun getRefreshKey(state: PagingState<Long, ContactIndexRecord>): Long? = null
}
@@ -0,0 +1,72 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.contactshare.screens.selectcontact
import android.content.Context
import kotlinx.coroutines.withContext
import org.signal.contacts.SystemContactsRepository
import org.signal.core.util.concurrent.SignalDispatchers
import org.signal.core.util.concurrent.SignalExecutors
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.contacts.index.ContactIndexBuildResult
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
/** 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
) {
companion object {
private val TAG = Log.tag(ContactIndexSource::class)
}
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)
}
/** How many rows the unfiltered index holds, which is what lets a page report its true length. */
suspend fun count(): Int = withContext(SignalDispatchers.IO) {
repository.count()
}
/**
* An address book row is handed on as a contact URI so the editor can read the full set of details.
* The URI is resolved from the lookup key rather than built from the stored contact id, because the
* provider may have re-aggregated since the index was built.
*/
suspend fun resolve(record: ContactIndexRecord): SharedContactSource? = withContext(SignalDispatchers.IO) {
if (record.lookupKey != null && record.contactId != null) {
val uri = try {
SystemContactsRepository.currentContactUri(context, record.lookupKey, record.contactId)
} catch (e: SecurityException) {
Log.w(TAG, "Contacts permission went away after the index was built.", e)
null
}
if (uri != null) {
return@withContext SharedContactSource.AddressBook(uri, record.recipientId)
}
Log.w(TAG, "No readable address book entry. Falling back to the Signal profile if there is one.")
}
record.recipientId?.let { SharedContactSource.SignalContact(it) }
}
/** `onCleared()` is on the main thread and nothing reads the index after this, so it is handed off. */
fun close() {
SignalExecutors.BOUNDED_IO.execute { repository.close() }
}
}
@@ -0,0 +1,22 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.contactshare.screens.selectcontact
import org.thoughtcrime.securesms.contactshare.SharedContactSource
sealed interface SelectContactAction {
data object Exit : SelectContactAction
/** The selection, already resolved to something the share editor can read. */
data class ContactResolved(val source: SharedContactSource) : SelectContactAction {
/** A contact uri identifies the person, so only which kind of source it is gets logged. */
override fun toString(): String = "ContactResolved(${source.javaClass.simpleName})"
}
data object CouldNotOpenContact : SelectContactAction
data object LaunchSystemContactPicker : SelectContactAction
}
@@ -0,0 +1,38 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.contactshare.screens.selectcontact
import android.net.Uri
import org.thoughtcrime.securesms.contacts.index.ContactIndexRecord
sealed interface SelectContactEvent {
data object Initialize : SelectContactEvent
data object BackClicked : SelectContactEvent
data class QueryChanged(val query: String) : SelectContactEvent {
/** What the user typed to find someone is as revealing as the name it matches. */
override fun toString(): String = "QueryChanged(length=${query.length})"
}
data class ContactClicked(val contact: ContactIndexRecord) : SelectContactEvent
data object AllowContactsAccessClicked : SelectContactEvent
data object DismissContactsAccessClicked : SelectContactEvent
data object OpenSystemContactPickerClicked : SelectContactEvent
/** [phoneUri] points at one phone row, and is null when the picker was backed out of. */
data class SystemContactPicked(val phoneUri: Uri?) : SelectContactEvent {
/** The uri identifies the person, so only whether there was one gets logged. */
override fun toString(): String = "SystemContactPicked(picked=${phoneUri != null})"
}
data object LearnMoreClicked : SelectContactEvent
data object PermissionDeniedSheetDismissed : SelectContactEvent
}
@@ -0,0 +1,660 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
@file:OptIn(ExperimentalFoundationApi::class)
package org.thoughtcrime.securesms.contactshare.screens.selectcontact
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withLink
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.paging.PagingData
import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.collectAsLazyPagingItems
import androidx.paging.compose.itemKey
import kotlinx.coroutines.flow.flowOf
import org.signal.contacts.SystemContactsRepository
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.Scaffolds
import org.signal.core.ui.fonts.SignalSymbols
import org.signal.core.ui.permissions.PermissionDeniedSheet
import org.signal.glide.compose.GlideImage
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.avatar.AvatarImage
import org.thoughtcrime.securesms.avatar.fallback.FallbackAvatar
import org.thoughtcrime.securesms.avatar.fallback.FallbackAvatarImage
import org.thoughtcrime.securesms.contacts.index.ContactIndexRecord
import org.thoughtcrime.securesms.contacts.index.ContactIndexType
import org.thoughtcrime.securesms.conversation.colors.AvatarColor
import org.thoughtcrime.securesms.recipients.RecipientId
import org.signal.core.ui.R as CoreUiR
private val ROW_HEIGHT = 64.dp
private val SECTION_HEADER_HEIGHT = 52.dp
private val AVATAR_SIZE = 40.dp
private val ROW_START_PADDING = 24.dp
private val AVATAR_TO_NAME_GAP = 16.dp
private val SIGNAL_BADGE_SIZE = 16.dp
private val SEARCH_CORNER_RADIUS = 32.dp
private val FULL_SCREEN_PROMPT_ICON_SIZE = 72.dp
private val CARD_PROMPT_ICON_SIZE = 64.dp
@Composable
fun SelectContactScreen(
state: SelectContactState,
rows: LazyPagingItems<SelectContactRow>,
onEvent: (SelectContactEvent) -> Unit
) {
Scaffolds.Default(
title = stringResource(R.string.SelectContactScreen__select_contact),
onNavigationClick = { onEvent(SelectContactEvent.BackClicked) },
navigationIconRes = CoreUiR.drawable.symbol_arrow_start_24,
navigationContentDescription = stringResource(R.string.DefaultTopAppBar__navigate_up_content_description)
) { contentPadding ->
val listState = rememberLazyListState()
val keyboardController = LocalSoftwareKeyboardController.current
// Results for a new query start at the top of the index, so the old scroll offset points at an
// unrelated part of the list. Tracked rather than keyed on the query alone so that a rotation,
// which recomposes without the query changing, keeps the position the user was at.
var lastQuery by rememberSaveable { mutableStateOf(state.query) }
LaunchedEffect(state.query) {
if (state.query != lastQuery) {
lastQuery = state.query
listState.scrollToItem(0)
}
}
Column(modifier = Modifier.padding(contentPadding).fillMaxSize()) {
SearchField(
query = state.query,
onQueryChange = { onEvent(SelectContactEvent.QueryChanged(it)) },
onSearch = { keyboardController?.hide() }
)
when {
state.isLoading -> {
Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) {
CircularProgressIndicator()
}
}
state.showFullScreenPermissionPrompt -> {
FullScreenPermissionPrompt(
onAllowClick = { onEvent(SelectContactEvent.AllowContactsAccessClicked) },
onDismissClick = { onEvent(SelectContactEvent.DismissContactsAccessClicked) }
)
}
else -> {
ContactList(
state = state,
rows = rows,
listState = listState,
onEvent = onEvent
)
}
}
}
}
if (state.showPermissionDeniedSheet) {
PermissionDeniedSheet(
titleRes = R.string.SelectContactScreen__allow_access_to_contacts,
subtitleRes = R.string.SelectContactScreen__to_find_people_you_know_on_signal,
onDismiss = { onEvent(SelectContactEvent.PermissionDeniedSheetDismissed) }
)
}
}
@Composable
private fun ContactList(
state: SelectContactState,
rows: LazyPagingItems<SelectContactRow>,
listState: LazyListState,
onEvent: (SelectContactEvent) -> Unit
) {
val keyboardController = LocalSoftwareKeyboardController.current
// Dragging the list is how people ask for the keyboard to get out of the way, and until it does
// the rows behind it cannot be reached.
val dismissKeyboardOnDrag = remember(keyboardController) {
object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
if (source == NestedScrollSource.UserInput && available.y != 0f) {
keyboardController?.hide()
}
return Offset.Zero
}
}
}
LazyColumn(
state = listState,
modifier = Modifier
.fillMaxSize()
.nestedScroll(dismissKeyboardOnDrag)
) {
if (state.showSystemPickerButton) {
item(key = "system-picker-button") {
OpenSystemContactPickerButton(
onClick = { onEvent(SelectContactEvent.OpenSystemContactPickerClicked) }
)
}
}
if (state.showPermissionCard) {
item(key = "permission-card") {
PermissionCard(
onAllowClick = { onEvent(SelectContactEvent.AllowContactsAccessClicked) },
onDismissClick = { onEvent(SelectContactEvent.DismissContactsAccessClicked) }
)
}
}
// Paging drives loading off the prefetch distance, so there is no scroll listener here.
items(
count = rows.itemCount,
// Scoped to the query, because a row of a filtered list is not the same list item as the same
// contact in the unfiltered one. Sharing keys across the two lets the list re-anchor on
// whichever contact was on screen when the query changed and scroll to wherever it sits in the
// new results, which undoes the reset that runs when the query changes.
key = rows.itemKey { row ->
when (row) {
is SelectContactRow.Header -> "${state.query}-header-${row.label}"
is SelectContactRow.Contact -> "${state.query}-contact-${row.contact.position}"
}
}
) { index ->
when (val row = rows[index]) {
is SelectContactRow.Header -> SectionHeader(label = row.label)
is SelectContactRow.Contact -> {
ContactRow(
contact = row.contact,
onClick = { onEvent(SelectContactEvent.ContactClicked(row.contact)) }
)
}
// A row of the unfiltered list that has not been read yet. Search reports no counts, so it
// has no placeholders and never lands here.
null -> Spacer(modifier = Modifier.height(ROW_HEIGHT))
}
}
if (state.showPermissionFooter) {
item(key = "permission-footer") {
PermissionDeniedFooter(
onLearnMoreClick = { onEvent(SelectContactEvent.LearnMoreClicked) }
)
}
}
}
}
@Composable
private fun SearchField(
query: String,
onQueryChange: (String) -> Unit,
onSearch: () -> Unit
) {
TextField(
value = query,
onValueChange = onQueryChange,
placeholder = { Text(text = stringResource(R.string.SelectContactScreen__search)) },
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
keyboardActions = KeyboardActions(onSearch = { onSearch() }),
trailingIcon = {
if (query.isNotEmpty()) {
IconButton(onClick = { onQueryChange("") }) {
Icon(
painter = painterResource(CoreUiR.drawable.symbol_x_24),
contentDescription = stringResource(R.string.SelectContactScreen__clear_search)
)
}
}
},
shape = RoundedCornerShape(SEARCH_CORNER_RADIUS),
colors = TextFieldDefaults.colors(
focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent
),
modifier = Modifier
.padding(horizontal = 16.dp, vertical = 10.dp)
.fillMaxWidth()
.defaultMinSize(minHeight = 44.dp)
)
}
@Composable
private fun SectionHeader(label: String) {
Box(
contentAlignment = Alignment.CenterStart,
modifier = Modifier
.fillMaxWidth()
.height(SECTION_HEADER_HEIGHT)
.padding(horizontal = ROW_START_PADDING)
) {
Text(
text = label,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurface
)
}
}
@Composable
private fun ContactRow(
contact: ContactIndexRecord,
onClick: () -> Unit
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.height(ROW_HEIGHT)
.clickable(onClick = onClick)
.padding(start = ROW_START_PADDING, end = 16.dp)
) {
ContactAvatar(contact = contact)
Spacer(modifier = Modifier.width(AVATAR_TO_NAME_GAP))
Text(
text = contact.displayName,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false)
)
if (contact.isInAddressBook) {
Spacer(modifier = Modifier.width(6.dp))
Icon(
painter = painterResource(R.drawable.symbol_person_circle_compat_16),
contentDescription = stringResource(R.string.SelectContactScreen__in_your_contacts),
tint = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.size(SIGNAL_BADGE_SIZE)
)
}
}
}
/**
* 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.
*/
@Composable
private fun ContactAvatar(contact: ContactIndexRecord) {
val modifier = Modifier.size(AVATAR_SIZE)
when {
contact.hasPhoto && contact.contactId != null -> {
if (LocalInspectionMode.current) {
Image(
painter = painterResource(R.drawable.ic_avatar_abstract_02),
contentDescription = null,
modifier = modifier.clip(CircleShape)
)
} else {
GlideImage(
model = SystemContactsRepository.photoUriForContact(contact.contactId),
contentScale = ContentScale.Crop,
modifier = modifier.clip(CircleShape)
)
}
}
contact.recipientId != null -> {
AvatarImage(recipientId = contact.recipientId, modifier = modifier)
}
else -> FallbackAvatarImage(fallbackAvatar = contact.fallbackAvatar(), modifier = modifier)
}
}
/**
* A company name or bare phone number has no initials worth showing, so it falls back to the person
* glyph rather than to the first letters of whatever the provider used as a name.
*/
private fun ContactIndexRecord.fallbackAvatar(): FallbackAvatar {
// No recipient to derive a color from, so address book entries all share the first avatar color.
return if (!hasPersonalName) {
FallbackAvatar.Resource.Person(AvatarColor.A100)
} else {
FallbackAvatar.forTextOrDefault(displayName, AvatarColor.A100)
}
}
@Composable
private fun FullScreenPermissionPrompt(
onAllowClick: () -> Unit,
onDismissClick: () -> Unit
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 32.dp)
) {
ContactsAccessPromptContent(
iconSize = FULL_SCREEN_PROMPT_ICON_SIZE,
iconToTitleGap = 24.dp,
onAllowClick = onAllowClick,
onDismissClick = onDismissClick
)
}
}
@Composable
private fun PermissionCard(
onAllowClick: () -> Unit,
onDismissClick: () -> Unit
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.padding(horizontal = 16.dp, vertical = 12.dp)
.fillMaxWidth()
.border(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.38f), RoundedCornerShape(18.dp))
.padding(horizontal = 16.dp, vertical = 24.dp)
) {
ContactsAccessPromptContent(
iconSize = CARD_PROMPT_ICON_SIZE,
iconToTitleGap = 16.dp,
onAllowClick = onAllowClick,
onDismissClick = onDismissClick
)
}
}
@Composable
private fun ContactsAccessPromptContent(
iconSize: Dp,
iconToTitleGap: Dp,
onAllowClick: () -> Unit,
onDismissClick: () -> Unit
) {
Icon(
painter = painterResource(R.drawable.permissions_contact_book),
contentDescription = null,
tint = Color.Unspecified,
modifier = Modifier.size(iconSize)
)
Spacer(modifier = Modifier.height(iconToTitleGap))
Text(
text = stringResource(R.string.SelectContactScreen__find_people_you_know_on_signal),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(R.string.SelectContactScreen__allow_access_to_your_contacts),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(16.dp))
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
) {
Buttons.MediumTonal(onClick = onDismissClick, modifier = Modifier.weight(1f)) {
Text(text = stringResource(R.string.SelectContactScreen__no_thanks))
}
Buttons.MediumTonal(onClick = onAllowClick, modifier = Modifier.weight(1f)) {
Text(text = stringResource(R.string.SelectContactScreen__allow_access))
}
}
}
@Composable
private fun OpenSystemContactPickerButton(onClick: () -> Unit) {
Buttons.MediumTonal(
onClick = onClick,
colors = ButtonDefaults.filledTonalButtonColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
),
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 12.dp)
) {
Text(
text = SignalSymbols.signalSymbolText(
text = stringResource(R.string.SelectContactScreen__open_phone_contacts),
glyphStart = SignalSymbols.Glyph.PERSON,
glyphStartWeight = SignalSymbols.Weight.BOLD
)
)
}
}
@Composable
private fun PermissionDeniedFooter(onLearnMoreClick: () -> Unit) {
val learnMore = stringResource(R.string.SelectContactScreen__learn_more)
val fullText = stringResource(R.string.SelectContactScreen__to_see_your_phone_contacts_here, learnMore)
val linkColor = MaterialTheme.colorScheme.onSurface
val text = remember(fullText, learnMore, linkColor, onLearnMoreClick) {
val linkStart = fullText.lastIndexOf(learnMore)
buildAnnotatedString {
if (linkStart < 0) {
append(fullText)
return@buildAnnotatedString
}
append(fullText.take(linkStart))
withLink(LinkAnnotation.Clickable(tag = "learn-more") { onLearnMoreClick() }) {
withStyle(SpanStyle(color = linkColor, fontWeight = FontWeight.Medium)) {
append(learnMore)
}
}
append(fullText.substring(linkStart + learnMore.length))
}
}
Text(
text = text,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 24.dp, vertical = 20.dp)
)
}
@DayNightPreviews
@Composable
private fun SelectContactScreenPreview() {
Previews.Preview {
SelectContactScreen(
state = SelectContactState(isLoading = false, indexCount = PREVIEW_ROWS.size),
rows = previewRows(PREVIEW_ROWS),
onEvent = {}
)
}
}
@DayNightPreviews
@Composable
private fun SelectContactScreenPermissionCardPreview() {
Previews.Preview {
SelectContactScreen(
state = SelectContactState(
isLoading = false,
indexCount = 4,
contactsPermission = SelectContactState.ContactsPermissionState.DENIED
),
rows = previewRows(PREVIEW_ROWS.take(4)),
onEvent = {}
)
}
}
@DayNightPreviews
@Composable
private fun SelectContactScreenNoPermissionPreview() {
Previews.Preview {
SelectContactScreen(
state = SelectContactState(
isLoading = false,
indexCount = 0,
contactsPermission = SelectContactState.ContactsPermissionState.DENIED
),
rows = previewRows(emptyList()),
onEvent = {}
)
}
}
@DayNightPreviews
@Composable
private fun SelectContactScreenDismissedPreview() {
Previews.Preview {
SelectContactScreen(
state = SelectContactState(
isLoading = false,
indexCount = PREVIEW_ROWS.size,
contactsPermission = SelectContactState.ContactsPermissionState.DISMISSED
),
rows = previewRows(PREVIEW_ROWS),
onEvent = {}
)
}
}
@DayNightPreviews
@Composable
private fun SelectContactScreenPermanentlyDeniedPreview() {
Previews.Preview {
SelectContactScreen(
state = SelectContactState(
isLoading = false,
indexCount = PREVIEW_ROWS.size,
contactsPermission = SelectContactState.ContactsPermissionState.PERMANENTLY_DENIED
),
rows = previewRows(PREVIEW_ROWS),
onEvent = {}
)
}
}
private fun previewContact(
position: Long,
name: String,
type: ContactIndexType = ContactIndexType.SYSTEM_ONLY,
hasPersonalName: Boolean = true
): ContactIndexRecord {
return ContactIndexRecord(
position = position,
type = type,
section = name.take(1),
displayName = name,
recipientId = if (type != ContactIndexType.SYSTEM_ONLY) RecipientId.from(position) else null,
lookupKey = if (type != ContactIndexType.SIGNAL_ONLY) "lookup-$position" else null,
contactId = null,
hasPersonalName = hasPersonalName,
hasPhoto = false
)
}
private val PREVIEW_ROWS = listOf(
SelectContactRow.Header("A"),
SelectContactRow.Contact(previewContact(1, "Andrew Bell")),
SelectContactRow.Contact(previewContact(2, "Abby Franklin", ContactIndexType.BOTH)),
SelectContactRow.Contact(previewContact(3, "Anna Morris", ContactIndexType.SIGNAL_ONLY)),
SelectContactRow.Header("C"),
SelectContactRow.Contact(previewContact(4, "Casey Fields", ContactIndexType.BOTH)),
SelectContactRow.Header("#"),
SelectContactRow.Contact(previewContact(5, "Pacific Plumbing", hasPersonalName = false)),
SelectContactRow.Contact(previewContact(6, "+1 555-123-4567", hasPersonalName = false))
)
@Composable
private fun previewRows(rows: List<SelectContactRow>): LazyPagingItems<SelectContactRow> {
return remember(rows) { flowOf(PagingData.from(rows)) }.collectAsLazyPagingItems()
}
@@ -0,0 +1,58 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.contactshare.screens.selectcontact
import org.thoughtcrime.securesms.contacts.index.ContactIndexRecord
/** State for the contact picker. */
data class SelectContactState(
val query: String = "",
val contactsPermission: ContactsPermissionState = ContactsPermissionState.GRANTED,
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 isEmpty: Boolean
get() = !isLoading && indexCount == 0
val showFullScreenPermissionPrompt: Boolean
get() = contactsPermission == ContactsPermissionState.DENIED && isEmpty && query.isEmpty()
/** Shown above the list, so Signal contacts stay reachable while the prompt is up. */
val showPermissionCard: Boolean
get() = contactsPermission == ContactsPermissionState.DENIED && !isEmpty && query.isEmpty()
val showSystemPickerButton: Boolean
get() = contactsPermission.isAskingOver && query.isEmpty()
val showPermissionFooter: Boolean
get() = showSystemPickerButton
enum class ContactsPermissionState {
GRANTED,
/** No permission, and the prompt has not been dismissed. */
DENIED,
/** No permission, and the user said no thanks. Only Signal contacts from here on. */
DISMISSED,
/** Denied at the device level, so the system will not prompt for it again. */
PERMANENTLY_DENIED;
/** Whether there is any point offering to ask for the permission again. */
val isAskingOver: Boolean
get() = this == DISMISSED || this == PERMANENTLY_DENIED
}
}
/** A rendered line in the list. Headers are interleaved into the paged stream to keep it flat. */
sealed interface SelectContactRow {
data class Header(val label: String) : SelectContactRow
data class Contact(val contact: ContactIndexRecord) : SelectContactRow
}
@@ -0,0 +1,220 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.contactshare.screens.selectcontact
import android.Manifest
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.PagingData
import androidx.paging.cachedIn
import androidx.paging.insertSeparators
import androidx.paging.map
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.update
import org.signal.core.ui.compose.EventDrivenViewModel
import org.signal.core.ui.compose.PermissionController
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.contacts.index.ContactIndexBuildResult
import org.thoughtcrime.securesms.contacts.index.ContactIndexRecord
import org.thoughtcrime.securesms.contactshare.SharedContactSource
import org.thoughtcrime.securesms.contactshare.screens.selectcontact.SelectContactState.ContactsPermissionState
@OptIn(ExperimentalCoroutinesApi::class)
class SelectContactViewModel(
private val source: ContactIndexSource,
private val savedState: SavedStateHandle = SavedStateHandle()
) : EventDrivenViewModel<SelectContactEvent>(TAG) {
companion object {
private val TAG = Log.tag(SelectContactViewModel::class)
/** The system picker can take the activity down with it, and the rebuild on the way back would otherwise re-raise the prompt. */
private const val KEY_PERMISSION_DECISION = "permission_decision"
const val PAGE_SIZE = 100
/** A full page, so a fling that reaches the loaded edge does not stop there waiting. */
private const val PREFETCH_DISTANCE = PAGE_SIZE
/** Six pages leaves three beyond the initial three-page load, so a long scroll does not re-read. */
private const val MAX_SIZE = PAGE_SIZE * 6
}
private val _state = MutableStateFlow(SelectContactState())
val state: StateFlow<SelectContactState> = _state.asStateFlow()
private val _actions = Channel<SelectContactAction>(Channel.BUFFERED)
val actions: Flow<SelectContactAction> = _actions.receiveAsFlow()
/** No `permanentDenialMessage`: this screen answers a permanent denial with the picker button and footer instead of a dialog. */
val contactsPermission = PermissionController(permission = Manifest.permission.READ_CONTACTS)
private val query = MutableStateFlow("")
/** Bumped after each rebuild so the pager throws away pages read from the previous index. */
private val indexGeneration = MutableStateFlow(0)
val rows: Flow<PagingData<SelectContactRow>> = combine(query, indexGeneration) { query, _ -> query }
.flatMapLatest { query ->
Pager(
config = PagingConfig(
pageSize = PAGE_SIZE,
prefetchDistance = PREFETCH_DISTANCE,
maxSize = MAX_SIZE,
// The unfiltered index is counted, so the list can be its full length from the first page.
enablePlaceholders = true
),
pagingSourceFactory = { ContactIndexPagingSource(source, query) }
).flow.map { page ->
page.withSectionHeaders(showHeaders = query.isBlank())
}
}
.cachedIn(viewModelScope)
init {
onEvent(SelectContactEvent.Initialize)
}
override suspend fun processEvent(event: SelectContactEvent) {
when (event) {
SelectContactEvent.Initialize -> rebuild()
is SelectContactEvent.QueryChanged -> {
if (event.query == _state.value.query) {
return
}
_state.update { it.copy(query = event.query) }
query.value = event.query
}
is SelectContactEvent.ContactClicked -> {
// The editor reads the provider again after this, so the row would otherwise sit there
// looking untapped for the whole handoff.
_state.update { it.copy(isLoading = true) }
val resolved = source.resolve(event.contact)
if (resolved != null) {
_actions.send(SelectContactAction.ContactResolved(resolved))
} else {
Log.w(TAG, "Could not resolve the selected contact.")
_state.update { it.copy(isLoading = false) }
_actions.send(SelectContactAction.CouldNotOpenContact)
}
}
SelectContactEvent.BackClicked -> _actions.send(SelectContactAction.Exit)
SelectContactEvent.AllowContactsAccessClicked -> {
if (contactsPermission.request()) {
rebuild()
} else if (contactsPermission.isPermanentlyDenied) {
recordPermissionDecision(ContactsPermissionState.PERMANENTLY_DENIED)
_state.update { it.copy(showPermissionDeniedSheet = contactsPermission.wasRefusedWithoutPrompting) }
}
}
SelectContactEvent.DismissContactsAccessClicked -> recordPermissionDecision(ContactsPermissionState.DISMISSED)
SelectContactEvent.OpenSystemContactPickerClicked -> _actions.send(SelectContactAction.LaunchSystemContactPicker)
is SelectContactEvent.SystemContactPicked -> {
if (event.phoneUri != null) {
_actions.send(SelectContactAction.ContactResolved(SharedContactSource.SystemPhone(event.phoneUri)))
}
}
SelectContactEvent.LearnMoreClicked -> {
_state.update { it.copy(showPermissionDeniedSheet = true) }
}
SelectContactEvent.PermissionDeniedSheetDismissed -> {
_state.update { it.copy(showPermissionDeniedSheet = false) }
}
}
}
override fun onCleared() {
super.onCleared()
source.close()
}
private fun recordPermissionDecision(decision: ContactsPermissionState) {
savedState[KEY_PERMISSION_DECISION] = decision.name
_state.update { it.copy(contactsPermission = decision) }
}
/** Also the path taken after access is granted, since the address book half was not read before. */
private suspend fun rebuild() {
_state.update { it.copy(isLoading = true) }
val result = source.build()
val built = when (result) {
is ContactIndexBuildResult.Success -> ContactsPermissionState.GRANTED
is ContactIndexBuildResult.SignalOnly -> ContactsPermissionState.DENIED
ContactIndexBuildResult.OutOfSpace -> {
Log.w(TAG, "Not enough space to index contacts. Showing an empty list.")
ContactsPermissionState.GRANTED
}
is ContactIndexBuildResult.Failure -> {
Log.w(TAG, "Could not index contacts. Showing an empty list.")
ContactsPermissionState.GRANTED
}
}
val permission = if (built == ContactsPermissionState.GRANTED) {
savedState.remove<String>(KEY_PERMISSION_DECISION)
ContactsPermissionState.GRANTED
} else {
savedState.get<String>(KEY_PERMISSION_DECISION)?.let { ContactsPermissionState.valueOf(it) } ?: built
}
val count = when (result) {
is ContactIndexBuildResult.Success -> result.count
is ContactIndexBuildResult.SignalOnly -> result.count
else -> 0
}
_state.update { it.copy(contactsPermission = permission, indexCount = count, isLoading = false) }
indexGeneration.update { it + 1 }
}
}
/**
* Interleaves section headers into a page of contacts.
*
* Relies on the index being ordered section major, so each section is one unbroken run. Filtered
* results are a subset and do not share those boundaries, which is why search renders flat.
*/
private fun PagingData<ContactIndexRecord>.withSectionHeaders(showHeaders: Boolean): PagingData<SelectContactRow> {
val rows: PagingData<SelectContactRow> = map { SelectContactRow.Contact(it) }
if (!showHeaders) {
return rows
}
return rows.insertSeparators { before, after ->
val nextSection = (after as? SelectContactRow.Contact)?.contact?.section ?: return@insertSeparators null
val previousSection = (before as? SelectContactRow.Contact)?.contact?.section
if (previousSection != nextSection) SelectContactRow.Header(nextSection) else null
}
}
@@ -6,7 +6,6 @@
package org.thoughtcrime.securesms.contactshare.screens.share
import android.content.Context
import android.net.Uri
import androidx.core.net.toUri
import kotlinx.coroutines.withContext
import org.signal.core.util.concurrent.SignalDispatchers
@@ -18,11 +17,16 @@ import org.thoughtcrime.securesms.contactshare.Contact
import org.thoughtcrime.securesms.contactshare.ContactCardReader
import org.thoughtcrime.securesms.contactshare.ContactUtil
import org.thoughtcrime.securesms.contactshare.EMAIL_PREFIX
import org.thoughtcrime.securesms.contactshare.NICKNAME_ID
import org.thoughtcrime.securesms.contactshare.NOTE_ID
import org.thoughtcrime.securesms.contactshare.ORGANIZATION_ID
import org.thoughtcrime.securesms.contactshare.PHONE_PREFIX
import org.thoughtcrime.securesms.contactshare.PHOTO_ID_ADDRESS_BOOK
import org.thoughtcrime.securesms.contactshare.PHOTO_ID_NONE
import org.thoughtcrime.securesms.contactshare.PHOTO_ID_SIGNAL_PROFILE
import org.thoughtcrime.securesms.contactshare.SharedContactSource
import org.thoughtcrime.securesms.contactshare.displayLines
import org.thoughtcrime.securesms.contactshare.displayText
import org.thoughtcrime.securesms.contactshare.labelText
import org.thoughtcrime.securesms.contactshare.resolveSignalRecipient
import org.thoughtcrime.securesms.contactshare.screens.editname.ContactNameParts
@@ -30,6 +34,7 @@ import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.profiles.AvatarHelper
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.RemoteConfig
import java.io.IOException
import java.util.Locale
@@ -44,11 +49,79 @@ class ShareContactRepository(
private val TAG = Log.tag(ShareContactRepository::class)
}
suspend fun load(uris: List<Uri>, recipientId: RecipientId?): LoadedContact? = withContext(SignalDispatchers.IO) {
val contact = reader.read(uris).firstOrNull() ?: return@withContext null
suspend fun load(source: SharedContactSource, recipientId: RecipientId?): LoadedContact? = withContext(SignalDispatchers.IO) {
val contact = when (source) {
is SharedContactSource.AddressBook -> reader.readSystemContact(source.contactUri)
is SharedContactSource.SystemPhone -> reader.readSystemPhone(source.dataUri)
is SharedContactSource.SignalContact -> Recipient.resolved(source.recipientId).toSharedContact()
is SharedContactSource.VCard -> reader.readVCard(source.uri)
} ?: return@withContext null
val sendingTo = recipientId?.let { Recipient.resolved(it).getDisplayName(context) } ?: ""
toLoadedContact(contact, sendingTo, recipientId)
toLoadedContact(contact, sendingTo, recipientId, (source as? SharedContactSource.AddressBook)?.recipientId)
}
/**
* Builds a shareable card for a Signal connection that has no address book entry.
*
* Everything comes from the profile, so there are no emails, addresses, or organization to offer.
* A card with no name cannot be rendered by the receiver, so a recipient without one has nothing
* worth sharing and yields null.
*/
private fun Recipient.toSharedContact(): Contact? {
val name = Contact.Name(
profileName.givenName.nullIfBlank(),
profileName.familyName.nullIfBlank(),
null,
null,
null,
null
)
if (name.givenName.isNullOrBlank() && name.familyName.isNullOrBlank()) {
Log.w(TAG, "Recipient has no name to share.")
return null
}
val phoneNumbers = e164
.map { listOf(Contact.Phone(it, Contact.Phone.Type.MOBILE, null)) }
.orElse(emptyList())
val avatar = profilePhotoBlobUri(id)?.let { Contact.Avatar(it.toUri(), true) }
return Contact(name, null, phoneNumbers, emptyList(), emptyList(), avatar).withSignalIdentity(this)
}
/**
* Stamps the Signal identity of the person on the card onto it, so the receiver can reach them
* without having to match on a phone number.
*
* The nickname and note are the sharer's own, private to them until they share the card. They are
* carried because the receiver has no other way to learn what the sharer calls this person; a
* recipient with neither leaves both absent.
*/
private fun Contact.withSignalIdentity(recipient: Recipient): Contact {
if (!RemoteConfig.contactSharingV2) {
return this
}
val nickname = Contact.SignalNickname(
recipient.nickname.givenName.nullIfBlank(),
recipient.nickname.familyName.nullIfBlank()
).takeUnless { it.isEmpty }
return Contact(
this.name,
this.organization,
this.phoneNumbers,
this.emails,
this.postalAddresses,
this.avatar,
recipient.aci.orElse(null)?.takeIf { it.isValid }?.toString(),
nickname,
recipient.note.nullIfBlank()
)
}
fun buildCard(contact: Contact, selection: ShareContactSelection): Contact {
@@ -60,7 +133,10 @@ class ShareContactRepository(
contact.phoneNumbers.selectedByIndex(selection.detailIds, PHONE_PREFIX),
contact.emails.selectedByIndex(selection.detailIds, EMAIL_PREFIX),
contact.postalAddresses.selectedByIndex(selection.detailIds, ADDRESS_PREFIX),
selection.photo?.let { Contact.Avatar(it.uri.toUri(), it.isProfile) }
selection.photo?.let { Contact.Avatar(it.uri.toUri(), it.isProfile) },
contact.aci,
contact.nickname?.takeIf { NICKNAME_ID in selection.detailIds },
contact.note?.takeIf { NOTE_ID in selection.detailIds }
)
}
@@ -73,8 +149,17 @@ class ShareContactRepository(
return if (!isOfferedAsRow || ORGANIZATION_ID in selection.detailIds) organization else null
}
private fun toLoadedContact(contact: Contact, sendingTo: String, recipientId: RecipientId?): LoadedContact {
val signalRecipient = contact.resolveSignalRecipient()
private fun toLoadedContact(source: Contact, sendingTo: String, recipientId: RecipientId?, subject: RecipientId? = null): LoadedContact {
val signalRecipient = subject ?: source.resolveSignalRecipient()
// An address book card only learns who it is on Signal once its numbers have been looked up, so
// the identity is stamped here rather than by the reader that built it.
val contact = if (source.aci == null && signalRecipient != null) {
source.withSignalIdentity(Recipient.resolved(signalRecipient))
} else {
source
}
val photoOptions = contact.resolvePhotoOptions(signalRecipient)
val displayName = ContactUtil.getDisplayName(contact)
@@ -141,10 +226,34 @@ class ShareContactRepository(
)
}
// Unselected by default. Both are the sharer's own private annotations about this person, so
// attaching one has to be a deliberate act rather than something that happens by not looking.
this.nickname?.takeUnless { it.isEmpty }?.let { nickname ->
details += ShareContactState.DetailSelection(
id = NICKNAME_ID,
lines = listOf(nickname.displayText()),
label = ShareContactState.DetailLabel.Nickname,
isSelected = false
)
}
this.note.nullIfBlank()?.let { note ->
details += ShareContactState.DetailSelection(
id = NOTE_ID,
lines = listOf(note),
label = ShareContactState.DetailLabel.Note,
isSelected = false
)
}
return details
}
/** Address book photo first per the design, Signal profile photo as the alternative. */
/**
* 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.
*/
private fun Contact.resolvePhotoOptions(signalRecipient: RecipientId?): List<ShareContactState.PhotoOption> {
val options = mutableListOf<ShareContactState.PhotoOption>()
val sharedAvatar = this.avatar
@@ -170,6 +279,10 @@ class ShareContactRepository(
)
}
if (options.isNotEmpty()) {
options += ShareContactState.PhotoOption(id = PHOTO_ID_NONE, photo = null)
}
return options
}
@@ -11,14 +11,17 @@ import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
@@ -30,7 +33,9 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -38,9 +43,12 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import org.signal.core.ui.compose.BottomSheets
@@ -52,9 +60,15 @@ import org.signal.core.ui.compose.Scaffolds
import org.signal.glide.compose.GlideImage
import org.signal.glide.decryptableuri.DecryptableUri
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.avatar.fallback.FallbackAvatar
import org.thoughtcrime.securesms.avatar.fallback.FallbackAvatarImage
import org.thoughtcrime.securesms.contactshare.PHOTO_ID_ADDRESS_BOOK
import org.thoughtcrime.securesms.contactshare.PHOTO_ID_NONE
import org.thoughtcrime.securesms.contactshare.PHOTO_ID_SIGNAL_PROFILE
import org.thoughtcrime.securesms.contactshare.screens.share.ShareContactState.ContactPhoto
import org.thoughtcrime.securesms.contactshare.screens.share.ShareContactState.DetailLabel
import org.thoughtcrime.securesms.contactshare.screens.share.ShareContactState.DetailSelection
import org.thoughtcrime.securesms.conversation.colors.AvatarColor
import org.thoughtcrime.securesms.recipients.RecipientId
import org.signal.core.ui.R as CoreUiR
@@ -62,7 +76,12 @@ import org.signal.core.ui.R as CoreUiR
private val SELECTION_COLUMN_WIDTH = 72.dp
private val HORIZONTAL_PADDING = 24.dp
private val AVATAR_SIZE = 72.dp
private val PICKER_PHOTO_SIZE = 120.dp
private val EDIT_BADGE_SIZE = 32.dp
/** The badge sits off the avatar's corner rather than tucked inside it. */
private val EDIT_BADGE_OVERHANG = 4.dp
private val PICKER_PHOTO_SIZE = 96.dp
private val PICKER_PHOTO_SPACING = 24.dp
@Composable
fun ShareContactScreen(
@@ -91,6 +110,7 @@ fun ShareContactScreen(
item {
AvatarRow(
avatar = state.avatar,
displayName = state.name?.displayName.orEmpty(),
onToggle = { onEvent(ShareContactEvent.AvatarToggled) },
onEditClick = { onEvent(ShareContactEvent.EditPhotoClicked) }
)
@@ -129,6 +149,7 @@ fun ShareContactScreen(
if (state.photoPicker != null) {
PhotoPickerSheet(
picker = state.photoPicker,
displayName = state.name?.displayName.orEmpty(),
onPhotoSelected = { onEvent(ShareContactEvent.PhotoSelected(it)) },
onConfirm = { onEvent(ShareContactEvent.PhotoPickerConfirmed) },
onDismiss = { onEvent(ShareContactEvent.PhotoPickerDismissed) }
@@ -139,6 +160,7 @@ fun ShareContactScreen(
@Composable
private fun AvatarRow(
avatar: ShareContactState.AvatarSelection,
displayName: String,
onToggle: () -> Unit,
onEditClick: () -> Unit
) {
@@ -155,6 +177,7 @@ private fun AvatarRow(
Box {
ContactPhotoImage(
photo = avatar.photo,
displayName = displayName,
modifier = Modifier.size(AVATAR_SIZE)
)
@@ -163,7 +186,8 @@ private fun AvatarRow(
contentAlignment = Alignment.Center,
modifier = Modifier
.align(Alignment.BottomEnd)
.size(32.dp)
.offset(x = EDIT_BADGE_OVERHANG, y = EDIT_BADGE_OVERHANG)
.size(EDIT_BADGE_SIZE)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceVariant)
.testTag(ShareContactTestTags.EDIT_PHOTO_BUTTON)
@@ -311,6 +335,9 @@ private fun Footer(
) {
val chatColor: Color? = recipientId?.let { LocalChatColorProvider.current(it.toLong()).value }
val sendBackground = chatColor ?: colorResource(CoreUiR.color.signal_light_colorPrimary)
val sendForeground = colorResource(R.color.conversation_send_button_tint)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
@@ -329,22 +356,20 @@ private fun Footer(
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
.background(
if (canSend) chatColor ?: MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant
)
.background(if (canSend) sendBackground else MaterialTheme.colorScheme.surfaceVariant)
.testTag(ShareContactTestTags.SEND_BUTTON)
.clickable(enabled = canSend, onClick = onSendClick)
) {
if (isSending) {
CircularProgressIndicator(
color = MaterialTheme.colorScheme.onPrimary,
color = sendForeground,
modifier = Modifier.size(20.dp)
)
} else {
Icon(
painter = painterResource(CoreUiR.drawable.symbol_send_fill_24),
contentDescription = stringResource(R.string.ShareContactScreen__send),
tint = if (canSend) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant
tint = if (canSend) sendForeground else MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
@@ -354,6 +379,7 @@ private fun Footer(
@Composable
private fun PhotoPickerSheet(
picker: ShareContactState.PhotoPicker,
displayName: String,
onPhotoSelected: (String) -> Unit,
onConfirm: () -> Unit,
onDismiss: () -> Unit
@@ -369,23 +395,34 @@ private fun PhotoPickerSheet(
modifier = Modifier.fillMaxWidth()
)
Row(
horizontalArrangement = Arrangement.spacedBy(44.dp, Alignment.CenterHorizontally),
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 24.dp)
) {
picker.options.forEach { option ->
Box(modifier = Modifier.clickable { onPhotoSelected(option.id) }) {
ContactPhotoImage(
photo = option.photo,
modifier = Modifier.size(PICKER_PHOTO_SIZE)
)
BoxWithConstraints(modifier = Modifier.fillMaxWidth()) {
val photoSize = pickerPhotoSize(available = maxWidth, count = picker.options.size)
SelectionCheck(
isSelected = option.id == picker.selectedId,
modifier = Modifier.align(Alignment.BottomEnd)
)
Row(
horizontalArrangement = Arrangement.spacedBy(PICKER_PHOTO_SPACING, Alignment.CenterHorizontally),
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 24.dp)
) {
picker.options.forEach { option ->
Box(
modifier = Modifier.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = ripple(bounded = false),
onClick = { onPhotoSelected(option.id) }
)
) {
ContactPhotoImage(
photo = option.photo,
displayName = displayName,
modifier = Modifier.size(photoSize)
)
SelectionCheck(
isSelected = option.id == picker.selectedId,
modifier = Modifier.align(Alignment.BottomEnd)
)
}
}
}
}
@@ -402,9 +439,29 @@ private fun PhotoPickerSheet(
}
}
/**
* Shrinks the photos on a narrow screen so that the row still fits, never growing them past the size
* the design calls for. The gaps hold their width, so only the circles give.
*/
private fun pickerPhotoSize(available: Dp, count: Int): Dp {
if (count <= 0) {
return PICKER_PHOTO_SIZE
}
val forPhotos = available - (HORIZONTAL_PADDING * 2) - (PICKER_PHOTO_SPACING * (count - 1))
return minOf(PICKER_PHOTO_SIZE, forPhotos / count)
}
/** A null [photo] is the "no photo" choice, drawn the way the receiver will draw it. */
@Composable
private fun ContactPhotoImage(photo: ContactPhoto, modifier: Modifier = Modifier) {
if (LocalInspectionMode.current) {
private fun ContactPhotoImage(photo: ContactPhoto?, displayName: String, modifier: Modifier = Modifier) {
if (photo == null) {
FallbackAvatarImage(
fallbackAvatar = FallbackAvatar.forTextOrDefault(displayName, AvatarColor.A100),
modifier = modifier
)
} else if (LocalInspectionMode.current) {
Image(
painter = painterResource(R.drawable.ic_avatar_abstract_02),
contentDescription = null,
@@ -490,18 +547,47 @@ private fun ShareContactScreenLockedNamePreview() {
@DayNightPreviews
@Composable
private fun ShareContactScreenPhotoPickerPreview() {
Previews.Preview {
ShareContactScreen(state = previewState(photoPicker = previewPicker()), onEvent = {})
}
}
@DayNightPreviews
@Composable
private fun ShareContactScreenNoPhotoPickedPreview() {
Previews.Preview {
ShareContactScreen(state = previewState(photoPicker = previewPicker(selectedId = PHOTO_ID_NONE)), onEvent = {})
}
}
/** What the row looks like once the no photo choice is committed. */
@DayNightPreviews
@Composable
private fun ShareContactScreenNoPhotoPreview() {
Previews.Preview {
ShareContactScreen(
state = previewState(
photoPicker = ShareContactState.PhotoPicker(
options = listOf(
ShareContactState.PhotoOption("address-book", ContactPhoto(uri = "", isProfile = false)),
ShareContactState.PhotoOption("signal-profile", ContactPhoto(uri = "", isProfile = true))
),
selectedId = "address-book"
)
),
state = previewState().let { it.copy(avatar = it.avatar?.copy(photo = null)) },
onEvent = {}
)
}
}
/** Narrow enough that the photos have to give up width to fit. */
@Preview(widthDp = 320)
@Composable
private fun ShareContactScreenNarrowPhotoPickerPreview() {
Previews.Preview {
ShareContactScreen(state = previewState(photoPicker = previewPicker()), onEvent = {})
}
}
private fun previewPicker(selectedId: String = PHOTO_ID_ADDRESS_BOOK): ShareContactState.PhotoPicker {
return ShareContactState.PhotoPicker(
options = listOf(
ShareContactState.PhotoOption(PHOTO_ID_ADDRESS_BOOK, ContactPhoto(uri = "", isProfile = false)),
ShareContactState.PhotoOption(PHOTO_ID_SIGNAL_PROFILE, ContactPhoto(uri = "", isProfile = true)),
ShareContactState.PhotoOption(PHOTO_ID_NONE, null)
),
selectedId = selectedId
)
}
@@ -23,7 +23,8 @@ data class ShareContactState(
data class AvatarSelection(
val isSelected: Boolean,
val photo: ContactPhoto,
/** Null renders the name's initials, and shares no photo. */
val photo: ContactPhoto?,
val isEditable: Boolean
)
@@ -49,7 +50,8 @@ data class ShareContactState(
data class PhotoOption(
val id: String,
val photo: ContactPhoto
/** Null for the "no photo" choice, which the picker draws as the initials fallback. */
val photo: ContactPhoto?
)
/** Profile photos are blobbed first, so sharing one is no different from an address book photo. */
@@ -5,7 +5,6 @@
package org.thoughtcrime.securesms.contactshare.screens.share
import android.net.Uri
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.channels.Channel
@@ -19,6 +18,7 @@ import kotlinx.coroutines.launch
import org.signal.core.ui.compose.EventDrivenViewModel
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.contactshare.Contact
import org.thoughtcrime.securesms.contactshare.SharedContactSource
import org.thoughtcrime.securesms.contactshare.screens.editname.ContactNameParts
import org.thoughtcrime.securesms.recipients.RecipientId
@@ -38,7 +38,7 @@ data class ShareContactSelection(
)
class ShareContactViewModel(
private val uris: List<Uri>,
private val contactSource: SharedContactSource?,
private val recipientId: RecipientId?,
private val repository: ShareContactRepository,
private val savedState: SavedStateHandle
@@ -81,7 +81,7 @@ class ShareContactViewModel(
override suspend fun processEvent(event: ShareContactEvent) {
when (event) {
ShareContactEvent.Initialize -> {
val loaded = repository.load(uris, recipientId)
val loaded = contactSource?.let { repository.load(it, recipientId) }
if (loaded == null) {
Log.w(TAG, "Could not read a contact to share.")
@@ -203,12 +203,13 @@ class ShareContactViewModel(
/** Returns the state untouched when nothing was saved, which is the normal first load. */
private fun ShareContactState.withSavedSelection(): ShareContactState {
val detailIds: List<String> = savedState.get<ArrayList<String>>(KEY_DETAIL_IDS) ?: return this
val savedPhoto = savedState.get<String>(KEY_PHOTO_ID)?.let { id -> photoOptions.firstOrNull { it.id == id }?.photo }
// Resolved to an option rather than to a photo, so that a saved "no photo" is not mistaken for nothing saved.
val savedOption = savedState.get<String>(KEY_PHOTO_ID)?.let { id -> photoOptions.firstOrNull { it.id == id } }
return copy(
avatar = avatar?.copy(
isSelected = savedState.get<Boolean>(KEY_AVATAR_SELECTED) ?: avatar.isSelected,
photo = savedPhoto ?: avatar.photo
photo = if (savedOption != null) savedOption.photo else avatar.photo
),
name = name?.copy(
isSelected = savedState.get<Boolean>(KEY_NAME_SELECTED) ?: name.isSelected,
@@ -113,10 +113,12 @@ class ConversationHeaderView : AbstractComposeView {
val isReleaseNotes = recipient.isReleaseNotes
val isOfficialAccount = recipient.showVerified
val hasUsernameOrSharedName = recipient.hasUsernameOrSharedName
val showUnverifiedName = if (recipient.isGroup) {
!info.groupInfo.nameVerified
} else if (!isOfficialAccount) {
recipient.nickname.isEmpty && !recipient.isSystemContact
recipient.nickname.isEmpty && !recipient.isSystemContact && !hasUsernameOrSharedName
} else {
false
}
@@ -139,6 +141,7 @@ class ConversationHeaderView : AbstractComposeView {
isReleaseNotes = isReleaseNotes,
badge = if (!isOfficialAccount) recipient.featuredBadge else null,
showUnverifiedName = showUnverifiedName,
hasUsernameOrSharedName = hasUsernameOrSharedName,
isGroup = recipient.isGroup,
hasWallpaper = recipient.hasWallpaper,
phoneNumber = phoneNumber,
@@ -188,6 +191,7 @@ private fun ConversationHeaderContent(
isReleaseNotes: Boolean = false,
badge: Badge?,
showUnverifiedName: Boolean,
hasUsernameOrSharedName: Boolean = false,
isGroup: Boolean,
hasWallpaper: Boolean = false,
phoneNumber: String? = null,
@@ -291,7 +295,9 @@ private fun ConversationHeaderContent(
)
}
if (!isSelf && !isReleaseNotes && (sharedGroups.isNotEmpty() || !isGroup)) {
val showSharedGroups = sharedGroups.isNotEmpty() || (!isGroup && !hasUsernameOrSharedName)
if (!isSelf && !isReleaseNotes && showSharedGroups) {
SharedGroupsDescription(
sharedGroups = sharedGroups,
modifier = Modifier.padding(top = 8.dp)
@@ -1374,7 +1374,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
if (joinCallLinkStub.resolved()) joinCallLinkStub.get().setVisibility(View.GONE);
paymentViewStub.setVisibility(View.GONE);
sharedContactStub.get().setContact(((MmsMessageRecord) messageRecord).getSharedContacts().get(0), requestManager, locale);
sharedContactStub.get().setContact(((MmsMessageRecord) messageRecord).getSharedContacts().get(0), conversationMessage.getSharedContactPresentation(), requestManager, locale);
sharedContactStub.get().setEventListener(sharedContactEventListener);
sharedContactStub.get().setOnClickListener(sharedContactClickListener);
sharedContactStub.get().setOnLongClickListener(passthroughClickListener);
@@ -2770,9 +2770,9 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
}
@Override
public void onMessageClicked(@NonNull List<Recipient> choices) {
public void onMessageClicked(@NonNull Contact contact, @NonNull List<Recipient> choices) {
if (eventListener != null && batchSelected.isEmpty()) {
eventListener.onMessageSharedContactClicked(choices);
eventListener.onMessageSharedContactClicked(contact, choices);
} else {
passthroughClickListener.onClick(sharedContactStub.get());
}
@@ -18,6 +18,8 @@ import org.thoughtcrime.securesms.database.CollapsedState;
import org.thoughtcrime.securesms.database.CollapsibleEvents;
import org.thoughtcrime.securesms.database.MentionUtil;
import org.thoughtcrime.securesms.database.NoSuchMessageException;
import org.thoughtcrime.securesms.contactshare.Contact;
import org.thoughtcrime.securesms.contactshare.SharedContactPresentation;
import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.database.model.Mention;
import org.thoughtcrime.securesms.database.model.MessageRecord;
@@ -44,20 +46,21 @@ public class ConversationMessage {
private static final String TAG = Log.tag(ConversationMessage.class);
@NonNull private final MessageRecord messageRecord;
@NonNull private final List<Mention> mentions;
@Nullable private final SpannableString body;
@NonNull private final MultiselectCollection multiselectCollection;
@NonNull private final MessageStyler.Result styleResult;
@NonNull private final Recipient threadRecipient;
private final boolean hasBeenQuoted;
@Nullable private final MessageRecord originalMessage;
@NonNull private final ComputedProperties computedProperties;
@Nullable private final MemberLabel memberLabel;
@Nullable private final MemberLabel quoteMemberLabel;
@Nullable private final Recipient deletedByRecipient;
private final int collapsedSize;
private final long collapsedExpirationInMs;
@NonNull private final MessageRecord messageRecord;
@NonNull private final List<Mention> mentions;
@Nullable private final SpannableString body;
@NonNull private final MultiselectCollection multiselectCollection;
@NonNull private final MessageStyler.Result styleResult;
@NonNull private final Recipient threadRecipient;
private final boolean hasBeenQuoted;
@Nullable private final MessageRecord originalMessage;
@NonNull private final ComputedProperties computedProperties;
@Nullable private final MemberLabel memberLabel;
@Nullable private final MemberLabel quoteMemberLabel;
@Nullable private final Recipient deletedByRecipient;
@NonNull private final SharedContactPresentation sharedContactPresentation;
private final int collapsedSize;
private final long collapsedExpirationInMs;
private ConversationMessage(@NonNull MessageRecord messageRecord,
@Nullable CharSequence body,
@@ -70,21 +73,23 @@ public class ConversationMessage {
@Nullable MemberLabel memberLabel,
@Nullable MemberLabel quoteMemberLabel,
@Nullable Recipient deletedByRecipient,
@Nullable SharedContactPresentation sharedContactPresentation,
int collapsedSize,
long collapsedExpirationInMs)
{
this.messageRecord = messageRecord;
this.hasBeenQuoted = hasBeenQuoted;
this.mentions = mentions != null ? mentions : Collections.emptyList();
this.styleResult = styleResult != null ? styleResult : MessageStyler.Result.none();
this.threadRecipient = threadRecipient;
this.originalMessage = originalMessage;
this.computedProperties = computedProperties;
this.memberLabel = memberLabel;
this.quoteMemberLabel = quoteMemberLabel;
this.deletedByRecipient = deletedByRecipient;
this.collapsedSize = collapsedSize;
this.collapsedExpirationInMs = collapsedExpirationInMs;
this.messageRecord = messageRecord;
this.hasBeenQuoted = hasBeenQuoted;
this.mentions = mentions != null ? mentions : Collections.emptyList();
this.styleResult = styleResult != null ? styleResult : MessageStyler.Result.none();
this.threadRecipient = threadRecipient;
this.originalMessage = originalMessage;
this.computedProperties = computedProperties;
this.memberLabel = memberLabel;
this.quoteMemberLabel = quoteMemberLabel;
this.deletedByRecipient = deletedByRecipient;
this.sharedContactPresentation = sharedContactPresentation != null ? sharedContactPresentation : SharedContactPresentation.EMPTY;
this.collapsedSize = collapsedSize;
this.collapsedExpirationInMs = collapsedExpirationInMs;
if (body != null) {
this.body = SpannableString.valueOf(body);
@@ -125,6 +130,10 @@ public class ConversationMessage {
return computedProperties;
}
public @NonNull SharedContactPresentation getSharedContactPresentation() {
return sharedContactPresentation;
}
public @Nullable MemberLabel getMemberLabel() {
return memberLabel;
}
@@ -257,7 +266,7 @@ public class ConversationMessage {
boolean hasBeenQuoted,
@NonNull Recipient threadRecipient)
{
return createWithUnresolvedData(context, messageRecord, body, mentions, hasBeenQuoted, threadRecipient, null);
return createWithUnresolvedData(context, messageRecord, body, mentions, hasBeenQuoted, threadRecipient, null, null);
}
/**
@@ -274,7 +283,8 @@ public class ConversationMessage {
@Nullable List<Mention> mentions,
boolean hasBeenQuoted,
@NonNull Recipient threadRecipient,
@Nullable Map<RecipientId, MemberLabel> prefetchedLabels)
@Nullable Map<RecipientId, MemberLabel> prefetchedLabels,
@Nullable SharedContactPresentation sharedContactPresentation)
{
SpannableString styledAndMentionBody = null;
MessageStyler.Result styleResult = MessageStyler.Result.none();
@@ -315,6 +325,9 @@ public class ConversationMessage {
}
}
SharedContactPresentation resolvedPresentation = sharedContactPresentation != null ? sharedContactPresentation
: resolveSharedContactPresentation(messageRecord);
return new ConversationMessage(messageRecord,
styledAndMentionBody != null ? styledAndMentionBody : mentionsUpdate != null ? mentionsUpdate.getBody() : body,
mentionsUpdate != null ? mentionsUpdate.getMentions() : null,
@@ -326,10 +339,22 @@ public class ConversationMessage {
memberLabel,
quoteMemberLabel,
deletedBy,
resolvedPresentation,
collapsedSize,
collapsedExpirationInMs);
}
@WorkerThread
private static @Nullable SharedContactPresentation resolveSharedContactPresentation(@NonNull MessageRecord messageRecord) {
if (!(messageRecord instanceof MmsMessageRecord)) {
return null;
}
List<Contact> contacts = ((MmsMessageRecord) messageRecord).getSharedContacts();
return contacts.isEmpty() ? null : SharedContactPresentation.resolve(contacts.get(0));
}
/**
* Creates a {@link ConversationMessage} wrapping the provided MessageRecord, and will query for potential mentions. If mentions
* are found, the body of the provided message will be updated and modified to match actual mentions. This will perform
@@ -42,7 +42,7 @@ object EmptyConversationAdapterListener : ConversationAdapter.ItemClickListener
override fun onViewOnceMessageClicked(messageRecord: MmsMessageRecord) = Unit
override fun onSharedContactDetailsClicked(contact: Contact, avatarTransitionView: View) = Unit
override fun onAddToContactsClicked(contact: Contact) = Unit
override fun onMessageSharedContactClicked(choices: List<Recipient?>) = Unit
override fun onMessageSharedContactClicked(contact: Contact, choices: List<Recipient?>) = Unit
override fun onInviteSharedContactClicked(contact: Contact) = Unit
override fun onReactionClicked(multiselectPart: MultiselectPart, messageId: Long, isMms: Boolean) = Unit
override fun onGroupMemberClicked(recipientId: RecipientId, groupId: GroupId) = Unit
@@ -23,6 +23,8 @@ import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.location.SignalPlace
import org.thoughtcrime.securesms.contactshare.Contact
import org.thoughtcrime.securesms.contactshare.ContactShareEditActivityV2
import org.thoughtcrime.securesms.contactshare.SelectContactActivity
import org.thoughtcrime.securesms.contactshare.SharedContactSource
import org.thoughtcrime.securesms.conversation.MessageSendType
import org.thoughtcrime.securesms.conversation.colors.ChatColors
import org.thoughtcrime.securesms.giph.ui.GiphyActivity
@@ -30,6 +32,7 @@ import org.thoughtcrime.securesms.maps.PlacePickerActivity
import org.thoughtcrime.securesms.mediasend.MediaSendActivityResult
import org.thoughtcrime.securesms.mediasend.MediaSendLauncher
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.RemoteConfig
/**
* This encapsulates the logic for interacting with other activities used throughout a conversation. The gist
@@ -46,7 +49,8 @@ class ConversationActivityResultContracts(private val fragment: Fragment, privat
}
private val contactShareLauncher = fragment.registerForActivityResult(ContactShareEditor) { contacts -> callbacks.onSendContacts(contacts) }
private val selectContactLauncher = fragment.registerForActivityResult(SelectContact) { uri -> callbacks.onContactSelect(uri) }
private val selectContactLauncher = fragment.registerForActivityResult(SelectContact) { source -> callbacks.onContactSelect(source) }
private val systemSelectContactLauncher = fragment.registerForActivityResult(SystemSelectContact) { source -> callbacks.onContactSelect(source) }
private val mediaSelectionLauncher = fragment.registerForActivityResult(MediaSelection) { result -> callbacks.onMediaSend(result) }
private val gifSearchLauncher = fragment.registerForActivityResult(GifSearch) { result -> callbacks.onMediaSend(result) }
private val mediaGalleryLauncher = fragment.registerForActivityResult(MediaGallery) { result -> callbacks.onMediaSend(result) }
@@ -54,18 +58,29 @@ class ConversationActivityResultContracts(private val fragment: Fragment, privat
private val selectFileLauncher = fragment.registerForActivityResult(SelectFile) { result -> callbacks.onFileSelected(result) }
private val cameraLauncher = fragment.registerForActivityResult(MediaCapture) { result -> callbacks.onMediaSend(result) }
fun launchContactShareEditor(uri: Uri, recipientId: RecipientId) {
contactShareLauncher.launch(uri to recipientId)
fun launchVCardShareEditor(uri: Uri, recipientId: RecipientId) {
contactShareLauncher.launch(SharedContactSource.VCard(uri) to recipientId)
}
fun launchContactShareEditor(source: SharedContactSource, recipientId: RecipientId) {
contactShareLauncher.launch(source to recipientId)
}
fun launchSelectContact() {
Permissions
.with(fragment)
.request(Manifest.permission.READ_CONTACTS)
.ifNecessary()
.withPermanentDenialDialog(fragment.getString(R.string.AttachmentManager_signal_requires_contacts_permission_in_order_to_attach_contact_information))
.onAllGranted { selectContactLauncher.launch(Unit) }
.execute()
if (RemoteConfig.contactSharingV2) {
// No permission gate on purpose. The new picker shows Signal connections without contacts
// permission and prompts for it inline, so asking in front of it would block a screen that
// works without it.
selectContactLauncher.launch(Unit)
} else {
Permissions
.with(fragment)
.request(Manifest.permission.READ_CONTACTS)
.ifNecessary()
.withPermanentDenialDialog(fragment.getString(R.string.AttachmentManager_signal_requires_contacts_permission_in_order_to_attach_contact_information))
.onAllGranted { systemSelectContactLauncher.launch(Unit) }
.execute()
}
}
fun launchGallery(recipientId: RecipientId, text: CharSequence?, isReply: Boolean) {
@@ -151,10 +166,14 @@ class ConversationActivityResultContracts(private val fragment: Fragment, privat
}
}
private object ContactShareEditor : ActivityResultContract<Pair<Uri, RecipientId>, List<Contact>>() {
override fun createIntent(context: Context, input: Pair<Uri, RecipientId>): Intent {
val (uri, recipientId) = input
return ContactShareEditActivityV2.getIntent(context, listOf(uri), recipientId)
/**
* Always the new editor, whichever picker fed it. Only contact selection is behind
* [RemoteConfig.contactSharingV2].
*/
private object ContactShareEditor : ActivityResultContract<Pair<SharedContactSource, RecipientId>, List<Contact>>() {
override fun createIntent(context: Context, input: Pair<SharedContactSource, RecipientId>): Intent {
val (source, recipientId) = input
return ContactShareEditActivityV2.getIntent(context, source, recipientId)
}
override fun parseResult(resultCode: Int, intent: Intent?): List<Contact> {
@@ -166,14 +185,29 @@ class ConversationActivityResultContracts(private val fragment: Fragment, privat
}
}
private object SelectContact : ActivityResultContract<Unit, Uri?>() {
/** The system contact picker, still used while [RemoteConfig.contactSharingV2] is off. */
private object SystemSelectContact : ActivityResultContract<Unit, SharedContactSource?>() {
override fun createIntent(context: Context, input: Unit): Intent {
return Intent(Intent.ACTION_PICK, ContactsContract.Contacts.CONTENT_URI)
}
override fun parseResult(resultCode: Int, intent: Intent?): Uri? {
override fun parseResult(resultCode: Int, intent: Intent?): SharedContactSource? {
return if (resultCode == Activity.RESULT_OK) {
intent?.data
intent?.data?.let { SharedContactSource.AddressBook(it) }
} else {
null
}
}
}
private object SelectContact : ActivityResultContract<Unit, SharedContactSource?>() {
override fun createIntent(context: Context, input: Unit): Intent {
return SelectContactActivity.getIntent(context)
}
override fun parseResult(resultCode: Int, intent: Intent?): SharedContactSource? {
return if (resultCode == Activity.RESULT_OK && intent != null) {
IntentCompat.getParcelableExtra(intent, SelectContactActivity.KEY_SOURCE, SharedContactSource::class.java)
} else {
null
}
@@ -249,7 +283,7 @@ class ConversationActivityResultContracts(private val fragment: Fragment, privat
interface Callbacks {
fun onSendContacts(contacts: List<Contact>)
fun onMediaSend(result: MediaSendActivityResult?)
fun onContactSelect(uri: Uri?)
fun onContactSelect(source: SharedContactSource?)
fun onLocationSelected(place: SignalPlace?, uri: Uri?)
fun onFileSelected(uri: Uri?)
}

Some files were not shown because too many files have changed in this diff Show More