mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-09-19 16:24:41 +01:00
Add ACI contact sharing support.
This commit is contained in:
committed by
Greyson Parrelli
parent
a7241605ea
commit
e94ce8a094
@@ -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")
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1
-1
@@ -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
|
||||
|
||||
|
||||
+1
-1
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
+2
@@ -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}",
|
||||
|
||||
+19
-3
@@ -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() &&
|
||||
|
||||
+15
@@ -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
|
||||
|
||||
+2
-2
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
+14
-2
@@ -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
|
||||
|
||||
+2
@@ -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,
|
||||
|
||||
+2
-2
@@ -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
|
||||
}
|
||||
+109
@@ -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) {
|
||||
|
||||
+11
-9
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
+5
-7
@@ -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
|
||||
}
|
||||
}
|
||||
+18
-2
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+30
@@ -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) {
|
||||
|
||||
+1
-2
@@ -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
|
||||
}
|
||||
|
||||
+30
-1
@@ -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",
|
||||
|
||||
+5
-4
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
|
||||
+27
-15
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
+66
@@ -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
|
||||
}
|
||||
+72
@@ -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() }
|
||||
}
|
||||
}
|
||||
+22
@@ -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
|
||||
}
|
||||
+38
@@ -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
|
||||
}
|
||||
+660
@@ -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()
|
||||
}
|
||||
+58
@@ -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
|
||||
}
|
||||
+220
@@ -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
|
||||
}
|
||||
}
|
||||
+121
-8
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+120
-34
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
+4
-2
@@ -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. */
|
||||
|
||||
+6
-5
@@ -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());
|
||||
}
|
||||
|
||||
+53
-28
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+52
-18
@@ -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
Reference in New Issue
Block a user