diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 36a0a0f360..f3739d829e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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") diff --git a/app/src/androidTest/assets/backupTests/chat_item_contact_message_00.binproto b/app/src/androidTest/assets/backupTests/chat_item_contact_message_00.binproto index 42e37e6b51..91fd208858 100644 Binary files a/app/src/androidTest/assets/backupTests/chat_item_contact_message_00.binproto and b/app/src/androidTest/assets/backupTests/chat_item_contact_message_00.binproto differ diff --git a/app/src/androidTest/assets/backupTests/chat_item_contact_message_01.binproto b/app/src/androidTest/assets/backupTests/chat_item_contact_message_01.binproto index c4213c7c7a..e41b8caf65 100644 Binary files a/app/src/androidTest/assets/backupTests/chat_item_contact_message_01.binproto and b/app/src/androidTest/assets/backupTests/chat_item_contact_message_01.binproto differ diff --git a/app/src/androidTest/assets/backupTests/chat_item_contact_message_02.binproto b/app/src/androidTest/assets/backupTests/chat_item_contact_message_02.binproto index a5cac24768..b464e36830 100644 Binary files a/app/src/androidTest/assets/backupTests/chat_item_contact_message_02.binproto and b/app/src/androidTest/assets/backupTests/chat_item_contact_message_02.binproto differ diff --git a/app/src/androidTest/assets/backupTests/chat_item_contact_message_03.binproto b/app/src/androidTest/assets/backupTests/chat_item_contact_message_03.binproto index 6a09241745..eecd06a70d 100644 Binary files a/app/src/androidTest/assets/backupTests/chat_item_contact_message_03.binproto and b/app/src/androidTest/assets/backupTests/chat_item_contact_message_03.binproto differ diff --git a/app/src/androidTest/assets/backupTests/chat_item_contact_message_04.binproto b/app/src/androidTest/assets/backupTests/chat_item_contact_message_04.binproto index 2a2045e8f6..1558cc222a 100644 Binary files a/app/src/androidTest/assets/backupTests/chat_item_contact_message_04.binproto and b/app/src/androidTest/assets/backupTests/chat_item_contact_message_04.binproto differ diff --git a/app/src/androidTest/assets/backupTests/chat_item_contact_message_05.binproto b/app/src/androidTest/assets/backupTests/chat_item_contact_message_05.binproto index c0bf0eb645..8ee0d6d2c1 100644 Binary files a/app/src/androidTest/assets/backupTests/chat_item_contact_message_05.binproto and b/app/src/androidTest/assets/backupTests/chat_item_contact_message_05.binproto differ diff --git a/app/src/androidTest/assets/backupTests/chat_item_contact_message_06.binproto b/app/src/androidTest/assets/backupTests/chat_item_contact_message_06.binproto index 8cb1ecb2a6..d068da183c 100644 Binary files a/app/src/androidTest/assets/backupTests/chat_item_contact_message_06.binproto and b/app/src/androidTest/assets/backupTests/chat_item_contact_message_06.binproto differ diff --git a/app/src/androidTest/assets/backupTests/chat_item_contact_message_07.binproto b/app/src/androidTest/assets/backupTests/chat_item_contact_message_07.binproto index 5ec25fb1f9..407b6922f2 100644 Binary files a/app/src/androidTest/assets/backupTests/chat_item_contact_message_07.binproto and b/app/src/androidTest/assets/backupTests/chat_item_contact_message_07.binproto differ diff --git a/app/src/androidTest/assets/backupTests/chat_item_contact_message_08.binproto b/app/src/androidTest/assets/backupTests/chat_item_contact_message_08.binproto index e724d14124..069662d311 100644 Binary files a/app/src/androidTest/assets/backupTests/chat_item_contact_message_08.binproto and b/app/src/androidTest/assets/backupTests/chat_item_contact_message_08.binproto differ diff --git a/app/src/androidTest/assets/backupTests/chat_item_contact_message_09.binproto b/app/src/androidTest/assets/backupTests/chat_item_contact_message_09.binproto index 9b3b3ecda9..06f66d0641 100644 Binary files a/app/src/androidTest/assets/backupTests/chat_item_contact_message_09.binproto and b/app/src/androidTest/assets/backupTests/chat_item_contact_message_09.binproto differ diff --git a/app/src/androidTest/assets/backupTests/chat_item_contact_message_10.binproto b/app/src/androidTest/assets/backupTests/chat_item_contact_message_10.binproto index adcabec834..4f9e5fdfe7 100644 Binary files a/app/src/androidTest/assets/backupTests/chat_item_contact_message_10.binproto and b/app/src/androidTest/assets/backupTests/chat_item_contact_message_10.binproto differ diff --git a/app/src/androidTest/assets/backupTests/chat_item_contact_message_11.binproto b/app/src/androidTest/assets/backupTests/chat_item_contact_message_11.binproto index b5890dceec..84b2512790 100644 Binary files a/app/src/androidTest/assets/backupTests/chat_item_contact_message_11.binproto and b/app/src/androidTest/assets/backupTests/chat_item_contact_message_11.binproto differ diff --git a/app/src/androidTest/assets/backupTests/chat_item_contact_message_12.binproto b/app/src/androidTest/assets/backupTests/chat_item_contact_message_12.binproto index b07c0712c9..75e16a5d02 100644 Binary files a/app/src/androidTest/assets/backupTests/chat_item_contact_message_12.binproto and b/app/src/androidTest/assets/backupTests/chat_item_contact_message_12.binproto differ diff --git a/app/src/androidTest/assets/backupTests/chat_item_contact_message_13.binproto b/app/src/androidTest/assets/backupTests/chat_item_contact_message_13.binproto index b98a123dfb..b0c384dc66 100644 Binary files a/app/src/androidTest/assets/backupTests/chat_item_contact_message_13.binproto and b/app/src/androidTest/assets/backupTests/chat_item_contact_message_13.binproto differ diff --git a/app/src/androidTest/assets/backupTests/chat_item_contact_message_14.binproto b/app/src/androidTest/assets/backupTests/chat_item_contact_message_14.binproto index 6589b3b03e..673c3e39af 100644 Binary files a/app/src/androidTest/assets/backupTests/chat_item_contact_message_14.binproto and b/app/src/androidTest/assets/backupTests/chat_item_contact_message_14.binproto differ diff --git a/app/src/androidTest/assets/backupTests/chat_item_learned_profile_update_00.binproto b/app/src/androidTest/assets/backupTests/chat_item_learned_profile_update_00.binproto index 95f06cd648..ffeaa8da78 100644 Binary files a/app/src/androidTest/assets/backupTests/chat_item_learned_profile_update_00.binproto and b/app/src/androidTest/assets/backupTests/chat_item_learned_profile_update_00.binproto differ diff --git a/app/src/androidTest/assets/backupTests/chat_item_learned_profile_update_01.binproto b/app/src/androidTest/assets/backupTests/chat_item_learned_profile_update_01.binproto index 83bf249c86..eb3cd8483c 100644 Binary files a/app/src/androidTest/assets/backupTests/chat_item_learned_profile_update_01.binproto and b/app/src/androidTest/assets/backupTests/chat_item_learned_profile_update_01.binproto differ diff --git a/app/src/androidTest/assets/backupTests/chat_item_learned_profile_update_02.binproto b/app/src/androidTest/assets/backupTests/chat_item_learned_profile_update_02.binproto index 1e65674dd8..1743777d83 100644 Binary files a/app/src/androidTest/assets/backupTests/chat_item_learned_profile_update_02.binproto and b/app/src/androidTest/assets/backupTests/chat_item_learned_profile_update_02.binproto differ diff --git a/app/src/androidTest/assets/backupTests/chat_item_learned_profile_update_03.binproto b/app/src/androidTest/assets/backupTests/chat_item_learned_profile_update_03.binproto index 5c1f0084b4..f60b5608fe 100644 Binary files a/app/src/androidTest/assets/backupTests/chat_item_learned_profile_update_03.binproto and b/app/src/androidTest/assets/backupTests/chat_item_learned_profile_update_03.binproto differ diff --git a/app/src/androidTest/assets/backupTests/chat_item_learned_profile_update_04.binproto b/app/src/androidTest/assets/backupTests/chat_item_learned_profile_update_04.binproto index 6d64ab3290..26603ea275 100644 Binary files a/app/src/androidTest/assets/backupTests/chat_item_learned_profile_update_04.binproto and b/app/src/androidTest/assets/backupTests/chat_item_learned_profile_update_04.binproto differ diff --git a/app/src/androidTest/assets/backupTests/chat_item_learned_profile_update_05.binproto b/app/src/androidTest/assets/backupTests/chat_item_learned_profile_update_05.binproto new file mode 100644 index 0000000000..7838f491cf Binary files /dev/null and b/app/src/androidTest/assets/backupTests/chat_item_learned_profile_update_05.binproto differ diff --git a/app/src/androidTest/assets/backupTests/chat_item_learned_profile_update_06.binproto b/app/src/androidTest/assets/backupTests/chat_item_learned_profile_update_06.binproto new file mode 100644 index 0000000000..06c38013be Binary files /dev/null and b/app/src/androidTest/assets/backupTests/chat_item_learned_profile_update_06.binproto differ diff --git a/app/src/androidTest/assets/backupTests/chat_item_learned_profile_update_07.binproto b/app/src/androidTest/assets/backupTests/chat_item_learned_profile_update_07.binproto new file mode 100644 index 0000000000..10f7b51319 Binary files /dev/null and b/app/src/androidTest/assets/backupTests/chat_item_learned_profile_update_07.binproto differ diff --git a/app/src/androidTest/assets/backupTests/recipient_contacts_00.binproto b/app/src/androidTest/assets/backupTests/recipient_contacts_00.binproto index 8dc53284f5..a5d2b5a2ac 100644 Binary files a/app/src/androidTest/assets/backupTests/recipient_contacts_00.binproto and b/app/src/androidTest/assets/backupTests/recipient_contacts_00.binproto differ diff --git a/app/src/androidTest/assets/backupTests/recipient_contacts_01.binproto b/app/src/androidTest/assets/backupTests/recipient_contacts_01.binproto index f57a4e49c6..4701a867fc 100644 Binary files a/app/src/androidTest/assets/backupTests/recipient_contacts_01.binproto and b/app/src/androidTest/assets/backupTests/recipient_contacts_01.binproto differ diff --git a/app/src/androidTest/assets/backupTests/recipient_contacts_02.binproto b/app/src/androidTest/assets/backupTests/recipient_contacts_02.binproto index 4a0e50528c..a67750a666 100644 Binary files a/app/src/androidTest/assets/backupTests/recipient_contacts_02.binproto and b/app/src/androidTest/assets/backupTests/recipient_contacts_02.binproto differ diff --git a/app/src/androidTest/assets/backupTests/recipient_contacts_03.binproto b/app/src/androidTest/assets/backupTests/recipient_contacts_03.binproto index 95dc318367..a1926eba60 100644 Binary files a/app/src/androidTest/assets/backupTests/recipient_contacts_03.binproto and b/app/src/androidTest/assets/backupTests/recipient_contacts_03.binproto differ diff --git a/app/src/androidTest/assets/backupTests/recipient_contacts_04.binproto b/app/src/androidTest/assets/backupTests/recipient_contacts_04.binproto index f70220929f..ae4138e6a7 100644 Binary files a/app/src/androidTest/assets/backupTests/recipient_contacts_04.binproto and b/app/src/androidTest/assets/backupTests/recipient_contacts_04.binproto differ diff --git a/app/src/androidTest/assets/backupTests/recipient_contacts_05.binproto b/app/src/androidTest/assets/backupTests/recipient_contacts_05.binproto index 6b67cc92f5..62ce496efb 100644 Binary files a/app/src/androidTest/assets/backupTests/recipient_contacts_05.binproto and b/app/src/androidTest/assets/backupTests/recipient_contacts_05.binproto differ diff --git a/app/src/androidTest/assets/backupTests/recipient_contacts_06.binproto b/app/src/androidTest/assets/backupTests/recipient_contacts_06.binproto index 1ab4fba7b0..4d71ac74d8 100644 Binary files a/app/src/androidTest/assets/backupTests/recipient_contacts_06.binproto and b/app/src/androidTest/assets/backupTests/recipient_contacts_06.binproto differ diff --git a/app/src/androidTest/assets/backupTests/recipient_contacts_07.binproto b/app/src/androidTest/assets/backupTests/recipient_contacts_07.binproto index 233e1ce30d..dc00d7ddbf 100644 Binary files a/app/src/androidTest/assets/backupTests/recipient_contacts_07.binproto and b/app/src/androidTest/assets/backupTests/recipient_contacts_07.binproto differ diff --git a/app/src/androidTest/assets/backupTests/recipient_contacts_08.binproto b/app/src/androidTest/assets/backupTests/recipient_contacts_08.binproto index 6608f92207..938b59f205 100644 Binary files a/app/src/androidTest/assets/backupTests/recipient_contacts_08.binproto and b/app/src/androidTest/assets/backupTests/recipient_contacts_08.binproto differ diff --git a/app/src/androidTest/assets/backupTests/recipient_contacts_09.binproto b/app/src/androidTest/assets/backupTests/recipient_contacts_09.binproto index f8ba1ad000..cea2c07f9a 100644 Binary files a/app/src/androidTest/assets/backupTests/recipient_contacts_09.binproto and b/app/src/androidTest/assets/backupTests/recipient_contacts_09.binproto differ diff --git a/app/src/androidTest/assets/backupTests/recipient_contacts_10.binproto b/app/src/androidTest/assets/backupTests/recipient_contacts_10.binproto index fb7f62f4a1..4214dd93d8 100644 Binary files a/app/src/androidTest/assets/backupTests/recipient_contacts_10.binproto and b/app/src/androidTest/assets/backupTests/recipient_contacts_10.binproto differ diff --git a/app/src/androidTest/assets/backupTests/recipient_contacts_11.binproto b/app/src/androidTest/assets/backupTests/recipient_contacts_11.binproto index 9d95054b76..15f6847d05 100644 Binary files a/app/src/androidTest/assets/backupTests/recipient_contacts_11.binproto and b/app/src/androidTest/assets/backupTests/recipient_contacts_11.binproto differ diff --git a/app/src/androidTest/assets/backupTests/recipient_groups_00.binproto b/app/src/androidTest/assets/backupTests/recipient_groups_00.binproto index 28acd273d0..2d9e61b3b6 100644 Binary files a/app/src/androidTest/assets/backupTests/recipient_groups_00.binproto and b/app/src/androidTest/assets/backupTests/recipient_groups_00.binproto differ diff --git a/app/src/androidTest/assets/backupTests/recipient_groups_01.binproto b/app/src/androidTest/assets/backupTests/recipient_groups_01.binproto index ec9470b1cf..fa700dbb5e 100644 Binary files a/app/src/androidTest/assets/backupTests/recipient_groups_01.binproto and b/app/src/androidTest/assets/backupTests/recipient_groups_01.binproto differ diff --git a/app/src/androidTest/assets/backupTests/recipient_groups_02.binproto b/app/src/androidTest/assets/backupTests/recipient_groups_02.binproto index ab17746c55..23aff358a4 100644 Binary files a/app/src/androidTest/assets/backupTests/recipient_groups_02.binproto and b/app/src/androidTest/assets/backupTests/recipient_groups_02.binproto differ diff --git a/app/src/androidTest/assets/backupTests/recipient_groups_03.binproto b/app/src/androidTest/assets/backupTests/recipient_groups_03.binproto index 7142bb9b34..e66c5e6dc4 100644 Binary files a/app/src/androidTest/assets/backupTests/recipient_groups_03.binproto and b/app/src/androidTest/assets/backupTests/recipient_groups_03.binproto differ diff --git a/app/src/androidTest/assets/backupTests/recipient_groups_04.binproto b/app/src/androidTest/assets/backupTests/recipient_groups_04.binproto index aa5d0f7a8e..24f981e6fd 100644 Binary files a/app/src/androidTest/assets/backupTests/recipient_groups_04.binproto and b/app/src/androidTest/assets/backupTests/recipient_groups_04.binproto differ diff --git a/app/src/androidTest/assets/backupTests/recipient_groups_05.binproto b/app/src/androidTest/assets/backupTests/recipient_groups_05.binproto index 0c76434e45..5f92e61a97 100644 Binary files a/app/src/androidTest/assets/backupTests/recipient_groups_05.binproto and b/app/src/androidTest/assets/backupTests/recipient_groups_05.binproto differ diff --git a/app/src/androidTest/assets/backupTests/recipient_groups_06.binproto b/app/src/androidTest/assets/backupTests/recipient_groups_06.binproto index 3bbba712f5..fb855c7335 100644 Binary files a/app/src/androidTest/assets/backupTests/recipient_groups_06.binproto and b/app/src/androidTest/assets/backupTests/recipient_groups_06.binproto differ diff --git a/app/src/androidTest/assets/backupTests/recipient_groups_07.binproto b/app/src/androidTest/assets/backupTests/recipient_groups_07.binproto index c4abfb693c..7ba234776b 100644 Binary files a/app/src/androidTest/assets/backupTests/recipient_groups_07.binproto and b/app/src/androidTest/assets/backupTests/recipient_groups_07.binproto differ diff --git a/app/src/androidTest/assets/backupTests/recipient_groups_08.binproto b/app/src/androidTest/assets/backupTests/recipient_groups_08.binproto index 12e98624dc..e68cf9276d 100644 Binary files a/app/src/androidTest/assets/backupTests/recipient_groups_08.binproto and b/app/src/androidTest/assets/backupTests/recipient_groups_08.binproto differ diff --git a/app/src/androidTest/assets/backupTests/recipient_groups_09.binproto b/app/src/androidTest/assets/backupTests/recipient_groups_09.binproto index 150af177c0..54e792af95 100644 Binary files a/app/src/androidTest/assets/backupTests/recipient_groups_09.binproto and b/app/src/androidTest/assets/backupTests/recipient_groups_09.binproto differ diff --git a/app/src/androidTest/assets/backupTests/recipient_groups_10.binproto b/app/src/androidTest/assets/backupTests/recipient_groups_10.binproto index 6cc5b289fa..9622285555 100644 Binary files a/app/src/androidTest/assets/backupTests/recipient_groups_10.binproto and b/app/src/androidTest/assets/backupTests/recipient_groups_10.binproto differ diff --git a/app/src/androidTest/assets/backupTests/recipient_groups_11.binproto b/app/src/androidTest/assets/backupTests/recipient_groups_11.binproto index 0ef98c8e27..2b38b8f64b 100644 Binary files a/app/src/androidTest/assets/backupTests/recipient_groups_11.binproto and b/app/src/androidTest/assets/backupTests/recipient_groups_11.binproto differ diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/conversation/v2/items/V2ConversationItemShapeTest.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/conversation/v2/items/V2ConversationItemShapeTest.kt index 9f546d2cca..4cf10ec7b7 100644 --- a/app/src/androidTest/java/org/thoughtcrime/securesms/conversation/v2/items/V2ConversationItemShapeTest.kt +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/conversation/v2/items/V2ConversationItemShapeTest.kt @@ -251,7 +251,7 @@ class V2ConversationItemShapeTest { override fun onAddToContactsClicked(contact: Contact) = Unit - override fun onMessageSharedContactClicked(choices: MutableList) = Unit + override fun onMessageSharedContactClicked(contact: Contact, choices: MutableList) = Unit override fun onInviteSharedContactClicked(contact: Contact) = Unit diff --git a/app/src/debug/java/org/thoughtcrime/securesms/components/settings/app/internal/conversation/test/InternalConversationTestFragment.kt b/app/src/debug/java/org/thoughtcrime/securesms/components/settings/app/internal/conversation/test/InternalConversationTestFragment.kt index cd2eec067e..13b6918de3 100644 --- a/app/src/debug/java/org/thoughtcrime/securesms/components/settings/app/internal/conversation/test/InternalConversationTestFragment.kt +++ b/app/src/debug/java/org/thoughtcrime/securesms/components/settings/app/internal/conversation/test/InternalConversationTestFragment.kt @@ -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) { + override fun onMessageSharedContactClicked(contact: Contact, choices: MutableList) { Toast.makeText(requireContext(), "Can't touch this.", Toast.LENGTH_SHORT).show() } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 52e78490a2..99dbabf308 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -814,7 +814,11 @@ android:theme="@style/Theme.Signal.DayNight.NoActionBar" android:windowSoftInputMode="adjustResize" /> - + DownloadLatestEmojiDataJob.scheduleIfNecessary(this)) .addPostRender(EmojiSearchIndexDownloadJob::scheduleIfNecessary) .addPostRender(MessageSendLogCleanupJob::enqueue) + .addPostRender(() -> ContactIndexRepository.deleteAbandonedIndex(this)) .addPostRender(() -> JumboEmoji.updateCurrentVersion(this)) .addPostRender(RetrieveRemoteAnnouncementsJob::enqueue) .addPostRender(AndroidTelecomUtil::registerPhoneAccount) diff --git a/app/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java b/app/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java index 2a9faea8d6..8ba7f8ace9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java +++ b/app/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java @@ -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 choices); + void onMessageSharedContactClicked(@NonNull Contact contact, @NonNull List choices); void onInviteSharedContactClicked(@NonNull Contact contact); void onReactionClicked(@NonNull MultiselectPart multiselectPart, long messageId, boolean isMms); void onGroupMemberClicked(@NonNull RecipientId recipientId, @NonNull GroupId groupId); diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/RecipientTableArchiveExtensions.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/RecipientTableArchiveExtensions.kt index e1cf16abfa..0506f29fb1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/RecipientTableArchiveExtensions.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/RecipientTableArchiveExtensions.kt @@ -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}", diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/exporters/ChatItemArchiveExporter.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/exporters/ChatItemArchiveExporter.kt index b7f48cb226..fe821d9e15 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/exporters/ChatItemArchiveExporter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/exporters/ChatItemArchiveExporter.kt @@ -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 return Contact.Visibility.VISIBLE diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/exporters/GroupArchiveExporter.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/exporters/GroupArchiveExporter.kt index 5d85563d99..b69bc8a3b1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/exporters/GroupArchiveExporter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/exporters/GroupArchiveExporter.kt @@ -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 ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/importer/ChatItemArchiveImporter.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/importer/ChatItemArchiveImporter.kt index ad879c94a4..6700404016 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/importer/ChatItemArchiveImporter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/importer/ChatItemArchiveImporter.kt @@ -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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/importer/ContactArchiveImporter.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/importer/ContactArchiveImporter.kt index b073026a32..b825c27077 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/importer/ContactArchiveImporter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/importer/ContactArchiveImporter.kt @@ -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, diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/importer/GroupArchiveImporter.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/importer/GroupArchiveImporter.kt index 1030fbdade..a8aa8cf88d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/importer/GroupArchiveImporter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/importer/GroupArchiveImporter.kt @@ -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 ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/SharedContactView.java b/app/src/main/java/org/thoughtcrime/securesms/components/SharedContactView.java index 8e98e495bd..f07ffabb36 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/SharedContactView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/SharedContactView.java @@ -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 registered = new ArrayList<>(activeRecipients.size()); + List 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 choices); + void onMessageClicked(@NonNull Contact contact, @NonNull List choices); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/index/ContactDisplayName.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/index/ContactDisplayName.kt new file mode 100644 index 0000000000..399080830e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/index/ContactDisplayName.kt @@ -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() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/index/ContactIndexBuilder.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/index/ContactIndexBuilder.kt new file mode 100644 index 0000000000..a5f549b8c0 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/index/ContactIndexBuilder.kt @@ -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> = SignalDatabase.recipients.getSystemContactLinksByLookupKey() + val signalOnly: List = 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, + 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>, + 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(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>, + sortKeys: ContactSortKeyGenerator + ): List { + 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 + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/index/ContactIndexDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/index/ContactIndexDatabase.kt new file mode 100644 index 0000000000..20caac4862 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/index/ContactIndexDatabase.kt @@ -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) { + 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 { + return database + .query("SELECT ${ROW_COLUMNS.joinToString(", ")} FROM $TABLE_NAME WHERE $ID >= ? ORDER BY $ID LIMIT ?", arrayOf(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 { + 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(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) + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/index/ContactIndexModels.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/index/ContactIndexModels.kt new file mode 100644 index 0000000000..a680a36b06 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/index/ContactIndexModels.kt @@ -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 +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/index/ContactIndexRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/index/ContactIndexRepository.kt new file mode 100644 index 0000000000..6df213b63a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/index/ContactIndexRepository.kt @@ -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 { + 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 { + 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): List { + 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 + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/contactshare/Contact.java b/app/src/main/java/org/thoughtcrime/securesms/contactshare/Contact.java deleted file mode 100644 index 3bbec20dad..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/contactshare/Contact.java +++ /dev/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 phoneNumbers; - - @JsonProperty - private final List emails; - - @JsonProperty - private final List postalAddresses; - - @JsonProperty - private final Avatar avatar; - - public Contact(@JsonProperty("name") @Nullable Name name, - @JsonProperty("organization") @Nullable String organization, - @JsonProperty("phoneNumbers") @NonNull List phoneNumbers, - @JsonProperty("emails") @NonNull List emails, - @JsonProperty("postalAddresses") @NonNull List 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 getPhoneNumbers() { - return phoneNumbers; - } - - public @NonNull List getEmails() { - return emails; - } - - public @NonNull List 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 CREATOR = new Creator() { - @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 CREATOR = new Creator() { - @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 CREATOR = new Creator() { - @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 CREATOR = new Creator() { - @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 CREATOR = new Creator() { - @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 CREATOR = new Creator() { - @Override - public Avatar createFromParcel(Parcel in) { - return new Avatar(in); - } - - @Override - public Avatar[] newArray(int size) { - return new Avatar[size]; - } - }; - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/contactshare/Contact.kt b/app/src/main/java/org/thoughtcrime/securesms/contactshare/Contact.kt new file mode 100644 index 0000000000..8af748ee77 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/contactshare/Contact.kt @@ -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, + @JsonProperty("emails") emails: List, + @JsonProperty("postalAddresses") postalAddresses: List, + @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 = phoneNumbers.toList() + + @get:JsonProperty("emails") + val emails: List = emails.toList() + + @get:JsonProperty("postalAddresses") + val postalAddresses: List = 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 = object : Parcelable.Creator { + override fun createFromParcel(parcel: Parcel): Contact = Contact(parcel) + override fun newArray(size: Int): Array = 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 = object : Parcelable.Creator { + override fun createFromParcel(parcel: Parcel): Avatar = Avatar(parcel) + override fun newArray(size: Int): Array = 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 Parcel.writeParcelableListCompat(values: List, flags: Int) { + writeInt(values.size) + values.forEach { writeParcelable(it, flags) } +} + +private fun Parcel.readParcelableListCompat(clazz: Class): List { + return (0 until readInt()).mapNotNull { readParcelableCompat(clazz) } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/contactshare/ContactCardReader.kt b/app/src/main/java/org/thoughtcrime/securesms/contactshare/ContactCardReader.kt index f1780394f1..1b5ef0befd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contactshare/ContactCardReader.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contactshare/ContactCardReader.kt @@ -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): List { - 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): Avatar? { return phoneNumbers .asSequence() .mapNotNull { SignalE164Util.formatAsE164(it.number) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/contactshare/ContactModelMapper.java b/app/src/main/java/org/thoughtcrime/securesms/contactshare/ContactModelMapper.java index 821fe1ed9b..8f634c1d1d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contactshare/ContactModelMapper.java +++ b/app/src/main/java/org/thoughtcrime/securesms/contactshare/ContactModelMapper.java @@ -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) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/contactshare/ContactShareEditActivityV2.kt b/app/src/main/java/org/thoughtcrime/securesms/contactshare/ContactShareEditActivityV2.kt index 040e5fc27f..17e6a4a1c3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contactshare/ContactShareEditActivityV2.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contactshare/ContactShareEditActivityV2.kt @@ -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, 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 = 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() } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/contactshare/ContactShareEditFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/contactshare/ContactShareEditFragment.kt index 215ffbf8c6..abec9c07af 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contactshare/ContactShareEditFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contactshare/ContactShareEditFragment.kt @@ -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, 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() diff --git a/app/src/main/java/org/thoughtcrime/securesms/contactshare/ContactShareFormatting.kt b/app/src/main/java/org/thoughtcrime/securesms/contactshare/ContactShareFormatting.kt index a363d30f06..d6c9c24c04 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contactshare/ContactShareFormatting.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contactshare/ContactShareFormatting.kt @@ -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) diff --git a/app/src/main/java/org/thoughtcrime/securesms/contactshare/SelectContactActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/contactshare/SelectContactActivity.kt new file mode 100644 index 0000000000..1c91c72356 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/contactshare/SelectContactActivity.kt @@ -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() + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/contactshare/SelectContactFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/contactshare/SelectContactFragment.kt new file mode 100644 index 0000000000..9ab6f5854a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/contactshare/SelectContactFragment.kt @@ -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) { + 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() { + 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 + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/contactshare/SharedContactDetailsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/contactshare/SharedContactDetailsFragment.kt index edabdbca1f..25a7227388 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contactshare/SharedContactDetailsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contactshare/SharedContactDetailsFragment.kt @@ -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()?.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 + ) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/contactshare/SharedContactPresentation.kt b/app/src/main/java/org/thoughtcrime/securesms/contactshare/SharedContactPresentation.kt new file mode 100644 index 0000000000..ae9c1465e6 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/contactshare/SharedContactPresentation.kt @@ -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 +) { + 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) + ) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/contactshare/SharedContactSource.kt b/app/src/main/java/org/thoughtcrime/securesms/contactshare/SharedContactSource.kt new file mode 100644 index 0000000000..1433a8d2cf --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/contactshare/SharedContactSource.kt @@ -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 +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/contactshare/VCardUtil.java b/app/src/main/java/org/thoughtcrime/securesms/contactshare/VCardUtil.java index ca4a1a06d9..1d120df147 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contactshare/VCardUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/contactshare/VCardUtil.java @@ -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) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/contactshare/screens/details/SharedContactDetailsAction.kt b/app/src/main/java/org/thoughtcrime/securesms/contactshare/screens/details/SharedContactDetailsAction.kt index a0a30cd596..827c3de046 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contactshare/screens/details/SharedContactDetailsAction.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contactshare/screens/details/SharedContactDetailsAction.kt @@ -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 } diff --git a/app/src/main/java/org/thoughtcrime/securesms/contactshare/screens/details/SharedContactDetailsRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/contactshare/screens/details/SharedContactDetailsRepository.kt index 95d3303ffe..0f6d09344f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contactshare/screens/details/SharedContactDetailsRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contactshare/screens/details/SharedContactDetailsRepository.kt @@ -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", diff --git a/app/src/main/java/org/thoughtcrime/securesms/contactshare/screens/details/SharedContactDetailsScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/contactshare/screens/details/SharedContactDetailsScreen.kt index f51019b6be..938642f8f4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contactshare/screens/details/SharedContactDetailsScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contactshare/screens/details/SharedContactDetailsScreen.kt @@ -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 ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/contactshare/screens/details/SharedContactDetailsState.kt b/app/src/main/java/org/thoughtcrime/securesms/contactshare/screens/details/SharedContactDetailsState.kt index b5e0719e8f..ba402950ad 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contactshare/screens/details/SharedContactDetailsState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contactshare/screens/details/SharedContactDetailsState.kt @@ -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, diff --git a/app/src/main/java/org/thoughtcrime/securesms/contactshare/screens/details/SharedContactDetailsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/contactshare/screens/details/SharedContactDetailsViewModel.kt index 39a4ada231..be80647581 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contactshare/screens/details/SharedContactDetailsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contactshare/screens/details/SharedContactDetailsViewModel.kt @@ -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)) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/contactshare/screens/selectcontact/ContactIndexPagingSource.kt b/app/src/main/java/org/thoughtcrime/securesms/contactshare/screens/selectcontact/ContactIndexPagingSource.kt new file mode 100644 index 0000000000..0d75074095 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/contactshare/screens/selectcontact/ContactIndexPagingSource.kt @@ -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() { + + 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): LoadResult { + 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 { + 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? = null +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/contactshare/screens/selectcontact/ContactIndexSource.kt b/app/src/main/java/org/thoughtcrime/securesms/contactshare/screens/selectcontact/ContactIndexSource.kt new file mode 100644 index 0000000000..4536009716 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/contactshare/screens/selectcontact/ContactIndexSource.kt @@ -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 = 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() } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/contactshare/screens/selectcontact/SelectContactAction.kt b/app/src/main/java/org/thoughtcrime/securesms/contactshare/screens/selectcontact/SelectContactAction.kt new file mode 100644 index 0000000000..c4116daea2 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/contactshare/screens/selectcontact/SelectContactAction.kt @@ -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 +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/contactshare/screens/selectcontact/SelectContactEvent.kt b/app/src/main/java/org/thoughtcrime/securesms/contactshare/screens/selectcontact/SelectContactEvent.kt new file mode 100644 index 0000000000..4dbd812d3f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/contactshare/screens/selectcontact/SelectContactEvent.kt @@ -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 +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/contactshare/screens/selectcontact/SelectContactScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/contactshare/screens/selectcontact/SelectContactScreen.kt new file mode 100644 index 0000000000..24bd8ee46c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/contactshare/screens/selectcontact/SelectContactScreen.kt @@ -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, + 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, + 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): LazyPagingItems { + return remember(rows) { flowOf(PagingData.from(rows)) }.collectAsLazyPagingItems() +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/contactshare/screens/selectcontact/SelectContactState.kt b/app/src/main/java/org/thoughtcrime/securesms/contactshare/screens/selectcontact/SelectContactState.kt new file mode 100644 index 0000000000..2a6b04eca9 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/contactshare/screens/selectcontact/SelectContactState.kt @@ -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 +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/contactshare/screens/selectcontact/SelectContactViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/contactshare/screens/selectcontact/SelectContactViewModel.kt new file mode 100644 index 0000000000..85f78df062 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/contactshare/screens/selectcontact/SelectContactViewModel.kt @@ -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(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 = _state.asStateFlow() + + private val _actions = Channel(Channel.BUFFERED) + val actions: Flow = _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> = 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(KEY_PERMISSION_DECISION) + ContactsPermissionState.GRANTED + } else { + savedState.get(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.withSectionHeaders(showHeaders: Boolean): PagingData { + val rows: PagingData = 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 + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/contactshare/screens/share/ShareContactRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/contactshare/screens/share/ShareContactRepository.kt index 4ab2844e4e..5edf0cea38 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contactshare/screens/share/ShareContactRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contactshare/screens/share/ShareContactRepository.kt @@ -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, 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 { val options = mutableListOf() val sharedAvatar = this.avatar @@ -170,6 +279,10 @@ class ShareContactRepository( ) } + if (options.isNotEmpty()) { + options += ShareContactState.PhotoOption(id = PHOTO_ID_NONE, photo = null) + } + return options } diff --git a/app/src/main/java/org/thoughtcrime/securesms/contactshare/screens/share/ShareContactScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/contactshare/screens/share/ShareContactScreen.kt index 49a0b0e95e..4b2ee49896 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contactshare/screens/share/ShareContactScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contactshare/screens/share/ShareContactScreen.kt @@ -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 + ) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/contactshare/screens/share/ShareContactState.kt b/app/src/main/java/org/thoughtcrime/securesms/contactshare/screens/share/ShareContactState.kt index cc3d804ae4..8271a9e871 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contactshare/screens/share/ShareContactState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contactshare/screens/share/ShareContactState.kt @@ -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. */ diff --git a/app/src/main/java/org/thoughtcrime/securesms/contactshare/screens/share/ShareContactViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/contactshare/screens/share/ShareContactViewModel.kt index bfa438fe0e..9604c6ab7d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contactshare/screens/share/ShareContactViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contactshare/screens/share/ShareContactViewModel.kt @@ -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, + 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 = savedState.get>(KEY_DETAIL_IDS) ?: return this - val savedPhoto = savedState.get(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(KEY_PHOTO_ID)?.let { id -> photoOptions.firstOrNull { it.id == id } } return copy( avatar = avatar?.copy( isSelected = savedState.get(KEY_AVATAR_SELECTED) ?: avatar.isSelected, - photo = savedPhoto ?: avatar.photo + photo = if (savedOption != null) savedOption.photo else avatar.photo ), name = name?.copy( isSelected = savedState.get(KEY_NAME_SELECTED) ?: name.isSelected, diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationHeaderView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationHeaderView.kt index a09b62ef1c..208ddc1f0c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationHeaderView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationHeaderView.kt @@ -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) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java index 6f814c7279..b059f8b160 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java @@ -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 choices) { + public void onMessageClicked(@NonNull Contact contact, @NonNull List choices) { if (eventListener != null && batchSelected.isEmpty()) { - eventListener.onMessageSharedContactClicked(choices); + eventListener.onMessageSharedContactClicked(contact, choices); } else { passthroughClickListener.onClick(sharedContactStub.get()); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationMessage.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationMessage.java index fb98ac3980..4d9692574f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationMessage.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationMessage.java @@ -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 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 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 mentions, boolean hasBeenQuoted, @NonNull Recipient threadRecipient, - @Nullable Map prefetchedLabels) + @Nullable Map 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 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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/edit/EmptyConversationAdapterListener.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/edit/EmptyConversationAdapterListener.kt index 759e3b7164..ac34da8bfa 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/edit/EmptyConversationAdapterListener.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/edit/EmptyConversationAdapterListener.kt @@ -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) = Unit + override fun onMessageSharedContactClicked(contact: Contact, choices: List) = 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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityResultContracts.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityResultContracts.kt index 2a651af2d1..bfe53545cc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityResultContracts.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityResultContracts.kt @@ -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, List>() { - override fun createIntent(context: Context, input: Pair): 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, List>() { + override fun createIntent(context: Context, input: Pair): Intent { + val (source, recipientId) = input + return ContactShareEditActivityV2.getIntent(context, source, recipientId) } override fun parseResult(resultCode: Int, intent: Intent?): List { @@ -166,14 +185,29 @@ class ConversationActivityResultContracts(private val fragment: Fragment, privat } } - private object SelectContact : ActivityResultContract() { + /** The system contact picker, still used while [RemoteConfig.contactSharingV2] is off. */ + private object SystemSelectContact : ActivityResultContract() { 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() { + 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) fun onMediaSend(result: MediaSendActivityResult?) - fun onContactSelect(uri: Uri?) + fun onContactSelect(source: SharedContactSource?) fun onLocationSelected(place: SignalPlace?, uri: Uri?) fun onFileSelected(uri: Uri?) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt index a8b67d0a12..23696c51a3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt @@ -132,6 +132,7 @@ import org.signal.core.util.Result import org.signal.core.util.ThreadUtil import org.signal.core.util.concurrent.LifecycleDisposable import org.signal.core.util.concurrent.ListenableFuture +import org.signal.core.util.concurrent.SignalDispatchers import org.signal.core.util.concurrent.addTo import org.signal.core.util.dp import org.signal.core.util.encourageNewBrowserTab @@ -193,6 +194,8 @@ import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey.RecipientSearc import org.thoughtcrime.securesms.contactshare.Contact import org.thoughtcrime.securesms.contactshare.ContactUtil import org.thoughtcrime.securesms.contactshare.SharedContactDetailsActivityV2 +import org.thoughtcrime.securesms.contactshare.SharedContactSource +import org.thoughtcrime.securesms.contactshare.resolveOrCreateSignalRecipient import org.thoughtcrime.securesms.conversation.AttachmentKeyboardButton import org.thoughtcrime.securesms.conversation.BadDecryptLearnMoreDialog import org.thoughtcrime.securesms.conversation.ConversationAdapter @@ -1741,7 +1744,7 @@ class ConversationFragment : val recipientId: RecipientId = viewModel.recipientSnapshot?.id ?: return if (mediaType == SlideFactory.MediaType.VCARD) { - conversationActivityResultContracts.launchContactShareEditor(uri, recipientId) + conversationActivityResultContracts.launchVCardShareEditor(uri, recipientId) } else { val mimeType = MediaUtil.getMimeType(requireContext(), uri) ?: mediaType.toFallbackMimeType() val media = Media( @@ -3698,10 +3701,21 @@ class ConversationFragment : ) } - override fun onMessageSharedContactClicked(choices: MutableList) { + override fun onMessageSharedContactClicked(contact: Contact, choices: MutableList) { val context = context ?: return - ContactUtil.selectRecipientThroughDialog(context, choices, Locale.getDefault()) { recipient: Recipient -> - CommunicationActions.startConversation(context, recipient, null) + + if (choices.isNotEmpty()) { + ContactUtil.selectRecipientThroughDialog(context, choices, Locale.getDefault()) { recipient: Recipient -> + CommunicationActions.startConversation(context, recipient, null) + } + return + } + + // A card that carries only an ACI has nobody to pick between and no row yet, so tapping it is + // what seeds one. + viewLifecycleOwner.lifecycleScope.launch { + val recipientId = withContext(SignalDispatchers.IO) { contact.resolveOrCreateSignalRecipient() } ?: return@launch + CommunicationActions.startConversation(context, Recipient.resolved(recipientId), null) } } @@ -4770,10 +4784,10 @@ class ConversationFragment : ) } - override fun onContactSelect(uri: Uri?) { + override fun onContactSelect(source: SharedContactSource?) { val recipient = viewModel.recipientSnapshot - if (uri != null && recipient != null) { - conversationActivityResultContracts.launchContactShareEditor(uri, recipient.id) + if (source != null && recipient != null) { + conversationActivityResultContracts.launchContactShareEditor(source, recipient.id) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/data/ConversationDataSource.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/data/ConversationDataSource.kt index 34eb287243..fd0e994ab2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/data/ConversationDataSource.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/data/ConversationDataSource.kt @@ -141,7 +141,8 @@ class ConversationDataSource( extraData.mentionsById[record.id], extraData.hasBeenQuoted.contains(record.id), threadRecipient, - extraData.memberLabels + extraData.memberLabels, + extraData.sharedContacts[record.id] ).toMappingModel() } @@ -210,7 +211,8 @@ class ConversationDataSource( extraData.mentionsById[record.id], extraData.hasBeenQuoted.contains(record.id), threadRecipient, - extraData.memberLabels + extraData.memberLabels, + extraData.sharedContacts[record.id] ).toMappingModel() } } finally { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/data/MessageDataFetcher.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/data/MessageDataFetcher.kt index 80cea0db9f..aba616bd4e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/data/MessageDataFetcher.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/data/MessageDataFetcher.kt @@ -10,6 +10,7 @@ import org.signal.core.util.UuidUtil import org.signal.core.util.concurrent.SignalExecutors import org.signal.core.util.roundedString import org.thoughtcrime.securesms.attachments.DatabaseAttachment +import org.thoughtcrime.securesms.contactshare.SharedContactPresentation import org.thoughtcrime.securesms.database.CallTable import org.thoughtcrime.securesms.database.SignalDatabase import org.thoughtcrime.securesms.database.model.Mention @@ -127,6 +128,13 @@ object MessageDataFetcher { null } + val sharedContactsFuture = executor.submitTimed { + messageRecords + .filterIsInstance() + .mapNotNull { record -> record.sharedContacts.firstOrNull()?.let { record.id to it } } + .associate { (id, contact) -> id to SharedContactPresentation.resolve(contact) } + } + val mentionsResult = mentionsFuture.get() val hasBeenQuotedResult = hasBeenQuotedFuture.get() val reactionsResult = reactionsFuture.get() @@ -136,6 +144,7 @@ object MessageDataFetcher { val recipientsResult = recipientsFuture.get() val pollsResult = pollsFuture.get() val memberLabelsResult = memberLabelsFuture?.get() + val sharedContactsResult = sharedContactsFuture.get() val wallTimeMs = (System.nanoTime() - startTimeNanos).nanoseconds.toDouble(DurationUnit.MILLISECONDS) @@ -150,8 +159,9 @@ object MessageDataFetcher { payments = paymentsResult.result, calls = callsResult.result, polls = pollsResult.result, + sharedContacts = sharedContactsResult.result, memberLabels = memberLabelsResult?.result, - timeLog = "mentions: ${mentionsResult.duration}, is-quoted: ${hasBeenQuotedResult.duration}, reactions: ${reactionsResult.duration}, attachments: ${attachmentsResult.duration}, payments: ${paymentsResult.duration}, calls: ${callsResult.duration}, member-labels: ${memberLabelsResult?.duration ?: "n/a"} >> cpuTime: ${cpuTimeMs.roundedString(2)}, wallTime: ${wallTimeMs.roundedString(2)}" + timeLog = "mentions: ${mentionsResult.duration}, is-quoted: ${hasBeenQuotedResult.duration}, reactions: ${reactionsResult.duration}, attachments: ${attachmentsResult.duration}, payments: ${paymentsResult.duration}, calls: ${callsResult.duration}, shared-contacts: ${sharedContactsResult.duration}, member-labels: ${memberLabelsResult?.duration ?: "n/a"} >> cpuTime: ${cpuTimeMs.roundedString(2)}, wallTime: ${wallTimeMs.roundedString(2)}" ) } @@ -224,6 +234,7 @@ object MessageDataFetcher { val payments: Map, val calls: Map, val polls: Map, + val sharedContacts: Map, val memberLabels: Map?, val timeLog: String ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt index 9797ff4cbc..4459658c76 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt @@ -1296,9 +1296,9 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat } } - fun insertLearnedProfileNameChangeMessage(recipient: Recipient, e164: String?, username: String?) { - if ((e164 == null && username == null) || (e164 != null && username != null)) { - Log.w(TAG, "Learn profile event expects an e164 or username") + fun insertLearnedProfileNameChangeMessage(recipient: Recipient, e164: String? = null, username: String? = null, sharedName: String? = null) { + if (listOfNotNull(e164, username, sharedName).size != 1) { + Log.w(TAG, "Learn profile event expects exactly one of an e164, username, or shared name") return } @@ -1307,7 +1307,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat if (threadId != null) { val now = System.currentTimeMillis() val extras = MessageExtras( - profileChangeDetails = ProfileChangeDetails(learnedProfileName = ProfileChangeDetails.LearnedProfileName(e164 = e164, username = username)) + profileChangeDetails = ProfileChangeDetails(learnedProfileName = ProfileChangeDetails.LearnedProfileName(e164 = e164, username = username, sharedName = sharedName)) ) val messageId = writableDatabase diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTable.kt index b444952f59..be6114a8e4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTable.kt @@ -9,6 +9,7 @@ import android.text.TextUtils import androidx.annotation.VisibleForTesting import androidx.core.content.contentValuesOf import okio.ByteString.Companion.toByteString +import org.signal.contacts.SystemContactsRepository import org.signal.core.models.ServiceId import org.signal.core.models.ServiceId.ACI import org.signal.core.models.ServiceId.PNI @@ -199,6 +200,8 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da const val NICKNAME_FAMILY_NAME = "nickname_family_name" const val NICKNAME_JOINED_NAME = "nickname_joined_name" const val NOTE = "note" + const val SHARED_GIVEN_NAME = "shared_given_name" + const val SHARED_FAMILY_NAME = "shared_family_name" const val SEARCH_PROFILE_NAME = "search_signal_profile" const val SORT_NAME = "sort_name" @@ -279,7 +282,9 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da $CALL_NOTIFICATION_SETTING INTEGER DEFAULT ${NotificationSetting.SYSTEM_DEFAULT.id}, $REPLY_NOTIFICATION_SETTING INTEGER DEFAULT ${NotificationSetting.SYSTEM_DEFAULT.id}, $BLOCKED_AT INTEGER DEFAULT 0, - $UNREAD_REMINDER INTEGER DEFAULT ${NotificationSetting.SYSTEM_DEFAULT.id} + $UNREAD_REMINDER INTEGER DEFAULT ${NotificationSetting.SYSTEM_DEFAULT.id}, + $SHARED_GIVEN_NAME TEXT DEFAULT NULL, + $SHARED_FAMILY_NAME TEXT DEFAULT NULL ) """ @@ -349,6 +354,8 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da NICKNAME_GIVEN_NAME, NICKNAME_FAMILY_NAME, NOTE, + SHARED_GIVEN_NAME, + SHARED_FAMILY_NAME, KEY_TRANSPARENCY_DATA ) @@ -376,6 +383,8 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da NULLIF($SYSTEM_GIVEN_NAME, ''), NULLIF($PROFILE_JOINED_NAME, ''), NULLIF($PROFILE_GIVEN_NAME, ''), + NULLIF($SHARED_GIVEN_NAME, ''), + NULLIF($SHARED_FAMILY_NAME, ''), NULLIF($USERNAME, '') ) ) AS $SORT_NAME @@ -2041,6 +2050,40 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da } } + /** Records a name supplied by a third party through a shared contact card. */ + fun setSharedName(id: RecipientId, sharedName: ProfileName) { + if (sharedName.isEmpty) { + return + } + + val updated = writableDatabase + .update(TABLE_NAME) + .values( + SHARED_GIVEN_NAME to sharedName.givenName.nullIfBlank(), + SHARED_FAMILY_NAME to sharedName.familyName.nullIfBlank() + ) + .where( + """ + $ID = ? AND COALESCE( + NULLIF($NICKNAME_JOINED_NAME, ''), + NULLIF($NICKNAME_GIVEN_NAME, ''), + NULLIF($SYSTEM_JOINED_NAME, ''), + NULLIF($SYSTEM_GIVEN_NAME, ''), + NULLIF($PROFILE_JOINED_NAME, ''), + NULLIF($PROFILE_GIVEN_NAME, '') + ) IS NULL + """, + id + ) + .run() + + if (updated > 0) { + rotateStorageId(id) + AppDependencies.databaseObserver.notifyRecipientChanged(id) + StorageSyncHelper.scheduleSyncForDataChange() + } + } + fun setNicknameAndNote(id: RecipientId, nickname: ProfileName, note: String) { val contentValues = contentValuesOf( NICKNAME_GIVEN_NAME to nickname.givenName.nullIfBlank(), @@ -2059,11 +2102,12 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da * Applies multiple profile fields in a single UPDATE statement. Calls [rotateStorageId] and * [notifyRecipientChanged] at most once. Designed for bulk profile fetches. * - * Of these fields, only the profile name and username live on the storage service contact record, so - * the storage id is only rotated when one of those actually changes. + * Of these fields, only the profile name, username, and shared name live on the storage service + * contact record, so the storage id is only rotated when one of those actually changes. */ fun applyProfileUpdate(id: RecipientId, update: ProfileUpdate) { val clearsUsername = update.clearUsername && hasUsername(id) + val clearsSharedName = update.clearSharedName && hasSharedName(id) val contentValues = ContentValues().apply { update.profileName?.let { @@ -2098,6 +2142,10 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da if (clearsUsername) { putNull(USERNAME) } + if (clearsSharedName) { + putNull(SHARED_GIVEN_NAME) + putNull(SHARED_FAMILY_NAME) + } } if (contentValues.size() == 0) { @@ -2105,7 +2153,7 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da } if (update(id, contentValues)) { - val needsStorageRotation = update.profileName != null || clearsUsername + val needsStorageRotation = update.profileName != null || clearsUsername || clearsSharedName if (needsStorageRotation) { rotateStorageId(id) } @@ -2120,6 +2168,13 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da .run() } + private fun hasSharedName(id: RecipientId): Boolean { + return readableDatabase + .exists(TABLE_NAME) + .where("$ID = ? AND ($SHARED_GIVEN_NAME NOT NULL OR $SHARED_FAMILY_NAME NOT NULL)", id.serialize()) + .run() + } + fun setProfileName(id: RecipientId, profileName: ProfileName) { val contentValues = ContentValues(1).apply { put(PROFILE_GIVEN_NAME, profileName.givenName.nullIfBlank()) @@ -3651,6 +3706,87 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da return getSignalContacts(includeSelfMode).count } + /** + * Every registered recipient linked to a system contact, keyed by that contact's lookup key. + * + * The key comes from the uri path, not its last segment, which is a Data row id. Two numbers on + * one contact share a lookup key and are separate Signal accounts, so all of them are kept. + */ + fun getSystemContactLinksByLookupKey(): Map> { + return readableDatabase + .select(ID, SYSTEM_CONTACT_URI, NICKNAME_JOINED_NAME, NICKNAME_GIVEN_NAME) + .from(TABLE_NAME) + .where( + "$REGISTERED = ? AND $SYSTEM_CONTACT_URI NOT NULL AND $BLOCKED = 0 AND $HIDDEN = 0 AND $ID != ?", + RegisteredState.REGISTERED.id, + Recipient.self().id.serialize() + ) + .orderBy(ID) + .run() + .readToList { cursor -> + val lookupKey = SystemContactsRepository.lookupKeyFromLookupUri(cursor.requireString(SYSTEM_CONTACT_URI)) + + if (lookupKey == null) { + null + } else { + SystemContactLink( + lookupKey = lookupKey, + recipientId = RecipientId.from(cursor.requireLong(ID)), + nickname = Util.getFirstNonEmpty(cursor.requireString(NICKNAME_JOINED_NAME), cursor.requireString(NICKNAME_GIVEN_NAME)).nullIfBlank() + ) + } + } + .filterNotNull() + .groupBy { it.lookupKey } + } + + /** + * Registered recipients for the contact index. + * + * [excludeSystemContacts] must be false when the address book is not being read, since those + * recipients would otherwise be in neither half of the index. + */ + fun getSignalOnlyContactsForIndex(excludeSystemContacts: Boolean): List { + val systemContactFilter = if (excludeSystemContacts) "$SYSTEM_CONTACT_URI IS NULL AND " else "" + + return readableDatabase + .select(ID, NICKNAME_JOINED_NAME, NICKNAME_GIVEN_NAME, SYSTEM_JOINED_NAME, SYSTEM_GIVEN_NAME, PROFILE_JOINED_NAME, PROFILE_GIVEN_NAME, USERNAME, E164, EMAIL) + .from(TABLE_NAME) + .where( + "$REGISTERED = ? AND $PROFILE_SHARING = 1 AND $systemContactFilter$GROUP_ID IS NULL AND $BLOCKED = 0 AND $HIDDEN = 0 AND $ID != ?", + RegisteredState.REGISTERED.id, + Recipient.self().id.serialize() + ) + .run() + .readToList { cursor -> + SignalOnlyContact( + recipientId = RecipientId.from(cursor.requireLong(ID)), + nickname = Util.getFirstNonEmpty(cursor.requireString(NICKNAME_JOINED_NAME), cursor.requireString(NICKNAME_GIVEN_NAME)).nullIfBlank(), + systemName = Util.getFirstNonEmpty(cursor.requireString(SYSTEM_JOINED_NAME), cursor.requireString(SYSTEM_GIVEN_NAME)).nullIfBlank(), + profileName = Util.getFirstNonEmpty(cursor.requireString(PROFILE_JOINED_NAME), cursor.requireString(PROFILE_GIVEN_NAME)).nullIfBlank(), + username = cursor.requireString(USERNAME).nullIfBlank(), + e164 = cursor.requireString(E164).nullIfBlank(), + email = cursor.requireString(EMAIL).nullIfBlank() + ) + } + } + + data class SystemContactLink( + val lookupKey: String, + val recipientId: RecipientId, + val nickname: String? + ) + + data class SignalOnlyContact( + val recipientId: RecipientId, + val nickname: String?, + val systemName: String?, + val profileName: String?, + val username: String?, + val e164: String?, + val email: String? + ) + private fun searchProjection(includeSelfMode: IncludeSelfMode): Array { return when (includeSelfMode) { is IncludeSelfMode.IncludeWithRemap -> { @@ -3678,6 +3814,8 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da NULLIF($SYSTEM_GIVEN_NAME, ''), NULLIF($PROFILE_JOINED_NAME, ''), NULLIF($PROFILE_GIVEN_NAME, ''), + NULLIF($SHARED_GIVEN_NAME, ''), + NULLIF($SHARED_FAMILY_NAME, ''), NULLIF($USERNAME, '') ) ) END AS $SORT_NAME @@ -4528,6 +4666,7 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da val systemName = ProfileName.fromParts(contact.proto.systemGivenName.nullIfBlank(), contact.proto.systemFamilyName.nullIfBlank()) val username = contact.proto.username.nullIfBlank() val nickname = ProfileName.fromParts(contact.proto.nickname?.given, contact.proto.nickname?.family) + val sharedName = ProfileName.fromParts(contact.proto.sharedName?.given, contact.proto.sharedName?.family) put(ACI_COLUMN, contact.proto.signalAci?.toString()) put(PNI_COLUMN, contact.proto.signalPni?.toString()) @@ -4552,6 +4691,8 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da put(NICKNAME_FAMILY_NAME, nickname.familyName.nullIfBlank()) put(NICKNAME_JOINED_NAME, nickname.toString().nullIfBlank()) put(NOTE, contact.proto.note.nullIfBlank()) + put(SHARED_GIVEN_NAME, sharedName.givenName.nullIfBlank()) + put(SHARED_FAMILY_NAME, sharedName.familyName.nullIfBlank()) put(CALL_NOTIFICATION_SETTING, NotificationSetting.fromOptionalBool(contact.proto.notifyForCallsIfMuted).id) put(UNREAD_REMINDER, NotificationSetting.fromOptionalBool(contact.proto.showUnreadReminders).id) @@ -4717,6 +4858,8 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da NICKNAME_GIVEN_NAME to null, NICKNAME_FAMILY_NAME to null, NICKNAME_JOINED_NAME to null, + SHARED_GIVEN_NAME to null, + SHARED_FAMILY_NAME to null, NOTE to null, KEY_TRANSPARENCY_DATA to null ) @@ -5393,7 +5536,8 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da val sealedSenderAccessMode: SealedSenderAccessMode? = null, val phoneNumberSharing: PhoneNumberSharingState? = null, val expiringProfileKeyCredential: Pair? = null, - val clearUsername: Boolean = false + val clearUsername: Boolean = false, + val clearSharedName: Boolean = false ) data class RecipientNotificationData(val id: RecipientId, val channel: String) diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTableCursorUtil.kt b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTableCursorUtil.kt index ff1bad738f..cda4da606f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTableCursorUtil.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTableCursorUtil.kt @@ -170,6 +170,7 @@ object RecipientTableCursorUtil { phoneNumberSharing = cursor.requireInt(RecipientTable.PHONE_NUMBER_SHARING).let { RecipientTable.PhoneNumberSharingState.fromId(it) }, nickname = ProfileName.fromParts(cursor.requireString(RecipientTable.NICKNAME_GIVEN_NAME), cursor.requireString(RecipientTable.NICKNAME_FAMILY_NAME)), note = cursor.requireString(RecipientTable.NOTE), + sharedName = ProfileName.fromParts(cursor.requireString(RecipientTable.SHARED_GIVEN_NAME), cursor.requireString(RecipientTable.SHARED_FAMILY_NAME)), keyTransparencyData = cursor.requireBlob(RecipientTable.KEY_TRANSPARENCY_DATA) ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt index 3c5feb298c..238cd44fec 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt @@ -180,6 +180,7 @@ import org.thoughtcrime.securesms.database.helpers.migration.V324_MoveGroupV1Sto import org.thoughtcrime.securesms.database.helpers.migration.V325_AddBlockedAtToRecipientTable import org.thoughtcrime.securesms.database.helpers.migration.V326_AddUnreadReminderColumn import org.thoughtcrime.securesms.database.helpers.migration.V327_AddLastUnreadReminderColumn +import org.thoughtcrime.securesms.database.helpers.migration.V328_AddSharedNameToRecipientTable import org.thoughtcrime.securesms.database.SQLiteDatabase as SignalSqliteDatabase /** @@ -367,10 +368,11 @@ object SignalDatabaseMigrations { 324 to V324_MoveGroupV1StorageIdsToUnknownIds, 325 to V325_AddBlockedAtToRecipientTable, 326 to V326_AddUnreadReminderColumn, - 327 to V327_AddLastUnreadReminderColumn + 327 to V327_AddLastUnreadReminderColumn, + 328 to V328_AddSharedNameToRecipientTable ) - const val DATABASE_VERSION = 327 + const val DATABASE_VERSION = 328 @JvmStatic fun migrate(context: Application, db: SignalSqliteDatabase, oldVersion: Int, newVersion: Int) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V328_AddSharedNameToRecipientTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V328_AddSharedNameToRecipientTable.kt new file mode 100644 index 0000000000..8941ae6113 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V328_AddSharedNameToRecipientTable.kt @@ -0,0 +1,14 @@ +package org.thoughtcrime.securesms.database.helpers.migration + +import android.app.Application +import org.thoughtcrime.securesms.database.SQLiteDatabase + +/** Adds columns for a name supplied by a third party through a shared contact card. */ +@Suppress("ClassName") +object V328_AddSharedNameToRecipientTable : SignalDatabaseMigration { + + override fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { + db.execSQL("ALTER TABLE recipient ADD COLUMN shared_given_name TEXT DEFAULT NULL") + db.execSQL("ALTER TABLE recipient ADD COLUMN shared_family_name TEXT DEFAULT NULL") + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java index f83302169e..fa60690561 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java @@ -553,8 +553,10 @@ public abstract class MessageRecord extends DisplayRecord { String previouslyKnownAs; if (!Util.isEmpty(profileChangeDetails.learnedProfileName.e164)) { previouslyKnownAs = SignalE164Util.prettyPrint(profileChangeDetails.learnedProfileName.e164); - } else { + } else if (!Util.isEmpty(profileChangeDetails.learnedProfileName.username)) { previouslyKnownAs = profileChangeDetails.learnedProfileName.username; + } else { + previouslyKnownAs = profileChangeDetails.learnedProfileName.sharedName; } if (!Util.isEmpty(previouslyKnownAs)) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/RecipientRecord.kt b/app/src/main/java/org/thoughtcrime/securesms/database/model/RecipientRecord.kt index fff6ec4111..dece955601 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/RecipientRecord.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/RecipientRecord.kt @@ -85,6 +85,7 @@ data class RecipientRecord( val phoneNumberSharing: PhoneNumberSharingState, val nickname: ProfileName, val note: String?, + val sharedName: ProfileName, val keyTransparencyData: ByteArray? = null ) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/addtogroup/AddToGroupsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/addtogroup/AddToGroupsActivity.kt index 646558c071..9fb17585c7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/addtogroup/AddToGroupsActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/addtogroup/AddToGroupsActivity.kt @@ -239,6 +239,17 @@ private fun UserMessagesHost( closeScreen() } + is UserMessage.InvitedRecipientToGroup -> { + Dialogs.SimpleMessageDialog( + message = stringResource(R.string.GroupManagement_invite_single_user, userMessage.recipient.getDisplayName(context)), + dismiss = stringResource(android.R.string.ok), + onDismiss = { + onDismiss(userMessage) + closeScreen() + } + ) + } + is UserMessage.CantAddRecipientToLegacyGroup -> { Toast.makeText(context, stringResource(R.string.AddToGroupActivity_this_person_cant_be_added_to_legacy_groups), Toast.LENGTH_SHORT).show() onDismiss(userMessage) diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/addtogroup/AddToGroupsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/addtogroup/AddToGroupsViewModel.kt index 6268841dfa..582459ffb6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/addtogroup/AddToGroupsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/addtogroup/AddToGroupsViewModel.kt @@ -100,7 +100,11 @@ class AddToGroupsViewModel( when (result) { is GroupAddMembersResult.Success -> { internalUiState.update { - it.copy(userMessage = UserMessage.AddedRecipientToGroup(recipient, groupRecipient)) + if (result.newMembersInvited.isNotEmpty()) { + it.copy(userMessage = UserMessage.InvitedRecipientToGroup(recipient, groupRecipient)) + } else { + it.copy(userMessage = UserMessage.AddedRecipientToGroup(recipient, groupRecipient)) + } } } @@ -138,6 +142,7 @@ data class AddToGroupsUiState( sealed interface UserMessage { data class ConfirmAddToGroup(val recipientToAdd: Recipient, val targetGroup: Recipient) : UserMessage data class AddedRecipientToGroup(val recipient: Recipient, val targetGroup: Recipient) : UserMessage + data class InvitedRecipientToGroup(val recipient: Recipient, val targetGroup: Recipient) : UserMessage data object CantAddRecipientToLegacyGroup : UserMessage data class GroupUpdateError(val failureReason: GroupChangeFailureReason) : UserMessage } diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/RetrieveProfileJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/RetrieveProfileJob.kt index 572f15b650..615010fc1b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/RetrieveProfileJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/RetrieveProfileJob.kt @@ -316,7 +316,8 @@ class RetrieveProfileJob private constructor(parameters: Parameters, private val val profileNameResult = resolveProfileName(recipient, recipientProfileKey, profile.name) val aboutResult = resolveProfileAbout(recipientProfileKey, profile.about, profile.aboutEmoji) val phoneNumberSharing = resolvePhoneNumberSharing(recipient, recipientProfileKey, profile.phoneNumberSharing) - val clearUsername = (recipient.username.isPresent && recipient.hasNonUsernameDisplayName(context)) || profileNameResult?.changed == true + val clearUsername = (recipient.username.isPresent && recipient.hasPersistentDisplayName(context)) || profileNameResult?.changed == true + val clearSharedName = (!recipient.sharedName.isEmpty && recipient.hasDisplayNameOutrankingSharedName()) || profileNameResult?.changed == true val update = RecipientTable.ProfileUpdate( profileName = if (profileNameResult?.changed == true) profileNameResult.remoteProfileName else null, @@ -326,7 +327,8 @@ class RetrieveProfileJob private constructor(parameters: Parameters, private val sealedSenderAccessMode = if (accessMode != recipient.sealedSenderAccessMode) accessMode else null, phoneNumberSharing = phoneNumberSharing, expiringProfileKeyCredential = expiringCredential?.let { Pair(recipientProfileKey, it) }, - clearUsername = clearUsername + clearUsername = clearUsername, + clearSharedName = clearSharedName ) SignalDatabase.recipients.applyProfileUpdate(recipient.id, update) @@ -421,17 +423,18 @@ class RetrieveProfileJob private constructor(parameters: Parameters, private val !recipient.isGroup && !recipient.isSelf - var username: String? = null - var e164: String? = null - if (learnedFirstTime) { - username = SignalDatabase.recipients.getUsername(recipient.id) - e164 = if (username == null) SignalDatabase.recipients.getE164sForIds(listOf(recipient.id)).firstOrNull() else null + val previousName: PreviousName? = if (learnedFirstTime) { + recipient.sharedName.takeUnless { it.isEmpty }?.let { PreviousName.SharedName(it.toString()) } + ?: SignalDatabase.recipients.getUsername(recipient.id)?.let { PreviousName.Username(it) } + ?: SignalDatabase.recipients.getE164sForIds(listOf(recipient.id)).firstOrNull()?.let { PreviousName.E164(it) } + } else { + null } return if (changed) { - ProfileNameResult(remoteProfileName, localProfileName, changed = true, learnedFirstTime, username, e164) + ProfileNameResult(remoteProfileName, localProfileName, changed = true, learnedFirstTime, previousName) } else if (learnedFirstTime) { - ProfileNameResult(remoteProfileName, localProfileName, changed = false, learnedFirstTime, username, e164) + ProfileNameResult(remoteProfileName, localProfileName, changed = false, learnedFirstTime, previousName) } else { null } @@ -478,11 +481,16 @@ class RetrieveProfileJob private constructor(parameters: Parameters, private val private fun handleProfileNameSideEffects(recipient: Recipient, result: ProfileNameResult) { if (result.learnedFirstTime) { - if (result.username != null || result.e164 != null) { - Log.i(TAG, "Learned profile name for first time, inserting event") - SignalDatabase.messages.insertLearnedProfileNameChangeMessage(recipient, result.e164, result.username) + val previous = result.previousName + if (previous == null) { + Log.w(TAG, "Learned profile name for first time, but have no previous name for ${recipient.id}") } else { - Log.w(TAG, "Learned profile name for first time, but do not have username or e164 for ${recipient.id}") + Log.i(TAG, "Learned profile name for first time, inserting event") + when (previous) { + is PreviousName.SharedName -> SignalDatabase.messages.insertLearnedProfileNameChangeMessage(recipient, sharedName = previous.sharedName) + is PreviousName.Username -> SignalDatabase.messages.insertLearnedProfileNameChangeMessage(recipient, username = previous.username) + is PreviousName.E164 -> SignalDatabase.messages.insertLearnedProfileNameChangeMessage(recipient, e164 = previous.e164) + } } } @@ -537,10 +545,16 @@ class RetrieveProfileJob private constructor(parameters: Parameters, private val val localProfileName: ProfileName, val changed: Boolean, val learnedFirstTime: Boolean, - val username: String?, - val e164: String? + val previousName: PreviousName? ) + /** The name a chat displayed before we learned a profile name. */ + private sealed interface PreviousName { + data class SharedName(val sharedName: String) : PreviousName + data class Username(val username: String) : PreviousName + data class E164(val e164: String) : PreviousName + } + class Factory : Job.Factory { override fun create(parameters: Parameters, serializedData: ByteArray?): RetrieveProfileJob { val data = JsonJobData.deserialize(serializedData) diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageDetailsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageDetailsFragment.kt index 9c34834cd2..b55b52d9d1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageDetailsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageDetailsFragment.kt @@ -207,7 +207,7 @@ class MessageDetailsFragment : Fragment(), MessageDetailsAdapter.Callbacks { Log.w(TAG, "Not yet implemented!", Exception()) } - override fun onMessageSharedContactClicked(choices: MutableList) { + override fun onMessageSharedContactClicked(contact: Contact, choices: MutableList) { Log.w(TAG, "Not yet implemented!", Exception()) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.kt b/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.kt index 89c81e5071..59bf8c3a04 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.kt @@ -130,6 +130,11 @@ class Recipient( val phoneNumberSharing: PhoneNumberSharingState = PhoneNumberSharingState.UNKNOWN, val nickname: ProfileName = ProfileName.EMPTY, val note: String? = null, + /** + * A name supplied by a third party through a shared contact card, for someone we would otherwise + * have no name for. Deliberately ranks below [profileName], so a real profile always wins. + */ + val sharedName: ProfileName = ProfileName.EMPTY, val keyTransparencyData: ByteArray? = null ) { @@ -554,7 +559,8 @@ class Recipient( return getGroupName(context).isNotNullOrBlank() || nickname.toString().isNotNullOrBlank() || systemContactName.isNotNullOrBlank() || - profileName.toString().isNotNullOrBlank() + profileName.toString().isNotNullOrBlank() || + sharedName.toString().isNotNullOrBlank() } fun isMatch(query: String): Boolean { @@ -570,6 +576,8 @@ class Recipient( systemProfileName.givenName, profileName.toString(), profileName.givenName, + sharedName.toString(), + sharedName.givenName, username.orElse("") ).firstOrNull { it.isNotNullOrBlank() }?.lowercase() @@ -590,12 +598,24 @@ class Recipient( return BidiUtil.isolateBidi(name) } - fun hasNonUsernameDisplayName(context: Context): Boolean { - return getNameFromLocalData(context).isNotNullOrBlank() + val hasUsernameOrSharedName: Boolean + get() = username.isPresent || !sharedName.isEmpty + + /** Excludes shared name and username. */ + fun hasPersistentDisplayName(context: Context): Boolean { + return getNameFromLocalData(context, includeSharedName = false).isNotNullOrBlank() + } + + /** Excludes the e164 and email, which a shared name outranks for display. */ + fun hasDisplayNameOutrankingSharedName(): Boolean { + return nickname.toString().isNotBlank() || + systemContactName.isNotNullOrBlank() || + systemProfileName.toString().isNotBlank() || + profileName.toString().isNotBlank() } /** A full-length display name for this user, ignoring the username. */ - private fun getNameFromLocalData(context: Context): String? { + private fun getNameFromLocalData(context: Context, includeSharedName: Boolean = true): String? { var name = getGroupName(context) if (name.isNullOrBlank()) { @@ -610,6 +630,10 @@ class Recipient( name = profileName.toString() } + if (name.isBlank() && includeSharedName) { + name = sharedName.toString() + } + if (name.isBlank() && e164Value.isNotNullOrBlank()) { name = SignalE164Util.prettyPrint(e164Value) } @@ -666,6 +690,8 @@ class Recipient( systemProfileName.toString(), profileName.givenName, profileName.toString(), + sharedName.givenName, + sharedName.toString(), username.orElse(null), getDisplayName(context) ).firstOrNull { it.isNotNullOrBlank() } @@ -743,6 +769,8 @@ class Recipient( FallbackAvatar.forTextOrDefault(systemContactName, avatarColor) } else if (!profileName.isEmpty) { FallbackAvatar.forTextOrDefault(profileName.toString(), avatarColor) + } else if (!sharedName.isEmpty) { + FallbackAvatar.forTextOrDefault(sharedName.toString(), avatarColor) } else { FallbackAvatar.Resource.Person(avatarColor) } @@ -901,6 +929,7 @@ class Recipient( phoneNumberSharing == other.phoneNumberSharing && nickname == other.nickname && note == other.note && + sharedName == other.sharedName && keyTransparencyData.contentEquals(other.keyTransparencyData) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientCreator.kt b/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientCreator.kt index c1a6c7dae1..d90a58a340 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientCreator.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientCreator.kt @@ -206,6 +206,7 @@ object RecipientCreator { phoneNumberSharing = record.phoneNumberSharing, nickname = record.nickname, note = record.note, + sharedName = record.sharedName, keyTransparencyData = record.keyTransparencyData ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/about/AboutSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/about/AboutSheet.kt index beeed5c447..034290c64c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/about/AboutSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/about/AboutSheet.kt @@ -121,6 +121,7 @@ class AboutSheet : ComposeBottomSheetDialogFragment() { }, profileSharing = recipient.isProfileSharing, systemContact = recipient.isSystemContact, + hasUsernameOrSharedName = recipient.hasUsernameOrSharedName, groupsInCommon = state.groupsInCommonCount, note = recipient.note ?: "", memberLabel = state.memberLabel, @@ -188,6 +189,7 @@ private data class AboutModel( val formattedE164: String?, val profileSharing: Boolean, val systemContact: Boolean, + val hasUsernameOrSharedName: Boolean = false, val groupsInCommon: Int, val note: String, val memberLabel: MemberLabel? = null, @@ -305,7 +307,7 @@ private fun Content( modifier = Modifier.align(alignment = Alignment.Start), onClick = onClickSignalConnections ) - } else if (model.groupsInCommon == 0) { + } else if (model.groupsInCommon == 0 && !model.hasUsernameOrSharedName) { AboutRow( startIcon = ImageVector.vectorResource(id = R.drawable.symbol_chat_badge_24), text = stringResource(id = R.string.AboutSheet__pending_message_request), @@ -522,6 +524,37 @@ private fun ContentPreviewDefault() { } } +@Preview(name = "Light Theme", group = "content", uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "Dark Theme", group = "content", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun ContentPreviewUsernameOrSharedName() { + Previews.Preview { + Surface { + Content( + model = AboutModel( + isSelf = false, + displayName = "Peter Parker", + shortName = "Peter", + profileName = "Peter Parker", + about = null, + verified = false, + hasAvatar = false, + recipientForAvatar = Recipient.UNKNOWN, + formattedE164 = null, + profileSharing = false, + systemContact = false, + hasUsernameOrSharedName = true, + groupsInCommon = 0, + note = "" + ), + onClickSignalConnections = {}, + onAvatarClicked = {}, + onNoteClicked = {} + ) + } + } +} + @Preview(name = "Light Theme", group = "content", uiMode = Configuration.UI_MODE_NIGHT_NO) @Preview(name = "Dark Theme", group = "content", uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable diff --git a/app/src/main/java/org/thoughtcrime/securesms/starred/StarredMessagesActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/starred/StarredMessagesActivity.kt index 557dd3c2a0..98e5b5882a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/starred/StarredMessagesActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/starred/StarredMessagesActivity.kt @@ -369,7 +369,7 @@ private class StarredMessageClickListener( override fun onViewOnceMessageClicked(messageRecord: MmsMessageRecord) = Unit override fun onSharedContactDetailsClicked(contact: Contact, avatarTransitionView: View) = Unit override fun onAddToContactsClicked(contact: Contact) = Unit - override fun onMessageSharedContactClicked(choices: MutableList) = Unit + override fun onMessageSharedContactClicked(contact: Contact, choices: MutableList) = 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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/storage/ContactRecordProcessor.kt b/app/src/main/java/org/thoughtcrime/securesms/storage/ContactRecordProcessor.kt index 0f1d8765f3..1551d5bfa5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/storage/ContactRecordProcessor.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/storage/ContactRecordProcessor.kt @@ -272,6 +272,7 @@ class ContactRecordProcessor( nickname = remote.proto.nickname pniSignatureVerified = (remote.proto.pniSignatureVerified || local.proto.pniSignatureVerified) && mergedPni?.isValid == true note = remote.proto.note.nullIfBlank() ?: "" + sharedName = remote.proto.sharedName avatarColor = if (SignalStore.account.isPrimaryDevice) local.proto.avatarColor else remote.proto.avatarColor notifyForCallsIfMuted = StorageSyncHelper.getOptionalBool(remote.proto.notifyForCallsIfMuted, local.proto.notifyForCallsIfMuted) showUnreadReminders = StorageSyncHelper.getOptionalBool(remote.proto.showUnreadReminders, local.proto.showUnreadReminders) diff --git a/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncModels.kt b/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncModels.kt index a86c7ae26b..20b3d6cfc7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncModels.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncModels.kt @@ -223,6 +223,7 @@ object StorageSyncModels { pniSignatureVerified = recipient.pni?.isValid == true && recipient.syncExtras.pniSignatureVerified nickname = recipient.nickname.takeUnless { it.isEmpty }?.let { ContactRecord.Name(given = it.givenName, family = it.familyName) } note = recipient.note ?: "" + sharedName = recipient.sharedName.takeUnless { it.isEmpty }?.let { ContactRecord.Name(given = it.givenName, family = it.familyName) } avatarColor = localToRemoteAvatarColor(recipient.avatarColor) notifyForCallsIfMuted = RecipientTable.NotificationSetting.toOptionalBool(recipient.callNotificationSetting) showUnreadReminders = RecipientTable.NotificationSetting.toOptionalBool(recipient.unreadReminder) diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/RemoteConfig.kt b/app/src/main/java/org/thoughtcrime/securesms/util/RemoteConfig.kt index 8e98de78d9..313e682b59 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/RemoteConfig.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/RemoteConfig.kt @@ -590,6 +590,16 @@ object RemoteConfig { !value.asBoolean(false) } + /** + * Whether to use the rewritten contact sharing flow, which lists Signal connections alongside the address book and can share a contact by ACI. + */ + @JvmStatic + val contactSharingV2: Boolean by remoteBoolean( + key = "android.contactSharingV2", + defaultValue = false, + hotSwappable = true + ) + /** Whether or not to use the UUID in verification codes. */ val verifyV2: Boolean by remoteBoolean( key = "android.verifyV2", diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/SignalServiceTransformExtensions.kt b/app/src/main/java/org/thoughtcrime/securesms/util/SignalServiceTransformExtensions.kt index 0ae3503cc5..4e19d1dc74 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/SignalServiceTransformExtensions.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/SignalServiceTransformExtensions.kt @@ -352,7 +352,7 @@ private fun LinkPreview.toProto(): Either = either { } private fun Contact.toProto(): Either = either { - DataMessage.Contact( + val contact = DataMessage.Contact( name = DataMessage.Contact.Name( givenName = name.givenName, familyName = name.familyName, @@ -388,6 +388,18 @@ private fun Contact.toProto(): Either = e }, organization = organization ) + + if (!RemoteConfig.contactSharingV2) { + return@either contact + } + + contact.copy( + aciBinary = ACI.parseOrNull(aci)?.takeIf { it.isValid }?.toByteString(), + nickname = nickname?.takeUnless { it.isEmpty }?.let { + DataMessage.Contact.SignalNickname(given = it.given, family = it.family) + }, + note = note + ) } private fun Contact.Phone.Type.toProto(): DataMessage.Contact.Phone.Type { diff --git a/app/src/main/protowire/Database.proto b/app/src/main/protowire/Database.proto index 687f1c340c..24da7e62a9 100644 --- a/app/src/main/protowire/Database.proto +++ b/app/src/main/protowire/Database.proto @@ -77,6 +77,7 @@ message ProfileChangeDetails { oneof PreviouslyKnownAs { string e164 = 1; string username = 2; + string sharedName = 3; } } diff --git a/app/src/main/res/layout/shared_contact_view.xml b/app/src/main/res/layout/shared_contact_view.xml index 147f9c142e..a3ecc110e7 100644 --- a/app/src/main/res/layout/shared_contact_view.xml +++ b/app/src/main/res/layout/shared_contact_view.xml @@ -7,6 +7,7 @@ diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a59e8dfd17..c5bc731894 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -9516,6 +9516,35 @@ %1$d replies from %2$s and others + + Select contact + + Search + + Clear search + + In your contacts + + Find people you know on Signal + + Allow access to your contacts. Your contacts are encrypted and not visible to the Signal service. + + Allow access + + No thanks + + Open phone contacts + + To see your phone contacts here, allow access in your device settings. %1$s + + Learn more + + Allow access to contacts + + To find people you know on Signal: + + Couldn\'t open that contact + Share contact diff --git a/app/src/test/java/org/thoughtcrime/securesms/contacts/index/ContactDisplayNameTest.kt b/app/src/test/java/org/thoughtcrime/securesms/contacts/index/ContactDisplayNameTest.kt new file mode 100644 index 0000000000..09ba925b7c --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/contacts/index/ContactDisplayNameTest.kt @@ -0,0 +1,209 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.contacts.index + +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isNull +import org.junit.Test +import java.util.Locale + +class ContactDisplayNameTest { + + @Test + fun `signal contact prefers nickname over everything`() { + val name = ContactDisplayName.forSignalContact( + nickname = "Nickname", + systemName = "System Name", + profileName = "Profile Name", + username = "username.01", + e164 = "+15551234567", + email = "a@b.com" + ) + + assertThat(name).isEqualTo("Nickname") + } + + @Test + fun `signal contact prefers system name over profile name`() { + val name = ContactDisplayName.forSignalContact( + nickname = null, + systemName = "System Name", + profileName = "Profile Name", + username = null, + e164 = null, + email = null + ) + + assertThat(name).isEqualTo("System Name") + } + + @Test + fun `signal contact falls through blanks`() { + val name = ContactDisplayName.forSignalContact( + nickname = "", + systemName = " ", + profileName = null, + username = "username.01", + e164 = "+15551234567", + email = null + ) + + assertThat(name).isEqualTo("username.01") + } + + @Test + fun `signal contact with nothing to show has no name`() { + val name = ContactDisplayName.forSignalContact(null, null, null, null, null, null) + + assertThat(name).isNull() + } + + @Test + fun `system contact prefers nickname over the provider name`() { + assertThat(ContactDisplayName.forSystemContact("Provider Name", "Nickname")).isEqualTo("Nickname") + assertThat(ContactDisplayName.forSystemContact("Provider Name", null)).isEqualTo("Provider Name") + } + + @Test + fun `system contact with no name at all has no name`() { + assertThat(ContactDisplayName.forSystemContact(null, null)).isNull() + } + + @Test + fun `section is the uppercased first letter`() { + assertThat(ContactDisplayName.sectionFor("alice", hasPersonalName = true, hasNickname = false)).isEqualTo("A") + assertThat(ContactDisplayName.sectionFor("Bob", hasPersonalName = true, hasNickname = false)).isEqualTo("B") + } + + @Test + fun `section folds accents so accented names are not stranded`() { + assertThat(ContactDisplayName.sectionFor("Éric", hasPersonalName = true, hasNickname = false)).isEqualTo("E") + assertThat(ContactDisplayName.sectionFor("Ólafur", hasPersonalName = true, hasNickname = false)).isEqualTo("O") + } + + @Test + fun `section is other for names that do not start with a letter`() { + assertThat(ContactDisplayName.sectionFor("+15551234567", hasPersonalName = true, hasNickname = false)).isEqualTo(ContactDisplayName.SECTION_OTHER) + assertThat(ContactDisplayName.sectionFor("911", hasPersonalName = true, hasNickname = false)).isEqualTo(ContactDisplayName.SECTION_OTHER) + assertThat(ContactDisplayName.sectionFor("", hasPersonalName = true, hasNickname = false)).isEqualTo(ContactDisplayName.SECTION_OTHER) + } + + @Test + fun `section ignores leading whitespace`() { + assertThat(ContactDisplayName.sectionFor(" Zoe", hasPersonalName = true, hasNickname = false)).isEqualTo("Z") + } + + @Test + fun `a name standing in for a personal name is sectioned as other`() { + assertThat(ContactDisplayName.sectionFor("Pacific Plumbing", hasPersonalName = false, hasNickname = false)) + .isEqualTo(ContactDisplayName.SECTION_OTHER) + assertThat(ContactDisplayName.sectionFor("paige@signal.org", hasPersonalName = false, hasNickname = false)) + .isEqualTo(ContactDisplayName.SECTION_OTHER) + } + + @Test + fun `a nicknamed entry is sectioned under the nickname even without a personal name`() { + assertThat(ContactDisplayName.sectionFor("Plumber Dave", hasPersonalName = false, hasNickname = true)).isEqualTo("P") + } + + @Test + fun `a person who has an organization is sectioned by their own name`() { + // Organization is a field on a named contact, so having one says nothing about being a company. + assertThat(ContactDisplayName.sectionFor("Luke Skywalker", hasPersonalName = true, hasNickname = false)).isEqualTo("L") + } + + @Test + fun `fold lowercases and strips diacritics`() { + assertThat(ContactDisplayName.normalize("Éric ÅKESSON")).isEqualTo("eric akesson") + } + + @Test + fun `search text is wrapped in spaces so word prefixes are matchable`() { + assertThat(ContactDisplayName.searchTextOf("Alice Smith")).isEqualTo(" alice smith ") + } + + @Test + fun `search text merges every known name and dedupes`() { + val text = ContactDisplayName.searchTextOf("Alice Smith", "Alice Smith", "Ali", null, "") + + assertThat(text).isEqualTo(" alice smith ali ") + } + + @Test + fun `search text collapses extra whitespace into single tokens`() { + assertThat(ContactDisplayName.searchTextOf(" Alice Smith ")).isEqualTo(" alice smith ") + } + + @Test + fun `search text of nothing is still matchable without crashing`() { + assertThat(ContactDisplayName.searchTextOf(null, "")).isEqualTo(" ") + } + + @Test + fun `sort keys order names case and accent insensitively`() { + val generator = ContactSortKeyGenerator(Locale.US) + + val alice = generator.of("alice") + val aliceUpper = generator.of("ALICE") + val bob = generator.of("Bob") + + assertThat(compare(alice, aliceUpper)).isEqualTo(0) + assertThat(compare(alice, bob) < 0).isEqualTo(true) + } + + @Test + fun `sort keys put accented names next to their unaccented form rather than after z`() { + val generator = ContactSortKeyGenerator(Locale.US) + + val eric = generator.of("Eric") + val ericAccented = generator.of("Éric") + val zoe = generator.of("Zoe") + + assertThat(compare(eric, ericAccented)).isEqualTo(0) + assertThat(compare(ericAccented, zoe) < 0).isEqualTo(true) + } + + @Test + fun `name query becomes a word prefix pattern`() { + assertThat(ContactDisplayName.searchPatternFor("ali")).isEqualTo("% ali%") + } + + @Test + fun `name query is folded so an unaccented query finds an accented name`() { + assertThat(ContactDisplayName.searchPatternFor("ÉRIC")).isEqualTo("% eric%") + } + + @Test + fun `like wildcards typed by the user are escaped rather than treated as wildcards`() { + assertThat(ContactDisplayName.searchPatternFor("100%")).isEqualTo("% 100\\%%") + assertThat(ContactDisplayName.searchPatternFor("a_b")).isEqualTo("% a\\_b%") + assertThat(ContactDisplayName.searchPatternFor("a%b")).isEqualTo("% a\\%b%") + assertThat(ContactDisplayName.searchPatternFor("a\\b")).isEqualTo("% a\\\\b%") + } + + @Test + fun `numeric query matches anywhere so a partial number is findable`() { + assertThat(ContactDisplayName.searchPatternFor("5551234")).isEqualTo("%5551234%") + assertThat(ContactDisplayName.searchPatternFor("+1 (555) 123-4567")).isEqualTo("%15551234567%") + } + + @Test + fun `blank query is a word prefix pattern that matches every row`() { + assertThat(ContactDisplayName.searchPatternFor(" ")).isEqualTo("% %") + } + + /** Mirrors SQLite's blob comparison, which is a memcmp over the stored bytes. */ + private fun compare(left: ByteArray, right: ByteArray): Int { + for (i in 0 until minOf(left.size, right.size)) { + val diff = (left[i].toInt() and 0xFF) - (right[i].toInt() and 0xFF) + if (diff != 0) { + return diff + } + } + return left.size - right.size + } +} diff --git a/app/src/test/java/org/thoughtcrime/securesms/contactshare/ContactCardReaderTest.kt b/app/src/test/java/org/thoughtcrime/securesms/contactshare/ContactCardReaderTest.kt index 46b87bea4e..36fc1e81a1 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/contactshare/ContactCardReaderTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/contactshare/ContactCardReaderTest.kt @@ -9,9 +9,9 @@ import android.app.Application import androidx.core.net.toUri import androidx.test.core.app.ApplicationProvider import assertk.assertThat -import assertk.assertions.hasSize -import assertk.assertions.isEmpty import assertk.assertions.isEqualTo +import assertk.assertions.isNotNull +import assertk.assertions.isNull import io.mockk.every import io.mockk.mockkStatic import io.mockk.unmockkStatic @@ -55,23 +55,18 @@ class ContactCardReaderTest { unmockkStatic(PartAuthority::class) } - @Test - fun `no uris reads nothing`() { - assertThat(reader.read(emptyList())).isEmpty() - } - @Test fun `an empty vcard is skipped rather than throwing`() { givenVcard("") - assertThat(reader.read(listOf(VCARD_URI))).isEmpty() + assertThat(reader.readVCard(VCARD_URI)).isNull() } @Test fun `an unparseable vcard is skipped rather than throwing`() { givenVcard("this is not a vcard at all") - assertThat(reader.read(listOf(VCARD_URI))).isEmpty() + assertThat(reader.readVCard(VCARD_URI)).isNull() } @Test @@ -87,11 +82,11 @@ class ContactCardReaderTest { """.trimIndent() ) - val contacts = reader.read(listOf(VCARD_URI)) + val contact = reader.readVCard(VCARD_URI) - assertThat(contacts).hasSize(1) - assertThat(ContactUtil.getDisplayName(contacts.first())).isEqualTo("Paige Hall") - assertThat(contacts.first().phoneNumbers.first().number).isEqualTo("+15105550101") + assertThat(contact).isNotNull() + assertThat(ContactUtil.getDisplayName(contact!!)).isEqualTo("Paige Hall") + assertThat(contact.phoneNumbers.first().number).isEqualTo("+15105550101") } private fun givenVcard(body: String) { diff --git a/app/src/test/java/org/thoughtcrime/securesms/contactshare/ContactSerializationTest.kt b/app/src/test/java/org/thoughtcrime/securesms/contactshare/ContactSerializationTest.kt new file mode 100644 index 0000000000..838da0fbf2 --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/contactshare/ContactSerializationTest.kt @@ -0,0 +1,140 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.contactshare + +import android.app.Application +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isNull +import assertk.assertions.isTrue +import org.json.JSONObject +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * The serialized form is what sits in the shared_contacts column, so it has to stay readable across + * versions in both directions. + */ +@RunWith(RobolectricTestRunner::class) +@Config(application = Application::class) +class ContactSerializationTest { + + @Test + fun `round trips every field`() { + val contact = Contact( + Contact.Name("Paige", "Hall", "Dr.", "Jr.", "Quinn", "Pea"), + "Pacific Plumbing", + listOf(Contact.Phone("+15105550101", Contact.Phone.Type.MOBILE, null)), + listOf(Contact.Email("paige@example.com", Contact.Email.Type.WORK, null)), + listOf(Contact.PostalAddress(Contact.PostalAddress.Type.HOME, null, "1 Main St", null, null, "Oakland", "CA", "94612", "US")), + null, + ACI_STRING, + Contact.SignalNickname("Pea", "H"), + "Met at the conference" + ) + + val restored = Contact.deserialize(contact.serialize()) + + assertThat(restored.name.givenName).isEqualTo("Paige") + assertThat(restored.organization).isEqualTo("Pacific Plumbing") + assertThat(restored.phoneNumbers.single().number).isEqualTo("+15105550101") + assertThat(restored.emails.single().email).isEqualTo("paige@example.com") + assertThat(restored.postalAddresses.single().city).isEqualTo("Oakland") + assertThat(restored.aci).isEqualTo(ACI_STRING) + assertThat(restored.nickname?.given).isEqualTo("Pea") + assertThat(restored.nickname?.family).isEqualTo("H") + assertThat(restored.note).isEqualTo("Met at the conference") + } + + @Test + fun `uses the stored property names`() { + val json = JSONObject( + Contact( + Contact.Name("Paige", null, null, null, null, null), + "Pacific Plumbing", + listOf(Contact.Phone("+15105550101", Contact.Phone.Type.MOBILE, "cell")), + emptyList(), + emptyList(), + null, + ACI_STRING, + Contact.SignalNickname("Pea", null), + "a note" + ).serialize() + ) + + assertThat(json.getJSONObject("name").getString("givenName")).isEqualTo("Paige") + assertThat(json.getString("organization")).isEqualTo("Pacific Plumbing") + assertThat(json.getJSONArray("phoneNumbers").getJSONObject(0).getString("number")).isEqualTo("+15105550101") + assertThat(json.has("emails")).isTrue() + assertThat(json.has("postalAddresses")).isTrue() + assertThat(json.getString("aci")).isEqualTo(ACI_STRING) + assertThat(json.getJSONObject("nickname").getString("given")).isEqualTo("Pea") + assertThat(json.getString("note")).isEqualTo("a note") + } + + /** The selection flag is transient, so it must never reach the column. */ + @Test + fun `does not serialize selection state`() { + val json = JSONObject( + Contact( + Contact.Name("Paige", null, null, null, null, null), + null, + listOf(Contact.Phone("+15105550101", Contact.Phone.Type.MOBILE, null)), + emptyList(), + emptyList(), + null + ).serialize() + ) + + assertThat(json.getJSONArray("phoneNumbers").getJSONObject(0).has("selected")).isEqualTo(false) + } + + /** A row written before the ACI work has none of the new keys and must still read back. */ + @Test + fun `reads a card written before the new fields existed`() { + val legacy = """ + { + "name": {"givenName":"Paige","familyName":"Hall","prefix":null,"suffix":null,"middleName":null,"nickname":null,"empty":false}, + "organization": "Pacific Plumbing", + "phoneNumbers": [{"number":"+15105550101","type":"MOBILE","label":null}], + "emails": [], + "postalAddresses": [], + "avatar": null + } + """.trimIndent() + + val contact = Contact.deserialize(legacy) + + assertThat(contact.name.givenName).isEqualTo("Paige") + assertThat(contact.phoneNumbers.single().type).isEqualTo(Contact.Phone.Type.MOBILE) + assertThat(contact.aci).isNull() + assertThat(contact.nickname).isNull() + assertThat(contact.note).isNull() + } + + /** A null name has always been allowed for a card whose only name is its company. */ + @Test + fun `reads a card with a null name`() { + val legacy = """ + { + "name": null, + "organization": "Pacific Plumbing", + "phoneNumbers": [], + "emails": [], + "postalAddresses": [], + "avatar": null + } + """.trimIndent() + + assertThat(Contact.deserialize(legacy).name.isEmpty).isTrue() + } + + companion object { + private const val ACI_STRING = "3b2c8e2a-1f4d-4a5e-9c7b-0d1e2f3a4b5c" + } +} diff --git a/app/src/test/java/org/thoughtcrime/securesms/contactshare/ContactUtilTest_getDisplayName.java b/app/src/test/java/org/thoughtcrime/securesms/contactshare/ContactUtilTest_getDisplayName.java index 4f6939ec18..d8bae12bdf 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/contactshare/ContactUtilTest_getDisplayName.java +++ b/app/src/test/java/org/thoughtcrime/securesms/contactshare/ContactUtilTest_getDisplayName.java @@ -71,6 +71,9 @@ public class ContactUtilTest_getDisplayName { Collections.emptyList(), Collections.emptyList(), Collections.emptyList(), + null, + null, + null, null); assertEquals("Paigey", ContactUtil.getDisplayName(contact)); @@ -87,6 +90,9 @@ public class ContactUtilTest_getDisplayName { Collections.emptyList(), Collections.emptyList(), Collections.emptyList(), + null, + null, + null, null); } @@ -96,6 +102,9 @@ public class ContactUtilTest_getDisplayName { Collections.emptyList(), Collections.emptyList(), Collections.emptyList(), + null, + null, + null, null); } } diff --git a/app/src/test/java/org/thoughtcrime/securesms/contactshare/ShareContactPiiLoggingTest.kt b/app/src/test/java/org/thoughtcrime/securesms/contactshare/ShareContactPiiLoggingTest.kt index af5dbb28d4..7cea0c7755 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/contactshare/ShareContactPiiLoggingTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/contactshare/ShareContactPiiLoggingTest.kt @@ -5,12 +5,21 @@ package org.thoughtcrime.securesms.contactshare +import android.app.Application +import android.net.Uri import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.thoughtcrime.securesms.contacts.index.ContactIndexRecord +import org.thoughtcrime.securesms.contacts.index.ContactIndexType import org.thoughtcrime.securesms.contactshare.screens.details.SharedContactDetailsAction import org.thoughtcrime.securesms.contactshare.screens.details.SharedContactDetailsEvent import org.thoughtcrime.securesms.contactshare.screens.editname.ContactNameParts import org.thoughtcrime.securesms.contactshare.screens.editname.EditContactNameEvent import org.thoughtcrime.securesms.contactshare.screens.editname.EditContactNameResult +import org.thoughtcrime.securesms.contactshare.screens.selectcontact.SelectContactAction +import org.thoughtcrime.securesms.contactshare.screens.selectcontact.SelectContactEvent import org.thoughtcrime.securesms.contactshare.screens.share.ShareContactAction import org.thoughtcrime.securesms.contactshare.screens.share.ShareContactEvent import org.thoughtcrime.securesms.recipients.RecipientId @@ -20,7 +29,11 @@ import kotlin.reflect.full.primaryConstructor /** * EventDrivenViewModel logs every event so try to enforce clean logs via reflection. + * + * Robolectric only so that a [Uri] can be built for the [SharedContactSource] sample. */ +@RunWith(RobolectricTestRunner::class) +@Config(application = Application::class) class ShareContactPiiLoggingTest { @Test @@ -80,7 +93,10 @@ class ShareContactPiiLoggingTest { classifier == String::class -> SECRET_TEXT classifier == ContactNameParts::class -> secretNameParts() classifier == Contact::class -> secretContact() + classifier == ContactIndexRecord::class -> secretContactRow() classifier == RecipientId::class -> RecipientId.from(1L) + classifier == SharedContactSource::class -> secretSource() + classifier == Uri::class -> secretContactUri() classifier?.java?.isEnum == true -> classifier.java.enumConstants.first() else -> throw AssertionError( "No sample value for ${classifier?.simpleName} on ${member.qualifiedName}. Add one to " + @@ -100,6 +116,30 @@ class ShareContactPiiLoggingTest { ) } + private fun secretContactRow(): ContactIndexRecord { + return ContactIndexRecord( + position = 1, + type = ContactIndexType.BOTH, + section = SECRET_TEXT, + displayName = SECRET_TEXT, + recipientId = RecipientId.from(1L), + lookupKey = SECRET_TEXT, + contactId = 1L, + hasPersonalName = true, + hasPhoto = false + ) + } + + /** A lookup key can carry the contact's own name, so the uri counts as private. */ + private fun secretSource(): SharedContactSource { + return SharedContactSource.AddressBook(secretContactUri()) + } + + /** A lookup key is built out of the contact's own details, so the uri names the person. */ + private fun secretContactUri(): Uri { + return Uri.parse("content://com.android.contacts/contacts/lookup/$SECRET_TEXT/1") + } + private fun secretContact(): Contact { return Contact( Contact.Name(SECRET_TEXT, SECRET_TEXT, SECRET_TEXT, SECRET_TEXT, SECRET_TEXT, SECRET_TEXT), @@ -107,7 +147,10 @@ class ShareContactPiiLoggingTest { listOf(Contact.Phone(SECRET_NUMBER, Contact.Phone.Type.MOBILE, SECRET_TEXT)), listOf(Contact.Email(SECRET_EMAIL, Contact.Email.Type.HOME, SECRET_TEXT)), listOf(Contact.PostalAddress(Contact.PostalAddress.Type.HOME, SECRET_TEXT, SECRET_TEXT, null, null, null, null, null, null)), - null + null, + null, + Contact.SignalNickname(SECRET_TEXT, SECRET_TEXT), + SECRET_TEXT ) } @@ -124,7 +167,9 @@ class ShareContactPiiLoggingTest { SharedContactDetailsEvent::class, SharedContactDetailsAction::class, EditContactNameEvent::class, - EditContactNameResult::class + EditContactNameResult::class, + SelectContactEvent::class, + SelectContactAction::class ) /** Payloads whose only string is an internal identifier, so logging it verbatim is useful and safe. */ diff --git a/app/src/test/java/org/thoughtcrime/securesms/contactshare/screens/details/SharedContactDetailsRepositoryTest.kt b/app/src/test/java/org/thoughtcrime/securesms/contactshare/screens/details/SharedContactDetailsRepositoryTest.kt index b5ec47340c..0087f89ff3 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/contactshare/screens/details/SharedContactDetailsRepositoryTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/contactshare/screens/details/SharedContactDetailsRepositoryTest.kt @@ -8,7 +8,9 @@ package org.thoughtcrime.securesms.contactshare.screens.details import android.app.Application import androidx.test.core.app.ApplicationProvider import assertk.assertThat +import assertk.assertions.contains import assertk.assertions.containsExactly +import assertk.assertions.doesNotContain import assertk.assertions.isEqualTo import assertk.assertions.isFalse import assertk.assertions.isNull @@ -17,6 +19,7 @@ import io.mockk.every import io.mockk.mockk import io.mockk.mockkObject import io.mockk.unmockkObject +import io.mockk.verify import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Before @@ -26,9 +29,13 @@ import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config import org.thoughtcrime.securesms.contactshare.Contact +import org.thoughtcrime.securesms.database.RecipientTable import org.thoughtcrime.securesms.database.SignalDatabase import org.thoughtcrime.securesms.keyvalue.AccountValues +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.testutil.MockSignalStoreRule +import org.thoughtcrime.securesms.util.RemoteConfig import java.util.Locale import java.util.Optional @@ -40,6 +47,10 @@ import java.util.Optional @Config(application = Application::class) class SharedContactDetailsRepositoryTest { + companion object { + private const val ACI_STRING = "3f0c1d5e-9b2a-4c7d-8e1f-6a5b4c3d2e1f" + } + @get:Rule val signalStore = MockSignalStoreRule(relaxed = setOf(AccountValues::class)) @@ -53,15 +64,24 @@ class SharedContactDetailsRepositoryTest { // A relaxed AccountValues hands back "" rather than null, which the number formatter cannot parse. every { signalStore.account.e164 } returns "+15105550000" + mockkObject(RemoteConfig) + every { RemoteConfig.contactSharingV2 } returns true + mockkObject(SignalDatabase) every { SignalDatabase.recipients } returns mockk { every { getByE164(any()) } returns Optional.empty() } + + // Resolution now checks registration, and a matched row is registered unless a test says otherwise. + mockkObject(Recipient.Companion) + every { Recipient.resolved(any()) } returns mockk { every { isRegistered } returns true } } @After fun tearDown() { + unmockkObject(RemoteConfig) unmockkObject(SignalDatabase) + unmockkObject(Recipient.Companion) } @Test @@ -120,6 +140,110 @@ class SharedContactDetailsRepositoryTest { assertThat(state.actions).containsExactly(SharedContactDetailsState.ContactAction.ADD_TO_PHONE_CONTACTS) } + @Test + fun `a card carrying only an ACI is on Signal and is not offered an invite`() = runTest { + stubRecipients(byAci = Optional.empty(), insertedFromAci = RecipientId.from(7)) + + val state = repository.loadState(contact(name = name("Paige", "Hall"), aci = ACI_STRING)) + + assertThat(state.actions).doesNotContain(SharedContactDetailsState.ContactAction.INVITE_TO_SIGNAL) + } + + /** Browsing a card must not write a row, so the recipient is only created once something is pressed. */ + @Test + fun `loading an ACI card does not create a recipient`() = runTest { + val recipients = stubRecipients(byAci = Optional.empty(), insertedFromAci = RecipientId.from(7)) + + val state = repository.loadState(contact(name = name("Paige", "Hall"), aci = ACI_STRING)) + + assertThat(state.signalRecipientId).isNull() + verify(exactly = 0) { recipients.getOrInsertFromServiceId(any()) } + } + + @Test + fun `resolving for an action creates the recipient from the ACI`() = runTest { + val seeded = RecipientId.from(7) + val recipients = stubRecipients(byAci = Optional.empty(), insertedFromAci = seeded) + + val resolved = repository.resolveOrCreateRecipient(contact(name = name("Paige", "Hall"), aci = ACI_STRING)) + + assertThat(resolved).isEqualTo(seeded) + verify(exactly = 1) { recipients.getOrInsertFromServiceId(any()) } + } + + @Test + fun `an ACI that already has a row is matched rather than inserted`() = runTest { + val existing = RecipientId.from(11) + val recipients = stubRecipients(byAci = Optional.of(existing), insertedFromAci = RecipientId.from(99)) + + val state = repository.loadState(contact(name = name("Paige", "Hall"), aci = ACI_STRING)) + + assertThat(state.signalRecipientId).isEqualTo(existing) + verify(exactly = 0) { recipients.getOrInsertFromServiceId(any()) } + assertThat(state.actions).doesNotContain(SharedContactDetailsState.ContactAction.INVITE_TO_SIGNAL) + } + + @Test + fun `a card with neither an ACI nor a known number is offered an invite`() = runTest { + val state = repository.loadState(contact(name = name("Paige", "Hall"), phones = listOf("+15105550101"))) + + assertThat(state.signalRecipientId).isNull() + assertThat(state.actions).contains(SharedContactDetailsState.ContactAction.INVITE_TO_SIGNAL) + } + + @Test + fun `a shared nickname and note are rendered as their own rows`() = runTest { + val state = repository.loadState( + contact( + name = name("Paige", "Hall"), + phones = listOf("+15105550101"), + nickname = Contact.SignalNickname("Paige", "H"), + note = "Met in 2017" + ) + ) + + assertThat(state.details.map { it.kind }).containsExactly( + SharedContactDetailsState.DetailKind.PHONE, + SharedContactDetailsState.DetailKind.NICKNAME, + SharedContactDetailsState.DetailKind.NOTE + ) + assertThat(state.details.first { it.kind == SharedContactDetailsState.DetailKind.NICKNAME }.lines).containsExactly("Paige H") + assertThat(state.details.first { it.kind == SharedContactDetailsState.DetailKind.NOTE }.lines).containsExactly("Met in 2017") + } + + @Test + fun `a card carrying neither renders no nickname or note row`() = runTest { + val state = repository.loadState(contact(name = name("Paige", "Hall"), phones = listOf("+15105550101"))) + + assertThat(state.details.map { it.kind }).containsExactly(SharedContactDetailsState.DetailKind.PHONE) + } + + @Test + fun `an empty nickname and a blank note render no rows`() = runTest { + val state = repository.loadState( + contact( + name = name("Paige", "Hall"), + nickname = Contact.SignalNickname(null, null), + note = " " + ) + ) + + assertThat(state.details).containsExactly() + } + + private fun stubRecipients(byAci: Optional, insertedFromAci: RecipientId): RecipientTable { + val recipients: RecipientTable = mockk { + every { getByE164(any()) } returns Optional.empty() + every { getByAci(any()) } returns byAci + every { getOrInsertFromServiceId(any()) } returns insertedFromAci + every { setSharedName(any(), any()) } returns Unit + } + + every { SignalDatabase.recipients } returns recipients + + return recipients + } + private fun name(given: String? = null, family: String? = null) = Contact.Name(given, family, null, null, null, null) private fun contact( @@ -127,7 +251,10 @@ class SharedContactDetailsRepositoryTest { organization: String? = null, phones: List = emptyList(), emails: List = emptyList(), - addresses: List = emptyList() + addresses: List = emptyList(), + aci: String? = null, + nickname: Contact.SignalNickname? = null, + note: String? = null ): Contact { return Contact( name, @@ -135,7 +262,10 @@ class SharedContactDetailsRepositoryTest { phones.map { Contact.Phone(it, Contact.Phone.Type.MOBILE, null) }, emails.map { Contact.Email(it, Contact.Email.Type.HOME, null) }, addresses.map { Contact.PostalAddress(Contact.PostalAddress.Type.HOME, null, it, null, null, null, null, null, null) }, - null + null, + aci, + nickname, + note ) } } diff --git a/app/src/test/java/org/thoughtcrime/securesms/contactshare/screens/details/SharedContactDetailsViewModelTest.kt b/app/src/test/java/org/thoughtcrime/securesms/contactshare/screens/details/SharedContactDetailsViewModelTest.kt index 0425d16aeb..a5e96efe3f 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/contactshare/screens/details/SharedContactDetailsViewModelTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/contactshare/screens/details/SharedContactDetailsViewModelTest.kt @@ -76,10 +76,17 @@ class SharedContactDetailsViewModelTest { } @Test - fun `on Signal with a number offers save, but no invite`() { + fun `on Signal with a number offers save and group, but no invite`() { val actions = SharedContactDetailsViewModel.contactActionsFor(isOnSignal = true, hasInviteTarget = true, hasAnythingToSave = true) - assertThat(actions).containsExactly(ContactAction.ADD_TO_PHONE_CONTACTS) + assertThat(actions).containsExactly(ContactAction.ADD_TO_PHONE_CONTACTS, ContactAction.ADD_TO_GROUP) + } + + @Test + fun `a card on Signal with nothing to save is still offered a group`() { + val actions = SharedContactDetailsViewModel.contactActionsFor(isOnSignal = true, hasInviteTarget = false, hasAnythingToSave = false) + + assertThat(actions).containsExactly(ContactAction.ADD_TO_GROUP) } @Test @@ -264,7 +271,7 @@ class SharedContactDetailsViewModelTest { assertThat(events).containsExactly( SharedContactDetailsAction.InviteBySms("+14045550185"), SharedContactDetailsAction.AddToPhoneContacts, - SharedContactDetailsAction.AddToGroup + SharedContactDetailsAction.AddToGroup(RECIPIENT_ID) ) } @@ -287,7 +294,7 @@ class SharedContactDetailsViewModelTest { photoUri = null, signalRecipientId = if (isOnSignal) RECIPIENT_ID else null, actions = if (isOnSignal) { - listOf(ContactAction.ADD_TO_PHONE_CONTACTS) + listOf(ContactAction.ADD_TO_PHONE_CONTACTS, ContactAction.ADD_TO_GROUP) } else { listOf(ContactAction.INVITE_TO_SIGNAL, ContactAction.ADD_TO_PHONE_CONTACTS) }, @@ -296,6 +303,7 @@ class SharedContactDetailsViewModelTest { val repository: SharedContactDetailsRepository = mockk() coEvery { repository.loadState(any()) } returns state + coEvery { repository.resolveOrCreateRecipient(any()) } returns if (isOnSignal) RECIPIENT_ID else null return SharedContactDetailsViewModel(contact = contact, repository = repository) } diff --git a/app/src/test/java/org/thoughtcrime/securesms/contactshare/screens/selectcontact/ContactIndexPagingSourceTest.kt b/app/src/test/java/org/thoughtcrime/securesms/contactshare/screens/selectcontact/ContactIndexPagingSourceTest.kt new file mode 100644 index 0000000000..d960e43037 --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/contactshare/screens/selectcontact/ContactIndexPagingSourceTest.kt @@ -0,0 +1,210 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.contactshare.screens.selectcontact + +import androidx.paging.PagingSource +import assertk.assertThat +import assertk.assertions.containsExactly +import assertk.assertions.hasSize +import assertk.assertions.isEqualTo +import assertk.assertions.isNull +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.thoughtcrime.securesms.contacts.index.ContactIndexRecord +import org.thoughtcrime.securesms.contacts.index.ContactIndexType + +@OptIn(ExperimentalCoroutinesApi::class) +class ContactIndexPagingSourceTest { + + @Test + fun `refresh starts at the first position`() = runTest { + val source = FakeSource(rows(1, 5)) + val pagingSource = ContactIndexPagingSource(source.mock, query = "") + + val page = pagingSource.load(refresh(loadSize = 3)) as PagingSource.LoadResult.Page + + assertThat(page.data.map { it.position }).containsExactly(1L, 2L, 3L) + assertThat(source.requestedStarts).containsExactly(ContactIndexPagingSource.FIRST_POSITION) + } + + @Test + fun `a full page points at the next position`() = runTest { + val pagingSource = ContactIndexPagingSource(FakeSource(rows(1, 10)).mock, query = "") + + val page = pagingSource.load(refresh(loadSize = 3)) as PagingSource.LoadResult.Page + + assertThat(page.nextKey).isEqualTo(4L) + } + + @Test + fun `a short page is the end of the list`() = runTest { + val pagingSource = ContactIndexPagingSource(FakeSource(rows(1, 2)).mock, query = "") + + val page = pagingSource.load(refresh(loadSize = 10)) as PagingSource.LoadResult.Page + + assertThat(page.nextKey).isNull() + } + + @Test + fun `the next key comes from the row, so a sparse filter still advances`() = runTest { + // A filtered query matches rows scattered through the index, so the next key cannot be derived + // from the start position plus the page size. + val scattered = listOf(rowAt(4), rowAt(9), rowAt(30)) + val pagingSource = ContactIndexPagingSource(FakeSource(scattered).mock, query = "anna") + + val page = pagingSource.load(refresh(loadSize = 3)) as PagingSource.LoadResult.Page + + assertThat(page.nextKey).isEqualTo(31L) + } + + @Test + fun `the first page cannot be paged backwards`() = runTest { + val pagingSource = ContactIndexPagingSource(FakeSource(rows(1, 10)).mock, query = "") + + val page = pagingSource.load(refresh(loadSize = 3)) as PagingSource.LoadResult.Page + + assertThat(page.prevKey).isNull() + } + + @Test + fun `a later page can be paged backwards, so dropped windows can be reloaded`() = runTest { + val pagingSource = ContactIndexPagingSource(FakeSource(rows(1, 400)).mock, query = "") + + val page = pagingSource.load( + PagingSource.LoadParams.Append(key = 201L, loadSize = 100, placeholdersEnabled = false) + ) as PagingSource.LoadResult.Page + + assertThat(page.prevKey).isEqualTo(101L) + } + + @Test + fun `paging backwards never runs off the front of the index`() = runTest { + val pagingSource = ContactIndexPagingSource(FakeSource(rows(1, 400)).mock, query = "") + + val page = pagingSource.load( + PagingSource.LoadParams.Append(key = 20L, loadSize = 100, placeholdersEnabled = false) + ) as PagingSource.LoadResult.Page + + assertThat(page.prevKey).isEqualTo(ContactIndexPagingSource.FIRST_POSITION) + } + + @Test + fun `an unfiltered page reports what surrounds it, so the list is its full length`() = runTest { + val pagingSource = ContactIndexPagingSource(FakeSource(rows(1, 500)).mock, query = "") + + val page = pagingSource.load( + PagingSource.LoadParams.Append(key = 201L, loadSize = 100, placeholdersEnabled = true) + ) as PagingSource.LoadResult.Page + + assertThat(page.itemsBefore).isEqualTo(200) + assertThat(page.itemsAfter).isEqualTo(200) + } + + /** Placeholders only line up when the three add back up to the whole index. */ + @Test + fun `the counts either side of the last page account for the whole index`() = runTest { + val pagingSource = ContactIndexPagingSource(FakeSource(rows(1, 250)).mock, query = "") + + val page = pagingSource.load( + PagingSource.LoadParams.Append(key = 201L, loadSize = 100, placeholdersEnabled = true) + ) as PagingSource.LoadResult.Page + + assertThat(page.itemsBefore).isEqualTo(200) + assertThat(page.data).hasSize(50) + assertThat(page.itemsAfter).isEqualTo(0) + } + + /** + * Matches are scattered through the index, so a filtered page cannot say how many rows surround it + * without scanning the rest. An undefined count is how it declines to offer placeholders. + */ + @Test + fun `a filtered page reports no counts`() = runTest { + val source = FakeSource(rows(1, 500)) + val pagingSource = ContactIndexPagingSource(source.mock, query = "anna") + + val page = pagingSource.load(refresh(loadSize = 3)) as PagingSource.LoadResult.Page + + assertThat(page.itemsBefore).isEqualTo(PagingSource.LoadResult.Page.COUNT_UNDEFINED) + assertThat(page.itemsAfter).isEqualTo(PagingSource.LoadResult.Page.COUNT_UNDEFINED) + assertThat(source.countCalls).isEqualTo(0) + } + + @Test + fun `the index is counted once however many pages are read`() = runTest { + val source = FakeSource(rows(1, 500)) + val pagingSource = ContactIndexPagingSource(source.mock, query = "") + + pagingSource.load(refresh(loadSize = 100)) + pagingSource.load(PagingSource.LoadParams.Append(key = 101L, loadSize = 100, placeholdersEnabled = true)) + pagingSource.load(PagingSource.LoadParams.Append(key = 201L, loadSize = 100, placeholdersEnabled = true)) + + assertThat(source.countCalls).isEqualTo(1) + } + + @Test + fun `a failure to read is surfaced rather than thrown`() = runTest { + val pagingSource = ContactIndexPagingSource(throwingSource(), query = "") + + val result = pagingSource.load(refresh(loadSize = 3)) + + assertThat(result is PagingSource.LoadResult.Error).isEqualTo(true) + } + + private fun refresh(loadSize: Int) = PagingSource.LoadParams.Refresh( + key = null, + loadSize = loadSize, + placeholdersEnabled = false + ) + + private fun rows(from: Long, to: Long): List = (from..to).map { rowAt(it) } + + private fun rowAt(position: Long): ContactIndexRecord { + return ContactIndexRecord( + position = position, + type = ContactIndexType.SYSTEM_ONLY, + section = "A", + displayName = "Contact $position", + recipientId = null, + lookupKey = "lookup-$position", + contactId = position, + hasPersonalName = true, + hasPhoto = false + ) + } + + /** Serves [rows] as a dense index, recording the start position of every page it is asked for. */ + private class FakeSource(private val rows: List) { + val requestedStarts = mutableListOf() + var countCalls = 0 + + val mock: ContactIndexSource = mockk(relaxUnitFun = true) + + init { + coEvery { mock.count() } answers { + countCalls++ + rows.size + } + coEvery { mock.page(any(), any(), any()) } answers { + val startPosition = secondArg() + val limit = thirdArg() + + requestedStarts += startPosition + rows.filter { it.position >= startPosition }.take(limit) + } + } + } + + private fun throwingSource(): ContactIndexSource { + return mockk(relaxUnitFun = true) { + coEvery { count() } returns 0 + coEvery { page(any(), any(), any()) } throws IllegalStateException("boom") + } + } +} diff --git a/app/src/test/java/org/thoughtcrime/securesms/contactshare/screens/selectcontact/ContactIndexSourceTest.kt b/app/src/test/java/org/thoughtcrime/securesms/contactshare/screens/selectcontact/ContactIndexSourceTest.kt new file mode 100644 index 0000000000..8a0532a247 --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/contactshare/screens/selectcontact/ContactIndexSourceTest.kt @@ -0,0 +1,97 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.contactshare.screens.selectcontact + +import android.app.Application +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isNull +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkStatic +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import org.signal.contacts.SystemContactsRepository +import org.thoughtcrime.securesms.contacts.index.ContactIndexRecord +import org.thoughtcrime.securesms.contacts.index.ContactIndexType +import org.thoughtcrime.securesms.contactshare.SharedContactSource +import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.testing.CoroutineDispatcherRule + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE, application = Application::class) +class ContactIndexSourceTest { + + companion object { + private const val LOOKUP_KEY = "lookup-1" + private const val CONTACT_ID = 1L + } + + private val testDispatcher = UnconfinedTestDispatcher() + + @get:Rule + val dispatcherRule = CoroutineDispatcherRule(testDispatcher) + + @After + fun tearDown() { + unmockkStatic(SystemContactsRepository::class) + } + + /** + * Contacts permission can go away after the index was built, which leaves rows whose address book half is + * no longer readable. Those still have a Signal profile to share, so they degrade rather than throw. + */ + @Test + fun `losing contacts permission degrades an address book row to its signal profile`() = runTest(testDispatcher) { + val resolved = sourceThatLostPermission().resolve(addressBookRecord(recipientId = RecipientId.from(7))) + + assertThat(resolved).isEqualTo(SharedContactSource.SignalContact(RecipientId.from(7))) + } + + @Test + fun `losing contacts permission on a row with no signal profile resolves to nothing`() = runTest(testDispatcher) { + val resolved = sourceThatLostPermission().resolve(addressBookRecord(recipientId = null)) + + assertThat(resolved).isNull() + } + + /** + * Stubbed with a concrete context rather than `any()`, because mockk runs the real call once while it + * records, and the dummy it fabricates for `any()` cannot answer `getContentResolver()`. + */ + private fun sourceThatLostPermission(): ContactIndexSource { + val context = RuntimeEnvironment.getApplication() + + mockkStatic(SystemContactsRepository::class) + every { SystemContactsRepository.currentContactUri(context, LOOKUP_KEY, CONTACT_ID) } throws SecurityException("denied") + + return ContactIndexSource(mockk(relaxed = true), context) + } + + private fun addressBookRecord(recipientId: RecipientId?): ContactIndexRecord { + return ContactIndexRecord( + position = 1, + type = if (recipientId != null) ContactIndexType.BOTH else ContactIndexType.SYSTEM_ONLY, + section = "A", + displayName = "Andrew Bell", + recipientId = recipientId, + lookupKey = LOOKUP_KEY, + contactId = CONTACT_ID, + hasPersonalName = true, + hasPhoto = false + ) + } +} diff --git a/app/src/test/java/org/thoughtcrime/securesms/contactshare/screens/selectcontact/SelectContactViewModelTest.kt b/app/src/test/java/org/thoughtcrime/securesms/contactshare/screens/selectcontact/SelectContactViewModelTest.kt new file mode 100644 index 0000000000..1ba8b13130 --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/contactshare/screens/selectcontact/SelectContactViewModelTest.kt @@ -0,0 +1,374 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.contactshare.screens.selectcontact + +import android.net.Uri +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.ViewModelStore +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import assertk.assertThat +import assertk.assertions.containsExactly +import assertk.assertions.isEmpty +import assertk.assertions.isEqualTo +import assertk.assertions.isFalse +import assertk.assertions.isTrue +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.thoughtcrime.securesms.contacts.index.ContactIndexBuildResult +import org.thoughtcrime.securesms.contacts.index.ContactIndexRecord +import org.thoughtcrime.securesms.contacts.index.ContactIndexType +import org.thoughtcrime.securesms.contactshare.SharedContactSource +import org.thoughtcrime.securesms.contactshare.screens.selectcontact.SelectContactState.ContactsPermissionState +import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.testing.CoroutineDispatcherRule + +@OptIn(ExperimentalCoroutinesApi::class) +class SelectContactViewModelTest { + + private val testDispatcher = UnconfinedTestDispatcher() + + @get:Rule + val dispatcherRule = CoroutineDispatcherRule(testDispatcher) + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `initialize publishes how many rows the index holds`() = runTest(testDispatcher) { + val viewModel = SelectContactViewModel(fakeSource(buildResult = ContactIndexBuildResult.Success(2))) + + assertThat(viewModel.state.value.isLoading).isFalse() + assertThat(viewModel.state.value.indexCount).isEqualTo(2) + } + + @Test + fun `changing the query is published so the pager can restart`() = runTest(testDispatcher) { + val viewModel = SelectContactViewModel(fakeSource(buildResult = ContactIndexBuildResult.Success(2))) + + viewModel.onEvent(SelectContactEvent.QueryChanged("anna")) + + assertThat(viewModel.state.value.query).isEqualTo("anna") + } + + @Test + fun `selecting a contact shows loading, since the handoff reads the provider`() = runTest(testDispatcher) { + val viewModel = SelectContactViewModel(fakeSource(buildResult = ContactIndexBuildResult.Success(1))) + + viewModel.onEvent(SelectContactEvent.ContactClicked(contactRow(1, "Andrew Bell"))) + + assertThat(viewModel.state.value.isLoading).isTrue() + } + + @Test + fun `building without contacts permission still shows signal contacts`() = runTest(testDispatcher) { + val viewModel = SelectContactViewModel( + fakeSource( + buildResult = ContactIndexBuildResult.SignalOnly(1), + contacts = contacts("Anna Morris") + ) + ) + + assertThat(viewModel.state.value.contactsPermission).isEqualTo(ContactsPermissionState.DENIED) + assertThat(viewModel.state.value.showPermissionCard).isTrue() + assertThat(viewModel.state.value.showFullScreenPermissionPrompt).isFalse() + } + + @Test + fun `no permission and nothing to show gets the full screen prompt`() = runTest(testDispatcher) { + val viewModel = SelectContactViewModel( + fakeSource(buildResult = ContactIndexBuildResult.SignalOnly(0), contacts = emptyList()) + ) + + assertThat(viewModel.state.value.showFullScreenPermissionPrompt).isTrue() + assertThat(viewModel.state.value.showPermissionCard).isFalse() + } + + @Test + fun `dismissing the prompt stops offering it`() = runTest(testDispatcher) { + val viewModel = SelectContactViewModel( + fakeSource(buildResult = ContactIndexBuildResult.SignalOnly(1), contacts = contacts("Anna Morris")) + ) + + viewModel.onEvent(SelectContactEvent.DismissContactsAccessClicked) + + assertThat(viewModel.state.value.contactsPermission).isEqualTo(ContactsPermissionState.DISMISSED) + assertThat(viewModel.state.value.showPermissionCard).isFalse() + } + + @Test + fun `dismissing the prompt offers the system picker and the settings note`() = runTest(testDispatcher) { + val viewModel = SelectContactViewModel( + fakeSource(buildResult = ContactIndexBuildResult.SignalOnly(1), contacts = contacts("Anna Morris")) + ) + + viewModel.onEvent(SelectContactEvent.DismissContactsAccessClicked) + + assertThat(viewModel.state.value.showSystemPickerButton).isTrue() + assertThat(viewModel.state.value.showPermissionFooter).isTrue() + } + + @Test + fun `holding the permission offers neither the system picker nor the settings note`() = runTest(testDispatcher) { + val viewModel = SelectContactViewModel(fakeSource(buildResult = ContactIndexBuildResult.Success(2))) + + assertThat(viewModel.state.value.showSystemPickerButton).isFalse() + assertThat(viewModel.state.value.showPermissionFooter).isFalse() + } + + @Test + fun `a dismissed prompt survives the view model being rebuilt`() = runTest(testDispatcher) { + val savedState = SavedStateHandle() + val buildResult = ContactIndexBuildResult.SignalOnly(1) + + SelectContactViewModel(fakeSource(buildResult = buildResult), savedState) + .onEvent(SelectContactEvent.DismissContactsAccessClicked) + + val rebuilt = SelectContactViewModel(fakeSource(buildResult = buildResult), savedState) + + assertThat(rebuilt.state.value.contactsPermission).isEqualTo(ContactsPermissionState.DISMISSED) + assertThat(rebuilt.state.value.showPermissionCard).isFalse() + assertThat(rebuilt.state.value.showSystemPickerButton).isTrue() + } + + @Test + fun `granting access while away clears a dismissed prompt`() = runTest(testDispatcher) { + val savedState = SavedStateHandle() + + SelectContactViewModel(fakeSource(buildResult = ContactIndexBuildResult.SignalOnly(1)), savedState) + .onEvent(SelectContactEvent.DismissContactsAccessClicked) + + val rebuilt = SelectContactViewModel(fakeSource(buildResult = ContactIndexBuildResult.Success(4)), savedState) + + assertThat(rebuilt.state.value.contactsPermission).isEqualTo(ContactsPermissionState.GRANTED) + assertThat(rebuilt.state.value.showSystemPickerButton).isFalse() + } + + @Test + fun `a device level denial offers both the system picker and the settings note`() = runTest(testDispatcher) { + val state = SelectContactState( + isLoading = false, + indexCount = 1, + contactsPermission = ContactsPermissionState.PERMANENTLY_DENIED + ) + + assertThat(state.showSystemPickerButton).isTrue() + assertThat(state.showPermissionFooter).isTrue() + assertThat(state.showPermissionCard).isFalse() + assertThat(state.showFullScreenPermissionPrompt).isFalse() + } + + @Test + fun `searching hides the system picker and the settings note`() = runTest(testDispatcher) { + val state = SelectContactState( + query = "anna", + isLoading = false, + indexCount = 1, + contactsPermission = ContactsPermissionState.PERMANENTLY_DENIED + ) + + assertThat(state.showSystemPickerButton).isFalse() + assertThat(state.showPermissionFooter).isFalse() + } + + @Test + fun `asking for the system picker hands off to the host`() = runTest(testDispatcher) { + val viewModel = SelectContactViewModel(fakeSource(buildResult = ContactIndexBuildResult.SignalOnly(1))) + val actions = viewModel.collectActions(this) + + viewModel.onEvent(SelectContactEvent.OpenSystemContactPickerClicked) + + assertThat(actions).containsExactly(SelectContactAction.LaunchSystemContactPicker) + } + + @Test + fun `a number picked from the system picker is handed on as a system phone source`() = runTest(testDispatcher) { + val uri = mockk() + val viewModel = SelectContactViewModel(fakeSource(buildResult = ContactIndexBuildResult.SignalOnly(1))) + val actions = viewModel.collectActions(this) + + viewModel.onEvent(SelectContactEvent.SystemContactPicked(uri)) + + assertThat(actions).containsExactly(SelectContactAction.ContactResolved(SharedContactSource.SystemPhone(uri))) + } + + @Test + fun `backing out of the system picker does nothing`() = runTest(testDispatcher) { + val viewModel = SelectContactViewModel(fakeSource(buildResult = ContactIndexBuildResult.SignalOnly(1))) + val actions = viewModel.collectActions(this) + + viewModel.onEvent(SelectContactEvent.SystemContactPicked(null)) + + assertThat(actions).isEmpty() + } + + @Test + fun `learn more opens the settings sheet and dismissing closes it`() = runTest(testDispatcher) { + val viewModel = SelectContactViewModel(fakeSource(buildResult = ContactIndexBuildResult.SignalOnly(1))) + + viewModel.onEvent(SelectContactEvent.LearnMoreClicked) + assertThat(viewModel.state.value.showPermissionDeniedSheet).isTrue() + + viewModel.onEvent(SelectContactEvent.PermissionDeniedSheetDismissed) + assertThat(viewModel.state.value.showPermissionDeniedSheet).isFalse() + } + + @Test + fun `a search does not turn the permission prompt into a full screen one`() = runTest(testDispatcher) { + val viewModel = SelectContactViewModel(fakeSource(buildResult = ContactIndexBuildResult.SignalOnly(1))) + + viewModel.onEvent(SelectContactEvent.QueryChanged("zzz")) + + assertThat(viewModel.state.value.showFullScreenPermissionPrompt).isFalse() + assertThat(viewModel.state.value.showPermissionCard).isFalse() + } + + @Test + fun `a failed build shows an empty list rather than a permission prompt`() = runTest(testDispatcher) { + val viewModel = SelectContactViewModel( + fakeSource(buildResult = ContactIndexBuildResult.Failure(IllegalStateException("boom")), contacts = emptyList()) + ) + + assertThat(viewModel.state.value.isEmpty).isTrue() + assertThat(viewModel.state.value.contactsPermission).isEqualTo(ContactsPermissionState.GRANTED) + assertThat(viewModel.state.value.showFullScreenPermissionPrompt).isFalse() + } + + @Test + fun `running out of space shows an empty list`() = runTest(testDispatcher) { + val viewModel = SelectContactViewModel( + fakeSource(buildResult = ContactIndexBuildResult.OutOfSpace, contacts = emptyList()) + ) + + assertThat(viewModel.state.value.isEmpty).isTrue() + assertThat(viewModel.state.value.contactsPermission).isEqualTo(ContactsPermissionState.GRANTED) + } + + @Test + fun `selecting a contact resolves it before handing it to the host`() = runTest(testDispatcher) { + val resolved = SharedContactSource.SignalContact(RecipientId.from(7)) + val source = fakeSource(contacts = contacts("Andrew Bell"), resolvesTo = resolved) + val viewModel = SelectContactViewModel(source) + val actions = viewModel.collectActions(this) + + viewModel.onEvent(SelectContactEvent.ContactClicked(contacts("Andrew Bell").first())) + + coVerify(exactly = 1) { source.resolve(match { it.displayName == "Andrew Bell" }) } + assertThat(actions).containsExactly(SelectContactAction.ContactResolved(resolved)) + } + + /** A row whose address book entry has gone since the index was built has nothing to hand on. */ + @Test + fun `a selection that cannot be resolved reports it and stops loading`() = runTest(testDispatcher) { + val viewModel = SelectContactViewModel(fakeSource(contacts = contacts("Andrew Bell"), resolvesTo = null)) + val actions = viewModel.collectActions(this) + + viewModel.onEvent(SelectContactEvent.ContactClicked(contacts("Andrew Bell").first())) + + assertThat(actions).containsExactly(SelectContactAction.CouldNotOpenContact) + assertThat(viewModel.state.value.isLoading).isFalse() + } + + @Test + fun `back exits`() = runTest(testDispatcher) { + val viewModel = SelectContactViewModel(fakeSource(contacts = contacts("Andrew Bell"))) + val actions = viewModel.collectActions(this) + + viewModel.onEvent(SelectContactEvent.BackClicked) + + assertThat(actions).containsExactly(SelectContactAction.Exit) + } + + private fun SelectContactViewModel.collectActions(scope: TestScope): List { + val collected = mutableListOf() + scope.backgroundScope.launch { actions.collect { collected += it } } + return collected + } + + /** + * The index outlives the activity being recreated, so it has to be released with the view model + * rather than by whatever is hosting it. + */ + @Test + fun `clearing the view model releases the index`() = runTest(testDispatcher) { + val source = fakeSource(buildResult = ContactIndexBuildResult.Success(2)) + val store = ViewModelStore() + val provider = ViewModelProvider( + store, + viewModelFactory { initializer { SelectContactViewModel(source) } } + ) + + provider[SelectContactViewModel::class.java] + verify(exactly = 0) { source.close() } + + store.clear() + verify(exactly = 1) { source.close() } + } + + private fun contacts(vararg names: String): List { + return names.mapIndexed { index, name -> contactRow(index + 1L, name) } + } + + private fun contacts(count: Int): List { + return (1..count).map { contactRow(it.toLong(), "Contact $it") } + } + + private fun contactRow(position: Long, name: String): ContactIndexRecord { + return ContactIndexRecord( + position = position, + type = ContactIndexType.SYSTEM_ONLY, + section = name.take(1), + displayName = name, + recipientId = null, + lookupKey = "lookup-$position", + contactId = position, + hasPersonalName = true, + hasPhoto = false + ) + } + + private fun fakeSource( + buildResult: ContactIndexBuildResult = ContactIndexBuildResult.Success(0), + contacts: List = emptyList(), + resolvesTo: SharedContactSource? = SharedContactSource.SignalContact(RecipientId.from(1)) + ): ContactIndexSource { + val source: ContactIndexSource = mockk(relaxUnitFun = true) + + coEvery { source.build() } returns buildResult + coEvery { source.count() } returns contacts.size + coEvery { source.resolve(any()) } returns resolvesTo + coEvery { source.page(any(), any(), any()) } answers { + val startPosition = secondArg() + val limit = thirdArg() + + contacts.filter { it.position >= startPosition }.take(limit) + } + + return source + } +} diff --git a/app/src/test/java/org/thoughtcrime/securesms/contactshare/screens/share/ShareContactRepositoryTest.kt b/app/src/test/java/org/thoughtcrime/securesms/contactshare/screens/share/ShareContactRepositoryTest.kt index 10755366a6..29653c8577 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/contactshare/screens/share/ShareContactRepositoryTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/contactshare/screens/share/ShareContactRepositoryTest.kt @@ -6,9 +6,11 @@ package org.thoughtcrime.securesms.contactshare.screens.share import android.app.Application +import android.net.Uri import androidx.test.core.app.ApplicationProvider import assertk.assertThat import assertk.assertions.containsExactly +import assertk.assertions.isEmpty import assertk.assertions.isEqualTo import assertk.assertions.isNull import io.mockk.every @@ -25,10 +27,12 @@ import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config import org.thoughtcrime.securesms.contactshare.Contact import org.thoughtcrime.securesms.contactshare.ContactCardReader +import org.thoughtcrime.securesms.contactshare.SharedContactSource import org.thoughtcrime.securesms.contactshare.screens.editname.ContactNameParts import org.thoughtcrime.securesms.database.SignalDatabase import org.thoughtcrime.securesms.keyvalue.AccountValues import org.thoughtcrime.securesms.testutil.MockSignalStoreRule +import org.thoughtcrime.securesms.util.RemoteConfig import java.util.Locale import java.util.Optional @@ -48,6 +52,9 @@ class ShareContactRepositoryTest { fun setUp() { every { signalStore.account.e164 } returns "+15105550000" + mockkObject(RemoteConfig) + every { RemoteConfig.contactSharingV2 } returns true + mockkObject(SignalDatabase) every { SignalDatabase.recipients } returns mockk { every { getByE164(any()) } returns Optional.empty() @@ -56,9 +63,34 @@ class ShareContactRepositoryTest { @After fun tearDown() { + unmockkObject(RemoteConfig) unmockkObject(SignalDatabase) } + @Test + fun `a card with a photo offers that photo and the choice to send none`() = runTest { + val loaded = load(contact(name = Contact.Name("Paige", "Hall", null, null, null, null), avatar = Contact.Avatar(Uri.parse("content://address-book/1"), false))) + + assertThat(loaded.photoOptions.map { it.id }).containsExactly("address-book", "none") + assertThat(loaded.photoOptions.last().photo).isNull() + } + + /** One photo is enough to make the row editable, since declining it is the second choice. */ + @Test + fun `a card with a single photo is still editable`() = runTest { + val loaded = load(contact(name = Contact.Name("Paige", "Hall", null, null, null, null), avatar = Contact.Avatar(Uri.parse("content://address-book/1"), false))) + + assertThat(loaded.state.avatar?.isEditable).isEqualTo(true) + } + + @Test + fun `a card with no photo offers nothing to choose`() = runTest { + val loaded = load(contact(name = Contact.Name("Paige", "Hall", null, null, null, null))) + + assertThat(loaded.photoOptions).isEmpty() + assertThat(loaded.state.avatar).isNull() + } + @Test fun `a company that is the only name is kept even though no row was selected`() { val card = repository.buildCard(contact(name = null, organization = "Pacific Plumbing"), selection()) @@ -156,16 +188,86 @@ class ShareContactRepositoryTest { assertThat(loaded.state.details.map { it.id }).containsExactly("phone:0") } + @Test + fun `the nickname and note are offered as rows, unselected`() = runTest { + val loaded = load( + contact( + name = Contact.Name("Paige", "Hall", null, null, null, null), + phones = listOf("+15105550101"), + nickname = Contact.SignalNickname("Paige", "H"), + note = "Met in 2017" + ) + ) + + val selectedById = loaded.state.details.associate { it.id to it.isSelected } + + assertThat(selectedById).isEqualTo( + mapOf( + "phone:0" to true, + "nickname" to false, + "note" to false + ) + ) + } + + @Test + fun `the nickname row shows both parts joined`() = runTest { + val loaded = load(contact(nickname = Contact.SignalNickname("Paige", "H"), phones = listOf("+15105550101"))) + + assertThat(loaded.state.details.first { it.id == "nickname" }.lines).containsExactly("Paige H") + } + + @Test + fun `an empty nickname and a blank note are not offered at all`() = runTest { + val loaded = load( + contact( + phones = listOf("+15105550101"), + nickname = Contact.SignalNickname(null, null), + note = " " + ) + ) + + assertThat(loaded.state.details.map { it.id }).containsExactly("phone:0") + } + + @Test + fun `the nickname and note are dropped from the card when their rows are deselected`() { + val contact = contact( + name = Contact.Name("Paige", "Hall", null, null, null, null), + nickname = Contact.SignalNickname("Paige", "H"), + note = "Met in 2017" + ) + + val card = repository.buildCard(contact, selection(detailIds = emptySet())) + + assertThat(card.nickname).isNull() + assertThat(card.note).isNull() + } + + @Test + fun `the nickname and note reach the card when their rows are selected`() { + val contact = contact( + name = Contact.Name("Paige", "Hall", null, null, null, null), + nickname = Contact.SignalNickname("Paige", "H"), + note = "Met in 2017" + ) + + val card = repository.buildCard(contact, selection(detailIds = setOf("nickname", "note"))) + + assertThat(card.nickname?.given).isEqualTo("Paige") + assertThat(card.note).isEqualTo("Met in 2017") + } + /** Drives the real load path with a stubbed reader, so the defaults come from buildDetails. */ private suspend fun load(contact: Contact): LoadedContact { - val reader: ContactCardReader = mockk { every { read(any()) } returns listOf(contact) } + val reader: ContactCardReader = mockk { every { readSystemContact(any()) } returns contact } val repository = ShareContactRepository( context = ApplicationProvider.getApplicationContext(), locale = Locale.US, reader = reader ) - return repository.load(uris = emptyList(), recipientId = null)!! + return repository.load(source = SharedContactSource.AddressBook(Uri.EMPTY), recipientId = null)!! } private fun selection( @@ -179,7 +281,10 @@ class ShareContactRepositoryTest { name: Contact.Name? = null, organization: String? = null, phones: List = emptyList(), - emails: List = emptyList() + emails: List = emptyList(), + nickname: Contact.SignalNickname? = null, + note: String? = null, + avatar: Contact.Avatar? = null ): Contact { return Contact( name ?: Contact.Name(null, null, null, null, null, null), @@ -187,7 +292,10 @@ class ShareContactRepositoryTest { phones.map { Contact.Phone(it, Contact.Phone.Type.MOBILE, null) }, emails.map { Contact.Email(it, Contact.Email.Type.HOME, null) }, emptyList(), - null + avatar, + null, + nickname, + note ) } } diff --git a/app/src/test/java/org/thoughtcrime/securesms/contactshare/screens/share/ShareContactViewModelTest.kt b/app/src/test/java/org/thoughtcrime/securesms/contactshare/screens/share/ShareContactViewModelTest.kt index 3dfab38292..c553e2d66d 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/contactshare/screens/share/ShareContactViewModelTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/contactshare/screens/share/ShareContactViewModelTest.kt @@ -32,6 +32,7 @@ import org.junit.Before import org.junit.Rule import org.junit.Test import org.thoughtcrime.securesms.contactshare.Contact +import org.thoughtcrime.securesms.contactshare.SharedContactSource import org.thoughtcrime.securesms.contactshare.screens.editname.ContactNameParts import org.thoughtcrime.securesms.testing.CoroutineDispatcherRule @@ -58,7 +59,8 @@ class ShareContactViewModelTest { private val photoOptions = listOf( ShareContactState.PhotoOption("address-book", addressBookPhoto), - ShareContactState.PhotoOption("signal-profile", profilePhoto) + ShareContactState.PhotoOption("signal-profile", profilePhoto), + ShareContactState.PhotoOption("none", null) ) private val nameParts = ContactNameParts(givenName = "Paige", familyName = "Hall") @@ -179,6 +181,69 @@ class ShareContactViewModelTest { assertThat(viewModel.state.value.photoPicker).isNull() } + /** One photo plus the option to decline it is still a choice, which is what makes the badge appear. */ + @Test + fun `a single photo alongside the no photo option can still be edited`() = runTest(testDispatcher) { + val viewModel = createViewModel(options = listOf(photoOptions.first(), ShareContactState.PhotoOption("none", null))) + + viewModel.onEvent(ShareContactEvent.EditPhotoClicked) + + assertThat(viewModel.state.value.photoPicker?.selectedId).isEqualTo("address-book") + } + + @Test + fun `choosing no photo drops the avatar back to the fallback`() = runTest(testDispatcher) { + val viewModel = createViewModel() + + viewModel.onEvent(ShareContactEvent.EditPhotoClicked) + viewModel.onEvent(ShareContactEvent.PhotoSelected("none")) + viewModel.onEvent(ShareContactEvent.PhotoPickerConfirmed) + + assertThat(viewModel.state.value.avatar?.photo).isNull() + assertThat(viewModel.state.value.avatar?.isSelected).isEqualTo(true) + } + + @Test + fun `choosing no photo sends the card without one`() = runTest(testDispatcher) { + val viewModel = createViewModel() + val events = viewModel.collectActions(this) + + viewModel.onEvent(ShareContactEvent.EditPhotoClicked) + viewModel.onEvent(ShareContactEvent.PhotoSelected("none")) + viewModel.onEvent(ShareContactEvent.PhotoPickerConfirmed) + viewModel.onEvent(ShareContactEvent.SendClicked) + + assertThat(events.single()).isInstanceOf(ShareContactAction.Send::class) + assertThat(selectionSlot.captured.photo).isNull() + } + + /** Reopening must land on the no photo option rather than reverting to the first real photo. */ + @Test + fun `the picker reopens on the no photo option once it is chosen`() = runTest(testDispatcher) { + val viewModel = createViewModel() + + viewModel.onEvent(ShareContactEvent.EditPhotoClicked) + viewModel.onEvent(ShareContactEvent.PhotoSelected("none")) + viewModel.onEvent(ShareContactEvent.PhotoPickerConfirmed) + viewModel.onEvent(ShareContactEvent.EditPhotoClicked) + + assertThat(viewModel.state.value.photoPicker?.selectedId).isEqualTo("none") + } + + @Test + fun `a chosen no photo survives the view model being rebuilt after process death`() = runTest(testDispatcher) { + val savedState = SavedStateHandle() + + val before = createViewModel(savedState = savedState) + before.onEvent(ShareContactEvent.EditPhotoClicked) + before.onEvent(ShareContactEvent.PhotoSelected("none")) + before.onEvent(ShareContactEvent.PhotoPickerConfirmed) + + val after = createViewModel(savedState = savedState) + + assertThat(after.state.value.avatar?.photo).isNull() + } + @Test fun `confirming the photo picker commits the choice`() = runTest(testDispatcher) { val viewModel = createViewModel() @@ -271,7 +336,7 @@ class ShareContactViewModelTest { every { repository.buildCard(any(), capture(selectionSlot)) } returns mockk(relaxed = true) return ShareContactViewModel( - uris = emptyList(), + contactSource = mockk(), recipientId = null, repository = repository, savedState = savedState diff --git a/app/src/test/java/org/thoughtcrime/securesms/database/RecipientDatabaseTestUtils.kt b/app/src/test/java/org/thoughtcrime/securesms/database/RecipientDatabaseTestUtils.kt index 5d30d88eb2..95cdbe2205 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/database/RecipientDatabaseTestUtils.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/database/RecipientDatabaseTestUtils.kt @@ -147,7 +147,8 @@ object RecipientDatabaseTestUtils { callLinkRoomId = null, phoneNumberSharing = RecipientTable.PhoneNumberSharingState.UNKNOWN, nickname = ProfileName.EMPTY, - note = null + note = null, + sharedName = ProfileName.EMPTY ), participantIds = participants, isReleaseChannel = isReleaseChannel, diff --git a/app/src/test/java/org/thoughtcrime/securesms/database/RecipientTableTest.kt b/app/src/test/java/org/thoughtcrime/securesms/database/RecipientTableTest.kt index 643fd88c99..3db43cc488 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/database/RecipientTableTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/database/RecipientTableTest.kt @@ -6,6 +6,7 @@ package org.thoughtcrime.securesms.database import android.app.Application +import androidx.test.core.app.ApplicationProvider import assertk.assertThat import assertk.assertions.isEmpty import assertk.assertions.isNotEmpty @@ -25,7 +26,9 @@ import org.signal.core.models.ServiceId.ACI import org.signal.core.models.ServiceId.PNI import org.signal.core.util.CursorUtil import org.signal.core.util.SqlUtil +import org.signal.core.util.update import org.thoughtcrime.securesms.profiles.ProfileName +import org.thoughtcrime.securesms.recipients.RecipientCreator import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.testutil.RecipientTestRule import java.util.UUID @@ -297,6 +300,102 @@ class RecipientTableTest { ) } + @Test + fun givenAContactWithNoSharedName_whenAProfileUpdateOnlyChangesFieldsAbsentFromTheContactRecord_thenIExpectNoStorageIdRotation() { + SignalDatabase.recipients.setStorageIdIfNotSet(target) + val originalStorageId: ByteArray? = SignalDatabase.recipients.getRecord(target).storageId + assertNotNull("Precondition: contact should have a storage id", originalStorageId) + assertTrue("Precondition: contact should have no shared name", SignalDatabase.recipients.getRecord(target).sharedName.isEmpty) + + SignalDatabase.recipients.applyProfileUpdate( + target, + RecipientTable.ProfileUpdate( + sealedSenderAccessMode = RecipientTable.SealedSenderAccessMode.ENABLED, + clearSharedName = true + ) + ) + + assertTrue( + "Storage id must not rotate for a shared name that was never set, otherwise we republish identical content under a fresh id", + originalStorageId!!.contentEquals(SignalDatabase.recipients.getRecord(target).storageId) + ) + } + + @Test + fun givenAContactWithASharedName_whenAProfileUpdateClearsIt_thenIExpectAStorageIdRotation() { + val cardStarted = recipients.createRecipient(ProfileName.EMPTY) + SignalDatabase.recipients.setSharedName(cardStarted, ProfileName.fromParts("Shared", "Name")) + SignalDatabase.recipients.setStorageIdIfNotSet(cardStarted) + + val originalStorageId: ByteArray? = SignalDatabase.recipients.getRecord(cardStarted).storageId + assertNotNull("Precondition: contact should have a storage id", originalStorageId) + assertFalse("Precondition: contact should have a shared name", SignalDatabase.recipients.getRecord(cardStarted).sharedName.isEmpty) + + SignalDatabase.recipients.applyProfileUpdate( + cardStarted, + RecipientTable.ProfileUpdate( + sealedSenderAccessMode = RecipientTable.SealedSenderAccessMode.ENABLED, + clearSharedName = true + ) + ) + + assertTrue("Shared name should be cleared", SignalDatabase.recipients.getRecord(cardStarted).sharedName.isEmpty) + assertFalse( + "Storage id should rotate when the shared name is actually cleared", + originalStorageId!!.contentEquals(SignalDatabase.recipients.getRecord(cardStarted).storageId) + ) + } + + @Test + fun givenARecipientWithASharedNameAndAnE164_whenICheckForAnOutrankingName_thenIExpectFalse() { + val cardStarted = SignalDatabase.recipients.getOrInsertFromE164("+15551234567") + SignalDatabase.recipients.setSharedName(cardStarted, ProfileName.fromParts("Shared", "Name")) + assertFalse("Precondition: shared name should be set", recipientFor(cardStarted).sharedName.isEmpty) + + assertFalse( + "An e164 must not outrank a shared name, otherwise a card-started chat regresses to showing a phone number", + recipientFor(cardStarted).hasDisplayNameOutrankingSharedName() + ) + } + + @Test + fun givenARecipientWithASharedNameAndANickname_whenICheckForAnOutrankingName_thenIExpectTrue() { + val cardStarted = recipients.createRecipient(ProfileName.EMPTY) + SignalDatabase.recipients.setSharedName(cardStarted, ProfileName.fromParts("Shared", "Name")) + assertFalse("Precondition: shared name should be set", recipientFor(cardStarted).sharedName.isEmpty) + + SignalDatabase.recipients.setNicknameAndNote(cardStarted, ProfileName.fromParts("Nick", "Name"), "") + + assertTrue( + "A nickname outranks a shared name, so the shared name is dead weight and should be retired", + recipientFor(cardStarted).hasDisplayNameOutrankingSharedName() + ) + } + + @Test + fun givenARecipientWithASharedNameAndOnlyASystemGivenName_whenICheckForAnOutrankingName_thenIExpectTrue() { + val cardStarted = recipients.createRecipient(ProfileName.EMPTY) + SignalDatabase.recipients.setSharedName(cardStarted, ProfileName.fromParts("Shared", "Name")) + assertFalse("Precondition: shared name should be set", recipientFor(cardStarted).sharedName.isEmpty) + + // Written directly because ContactArchiveImporter populates the given name and leaves the joined name null. + SignalDatabase.recipients.writableDatabase + .update(RecipientTable.TABLE_NAME) + .values(RecipientTable.SYSTEM_GIVEN_NAME to "Sys") + .where("${RecipientTable.ID} = ?", cardStarted) + .run() + + assertTrue( + "A system given name outranks a shared name even with no joined name, matching what setSharedName refuses to write over", + recipientFor(cardStarted).hasDisplayNameOutrankingSharedName() + ) + } + + private fun recipientFor(id: RecipientId) = RecipientCreator.forRecord( + ApplicationProvider.getApplicationContext(), + SignalDatabase.recipients.getRecord(id) + ) + companion object { val ACI_A = ACI.from(UUID.fromString("aaaa0000-5a76-47fa-a98a-7e72c948a82e")) val PNI_A = PNI.from(UUID.fromString("aaaa1111-c960-4f6c-8385-671ad2ffb999")) diff --git a/core/ui/src/main/java/org/signal/core/ui/compose/PermissionController.kt b/core/ui/src/main/java/org/signal/core/ui/compose/PermissionController.kt index 68ea47b255..4ea08ffbf5 100644 --- a/core/ui/src/main/java/org/signal/core/ui/compose/PermissionController.kt +++ b/core/ui/src/main/java/org/signal/core/ui/compose/PermissionController.kt @@ -48,11 +48,23 @@ class PermissionController( private var requestId: Int by mutableIntStateOf(0) private var granted: Boolean? by mutableStateOf(null) + private var permanentlyDenied: Boolean by mutableStateOf(false) private var launchedRequestId: Int = 0 + private var couldShowRationaleBeforeRequest: Boolean = false + + /** Whether the last [request] was refused for good, leaving app settings as the only way back. */ + val isPermanentlyDenied: Boolean + get() = permanentlyDenied + + /** Whether the last [request] was refused without the system ever prompting. Narrower than [isPermanentlyDenied]. */ + val wasRefusedWithoutPrompting: Boolean + get() = permanentlyDenied && !couldShowRationaleBeforeRequest /** Prompts for the permission and suspends until the user resolves it. */ suspend fun request(): Boolean { granted = null + permanentlyDenied = false + couldShowRationaleBeforeRequest = false requestId++ return snapshotFlow { granted } .filterNotNull() @@ -69,10 +81,13 @@ class PermissionController( val permissionState = rememberPermissionState(permission = permission) { isGranted -> // The system stops prompting once a permission is permanently denied, so app settings is the only way back. - if (!isGranted && activity != null && !ActivityCompat.shouldShowRequestPermissionRationale(activity, permission)) { + permanentlyDenied = !isGranted && activity != null && !ActivityCompat.shouldShowRequestPermissionRationale(activity, permission) + + if (permanentlyDenied) { showPermanentDenialDialog = permanentDenialMessage != null } + // Set last, since this is what releases the suspended request. granted = isGranted } @@ -86,6 +101,7 @@ class PermissionController( if (permissionState.status.isGranted) { granted = true } else { + couldShowRationaleBeforeRequest = activity != null && ActivityCompat.shouldShowRequestPermissionRationale(activity, permission) permissionState.launchPermissionRequest() } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 57779b5024..1a8baa63a8 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -23,6 +23,7 @@ androidx-lifecycle = "2.10.0" androidx-lifecycle-navigation3 = "2.10.0" androidx-media3 = "1.9.1" androidx-navigation = "2.9.8" +androidx-paging = "3.4.0" androidx-navigation3-core = "1.1.4" androidx-core-telecom = "1.1.0-alpha06" androidx-window = "1.3.0" @@ -116,6 +117,8 @@ androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle- androidx-lifecycle-livedata-core = { module = "androidx.lifecycle:lifecycle-livedata", version.ref = "androidx-lifecycle" } androidx-lifecycle-livedata-ktx = { module = "androidx.lifecycle:lifecycle-livedata-ktx", version.ref = "androidx-lifecycle" } androidx-lifecycle-process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "androidx-lifecycle" } +androidx-paging-runtime = { module = "androidx.paging:paging-runtime", version.ref = "androidx-paging" } +androidx-paging-compose = { module = "androidx.paging:paging-compose", version.ref = "androidx-paging" } androidx-lifecycle-viewmodel-savedstate = { module = "androidx.lifecycle:lifecycle-viewmodel-savedstate", version.ref = "androidx-lifecycle" } androidx-lifecycle-common-java8 = { module = "androidx.lifecycle:lifecycle-common-java8", version.ref = "androidx-lifecycle" } androidx-lifecycle-reactivestreams-ktx = { module = "androidx.lifecycle:lifecycle-reactivestreams-ktx", version.ref = "androidx-lifecycle" } diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index 1b61bde7b1..6c01940ed3 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -867,6 +867,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + @@ -2367,6 +2372,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + @@ -3029,6 +3039,70 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/lib/archive/src/main/protowire/Backup.proto b/lib/archive/src/main/protowire/Backup.proto index 32b3ac3f80..96f3e15325 100644 --- a/lib/archive/src/main/protowire/Backup.proto +++ b/lib/archive/src/main/protowire/Backup.proto @@ -182,6 +182,10 @@ message AccountData { NavigationBarSize navigationBarSize = 3; // If unset, treat the same as "Unknown" case } + message IOSSpecificSettings { + bool isSystemCallLogEnabled = 1; + } + bytes profileKey = 1; optional string username = 2; UsernameLink usernameLink = 3; @@ -196,7 +200,8 @@ message AccountData { AndroidSpecificSettings androidSpecificSettings = 12; string bioText = 13; string bioEmoji = 14; - reserved /*keyTransparencyData*/ 15; // No longer want to persist self-KT data + reserved /*keyTransparencyData*/ 15; + IOSSpecificSettings iosSpecificSettings = 16; } message Recipient { @@ -285,8 +290,10 @@ message Contact { string systemFamilyName = 19; string systemNickname = 20; optional AvatarColor avatarColor = 21; + // Opaque blob containing key transparency data for the contact optional bytes keyTransparencyData = 22; uint64 blockedAtTimestamp = 23; // if `blocked` is true, 0 means unknown block time + Name sharedName = 24; // absent iff both `given` and `family` are empty, name provided by third party } message Group { @@ -322,8 +329,8 @@ message Group { repeated MemberPendingProfileKey membersPendingProfileKey = 8; repeated MemberPendingAdminApproval membersPendingAdminApproval = 9; bytes inviteLinkPassword = 10; - bool announcements_only = 12; - repeated MemberBanned members_banned = 13; + bool announcementsOnly = 12; + repeated MemberBanned membersBanned = 13; bool terminated = 14; } @@ -687,6 +694,12 @@ message ContactAttachment { string nickname = 6; } + // The sharer's Signal nickname for this contact + message SignalNickname { + string given = 1; + string family = 2; + } + message Phone { enum Type { UNKNOWN = 0; // Interpret as "Home" @@ -735,11 +748,15 @@ message ContactAttachment { } optional Name name = 1; + reserved 2; repeated Phone number = 3; repeated Email email = 4; repeated PostalAddress address = 5; optional FilePointer avatar = 6; string organization = 7; + bytes aci = 8; // should be 16 bytes + optional SignalNickname nickname = 9; // absent iff both `given` and `family` are empty + string note = 10; } message StickerMessage { @@ -1049,6 +1066,7 @@ message LearnedProfileChatUpdate { oneof previousName { uint64 e164 = 1; string username = 2; + string sharedName = 3; } } @@ -1146,18 +1164,14 @@ message GroupMembershipAccessLevelChangeUpdate { GroupV2AccessLevel accessLevel = 2; } -message GroupAttributesAccessLevelChangeUpdate { - optional bytes updaterAci = 1; - GroupV2AccessLevel accessLevel = 2; -} - message GroupMemberLabelAccessLevelChangeUpdate { optional bytes updaterAci = 1; GroupV2AccessLevel accessLevel = 2; } -message GroupTerminateChangeUpdate { +message GroupAttributesAccessLevelChangeUpdate { optional bytes updaterAci = 1; + GroupV2AccessLevel accessLevel = 2; } message GroupAnnouncementOnlyChangeUpdate { @@ -1318,6 +1332,10 @@ message GroupExpirationTimerUpdate { optional bytes updaterAci = 2; } +message GroupTerminateChangeUpdate { + optional bytes updaterAci = 1; +} + message PollTerminateUpdate { uint64 targetSentTimestamp = 1; string question = 2; // Between 1-100 characters @@ -1335,6 +1353,12 @@ message StickerPack { message ChatStyle { message Gradient { + // Color ordering: + // 0 degrees: bottom-to-top + // 90 degrees: left-to-right + // 180 degrees: top-to-bottom + // 270 degrees: right-to-left + uint32 angle = 1; // degrees repeated fixed32 colors = 2; // 0xAARRGGBB repeated float positions = 3; // percent from 0 to 1 diff --git a/lib/contacts/src/main/java/org/signal/contacts/SystemContactsRepository.kt b/lib/contacts/src/main/java/org/signal/contacts/SystemContactsRepository.kt index 31de0bfb38..6019e6365c 100644 --- a/lib/contacts/src/main/java/org/signal/contacts/SystemContactsRepository.kt +++ b/lib/contacts/src/main/java/org/signal/contacts/SystemContactsRepository.kt @@ -4,6 +4,7 @@ import android.accounts.Account import android.accounts.AccountManager import android.content.ContentProviderOperation import android.content.ContentResolver +import android.content.ContentUris import android.content.Context import android.content.OperationApplicationException import android.database.Cursor @@ -157,6 +158,99 @@ object SystemContactsRepository { return CursorContactIterator(cursor, e164Formatter) } + /** + * A cursor over one row per aggregated contact, carrying only the fields a contact list needs. + * + * This queries [ContactsContract.Contacts] rather than [ContactsContract.Data], which matters for + * three reasons: it is one row per contact rather than one per phone number, it includes contacts + * with no phone number at all, and [ContactsContract.Contacts.DISPLAY_NAME_PRIMARY] already falls + * back to the company name so business entries need no separate Organization query. + * + * Caller is expected to sort. + */ + @JvmStatic + fun getAllContactsForList(context: Context): Cursor? { + val projection = arrayOf( + ContactsContract.Contacts._ID, + ContactsContract.Contacts.LOOKUP_KEY, + ContactsContract.Contacts.DISPLAY_NAME_PRIMARY, + ContactsContract.Contacts.DISPLAY_NAME_SOURCE, + ContactsContract.Contacts.HAS_PHONE_NUMBER, + ContactsContract.Contacts.PHOTO_URI + ) + + return context.contentResolver.query(ContactsContract.Contacts.CONTENT_URI, projection, null, null, null) + } + + /** + * Resolves a lookup key to the contact URI the rest of the app expects, whose last path segment is + * a real contact id. + * + * Contact ids change when the provider re-aggregates, so a stored id can point at the wrong person + * or at nothing. The lookup key is stable, and [ContactsContract.Contacts.lookupContact] is what + * turns it back into a currently valid id. Returns null when the contact is gone. + */ + @JvmStatic + fun currentContactUri(context: Context, lookupKey: String, contactId: Long): Uri? { + val lookupUri = ContactsContract.Contacts.getLookupUri(contactId, lookupKey) + return ContactsContract.Contacts.lookupContact(context.contentResolver, lookupUri) + } + + /** + * The thumbnail URI for a contact, built from its id rather than stored. + * + * Callers should only use this when they know the contact has a photo, since the provider returns + * nothing for one that does not. + */ + @JvmStatic + fun photoUriForContact(contactId: Long): Uri { + return Uri.withAppendedPath( + ContentUris.withAppendedId(ContactsContract.Contacts.CONTENT_URI, contactId), + ContactsContract.Contacts.Photo.CONTENT_DIRECTORY + ) + } + + /** + * Whether a contact's display name is a personal name rather than a stand in for one. + * + * There is no "this is a company" flag in the contacts provider. Organization is a field you hang + * on a contact that already has a name, so a person who works somewhere is not a company. What can + * be told apart is where the provider had to *source* the display name from: a contact with no + * structured name falls back to its company, email, or phone number, and that fallback is what the + * A-Z list files under "#". + * + * Nicknames and phonetic names are still names, so they are personal names here even though they + * are not [ContactsContract.DisplayNameSources.STRUCTURED_NAME]. + */ + @JvmStatic + fun isPersonalDisplayName(displayNameSource: Int): Boolean { + return when (displayNameSource) { + ContactsContract.DisplayNameSources.STRUCTURED_NAME, + ContactsContract.DisplayNameSources.STRUCTURED_PHONETIC_NAME, + ContactsContract.DisplayNameSources.NICKNAME -> true + else -> false + } + } + + /** + * Pulls the lookup key out of a contact lookup URI. + * + * Note this reads the segment after "lookup" rather than the last segment. [readAllPhones] builds + * these URIs with a Data row id in the trailing position instead of a contact id, so the trailing + * segment cannot be trusted, while the lookup key is both correct and stable across contact edits. + */ + @JvmStatic + fun lookupKeyFromLookupUri(uri: String?): String? { + if (uri.isNullOrBlank()) { + return null + } + + val segments = Uri.parse(uri).pathSegments + val lookupIndex = segments.indexOf("lookup") + + return if (lookupIndex >= 0 && lookupIndex + 1 < segments.size) segments[lookupIndex + 1] else null + } + /** * Retrieves all unique display numbers in the system contacts. (By display, we mean not-E164-formatted) */ @@ -364,6 +458,36 @@ object SystemContactsRepository { return phoneDetails } + /** + * Reads the one data row a system phone number picker handed back. + * + * [dataUri] carries its own read grant, so this needs no `READ_CONTACTS`. The grant covers that row alone. + */ + @JvmStatic + fun getPickedPhone(context: Context, dataUri: Uri): PickedPhone? { + val projection = arrayOf( + ContactsContract.CommonDataKinds.Phone.NUMBER, + ContactsContract.CommonDataKinds.Phone.TYPE, + ContactsContract.CommonDataKinds.Phone.LABEL, + ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME_PRIMARY + ) + + context.contentResolver.query(dataUri, projection, null, null, null)?.use { cursor -> + if (cursor.moveToFirst()) { + val number = cursor.requireString(ContactsContract.CommonDataKinds.Phone.NUMBER) ?: return null + + return PickedPhone( + number = number, + type = cursor.requireInt(ContactsContract.CommonDataKinds.Phone.TYPE), + label = cursor.requireString(ContactsContract.CommonDataKinds.Phone.LABEL), + displayName = cursor.requireString(ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME_PRIMARY) + ) + } + } + + return null + } + @JvmStatic fun getEmailDetails(context: Context, contactId: Long): List { val projection = arrayOf( @@ -839,6 +963,14 @@ object SystemContactsRepository { val label: String? ) + /** The single row behind a uri handed back by the system phone number picker. */ + data class PickedPhone( + val number: String, + val type: Int, + val label: String?, + val displayName: String? + ) + data class EmailDetails( val address: String?, val type: Int, diff --git a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageSender.java b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageSender.java index ccb82a347a..6af1859105 100644 --- a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageSender.java +++ b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageSender.java @@ -1933,6 +1933,28 @@ public class SignalServiceMessageSender { contactBuilder.organization(contact.getOrganization().get()); } + if (contact.getAci().isPresent()) { + contactBuilder.aciBinary(contact.getAci().get().toByteString()); + } + + if (contact.getNickname().isPresent() && !contact.getNickname().get().isEmpty()) { + DataMessage.Contact.SignalNickname.Builder nicknameBuilder = new DataMessage.Contact.SignalNickname.Builder(); + + if (contact.getNickname().get().getGiven().isPresent()) { + nicknameBuilder.given(contact.getNickname().get().getGiven().get()); + } + + if (contact.getNickname().get().getFamily().isPresent()) { + nicknameBuilder.family(contact.getNickname().get().getFamily().get()); + } + + contactBuilder.nickname(nicknameBuilder.build()); + } + + if (contact.getNote().isPresent()) { + contactBuilder.note(contact.getNote().get()); + } + results.add(contactBuilder.build()); } diff --git a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/messages/shared/SharedContact.java b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/messages/shared/SharedContact.java index a7ad777348..7578cd60e5 100644 --- a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/messages/shared/SharedContact.java +++ b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/messages/shared/SharedContact.java @@ -2,6 +2,7 @@ package org.whispersystems.signalservice.api.messages.shared; +import org.signal.core.models.ServiceId.ACI; import org.whispersystems.signalservice.api.messages.SignalServiceAttachment; import java.util.LinkedList; @@ -16,13 +17,19 @@ public class SharedContact { private final Optional> email; private final Optional> address; private final Optional organization; + private final Optional aci; + private final Optional nickname; + private final Optional note; public SharedContact(Name name, Optional avatar, Optional> phone, Optional> email, Optional> address, - Optional organization) + Optional organization, + Optional aci, + Optional nickname, + Optional note) { this.name = name; this.avatar = avatar; @@ -30,6 +37,9 @@ public class SharedContact { this.email = email; this.address = address; this.organization = organization; + this.aci = aci; + this.nickname = nickname; + this.note = note; } public static Builder newBuilder() { @@ -60,6 +70,21 @@ public class SharedContact { return organization; } + /** The ACI of the person on the card, present when the sharer knew they were on Signal. */ + public Optional getAci() { + return aci; + } + + /** The sharer's own Signal nickname for the contact, not the vcard nickname on {@link Name}. */ + public Optional getNickname() { + return nickname; + } + + /** The sharer's own Signal note about the contact. */ + public Optional getNote() { + return note; + } + public static class Avatar { private final SignalServiceAttachment attachment; private final boolean isProfile; @@ -449,10 +474,59 @@ public class SharedContact { } } + public static class Nickname { + + private final Optional given; + private final Optional family; + + public Nickname(Optional given, Optional family) { + this.given = given; + this.family = family; + } + + public static Builder newBuilder() { + return new Builder(); + } + + public Optional getGiven() { + return given; + } + + public Optional getFamily() { + return family; + } + + public boolean isEmpty() { + return given.isEmpty() && family.isEmpty(); + } + + public static class Builder { + private String given; + private String family; + + public Builder setGiven(String given) { + this.given = given; + return this; + } + + public Builder setFamily(String family) { + this.family = family; + return this; + } + + public Nickname build() { + return new Nickname(Optional.ofNullable(given), Optional.ofNullable(family)); + } + } + } + public static class Builder { - private Name name; - private Avatar avatar; - private String organization; + private Name name; + private Avatar avatar; + private String organization; + private ACI aci; + private Nickname nickname; + private String note; private List phone = new LinkedList<>(); private List email = new LinkedList<>(); @@ -503,12 +577,30 @@ public class SharedContact { return this; } + public Builder withAci(ACI aci) { + this.aci = aci; + return this; + } + + public Builder withNickname(Nickname nickname) { + this.nickname = nickname; + return this; + } + + public Builder withNote(String note) { + this.note = note; + return this; + } + public SharedContact build() { return new SharedContact(name, Optional.ofNullable(avatar), phone.isEmpty() ? Optional.empty() : Optional.of(phone), email.isEmpty() ? Optional.empty() : Optional.of(email), address.isEmpty() ? Optional.empty() : Optional.of(address), - Optional.ofNullable(organization)); + Optional.ofNullable(organization), + Optional.ofNullable(aci), + Optional.ofNullable(nickname), + Optional.ofNullable(note)); } } } diff --git a/lib/libsignal-service/src/main/protowire/SignalService.proto b/lib/libsignal-service/src/main/protowire/SignalService.proto index 562f47f8b8..2db08f1d8d 100644 --- a/lib/libsignal-service/src/main/protowire/SignalService.proto +++ b/lib/libsignal-service/src/main/protowire/SignalService.proto @@ -319,12 +319,22 @@ message DataMessage { optional bool isProfile = 2; } + message SignalNickname { + optional string given = 1; + optional string family = 2; + } + optional Name name = 1; + reserved 2; repeated Phone number = 3; repeated Email email = 4; repeated PostalAddress address = 5; optional Avatar avatar = 6; optional string organization = 7; + optional bytes aciBinary = 8; // 16-byte UUID + optional SignalNickname nickname = 9; + optional string note = 10; + // Next ID: 11 } message Sticker { diff --git a/lib/libsignal-service/src/main/protowire/StorageService.proto b/lib/libsignal-service/src/main/protowire/StorageService.proto index 4109826f04..5d6b77fa5d 100644 --- a/lib/libsignal-service/src/main/protowire/StorageService.proto +++ b/lib/libsignal-service/src/main/protowire/StorageService.proto @@ -146,7 +146,8 @@ message ContactRecord { uint64 blockedAtTimestamp = 27; // 0 means the blocked time is unknown OptionalBool notifyForCallsIfMuted = 28; // If unset, use the default settings OptionalBool showUnreadReminders = 29; // If unset, use the default settings - // Next ID: 30 + Name sharedName = 30; // Name shared from a third party + // Next ID: 31 } message GroupV1Record {